PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / trunk
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN vtrunk
1.2.4 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.1.1 1.1.2 1.1.3 1.1.4 1.1.5 1.1.6 1.1.7 1.1.8 1.2.0 1.2.1 1.2.2 1.2.3
xspeed / includes / class-plugin.php

class-plugin.php in xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN trunk, at includes/class-plugin.php

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