Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,24 +22,25 @@ composer phpcs
composer fix
```

No phpunit.xml config exists yet — tests are fixture-based JSON files in `tests/fixtures/` representing API response shapes.
Tests use PHPUnit 9 with Brain Monkey for WordPress function mocking. Fixture-based JSON files in `tests/fixtures/` represent API response shapes.

## Architecture

**Entry point:** `Integration` is the main class — consumer plugins instantiate it with API URL, product file, product ID, and optional license/menu config. The constructor wires up the three subsystems:
**Entry point:** `Integration` is the main class — consumer plugins instantiate it with API URL, product file, product ID, and optional license/menu config. The constructor creates and stores all subsystem instances as public properties (`$client`, `$updater`, `$tracker`, `$admin`):

- **`Integration`** (`src/Integration.php`) — Holds all shared state (API URL, product info, license config). Provides `api_request()` for all remote API calls and license/transient management helpers.
- **`Integration`** (`src/Integration.php`) — Holds all shared state (API URL, product info, license config) and license/transient management helpers. Stores subsystem instances so classes can reach each other.
- **`Client`** (`src/Client.php`) — Handles all HTTP communication with the remote API. Public methods: `ping()`, `check_license($license)`, `updates()`, `details()`. Private `request()` method contains shared HTTP/response logic.
- **`Updater`** (`src/Updater.php`) — Hooks into WordPress update system (`pre_set_site_transient_update_plugins`, `plugins_api`, `upgrader_package_options`, `upgrader_process_complete`) to inject update data from the remote API.
- **`Admin`** (`src/Admin.php`) — Renders the license management admin page. Only instantiated when `license_enabled` and `display_menu` are both true. Handles license save/verify via POST with nonce verification.
- **`Tracker`** (`src/Tracker.php`) — Handles plugin activation/deactivation hooks and hourly cron license sync via `sync_license_data()`.

All classes receive the `Integration` instance and use its public properties directly (no getters/setters pattern).
All classes receive the `Integration` instance and use its public properties directly (no getters/setters pattern). API calls go through `$integration->client->method()`.

## Code Conventions

- **WordPress coding standards** enforced via PHPCS (`WordPress` standard)
- PHP 7.4+ with WordPress `ABSPATH` guard and `class_exists()` guard wrapping each class
- Namespace: `Shazzad\PluginUpdater` with PSR-4 autoloading from `src/`
- Uses tabs for indentation (WordPress standard)
- All API calls go through `Integration::api_request()` — returns associative array on success or `WP_Error` on failure
- All API calls go through `Client` methods (`ping()`, `check_license()`, `updates()`, `details()`) — each returns associative array on success or `WP_Error` on failure
- WordPress capability required for admin: `delete_users`
14 changes: 3 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,9 @@ new \Shazzad\PluginUpdater\Integration(
## File Structure

```
/updater/
├── Integration.php # Core functionality and API handling
/src/
├── Integration.php # Core state, license helpers, and subsystem wiring
├── Client.php # API client with typed methods (ping, check_license, updates, details)
├── Updater.php # Update checks and WordPress integration
├── Admin.php # WordPress admin interface
└── Tracker.php # Plugin tracking and license sync
Expand Down Expand Up @@ -248,15 +249,6 @@ The updater includes comprehensive error handling:

Errors are logged and displayed appropriately in the WordPress admin.

## Debugging

Enable debugging with the included helper method:

```php
$integration = new \Shazzad\PluginUpdater\Integration(/* ... */);
$integration->p($some_data); // Pretty print data
```

## Changelog

