client = &$client; $this->cache_key = $this->client->getHookName( 'version_info' ); $this->disable_cache = apply_filters( $this->client->getHookName( 'disable-updater-cache' ), false ); } /** * Initialize Updater * * @return void */ public function init() { if ( $this->did_init ) { return; } $this->did_init = true; $method = 'run_' . $this->client->getType() . '_hooks'; // run_(plugin/theme)_hooks // Run the hooks. if ( method_exists( $this, $method ) ) { $this->$method(); } add_action( 'init', [ $this, 'clear_package_cache' ], - 1 ); // Background, batched update checks for every SDK product on the site. self::require_sibling( 'SE_License_SDK_Update_Batch' ); SE_License_SDK_Update_Batch::init(); // Drop the cached version info on every license lifecycle transition, // not just the two fired by the PHP license form. `emit_license_event()` // is reached from the form, the REST endpoints, WP-CLI and the scheduled // re-check alike, so hooking the events here covers every path a license // can change through. Registered from init() (plugins_loaded) rather than // from clear_package_cache() (`init`) so the listeners are armed for REST // and cron requests too. $this->client->add_action( 'license_activated', [ $this, 'delete_cached_version_info' ] ); $this->client->add_action( 'license_deactivated', [ $this, 'delete_cached_version_info' ] ); $this->client->add_action( 'license_grace_expired', [ $this, 'delete_cached_version_info' ] ); // Capture upgrades performed by WP itself (cron, plugins.php "update // now" link, our own Install_Job) so the SDK can offer a one-click // rollback to the previous version. add_action( 'upgrader_process_complete', [ $this, 'record_previous_version' ], 10, 2 ); } /** * Load a sibling SDK class file. The SDK ships its own spl_autoload * but in setups where multiple SDK copies coexist (Strauss-prefixed * vendor folders, classmap-authoritative composer dumps, etc.) the * autoloader's `$sdk_init_file` can end up pointing at a different * vendor folder than the one this Updater was loaded from. Falling * back to a relative require_once guarantees the new 1.5.0 classes * load from the same wordpress-sdk/ that contains this Updater. */ private static function require_sibling( string $class ): void { if ( class_exists( $class, false ) ) { return; } $path = __DIR__ . DIRECTORY_SEPARATOR . $class . '.php'; if ( is_readable( $path ) ) { require_once $path; } } /** * Set up WordPress filter hooks to get plugin update. * * @return void */ private function run_plugin_hooks() { add_filter( 'pre_set_site_transient_update_plugins', [ $this, 'check_plugin_update' ], 1, 1 ); add_filter( 'plugins_api', [ $this, 'plugins_api_filter' ], 10, 3 ); // Abort an incomplete update BEFORE WP swaps the live plugin folder. // Priority 20 so it runs AFTER the Install_Job folder-normalizer // (priority 10) and therefore inspects the final source directory. // Covers both the native "Update now" path and the SDK REST installer. add_filter( 'upgrader_source_selection', [ $this, 'validate_package_source' ], 20, 4 ); register_activation_hook( $this->client->getPackageFile(), [ $this, 'delete_cached_version_info' ] ); register_deactivation_hook( $this->client->getPackageFile(), [ $this, 'delete_cached_version_info' ] ); // Core/free plugin dependency gate (Points 1+2): never let the pro // update out-run the free plugin it depends on. No-op unless the // consumer declared `requires_core`. if ( $this->client->core_dependency()->is_configured() ) { // Abort a native "Update now" / bulk / cron auto-update of the pro // plugin while the core plugin is behind — priority 5 so it runs // before the package-integrity check and short-circuits early. // (The SDK's own installer attempts a core update first; this is // the safety net for every other upgrade path.) add_filter( 'upgrader_pre_install', [ $this, 'gate_core_dependency' ], 5, 2 ); add_action( 'admin_notices', [ $this, 'core_dependency_notice' ] ); } } /** * Validate the extracted update package before WordPress deletes/swaps the * live plugin folder. If the package is missing its main file or any * declared critical path, return a WP_Error to abort: WP keeps the old * folder (it never reaches `clear_destination`) and, on WP 6.3+, restores * the temp_backup. Net effect — a failed/incomplete update is dismissed and * the user keeps a working plugin instead of a fatal/white-screen. * * Hooked on the core `upgrader_source_selection` filter: * ($source, $remote_source, $upgrader, $hook_extra) * * @param string|WP_Error $source Extracted (possibly normalized) source dir. * @param string $remote_source Working dir the package was unpacked into. * @param WP_Upgrader $upgrader The upgrader instance. * @param array $hook_extra Context (plugin basename / SDK slug). * * @return string|WP_Error $source unchanged, or WP_Error to abort the install. */ public function validate_package_source( $source, $remote_source, $upgrader, $hook_extra = [] ) { // An upstream filter already errored (e.g. the normalizer) — pass through. if ( is_wp_error( $source ) ) { return $source; } // Only ever inspect packages we can prove belong to this plugin. if ( ! $this->source_belongs_to_this_plugin( $hook_extra ) ) { return $source; } global $wp_filesystem; // Without a usable filesystem we can't validate; don't block the update. if ( ! $wp_filesystem || ! is_string( $source ) ) { return $source; } $dir = trailingslashit( $source ); $slug = $this->client->getSlug(); // 1) Main plugin file must be present in the package root. $main_file = basename( $this->client->getBasename() ); if ( $main_file && ! $wp_filesystem->exists( $dir . $main_file ) ) { return new WP_Error( 'sdk-package-incomplete', sprintf( /* translators: 1: plugin slug, 2: missing main file name. */ __( 'Update aborted: the downloaded %1$s package is missing its main file (%2$s). Your current version was kept.', 'storeengine-sdk' ), $slug, $main_file ) ); } // 2) Every declared critical path must exist. foreach ( $this->get_critical_paths() as $rel ) { if ( ! is_string( $rel ) ) { continue; } $rel = ltrim( $rel, '/' ); if ( '' === $rel ) { continue; } if ( ! $wp_filesystem->exists( $dir . $rel ) ) { return new WP_Error( 'sdk-package-incomplete', sprintf( /* translators: 1: plugin slug, 2: missing package-relative path. */ __( 'Update aborted: the downloaded %1$s package is incomplete (missing %2$s). Your current version was kept.', 'storeengine-sdk' ), $slug, $rel ) ); } } return $source; } /** * Whether $hook_extra unambiguously identifies the package this Updater * instance manages. `upgrader_source_selection` is a global (non-prefixed) * core hook, so every SDK consumer's callback fires for every install — we * MUST self-scope here and default to false on any ambiguity so we never * validate (or block) a package that isn't provably ours. * * @param array $hook_extra * * @return bool */ private function source_belongs_to_this_plugin( $hook_extra ): bool { if ( empty( $hook_extra ) || ! is_array( $hook_extra ) ) { return false; } // SDK REST Install_Job path — explicit slug tag. if ( ! empty( $hook_extra['storeengine_sdk']['slug'] ) ) { return $hook_extra['storeengine_sdk']['slug'] === $this->client->getSlug(); } // Native single update (plugins.php "Update now"). if ( ! empty( $hook_extra['plugin'] ) ) { return $hook_extra['plugin'] === $this->client->getBasename(); } // Native bulk update (update-core.php). if ( ! empty( $hook_extra['plugins'] ) && is_array( $hook_extra['plugins'] ) ) { return in_array( $this->client->getBasename(), $hook_extra['plugins'], true ); } // Native single theme update (themes are keyed by stylesheet == slug). if ( ! empty( $hook_extra['theme'] ) ) { return $hook_extra['theme'] === $this->client->getSlug(); } // Native bulk theme update. if ( ! empty( $hook_extra['themes'] ) && is_array( $hook_extra['themes'] ) ) { return in_array( $this->client->getSlug(), $hook_extra['themes'], true ); } return false; } /** * Effective list of package-relative paths that must exist for an update to * be accepted. Uses the consumer's declared `critical_paths`, falling back * to `vendor/autoload.php` only when the installed build actually ships one. * Filterable so a site can trim/extend without re-vendoring the SDK. * * @return array */ private function get_critical_paths(): array { $paths = $this->client->getCriticalPaths(); if ( null === $paths ) { // Derive the default from the installed copy rather than assuming a // layout. Until 1.5.6 this was a blind [ 'vendor/autoload.php' ], // which permanently blocked updates for any consumer that ships no // vendor/ directory — a pro plugin with no runtime Composer // dependencies, or one that vendors somewhere else. Their packages // were rejected for a file they never had, with no way to recover: // the check runs from the *installed* build, so declaring // `critical_paths` in a later release cannot rescue a site already // running an older one. // // Only require the autoloader when the installed build actually has // one. That keeps the protection for the consumers it was written // for and stops it firing where it was always a false positive. An // explicitly declared `critical_paths` is untouched by this. $installed = trailingslashit( dirname( $this->client->getPackageFile() ) ) . 'vendor/autoload.php'; $paths = file_exists( $installed ) ? [ 'vendor/autoload.php' ] : []; } /** * Filter the critical paths checked before an update is applied. * * @param array $paths Package-relative paths that must exist. */ $paths = apply_filters( $this->client->getHookName( 'critical_paths' ), $paths ); return is_array( $paths ) ? $paths : []; } /** * Abort a pro update while the core/free plugin it depends on is behind. * * Hooked on core's `upgrader_pre_install` so it covers the native "Update * now" link, bulk updates on update-core.php, and unattended background * auto-updates alike. We only GATE here (never trigger a nested core * upgrade — a re-entrant WP_Upgrader run is unsafe); the SDK's own * installer does the auto-update-the-core-first attempt before it gets here. * * @param bool|WP_Error $response Whether to proceed. WP_Error to abort. * @param array $hook_extra Upgrade context. * * @return bool|WP_Error */ public function gate_core_dependency( $response, $hook_extra = [] ) { // An upstream pre-install check already failed — pass it through. if ( is_wp_error( $response ) ) { return $response; } // Only ever gate an upgrade we can prove is this pro plugin. if ( ! $this->source_belongs_to_this_plugin( $hook_extra ) ) { return $response; } // Never gate a rollback/downgrade — the latest release's core-version // requirement doesn't apply to an older pro version. if ( ! empty( $hook_extra['storeengine_sdk']['is_rollback'] ) ) { return $response; } $result = $this->client->core_dependency()->ensure_satisfied_or_error( false ); return is_wp_error( $result ) ? $result : $response; } /** * Nag the admin to update the core/free plugin first when a pro update is * pending but the dependency isn't satisfied. Only shown while an update is * actually waiting, so a latent version gap that blocks nothing stays quiet. * * @return void */ public function core_dependency_notice() { if ( ! current_user_can( 'update_plugins' ) ) { return; } $dep = $this->client->core_dependency(); if ( ! $dep->is_configured() || $dep->is_satisfied() ) { return; } // Only nag when a pro update is actually pending. $which = $this->client->isPlugin() ? 'plugin_update' : 'theme_update'; $info = get_site_transient( $this->cache_key . $which ); $update_pending = is_object( $info ) && ! empty( $info->new_version ) && version_compare( $this->client->getProjectVersion(), $info->new_version, '<' ); if ( ! $update_pending ) { return; } printf( '

%1$s: %2$s

', esc_html( $this->client->getPackageName() ), esc_html( $dep->unmet_message() ) ); } /** * Set up WordPress filter hooks to get theme update. * * @return void */ private function run_theme_hooks() { add_filter( 'pre_set_site_transient_update_themes', [ $this, 'check_theme_update' ] ); add_filter( 'themes_api', [ $this, 'themes_api_filter' ], 10, 3 ); add_action( 'switch_theme', [ $this, 'delete_cached_version_info' ] ); // Abort an incomplete theme update BEFORE WP swaps the live theme // folder — parity with the plugin path. add_filter( 'upgrader_source_selection', [ $this, 'validate_package_source' ], 20, 4 ); // Core/free plugin dependency gate — no-op unless `requires_core` is set. if ( $this->client->core_dependency()->is_configured() ) { add_filter( 'upgrader_pre_install', [ $this, 'gate_core_dependency' ], 5, 2 ); add_action( 'admin_notices', [ $this, 'core_dependency_notice' ] ); } } /** * Check for Update for this specific project * * @param false|object $transient_data plugin update transient data. * * @return object */ public function check_plugin_update( $transient_data ) { global $pagenow; // On multisite, skip injection on the per-site plugins.php (a site admin // can't update a network plugin anyway) — but DO inject in the Network // Admin, where a super admin manages network-activated plugin updates. if ( 'plugins.php' === $pagenow && is_multisite() && ! is_network_admin() ) { return $transient_data; } if ( ! is_object( $transient_data ) ) { $transient_data = new stdClass; } $basename = $this->client->getBasename(); // Cache (or background batch) only — see get_information(). $project_info = $this->get_information( 'plugin_update' ); if ( false !== $project_info && is_object( $project_info ) && isset( $project_info->new_version ) ) { if ( version_compare( $this->client->getProjectVersion(), $project_info->new_version, '<' ) ) { $project_info = clone $project_info; unset( $project_info->sections ); $transient_data->response[ $basename ] = $this->strip_unlicensed_package( $project_info ); } elseif ( isset( $transient_data->response[ $basename ] ) ) { // Up to date now (e.g. updated manually): drop a stale row. unset( $transient_data->response[ $basename ] ); } $transient_data->checked[ $basename ] = $this->client->getProjectVersion(); } elseif ( ! empty( $transient_data->response[ $basename ] ) ) { // Nothing fresh to go on: keep the row an earlier check wrote, but // re-apply the license gate so a download URL obtained while the // license was active cannot outlive the license itself. $transient_data->response[ $basename ] = $this->strip_unlicensed_package( $transient_data->response[ $basename ] ); } return $transient_data; } /** * Check theme update * * @param false|object $transient_data Theme update transient data. * * @return object */ public function check_theme_update( $transient_data ) { global $pagenow; if ( 'themes.php' === $pagenow && is_multisite() ) { return $transient_data; } if ( ! is_object( $transient_data ) ) { $transient_data = new stdClass(); } $slug = $this->client->getSlug(); // Cache (or background batch) only — see check_plugin_update(). $project_info = $this->get_information( 'theme_update' ); if ( false !== $project_info && is_object( $project_info ) && isset( $project_info->new_version ) ) { if ( version_compare( $this->client->getProjectVersion(), $project_info->new_version, '<' ) ) { $transient_data->response[ $slug ] = (array) $this->strip_unlicensed_package( $project_info ); } elseif ( isset( $transient_data->response[ $slug ] ) ) { unset( $transient_data->response[ $slug ] ); } $transient_data->last_checked = time(); $transient_data->checked[ $this->client->getSlug() ] = $this->client->getProjectVersion(); } elseif ( ! empty( $transient_data->response[ $slug ] ) ) { $transient_data->response[ $slug ] = $this->strip_unlicensed_package( $transient_data->response[ $slug ] ); } return $transient_data; } /** * Remove the download URL from an update payload when this product has no * valid license. * * The license server already omits `package` for an unlicensed site, so * under normal conditions this is a no-op. It matters because the response * is cached — in this SDK's own transient and again in WordPress's * `update_plugins` site transient, which plugins.php renders from directly. * A package URL fetched while the license was active used to survive the * license being deactivated, and WordPress decides between "update now" and * "Automatic update is unavailable" purely on `empty( $response->package )`. * Those signed URLs stay valid on the server for days, so the stale row was * enough to let an unlicensed site pull a paid release from plugins.php. * Invalidating on the license lifecycle events (see init()) closes the * window; this closes it for any payload that predates the fix or reaches * the transient some other way. * * Free products are never gated. A license inside its offline grace period * still counts as valid, so a brief outage doesn't strip working updates. * * @param object|array $info Update payload, as stored in the update transient. * * @return object|array The payload, with `package`/`download_link` blanked * when the license doesn't cover it. */ private function strip_unlicensed_package( $info ) { if ( ! $this->client->isPro() ) { return $info; } $is_object = is_object( $info ); if ( ! $is_object && ! is_array( $info ) ) { return $info; } if ( empty( $is_object ? ( $info->package ?? '' ) : ( $info['package'] ?? '' ) ) && empty( $is_object ? ( $info->download_link ?? '' ) : ( $info['download_link'] ?? '' ) ) ) { return $info; } if ( $this->client->license( false )->is_valid() ) { return $info; } // Don't mutate a shared/cached instance — the caller may be handing us // the object still held by the version-info transient. $info = $is_object ? clone $info : $info; foreach ( [ 'package', 'download_link' ] as $field ) { if ( $is_object ) { if ( property_exists( $info, $field ) ) { $info->{$field} = ''; } } elseif ( array_key_exists( $field, $info ) ) { $info[ $field ] = ''; } } return $info; } /** * How long a successful update/information payload is cached. * * WordPress itself refreshes the `update_plugins` / `update_themes` site * transient twice a day, so a shorter TTL only adds license-server traffic * without surfacing a release any sooner. Until 1.5.9 this was 3 hours. * * @return int Seconds. */ private function get_cache_ttl(): int { return (int) apply_filters( $this->client->getHookName( 'updater_cache_ttl' ), 12 * HOUR_IN_SECONDS ); } /** * How long to stop asking the license server after a failed check. * * @return int Seconds. */ private function get_failure_ttl(): int { return (int) apply_filters( $this->client->getHookName( 'updater_failure_ttl' ), HOUR_IN_SECONDS ); } /** * Whether a cached value is the "last check failed" marker written by * get_information(). * * @param mixed $info Cached value. * * @return bool */ private function is_failure_marker( $info ): bool { return is_object( $info ) && ! empty( $info->se_sdk_check_failed ); } /** * Whether WordPress is running an explicit, user-requested update check * (the "Check again" button on Dashboard → Updates). * * Visiting update-core.php used to bypass the cache on every page view, so * each visit sent one request per SDK product. WordPress only re-checks * wp.org there when `force-check` is present; we now do the same. * * @return bool */ private function is_forced_update_check(): bool { self::require_sibling( 'SE_License_SDK_Update_Batch' ); return SE_License_SDK_Update_Batch::is_forced_update_check(); } /** * Get version info from database. * * Returns the cached payload, the failure marker when the last check failed * and its back-off hasn't elapsed, or false when there is nothing usable. * * @return object|bool */ private function get_cached_version_info( $which ) { if ( $this->disable_cache || $this->is_forced_update_check() ) { return false; // Force fetching update } $info = get_site_transient( $this->cache_key . $which ); if ( $this->is_failure_marker( $info ) ) { return $info; } // The two payload shapes carry different identifying fields: the // `*_information` routes include `name` (they merge in package-info), // while the `*_update` route returns only the version envelope. Testing // for `name` alone therefore rejected every cached update payload, so // set_cached_version_info() wrote a transient that was never once read // back and every update check hit the license server. if ( ! is_object( $info ) || ( ! isset( $info->name ) && ! isset( $info->new_version ) ) ) { return false; // Cache is expired. } return $this->__children_to_array( $info, [ 'icons', 'banners', 'sections' ] ); } /** * Set version info to database * * A failed check is cached too (as a marker, for get_failure_ttl()). Until * 1.5.9 a failure *deleted* the transient, so while the license server was * slow or down every admin request that touched update data retried it — * each one blocking the page on the request timeout, and together keeping * the server overloaded. * * @param mixed $value data (version info) to cache. * * @return void */ private function set_cached_version_info( $value, $which ) { if ( ! $value ) { set_site_transient( $this->cache_key . $which, (object) [ 'se_sdk_check_failed' => time() ], SE_License_SDK_Client::jitter( max( MINUTE_IN_SECONDS, $this->get_failure_ttl() ) ) ); return; } $ttl = $this->get_cache_ttl(); // The server may say how long this answer is good for (`next_check_in`), // so check frequency can be tuned without shipping a new SDK. Clamped so // a bad value can neither hammer the server nor hide updates for long. if ( is_object( $value ) && isset( $value->next_check_in ) && is_numeric( $value->next_check_in ) ) { $ttl = min( 7 * DAY_IN_SECONDS, max( HOUR_IN_SECONDS, (int) $value->next_check_in ) ); } // ±10 % so sites don't all expire, and ask again, in lockstep. set_site_transient( $this->cache_key . $which, $value, SE_License_SDK_Client::jitter( max( MINUTE_IN_SECONDS, $ttl ), 0.1 ) ); } /** * Delete cached version info * @return void */ public function delete_cached_version_info() { $this->runtime_cache = []; delete_site_transient( $this->cache_key ); if ( $this->client->isPlugin() ) { delete_site_transient( 'update_plugins' ); } else { delete_site_transient( 'update_themes' ); } $actions = [ 'plugin_update', 'plugin_information', 'theme_update', 'theme_information' ]; foreach ( $actions as $which ) { delete_site_transient( $this->cache_key . $which ); delete_site_transient( $this->cache_key . $which . '_lock' ); } delete_site_transient( $this->client->getHookName( 'versions_list' ) ); } /** * Get plugin info, cache first. * * Update envelopes (`*_update`) are never fetched by an ordinary request: * a miss either runs the site-wide batch (cron, WP-CLI, "Check again") or * schedules it in the background and returns false for now. The full * `*_information` payload (the "View details" modal, REST package-info) and * forced checks still fetch directly, with a short lock so concurrent * requests don't all refetch the moment the cache expires. * * @param string $action * @param bool $force Explicit user action ("Check for updates", WP-CLI): skip cache and lock. * * @return bool|object */ private function get_information( string $action, bool $force = false ) { if ( ! $force && array_key_exists( $action, $this->runtime_cache ) ) { return $this->runtime_cache[ $action ]; } $project_info = $force ? false : $this->get_cached_version_info( $action ); if ( $this->is_failure_marker( $project_info ) ) { return false; } if ( false !== $project_info ) { return $project_info; } if ( ! $force && $action === $this->get_update_action() ) { self::require_sibling( 'SE_License_SDK_Update_Batch' ); if ( SE_License_SDK_Update_Batch::can_run_inline() ) { SE_License_SDK_Update_Batch::run(); $project_info = $this->read_cache( $action ); } else { SE_License_SDK_Update_Batch::schedule(); $project_info = false; } $this->runtime_cache[ $action ] = $project_info; return $project_info; } $lock = $this->cache_key . $action . '_lock'; if ( ! $force ) { if ( get_site_transient( $lock ) ) { // Another request is already refreshing this product. return false; } set_site_transient( $lock, 1, MINUTE_IN_SECONDS ); } $project_info = $this->get_updates( $action, $force ); $this->set_cached_version_info( $project_info, $action ); delete_site_transient( $lock ); $this->runtime_cache[ $action ] = $project_info; return $project_info; } /** * `plugin_update` or `theme_update`. * * @return string */ private function get_update_action(): string { return $this->client->isPlugin() ? 'plugin_update' : 'theme_update'; } /** * Cached payload for an action, ignoring the forced-check bypass. * * @param string $action Action. * * @return object|false */ private function read_cache( string $action ) { $info = get_site_transient( $this->cache_key . $action ); if ( ! is_object( $info ) || $this->is_failure_marker( $info ) || ( ! isset( $info->name ) && ! isset( $info->new_version ) ) ) { return false; } return $this->__children_to_array( $info, [ 'icons', 'banners', 'sections' ] ); } /** * Whether this product's update cache (or failure marker) has expired. * Used by the batch to pick which products to ask about. * * @return bool */ public function needs_refresh(): bool { return false === get_site_transient( $this->cache_key . $this->get_update_action() ); } /** * Store the result of a batched check for this product. * * @param array $response `success` / `data` envelope, as returned by SE_License_SDK_Client::request(). * * @return void */ public function store_update_response( array $response ) { $action = $this->get_update_action(); unset( $this->runtime_cache[ $action ] ); if ( ! empty( $response['success'] ) && isset( $response['data'] ) && is_array( $response['data'] ) ) { $this->set_cached_version_info( $this->process_update_data( $action, $response['data'] ), $action ); return; } $this->store_update_failure(); } /** * Record a failed check so this product isn't asked about again until the * failure back-off (or the server's breaker, whichever is longer) elapses. * * @param int $retry_in Seconds the server asked us to wait, if known. * * @return void */ public function store_update_failure( int $retry_in = 0 ) { $action = $this->get_update_action(); unset( $this->runtime_cache[ $action ] ); set_site_transient( $this->cache_key . $action, (object) [ 'se_sdk_check_failed' => time() ], max( MINUTE_IN_SECONDS, SE_License_SDK_Client::jitter( $this->get_failure_ttl() ), $retry_in ) ); } /** * Fallback for servers without the batch route: one request for this product. * * @return void */ public function refresh_single() { $action = $this->get_update_action(); unset( $this->runtime_cache[ $action ] ); $this->set_cached_version_info( $this->get_updates( $action ), $action ); } /** * Read whatever update information is already stored locally — never * contacts the license server. * * For code that runs on ordinary admin page loads (localized JS params, * dashboards). Prefers the full `*_information` payload, then the * `*_update` envelope, then the row WordPress keeps in its own update * transient. The license gate is applied to whatever is returned. * * @return object|null */ public function get_cached_update_info() { $type = $this->client->isPlugin() ? 'plugin' : 'theme'; foreach ( [ $type . '_information', $type . '_update' ] as $which ) { $info = get_site_transient( $this->cache_key . $which ); if ( is_object( $info ) && ! $this->is_failure_marker( $info ) && isset( $info->new_version ) ) { return $this->strip_unlicensed_package( $this->__children_to_array( $info, [ 'icons', 'banners', 'sections' ] ) ); } } if ( 'plugin' === $type ) { $wp = get_site_transient( 'update_plugins' ); $row = $wp->response[ $this->client->getBasename() ] ?? null; } else { $wp = get_site_transient( 'update_themes' ); $row = $wp->response[ $this->client->getSlug() ] ?? null; $row = is_array( $row ) ? (object) $row : $row; } if ( is_object( $row ) && isset( $row->new_version ) ) { return $this->strip_unlicensed_package( $row ); } return null; } private function get_updates( $action, bool $force = false ) { // Updater doesn't need to care for license. // License key will be added to the request body by client (if available). // Server will provide update information without package/download link if license not available. // For free version response will contain the package/download link. $data = $this->client->get_admin_info(); // Channel //$data['channel'] = 'beta'; // Update -> check-update, // Background checks mirror wp_update_plugins(): a short timeout on a // page load so a slow license server can't stall wp-admin, a longer one // in cron or when the user explicitly asked. $timeout = ( $force || wp_doing_cron() || ( defined( 'WP_CLI' ) && WP_CLI ) ) ? 15 : 5; $response = $this->client->request( [ 'body' => $data, 'route' => 'check-update', 'timeout' => $timeout, 'interactive' => $force ] ); if ( isset( $response['success'] ) && $response['success'] ) { $data = $response['data']; $this->client->apply_server_directives( is_array( $data ) ? $data : [] ); if ( 'plugin_update' !== $action && 'theme_update' !== $action ) { // information -> package-info $response = $this->client->request( [ 'body' => $this->client->get_admin_info(), 'route' => 'package-info', 'timeout' => $timeout, 'interactive' => $force, ] ); if ( isset( $response['success'] ) && $response['success'] ) { $data = array_merge( $data, $response['data'] ); } } return $this->process_update_data( $action, $data ); } return false; } /** * Turn a successful `check-update` answer (single or batched) into the * cached payload, recording the check time and core requirement on the way. * * @param string $action Action. * @param array $data Response data. * * @return object */ private function process_update_data( string $action, array $data ) { // Stamp the local "last checked" timestamp so the UI can render // "checked 2 minutes ago" without polling the server. self::require_sibling( 'SE_License_SDK_Update_State' ); ( new SE_License_SDK_Update_State( $this->client ) )->record_check(); // Mirror the license server's per-release core-plugin requirement // (if any) so the dependency gate can read it during a later install // request. Stored even when empty so a dropped requirement clears. $required_core = ( isset( $data['requires_core']['min_version'] ) && is_string( $data['requires_core']['min_version'] ) ) ? $data['requires_core']['min_version'] : ''; ( new SE_License_SDK_Update_State( $this->client ) )->set( [ 'required_core_version' => $required_core ] ); if ( isset( $data['product_id'] ) ) { unset( $data['product_id'] ); } /** * Filter API Response Data * * @param array $data */ $data = apply_filters( $this->client->getHookName( $action ), $data, $action ); return $this->__children_to_array( (object) $data, [ 'icons', 'banners', 'sections', 'compatibility', 'ratings', 'contributors', 'screenshots', 'tags' ] ); } /** * Updates information on the "View version x.x details" page with custom data. * * @param mixed $data Plugin info Data. * @param string $action Request Action. * @param ?object $args API Args. * * @return object $data */ public function plugins_api_filter( $data, string $action = '', object $args = null ) { if ( 'plugin_information' !== $action ) { return $data; } if ( ! isset( $args->slug ) || ( $args->slug !== $this->client->getSlug() ) ) { return $data; } return $this->strip_unlicensed_package( $this->get_information( 'plugin_information', ! empty( $args->force ) ) ); } public function themes_api_filter( $data, string $action = '', $args = null ) { if ( 'theme_information' !== $action ) { return $data; } if ( ! isset( $args->slug ) || ( $args->slug !== $this->client->getSlug() ) ) { return $data; } return $this->strip_unlicensed_package( $this->get_information( 'theme_information', ! empty( $args->force ) ) ); } /** * Legacy invalidation hooks fired by the SDK's own PHP license form. Kept * for consumers that render that form; every other path (REST, WP-CLI, the * scheduled re-check) is covered by the license lifecycle events wired up in * init(). Both are idempotent, so overlapping is harmless. */ public function clear_package_cache() { add_action( $this->client->getHookName( 'license-activate' ), [ $this, 'delete_cached_version_info' ] ); add_action( $this->client->getHookName( 'license-deactivate' ), [ $this, 'delete_cached_version_info' ] ); } /** * Force a fresh update check by clearing the local transient and * re-querying the server with the `force` flag. Returns the same * structure as get_information() so callers can use it interchangeably. * * @return object|bool */ public function force_check() { $this->delete_cached_version_info(); // Tell get_updates() to send `force=true` to the server via the // before_client_request_check-update hook. Channel-aware (server // also honours the per-installation beta_enabled flag). add_filter( $this->client->getHookName( 'before_client_request_check-update' ), [ $this, 'inject_force_param' ], 10, 2 ); $info = $this->get_information( $this->client->isPlugin() ? 'plugin_update' : 'theme_update', true ); remove_filter( $this->client->getHookName( 'before_client_request_check-update' ), [ $this, 'inject_force_param' ], 10 ); return $info; } /** * Hook callback used by force_check() to flag the outgoing /check-update * request body as a forced refresh. Not used outside force_check(). * * @param array $args * * @return array */ public function inject_force_param( $args ) { if ( ! isset( $args['body'] ) || ! is_array( $args['body'] ) ) { $args['body'] = []; } $args['body']['force'] = true; return $args; } /** * Record `previous_version` after WP / our installer upgrades the host * plugin. Powers the "Roll back to v1.9.1" shortcut in the UI. * * @param WP_Upgrader $upgrader * @param array $hook_extra */ public function record_previous_version( $upgrader, $hook_extra ) { $type = $hook_extra['type'] ?? ''; // Only this product's own plugin/theme update. if ( ! in_array( $type, [ 'plugin', 'theme' ], true ) ) { return; } if ( ! $this->source_belongs_to_this_plugin( $hook_extra ) ) { return; } // At this point the new code is already on disk, so reading the // header here would return the *new* version. The pre-install // version is whatever the SDK booted with on this request. $previous = $this->client->getProjectVersion(); self::require_sibling( 'SE_License_SDK_Update_State' ); ( new SE_License_SDK_Update_State( $this->client ) )->set( [ 'previous_version' => $previous, 'last_install_at' => time(), ] ); /** * Fires after this product's files have been updated in place (covers the * native "Update now"/bulk/auto-update paths and the SDK installer alike). * Hook name: "{product-hook-prefix}_update_installed". * * @param string $previous The version that was running before the swap. * @param SE_License_SDK_Updater $this The updater instance. */ $this->client->do_action( 'update_installed', $previous, $this ); } /** * Typecast child element to array or object * Utility method * * @param array|stdClass $input the array or object to convert/typecast. * @param array $children children array. * * @return array|object */ private function __children_to_array( $input, array $children = [] ) { if ( ! empty( $children ) && is_array( $children ) && ( is_object( $input ) || is_array( $input ) ) ) { $isObject = is_object( $input ); foreach ( $children as $child ) { if ( call_user_func_array( $isObject ? 'property_exists' : 'array_key_exists', [ $input, $child ] ) ) { if ( $isObject ) { $input->{$child} = (array) $input->{$child}; } else { // Was `(array) $input->{$child}` — reading an object // property off an array, which warns and yields []. $input[ $child ] = (array) $input[ $child ]; } } } } return $input; } final public function __clone() { trigger_error( 'Singleton. No cloning allowed!', E_USER_ERROR ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_trigger_error } /** * Wakeup. */ final public function __wakeup() { trigger_error( 'Singleton. No serialization allowed!', E_USER_ERROR ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_trigger_error } } // End of file SE_License_SDK_Updater.php.