PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.2.0
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.2.0
1.3.3 1.3.2 1.3.1 1.3.0 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 All 29 releases
xspeed / includes / class-plugin.php

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

344 lines 14.4 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 // Integrations that clear OTHER plugins' caches of rendered output
86 // (Elementor's element cache + generated CSS). Registers a listener
87 // only; nothing runs until Cache::purge_render_caches() asks.
88 Render_Caches::boot();
89
90 // Register Free modules via the same action xspeed-pro uses, so
91 // the bootstrap path is symmetric across tiers.
92 add_action( 'xspeed_register_modules', array( $this, 'register_free_modules' ) );
93
94 // Fire the registration action + boot the registry at a LATER
95 // plugins_loaded priority so add-on plugins (xspeed-pro, in
96 // alphabetical order so it runs at default priority 10 AFTER
97 // us, but any add-on loaded at plugins_loaded(< 20)) have a
98 // chance to register their `xspeed_register_modules` callback
99 // before we fire the action.
100 //
101 // Bug history: previously this fired inline from init() at
102 // priority 10. xspeed-pro's plugins_loaded(15) hook then added
103 // its register_pro_modules callback AFTER the action had
104 // already fired — Pro modules never appeared in the registry.
105 // Caught by the ProStatus sentinel module's integration test.
106 add_action( 'plugins_loaded', array( $this, 'fire_module_lifecycle' ), 20 );
107
108 // One-time data migrations keyed on the stored version. Runs in
109 // admin only — nothing here needs to touch a front-end request.
110 if ( is_admin() ) {
111 add_action( 'plugins_loaded', array( $this, 'maybe_upgrade' ), 21 );
112 }
113 }
114
115 /**
116 * Run version-gated data migrations exactly once per upgrade.
117 *
118 * Keyed on `xspeed_data_version` rather than the plugin version header
119 * so a migration can be added without forcing a release bump.
120 */
121 public function maybe_upgrade(): void {
122 $current = (string) get_option( 'xspeed_data_version', '0' );
123 if ( version_compare( $current, self::DATA_VERSION, '>=' ) ) {
124 return;
125 }
126
127 // 1.1.2 — strip credential values recorded by earlier versions'
128 // settings change annotations (they're served by the trend endpoints).
129 Activity_Log::redact_legacy_secrets();
130
131 // 1.1.4 — earlier versions cached a failed loopback as "gzip is not
132 // active" for an hour, which showed up as a bogus server-config
133 // warning. Drop the stale answer so the fixed probe re-runs instead
134 // of the wrong verdict living on past the update (issue #18).
135 delete_transient( 'xspeed_gzip_active' );
136
137 // 1.1.6 — a `/?s=<term>` request used to write its results page into
138 // the static tree under the *searched-from* path, which for the usual
139 // query-form search is `/`. The web server then served that results
140 // page as the homepage to every visitor. The write is fixed in
141 // Cache::store_static(), but an entry poisoned before the update
142 // outlives it: nothing purges on upgrade, and the static serve path
143 // never revalidates. Clear the tree once. The flat cache is keyed
144 // correctly and is deliberately left alone. (issue #191)
145 Cache::purge_static_tree();
146
147 update_option( 'xspeed_data_version', self::DATA_VERSION, false );
148 }
149
150 /**
151 * Phase 2 of plugin init: fire the registration action (collecting
152 * Free + Pro + any third-party modules hooked into
153 * `xspeed_register_modules`) and boot the registry.
154 *
155 * Runs at plugins_loaded(20) so every add-on that hooks at any
156 * priority < 20 has time to register first.
157 */
158 public function fire_module_lifecycle(): void {
159 /**
160 * Action: xspeed_register_modules
161 *
162 * Free modules register at priority 10; xspeed-pro at priority
163 * 20; site code can hook in between to inject custom modules.
164 * Fires exactly once per request.
165 */
166 do_action( 'xspeed_register_modules' );
167
168 Module_Registry::boot_all();
169 }
170
171 /**
172 * Register the Free Modules shipped in this plugin. Add new module
173 * registrations here. Pro plugin hooks the same action separately.
174 */
175 public function register_free_modules(): void {
176 Module_Registry::register( new \XSpeed\Modules\Cache\CacheModule() );
177 Module_Registry::register( new \XSpeed\Modules\Health\HealthModule() );
178 // Settings — owns no settings itself; it's the CLI/MCP surface over
179 // Settings_Manager. Registering it is what makes `xspeed settings`
180 // exist, which is what keeps the curated get_settings/update_settings
181 // tools in the MCP catalog. (#149/#153)
182 Module_Registry::register( new \XSpeed\Modules\Settings\SettingsModule() );
183 // External performance scores (PSI / GTmetrix) — Free, off by
184 // default. Rendered inside the Health host page's PageSpeed tab, so
185 // it has no sidebar row of its own.
186 Module_Registry::register( new \XSpeed\Modules\Score\ScoreModule() );
187 Module_Registry::register( new \XSpeed\Modules\Preloader\PreloaderModule() );
188 Module_Registry::register( new \XSpeed\Modules\Heartbeat\HeartbeatModule() );
189 Module_Registry::register( new \XSpeed\Modules\Minify\MinifyModule() );
190 Module_Registry::register( new \XSpeed\Modules\Gzip\GzipModule() );
191 Module_Registry::register( new \XSpeed\Modules\Lazy\LazyModule() );
192 Module_Registry::register( new \XSpeed\Modules\Bloat\BloatModule() );
193 Module_Registry::register( new \XSpeed\Modules\Database\DatabaseModule() );
194 Module_Registry::register( new \XSpeed\Modules\Cdn\CdnModule() );
195 Module_Registry::register( new \XSpeed\Modules\Cloudflare\CloudflareModule() );
196 Module_Registry::register( new \XSpeed\Modules\ObjectCache\ObjectCacheModule() );
197 Module_Registry::register( new \XSpeed\Modules\BrowserCache\BrowserCacheModule() );
198 // Advanced Cache — a Free container row that gathers the Pro
199 // cache-coverage features (404 / search / feed / REST / rules /
200 // maintenance) into one sidebar sub-item (FBS-83633).
201 Module_Registry::register( new \XSpeed\Modules\CacheCoverage\CacheCoverageModule() );
202 Module_Registry::register( new \XSpeed\Modules\Fonts\FontsModule() );
203 Module_Registry::register( new \XSpeed\Modules\ResourceHints\ResourceHintsModule() );
204 // AI Privacy (GDPR off-switch) ships in Free even though every AI
205 // *feature* is Pro — privacy is a right, not a paid tier. FEATURES.md
206 // §AI row 6 mandates it. Without this registration the module was dead
207 // code: no REST/settings surface, the promised off-switch unreachable
208 // (FBS-83633 Bug 1). It carries its own cli_commands() so it satisfies
209 // the CLI/MCP coverage guard once registered.
210 Module_Registry::register( new \XSpeed\Modules\AIPrivacy\AIPrivacyModule() );
211 // Migration moved Pro → Free: it's an acquisition/onboarding feature
212 // (detect a competing caching plugin, import its settings, switch over),
213 // so it must work without a Pro license. Agency-scale extras (profiles,
214 // bulk multisite, host presets) remain Pro.
215 Module_Registry::register( new \XSpeed\Modules\Migration\MigrationModule() );
216 // Help & Support moved Pro → Free: a ticket link + read-only system
217 // snapshot is onboarding/diagnostics, not a paid value-add, so every
218 // user gets it. The snapshot degrades gracefully without Pro (Pro
219 // version/license fields fall back to defaults via defined()/get_option).
220 Module_Registry::register( new \XSpeed\Modules\Support\SupportModule() );
221 // MCP remote control (AI assistants) — Free. The plugin serves the
222 // MCP protocol at the site's own /xspeed/mcp URL; the only gate is
223 // the per-site connection token an admin mints via Connect. No
224 // license, no hosted infra. See IMPLEMENTATION.md §17.
225 Module_Registry::register( new \XSpeed\Modules\Mcp\McpModule() );
226 }
227
228 /**
229 * Boot the opt-in usage tracker. Registers the cron sender only; the send
230 * itself is consent-gated inside Usage_Tracker. The instance is held so the
231 * onboarding REST handler can flip consent via usage_tracker()->opt_in().
232 */
233 public function start_plugin_tracking(): void {
234 $this->usage_tracker = Usage_Tracker::get_instance(
235 XSPEED_FILE,
236 array(
237 'opt_in' => true,
238 'item_id' => defined( 'XSPEED_INSIGHTS_ITEM_ID' ) ? XSPEED_INSIGHTS_ITEM_ID : false,
239 )
240 );
241 $this->usage_tracker->init();
242 }
243
244 /**
245 * The shared Usage_Tracker singleton (or null if tracking wasn't booted,
246 * e.g. on the activation hook before init() runs).
247 */
248 public function usage_tracker(): ?Usage_Tracker {
249 return $this->usage_tracker;
250 }
251
252 public static function activate() {
253 Settings::set_defaults();
254
255 // Score history table. Also called on admin_init (see init()) because
256 // activation does not fire for a site added to a multisite network
257 // later, nor after an update that ships a new schema version.
258 Score_Store::maybe_install();
259
260 if ( ! file_exists( XSPEED_CACHE_DIR ) ) {
261 wp_mkdir_p( XSPEED_CACHE_DIR );
262 }
263 Cache::write_silence( XSPEED_CACHE_DIR );
264
265 // Caching is only ever ENABLED from the admin UI — see Cache::toggle()
266 // and Rest_Api::toggle_cache(). A fresh install therefore gets no
267 // drop-in and no wp-config.php edit here: cache_enabled is unset, so
268 // the call below is a no-op.
269 //
270 // It is NOT a no-op during an upgrade. WordPress runs an update as
271 // deactivate → wipe files → install → activate, which deletes
272 // advanced-cache.php while cache_enabled stays true. Restoring it
273 // here closes the window in which the site silently serves uncached
274 // (auto_heal() alone only fires on the next wp-admin page load).
275 Cache::restore_dropin_if_enabled();
276
277 // First-run wizard: flag a one-time redirect for the activating user.
278 // Suppressed for bulk activations / already-completed sites in
279 // Onboarding::maybe_redirect().
280 Onboarding::flag_redirect();
281
282 // Propagate activation to every registered Module. Activation
283 // happens after plugins_loaded → modules are already registered.
284 Module_Registry::activate_all();
285 }
286
287 /**
288 * Restore the cache drop-in right after THIS plugin is updated.
289 *
290 * Bound to upgrader_process_complete. Bulk updates, auto-updates and
291 * host-level updaters finish without re-running activate(), so this is
292 * the only hook that repairs the drop-in before the next wp-admin page
293 * load. Narrow by design: bails unless the completed action was a
294 * plugin update whose payload actually includes xspeed.
295 *
296 * @param \WP_Upgrader $upgrader Upgrader instance (unused).
297 * @param array $hook_extra Contextual data about the update.
298 * @return void
299 */
300 public function maybe_restore_after_update( $upgrader, $hook_extra ) {
301 unset( $upgrader );
302
303 if ( ! is_array( $hook_extra ) ) {
304 return;
305 }
306 if ( ! isset( $hook_extra['type'], $hook_extra['action'] ) ) {
307 return;
308 }
309 if ( 'plugin' !== $hook_extra['type'] || 'update' !== $hook_extra['action'] ) {
310 return;
311 }
312
313 // Single update uses 'plugin'; bulk uses 'plugins'.
314 $updated = array();
315 if ( isset( $hook_extra['plugins'] ) && is_array( $hook_extra['plugins'] ) ) {
316 $updated = $hook_extra['plugins'];
317 } elseif ( isset( $hook_extra['plugin'] ) && is_string( $hook_extra['plugin'] ) ) {
318 $updated = array( $hook_extra['plugin'] );
319 }
320
321 $ours = plugin_basename( XSPEED_FILE );
322 if ( ! in_array( $ours, $updated, true ) ) {
323 return;
324 }
325
326 Cache::restore_dropin_if_enabled();
327 }
328
329 public static function deactivate() {
330 // Drop-in + WP_CACHE constant are NOT touched here. WordPress
331 // upgrades run as deactivate → wipe files → install → activate,
332 // so removing those artifacts on every deactivate would silently
333 // disable caching after each plugin update. uninstall.php
334 // handles full teardown when the user actually removes the
335 // plugin; auto_heal() restores state on the next admin_init if
336 // the drop-in or WP_CACHE went missing for any other reason.
337 Cache::purge_all();
338 Minifier::purge_minified();
339 Gzip::apply( false );
340
341 Module_Registry::deactivate_all();
342 }
343 }
344