Files
unsupervised-scheduler/src/Update/UpdateChecker.php
T
thatguygriffandClaude Opus 4.8 51dd032668
CI / Tests (PHP 8.1) (pull_request) Successful in 47s
CI / Tests (PHP 8.2) (pull_request) Successful in 47s
CI / No Debug Code (pull_request) Successful in 3s
CI / Coding Standards (pull_request) Successful in 3m7s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m38s
CI / PHPStan (pull_request) Successful in 2m50s
CI / Build Plugin Zip (pull_request) Skipped
Show the Enable auto-updates toggle for the self-updater
WordPress only renders the "Enable auto-updates" toggle for a plugin that
appears in the update_plugins transient's response or no_update list, which
is what sets core's update-supported flag. UpdateChecker only populated the
response side (when a newer release existed), so between releases the plugin
was absent from the transient and the toggle never showed.

provideUpdate() now returns a no_update payload (installed version, empty
package) whenever no newer release is offered — including when the release
lookup fails — so the plugin stays in the transient and the toggle appears.
The response path (one-click and unattended updates) is unchanged.

Bumps to 1.1.1 so the fix ships to installed sites via the self-updater.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-24 08:46:31 -03:00

198 lines
6.5 KiB
PHP

<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Update;
use Unsupervised\Schedular\Val;
/**
* Serves plugin updates from the Gitea repository's releases.
*
* Core reads the plugin's `Update URI` header and, during every update
* check, fires the `update_plugins_{hostname}` filter for that host. This
* class answers the filter by fetching the latest published release from
* the Gitea API and returning its zip asset when it is newer than the
* installed version. Everything downstream — the Plugins-screen notice,
* one-click updates, and opt-in auto-updates — is handled by core.
*
* Drafts and releases marked "pre-release" in Gitea are never offered:
* the `/releases/latest` endpoint excludes both.
*/
class UpdateChecker {
public const HOSTNAME = 'git.unsupervised.ca';
public const REPO_URL = 'https://git.unsupervised.ca/Unsupervised/unsupervised-scheduler';
public const API_URL = 'https://git.unsupervised.ca/api/v1/repos/Unsupervised/unsupervised-scheduler/releases/latest';
public const TRANSIENT = 'us_schedular_latest_release';
/**
* How long a release lookup (including a failed one) is cached. Core
* runs update checks on admin page loads as well as twice-daily cron,
* so the cache keeps the plugin from hammering the Gitea API.
*/
private const CACHE_TTL = 6 * 3600;
public function register(): void {
add_filter( 'update_plugins_' . self::HOSTNAME, [ $this, 'provideUpdate' ], 10, 3 );
add_filter( 'plugin_row_meta', [ $this, 'filterRowMeta' ], 10, 2 );
}
/**
* Replace core's "View details" link on the Plugins screen.
*
* Core points that link at the WordPress.org plugin-information API
* (`plugin-install.php?tab=plugin-information&plugin=…`), which 404s in a
* "Plugin not found" iframe for this off-directory plugin. We swap it for a
* direct link to the matching Gitea release tag page, opened in a new tab —
* embedding Gitea in the thickbox iframe would be blocked by its
* `X-Frame-Options: SAMEORIGIN` anyway.
*
* @param mixed $meta Row-meta links for the plugin.
* @param mixed $plugin_file Plugin file the meta belongs to.
* @return mixed The (possibly modified) row-meta array.
*/
public function filterRowMeta( mixed $meta, mixed $plugin_file ): mixed {
if ( ! is_array( $meta ) || plugin_basename( USC_PLUGIN_FILE ) !== $plugin_file ) {
return $meta;
}
// Drop core's WordPress.org "View details" thickbox link.
$meta = array_values(
array_filter(
$meta,
static fn( $item ): bool => ! ( is_string( $item ) && str_contains( $item, 'open-plugin-details-modal' ) )
)
);
$meta[] = sprintf(
'<a href="%s" target="_blank" rel="noopener noreferrer">%s</a>',
esc_url( self::REPO_URL . '/releases/tag/v' . USC_VERSION ),
esc_html__( 'View details', 'unsupervised-schedular' )
);
return $meta;
}
/**
* `update_plugins_{hostname}` filter callback.
*
* For a newer release with a zip asset, returns the update array core
* files under the transient's `response` list (the update offer).
* Otherwise — the plugin is current, or the release lookup failed — it
* returns a payload with the installed version and no package, which core
* files under `no_update`. That `no_update` entry is what sets core's
* `update-supported` flag and makes the "Enable auto-updates" toggle
* appear on the Plugins screen; without it, an off-directory plugin is
* absent from the transient between releases and the toggle never shows.
*
* The incoming value is only passed through untouched for other plugins.
*/
public function provideUpdate( mixed $update, mixed $plugin_data, mixed $plugin_file ): mixed {
if ( plugin_basename( USC_PLUGIN_FILE ) !== $plugin_file ) {
return $update;
}
$release = $this->latestRelease();
if ( '' !== $release['version'] && '' !== $release['package']
&& version_compare( $release['version'], USC_VERSION, '>' ) ) {
return [
'slug' => 'unsupervised-schedular',
'version' => $release['version'],
'url' => self::REPO_URL,
'package' => $release['package'],
];
}
// No newer release: answer with a `no_update` payload so core keeps
// the plugin in the update transient and shows the auto-update toggle.
// The empty package leaves core nothing to auto-install, as intended.
return [
'slug' => 'unsupervised-schedular',
'version' => USC_VERSION,
'url' => self::REPO_URL,
'package' => '',
];
}
/**
* The latest published release, from the transient cache when fresh.
*
* @return array{version: string, package: string} Empty strings when no
* usable release exists.
*/
private function latestRelease(): array {
$cached = get_transient( self::TRANSIENT );
if ( is_array( $cached ) ) {
return [
'version' => Val::string( $cached['version'] ?? '' ),
'package' => Val::string( $cached['package'] ?? '' ),
];
}
$release = $this->fetchLatestRelease();
set_transient( self::TRANSIENT, $release, self::CACHE_TTL );
return $release;
}
/**
* Ask the Gitea API for the latest published release's version and zip asset.
*
* @return array{version: string, package: string}
*/
private function fetchLatestRelease(): array {
$none = [
'version' => '',
'package' => '',
];
$response = wp_remote_get(
self::API_URL,
[
'timeout' => 10,
'headers' => [ 'Accept' => 'application/json' ],
]
);
if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) {
return $none;
}
$body = json_decode( wp_remote_retrieve_body( $response ), true );
if ( ! is_array( $body ) ) {
return $none;
}
// Release tags are named v1.2.3; the plugin header carries the bare version.
$version = preg_replace( '/^v/i', '', Val::string( $body['tag_name'] ?? '' ) ) ?? '';
// The release workflow attaches the built plugin zip (top-level
// unsupervised-schedular/ folder, production autoloader) as an asset.
// Gitea's auto-generated source archives are not usable packages.
$package = '';
$assets = $body['assets'] ?? null;
if ( is_array( $assets ) ) {
foreach ( $assets as $asset ) {
if ( ! is_array( $asset ) ) {
continue;
}
if ( str_ends_with( strtolower( Val::string( $asset['name'] ?? '' ) ), '.zip' ) ) {
$package = Val::string( $asset['browser_download_url'] ?? '' );
break;
}
}
}
if ( '' === $version || '' === $package ) {
return $none;
}
return [
'version' => $version,
'package' => $package,
];
}
}