| @@ -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,19 +84,176 @@ | ||
| 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() ); | |
| 54 | - // Only a genuinely fresh install reaches this branch — the | |
| 55 | - // option survives deactivation, so an upgrade (deactivate → | |
| 56 | - // wipe → install → activate) always finds it present. | |
| 57 | - self::seed_recommended_modules(); | |
| 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; | |
| 58 | 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; | |
| 59 | 156 | } |
| 60 | 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 | + | |
| 61 | 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 | + /** | |
| 62 | 256 | * Settings a fresh install starts with, beyond each module's schema |
| 63 | 257 | * default. Mirrors the wizard's "Balanced" preset — the profile the |
| 64 | 258 | * product already labels "Recommended for most sites". |
| 65 | 259 | * |
| @@ -75,11 +269,16 @@ | ||
| 75 | 269 | * `combine_js`, `delay_js`. Each can break a theme, and a default that |
| 76 | 270 | * breaks the site is worse than a default that is merely slow. They stay |
| 77 | 271 | * opt-in via the wizard's Aggressive preset or the dashboard. |
| 78 | 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 | + * | |
| 79 | 278 | * @return array<string,array<string,bool>> module slug => settings |
| 80 | 279 | */ |
| 81 | - private static function recommended_module_settings(): array { | |
| 280 | + public static function recommended_module_settings(): array { | |
| 82 | 281 | return array( |
| 83 | 282 | // Compression — the single largest byte win, and inert until a |
| 84 | 283 | // server actually supports it (GzipModule writes .htaccess only |
| 85 | 284 | // where supports_htaccess() is true, and emits a snippet |
| @@ -98,8 +297,126 @@ | ||
| 98 | 297 | ); |
| 99 | 298 | } |
| 100 | 299 | |
| 101 | 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 | + /** | |
| 102 | 419 | * Write the recommended defaults for a fresh install, without ever |
| 103 | 420 | * overwriting a value the user has already chosen. |
| 104 | 421 | * |
| 105 | 422 | * Each key is written only when it is absent from stored settings, so |
| @@ -113,16 +430,41 @@ | ||
| 113 | 430 | $stored = is_array( $stored ) ? $stored : array(); |
| 114 | 431 | |
| 115 | 432 | $next = $stored; |
| 116 | 433 | foreach ( $values as $setting => $value ) { |
| 117 | - if ( ! array_key_exists( $setting, $stored ) ) { | |
| 118 | - $next[ $setting ] = $value; | |
| 434 | + if ( array_key_exists( $setting, $stored ) || self::seed_is_refused( $slug, $setting ) ) { | |
| 435 | + continue; | |
| 119 | 436 | } |
| 437 | + $next[ $setting ] = $value; | |
| 120 | 438 | } |
| 121 | 439 | if ( $next !== $stored ) { |
| 122 | 440 | update_option( $key, $next, false ); |
| 123 | 441 | } |
| 124 | 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 ); | |
| 125 | 467 | } |
| 126 | 468 | |
| 127 | 469 | // sanitize_urls() removed — excluded_urls now owned by CacheModule |
| 128 | 470 | // and validated by Settings_Manager's typed schema (list / item_type). |