'templately', // slug of whoever last put the offer up * 'offered_at' => 1757462400, // when it went up * 'outcome' => 'offered', // see below * 'outcome_at' => 1757462400, * ] * * `outcome` is one of: * * - `offered` — the row went out; nobody has answered yet. * - `accepted` — a host installed it. Written immediately before activation. * - `declined` — the user said no in a way that was meant to stick (a dismissed * promo, a "never show again"). Templately never writes it: its own * decline is the local timer above, which expires. A host with a * permanent opt-out control should write it there. * - `removed` — accepted, then taken off the site. NOT stored: it is derived, so * a deletion performed outside any of our code is still seen. The * name exists so a reader can talk about the state. * * Terminal outcomes are terminal. Nothing here re-offers past one. * * @see docs/guides/installing-from-another-plugin.md in the xSpeed repo. */ const OFFER_RECORD_OPTION = 'wpdeveloper_xspeed_offer'; /** * On disk at all, active or not — free or Pro. * * Presence, not activation. A site that has it has decided about it, including a * user who installed it and switched it off, and re-offering that is nagging. * Offering Free to a site running Pro would be worse still: a downgrade. */ public static function is_installed(): bool { $plugins = Helper::get_plugins(); return isset( $plugins[ self::PLUGIN_FILE ] ) || isset( $plugins[ self::PRO_PLUGIN_FILE ] ); } /** * Has xSpeed ever run here? Its settings row survives deactivation and deletion. */ public static function has_settings(): bool { return false !== get_option( self::SETTINGS_OPTION, false ); } public static function is_supported(): bool { global $wp_version; return version_compare( (string) $wp_version, self::REQUIRES_WP, '>=' ) && version_compare( PHP_VERSION, self::REQUIRES_PHP, '>=' ); } /** * Whether the suggestion has already had its turn. * * A window rather than a permanent flag. Inside OFFER_GRACE the row keeps showing. * Past OFFER_COOLDOWN the answer has aged out and may be asked again. Between the * two, it is spent. */ public static function has_been_offered(): bool { $shown = (int) get_option( self::OFFER_SHOWN_OPTION, 0 ); if ( $shown <= 0 ) { return false; } $age = time() - $shown; return $age > self::OFFER_GRACE && $age < self::OFFER_COOLDOWN; } /** * Record that the row went out. * * Re-arms only once the previous showing has aged out. Rewriting on every re-render * would mean the offer never expires, and the wizard re-fetches this step often. */ public static function mark_offered() { $shown = (int) get_option( self::OFFER_SHOWN_OPTION, 0 ); if ( $shown <= 0 || ( time() - $shown ) >= self::OFFER_COOLDOWN ) { update_option( self::OFFER_SHOWN_OPTION, time(), false ); self::record_offer(); return; } // The row is up but the clock is already running, so the shared record has // nothing new to learn — except on a site that was mid-window when this // release landed, where it does not exist yet. Writing it on every fetch // would stamp `offered_at` with the current second forever, and a sibling // pacing itself off that field would never see the offer age out. if ( '' === self::outcome() ) { self::record_offer(); } } /** * The shared record, always an array so callers can read it without guarding. */ public static function offer_record(): array { $record = get_option( self::OFFER_RECORD_OPTION, array() ); return is_array( $record ) ? $record : array(); } /** * Note in the shared record that the row went out. * * Never downgrades an answer. A site that already accepted or declined has told us * something; putting it back to `offered` because the row rendered again would lose * that, and the row should not have rendered in the first place. */ public static function record_offer() { $record = self::offer_record(); if ( in_array( self::outcome(), array( 'accepted', 'declined' ), true ) ) { return; } $record['offered_by'] = self::INSTALLER_SLUG; $record['offered_at'] = time(); $record['outcome'] = 'offered'; $record['outcome_at'] = time(); update_option( self::OFFER_RECORD_OPTION, $record, false ); } /** * Write a terminal answer into the shared record. * * @param string $outcome `accepted` or `declined`. */ public static function record_outcome( string $outcome ) { if ( ! in_array( $outcome, array( 'accepted', 'declined' ), true ) ) { return; } $record = self::offer_record(); if ( empty( $record['offered_by'] ) ) { $record['offered_by'] = self::INSTALLER_SLUG; $record['offered_at'] = time(); } $record['outcome'] = $outcome; $record['outcome_at'] = time(); update_option( self::OFFER_RECORD_OPTION, $record, false ); } /** * The recorded outcome, or '' when nobody has written a usable one. * * Scalar-guarded because three plugins write this row and only one of them is * this file. A nested array would otherwise be cast to the string 'Array' — a * PHP notice, which the test rig turns into an exception and WP_DEBUG_DISPLAY * prints into the REST response. */ public static function outcome(): string { $record = self::offer_record(); if ( ! isset( $record['outcome'] ) || ! is_scalar( $record['outcome'] ) ) { return ''; } return (string) $record['outcome']; } /** * Has this site already answered the question, whoever asked it? * * The check that makes the offer survive a deletion. `accepted` plus an absent * plugin is a user who installed it and then took it off — the clearest "no" a * user can give, and the one every other guard here misses, because deleting a * plugin runs its uninstaller: `xspeed_options` goes with it, so has_settings() * forgets, and OFFER_COOLDOWN then re-offers a month later. Derived rather than * stored so a deletion done from the Plugins screen — with none of our code * running — still counts. */ public static function was_answered(): bool { $outcome = self::outcome(); if ( 'declined' === $outcome ) { return true; } if ( 'accepted' === $outcome ) { return ! self::is_installed(); } return false; } /** * The dependency row, in the same shape as every other entry on that screen. * * The name, icon and link are the real plugin's: the user is agreeing to install a * specific thing and should be able to see and check what it is. `installed` is * always false — the offer is withheld outright when the plugin is present — and * false is what keeps the checkbox enabled. `mustHave` is omitted: a suggestion, * not a requirement. */ public static function dependency_entry(): array { return array( 'name' => self::PLUGIN_NAME, 'icon' => self::PLUGIN_ICON, 'plugin_file' => self::PLUGIN_FILE, 'plugin_original_slug' => self::PLUGIN_SLUG, 'is_pro' => false, 'installed' => false, 'link' => self::PLUGIN_LINK, ); } /** * Whether to offer the caching plugin alongside whatever the pack itself asked for. * * Deliberately not conditional on anything already owning the page cache. xSpeed * installs beside another cache plugin and stands down from the cache itself — that * is its decision, made at its own activation, and asking it here would only be * asking on an earlier request than the one that matters. * * Cheapest checks first, and the order is load-bearing: * * - Can this user even accept? The dependency screen is readable at `delete_posts`, * so a contributor can open the wizard; installing needs `install_plugins`. * Without this they would spend the site's one offer on themselves. * - Already answered, by us or by any sibling plugin. Permanent, and checked ahead * of our own timer because it outranks it: OFFER_GRACE keeps the row up for half * an hour, which was long enough to install xSpeed, delete it, and be offered it * again in the same sitting. * - Already offered, within the window. * - Already present — free or Pro — or already carrying xSpeed's settings. * - The site can run it. */ public static function should_offer(): bool { if ( ! Helper::current_user_can( 'install_plugins' ) ) { return false; } if ( self::was_answered() ) { return false; } if ( self::has_been_offered() ) { return false; } return ! self::is_installed() && ! self::has_settings() && self::is_supported(); } /** * Claim the install, so xSpeed comes up as a host install rather than a hand one. * * Call immediately before activating, and never speculatively. It is a one-shot * trigger that changes what activation does, not a record of intent — an install * that dies between this and the activation arms the NEXT activation on the site, * whoever starts it. * * Also settles the shared record at `accepted`. Written here rather than after a * successful activation on purpose: an install that got this far has been agreed * to, and an activation that then fails still leaves files on disk. Recording the * answer is what stops the site being asked again once those files are removed. */ public static function claim_install() { update_option( self::INSTALLED_BY_OPTION, self::INSTALLER_SLUG, false ); self::record_outcome( 'accepted' ); } /** * What the install came up as, for reporting. Null when xSpeed is not active or is * older than the release that introduced the API. * * `conflict-safe` is a success, not a failure: it means another plugin was already * caching and xSpeed stood down, which is the designed outcome. * * @return array|null */ public static function install_status() { if ( ! class_exists( '\XSpeed\Host' ) ) { return null; } return \XSpeed\Host::status(); } }