PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.3
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.3
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
← All changes | includes/class-plugin.php +172 -13 1.1.81.3.3 View file →
@@ -76,13 +76,34 @@
76 76 // Per-post cache rules (Phase 3.4) — registers postmeta with
77 77 // REST + meta box on edit screens.
78 78 Cache_Meta_Box::boot();
79 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 +
80 84 // Phase 0 architecture — managers + Free modules. v1 services
81 85 // (Cache/Minifier/Gzip) are NOT yet Modules; they'll be refactored
82 86 // in a follow-up PR with parity tests.
83 87 Conflict_Registry::boot();
84 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 +
85 106 // Register Free modules via the same action xspeed-pro uses, so
86 107 // the bootstrap path is symmetric across tiers.
87 108 add_action( 'xspeed_register_modules', array( $this, 'register_free_modules' ) );
88 109
@@ -150,8 +171,63 @@
150 171 * Runs at plugins_loaded(20) so every add-on that hooks at any
151 172 * priority < 20 has time to register first.
152 173 */
153 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 +
154 230 /**
155 231 * Action: xspeed_register_modules
156 232 *
157 233 * Free modules register at priority 10; xspeed-pro at priority
@@ -158,10 +234,8 @@
158 234 * 20; site code can hook in between to inject custom modules.
159 235 * Fires exactly once per request.
160 236 */
161 237 do_action( 'xspeed_register_modules' );
162 -
163 - Module_Registry::boot_all();
164 238 }
165 239
166 240 /**
167 241 * Register the Free Modules shipped in this plugin. Add new module
@@ -212,8 +286,12 @@
212 286 // snapshot is onboarding/diagnostics, not a paid value-add, so every
213 287 // user gets it. The snapshot degrades gracefully without Pro (Pro
214 288 // version/license fields fall back to defaults via defined()/get_option).
215 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() );
216 294 // MCP remote control (AI assistants) — Free. The plugin serves the
217 295 // MCP protocol at the site's own /xspeed/mcp URL; the only gate is
218 296 // the per-site connection token an admin mints via Connect. No
219 297 // license, no hosted infra. See IMPLEMENTATION.md §17.
@@ -244,10 +322,16 @@
244 322 return $this->usage_tracker;
245 323 }
246 324
247 325 public static function activate() {
248 - 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();
249 331
332 + $profile = Settings::set_defaults();
333 +
250 334 // Score history table. Also called on admin_init (see init()) because
251 335 // activation does not fire for a site added to a multisite network
252 336 // later, nor after an update that ships a new schema version.
253 337 Score_Store::maybe_install();
@@ -256,12 +340,62 @@
256 340 wp_mkdir_p( XSPEED_CACHE_DIR );
257 341 }
258 342 Cache::write_silence( XSPEED_CACHE_DIR );
259 343
260 - // Caching is only ever ENABLED from the admin UI — see Cache::toggle()
261 - // and Rest_Api::toggle_cache(). A fresh install therefore gets no
262 - // drop-in and no wp-config.php edit here: cache_enabled is unset, so
263 - // the call below is a no-op.
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.
264 398 //
265 399 // It is NOT a no-op during an upgrade. WordPress runs an update as
266 400 // deactivate → wipe files → install → activate, which deletes
267 401 // advanced-cache.php while cache_enabled stays true. Restoring it
@@ -268,16 +402,41 @@
268 402 // here closes the window in which the site silently serves uncached
269 403 // (auto_heal() alone only fires on the next wp-admin page load).
270 404 Cache::restore_dropin_if_enabled();
271 405
272 - // First-run wizard: flag a one-time redirect for the activating user.
273 - // Suppressed for bulk activations / already-completed sites in
274 - // Onboarding::maybe_redirect().
275 - Onboarding::flag_redirect();
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 + }
276 419
277 - // Propagate activation to every registered Module. Activation
278 - // happens after plugins_loaded → modules are already registered.
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.
279 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 );
280 439 }
281 440
282 441 /**
283 442 * Restore the cache drop-in right after THIS plugin is updated.