PluginProbe
Templately – Elementor & Gutenberg Template Library: 6500+ Free & Pro Ready Templates And Cloud! / trunk
Templately – Elementor & Gutenberg Template Library: 6500+ Free & Pro Ready Templates And Cloud! vtrunk
3.8.0 3.7.5 3.7.4 3.7.3 3.7.2 1-final 3.7.1 3.7.0 3.6.8 3.6.7 3.6.6 3.6.5 3.6.4 3.6.3 3.6.2 3.6.1 3.0.3 3.0.4 3.0.5 3.0.6 3.0.7 3.0.8 3.0.9 3.1.0 3.1.1 All 112 releases
← All changes | includes/Utils/Caching.php +274 -276 3.7.5 → trunk View file →
@@ -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 }