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-settings.php +415 -3 1.0.31.3.3 View file →
@@ -12,8 +12,45 @@
12 12 class Settings {
13 13
14 14 const OPTION_KEY = 'xspeed_options';
15 15
16 + /**
17 + * Set by a plugin that installs xSpeed on a user's behalf, BEFORE it
18 + * activates it.
19 + *
20 + * A plain option rather than a constant, a filter or a class: the host
21 + * writes it at a moment when no xSpeed code has loaded and none can be
22 + * relied on to exist, so `update_option( 'xspeed_installed_by', 'my-slug' )`
23 + * is the whole contract. It survives into the activation request, which is
24 + * where it is read.
25 + */
26 + const INSTALLED_BY_OPTION = 'xspeed_installed_by';
27 +
28 + /**
29 + * Where the slug lives once activation has consumed it.
30 + *
31 + * The option above is a one-shot TRIGGER, not a record: it changes what
32 + * activation does, so leaving it on disk means the next activation is a
33 + * host install too. A host whose install fails between writing it and
34 + * activating us would otherwise arm a plain user activation to write
35 + * advanced-cache.php and edit wp-config.php — for ever. Activation moves
36 + * the value here and deletes the trigger, so a stale one is spent by the
37 + * first activation that sees it rather than every one after.
38 + */
39 + const INSTALLER_OPTION = 'xspeed_installer';
40 +
41 + /** Which profile a fresh install came up with. Read back by Host::status(). */
42 + const PROFILE_OPTION = 'xspeed_install_profile';
43 +
44 + /** Nothing was decided — the option already existed, so this is not a fresh install. */
45 + const PROFILE_NONE = '';
46 + /** A clear site the user installed themselves: the Balanced set. */
47 + const PROFILE_RECOMMENDED = 'recommended';
48 + /** Something else owns the page cache: everything off. */
49 + const PROFILE_CONFLICT_SAFE = 'conflict-safe';
50 + /** A clear site, installed by another plugin: everything off, page cache on. */
51 + const PROFILE_HOST_PAGE_CACHE = 'host-page-cache';
52 +
16 53 public static function defaults() {
17 54 // Migrated out of this legacy blob (now per-module storage):
18 55 // - minify_html / minify_css / minify_js → xspeed_module_minify
19 56 // - gzip_enabled → xspeed_module_gzip
@@ -47,12 +84,387 @@
47 84 update_option( self::OPTION_KEY, $clean );
48 85 return $clean;
49 86 }
50 87
51 - public static function set_defaults() {
52 - if ( false === get_option( self::OPTION_KEY ) ) {
53 - add_option( self::OPTION_KEY, self::defaults() );
88 + /**
89 + * Decide how a fresh install comes up.
90 + *
91 + * @return string One of the PROFILE_* constants. PROFILE_NONE means this
92 + * was not a fresh install and nothing was decided.
93 + */
94 + public static function set_defaults(): string {
95 + if ( false !== get_option( self::OPTION_KEY ) ) {
96 + return self::PROFILE_NONE;
54 97 }
98 +
99 + add_option( self::OPTION_KEY, self::defaults() );
100 +
101 + /*
102 + * Only a genuinely fresh install reaches this branch — the option
103 + * survives deactivation, so an upgrade (deactivate → wipe → install →
104 + * activate) always finds it present.
105 + *
106 + * Three ways a fresh install can come up.
107 + *
108 + * Something else owns the page cache: everything off. The user
109 + * installed xSpeed beside a cache plugin they are still using, and
110 + * rewriting their markup on activation is not what they asked for.
111 + *
112 + * Nothing owns it and another plugin installed us: everything off
113 + * EXCEPT page caching. That plugin asked the user for site speed and
114 + * installed us to provide it, so the cache is the one thing it may
115 + * assume — and nothing else, because the user never saw our settings
116 + * and did not ask for lazy loading or minification. It is also what
117 + * the copy-vendored Setup wrote by hand before every host install, so
118 + * this is that behaviour moving to the side that owns the settings.
119 + *
120 + * Nothing owns it and the user installed us themselves: the Balanced
121 + * set, and they choose page caching in the wizard.
122 + */
123 + $state = self::page_cache_occupant_state();
124 + if ( null !== $state ) {
125 + $written = self::apply_conflict_safe_profile();
126 +
127 + /**
128 + * Fires when a fresh install came up with everything switched off
129 + * because something else owns the page cache.
130 + *
131 + * The host plugin that installed xSpeed uses this to tell the user
132 + * what happened; nothing here renders a notice on its behalf.
133 + *
134 + * @param array $written Module slug => the fields set to false.
135 + * @param string $state The detector's ownership state.
136 + * @param string $host Whoever claimed the install, or ''.
137 + */
138 + do_action( 'xspeed_conflict_profile_applied', $written, $state, self::installed_by() );
139 + update_option( self::PROFILE_OPTION, self::PROFILE_CONFLICT_SAFE, false );
140 + return self::PROFILE_CONFLICT_SAFE;
141 + }
142 +
143 + if ( '' !== self::installed_by() ) {
144 + // The same sweep the occupied site gets — every Free bool off —
145 + // because "the user did not ask for it" is the same fact in both
146 + // cases. Only what happens to page caching differs, and that is
147 + // Plugin::activate()'s call, not a settings write.
148 + self::apply_conflict_safe_profile();
149 + update_option( self::PROFILE_OPTION, self::PROFILE_HOST_PAGE_CACHE, false );
150 + return self::PROFILE_HOST_PAGE_CACHE;
151 + }
152 +
153 + self::seed_recommended_modules();
154 + update_option( self::PROFILE_OPTION, self::PROFILE_RECOMMENDED, false );
155 + return self::PROFILE_RECOMMENDED;
156 + }
157 +
158 + /** Which profile the fresh install came up with, or '' if we never decided. */
159 + public static function install_profile(): string {
160 + $profile = get_option( self::PROFILE_OPTION, self::PROFILE_NONE );
161 + return is_string( $profile ) ? $profile : self::PROFILE_NONE;
162 + }
163 +
164 + /**
165 + * Who installed xSpeed, or '' when the user did it themselves.
166 + *
167 + * A slug the host plugin chose — 'essential-addons', 'templately'. Not
168 + * validated against a list: this is provenance for a message, never an
169 + * authorization, and a host we have never heard of is still allowed to
170 + * say who it is.
171 + */
172 + public static function installed_by(): string {
173 + $recorded = get_option( self::INSTALLER_OPTION, '' );
174 + if ( is_string( $recorded ) && '' !== $recorded ) {
175 + return sanitize_key( $recorded );
176 + }
177 + $slug = get_option( self::INSTALLED_BY_OPTION, '' );
178 + return is_string( $slug ) ? sanitize_key( $slug ) : '';
179 + }
180 +
181 + /**
182 + * Spend the trigger: record who installed us, and clear the arming option.
183 + *
184 + * Called once, by Plugin::activate(), after the profile is decided — the
185 + * decision reads the trigger, so it cannot be cleared before then.
186 + *
187 + * @param string $profile The Settings::PROFILE_* this activation chose.
188 + */
189 + public static function consume_installed_by( string $profile ): void {
190 + $slug = get_option( self::INSTALLED_BY_OPTION, '' );
191 + $slug = is_string( $slug ) ? sanitize_key( $slug ) : '';
192 +
193 + if ( '' !== $slug && self::PROFILE_NONE !== $profile ) {
194 + // A fresh install someone claimed. Worth keeping: it is what
195 + // Host::status() reports and what support reads to know whether a
196 + // site's settings were chosen by a person.
197 + update_option( self::INSTALLER_OPTION, $slug, false );
198 + }
199 +
200 + delete_option( self::INSTALLED_BY_OPTION );
201 + }
202 +
203 + /**
204 + * Does something other than xSpeed own this site's page cache?
205 + *
206 + * Deliberately NOT `! can_acquire()`. That helper refuses on anything it
207 + * cannot verify, which is right for a write that could destroy another
208 + * plugin's drop-in — but wrong for choosing a settings profile. An
209 + * unreadable wp-config.php would then hand an ordinary site an xSpeed with
210 + * every optimisation off and nothing on screen to explain it.
211 + *
212 + * So this asks the narrower question: is there positive evidence of
213 + * somebody else? `unavailable` is not evidence, and neither is a residual
214 + * artifact from a plugin that is gone. The write paths keep failing closed
215 + * on both.
216 + *
217 + * `unknown-occupied` counts only when a drop-in is actually there. The
218 + * same state also covers `WP_CACHE` left true in wp-config.php with no
219 + * drop-in at all — a line a removed cache plugin forgot — and nothing is
220 + * serving cached pages then. Treating that as an occupant gave a clean
221 + * site an xSpeed with every switch off while the wizard said "No other
222 + * caching plugins detected" (PR #295 review). The write path still refuses
223 + * it, because it cannot know what set the constant.
224 + */
225 + private static function page_cache_occupant_state(): ?string {
226 + if ( ! class_exists( __NAMESPACE__ . '\\Page_Cache_Detector' ) ) {
227 + return null;
228 + }
229 +
230 + $report = Page_Cache_Detector::inspect();
231 + $state = Page_Cache_Detector::classify( $report )['state'];
232 +
233 + /*
234 + * A drop-in file is present either way here. ABANDONED means we may
235 + * TAKE it (#391) -- it does not mean the site is a clear field, so a
236 + * fresh install still comes up conservative rather than assuming it
237 + * has the machine to itself.
238 + */
239 + if ( Page_Cache_Detector::STATE_UNKNOWN_OCCUPIED === $state
240 + || Page_Cache_Detector::STATE_ABANDONED === $state ) {
241 + return ! empty( $report['dropin']['exists'] ) ? $state : null;
242 + }
243 +
244 + return in_array(
245 + $state,
246 + array(
247 + Page_Cache_Detector::STATE_FOREIGN_LIVE,
248 + Page_Cache_Detector::STATE_POSSIBLE_LIVE,
249 + Page_Cache_Detector::STATE_CONTESTED,
250 + ),
251 + true
252 + ) ? $state : null;
253 + }
254 +
255 + /**
256 + * Settings a fresh install starts with, beyond each module's schema
257 + * default. Mirrors the wizard's "Balanced" preset — the profile the
258 + * product already labels "Recommended for most sites".
259 + *
260 + * Why this exists: the wizard is skippable, and a WP-CLI or bulk
261 + * activation never shows it at all. Those users fell through to the raw
262 + * schema defaults, which are more conservative than what we recommend to
263 + * the very same site — so whether a site compressed its responses came
264 + * down to whether someone clicked through a wizard. Measured on a real
265 + * install: gzip off, browser-cache headers off, no minification, while
266 + * lazy-load and resource hints (schema default `true`) were on.
267 + *
268 + * Deliberately excluded: `minify_js`, `defer_js`, `combine_css`,
269 + * `combine_js`, `delay_js`. Each can break a theme, and a default that
270 + * breaks the site is worse than a default that is merely slow. They stay
271 + * opt-in via the wizard's Aggressive preset or the dashboard.
272 + *
273 + * Public because the portable Setup copy mirrors it: these four are
274 + * written by activation rather than declared as schema defaults, so
275 + * scanning the schemas alone misses the four settings a user is most
276 + * likely to notice.
277 + *
278 + * @return array<string,array<string,bool>> module slug => settings
279 + */
280 + public static function recommended_module_settings(): array {
281 + return array(
282 + // Compression — the single largest byte win, and inert until a
283 + // server actually supports it (GzipModule writes .htaccess only
284 + // where supports_htaccess() is true, and emits a snippet
285 + // otherwise).
286 + 'gzip' => array( 'gzip_enabled' => true ),
287 + // Far-future caching for static assets. Only ever affects
288 + // css/js/images/fonts; HTML keeps its own short TTL.
289 + 'browser-cache' => array( 'enabled' => true ),
290 + // HTML + CSS minification. Both are whitespace/comment-level
291 + // and do not reorder or combine anything, so they carry none of
292 + // the cascade risk that combine_css does.
293 + 'minify' => array(
294 + 'minify_html' => true,
295 + 'minify_css' => true,
296 + ),
297 + );
298 + }
299 +
300 + /**
301 + * The settings a site gets when something else owns the page cache.
302 + *
303 + * Refusing the page cache is only half of "install beside a caching plugin
304 + * and do nothing". Two things put the other half back on:
305 + *
306 + * - recommended_module_settings() above, which activation writes TRUE;
307 + * - an ABSENT option row, which is not off. Settings_Manager merges each
308 + * module's schema defaults, so `lazy` with no row reads back with four
309 + * switches ON, and resource-hints, fonts and preloader likewise.
310 + *
311 + * So this is composed from the live registry rather than a hand-kept list:
312 + * every bool of every registered FREE module, minus what that module names
313 + * in Module::conflict_safe_exempt(). Every bool, not only the ones
314 + * defaulting on — a default that moves in a later release must not switch
315 + * something on behind the user.
316 + *
317 + * Pro is out of scope: its modules sit behind a licence and their own
318 + * enable switches, and writing rows for a plugin that may never be
319 + * installed would record decisions on someone else's behalf.
320 + *
321 + * @return array<string,array<string,bool>> module slug => field => false
322 + */
323 + public static function conflict_safe_profile(): array {
324 + $out = array();
325 +
326 + foreach ( self::registered_modules() as $slug => $module ) {
327 + if ( Module::TIER_FREE !== $module::TIER ) {
328 + continue;
329 + }
330 +
331 + $exempt = $module->conflict_safe_exempt();
332 +
333 + foreach ( $module->settings_schema() as $field => $spec ) {
334 + if ( 'bool' !== ( $spec['type'] ?? '' ) || in_array( $field, $exempt, true ) ) {
335 + continue;
336 + }
337 + $out[ $slug ][ $field ] = false;
338 + }
339 + }
340 +
341 + // The sweep above already covers these as ordinary bools. Naming them
342 + // again keeps the two lists in step if the recommended profile ever
343 + // grows a field no schema declares.
344 + foreach ( self::recommended_module_settings() as $slug => $values ) {
345 + foreach ( array_keys( $values ) as $field ) {
346 + $out[ $slug ][ $field ] = false;
347 + }
348 + }
349 +
350 + ksort( $out );
351 + foreach ( $out as $slug => $values ) {
352 + ksort( $values );
353 + $out[ $slug ] = $values;
354 + }
355 +
356 + return $out;
357 + }
358 +
359 + /**
360 + * Write that profile, preserving every key it does not name.
361 + *
362 + * Merged rather than replaced: option rows outlive plugin deletion, so a
363 + * site can still carry lazy-load exclusions or a preloader sitemap from an
364 + * earlier install, and there is no reason to destroy those to turn
365 + * switches off.
366 + *
367 + * @return array<string,array<string,bool>> What was written, by slug.
368 + */
369 + public static function apply_conflict_safe_profile(): array {
370 + $written = array();
371 +
372 + foreach ( self::conflict_safe_profile() as $slug => $values ) {
373 + $option = 'xspeed_module_' . $slug;
374 + $stored = get_option( $option, null );
375 + $stored = is_array( $stored ) ? $stored : array();
376 + $next = array_merge( $stored, $values );
377 +
378 + if ( $next === $stored ) {
379 + continue;
380 + }
381 +
382 + update_option( $option, $next, false );
383 + $written[ $slug ] = $values;
384 + }
385 +
386 + return $written;
387 + }
388 +
389 + /**
390 + * Every registered module, registering them first when the registry is
391 + * empty.
392 + *
393 + * On the activation request nothing has registered yet, and an empty
394 + * registry would compose an empty profile — a silent pass that leaves
395 + * every switch on. Firing `xspeed_register_modules` from here was not
396 + * enough: init() never ran on that request, so Free's own callback was
397 + * not on the action and the firing registered nothing. Plugin owns the
398 + * repair — see Plugin::ensure_modules_registered().
399 + *
400 + * That repair fires the action once per request. Reaching this before
401 + * plugins_loaded(20) from anywhere other than activation would register
402 + * Free and lock every add-on out for the request; activation is the only
403 + * caller, and it must stay that way.
404 + *
405 + * @return array<string,Module>
406 + */
407 + private static function registered_modules(): array {
408 + $modules = Module_Registry::all();
409 + if ( ! empty( $modules ) ) {
410 + return $modules;
411 + }
412 +
413 + Plugin::instance()->ensure_modules_registered();
414 +
415 + return Module_Registry::all();
416 + }
417 +
418 + /**
419 + * Write the recommended defaults for a fresh install, without ever
420 + * overwriting a value the user has already chosen.
421 + *
422 + * Each key is written only when it is absent from stored settings, so
423 + * this stays safe if it is ever reached on a site that has some — but
424 + * not all — module options saved.
425 + */
426 + private static function seed_recommended_modules(): void {
427 + foreach ( self::recommended_module_settings() as $slug => $values ) {
428 + $key = 'xspeed_module_' . $slug;
429 + $stored = get_option( $key, null );
430 + $stored = is_array( $stored ) ? $stored : array();
431 +
432 + $next = $stored;
433 + foreach ( $values as $setting => $value ) {
434 + if ( array_key_exists( $setting, $stored ) || self::seed_is_refused( $slug, $setting ) ) {
435 + continue;
436 + }
437 + $next[ $setting ] = $value;
438 + }
439 + if ( $next !== $stored ) {
440 + update_option( $key, $next, false );
441 + }
442 + }
443 + }
444 +
445 + /**
446 + * Would the dashboard refuse this setting right now?
447 + *
448 + * A recommended seed is a switch the user never touched, so it must not
449 + * turn on what the conflict matrix would refuse to let them turn on: a
450 + * fresh install beside Autoptimize seeded HTML and CSS minification while
451 + * the same request could already say "Autoptimize is active and handles
452 + * the same feature" (PR #295 review). Only settings with a feature key in
453 + * the matrix are checked; compression and browser caching have none.
454 + */
455 + private static function seed_is_refused( string $slug, string $setting ): bool {
456 + $keys = array(
457 + 'minify' => array(
458 + 'minify_html' => 'minify.html',
459 + 'minify_css' => 'minify.css',
460 + ),
461 + );
462 + $feature = $keys[ $slug ][ $setting ] ?? null;
463 + if ( null === $feature || ! class_exists( __NAMESPACE__ . '\\Conflict_Registry' ) ) {
464 + return false;
465 + }
466 + return Conflict_Registry::STRATEGY_REFUSE === Conflict_Registry::strategy_for( $feature );
55 467 }
56 468
57 469 // sanitize_urls() removed — excluded_urls now owned by CacheModule
58 470 // and validated by Settings_Manager's typed schema (list / item_type).