| @@ -10,10 +10,19 @@ | ||
| 10 | 10 | defined( 'ABSPATH' ) || exit; |
| 11 | 11 | |
| 12 | 12 | class Plugin { |
| 13 | 13 | |
| 14 | + /** | |
| 15 | + * Data-schema version for one-time migrations, independent of the | |
| 16 | + * plugin version header. Bump when adding a step to maybe_upgrade(). | |
| 17 | + */ | |
| 18 | + public const DATA_VERSION = '1.1.6'; | |
| 19 | + | |
| 14 | 20 | private static $instance = null; |
| 15 | 21 | |
| 22 | + /** @var Usage_Tracker|null */ | |
| 23 | + private $usage_tracker = null; | |
| 24 | + | |
| 16 | 25 | public static function instance() { |
| 17 | 26 | if ( null === self::$instance ) { |
| 18 | 27 | self::$instance = new self(); |
| 19 | 28 | } |
| @@ -20,32 +29,469 @@ | ||
| 20 | 29 | return self::$instance; |
| 21 | 30 | } |
| 22 | 31 | |
| 23 | 32 | public function init() { |
| 33 | + // v1 services that are NOT yet wrapped as Modules (Admin, Rest_Api, | |
| 34 | + // Cache, Onboarding) are instantiated directly. Minifier is now | |
| 35 | + // instantiated by MinifyModule::boot(); Gzip is purely static. | |
| 24 | 36 | new Admin(); |
| 25 | 37 | new Rest_Api(); |
| 26 | 38 | new Cache(); |
| 27 | - new Minifier(); | |
| 39 | + new Rest_Cache(); | |
| 40 | + new Onboarding(); | |
| 41 | + | |
| 42 | + // Optional deactivation feedback survey on the Plugins screen. Admin | |
| 43 | + // context only (its hooks are admin_enqueue_scripts / admin_footer / | |
| 44 | + // wp_ajax). Sends nothing unless the user clicks "Submit & Deactivate". | |
| 45 | + if ( is_admin() ) { | |
| 46 | + new Deactivation_Feedback(); | |
| 47 | + } | |
| 48 | + | |
| 49 | + // Opt-in usage analytics. Instantiating + init() only registers the | |
| 50 | + // cron callback; NOTHING is collected or sent until the admin opts in | |
| 51 | + // from the setup wizard (Onboarding wires the consent toggle to | |
| 52 | + // Usage_Tracker::opt_in()). See class-usage-tracker.php privacy contract. | |
| 53 | + $this->start_plugin_tracking(); | |
| 54 | + | |
| 55 | + // Auto-heal the cache drop-in + WP_CACHE constant when state | |
| 56 | + // drifts. Scoped to admin_init only: filesystem writes belong in | |
| 57 | + // an authenticated admin context, never on anonymous front-end | |
| 58 | + // requests. The user already opted in (cache_enabled=true) — | |
| 59 | + // this is a consistency check, not a new install path. First | |
| 60 | + // admin page load after a plugin upgrade restores the state; | |
| 61 | + // front-end then serves from cache on the next request. | |
| 62 | + add_action( 'admin_init', array( Cache::class, 'auto_heal' ) ); | |
| 63 | + // Cheap: returns immediately unless the stored schema version is | |
| 64 | + // behind. Covers updates and multisite, where activate() never runs. | |
| 65 | + add_action( 'admin_init', array( Score_Store::class, 'maybe_install' ) ); | |
| 66 | + | |
| 67 | + // Secondary net: restore as soon as an update completes, for the | |
| 68 | + // cases where activate() does not re-run (bulk updates, auto-updates, | |
| 69 | + // some host updaters). Best-effort by nature — this callback is only | |
| 70 | + // registered when we were loaded in the request performing the | |
| 71 | + // update, which is not guaranteed while WE are the plugin being | |
| 72 | + // replaced. The restore in activate() is the primary guarantee; | |
| 73 | + // auto_heal() on admin_init remains the backstop. | |
| 74 | + add_action( 'upgrader_process_complete', array( $this, 'maybe_restore_after_update' ), 10, 2 ); | |
| 75 | + | |
| 76 | + // Per-post cache rules (Phase 3.4) — registers postmeta with | |
| 77 | + // REST + meta box on edit screens. | |
| 78 | + Cache_Meta_Box::boot(); | |
| 79 | + | |
| 80 | + // Single-URL purge entry points — row actions, the edit-screen | |
| 81 | + // button and the admin-post handler the admin-bar item also uses. | |
| 82 | + Purge_Ui::boot(); | |
| 83 | + | |
| 84 | + // Phase 0 architecture — managers + Free modules. v1 services | |
| 85 | + // (Cache/Minifier/Gzip) are NOT yet Modules; they'll be refactored | |
| 86 | + // in a follow-up PR with parity tests. | |
| 87 | + Conflict_Registry::boot(); | |
| 88 | + | |
| 89 | + // Read-only page-cache ownership evidence, behind the same | |
| 90 | + // activation/deactivation invalidation as the conflict matrix. | |
| 91 | + Page_Cache_Detector::boot(); | |
| 92 | + | |
| 93 | + // Integrations that clear OTHER plugins' caches of rendered output | |
| 94 | + // (Elementor's element cache + generated CSS). Registers a listener | |
| 95 | + // only; nothing runs until Cache::purge_render_caches() asks. | |
| 96 | + Render_Caches::boot(); | |
| 97 | + // Server_Caches needs no boot(): Cache::dispatch_purge_event() calls it | |
| 98 | + // directly so a throwing third-party listener cannot skip it. | |
| 99 | + | |
| 100 | + // Forward a full purge to a full-page cache owned by the WEB SERVER | |
| 101 | + // (nginx FastCGI, via the host's Nginx Helper install). Registers a | |
| 102 | + // listener on xspeed_after_purge_all only; it stands down unless | |
| 103 | + // that cache is actually configured. | |
| 104 | + Host_Page_Caches::boot(); | |
| 105 | + | |
| 106 | + // Register Free modules via the same action xspeed-pro uses, so | |
| 107 | + // the bootstrap path is symmetric across tiers. | |
| 108 | + add_action( 'xspeed_register_modules', array( $this, 'register_free_modules' ) ); | |
| 109 | + | |
| 110 | + // Fire the registration action + boot the registry at a LATER | |
| 111 | + // plugins_loaded priority so add-on plugins (xspeed-pro, in | |
| 112 | + // alphabetical order so it runs at default priority 10 AFTER | |
| 113 | + // us, but any add-on loaded at plugins_loaded(< 20)) have a | |
| 114 | + // chance to register their `xspeed_register_modules` callback | |
| 115 | + // before we fire the action. | |
| 116 | + // | |
| 117 | + // Bug history: previously this fired inline from init() at | |
| 118 | + // priority 10. xspeed-pro's plugins_loaded(15) hook then added | |
| 119 | + // its register_pro_modules callback AFTER the action had | |
| 120 | + // already fired — Pro modules never appeared in the registry. | |
| 121 | + // Caught by the ProStatus sentinel module's integration test. | |
| 122 | + add_action( 'plugins_loaded', array( $this, 'fire_module_lifecycle' ), 20 ); | |
| 123 | + | |
| 124 | + // One-time data migrations keyed on the stored version. Runs in | |
| 125 | + // admin only — nothing here needs to touch a front-end request. | |
| 126 | + if ( is_admin() ) { | |
| 127 | + add_action( 'plugins_loaded', array( $this, 'maybe_upgrade' ), 21 ); | |
| 128 | + } | |
| 28 | 129 | } |
| 29 | 130 | |
| 131 | + /** | |
| 132 | + * Run version-gated data migrations exactly once per upgrade. | |
| 133 | + * | |
| 134 | + * Keyed on `xspeed_data_version` rather than the plugin version header | |
| 135 | + * so a migration can be added without forcing a release bump. | |
| 136 | + */ | |
| 137 | + public function maybe_upgrade(): void { | |
| 138 | + $current = (string) get_option( 'xspeed_data_version', '0' ); | |
| 139 | + if ( version_compare( $current, self::DATA_VERSION, '>=' ) ) { | |
| 140 | + return; | |
| 141 | + } | |
| 142 | + | |
| 143 | + // 1.1.2 — strip credential values recorded by earlier versions' | |
| 144 | + // settings change annotations (they're served by the trend endpoints). | |
| 145 | + Activity_Log::redact_legacy_secrets(); | |
| 146 | + | |
| 147 | + // 1.1.4 — earlier versions cached a failed loopback as "gzip is not | |
| 148 | + // active" for an hour, which showed up as a bogus server-config | |
| 149 | + // warning. Drop the stale answer so the fixed probe re-runs instead | |
| 150 | + // of the wrong verdict living on past the update (issue #18). | |
| 151 | + delete_transient( 'xspeed_gzip_active' ); | |
| 152 | + | |
| 153 | + // 1.1.6 — a `/?s=<term>` request used to write its results page into | |
| 154 | + // the static tree under the *searched-from* path, which for the usual | |
| 155 | + // query-form search is `/`. The web server then served that results | |
| 156 | + // page as the homepage to every visitor. The write is fixed in | |
| 157 | + // Cache::store_static(), but an entry poisoned before the update | |
| 158 | + // outlives it: nothing purges on upgrade, and the static serve path | |
| 159 | + // never revalidates. Clear the tree once. The flat cache is keyed | |
| 160 | + // correctly and is deliberately left alone. (issue #191) | |
| 161 | + Cache::purge_static_tree(); | |
| 162 | + | |
| 163 | + update_option( 'xspeed_data_version', self::DATA_VERSION, false ); | |
| 164 | + } | |
| 165 | + | |
| 166 | + /** | |
| 167 | + * Phase 2 of plugin init: fire the registration action (collecting | |
| 168 | + * Free + Pro + any third-party modules hooked into | |
| 169 | + * `xspeed_register_modules`) and boot the registry. | |
| 170 | + * | |
| 171 | + * Runs at plugins_loaded(20) so every add-on that hooks at any | |
| 172 | + * priority < 20 has time to register first. | |
| 173 | + */ | |
| 174 | + public function fire_module_lifecycle(): void { | |
| 175 | + $this->ensure_modules_registered(); | |
| 176 | + | |
| 177 | + Module_Registry::boot_all(); | |
| 178 | + } | |
| 179 | + | |
| 180 | + /** | |
| 181 | + * Fire `xspeed_register_modules` if this request has not yet, hooking | |
| 182 | + * Free's own registration first when init() never got the chance. | |
| 183 | + * | |
| 184 | + * The activation request is the case that matters. activate_plugin() | |
| 185 | + * includes the plugin file long after `plugins_loaded` has fired, so the | |
| 186 | + * `plugins_loaded` callback init() would have added never runs, and | |
| 187 | + * neither does the add_action() inside it that puts register_free_modules | |
| 188 | + * on the action. Firing the action from activate() then registered | |
| 189 | + * nothing: Settings::conflict_safe_profile() composed from an empty | |
| 190 | + * registry, and a site with WP Super Cache came up with lazy-load, | |
| 191 | + * resource hints, font swapping and preloading switched on — only the | |
| 192 | + * four settings the registry-independent fallback names were held down | |
| 193 | + * (PR #295 review). Module_Registry::activate_all() has been a no-op on | |
| 194 | + * the same request for the same reason. | |
| 195 | + * | |
| 196 | + * On the activation request that means Free only: an add-on cannot have | |
| 197 | + * hooked yet, because xspeed-pro bails when Free's classes are absent and | |
| 198 | + * only hooks the action (at priority 20, from plugins_loaded(15)) once | |
| 199 | + * Free is active. On an ordinary request fire_module_lifecycle() reaches | |
| 200 | + * this at plugins_loaded(20) with every add-on already hooked. Do not | |
| 201 | + * call this from anything that can run in between: the action fires | |
| 202 | + * once, and an add-on that has not hooked yet stays unregistered for the | |
| 203 | + * whole request. | |
| 204 | + * did_action() keeps the action to one firing per request, so an | |
| 205 | + * activation that ran first does not make plugins_loaded(20) register | |
| 206 | + * every module a second time. | |
| 207 | + */ | |
| 208 | + public function ensure_modules_registered(): void { | |
| 209 | + if ( did_action( 'xspeed_register_modules' ) ) { | |
| 210 | + return; | |
| 211 | + } | |
| 212 | + | |
| 213 | + /* | |
| 214 | + * register_free_modules() does an unconditional `new` on every module | |
| 215 | + * class. On an ordinary request xspeed.php's integrity check refuses | |
| 216 | + * to boot before that can fatal and explains itself in an admin | |
| 217 | + * notice; the activation hook is registered outside that check, so | |
| 218 | + * an install missing a module file (truncated zip, a security | |
| 219 | + * plugin's quarantine, a half-applied update) would fatal here with | |
| 220 | + * no notice and no active plugin. Same answer as boot: do nothing. | |
| 221 | + */ | |
| 222 | + if ( function_exists( 'xspeed_missing_core_classes' ) && ! empty( xspeed_missing_core_classes() ) ) { | |
| 223 | + return; | |
| 224 | + } | |
| 225 | + | |
| 226 | + if ( ! has_action( 'xspeed_register_modules', array( $this, 'register_free_modules' ) ) ) { | |
| 227 | + add_action( 'xspeed_register_modules', array( $this, 'register_free_modules' ) ); | |
| 228 | + } | |
| 229 | + | |
| 230 | + /** | |
| 231 | + * Action: xspeed_register_modules | |
| 232 | + * | |
| 233 | + * Free modules register at priority 10; xspeed-pro at priority | |
| 234 | + * 20; site code can hook in between to inject custom modules. | |
| 235 | + * Fires exactly once per request. | |
| 236 | + */ | |
| 237 | + do_action( 'xspeed_register_modules' ); | |
| 238 | + } | |
| 239 | + | |
| 240 | + /** | |
| 241 | + * Register the Free Modules shipped in this plugin. Add new module | |
| 242 | + * registrations here. Pro plugin hooks the same action separately. | |
| 243 | + */ | |
| 244 | + public function register_free_modules(): void { | |
| 245 | + Module_Registry::register( new \XSpeed\Modules\Cache\CacheModule() ); | |
| 246 | + Module_Registry::register( new \XSpeed\Modules\Health\HealthModule() ); | |
| 247 | + // Settings — owns no settings itself; it's the CLI/MCP surface over | |
| 248 | + // Settings_Manager. Registering it is what makes `xspeed settings` | |
| 249 | + // exist, which is what keeps the curated get_settings/update_settings | |
| 250 | + // tools in the MCP catalog. (#149/#153) | |
| 251 | + Module_Registry::register( new \XSpeed\Modules\Settings\SettingsModule() ); | |
| 252 | + // External performance scores (PSI / GTmetrix) — Free, off by | |
| 253 | + // default. Rendered inside the Health host page's PageSpeed tab, so | |
| 254 | + // it has no sidebar row of its own. | |
| 255 | + Module_Registry::register( new \XSpeed\Modules\Score\ScoreModule() ); | |
| 256 | + Module_Registry::register( new \XSpeed\Modules\Preloader\PreloaderModule() ); | |
| 257 | + Module_Registry::register( new \XSpeed\Modules\Heartbeat\HeartbeatModule() ); | |
| 258 | + Module_Registry::register( new \XSpeed\Modules\Minify\MinifyModule() ); | |
| 259 | + Module_Registry::register( new \XSpeed\Modules\Gzip\GzipModule() ); | |
| 260 | + Module_Registry::register( new \XSpeed\Modules\Lazy\LazyModule() ); | |
| 261 | + Module_Registry::register( new \XSpeed\Modules\Bloat\BloatModule() ); | |
| 262 | + Module_Registry::register( new \XSpeed\Modules\Database\DatabaseModule() ); | |
| 263 | + Module_Registry::register( new \XSpeed\Modules\Cdn\CdnModule() ); | |
| 264 | + Module_Registry::register( new \XSpeed\Modules\Cloudflare\CloudflareModule() ); | |
| 265 | + Module_Registry::register( new \XSpeed\Modules\ObjectCache\ObjectCacheModule() ); | |
| 266 | + Module_Registry::register( new \XSpeed\Modules\BrowserCache\BrowserCacheModule() ); | |
| 267 | + // Advanced Cache — a Free container row that gathers the Pro | |
| 268 | + // cache-coverage features (404 / search / feed / REST / rules / | |
| 269 | + // maintenance) into one sidebar sub-item (FBS-83633). | |
| 270 | + Module_Registry::register( new \XSpeed\Modules\CacheCoverage\CacheCoverageModule() ); | |
| 271 | + Module_Registry::register( new \XSpeed\Modules\Fonts\FontsModule() ); | |
| 272 | + Module_Registry::register( new \XSpeed\Modules\ResourceHints\ResourceHintsModule() ); | |
| 273 | + // AI Privacy (GDPR off-switch) ships in Free even though every AI | |
| 274 | + // *feature* is Pro — privacy is a right, not a paid tier. FEATURES.md | |
| 275 | + // §AI row 6 mandates it. Without this registration the module was dead | |
| 276 | + // code: no REST/settings surface, the promised off-switch unreachable | |
| 277 | + // (FBS-83633 Bug 1). It carries its own cli_commands() so it satisfies | |
| 278 | + // the CLI/MCP coverage guard once registered. | |
| 279 | + Module_Registry::register( new \XSpeed\Modules\AIPrivacy\AIPrivacyModule() ); | |
| 280 | + // Migration moved Pro → Free: it's an acquisition/onboarding feature | |
| 281 | + // (detect a competing caching plugin, import its settings, switch over), | |
| 282 | + // so it must work without a Pro license. Agency-scale extras (profiles, | |
| 283 | + // bulk multisite, host presets) remain Pro. | |
| 284 | + Module_Registry::register( new \XSpeed\Modules\Migration\MigrationModule() ); | |
| 285 | + // Help & Support moved Pro → Free: a ticket link + read-only system | |
| 286 | + // snapshot is onboarding/diagnostics, not a paid value-add, so every | |
| 287 | + // user gets it. The snapshot degrades gracefully without Pro (Pro | |
| 288 | + // version/license fields fall back to defaults via defined()/get_option). | |
| 289 | + Module_Registry::register( new \XSpeed\Modules\Support\SupportModule() ); | |
| 290 | + // The dashboard control for usage-analytics consent. Consent used to | |
| 291 | + // be collectable only in the wizard and withdrawable nowhere, while | |
| 292 | + // the wizard and readme both promised a dashboard switch. (#437) | |
| 293 | + Module_Registry::register( new \XSpeed\Modules\Privacy\PrivacyModule() ); | |
| 294 | + // MCP remote control (AI assistants) — Free. The plugin serves the | |
| 295 | + // MCP protocol at the site's own /xspeed/mcp URL; the only gate is | |
| 296 | + // the per-site connection token an admin mints via Connect. No | |
| 297 | + // license, no hosted infra. See IMPLEMENTATION.md §17. | |
| 298 | + Module_Registry::register( new \XSpeed\Modules\Mcp\McpModule() ); | |
| 299 | + } | |
| 300 | + | |
| 301 | + /** | |
| 302 | + * Boot the opt-in usage tracker. Registers the cron sender only; the send | |
| 303 | + * itself is consent-gated inside Usage_Tracker. The instance is held so the | |
| 304 | + * onboarding REST handler can flip consent via usage_tracker()->opt_in(). | |
| 305 | + */ | |
| 306 | + public function start_plugin_tracking(): void { | |
| 307 | + $this->usage_tracker = Usage_Tracker::get_instance( | |
| 308 | + XSPEED_FILE, | |
| 309 | + array( | |
| 310 | + 'opt_in' => true, | |
| 311 | + 'item_id' => defined( 'XSPEED_INSIGHTS_ITEM_ID' ) ? XSPEED_INSIGHTS_ITEM_ID : false, | |
| 312 | + ) | |
| 313 | + ); | |
| 314 | + $this->usage_tracker->init(); | |
| 315 | + } | |
| 316 | + | |
| 317 | + /** | |
| 318 | + * The shared Usage_Tracker singleton (or null if tracking wasn't booted, | |
| 319 | + * e.g. on the activation hook before init() runs). | |
| 320 | + */ | |
| 321 | + public function usage_tracker(): ?Usage_Tracker { | |
| 322 | + return $this->usage_tracker; | |
| 323 | + } | |
| 324 | + | |
| 30 | 325 | public static function activate() { |
| 31 | - Settings::set_defaults(); | |
| 326 | + // Nothing below can see a module the registry does not hold — the | |
| 327 | + // conflict-safe profile Settings::set_defaults() may pick is composed | |
| 328 | + // from it, and activate_all() walks it. See ensure_modules_registered() | |
| 329 | + // for why the registry is empty on this request without this call. | |
| 330 | + self::instance()->ensure_modules_registered(); | |
| 32 | 331 | |
| 332 | + $profile = Settings::set_defaults(); | |
| 333 | + | |
| 334 | + // Score history table. Also called on admin_init (see init()) because | |
| 335 | + // activation does not fire for a site added to a multisite network | |
| 336 | + // later, nor after an update that ships a new schema version. | |
| 337 | + Score_Store::maybe_install(); | |
| 338 | + | |
| 33 | 339 | if ( ! file_exists( XSPEED_CACHE_DIR ) ) { |
| 34 | 340 | wp_mkdir_p( XSPEED_CACHE_DIR ); |
| 35 | 341 | } |
| 36 | 342 | Cache::write_silence( XSPEED_CACHE_DIR ); |
| 37 | 343 | |
| 38 | - // Drop-in installation (advanced-cache.php) and the WP_CACHE constant | |
| 39 | - // edit happen only when the user explicitly enables caching from the | |
| 40 | - // admin UI — never on activation. See Cache::toggle() and | |
| 41 | - // Rest_Api::toggle_cache(). This is a WordPress.org review requirement. | |
| 344 | + /* | |
| 345 | + * One exception to "caching is only ever enabled from the admin UI": | |
| 346 | + * a fresh install another plugin performed on the user's behalf. | |
| 347 | + * | |
| 348 | + * That plugin asked the user for site performance and installed us to | |
| 349 | + * provide it; making them go and find a second switch afterwards is | |
| 350 | + * a step nobody wants. It is also what the copy-vendored Setup::finish() | |
| 351 | + * did, so hosts migrating off it keep the behaviour they have. | |
| 352 | + * | |
| 353 | + * PROFILE_HOST_PAGE_CACHE is the whole condition, and it means three | |
| 354 | + * things at once: the install was genuinely fresh, nothing else owns | |
| 355 | + * the page cache, and another plugin claimed the install. Every other | |
| 356 | + * fresh install — including a user's own on a clear site — waits for | |
| 357 | + * the wizard. Every OTHER feature is off in this profile; the cache is | |
| 358 | + * the one thing a host may assume, because it is what it installed us | |
| 359 | + * for. And toggle() runs its own ownership transaction, so a | |
| 360 | + * competitor appearing between the two checks loses the race rather | |
| 361 | + * than being overwritten. | |
| 362 | + */ | |
| 363 | + if ( Settings::PROFILE_HOST_PAGE_CACHE === $profile ) { | |
| 364 | + $state = Cache::toggle( true ); | |
| 365 | + if ( ! empty( $state['blocked'] ) ) { | |
| 366 | + /* | |
| 367 | + * Refused after all — a competitor that appeared between the | |
| 368 | + * profile decision and the write, or a drop-in we could not | |
| 369 | + * install. The settings are identical either way (everything | |
| 370 | + * off), so the honest record is the one that does not claim a | |
| 371 | + * cache: conflict-safe is what the site actually got. | |
| 372 | + */ | |
| 373 | + $profile = Settings::PROFILE_CONFLICT_SAFE; | |
| 374 | + update_option( 'xspeed_install_profile', $profile, false ); | |
| 375 | + } | |
| 376 | + } | |
| 377 | + | |
| 378 | + /* | |
| 379 | + * Read the claim BEFORE spending it. consume_installed_by() records | |
| 380 | + * the installer only for a FRESH install — on a re-activation over | |
| 381 | + * settings that are already there it deletes the arming option and | |
| 382 | + * keeps nothing — so asking again afterwards answered "the user did | |
| 383 | + * it" about an install a host had just claimed, and the wizard opened | |
| 384 | + * over the host's own flow. The claim decides who to tell and whether | |
| 385 | + * to open the wizard; whether it is worth RECORDING is a separate | |
| 386 | + * question, and only the recording depends on the install being fresh. | |
| 387 | + */ | |
| 388 | + $installed_by = Settings::installed_by(); | |
| 389 | + | |
| 390 | + // Spend the arming option now the profile is settled. It changes what | |
| 391 | + // activation does, so it may not survive into the next one. | |
| 392 | + Settings::consume_installed_by( $profile ); | |
| 393 | + | |
| 394 | + // Caching is otherwise only ENABLED from the admin UI — see | |
| 395 | + // Cache::toggle() and Rest_Api::toggle_cache(). A fresh install | |
| 396 | + // therefore gets no drop-in and no wp-config.php edit here: | |
| 397 | + // cache_enabled is unset, so the call below is a no-op. | |
| 398 | + // | |
| 399 | + // It is NOT a no-op during an upgrade. WordPress runs an update as | |
| 400 | + // deactivate → wipe files → install → activate, which deletes | |
| 401 | + // advanced-cache.php while cache_enabled stays true. Restoring it | |
| 402 | + // here closes the window in which the site silently serves uncached | |
| 403 | + // (auto_heal() alone only fires on the next wp-admin page load). | |
| 404 | + Cache::restore_dropin_if_enabled(); | |
| 405 | + | |
| 406 | + /* | |
| 407 | + * First-run wizard: flag a one-time redirect for the activating user. | |
| 408 | + * Suppressed for bulk activations / already-completed sites in | |
| 409 | + * Onboarding::maybe_redirect() — and here for an install another plugin | |
| 410 | + * performed, which has an onboarding flow of its own. The install runs | |
| 411 | + * over AJAX, so our redirect would fire on that admin's NEXT page load | |
| 412 | + * and pull them out of the middle of the host's wizard; finishing ours | |
| 413 | + * would then overwrite the deliberately all-off profile they never | |
| 414 | + * asked to change. | |
| 415 | + */ | |
| 416 | + if ( '' === $installed_by ) { | |
| 417 | + Onboarding::flag_redirect(); | |
| 418 | + } | |
| 419 | + | |
| 420 | + // Propagate activation to every registered Module — registered by | |
| 421 | + // ensure_modules_registered() at the top, not by plugins_loaded, | |
| 422 | + // which fired before this file was even included. | |
| 423 | + Module_Registry::activate_all(); | |
| 424 | + | |
| 425 | + /** | |
| 426 | + * Fires at the end of activation, once the settings profile is decided. | |
| 427 | + * | |
| 428 | + * The other half of the host-plugin contract: a plugin that installed | |
| 429 | + * xSpeed for the user writes `xspeed_installed_by` before activating | |
| 430 | + * and listens here to find out how it came up. It carries no return | |
| 431 | + * value and nothing branches on it — a host that ignores it changes | |
| 432 | + * nothing about the install. | |
| 433 | + * | |
| 434 | + * @param string $installed_by Host slug, or '' when the user did it. | |
| 435 | + * @param string $profile Settings::PROFILE_* — which profile a fresh | |
| 436 | + * install came up with, '' if not fresh. | |
| 437 | + */ | |
| 438 | + do_action( 'xspeed_activated', $installed_by, $profile ); | |
| 42 | 439 | } |
| 43 | 440 | |
| 441 | + /** | |
| 442 | + * Restore the cache drop-in right after THIS plugin is updated. | |
| 443 | + * | |
| 444 | + * Bound to upgrader_process_complete. Bulk updates, auto-updates and | |
| 445 | + * host-level updaters finish without re-running activate(), so this is | |
| 446 | + * the only hook that repairs the drop-in before the next wp-admin page | |
| 447 | + * load. Narrow by design: bails unless the completed action was a | |
| 448 | + * plugin update whose payload actually includes xspeed. | |
| 449 | + * | |
| 450 | + * @param \WP_Upgrader $upgrader Upgrader instance (unused). | |
| 451 | + * @param array $hook_extra Contextual data about the update. | |
| 452 | + * @return void | |
| 453 | + */ | |
| 454 | + public function maybe_restore_after_update( $upgrader, $hook_extra ) { | |
| 455 | + unset( $upgrader ); | |
| 456 | + | |
| 457 | + if ( ! is_array( $hook_extra ) ) { | |
| 458 | + return; | |
| 459 | + } | |
| 460 | + if ( ! isset( $hook_extra['type'], $hook_extra['action'] ) ) { | |
| 461 | + return; | |
| 462 | + } | |
| 463 | + if ( 'plugin' !== $hook_extra['type'] || 'update' !== $hook_extra['action'] ) { | |
| 464 | + return; | |
| 465 | + } | |
| 466 | + | |
| 467 | + // Single update uses 'plugin'; bulk uses 'plugins'. | |
| 468 | + $updated = array(); | |
| 469 | + if ( isset( $hook_extra['plugins'] ) && is_array( $hook_extra['plugins'] ) ) { | |
| 470 | + $updated = $hook_extra['plugins']; | |
| 471 | + } elseif ( isset( $hook_extra['plugin'] ) && is_string( $hook_extra['plugin'] ) ) { | |
| 472 | + $updated = array( $hook_extra['plugin'] ); | |
| 473 | + } | |
| 474 | + | |
| 475 | + $ours = plugin_basename( XSPEED_FILE ); | |
| 476 | + if ( ! in_array( $ours, $updated, true ) ) { | |
| 477 | + return; | |
| 478 | + } | |
| 479 | + | |
| 480 | + Cache::restore_dropin_if_enabled(); | |
| 481 | + } | |
| 482 | + | |
| 44 | 483 | public static function deactivate() { |
| 45 | - Cache::remove_dropin(); | |
| 46 | - Cache::set_wp_cache_constant( false ); | |
| 484 | + // Drop-in + WP_CACHE constant are NOT touched here. WordPress | |
| 485 | + // upgrades run as deactivate → wipe files → install → activate, | |
| 486 | + // so removing those artifacts on every deactivate would silently | |
| 487 | + // disable caching after each plugin update. uninstall.php | |
| 488 | + // handles full teardown when the user actually removes the | |
| 489 | + // plugin; auto_heal() restores state on the next admin_init if | |
| 490 | + // the drop-in or WP_CACHE went missing for any other reason. | |
| 47 | 491 | Cache::purge_all(); |
| 48 | 492 | Minifier::purge_minified(); |
| 49 | 493 | Gzip::apply( false ); |
| 494 | + | |
| 495 | + Module_Registry::deactivate_all(); | |
| 50 | 496 | } |
| 51 | 497 | } |