| @@ -1,58 +1,77 @@ | ||
| 1 | 1 | <?php |
| 2 | 2 | |
| 3 | 3 | namespace Templately\Utils; |
| 4 | 4 | |
| 5 | -use WPDeveloper\PageCacheSafety\Detector; | |
| 6 | - | |
| 7 | 5 | /** |
| 8 | - * The caching solution Templately offers on the import dependency screen. | |
| 6 | + * The caching plugin Templately offers on the import dependency screen. | |
| 9 | 7 | * |
| 10 | - * Everything vendor-specific about the recommended plugin — its file, slug, display details, | |
| 11 | - * option names and the settings it should come up with — is confined to the constants at the | |
| 12 | - * top of this class. Nothing else in the plugin names it. Swapping the recommendation for a | |
| 13 | - * different caching plugin is an edit to this block and its INITIAL_SETTINGS map; no caller | |
| 14 | - * changes. | |
| 8 | + * Templately owns the OFFER — whether to put a row on the screen, and how often. | |
| 9 | + * It does not own what the plugin comes up as. Claiming the install is one option | |
| 10 | + * write immediately before activation: | |
| 15 | 11 | * |
| 16 | - * Two jobs: decide whether to offer a caching solution at all, and — only once the user has | |
| 17 | - * left the row ticked and it has actually installed — leave it configured the way the offer | |
| 18 | - * implied. | |
| 12 | + * update_option( 'xspeed_installed_by', 'templately' ); | |
| 13 | + * | |
| 14 | + * xSpeed reads that on its own activation and picks its own profile — every Free | |
| 15 | + * feature off, page caching on when nothing else owns it and refused when something | |
| 16 | + * does — and skips its own setup wizard so it does not interrupt ours. No settings | |
| 17 | + * writes from here, no enable call, no rollback path. | |
| 18 | + * | |
| 19 | + * A plain option rather than a constant or a filter because it is written at a moment | |
| 20 | + * when none of xSpeed's code has loaded and none can be relied on to exist. | |
| 21 | + * | |
| 22 | + * See docs/guides/installing-from-another-plugin.md in the xSpeed repo. This class | |
| 23 | + * replaced a copy-vendored detector that decided the cache question here and wrote | |
| 24 | + * xSpeed's settings by hand; that directory is deleted upstream, not versioned up. | |
| 19 | 25 | */ |
| 20 | 26 | class Caching { |
| 21 | 27 | |
| 22 | 28 | /** |
| 23 | - * The recommended plugin. The display fields are what the user sees on the dependency | |
| 24 | - * row and are deliberately the plugin's real name and listing, so the row says what it | |
| 25 | - * installs. | |
| 29 | + * The plugin we offer, and the Pro build that ships beside it. | |
| 30 | + * | |
| 31 | + * Named here rather than read from xSpeed because every question below has to be | |
| 32 | + * answerable on a site where xSpeed does not exist. `XSpeed\Host` is the supported | |
| 33 | + * API for everything else, but it only exists once xSpeed is active. | |
| 26 | 34 | */ |
| 27 | - const PLUGIN_FILE = 'xspeed/xspeed.php'; | |
| 28 | - const PLUGIN_SLUG = 'xspeed'; | |
| 29 | - const PLUGIN_NAME = 'xSpeed Cache'; | |
| 30 | - const PLUGIN_ICON = 'https://ps.w.org/xspeed/assets/icon-256x256.png'; | |
| 31 | - const PLUGIN_LINK = 'https://wordpress.org/plugins/xspeed/'; | |
| 35 | + const PLUGIN_FILE = 'xspeed/xspeed.php'; | |
| 36 | + const PRO_PLUGIN_FILE = 'xspeed-pro/xspeed-pro.php'; | |
| 37 | + const PLUGIN_SLUG = 'xspeed'; | |
| 38 | + const PLUGIN_NAME = 'xSpeed Cache'; | |
| 39 | + const PLUGIN_ICON = 'https://ps.w.org/xspeed/assets/icon-256x256.png'; | |
| 40 | + const PLUGIN_LINK = 'https://wordpress.org/plugins/xspeed/'; | |
| 32 | 41 | |
| 33 | 42 | /** |
| 34 | - * Where the recommended plugin keeps its settings. | |
| 43 | + * The one-shot trigger that tells xSpeed a host installed it. | |
| 44 | + * | |
| 45 | + * A trigger, not a record: activation spends it, moving the value where | |
| 46 | + * Host::installed_by() reads it afterwards. | |
| 35 | 47 | */ |
| 48 | + const INSTALLED_BY_OPTION = 'xspeed_installed_by'; | |
| 49 | + | |
| 50 | + /** Our slug, as xSpeed records it. */ | |
| 51 | + const INSTALLER_SLUG = 'templately'; | |
| 52 | + | |
| 53 | + /** | |
| 54 | + * Where xSpeed keeps its settings. Read only to answer "has this site had xSpeed | |
| 55 | + * before" — an option row outlives plugin deletion, and it is still the user's | |
| 56 | + * answer about a plugin we would otherwise re-offer. | |
| 57 | + */ | |
| 36 | 58 | const SETTINGS_OPTION = 'xspeed_options'; |
| 37 | - const MODULE_PREFIX = 'xspeed_module_'; | |
| 38 | 59 | |
| 39 | 60 | /** |
| 40 | - * The option its activation sets to force a first-run setup wizard redirect. Written | |
| 41 | - * unconditionally on activation and consumed on the next admin_init. | |
| 61 | + * What xSpeed needs to run. Templately's own floor is lower, and an install that | |
| 62 | + * cannot activate is worse than an offer never made. | |
| 42 | 63 | */ |
| 43 | - const WIZARD_REDIRECT_OPTION = 'xspeed_redirect_to_onboarding'; | |
| 64 | + const REQUIRES_WP = '6.0'; | |
| 65 | + const REQUIRES_PHP = '7.4'; | |
| 44 | 66 | |
| 45 | 67 | /** |
| 46 | 68 | * When the suggestion was first put in front of this site, as a Unix timestamp. |
| 47 | 69 | * |
| 48 | - * The offer is made once. A user who ticked it has the plugin; a user who unticked it | |
| 49 | - * said no, and asking again on their next import is nagging. Delete this option to offer | |
| 50 | - * it again — that is the supported reset, for support staff and for testing. | |
| 51 | - * | |
| 52 | - * Site-scoped rather than per-user: whether this site wants a page cache is a fact about | |
| 53 | - * the site, and a second administrator should not be re-asked a question the first one | |
| 54 | - * already answered. | |
| 70 | + * Site-scoped rather than per-user: whether this site wants a page cache is a fact | |
| 71 | + * about the site, and a second administrator should not be re-asked a question the | |
| 72 | + * first one already answered. Delete this option to offer it again — that is the | |
| 73 | + * supported reset, for support staff and for testing. | |
| 55 | 74 | */ |
| 56 | 75 | const OFFER_SHOWN_OPTION = 'templately_caching_offer_shown'; |
| 57 | 76 | |
| 58 | 77 | /** |
| @@ -57,345 +76,324 @@ | ||
| 57 | 76 | |
| 58 | 77 | /** |
| 59 | 78 | * How long after the first showing the row keeps appearing. |
| 60 | 79 | * |
| 61 | - * Without this, "once" would mean once per HTTP request rather than once per user. The | |
| 62 | - * dependency step re-fetches whenever the wizard is reopened or the user steps back and | |
| 63 | - * forward, so a flag set on first render would make the row vanish underneath someone | |
| 64 | - * who was still deciding about it. Inside the window the answer is unchanged; after it, | |
| 65 | - * the offer is spent. | |
| 80 | + * The dependency step re-fetches whenever the wizard is reopened or the user steps | |
| 81 | + * back and forward, so a flag set on first render would make the row vanish | |
| 82 | + * underneath someone still deciding about it. | |
| 66 | 83 | */ |
| 67 | 84 | const OFFER_GRACE = 1800; |
| 68 | 85 | |
| 69 | 86 | /** |
| 70 | - * Version floors, pinned rather than read from plugins_api(). | |
| 87 | + * How long before a declined offer may be made again. | |
| 71 | 88 | * |
| 72 | - * The recommended plugin asks for more than Templately advertises (readme.txt: | |
| 73 | - * WordPress 5.0, PHP 7.2), and Installer::check_compatibility() turns a mismatch into a | |
| 74 | - * hard failure of the import's plugin step — so a pre-ticked row on an older site is a | |
| 75 | - * broken import, not a declined suggestion. Fetching the real numbers per request would | |
| 76 | - * mean a network call on a screen the user is already waiting on. The cost of pinning | |
| 77 | - * them is that a floor change needs a matching edit here. | |
| 89 | + * The ONLY suppression that expires, and it only ever applies to a decline. Every | |
| 90 | + * other reason to withhold the row is permanent by construction and outlives this | |
| 91 | + * window: the plugin is on disk (free or Pro), its settings are here, or the site | |
| 92 | + * cannot run it. Accepting installs the plugin, so an accepted offer is never made | |
| 93 | + * twice either. | |
| 78 | 94 | */ |
| 79 | - const REQUIRES_WP = '6.0'; | |
| 80 | - const REQUIRES_PHP = '7.4'; | |
| 95 | + const OFFER_COOLDOWN = MONTH_IN_SECONDS; | |
| 81 | 96 | |
| 82 | 97 | /** |
| 83 | - * The settings a Templately-installed caching plugin should come up with. | |
| 98 | + * The cross-plugin record of what this site already decided about xSpeed. | |
| 84 | 99 | * |
| 85 | - * The whole desired end state, not a list of things to switch off, because pre-writing | |
| 86 | - * SETTINGS_OPTION suppresses whatever first-run path the plugin otherwise uses to seed | |
| 87 | - * these. Browser caching defaults to `false` in its own schema and was only ever on | |
| 88 | - * because of that path — so leaving it out here silently turned it off. Anything wanted | |
| 89 | - * on has to be said out loud. | |
| 100 | + * Shared, not ours: EmbedPress, Essential Addons and Templately all offer the same | |
| 101 | + * plugin, and each one keeping its own answer means a user who says no three times | |
| 102 | + * has said no once as far as any of them can tell. Namespaced `wpdeveloper_` after | |
| 103 | + * `wpdeveloper_plugins_data`, the convention the shared notice library already | |
| 104 | + * uses, and deliberately OUTSIDE the `xspeed_` namespace — xSpeed's uninstall.php | |
| 105 | + * deletes every option it owns, so a decision stored there would be erased by the | |
| 106 | + * very act it is meant to remember. | |
| 90 | 107 | * |
| 91 | - * Page caching is the only thing Templately switches on. The row the user ticked offered | |
| 92 | - * a page cache, so that is what they get — browser caching, minification, lazy loading, | |
| 93 | - * resource hints and the rest all stay off, whatever the plugin would have enabled for | |
| 94 | - * itself. | |
| 108 | + * Shape, all keys optional to a reader: | |
| 95 | 109 | * |
| 96 | - * Every module is written explicitly, including the ones that would be off anyway. | |
| 97 | - * Measured against 1.2.0 with no rows written at all: lazy, resource-hints, fonts and | |
| 98 | - * preloader come up ON — their schema defaults are true — so writing those is what turns | |
| 99 | - * them off. gzip, minify and browser-cache come up off, but only because pre-writing | |
| 100 | - * SETTINGS_OPTION happens to suppress the plugin's first-run seeding; a normal install | |
| 101 | - * brings all three up ON. Leaning on that side effect is what silently re-enabled browser | |
| 102 | - * caching once already, so the redundant rows stay. | |
| 110 | + * [ | |
| 111 | + * 'offered_by' => 'templately', // slug of whoever last put the offer up | |
| 112 | + * 'offered_at' => 1757462400, // when it went up | |
| 113 | + * 'outcome' => 'offered', // see below | |
| 114 | + * 'outcome_at' => 1757462400, | |
| 115 | + * ] | |
| 103 | 116 | * |
| 104 | - * Deliberately absent, and left exactly as the plugin sets them: its GDPR consent | |
| 105 | - * requirement, its Cloudflare purge behaviour, and its object-cache flag. None is an | |
| 106 | - * optimisation, and for the first of them off would be the wrong answer. A blanket | |
| 107 | - * "everything except browser caching" rule would have caught all three, and would | |
| 108 | - * silently swallow whatever module the plugin ships next. The cost of naming them is | |
| 109 | - * that a future opt-in-by-default optimisation has to be added here by hand. | |
| 117 | + * `outcome` is one of: | |
| 110 | 118 | * |
| 111 | - * Keys are module slugs, values the fields written to MODULE_PREFIX . <slug>. Verified | |
| 112 | - * against the recommended plugin at 1.2.0. | |
| 119 | + * - `offered` — the row went out; nobody has answered yet. | |
| 120 | + * - `accepted` — a host installed it. Written immediately before activation. | |
| 121 | + * - `declined` — the user said no in a way that was meant to stick (a dismissed | |
| 122 | + * promo, a "never show again"). Templately never writes it: its own | |
| 123 | + * decline is the local timer above, which expires. A host with a | |
| 124 | + * permanent opt-out control should write it there. | |
| 125 | + * - `removed` — accepted, then taken off the site. NOT stored: it is derived, so | |
| 126 | + * a deletion performed outside any of our code is still seen. The | |
| 127 | + * name exists so a reader can talk about the state. | |
| 128 | + * | |
| 129 | + * Terminal outcomes are terminal. Nothing here re-offers past one. | |
| 130 | + * | |
| 131 | + * @see docs/guides/installing-from-another-plugin.md in the xSpeed repo. | |
| 113 | 132 | */ |
| 114 | - const INITIAL_SETTINGS = array( | |
| 115 | - 'browser-cache' => array( | |
| 116 | - 'enabled' => false, | |
| 117 | - ), | |
| 118 | - 'gzip' => array( | |
| 119 | - 'gzip_enabled' => false, | |
| 120 | - ), | |
| 121 | - 'minify' => array( | |
| 122 | - 'minify_html' => false, | |
| 123 | - 'minify_css' => false, | |
| 124 | - ), | |
| 125 | - 'lazy' => array( | |
| 126 | - 'lazy_images' => false, | |
| 127 | - 'lazy_iframes' => false, | |
| 128 | - 'lazy_videos' => false, | |
| 129 | - 'add_missing_dimensions' => false, | |
| 130 | - ), | |
| 131 | - 'resource-hints' => array( | |
| 132 | - 'enabled' => false, | |
| 133 | - 'lcp_preload' => false, | |
| 134 | - 'preconnect' => false, | |
| 135 | - ), | |
| 136 | - 'fonts' => array( | |
| 137 | - 'font_display_swap' => false, | |
| 138 | - ), | |
| 139 | - 'preloader' => array( | |
| 140 | - 'warm_on_publish' => false, | |
| 141 | - ), | |
| 142 | - ); | |
| 133 | + const OFFER_RECORD_OPTION = 'wpdeveloper_xspeed_offer'; | |
| 143 | 134 | |
| 144 | 135 | /** |
| 145 | - * Present on disk at all, active or not. | |
| 136 | + * On disk at all, active or not — free or Pro. | |
| 137 | + * | |
| 138 | + * Presence, not activation. A site that has it has decided about it, including a | |
| 139 | + * user who installed it and switched it off, and re-offering that is nagging. | |
| 140 | + * Offering Free to a site running Pro would be worse still: a downgrade. | |
| 146 | 141 | */ |
| 147 | 142 | public static function is_installed(): bool { |
| 148 | - return isset( Helper::get_plugins()[ self::PLUGIN_FILE ] ); | |
| 143 | + $plugins = Helper::get_plugins(); | |
| 144 | + | |
| 145 | + return isset( $plugins[ self::PLUGIN_FILE ] ) || isset( $plugins[ self::PRO_PLUGIN_FILE ] ); | |
| 149 | 146 | } |
| 150 | 147 | |
| 151 | - public static function is_active(): bool { | |
| 152 | - return Helper::is_plugin_active( self::PLUGIN_FILE ); | |
| 148 | + /** | |
| 149 | + * Has xSpeed ever run here? Its settings row survives deactivation and deletion. | |
| 150 | + */ | |
| 151 | + public static function has_settings(): bool { | |
| 152 | + return false !== get_option( self::SETTINGS_OPTION, false ); | |
| 153 | 153 | } |
| 154 | 154 | |
| 155 | + public static function is_supported(): bool { | |
| 156 | + global $wp_version; | |
| 157 | + | |
| 158 | + return version_compare( (string) $wp_version, self::REQUIRES_WP, '>=' ) | |
| 159 | + && version_compare( PHP_VERSION, self::REQUIRES_PHP, '>=' ); | |
| 160 | + } | |
| 161 | + | |
| 155 | 162 | /** |
| 156 | - * Whether the suggestion has already had its turn, grace window elapsed. | |
| 163 | + * Whether the suggestion has already had its turn. | |
| 164 | + * | |
| 165 | + * A window rather than a permanent flag. Inside OFFER_GRACE the row keeps showing. | |
| 166 | + * Past OFFER_COOLDOWN the answer has aged out and may be asked again. Between the | |
| 167 | + * two, it is spent. | |
| 157 | 168 | */ |
| 158 | 169 | public static function has_been_offered(): bool { |
| 159 | 170 | $shown = (int) get_option( self::OFFER_SHOWN_OPTION, 0 ); |
| 160 | 171 | |
| 161 | - return $shown > 0 && ( time() - $shown ) > self::OFFER_GRACE; | |
| 172 | + if ( $shown <= 0 ) { | |
| 173 | + return false; | |
| 174 | + } | |
| 175 | + | |
| 176 | + $age = time() - $shown; | |
| 177 | + | |
| 178 | + return $age > self::OFFER_GRACE && $age < self::OFFER_COOLDOWN; | |
| 162 | 179 | } |
| 163 | 180 | |
| 164 | 181 | /** |
| 165 | - * Record that the row went out. First writing wins, so the grace window is measured from | |
| 166 | - * the first showing rather than being pushed forward by every re-render. | |
| 182 | + * Record that the row went out. | |
| 183 | + * | |
| 184 | + * Re-arms only once the previous showing has aged out. Rewriting on every re-render | |
| 185 | + * would mean the offer never expires, and the wizard re-fetches this step often. | |
| 167 | 186 | */ |
| 168 | 187 | public static function mark_offered() { |
| 169 | - if ( ! get_option( self::OFFER_SHOWN_OPTION ) ) { | |
| 188 | + $shown = (int) get_option( self::OFFER_SHOWN_OPTION, 0 ); | |
| 189 | + | |
| 190 | + if ( $shown <= 0 || ( time() - $shown ) >= self::OFFER_COOLDOWN ) { | |
| 170 | 191 | update_option( self::OFFER_SHOWN_OPTION, time(), false ); |
| 192 | + self::record_offer(); | |
| 193 | + | |
| 194 | + return; | |
| 171 | 195 | } |
| 196 | + | |
| 197 | + // The row is up but the clock is already running, so the shared record has | |
| 198 | + // nothing new to learn — except on a site that was mid-window when this | |
| 199 | + // release landed, where it does not exist yet. Writing it on every fetch | |
| 200 | + // would stamp `offered_at` with the current second forever, and a sibling | |
| 201 | + // pacing itself off that field would never see the offer age out. | |
| 202 | + if ( '' === self::outcome() ) { | |
| 203 | + self::record_offer(); | |
| 204 | + } | |
| 172 | 205 | } |
| 173 | 206 | |
| 174 | 207 | /** |
| 175 | - * The dependency row, in the same shape as every other entry on that screen. | |
| 176 | - * | |
| 177 | - * `installed` is always false and not worth deriving: the offer is withheld outright | |
| 178 | - * when the plugin is on disk at all, so anything reaching here is a fresh install. false | |
| 179 | - * is also what keeps the checkbox enabled, which is the point of offering it. `mustHave` | |
| 180 | - * is omitted — this is a suggestion, not a requirement. | |
| 208 | + * The shared record, always an array so callers can read it without guarding. | |
| 181 | 209 | */ |
| 182 | - public static function dependency_entry(): array { | |
| 183 | - return array( | |
| 184 | - 'name' => self::PLUGIN_NAME, | |
| 185 | - 'icon' => self::PLUGIN_ICON, | |
| 186 | - 'plugin_file' => self::PLUGIN_FILE, | |
| 187 | - 'plugin_original_slug' => self::PLUGIN_SLUG, | |
| 188 | - 'is_pro' => false, | |
| 189 | - 'installed' => false, | |
| 190 | - 'link' => self::PLUGIN_LINK, | |
| 191 | - ); | |
| 210 | + public static function offer_record(): array { | |
| 211 | + $record = get_option( self::OFFER_RECORD_OPTION, array() ); | |
| 212 | + | |
| 213 | + return is_array( $record ) ? $record : array(); | |
| 192 | 214 | } |
| 193 | 215 | |
| 194 | 216 | /** |
| 195 | - * Whether to offer a caching solution alongside whatever the pack itself asked for. | |
| 217 | + * Note in the shared record that the row went out. | |
| 196 | 218 | * |
| 197 | - * Three conditions, cheapest first. The first two are Templately's own, and the shared | |
| 198 | - * detector cannot answer either: | |
| 199 | - * | |
| 200 | - * - Already offered once. See OFFER_SHOWN_OPTION. | |
| 201 | - * - Already on disk. We only ever offer to put it there. A site that has it — running or | |
| 202 | - * not — has made its own decision about that plugin, and a row for something already | |
| 203 | - * sitting in wp-content is noise. | |
| 204 | - * - Version floors, per the constants above. | |
| 205 | - * | |
| 206 | - * Only then the detector, which is the sole authority on whether anything owns the page | |
| 207 | - * cache. The order is load-bearing, not incidental: the detector deliberately does not | |
| 208 | - * catalogue the plugin we recommend — it answers "is anything *else* here", from a site | |
| 209 | - * where that plugin may not be installed at all. An active copy with page caching | |
| 210 | - * switched off leaves no drop-in, matches no catalogue entry, and classifies as | |
| 211 | - * `unclaimed`. Consult the detector first and you offer users a plugin they already run. | |
| 212 | - * | |
| 213 | - * Read-only throughout: deciding installs, activates, and configures nothing. | |
| 219 | + * Never downgrades an answer. A site that already accepted or declined has told us | |
| 220 | + * something; putting it back to `offered` because the row rendered again would lose | |
| 221 | + * that, and the row should not have rendered in the first place. | |
| 214 | 222 | */ |
| 215 | - public static function should_offer(): bool { | |
| 216 | - if ( self::has_been_offered() ) { | |
| 217 | - return false; | |
| 223 | + public static function record_offer() { | |
| 224 | + $record = self::offer_record(); | |
| 225 | + | |
| 226 | + if ( in_array( self::outcome(), array( 'accepted', 'declined' ), true ) ) { | |
| 227 | + return; | |
| 218 | 228 | } |
| 219 | 229 | |
| 220 | - if ( self::is_installed() ) { | |
| 221 | - return false; | |
| 230 | + $record['offered_by'] = self::INSTALLER_SLUG; | |
| 231 | + $record['offered_at'] = time(); | |
| 232 | + $record['outcome'] = 'offered'; | |
| 233 | + $record['outcome_at'] = time(); | |
| 234 | + | |
| 235 | + update_option( self::OFFER_RECORD_OPTION, $record, false ); | |
| 236 | + } | |
| 237 | + | |
| 238 | + /** | |
| 239 | + * Write a terminal answer into the shared record. | |
| 240 | + * | |
| 241 | + * @param string $outcome `accepted` or `declined`. | |
| 242 | + */ | |
| 243 | + public static function record_outcome( string $outcome ) { | |
| 244 | + if ( ! in_array( $outcome, array( 'accepted', 'declined' ), true ) ) { | |
| 245 | + return; | |
| 222 | 246 | } |
| 223 | 247 | |
| 224 | - global $wp_version; | |
| 248 | + $record = self::offer_record(); | |
| 225 | 249 | |
| 226 | - if ( version_compare( $wp_version, self::REQUIRES_WP, '<' ) | |
| 227 | - || version_compare( PHP_VERSION, self::REQUIRES_PHP, '<' ) ) { | |
| 228 | - return false; | |
| 250 | + if ( empty( $record['offered_by'] ) ) { | |
| 251 | + $record['offered_by'] = self::INSTALLER_SLUG; | |
| 252 | + $record['offered_at'] = time(); | |
| 229 | 253 | } |
| 230 | 254 | |
| 231 | - self::load_detector(); | |
| 255 | + $record['outcome'] = $outcome; | |
| 256 | + $record['outcome_at'] = time(); | |
| 232 | 257 | |
| 233 | - return Detector::is_field_clear(); | |
| 258 | + update_option( self::OFFER_RECORD_OPTION, $record, false ); | |
| 234 | 259 | } |
| 235 | 260 | |
| 236 | 261 | /** |
| 237 | - * Load the vendored page-cache detector. | |
| 262 | + * The recorded outcome, or '' when nobody has written a usable one. | |
| 238 | 263 | * |
| 239 | - * Copy-vendored from xSpeed Free: WPDevelopers/xspeed, branch | |
| 240 | - * feat/portable-page-cache-detector (PR #297), page-cache-safety/, at commit | |
| 241 | - * 2565b33fc90be8c3dae90aa1f0f1dd1f53339f14. | |
| 242 | - * | |
| 243 | - * That repo is the source of truth. Fixes go THERE and get re-copied here — never | |
| 244 | - * patched in place, or the parity test that keeps every copy honest stops meaning | |
| 245 | - * anything. Required at the point of use rather than at boot so it costs nothing on any | |
| 246 | - * other request; its own class_exists() wrapper makes it safe for another plugin on the | |
| 247 | - * same site to carry its own copy. | |
| 264 | + * Scalar-guarded because three plugins write this row and only one of them is | |
| 265 | + * this file. A nested array would otherwise be cast to the string 'Array' — a | |
| 266 | + * PHP notice, which the test rig turns into an exception and WP_DEBUG_DISPLAY | |
| 267 | + * prints into the REST response. | |
| 248 | 268 | */ |
| 249 | - public static function load_detector() { | |
| 250 | - require_once TEMPLATELY_PATH . 'includes/Vendor/page-cache-safety/class-page-cache-safety.php'; | |
| 269 | + public static function outcome(): string { | |
| 270 | + $record = self::offer_record(); | |
| 271 | + | |
| 272 | + if ( ! isset( $record['outcome'] ) || ! is_scalar( $record['outcome'] ) ) { | |
| 273 | + return ''; | |
| 274 | + } | |
| 275 | + | |
| 276 | + return (string) $record['outcome']; | |
| 251 | 277 | } |
| 252 | 278 | |
| 253 | 279 | /** |
| 254 | - * Write the settings the plugin should come up with, BEFORE it is activated. | |
| 280 | + * Has this site already answered the question, whoever asked it? | |
| 255 | 281 | * |
| 256 | - * This is the primary mechanism, and the ordering is the whole trick. Its activation | |
| 257 | - * reads what is already stored rather than stamping over it: | |
| 258 | - * | |
| 259 | - * - Each module seeds its option row only when one does not already exist, so a row we | |
| 260 | - * wrote first survives activation untouched. | |
| 261 | - * - Its cache drop-in restore runs during activation and, finding the page-cache flag | |
| 262 | - * already true, installs `advanced-cache.php` and writes the `WP_CACHE` constant | |
| 263 | - * itself — through the plugin's own supported path, on its own schedule. | |
| 264 | - * | |
| 265 | - * Verified at 1.2.0: with only this pre-write and no post-install step at all, the site | |
| 266 | - * comes up with page caching live, browser caching on, and every other front-end | |
| 267 | - * optimisation off. | |
| 268 | - * | |
| 269 | - * Doing it this way sidesteps the trap the post-install approach fell into. The plugin's | |
| 270 | - * settings manager resolves modules through a registry that is empty for a plugin | |
| 271 | - * activated part-way through the request — it returns without writing and without | |
| 272 | - * complaining. These are plain update_option() calls that need none of its code to be | |
| 273 | - * loaded, because at this point none of it is. | |
| 274 | - * | |
| 275 | - * Called immediately before activation so a failed download never leaves rows behind; if | |
| 276 | - * activation itself fails, forget_settings() takes them back out. | |
| 282 | + * The check that makes the offer survive a deletion. `accepted` plus an absent | |
| 283 | + * plugin is a user who installed it and then took it off — the clearest "no" a | |
| 284 | + * user can give, and the one every other guard here misses, because deleting a | |
| 285 | + * plugin runs its uninstaller: `xspeed_options` goes with it, so has_settings() | |
| 286 | + * forgets, and OFFER_COOLDOWN then re-offers a month later. Derived rather than | |
| 287 | + * stored so a deletion done from the Plugins screen — with none of our code | |
| 288 | + * running — still counts. | |
| 277 | 289 | */ |
| 278 | - public static function prepare_settings() { | |
| 279 | - update_option( self::SETTINGS_OPTION, array( 'cache_enabled' => true ) ); | |
| 290 | + public static function was_answered(): bool { | |
| 291 | + $outcome = self::outcome(); | |
| 280 | 292 | |
| 281 | - foreach ( self::INITIAL_SETTINGS as $slug => $values ) { | |
| 282 | - update_option( self::MODULE_PREFIX . $slug, $values ); | |
| 293 | + if ( 'declined' === $outcome ) { | |
| 294 | + return true; | |
| 283 | 295 | } |
| 296 | + | |
| 297 | + if ( 'accepted' === $outcome ) { | |
| 298 | + return ! self::is_installed(); | |
| 299 | + } | |
| 300 | + | |
| 301 | + return false; | |
| 284 | 302 | } |
| 285 | 303 | |
| 286 | 304 | /** |
| 287 | - * Undo prepare_settings() when the install did not survive to activation. | |
| 305 | + * The dependency row, in the same shape as every other entry on that screen. | |
| 288 | 306 | * |
| 289 | - * Leaving these rows on a site that has no caching plugin is litter, and worse, a | |
| 290 | - * page-cache flag sitting there would tell a LATER hand-install to bring up caching the | |
| 291 | - * user never asked for. | |
| 307 | + * The name, icon and link are the real plugin's: the user is agreeing to install a | |
| 308 | + * specific thing and should be able to see and check what it is. `installed` is | |
| 309 | + * always false — the offer is withheld outright when the plugin is present — and | |
| 310 | + * false is what keeps the checkbox enabled. `mustHave` is omitted: a suggestion, | |
| 311 | + * not a requirement. | |
| 292 | 312 | */ |
| 293 | - public static function forget_settings() { | |
| 294 | - delete_option( self::SETTINGS_OPTION ); | |
| 295 | - | |
| 296 | - foreach ( array_keys( self::INITIAL_SETTINGS ) as $slug ) { | |
| 297 | - delete_option( self::MODULE_PREFIX . $slug ); | |
| 298 | - } | |
| 313 | + public static function dependency_entry(): array { | |
| 314 | + return array( | |
| 315 | + 'name' => self::PLUGIN_NAME, | |
| 316 | + 'icon' => self::PLUGIN_ICON, | |
| 317 | + 'plugin_file' => self::PLUGIN_FILE, | |
| 318 | + 'plugin_original_slug' => self::PLUGIN_SLUG, | |
| 319 | + 'is_pro' => false, | |
| 320 | + 'installed' => false, | |
| 321 | + 'link' => self::PLUGIN_LINK, | |
| 322 | + ); | |
| 299 | 323 | } |
| 300 | 324 | |
| 301 | 325 | /** |
| 302 | - * Finish the job after a successful, Templately-driven activation. | |
| 326 | + * Whether to offer the caching plugin alongside whatever the pack itself asked for. | |
| 303 | 327 | * |
| 304 | - * Only for an install Templately performed. Deliberately not hooked to `activated_plugin`: | |
| 305 | - * that fires when the user activates the plugin themselves from the Plugins screen, and | |
| 306 | - * reconfiguring their site off the back of an action they took elsewhere would be exactly | |
| 307 | - * the overreach this feature is trying not to commit. | |
| 328 | + * Deliberately not conditional on anything already owning the page cache. xSpeed | |
| 329 | + * installs beside another cache plugin and stands down from the cache itself — that | |
| 330 | + * is its decision, made at its own activation, and asking it here would only be | |
| 331 | + * asking on an earlier request than the one that matters. | |
| 308 | 332 | * |
| 309 | - * Two jobs left, because pre-writing cannot cover either: | |
| 333 | + * Cheapest checks first, and the order is load-bearing: | |
| 310 | 334 | * |
| 311 | - * - The setup-wizard redirect, which activation arms unconditionally. | |
| 312 | - * - A safety net for page caching. The pre-write should already have caused the plugin to | |
| 313 | - * install its drop-in; if it did not, enable it the long way rather than leave the user | |
| 314 | - * with a cache plugin that caches nothing. | |
| 315 | - * | |
| 316 | - * Nothing here may break the import. A caching plugin that installed but did not | |
| 317 | - * configure is a worse outcome than one that did, and a far better one than a failed | |
| 318 | - * import. | |
| 335 | + * - Can this user even accept? The dependency screen is readable at `delete_posts`, | |
| 336 | + * so a contributor can open the wizard; installing needs `install_plugins`. | |
| 337 | + * Without this they would spend the site's one offer on themselves. | |
| 338 | + * - Already answered, by us or by any sibling plugin. Permanent, and checked ahead | |
| 339 | + * of our own timer because it outranks it: OFFER_GRACE keeps the row up for half | |
| 340 | + * an hour, which was long enough to install xSpeed, delete it, and be offered it | |
| 341 | + * again in the same sitting. | |
| 342 | + * - Already offered, within the window. | |
| 343 | + * - Already present — free or Pro — or already carrying xSpeed's settings. | |
| 344 | + * - The site can run it. | |
| 319 | 345 | */ |
| 320 | - public static function configure_after_install() { | |
| 321 | - if ( ! self::is_active() ) { | |
| 322 | - return; | |
| 346 | + public static function should_offer(): bool { | |
| 347 | + if ( ! Helper::current_user_can( 'install_plugins' ) ) { | |
| 348 | + return false; | |
| 323 | 349 | } |
| 324 | 350 | |
| 325 | - self::suppress_setup_wizard(); | |
| 351 | + if ( self::was_answered() ) { | |
| 352 | + return false; | |
| 353 | + } | |
| 326 | 354 | |
| 327 | - if ( ! self::page_cache_is_live() ) { | |
| 328 | - self::enable_page_cache(); | |
| 355 | + if ( self::has_been_offered() ) { | |
| 356 | + return false; | |
| 329 | 357 | } |
| 358 | + | |
| 359 | + return ! self::is_installed() | |
| 360 | + && ! self::has_settings() | |
| 361 | + && self::is_supported(); | |
| 330 | 362 | } |
| 331 | 363 | |
| 332 | 364 | /** |
| 333 | - * Drop the one-time wizard redirect the activation hook just armed. | |
| 365 | + * Claim the install, so xSpeed comes up as a host install rather than a hand one. | |
| 334 | 366 | * |
| 335 | - * The one thing pre-writing cannot prevent: the flag is set unconditionally on | |
| 336 | - * activation, so it has to be cleared afterwards. | |
| 367 | + * Call immediately before activating, and never speculatively. It is a one-shot | |
| 368 | + * trigger that changes what activation does, not a record of intent — an install | |
| 369 | + * that dies between this and the activation arms the NEXT activation on the site, | |
| 370 | + * whoever starts it. | |
| 337 | 371 | * |
| 338 | - * The user came here to import a template. Bouncing them into another plugin's setup | |
| 339 | - * wizard on their next wp-admin page load is not what they asked for. The wizard stays in | |
| 340 | - * that plugin's own menu and can be run whenever they like — this cancels only the forced | |
| 341 | - * redirect, and deliberately does not mark onboarding "complete", which would be a claim | |
| 342 | - * about something the user never did. | |
| 372 | + * Also settles the shared record at `accepted`. Written here rather than after a | |
| 373 | + * successful activation on purpose: an install that got this far has been agreed | |
| 374 | + * to, and an activation that then fails still leaves files on disk. Recording the | |
| 375 | + * answer is what stops the site being asked again once those files are removed. | |
| 343 | 376 | */ |
| 344 | - private static function suppress_setup_wizard() { | |
| 345 | - delete_option( self::WIZARD_REDIRECT_OPTION ); | |
| 346 | - } | |
| 377 | + public static function claim_install() { | |
| 378 | + update_option( self::INSTALLED_BY_OPTION, self::INSTALLER_SLUG, false ); | |
| 347 | 379 | |
| 348 | - /** | |
| 349 | - * Whether page caching actually took effect, rather than merely being requested. | |
| 350 | - * | |
| 351 | - * The stored flag on its own proves nothing — it is the drop-in and the constant that | |
| 352 | - * make WordPress serve from cache, and either can be missing if the filesystem or | |
| 353 | - * wp-config.php refused the write. | |
| 354 | - */ | |
| 355 | - private static function page_cache_is_live(): bool { | |
| 356 | - $options = get_option( self::SETTINGS_OPTION, array() ); | |
| 357 | - | |
| 358 | - return ! empty( $options['cache_enabled'] ) | |
| 359 | - && file_exists( WP_CONTENT_DIR . '/advanced-cache.php' ) | |
| 360 | - && defined( 'WP_CACHE' ) && WP_CACHE; | |
| 380 | + self::record_outcome( 'accepted' ); | |
| 361 | 381 | } |
| 362 | 382 | |
| 363 | 383 | /** |
| 364 | - * Fallback: switch page caching on the long way. | |
| 384 | + * What the install came up as, for reporting. Null when xSpeed is not active or is | |
| 385 | + * older than the release that introduced the API. | |
| 365 | 386 | * |
| 366 | - * Only reached when the pre-write did not take — the drop-in restore declined, or the | |
| 367 | - * drop-in / wp-config.php write failed. The happy path never comes here. | |
| 387 | + * `conflict-safe` is a success, not a failure: it means another plugin was already | |
| 388 | + * caching and xSpeed stood down, which is the designed outcome. | |
| 368 | 389 | * |
| 369 | - * The page-cache flag cannot simply be written: the plugin's own settings manager rejects | |
| 370 | - * that key by name, because the flag is what drives the drop-in install and the | |
| 371 | - * wp-config.php edit. A bare write leaves a site claiming a cache it does not have — | |
| 372 | - * which the very detector that decided to offer it would then read as `unknown-occupied`. | |
| 373 | - * The toggle does the drop-in and the constant; the settings write after it persists the | |
| 374 | - * flag, mirroring the plugin's own REST handler. | |
| 375 | - * | |
| 376 | - * That REST route is its documented entry point and would be the tidier call, but it is | |
| 377 | - * unreachable here: the plugin was activated part-way through THIS request, so | |
| 378 | - * `rest_api_init` has already fired and its routes are not registered. These static calls | |
| 379 | - * are the same code that route runs. | |
| 390 | + * @return array|null | |
| 380 | 391 | */ |
| 381 | - private static function enable_page_cache() { | |
| 382 | - if ( ! class_exists( '\XSpeed\Cache' ) || ! class_exists( '\XSpeed\Settings' ) ) { | |
| 383 | - return; | |
| 392 | + public static function install_status() { | |
| 393 | + if ( ! class_exists( '\XSpeed\Host' ) ) { | |
| 394 | + return null; | |
| 384 | 395 | } |
| 385 | 396 | |
| 386 | - try { | |
| 387 | - $state = \XSpeed\Cache::toggle( true ); | |
| 388 | - \XSpeed\Settings::update( array( 'cache_enabled' => ! empty( $state['enabled'] ) ) ); | |
| 389 | - } catch ( \Throwable $e ) { | |
| 390 | - // A failed cache switch-on must never take the import down with it. | |
| 391 | - Helper::log( 'Page cache could not be enabled: ' . $e->getMessage() ); | |
| 392 | - return; | |
| 393 | - } | |
| 394 | - | |
| 395 | - // The site's cache state just changed under the detector's feet. Anything asking | |
| 396 | - // again in this request must not get the pre-install answer back from its memo. | |
| 397 | - if ( class_exists( '\WPDeveloper\PageCacheSafety\Detector' ) ) { | |
| 398 | - Detector::invalidate(); | |
| 399 | - } | |
| 397 | + return \XSpeed\Host::status(); | |
| 400 | 398 | } |
| 401 | 399 | } |