PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.2
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.2
1.3.2 1.3.1 1.3.0 1.2.4 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.1.1 1.1.2 1.1.3 1.1.4 1.1.5 1.1.6 1.1.7 1.1.8 1.2.0 All 28 releases
← All changes | includes/modules/Migration/MigrationModule.php +928 -72 1.0.71.3.2 View file →
@@ -26,11 +26,11 @@
26 26 public const VERSION = '1.0.0';
27 27
28 28 public function ui_metadata(): array {
29 29 return array(
30 - 'label' => 'Migration',
30 + 'label' => __( 'Migration', 'xspeed' ),
31 31 'icon' => 'Import',
32 - 'description' => 'Import settings from WP Rocket, W3 Total Cache, or WP Super Cache.',
32 + 'description' => __( 'Import settings from WP Rocket, W3 Total Cache, or WP Super Cache.', 'xspeed' ),
33 33 'custom_panel' => 'MigrationPanel',
34 34 );
35 35 }
36 36
@@ -61,10 +61,69 @@
61 61 // Dashboard nudge: when another caching plugin is detected, offer a
62 62 // one-click import — the same "we noticed you use X" prompt other
63 63 // plugins show. Renders on standard WP admin screens (NOT xSpeed's
64 64 // own pages, where the Migration panel already covers it).
65 - add_action( 'admin_notices', array( $this, 'maybe_render_notice' ) );
65 + /*
66 + * Priority 1 put this ABOVE everything, including the page title.
67 + *
68 + * On plugins.php (and most core screens) `admin_notices` fires before
69 + * the `.wrap` div and the <h1>, so a very early notice is emitted into
70 + * the gap under the screen-options bar — outside the page's own
71 + * container, floating above the heading it belongs to. Hooking last
72 + * instead keeps it inside the normal notice flow, below the title and
73 + * above the rest, without leaving the notice system.
74 + */
75 + /*
76 + * Every screen EXCEPT plugins.php, which gets the later hook below —
77 + * otherwise this fires first and wins the render-once guard, putting
78 + * the notice back above the title on exactly the screen we moved it
79 + * off.
80 + *
81 + * Priority 1 so it leads the notice stack: other plugins hook at the
82 + * default 10, and a migration offer buried under three review prompts
83 + * and an upsell is one nobody reads. This is safe here in a way it was
84 + * not on plugins.php — on the screens that keep this hook, the notice
85 + * area is the page's own, so being first within it means first inside
86 + * the layout rather than adrift above the heading.
87 + */
88 + if ( ! $this->is_plugins_screen() ) {
89 + add_action( 'admin_notices', array( $this, 'maybe_render_notice' ), 1 );
90 + }
91 + /*
92 + * plugins.php renders its <h1> AFTER admin_notices has already fired,
93 + * so nothing hooked there can sit below the title — the notice lands
94 + * in the gap under the screen-options bar instead, above the heading
95 + * it belongs to. This screen offers `pre_current_active_plugins`,
96 + * which runs inside `.wrap` just after the title, so the notice is
97 + * moved there and the admin_notices copy stands down (see the guard in
98 + * maybe_render_notice, which renders once per request).
99 + */
100 + // Priority 1 for the same reason: this hook carries WordPress's own
101 + // "Plugin activated." messages too, and ours should lead them.
102 + add_action( 'pre_current_active_plugins', array( $this, 'maybe_render_notice' ), 1 );
103 +
104 + /*
105 + * Core's "WordPress x.y is available" nag is hooked to admin_notices
106 + * at priority 3, so on plugins.php — where our card moved to the
107 + * later `pre_current_active_plugins` to stay under the title — the nag
108 + * prints first and ours lands beneath it.
109 + *
110 + * Re-hook the nag to the same later action so the two share a
111 + * container and the order is ours, then core's. It is re-added rather
112 + * than dropped: suppressing a core update prompt to win a slot would
113 + * trade the user's security notice for our marketing, which is not a
114 + * trade we get to make.
115 + */
116 + if ( $this->is_plugins_screen() ) {
117 + add_action( 'admin_init', array( $this, 'reorder_core_update_nag' ) );
118 + }
66 119 add_action( 'admin_init', array( $this, 'handle_dismiss' ) );
120 + // Migrate straight from the notice. The Import control used to be a
121 + // LINK to the dashboard, so "Import" meant "go to another screen and
122 + // find the button again" — the migration never started from the place
123 + // that offered it. This endpoint runs the same apply() the panel and
124 + // the CLI use, so all three behave identically.
125 + add_action( 'wp_ajax_xspeed_migrate_now', array( $this, 'ajax_migrate_now' ) );
67 126 // Sidebar attention badge: surface the count of importable plugins on
68 127 // the Migration nav item so the user knows there's an action to take.
69 128 add_filter( 'xspeed_module_descriptor', array( $this, 'add_sidebar_badge' ), 10, 2 );
70 129 }
@@ -105,8 +164,17 @@
105 164 * conservative: skipped on xSpeed's own screens, for users without
106 165 * manage_options, and once dismissed.
107 166 */
108 167 public function maybe_render_notice(): void {
168 + // Two hooks feed this on plugins.php — admin_notices for every other
169 + // screen, pre_current_active_plugins so this one lands below the
170 + // title. Whichever fires first wins; the second is a no-op rather
171 + // than a duplicate card.
172 + static $rendered = false;
173 + if ( $rendered ) {
174 + return;
175 + }
176 +
109 177 if ( ! current_user_can( 'manage_options' ) ) {
110 178 return;
111 179 }
112 180 // Don't double up on xSpeed's own pages — the Migration panel is right there.
@@ -148,64 +216,225 @@
148 216 $brand,
149 217 $count
150 218 );
151 219
220 + $rendered = true;
152 221 $brand_color = $this->brand_color();
153 - // Brand-color the Import CTAs (override WP's default blue primary).
154 - // This notice is echoed directly (not through wp_kses), so an inline
155 - // <style> block is fine here.
156 - echo '<style>.xspeed-migration-notice .xspeed-mig-cta.button-primary{'
157 - . 'background:' . esc_attr( $brand_color ) . ' !important;'
158 - . 'border-color:' . esc_attr( $brand_color ) . ' !important;box-shadow:none !important;'
159 - . 'box-sizing:border-box !important;min-height:36px !important;max-height:36px !important;height:36px !important;line-height:1 !important;padding-top:0;padding-bottom:0;display:inline-flex;align-items:center;}'
160 - . '.xspeed-migration-notice .xspeed-mig-cta.button-primary:hover{filter:brightness(1.15);}'
222 + $nonce = wp_create_nonce( 'xspeed_migrate_now' );
223 +
224 + /*
225 + * The guarantee is the headline; the product name is secondary.
226 + *
227 + * The old notice led with the mechanism ("Import your existing
228 + * settings instead of configuring everything by hand…") and spent
229 + * three lines on caveats before offering anything — the reader met the
230 + * hedging before the offer. It also put the source rows in a sub-card
231 + * even when there was only one, and its Import control was an `<a>`
232 + * that NAVIGATED to the dashboard: pressing the button in the notice
233 + * did not migrate, it moved you to a screen where you had to find the
234 + * button again.
235 + *
236 + * The consequence ("migrating deactivates X") sits under the buttons
237 + * rather than inside a dialog after the click.
238 + */
239 + $single = 1 === $count ? $detected[0] : null;
240 +
241 + /**
242 + * Filter the "book a call" destination shown beside the migrate CTA.
243 + *
244 + * Returning '' hides the button — a white-label build or a reseller
245 + * who cannot staff the call has to be able to drop it. It ships with a
246 + * destination so the control is real out of the box rather than a
247 + * promise nobody answers.
248 + *
249 + * @param string $url Booking URL, or '' to hide the control.
250 + */
251 + $call_url = (string) apply_filters(
252 + 'xspeed_migration_call_url',
253 + 'https://xspeedcache.com/support/'
254 + );
255 +
256 + /**
257 + * Filter the speed-guarantee wording, or remove it.
258 + *
259 + * Returning '' drops the badge and falls back to a plain benefit
260 + * headline — white-label builds and resellers who cannot honour a
261 + * guarantee must be able to switch it off.
262 + *
263 + * @param string $label Badge text.
264 + */
265 + $guarantee = (string) apply_filters( 'xspeed_migration_guarantee_label', __( 'Speed guarantee', 'xspeed' ) );
266 +
267 + echo '<style>'
268 + . '.xspeed-mig{position:relative;padding:0!important;border:1px solid #e2e8f0!important;'
269 + . 'border-left:4px solid ' . esc_attr( $brand_color ) . '!important;background:#fff;}'
270 + . '.xspeed-mig__in{display:flex;align-items:flex-start;gap:16px;padding:18px 44px 18px 20px;flex-wrap:wrap;}'
271 + . '.xspeed-mig__ico{flex:0 0 44px;width:44px;height:44px;border-radius:10px;display:inline-flex;'
272 + . 'align-items:center;justify-content:center;background:' . esc_attr( $brand_color ) . ';color:#fff;}'
273 + . '.xspeed-mig__body{flex:1 1 420px;min-width:0;}'
274 + . '.xspeed-mig__hrow{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin:0 0 5px;}'
275 + . '.xspeed-mig__h{margin:0;font-size:17px;font-weight:600;color:#0f172a;line-height:1.35;letter-spacing:-.01em;}'
276 + . '.xspeed-mig__badge{display:inline-flex;align-items:center;gap:5px;font-size:12px;font-weight:600;'
277 + . 'padding:4px 10px;border-radius:999px;background:' . esc_attr( $brand_color ) . '14;color:' . esc_attr( $brand_color ) . ';}'
278 + . '.xspeed-mig__p{margin:0!important;font-size:13.5px!important;color:#475569!important;line-height:1.55;}'
279 + . '.xspeed-mig__meta{margin:7px 0 0!important;font-size:12px!important;color:#94a3b8!important;}'
280 + . '.xspeed-mig__act{flex:0 0 auto;display:flex;flex-direction:column;align-items:flex-end;gap:8px;margin-left:auto;}'
281 + . '.xspeed-mig__btns{display:flex;align-items:center;gap:10px;flex-wrap:wrap;justify-content:flex-end;}'
282 + . '.xspeed-mig__cta{background:' . esc_attr( $brand_color ) . '!important;border:1px solid ' . esc_attr( $brand_color ) . '!important;'
283 + . 'color:#fff!important;box-shadow:none!important;height:44px!important;line-height:1!important;padding:0 22px!important;'
284 + . 'display:inline-flex!important;align-items:center;gap:8px;border-radius:8px!important;font-size:14px!important;font-weight:600;}'
285 + . '.xspeed-mig__cta:hover{filter:brightness(1.12);}'
286 + . '.xspeed-mig__cta[disabled]{opacity:.6;cursor:default;}'
287 + . '.xspeed-mig__ghost{display:inline-flex;align-items:center;gap:8px;height:44px;padding:0 16px;'
288 + . 'border:1px solid #cbd5e1;border-radius:8px;background:#fff;color:#334155!important;font-size:13.5px;text-decoration:none!important;box-shadow:none;}'
289 + . '.xspeed-mig__ghost:hover{border-color:#94a3b8;color:#0f172a;}'
290 + . '.xspeed-mig__sub{font-size:12px!important;color:#94a3b8!important;text-align:right;}'
291 + . '.xspeed-mig__sub a{color:#64748b!important;text-decoration:none!important;box-shadow:none;}'
292 + . '.xspeed-mig__sub a:hover{text-decoration:underline!important;}'
293 + . '.xspeed-mig__x{position:absolute;top:11px;right:11px;width:28px;height:28px;border:0;'
294 + . 'background:transparent;color:#94a3b8!important;cursor:pointer;padding:0;text-decoration:none!important;box-shadow:none;'
295 + . 'border-radius:6px;display:inline-flex;align-items:center;justify-content:center;transition:color .15s;}'
296 + . '.xspeed-mig__x:hover{color:#0f172a!important;}'
297 + . '.xspeed-mig__rows{display:flex;flex-direction:column;gap:8px;padding:0 20px 18px;}'
298 + . '.xspeed-mig__row{display:flex;align-items:center;justify-content:space-between;gap:12px;'
299 + . 'padding:10px 14px;background:#f8fafc;border:1px solid #e2e8f0;border-radius:8px;}'
300 + . '.xspeed-mig__spin{display:inline-block;width:14px;height:14px;border:2px solid #ffffff66;'
301 + . 'border-top-color:#fff;border-radius:50%;animation:xspeed-mig-spin .7s linear infinite;}'
302 + . '@keyframes xspeed-mig-spin{to{transform:rotate(360deg)}}'
161 303 . '</style>';
162 - echo '<div class="notice xspeed-migration-notice" style="padding:0;border:1px solid #e2e8f0;border-left:4px solid ' . esc_attr( $brand_color ) . ';border-radius:8px;overflow:hidden;background:#fff;">';
163 - echo '<div style="padding:16px 18px;">';
164 304
165 - // Header row: brand mark + heading.
166 - echo '<div style="display:flex;align-items:center;gap:10px;margin-bottom:6px;">';
305 + echo '<div class="notice xspeed-mig" data-nonce="' . esc_attr( $nonce ) . '">';
306 +
307 + // Close control, top-right, as an ordinary dismiss link so it still
308 + // works with JavaScript unavailable.
309 + echo '<a href="' . esc_url( $dismiss_url ) . '" class="xspeed-mig__x" aria-label="'
310 + . esc_attr__( 'Dismiss this notice', 'xspeed' ) . '">'
311 + // Drawn glyph rather than the "&times;" character: at 12px the
312 + // entity renders as stray punctuation, off-centre and weight-
313 + // mismatched against the rest of the card.
314 + . '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg>'
315 + . '</a>';
316 +
317 + echo '<div class="xspeed-mig__in">';
318 +
167 319 $logo = $this->branding_logo();
168 - if ( '' !== $logo ) {
169 - echo '<span style="display:inline-flex;width:24px;height:24px;flex:0 0 24px;">' . $logo . '</span>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- logo is a sanitized inline SVG from branding, escaped at source.
320 + echo '<span class="xspeed-mig__ico">';
321 + echo '' !== $logo
322 + ? $logo // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- sanitized inline SVG from branding, escaped at source.
323 + : $this->brand_mark(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- our own bundled icon.svg, read from disk and size-capped in brand_mark().
324 + echo '</span>';
325 +
326 + echo '<div class="xspeed-mig__body">';
327 +
328 + echo '<div class="xspeed-mig__hrow">';
329 + echo '<span class="xspeed-mig__h">' . esc_html(
330 + '' !== $guarantee
331 + ? __( "Guaranteed faster load times — or we'll tune it for you, free.", 'xspeed' )
332 + : __( 'Faster load times, without configuring anything by hand.', 'xspeed' )
333 + ) . '</span>';
334 + if ( '' !== $guarantee ) {
335 + echo '<span class="xspeed-mig__badge">'
336 + . '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>'
337 + . esc_html( $guarantee ) . '</span>';
170 338 }
171 - echo '<strong style="font-size:14px;color:#1e293b;">' . esc_html( $heading ) . '</strong>';
172 339 echo '</div>';
173 340
174 - echo '<p style="margin:0 0 12px;color:#475569;font-size:13px;">'
175 - . esc_html__( 'Import your existing settings in one click instead of configuring everything by hand. Pick a source to migrate:', 'xspeed' )
176 - . '</p>';
341 + // Product name secondary, benefits folded into one sentence.
342 + echo '<p class="xspeed-mig__p">' . esc_html(
343 + sprintf(
344 + /* translators: %s: brand name, e.g. xSpeed Cache. */
345 + __( '%s imports your settings in one click, then handles caching and optimization with AI-powered control.', 'xspeed' ),
346 + $brand
347 + )
348 + ) . '</p>';
177 349
178 - // One row per detected source: label + value count + its own Import button.
179 - echo '<div style="display:flex;flex-direction:column;gap:8px;">';
180 - foreach ( $detected as $s ) {
181 - // Query arg BEFORE the hash so the dashboard still reads #migration.
182 - $src_url = $base_url . '&source=' . rawurlencode( $s['id'] ) . '#migration';
183 - echo '<div style="display:flex;align-items:center;justify-content:space-between;gap:12px;padding:8px 12px;background:#f8fafc;border:1px solid #e2e8f0;border-radius:6px;">';
184 - $mapped = (int) ( $s['mapped_count'] ?? 0 );
185 - echo '<span style="font-size:13px;color:#1e293b;"><strong>' . esc_html( $s['label'] ) . '</strong>'
186 - . ' <span style="color:#94a3b8;">'
350 + if ( null !== $single ) {
351 + $mapped = (int) ( $single['mapped_count'] ?? 0 );
352 + echo '<p class="xspeed-mig__meta">' . esc_html(
353 + sprintf(
354 + /* translators: 1: detected plugin name, 2: number of settings. */
355 + _n(
356 + '%1$s detected · %2$d setting ready to import',
357 + '%1$s detected · %2$d settings ready to import',
358 + $mapped,
359 + 'xspeed'
360 + ),
361 + $single['label'],
362 + $mapped
363 + )
364 + ) . '</p>';
365 + } else {
366 + echo '<p class="xspeed-mig__meta">' . esc_html( $heading ) . '</p>';
367 + }
368 + echo '</div>'; // .body
369 +
370 + echo '<div class="xspeed-mig__act">';
371 + echo '<div class="xspeed-mig__btns">';
372 + if ( '' !== $call_url ) {
373 + echo '<a href="' . esc_url( $call_url ) . '" class="xspeed-mig__ghost" target="_blank" rel="noopener noreferrer">'
374 + . '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 18v-6a9 9 0 0 1 18 0v6"/><path d="M21 19a2 2 0 0 1-2 2h-1a2 2 0 0 1-2-2v-3a2 2 0 0 1 2-2h3zM3 19a2 2 0 0 0 2 2h1a2 2 0 0 0 2-2v-3a2 2 0 0 0-2-2H3z"/></svg>'
375 + . esc_html__( 'Prefer we handle it? Book a call', 'xspeed' ) . '</a>';
376 + }
377 + if ( null !== $single ) {
378 + echo '<button type="button" class="button button-primary xspeed-mig__cta" data-source="' . esc_attr( $single['id'] ) . '"'
379 + . ' data-deactivate="' . ( ! empty( $single['active'] ) ? '1' : '0' ) . '">'
187 380 . esc_html(
188 381 sprintf(
189 - /* translators: %d: number of settings xSpeed will actually import. */
190 - _n( 'imports %d setting', 'imports %d settings', $mapped, 'xspeed' ),
191 - $mapped
382 + /* translators: %s: brand name. */
383 + __( 'Migrate to %s', 'xspeed' ),
384 + $brand
192 385 )
193 386 )
194 - . '</span></span>';
195 - echo '<a href="' . esc_url( $src_url ) . '" class="button button-primary xspeed-mig-cta" style="flex:0 0 auto;">'
196 - . esc_html__( 'Import', 'xspeed' ) . '</a>';
387 + . '</button>';
388 + }
389 + echo '</div>';
390 +
391 + // The consequence sits UNDER the buttons, before the click — not in a
392 + // dialog after it.
393 + if ( null !== $single ) {
394 + echo '<div class="xspeed-mig__sub">';
395 + if ( ! empty( $single['active'] ) ) {
396 + echo esc_html(
397 + sprintf(
398 + /* translators: %s: detected plugin name. */
399 + __( 'Migrating deactivates %s', 'xspeed' ),
400 + $single['label']
401 + )
402 + ) . ' &nbsp; ';
403 + }
404 + echo '<a href="' . esc_url( $panel_url ) . '">' . esc_html__( 'View migration details', 'xspeed' ) . '</a>';
197 405 echo '</div>';
198 406 }
199 - echo '</div>';
407 + echo '</div>'; // .act
200 408
201 - // Footer: open the full panel + dismiss the whole notice.
202 - echo '<p style="margin:12px 0 0;display:flex;gap:16px;align-items:center;">';
203 - echo '<a href="' . esc_url( $panel_url ) . '" style="font-size:13px;">' . esc_html__( 'Open Migration panel', 'xspeed' ) . '</a>';
204 - echo '<a href="' . esc_url( $dismiss_url ) . '" style="font-size:13px;color:#94a3b8;text-decoration:none;">' . esc_html__( 'Dismiss', 'xspeed' ) . '</a>';
205 - echo '</p>';
409 + echo '</div>'; // .in
206 410
207 - echo '</div></div>';
411 + // Several sources: one row each, since there is no single obvious
412 + // action to promote.
413 + if ( null === $single ) {
414 + echo '<div class="xspeed-mig__rows">';
415 + foreach ( $detected as $s ) {
416 + $mapped = (int) ( $s['mapped_count'] ?? 0 );
417 + echo '<div class="xspeed-mig__row">';
418 + echo '<span style="font-size:13px;color:#1e293b;"><strong>' . esc_html( $s['label'] ) . '</strong> '
419 + . '<span style="color:#94a3b8;">' . esc_html(
420 + sprintf(
421 + /* translators: %d: number of settings imported. */
422 + _n( 'imports %d setting', 'imports %d settings', $mapped, 'xspeed' ),
423 + $mapped
424 + )
425 + ) . '</span></span>';
426 + echo '<button type="button" class="button button-primary xspeed-mig__cta" data-source="' . esc_attr( $s['id'] ) . '"'
427 + . ' data-deactivate="' . ( ! empty( $s['active'] ) ? '1' : '0' ) . '" style="height:36px!important;padding:0 16px!important;font-size:13px!important;">'
428 + . esc_html__( 'Migrate', 'xspeed' ) . '</button>';
429 + echo '</div>';
430 + }
431 + echo '</div>';
432 + }
433 +
434 + $this->print_notice_script();
435 +
436 + echo '</div>';
208 437 }
209 438
210 439 /**
211 440 * Detected sources that are still actionable — not dismissed and not
@@ -219,8 +448,18 @@
219 448 private function detected_sources( array $dismissed = array() ): array {
220 449 if ( in_array( 'all', $dismissed, true ) ) {
221 450 return array();
222 451 }
452 + // Migration is an optional collaborator, not a hard dependency: this
453 + // runs on `admin_notices`, which fires on EVERY admin screen. If
454 + // includes/class-migration.php is unreadable — a partial plugin
455 + // update, a stale opcache file map, a bad deploy — the autoloader
456 + // no-ops silently and the static call below fatals, taking wp-admin
457 + // down with it. That includes plugins.php, so the owner cannot even
458 + // deactivate us to recover. Degrade to "no sources detected" instead.
459 + if ( ! class_exists( '\\XSpeed\\Migration' ) ) {
460 + return array();
461 + }
223 462 $out = array();
224 463 foreach ( Migration::status() as $s ) {
225 464 if ( empty( $s['detected'] ) || ! empty( $s['imported'] ) || in_array( $s['id'], $dismissed, true ) ) {
226 465 continue;
@@ -231,8 +470,37 @@
231 470 usort( $out, static fn( $a, $b ) => (int) $b['mapped_count'] <=> (int) $a['mapped_count'] );
232 471 return $out;
233 472 }
234 473
474 + /**
475 + * The bundled xSpeed mark, inlined so it can take the tile's colour.
476 + *
477 + * `assets/icon.svg` is `fill="currentColor"`, which an `<img>` cannot
478 + * recolour — it would paint black on the brand tile. Inlining lets the
479 + * parent's `color` drive the fill, the same trick the admin menu and the
480 + * boot skeleton use on their own surfaces (DESIGN.md §24.28).
481 + *
482 + * Read from disk rather than duplicated here: the mark is a tracked build
483 + * output and a second copy in PHP is the drift that section warns about.
484 + * Falls back to a bolt glyph if the file is missing (a partial deploy),
485 + * because a notice with no icon is better than a fatal.
486 + */
487 + private function brand_mark(): string {
488 + $path = XSPEED_DIR . 'assets/icon.svg';
489 + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- our own bundled asset; WP_Filesystem needs credentials unavailable when rendering a notice.
490 + $svg = is_readable( $path ) ? (string) @file_get_contents( $path ) : ''; // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- unreadable file falls back below.
491 + if ( '' === $svg || false === strpos( $svg, '<svg' ) ) {
492 + return '<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M13 2 3 14h9l-1 8 10-12h-9l1-8z"/></svg>';
493 + }
494 + // Size it for the tile; the file ships at 20x20.
495 + return (string) preg_replace(
496 + '/\swidth="[^"]*"\s+height="[^"]*"/',
497 + ' width="24" height="24"',
498 + $svg,
499 + 1
500 + );
501 + }
502 +
235 503 /** Inline brand logo SVG when white-label supplies one; else empty. */
236 504 private function branding_logo(): string {
237 505 $brand = apply_filters( 'xspeed_branding', array() );
238 506 return isset( $brand['logo_svg'] ) && is_string( $brand['logo_svg'] ) ? $brand['logo_svg'] : '';
@@ -237,8 +505,273 @@
237 505 $brand = apply_filters( 'xspeed_branding', array() );
238 506 return isset( $brand['logo_svg'] ) && is_string( $brand['logo_svg'] ) ? $brand['logo_svg'] : '';
239 507 }
240 508
509 + /**
510 + * Move core's update nag below our notice on plugins.php.
511 + *
512 + * Only on this screen, only that one callback, and only when it is
513 + * actually registered — every other notice on every other screen is left
514 + * exactly where its owner put it. The nag still renders; it renders
515 + * second.
516 + */
517 + public function reorder_core_update_nag(): void {
518 + if ( ! has_action( 'admin_notices', 'update_nag' ) ) {
519 + return;
520 + }
521 + remove_action( 'admin_notices', 'update_nag', 3 );
522 + add_action( 'pre_current_active_plugins', 'update_nag', 2 );
523 + }
524 +
525 + /**
526 + * Are we rendering plugins.php (or its network equivalent)?
527 + *
528 + * Checked from `boot()`, which runs before `admin_init`, so `get_current_screen()`
529 + * is not available yet — the global WordPress sets while resolving the
530 + * request is, and it is what admin-side code keys on this early.
531 + */
532 + private function is_plugins_screen(): bool {
533 + global $pagenow;
534 + return 'plugins.php' === $pagenow;
535 + }
536 +
537 + /**
538 + * Human name for a module slug, for copy the user reads.
539 + *
540 + * The notice showed the raw slug ("object-cache"), which is an internal
541 + * identifier — it tells someone nothing about which feature needs their
542 + * attention. Unknown slugs fall back to a title-cased form rather than
543 + * being dropped, so a Pro module added later still reads sensibly.
544 + */
545 + private static function module_label( string $slug ): string {
546 + $labels = array(
547 + 'cache' => __( 'Page Cache', 'xspeed' ),
548 + 'minify' => __( 'Minify', 'xspeed' ),
549 + 'lazy' => __( 'Lazy Load', 'xspeed' ),
550 + 'preloader' => __( 'Preloader', 'xspeed' ),
551 + 'browser-cache' => __( 'Browser Cache', 'xspeed' ),
552 + 'object-cache' => __( 'Object Cache', 'xspeed' ),
553 + 'gzip' => __( 'Compression', 'xspeed' ),
554 + 'bloat' => __( 'Bloat Removal', 'xspeed' ),
555 + 'cdn' => __( 'CDN', 'xspeed' ),
556 + );
557 + if ( isset( $labels[ $slug ] ) ) {
558 + return $labels[ $slug ];
559 + }
560 + return ucwords( str_replace( array( '-', '_' ), ' ', $slug ) );
561 + }
562 +
563 + /**
564 + * Migrate from the notice, without leaving the screen.
565 + *
566 + * The notice's Import control was an `<a href>` pointing at the dashboard,
567 + * so pressing it navigated to the Migration panel and asked the user to
568 + * find the button again. The offer and the action lived on different
569 + * screens.
570 + *
571 + * Dispatches through rest_apply() rather than reimplementing it: the
572 + * import, the deactivation gate, the activity record and the partial-
573 + * failure reporting are all decided in one place, so this path cannot
574 + * drift from the panel, WP-CLI or MCP. Deactivation stays the caller's
575 + * explicit choice and is passed through as such. (#189)
576 + */
577 + public function ajax_migrate_now(): void {
578 + if ( ! current_user_can( 'manage_options' ) ) {
579 + wp_send_json_error( array( 'message' => __( 'You do not have permission to migrate settings.', 'xspeed' ) ), 403 );
580 + }
581 + check_ajax_referer( 'xspeed_migrate_now', 'nonce' );
582 +
583 + $source = isset( $_POST['source'] ) ? sanitize_key( wp_unslash( $_POST['source'] ) ) : '';
584 + if ( '' === $source ) {
585 + wp_send_json_error( array( 'message' => __( 'No source selected.', 'xspeed' ) ), 400 );
586 + }
587 + $deactivate = ! empty( $_POST['deactivate'] );
588 +
589 + // Same request shape rest_apply() reads, so one implementation serves
590 + // the panel, the CLI, MCP and this notice.
591 + $request = new \WP_REST_Request( 'POST', '/xspeed/v1/migration/apply' );
592 + $request->set_body( (string) wp_json_encode(
593 + array(
594 + 'source' => $source,
595 + 'deactivate_source' => (bool) $deactivate,
596 + )
597 + ) );
598 + $request->set_header( 'content-type', 'application/json' );
599 +
600 + $result = $this->rest_apply( $request );
601 + if ( is_wp_error( $result ) ) {
602 + wp_send_json_error( array( 'message' => $result->get_error_message() ), 400 );
603 + }
604 +
605 + $data = $result instanceof \WP_REST_Response ? $result->get_data() : (array) $result;
606 +
607 + // Report the SAME three outcomes the panel distinguishes: clean,
608 + // partial, and "imported but the old plugin is still running". A
609 + // notice that only ever said "Done" would hide a half-applied import.
610 + $failed = array();
611 + foreach ( (array) ( $data['results'] ?? array() ) as $slug => $r ) {
612 + if ( empty( $r['ok'] ) ) {
613 + // Human label, not the raw slug — "Object Cache", not
614 + // "object-cache" — and the message trimmed of its own
615 + // terminator so the sentence does not end in "6379..".
616 + $msg = trim( (string) ( $r['message'] ?? '' ) );
617 + $msg = '' !== $msg ? rtrim( $msg, '. ' ) : __( 'it could not be enabled', 'xspeed' );
618 + // Strip the engine's "Could not enable: " prefix; the sentence
619 + // around it already says that.
620 + $msg = (string) preg_replace( '/^could not enable:\s*/i', '', $msg );
621 + $failed[] = self::module_label( (string) $slug ) . ' — ' . $msg;
622 + }
623 + }
624 +
625 + wp_send_json_success(
626 + array(
627 + 'label' => (string) ( $data['source_label'] ?? '' ),
628 + 'deactivated' => ! empty( $data['deactivated'] ),
629 + 'refused' => (string) ( $data['refused_message'] ?? '' ),
630 + 'failed' => $failed,
631 + 'panel_url' => admin_url( 'admin.php?page=xspeed' ) . '#migration',
632 + )
633 + );
634 + }
635 +
636 + /**
637 + * The notice's own behaviour: migrate in place, then report the outcome
638 + * where the offer was.
639 + *
640 + * Inline rather than an enqueued file because admin_notices renders on
641 + * every admin screen and this is a few lines — a separate request for it
642 + * would cost more than it saves. No jQuery: the notice must work on
643 + * screens that do not load it.
644 + */
645 + private function print_notice_script(): void {
646 + $ajax = esc_url_raw( admin_url( 'admin-ajax.php' ) );
647 + ?>
648 +<script>
649 +(function () {
650 + var box = document.currentScript && document.currentScript.closest('.xspeed-mig');
651 + if (!box) { return; }
652 + var nonce = box.getAttribute('data-nonce') || '';
653 +
654 + /**
655 + * Render the outcome in the SAME card shape as the offer — icon tile,
656 + * heading, body, meta — rather than dropping to a bare paragraph. The
657 + * result is the last thing the user sees from this feature; a plain
658 + * sentence where a designed card was reads as something having gone
659 + * wrong even when nothing did.
660 + *
661 + * `tone` colours only the tile and the left rule:
662 + * ok — the import succeeded. Also covers the case where an
663 + * optional module could not enable itself (no Redis for the
664 + * object cache, say): the migration did what it was asked,
665 + * and the server's own limits are not a failure of it. That
666 + * detail rides along in the meta line as "Skipped: …",
667 + * which is a footnote rather than a task.
668 + * partial — kept for a genuinely mixed result.
669 + * error — nothing was imported
670 + */
671 + function say(tone, title, body, meta) {
672 + var colour = tone === 'error' ? '#dc2626' : (tone === 'partial' ? '#d97706' : '#16a34a');
673 + var glyph = tone === 'error'
674 + ? '<path d="M18 6 6 18M6 6l12 12"/>'
675 + : (tone === 'partial'
676 + ? '<path d="M12 9v4M12 17h.01M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0z"/>'
677 + : '<path d="M20 6 9 17l-5-5"/>');
678 +
679 + box.style.borderLeftColor = colour;
680 + box.innerHTML =
681 + '<div class="xspeed-mig__in">' +
682 + '<span class="xspeed-mig__ico" style="background:' + colour + '">' +
683 + '<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" ' +
684 + 'stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round">' + glyph + '</svg>' +
685 + '</span>' +
686 + '<div class="xspeed-mig__body">' +
687 + '<div class="xspeed-mig__hrow"><span class="xspeed-mig__h">' + title + '</span></div>' +
688 + (body ? '<p class="xspeed-mig__p">' + body + '</p>' : '') +
689 + (meta ? '<p class="xspeed-mig__meta">' + meta + '</p>' : '') +
690 + '</div>' +
691 + '</div>';
692 + }
693 +
694 + box.addEventListener('click', function (e) {
695 + var btn = e.target.closest('.xspeed-mig__cta');
696 + if (!btn) { return; }
697 + e.preventDefault();
698 + if (btn.disabled) { return; }
699 +
700 + var label = btn.textContent;
701 + btn.disabled = true;
702 + btn.innerHTML = '<span class="xspeed-mig__spin"></span> <?php echo esc_js( __( 'Migrating…', 'xspeed' ) ); ?>';
703 +
704 + var body = new URLSearchParams();
705 + body.set('action', 'xspeed_migrate_now');
706 + body.set('nonce', nonce);
707 + body.set('source', btn.getAttribute('data-source') || '');
708 + body.set('deactivate', btn.getAttribute('data-deactivate') || '0');
709 +
710 + fetch(<?php echo wp_json_encode( $ajax ); ?>, {
711 + method: 'POST',
712 + credentials: 'same-origin',
713 + headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
714 + body: body.toString()
715 + })
716 + .then(function (r) { return r.json(); })
717 + .then(function (res) {
718 + if (!res || !res.success) {
719 + throw new Error((res && res.data && res.data.message) || '<?php echo esc_js( __( 'Migration failed.', 'xspeed' ) ); ?>');
720 + }
721 + var d = res.data || {};
722 + var name = d.label || '<?php echo esc_js( __( 'the source plugin', 'xspeed' ) ); ?>';
723 + var panel = '<a href="' + d.panel_url + '"><?php echo esc_js( __( 'Review imported settings', 'xspeed' ) ); ?></a>';
724 +
725 + // Three outcomes, because a flat "Done" would hide a
726 + // half-applied import or a plugin still running.
727 + if (d.failed && d.failed.length) {
728 + // The import SUCCEEDED — one optional group could not be
729 + // enabled (no Redis for the object cache, say). Reporting
730 + // that in red read as a failed migration and sent people
731 + // looking for a problem that was not there.
732 + say(
733 + 'ok',
734 + name + ' <?php echo esc_js( __( 'settings are now running in xSpeed.', 'xspeed' ) ); ?>',
735 + (d.deactivated
736 + ? '<?php echo esc_js( __( 'It has been switched off, so the two page caches cannot conflict.', 'xspeed' ) ); ?>'
737 + : (d.refused || '<?php echo esc_js( __( 'It is still active — switch it off when you are ready.', 'xspeed' ) ); ?>')),
738 + '<?php echo esc_js( __( 'Skipped:', 'xspeed' ) ); ?> ' + d.failed.join('; ') + '. ' + panel
739 + );
740 + return;
741 + }
742 + if (d.deactivated) {
743 + say(
744 + 'ok',
745 + name + ' <?php echo esc_js( __( 'settings are now running in xSpeed.', 'xspeed' ) ); ?>',
746 + '<?php echo esc_js( __( 'It has been switched off, so the two page caches cannot conflict.', 'xspeed' ) ); ?>',
747 + panel
748 + );
749 + return;
750 + }
751 + say(
752 + 'ok',
753 + name + ' <?php echo esc_js( __( 'settings are now running in xSpeed.', 'xspeed' ) ); ?>',
754 + d.refused || '<?php echo esc_js( __( 'It is still active — running two page caches conflicts, so switch it off when you are ready.', 'xspeed' ) ); ?>',
755 + panel
756 + );
757 + })
758 + .catch(function (err) {
759 + btn.disabled = false;
760 + btn.textContent = label;
761 + say(
762 + 'error',
763 + '<?php echo esc_js( __( 'Migration could not finish.', 'xspeed' ) ); ?>',
764 + (err.message || '') + ' <?php echo esc_js( __( 'Nothing was changed — you can try again.', 'xspeed' ) ); ?>',
765 + ''
766 + );
767 + });
768 + });
769 +})();
770 +</script>
771 + <?php
772 + }
773 +
241 774 /** Persist the per-source dismissal when the user clicks our Dismiss link. */
242 775 public function handle_dismiss(): void {
243 776 if ( ! isset( $_GET[ self::DISMISS_ARG ] ) || ! current_user_can( 'manage_options' ) ) {
244 777 return;
@@ -269,8 +802,14 @@
269 802 * @param string[] $dismissed Source ids the user has dismissed.
270 803 * @return array{id:string,label:string,value_count:int}|null
271 804 */
272 805 private function top_detected_source( array $dismissed = array() ): ?array {
806 + // Same optional-collaborator guard as detected_sources(): this feeds
807 + // admin-render paths, so a missing class must degrade to "nothing to
808 + // prompt about" rather than fatal. See detected_sources().
809 + if ( ! class_exists( '\\XSpeed\\Migration' ) ) {
810 + return null;
811 + }
273 812 $best = null;
274 813 foreach ( Migration::status() as $s ) {
275 814 if ( empty( $s['detected'] ) || in_array( $s['id'], $dismissed, true ) ) {
276 815 continue;
@@ -296,9 +835,14 @@
296 835 */
297 836 private function brand_color(): string {
298 837 $brand = apply_filters( 'xspeed_branding', array() );
299 838 $color = isset( $brand['brand_color'] ) ? (string) $brand['brand_color'] : '';
300 - return preg_match( '/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/', $color ) ? $color : '#1e1e1e';
839 + // Falls back to the product's own primary — the muted teal that
840 + // `--primary` carries in assets/theme.css. This notice renders on
841 + // stock admin screens, OUTSIDE #xspeed-app, so the CSS variable does
842 + // not resolve here and the value has to be literal. Keep the two in
843 + // step: a drifting hex is a notice that stops looking like the plugin.
844 + return preg_match( '/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/', $color ) ? $color : '#2AA7A0';
301 845 }
302 846
303 847 public function rest_routes(): array {
304 848 return array(
@@ -355,27 +899,23 @@
355 899
356 900 public function rest_preview( \WP_REST_Request $request ) {
357 901 $params = $request->get_json_params();
358 902 $source = isset( $params['source'] ) ? (string) $params['source'] : '';
359 - $patch = Migration::preview( $source );
360 - if ( null === $patch ) {
903 + $full = Migration::preview_with_notes( $source );
904 + if ( null === $full ) {
361 905 return new \WP_Error( 'xspeed_pro_mig_no_source', 'Source not detected or unknown.', array( 'status' => 404 ) );
362 906 }
363 - return rest_ensure_response( array( 'patch' => $patch ) );
907 + // `notes` carries the lossy-conversion warnings the panel renders
908 + // beside the plan, so a value we had to round is stated rather than
909 + // presented as an exact import. (#224 F2)
910 + return rest_ensure_response(
911 + array(
912 + 'patch' => $full['patch'],
913 + 'notes' => $full['notes'],
914 + )
915 + );
364 916 }
365 917
366 - /**
367 - * Source id → its plugin file (folder/main.php), so we can deactivate the
368 - * source after a successful import. Running two page caches at once causes
369 - * double-caching / conflicting drop-ins, so the source must be turned off.
370 - */
371 - private const SOURCE_PLUGIN_FILE = array(
372 - 'wp-rocket' => 'wp-rocket/wp-rocket.php',
373 - 'w3-total-cache' => 'w3-total-cache/w3-total-cache.php',
374 - 'wp-super-cache' => 'wp-super-cache/wp-cache.php',
375 - 'litespeed-cache' => 'litespeed-cache/litespeed-cache.php',
376 - );
377 -
378 918 public function rest_apply( \WP_REST_Request $request ) {
379 919 $params = $request->get_json_params();
380 920 $source = isset( $params['source'] ) ? (string) $params['source'] : '';
381 921 if ( '' === $source ) {
@@ -380,15 +920,33 @@
380 920 $source = isset( $params['source'] ) ? (string) $params['source'] : '';
381 921 if ( '' === $source ) {
382 922 return new \WP_Error( 'xspeed_pro_mig_no_source', 'Provide a source id.', array( 'status' => 400 ) );
383 923 }
924 +
925 + /*
926 + * Deactivating the source is the CALLER's decision, and it defaults to
927 + * NO. (#189)
928 + *
929 + * This used to happen unconditionally: the request carried only
930 + * `source`, so the server could not distinguish "the user clicked
931 + * through our warning" from any other POST to this route. The only
932 + * guard rail was an InlineConfirm in the React client, which is the
933 + * wrong layer for a destructive action — and WP-CLI and MCP, hitting
934 + * the same product action, did the opposite and left the plugin on.
935 + *
936 + * Defaulting to false rather than true is what makes the documented
937 + * contract true again (docs/user/advanced-migration.md said migration
938 + * "never changes" the old plugin) and matches the house rule that we
939 + * never modify another plugin's state on our own initiative. The panel
940 + * now passes deactivate:true explicitly after its confirm, so the
941 + * common path is unchanged for users.
942 + */
943 + $deactivate = ! empty( $params['deactivate_source'] );
944 +
384 945 $results = Migration::apply( $source );
385 946
386 - // After a successful import, deactivate the source plugin — two page
387 - // caches running together double-cache and fight over the drop-in.
388 - // We report what we did so the panel can tell the user plainly.
389 - $deactivated = false;
390 - $source_label = '';
947 + $deactivated = false;
948 + $source_label = '';
391 949 foreach ( Migration::status() as $s ) {
392 950 if ( $s['id'] === $source ) {
393 951 $source_label = (string) $s['label'];
394 952 break;
@@ -393,12 +951,79 @@
393 951 $source_label = (string) $s['label'];
394 952 break;
395 953 }
396 954 }
397 - if ( ! empty( $results ) ) {
398 - $deactivated = $this->deactivate_source( $source );
955 +
956 + /*
957 + * Did the import actually cover anything?
958 + *
959 + * `applied` lists the meaningful keys the import attempted, so it is the
960 + * right signal for the activity and Health records below. Deactivation
961 + * has a stronger gate: every attempted result must also report `ok`, so a
962 + * partial import never switches the source off. (#189, #224)
963 + */
964 + $imported_something = false;
965 + foreach ( (array) $results as $info ) {
966 + if ( is_array( $info ) && ! empty( $info['applied'] ) ) {
967 + $imported_something = true;
968 + break;
969 + }
399 970 }
400 971
972 + $refused = '';
973 + $refused_message = '';
974 + $import_completed = Migration::completed_successfully( (array) $results );
975 + // Gate the handover on the modules that MATTER, not on every one.
976 + // completed_successfully() is all-or-nothing, so a host without Redis
977 + // — where the object cache can never enable — vetoed the deactivation
978 + // the notice had already promised, and the user was left running two
979 + // page caches. safe_to_hand_over() ignores the optional extras and
980 + // still refuses if page caching itself did not take. (#189)
981 + if ( $deactivate && Migration::safe_to_hand_over( (array) $results ) ) {
982 + if ( ! $this->can_deactivate( $source ) ) {
983 + // Not an error: the import succeeded and is the thing the user
984 + // asked for. Report the refusal so the panel can say why the
985 + // plugin is still on rather than silently implying it is off.
986 + $refused = 'insufficient_capability';
987 +
988 + // Name WHO can do it, not just that the caller cannot. On a
989 + // network-activated source the answer is specifically a network
990 + // administrator, and a site admin has no way to work that out
991 + // from a bare capability code. (#189 AC5)
992 + $file = Migration::plugin_file( $source );
993 + $network_scoped = is_multisite() && '' !== $file && is_plugin_active_for_network( $file );
994 + $refused_message = $network_scoped
995 + ? sprintf(
996 + /* translators: %s: source plugin label. */
997 + __( '%s is activated across the whole network, so only a network administrator can switch it off. Your settings were imported — ask a network administrator to deactivate it.', 'xspeed' ),
998 + $source_label
999 + )
1000 + : sprintf(
1001 + /* translators: %s: source plugin label. */
1002 + __( 'Your account can change settings but not switch plugins off, so %s is still active. Your settings were imported — ask an administrator to deactivate it.', 'xspeed' ),
1003 + $source_label
1004 + );
1005 + } else {
1006 + $deactivated = $this->deactivate_source( $source );
1007 + if ( $deactivated ) {
1008 + // Switching the source off runs ITS teardown, which
1009 + // removes WP_CACHE and can take the shared drop-in file
1010 + // with it — leaving our own page cache configured-on but
1011 + // not actually serving. Re-assert both. (#219)
1012 + $this->restore_own_environment();
1013 + }
1014 + }
1015 + }
1016 +
1017 + // The user declined (or was refused) and the source is still running.
1018 + // Record it so the warning OUTLIVES this screen — see pending_source().
1019 + // Called on every import, not just the declining ones: the helper
1020 + // checks the plugin's live state and clears itself when it is off, so
1021 + // a later "import and switch" also resolves an earlier warning.
1022 + if ( $imported_something ) {
1023 + Migration::remember_active_source( $source, $source_label );
1024 + }
1025 +
401 1026 if ( class_exists( '\\XSpeed\\Activity_Log' ) && ! empty( $results ) ) {
402 1027 \XSpeed\Activity_Log::record(
403 1028 'migration_applied',
404 1029 $deactivated
@@ -409,24 +1034,77 @@
409 1034 }
410 1035
411 1036 return rest_ensure_response(
412 1037 array(
413 - 'results' => $results,
414 - 'deactivated' => $deactivated,
415 - 'source_label' => $source_label,
1038 + 'results' => $results,
1039 + 'deactivated' => $deactivated,
1040 + 'source_label' => $source_label,
1041 + // Empty unless we were asked to deactivate and declined to.
1042 + // The panel needs to distinguish "you didn't ask" from "you
1043 + // asked and you may not", or it would report the source as
1044 + // still active with no explanation.
1045 + 'refused' => $refused,
1046 + // A ready-to-show sentence naming who CAN do it. The panel
1047 + // prints this verbatim rather than mapping codes to copy, so
1048 + // the network-vs-site distinction stays in one place.
1049 + 'refused_message' => $refused_message,
416 1050 )
417 1051 );
418 1052 }
419 1053
420 1054 /**
1055 + * May the CURRENT user switch this source plugin off?
1056 + *
1057 + * The route itself only requires `manage_options` (the module default),
1058 + * which is right for importing settings — that writes nothing but our own
1059 + * options. Deactivating somebody else's plugin is a different act, and WP
1060 + * core guards its own plugins screen with `activate_plugins`, escalating
1061 + * to `manage_network_plugins` for a network-active plugin.
1062 + *
1063 + * Without this check a subsite Administrator — who has manage_options but
1064 + * neither of those — could deactivate a NETWORK-ACTIVE caching plugin
1065 + * across every site in the network with one REST call. Reproduced on a
1066 + * live multisite install for #189; core would have refused the same user
1067 + * on wp-admin/plugins.php.
1068 + *
1069 + * @param string $source Source id.
1070 + */
1071 + private function can_deactivate( string $source ): bool {
1072 + $file = Migration::plugin_file( $source );
1073 + if ( '' === $file ) {
1074 + return false;
1075 + }
1076 +
1077 + foreach ( array( 'plugin.php' ) as $inc ) {
1078 + require_once ABSPATH . 'wp-admin/includes/' . $inc;
1079 + }
1080 +
1081 + // Network-active plugins are a network-level object: deactivating one
1082 + // affects every site, so it needs the network capability regardless of
1083 + // how much power the caller holds on this one site.
1084 + if ( is_multisite() && is_plugin_active_for_network( $file ) ) {
1085 + return current_user_can( 'manage_network_plugins' );
1086 + }
1087 +
1088 + return current_user_can( 'activate_plugins' );
1089 + }
1090 +
1091 + /**
421 1092 * Deactivate the source caching plugin (network-wide on multisite).
422 1093 * Returns true only if it was active and is now off.
423 1094 *
1095 + * Callers MUST gate this on can_deactivate() — it performs no capability
1096 + * check of its own, because the CLI path resolves permission differently
1097 + * (a WP-CLI operator is root by definition and has no current user).
1098 + *
424 1099 * @param string $source Source id.
425 1100 * @return bool
426 1101 */
427 1102 private function deactivate_source( string $source ): bool {
428 - $file = self::SOURCE_PLUGIN_FILE[ $source ] ?? '';
1103 + // One home for this map, shared with Migration::status()'s active
1104 + // flag. A private copy here could drift and deactivate a plugin the
1105 + // panel had reported as inactive. (#189)
1106 + $file = Migration::plugin_file( $source );
429 1107 if ( '' === $file ) {
430 1108 return false;
431 1109 }
432 1110 // deactivate_plugins() fires each plugin's deactivation hook, and some
@@ -440,12 +1118,84 @@
440 1118 }
441 1119 if ( ! is_plugin_active( $file ) ) {
442 1120 return false;
443 1121 }
444 - deactivate_plugins( $file ); // network-wide if it was network-active.
1122 +
1123 + /*
1124 + * Be EXPLICIT about scope rather than leaving $network_wide at null.
1125 + *
1126 + * Core evaluates `( false !== $network_wide ) && is_plugin_active_for_network()`,
1127 + * and `false !== null` is true — so the default silently takes the
1128 + * network-wide branch. That is the correct scope for a network-active
1129 + * plugin (a per-site deactivation would not turn it off anyway), but
1130 + * it should be a decision we state, not a fact of PHP's comparison
1131 + * rules. can_deactivate() has already required the matching
1132 + * capability for whichever branch this picks. (#189)
1133 + */
1134 + $network_wide = is_multisite() && is_plugin_active_for_network( $file );
1135 + deactivate_plugins( $file, false, $network_wide );
1136 +
1137 + /*
1138 + * The source's teardown just rewrote the very state the detector
1139 + * memoizes for the request -- WP Rocket truncates advanced-cache.php
1140 + * to 0 bytes and W3TC strips WP_CACHE, both from inside the call
1141 + * above. Without dropping the memo, restore_own_environment() asks a
1142 + * report taken while the source still held the field, sees "foreign",
1143 + * and refuses -- so the handover deactivated the source and then
1144 + * declined to take over, which is the outcome #391 describes.
1145 + */
1146 + if ( class_exists( '\\XSpeed\\Page_Cache_Detector' ) ) {
1147 + \XSpeed\Page_Cache_Detector::invalidate();
1148 + }
1149 +
445 1150 return ! is_plugin_active( $file );
446 1151 }
447 1152
1153 + /**
1154 + * Put our own drop-in and WP_CACHE back after the source plugin's
1155 + * teardown, when we are the one that should own them.
1156 + *
1157 + * A source plugin's deactivation routine cleans up "the page cache
1158 + * environment" without checking whose it is. W3 Total Cache is the
1159 + * clearest case: PgCache_Environment.php strips EVERY
1160 + * `define( 'WP_CACHE', … )` line from wp-config.php with a blanket
1161 + * regex, so it deletes the line xSpeed wrote when the wizard enabled
1162 + * caching. wp-content/advanced-cache.php survives, but WordPress never
1163 + * loads it without the constant, and the cache silently degrades to the
1164 + * slow in-PHP path — measured at 78ms vs 16ms TTFB on an otherwise
1165 + * identical request.
1166 + *
1167 + * Runs at exactly one moment -- the user asked to import from another
1168 + * plugin AND switch it off, and we just switched it off -- so it turns
1169 + * caching ON rather than only re-asserting an existing setting. It used
1170 + * to return early unless cache_enabled was already set, which it almost
1171 + * never is here: the site was being cached by the plugin we just
1172 + * deactivated. That is how a migration could end with the source gone
1173 + * and nothing serving. (#218, #219, #391)
1174 + *
1175 + * Not a licence to trample: toggle() still refuses on an occupied field,
1176 + * so a page cache we were not asked to replace is left alone.
1177 + */
1178 + private function restore_own_environment(): void {
1179 + if ( ! class_exists( '\\XSpeed\\Cache' ) || ! class_exists( '\\XSpeed\\Settings' ) ) {
1180 + return;
1181 + }
1182 +
1183 + /*
1184 + * Turn caching ON, rather than only re-asserting it when it was
1185 + * already on. This runs at exactly one moment: the user asked us to
1186 + * import from another plugin AND switch it off, and we just did. A
1187 + * handover that ends with the old cache gone and no new one is not a
1188 + * handover -- and cache_enabled is nearly always empty here, because
1189 + * the site was being cached by the plugin we just deactivated. That
1190 + * early return is why #391 ended with nothing serving.
1191 + *
1192 + * toggle() still refuses if the field is genuinely occupied, so this
1193 + * cannot trample a cache we were not asked to replace.
1194 + */
1195 + \XSpeed\Cache::toggle( true );
1196 + }
1197 +
448 1198 public function cli_commands(): array {
449 1199 return array(
450 1200 array(
451 1201 'name' => 'xspeed migrate',
@@ -450,8 +1200,9 @@
450 1200 array(
451 1201 'name' => 'xspeed migrate',
452 1202 'callback' => array( $this, 'cli_handler' ),
453 1203 'shortdesc' => 'Import settings from another caching plugin.',
1204 + 'ai_hint' => 'Import settings from another caching plugin (WP Rocket, W3 Total Cache, LiteSpeed, WP Super Cache). Use when a site is switching to xSpeed and the user does not want to reconfigure by hand.',
454 1205 'synopsis' => array(
455 1206 array(
456 1207 'type' => 'positional',
457 1208 'name' => 'action',
@@ -462,8 +1213,14 @@
462 1213 'type' => 'assoc',
463 1214 'name' => 'source',
464 1215 'optional' => true,
465 1216 ),
1217 + array(
1218 + 'type' => 'flag',
1219 + 'name' => 'deactivate-source',
1220 + 'description' => 'After a successful import, also deactivate the source plugin. Off by default: running two page caches at once breaks both, but switching off another plugin is your call, not ours.',
1221 + 'optional' => true,
1222 + ),
466 1223 ),
467 1224 ),
468 1225 );
469 1226 }
@@ -490,11 +1247,110 @@
490 1247 if ( empty( $r ) ) {
491 1248 \WP_CLI::error( 'Nothing imported.' );
492 1249 }
493 1250 foreach ( $r as $mod => $info ) {
494 - \WP_CLI::log( sprintf( '%-20s %s — %s', $mod, $info['ok'] ? 'ok' : 'failed', implode( ',', $info['applied'] ) ) );
1251 + /*
1252 + * "failed" was a lie. `ok` is update_option()'s return,
1253 + * which is false when the stored value did not CHANGE — so
1254 + * re-importing settings already in place printed
1255 + * "failed" for every module beside the list of fields it
1256 + * had just imported correctly. Report what actually
1257 + * happened instead. (#189)
1258 + */
1259 + $applied = (array) ( $info['applied'] ?? array() );
1260 + if ( empty( $applied ) ) {
1261 + $state = 'nothing to import';
1262 + } elseif ( ! empty( $info['ok'] ) ) {
1263 + $state = 'imported';
1264 + } else {
1265 + $state = 'already up to date';
1266 + }
1267 + \WP_CLI::log( sprintf( '%-20s %-18s %s', $mod, $state, implode( ',', $applied ) ) );
495 1268 }
1269 +
1270 + /*
1271 + * Same contract as REST: deactivate only when asked. This path
1272 + * used to never deactivate AND never say so, so an operator
1273 + * (or an AI through MCP `run_command`) finished with two page
1274 + * caches live on the site and nothing in the output to say it.
1275 + * That is the failure mode the troubleshooting docs describe
1276 + * as breaking caching for both plugins. (#189)
1277 + *
1278 + * No capability check here: a WP-CLI caller is root by
1279 + * definition and there is no current user to test. The gate
1280 + * that matters is on the REST route, which is the one a
1281 + * browser can reach.
1282 + */
1283 + // `applied`, not `ok` — see rest_apply() for why ok:false is a
1284 + // normal outcome of a successful re-import.
1285 + $imported_something = false;
1286 + foreach ( (array) $r as $info ) {
1287 + if ( is_array( $info ) && ! empty( $info['applied'] ) ) {
1288 + $imported_something = true;
1289 + break;
1290 + }
1291 + }
1292 +
1293 + // WP-CLI normalises --deactivate-source to a 'deactivate-source'
1294 + // key; accept the underscore spelling too so MCP callers passing
1295 + // options as JSON don't have to guess which one we mean.
1296 + $want_off = ! empty( $assoc['deactivate-source'] ) || ! empty( $assoc['deactivate_source'] );
1297 + $file = Migration::plugin_file( $src );
1298 +
1299 + require_once ABSPATH . 'wp-admin/includes/plugin.php';
1300 + $still_on = '' !== $file && is_plugin_active( $file );
1301 +
1302 + if ( $want_off && $imported_something && $still_on ) {
1303 + if ( $this->deactivate_source( $src ) ) {
1304 + \WP_CLI::log( sprintf( 'Deactivated %s.', $src ) );
1305 + $still_on = false;
1306 + // Same handover the REST route performs. Without it
1307 + // the CLI switched the source off and stopped there,
1308 + // leaving the husk of its drop-in and no page cache
1309 + // at all -- reported as "Success: Import complete."
1310 + // CLI and REST must not disagree about what
1311 + // --deactivate-source means. (#391)
1312 + $this->restore_own_environment();
1313 + } else {
1314 + \WP_CLI::warning( sprintf( 'Could not deactivate %s.', $src ) );
1315 + }
1316 + }
1317 +
1318 + if ( $still_on ) {
1319 + \WP_CLI::warning(
1320 + sprintf(
1321 + '%s is still active. Two page caches running together fight over the drop-in and can break caching for both — deactivate it, or re-run with --deactivate-source.',
1322 + $src
1323 + )
1324 + );
1325 + }
1326 +
1327 + // Same persistent record as the REST path, so a CLI or MCP
1328 + // import that leaves the source running also raises the Health
1329 + // warning — the three surfaces must end in the same state for
1330 + // the same input. (#189 AC4, AC10)
1331 + if ( $imported_something ) {
1332 + $label = '';
1333 + foreach ( Migration::status() as $s ) {
1334 + if ( $s['id'] === $src ) {
1335 + $label = (string) $s['label'];
1336 + break;
1337 + }
1338 + }
1339 + Migration::remember_active_source( $src, $label );
1340 + }
1341 +
496 1342 \WP_CLI::success( 'Import complete.' );
497 1343 return;
1344 + default:
1345 + // Without this, an unrecognised action fell out of the switch
1346 + // and returned success with no output — indistinguishable from
1347 + // "ran fine, nothing to report", and ok:true over MCP.
1348 + \WP_CLI::error(
1349 + sprintf(
1350 + 'Unknown action "%s". Expected: status | preview --source=<id> | apply --source=<id>.',
1351 + $action
1352 + )
1353 + );
498 1354 }
499 1355 }
500 1356 }