Files
unsupervised-scheduler/src/Update/UpdateChecker.php
T
thatguygriffandClaude Opus 4.8 83be388186
CI / No Debug Code (pull_request) Successful in 4s
CI / Tests (PHP 8.2) (pull_request) Successful in 39s
CI / Tests (PHP 8.1) (pull_request) Successful in 1m21s
CI / Coding Standards (pull_request) Successful in 2m52s
CI / PHPStan (pull_request) Successful in 2m53s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m36s
CI / Build Plugin Zip (pull_request) Skipped
Point plugin metadata links at Unsupervised and Gitea
Make the "By Unsupervised" author link go to https://unsupervised.ca
(via a new Author URI header) and the plugin site / "View details" link
point at the Gitea project instead of WordPress.org.

Core's "View details" link opened a thickbox iframe against the
WordPress.org plugin-information API, which 404s ("Plugin not found")
for this off-directory plugin. A plugin_row_meta filter now replaces it
with a new-tab link to the matching Gitea release tag page; embedding
Gitea in the iframe is blocked by its X-Frame-Options anyway.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-23 11:14:44 -03:00

184 lines
5.6 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. Returns the incoming
* value untouched unless a newer release with a zip asset exists, in
* which case it returns the update array core expects.
*/
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'] ) {
return $update;
}
if ( version_compare( $release['version'], USC_VERSION, '<=' ) ) {
return $update;
}
return [
'slug' => 'unsupervised-schedular',
'version' => $release['version'],
'url' => self::REPO_URL,
'package' => $release['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,
];
}
}