### Version 1.0
Expand Down
89 changes: 87 additions & 2 deletions src/Admin.php
Original file line number Diff line number Diff line change
Expand Up @@ -78,10 +78,87 @@ public function admin_menu() {
* @return void
*/
public function load_page() {
if ( isset( $_POST['wprepo_sync'] ) ) {
$this->handle_sync();
return;
}

if ( ! isset( $_POST['wprepo_update'] ) ) {
return;
}

$this->handle_save();
}

/**
* Handle syncing/refreshing the existing license data.
*
* @since 1.1
* @return void
*/
private function handle_sync() {
check_admin_referer( 'wprepo_license_update' );

$base_url = remove_query_arg( [ 'm' ] );
$key = $this->integration->get_license_code();

if ( empty( $key ) ) {
wp_redirect(
add_query_arg(
'error',
urlencode( 'No license key to sync' ),
$base_url
)
);
exit;
}

$response = $this->integration->client->check_license( $key );

if ( is_wp_error( $response ) ) {
wp_redirect(
add_query_arg(
'error',
urlencode( $response->get_error_message() ),
$base_url
)
);
exit;
}

if ( ! empty( $response['license'] ) ) {
$this->integration->update_license_data( $response['license'] );
$this->integration->refresh_updates_transient();
wp_redirect(
add_query_arg(
'message',
urlencode( 'License data synced' ),
$base_url
)
);
exit;
}

$message = ! empty( $response['message'] )
? $response['message']
: 'Unable to sync license data';
wp_redirect(
add_query_arg(
'error',
urlencode( $message ),
$base_url
)
);
exit;
}

/**
* Handle saving/updating the license key.
*
* @since 1.1
* @return void
*/
private function handle_save() {
check_admin_referer( 'wprepo_license_update' );

$base_url = remove_query_arg( [ 'm' ] );
Expand All @@ -100,7 +177,7 @@ public function load_page() {
}

$key = sanitize_text_field( $_POST['wprepo_license'] );
$response = $this->integration->api_request( 'check_license', $key );
$response = $this->integration->client->check_license( $key );

if ( is_wp_error( $response ) ) {
wp_redirect(
Expand Down Expand Up @@ -179,6 +256,14 @@ public function admin_page() {
aria-describedby="wprepo_license-description"
value="<?php echo esc_attr( $this->integration->get_license_code() ); ?>"
class="regular-text" />
<?php if ( $this->integration->has_license_code() ) : ?>
<button type="submit" name="wprepo_sync" value="1"
class="button button-secondary" title="<?php esc_attr_e( 'Sync license data' ); ?>"
style="vertical-align: baseline;">
<span class="dashicons dashicons-update" style="vertical-align: text-bottom;"></span>
<?php _e( 'Sync' ); ?>
</button>
<?php endif; ?>
<p class="description" id="wprepo_license-description">
Enter your License Key to receive automatic Updates
</p>
Expand All @@ -193,7 +278,7 @@ class="regular-text" />
</form>

<?php
$response = $this->integration->api_request( 'details' );
$response = $this->integration->client->details();

if ( is_wp_error( $response ) ) {
\printf(
Expand Down
174 changes: 174 additions & 0 deletions src/Client.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
<?php
/**
* WordPress Plugin Updater API Client.
*
* @package Shazzad\PluginUpdater
* @version 1.0
*/
namespace Shazzad\PluginUpdater;

use WP_Error;

if ( ! \defined( 'ABSPATH' ) ) {
exit;
}

if ( ! class_exists( __NAMESPACE__ . '\\Client' ) ) :

/**
* Class Client
*
* Handles all HTTP communication with the remote API server.
*
* @since 1.1
*/
class Client {

/**
* Integration instance holding shared state.
*
* @since 1.1
*
* @var Integration
*/
public Integration $integration;

/**
* Constructor.
*
* @since 1.1
*
* @param Integration $integration Integration instance.
*/
public function __construct( Integration $integration ) {
$this->integration = $integration;
}

/**
* Ping the remote API server.
*
* @since 1.1
*
* @return array|WP_Error Response data or WP_Error on failure.
*/
public function ping() {
return $this->request( 'ping', [], 2 );
}

/**
* Check a license against the remote API.
*
* @since 1.1
*
* @param string $license License key. Uses stored license if empty.
* @return array|WP_Error Response data or WP_Error on failure.
*/
public function check_license( $license = '' ) {
if ( empty( $license ) ) {
$license = $this->integration->get_license_code();
}

$args = [];
if ( $license ) {
$args['license'] = $license;
}

return $this->request( 'check_license', $args );
}

/**
* Fetch available updates from the remote API.
*
* @since 1.1
*
* @return array|WP_Error Response data or WP_Error on failure.
*/
public function updates() {
$args = [];
if ( $this->integration->license_enabled ) {
$license = $this->integration->get_license_code();
if ( $license ) {
$args['license'] = $license;
}
}

return $this->request( 'updates', $args );
}

/**
* Fetch plugin details from the remote API.
*
* @since 1.1
*
* @return array|WP_Error Response data or WP_Error on failure.
*/
public function details() {
$args = [];
if ( $this->integration->license_enabled ) {
$license = $this->integration->get_license_code();
if ( $license ) {
$args['license'] = $license;
}
}

return $this->request( 'details', $args );
}

/**
* Sends an API request to the remote server.
*
* @since 1.1
*
* @param string $method The API endpoint method.
* @param array $args Additional query arguments.
* @param int $timeout Request timeout in seconds.
* @return array|WP_Error Response data or WP_Error on failure.
*/
private function request( $method, $args = [], $timeout = 5 ) {
$request_url = "{$this->integration->api_url}/products/{$this->integration->product_id}/$method";

$args = array_merge(
[
'product_version' => $this->integration->product_version,
'product_status' => $this->integration->product_status,
'wp_url' => esc_url( site_url( '', 'https' ) ),
'wp_locale' => get_locale(),
'wp_version' => get_bloginfo( 'version', 'display' ),
],
$args
);

$request_url = add_query_arg( $args, $request_url );

$request = wp_remote_request(
$request_url,
[ 'timeout' => $timeout ]
);

if ( is_wp_error( $request ) ) {
return $request;
}

$status_code = wp_remote_retrieve_response_code( $request );
$body = wp_remote_retrieve_body( $request );
$body = json_decode( $body, true );

if ( empty( $body ) ) {
return new WP_Error(
'wprepo_api_fail',
'No response from update server'
);
}

if ( $status_code >= 400 ) {
return new WP_Error(
! empty( $body['code'] ) ? $body['code'] : 'wprepo_api_error',
! empty( $body['message'] ) ? $body['message'] : 'API request failed'
);
}

return $body;
}
}

endif;
Loading