PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.4.1
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.4.1
1.4.1 1.4.0 1.3.7 1.3.6 1.3.5 1.3.4 1.3.3 1.3.2 1.3.1 1.3.0 1.2.4 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.1.1 1.1.2 All 35 releases
← All changes | includes/class-admin.php +627 -12 1.0.0 → 1.4.1 View file →
@@ -12,27 +12,280 @@
12 12 class Admin {
13 13
14 14 const PAGE_SLUG = 'xspeed';
15 15
16 + const THEME_COOKIE = 'xspeed_theme';
17 +
18 + /**
19 + * The dashboard's sidebar offer reads a module's `purchase_needed` before
20 + * its `status_label`. An add-on checks for this constant before it labels
21 + * a setup step, which an older dashboard would read as "still for sale".
22 + */
23 + const OFFER_READS_PURCHASE_NEEDED = true;
24 +
16 25 public function __construct() {
17 26 add_action( 'admin_menu', array( $this, 'register_menu' ) );
18 27 add_action( 'admin_enqueue_scripts', array( $this, 'enqueue' ) );
19 28 add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_menu_styles' ) );
29 + add_filter( 'admin_body_class', array( __CLASS__, 'admin_body_class' ) );
30 + // Add a "Settings" shortcut to the plugin's row on the Plugins screen,
31 + // deep-linking straight to the xSpeed dashboard. (FBS-83234)
32 + add_filter( 'plugin_action_links_' . plugin_basename( XSPEED_FILE ), array( __CLASS__, 'plugin_action_links' ) );
33 + // Strip third-party admin notices on our screens only. Fires in
34 + // `in_admin_header` (after `get_current_screen()` is populated
35 + // but before notices render) so the screen check is reliable.
36 + add_action( 'in_admin_header', array( __CLASS__, 'suppress_foreign_notices' ), 0 );
20 37 }
21 38
39 + /**
40 + * Remove every third-party admin notice on xSpeed admin screens so the
41 + * dashboard stays visually clean. Scoped via `is_plugin_page()` — runs
42 + * nowhere else. Our own notices stay rendered: register them on the
43 + * dedicated `xspeed_admin_notices` action below, which fires after
44 + * this suppression and is wired to all three core notice hooks.
45 + *
46 + * Hooks cleared: `admin_notices`, `all_admin_notices`,
47 + * `user_admin_notices`, `network_admin_notices`. WordPress's own
48 + * settings-saved / updated messages are emitted via `settings_errors()`
49 + * and printed inline by `options.php` — they are NOT on these hooks
50 + * and are unaffected.
51 + *
52 + * @return void
53 + */
54 + public static function suppress_foreign_notices() {
55 + if ( ! self::is_plugin_page() ) {
56 + return;
57 + }
58 + remove_all_actions( 'admin_notices' );
59 + remove_all_actions( 'all_admin_notices' );
60 + remove_all_actions( 'user_admin_notices' );
61 + remove_all_actions( 'network_admin_notices' );
62 +
63 + // Re-route the four standard notice hooks to a single namespaced
64 + // action so xSpeed (and any deliberate extender that opts in)
65 + // keeps a place to emit notices after the strip.
66 + $relay = static function () {
67 + /**
68 + * Fires in place of WP's `admin_notices` family on xSpeed
69 + * admin screens. Use this instead of `admin_notices` when
70 + * you want a notice to survive xSpeed's third-party
71 + * suppression.
72 + *
73 + * @since 1.0.3
74 + */
75 + do_action( 'xspeed_admin_notices' );
76 + };
77 + add_action( 'admin_notices', $relay );
78 + add_action( 'all_admin_notices', $relay );
79 + add_action( 'user_admin_notices', $relay );
80 + add_action( 'network_admin_notices', $relay );
81 + }
82 +
83 + /**
84 + * Server-side theme detection from the cookie written by useTheme. Used
85 + * to emit the `.dark` class on the React mount node and the
86 + * `xspeed-dark` class on `<body>` during the initial render — kills the
87 + * light→dark flash that happens when JS adds those classes after the
88 + * page has already painted.
89 + *
90 + * @return string 'dark' | 'light'
91 + */
92 + public static function user_theme() {
93 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotValidated, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- read-only string compare; value is sanitized via sanitize_key() on the next line before any use.
94 + $raw = isset( $_COOKIE[ self::THEME_COOKIE ] ) ? wp_unslash( $_COOKIE[ self::THEME_COOKIE ] ) : '';
95 + return 'dark' === sanitize_key( $raw ) ? 'dark' : 'light';
96 + }
97 +
98 + public static function is_plugin_page() {
99 + // Prefer the ?page= slug: it's brand-independent. WordPress derives
100 + // the submenu screen base from the *sanitized parent menu title*, so
101 + // once White-Label renames the menu the base becomes e.g.
102 + // "acmespeed_page_xspeed-onboarding" and any check anchored on
103 + // self::PAGE_SLUG ("xspeed_page_…") silently stops matching — which
104 + // dropped the xspeed-page / xspeed-dark body classes on the wizard and
105 + // left it unstyled. Match the slug instead. (FBS-82222)
106 + $page = isset( $_GET['page'] ) ? sanitize_key( wp_unslash( $_GET['page'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only screen gate.
107 + if ( self::PAGE_SLUG === $page || Onboarding::PAGE_SLUG === $page ) {
108 + return true;
109 + }
110 +
111 + // Fallback for contexts where $_GET['page'] isn't set but the screen is
112 + // available. The toplevel base is slug-based (stable); the submenu base
113 + // is title-derived, so match on its slug SUFFIX rather than the prefix.
114 + $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null;
115 + if ( ! $screen ) {
116 + return false;
117 + }
118 + $base = (string) $screen->base;
119 + return 0 === strpos( $base, 'toplevel_page_' . self::PAGE_SLUG )
120 + || (bool) preg_match( '/_page_' . preg_quote( self::PAGE_SLUG, '/' ) . '($|-)/', $base );
121 + }
122 +
123 + public static function admin_body_class( $classes ) {
124 + if ( ! self::is_plugin_page() ) {
125 + return $classes;
126 + }
127 + $classes .= ' xspeed-page';
128 + // Dashboard-only marker (NOT the onboarding wizard, which shares
129 + // `xspeed-page` + the same admin.css but must scroll as a normal
130 + // centered card). Layout rules that reshape the WP admin chrome — the
131 + // sticky content column that pins the fixed-height dashboard while a
132 + // tall admin menu scrolls — are scoped to `.xspeed-dashboard` so they
133 + // never touch the wizard. Keyed on the ?page= slug (brand-independent).
134 + $page = isset( $_GET['page'] ) ? sanitize_key( wp_unslash( $_GET['page'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only screen gate.
135 + if ( self::PAGE_SLUG === $page ) {
136 + $classes .= ' xspeed-dashboard';
137 + }
138 + if ( 'dark' === self::user_theme() ) {
139 + $classes .= ' xspeed-dark';
140 + }
141 + return $classes;
142 + }
143 +
144 + /**
145 + * Prepend a "Settings" link to the plugin's action links on the Plugins
146 + * screen, deep-linking to the xSpeed dashboard. Only users who can reach
147 + * the settings page (`manage_options`, the same cap the menu uses) see it.
148 + *
149 + * @param array $links Existing action links (Deactivate, etc.).
150 + * @return array
151 + */
152 + public static function plugin_action_links( $links ) {
153 + if ( ! current_user_can( 'manage_options' ) ) {
154 + return $links;
155 + }
156 +
157 + $settings_link = sprintf(
158 + '<a href="%1$s">%2$s</a>',
159 + esc_url( admin_url( 'admin.php?page=' . self::PAGE_SLUG ) ),
160 + esc_html__( 'Settings', 'xspeed' )
161 + );
162 +
163 + array_unshift( $links, $settings_link );
164 +
165 + return $links;
166 + }
167 +
22 168 public function register_menu() {
169 + $brand = self::branding();
170 + // Use the white-label logo for the admin menu icon when set, so the
171 + // rebrand carries to the menu mark too — not just the title. Falls
172 + // back to the built-in xSpeed SVG. (FBS-82222)
173 + $menu_icon = ! empty( $brand['logo_svg'] ) ? $brand['logo_svg'] : self::menu_icon();
174 + // Menu label + page <title> both read the resolved brand name
175 + // ("xSpeed Cache" by default; a white-label brand is used verbatim).
176 + // Positioned just after Appearance
177 + // (WP core slot 60) — up with the site-management group, not buried
178 + // down by Settings and not pinned to the very top.
179 + $menu_label = self::menu_label( $brand );
23 180 add_menu_page(
24 - __( 'xSpeed', 'xspeed' ),
25 - __( 'xSpeed', 'xspeed' ),
181 + $brand['name'],
182 + $menu_label,
26 183 'manage_options',
27 184 self::PAGE_SLUG,
28 185 array( $this, 'render' ),
29 - self::menu_icon(),
30 - 80
186 + $menu_icon,
187 + 61
31 188 );
189 +
190 + // Drop WP's auto-generated duplicate first submenu (which
191 + // inherits the toplevel "xSpeed" title). The group deep-links
192 + // below replace it — keeping it would render a redundant
193 + // "xSpeed" / "Dashboard" row that just re-links to the same
194 + // page as the toplevel entry.
195 + global $submenu;
196 + // $submenu may not yet be populated for this slug; the
197 + // `remove_submenu_page` call covers either case.
198 + remove_submenu_page( self::PAGE_SLUG, self::PAGE_SLUG );
199 +
200 + // Deep-link submenus — each points to the same dashboard page
201 + // with a section hash so React's App.tsx routing lands the
202 + // user inside the right module. WordPress's add_submenu_page
203 + // strips the hash, so we inject directly into $submenu where
204 + // it survives intact (the same trick Yoast / WooCommerce use
205 + // for their per-area shortcuts).
206 + global $submenu;
207 + $deep_links = self::deep_link_items();
208 + foreach ( $deep_links as $hash => $label ) {
209 + $submenu[ self::PAGE_SLUG ][] = array(
210 + $label,
211 + 'manage_options',
212 + 'admin.php?page=' . self::PAGE_SLUG . '#' . $hash,
213 + );
214 + }
32 215 }
33 216
34 217 /**
218 + * Section deep-links rendered under the xSpeed menu.
219 + *
220 + * This is deliberately a SHORT LIST, not a mirror of React's
221 + * SIDEBAR_GROUPS. It used to mirror all eight groups 1:1, which made the
222 + * WP admin rail a second, competing copy of the in-app sidebar — the same
223 + * map rendered twice, one of them permanently expanded and pushing every
224 + * other plugin's menu down the page.
225 + *
226 + * What stays are the entry points a user navigates to from OUTSIDE the
227 + * app: the dashboard itself, the two areas people arrive at with a
228 + * specific errand (AI & agents to connect an assistant, Settings), and the
229 + * wizard. Everything else — Cache, Optimization, Network, Health &
230 + * insights, Tools — is one click away in the app's own sidebar, which is
231 + * where in-app navigation belongs.
232 + *
233 + * So adding a group to React's SIDEBAR_GROUPS no longer means adding it
234 + * here. The two lists are intentionally different lengths now.
235 + *
236 + * Setup Wizard is NOT in this array — it's a real submenu page registered
237 + * by Onboarding::register_menu() on `admin_menu` at priority 20, so it
238 + * lands after these entries.
239 + *
240 + * @return array<string,string> map of route hash → menu label.
241 + */
242 + private static function deep_link_items() {
243 + // Keys are route hashes (`/<group-id>`); the submenu loop prepends '#'.
244 + // React (nav.ts parseRoute) resolves `#/<group-id>` to that landing;
245 + // legacy `#slug` links still redirect, so old bookmarks keep working —
246 + // including bookmarks to the groups no longer listed here.
247 + return array(
248 + '/overview' => __( 'Overview', 'xspeed' ),
249 + '/ai-agents' => __( 'AI & agents', 'xspeed' ),
250 + '/settings' => __( 'Settings', 'xspeed' ),
251 + );
252 + }
253 +
254 + /**
255 + * Pluggable branding for the dashboard chrome. xspeed-pro's
256 + * White-Label module hooks `xspeed_branding` to override these
257 + * values from saved settings.
258 + *
259 + * @return array{name:string,footer_credit:?string,hide_help_links:bool,logo_svg:?string}
260 + * @since 1.5.0
261 + */
262 + public static function branding() {
263 + $defaults = array(
264 + 'name' => 'xSpeed Cache',
265 + 'footer_credit' => null, // null = show the default WPDeveloper credit.
266 + 'hide_help_links' => false,
267 + 'logo_svg' => null, // null = use the built-in brand mark.
268 + );
269 + $out = apply_filters( 'xspeed_branding', $defaults );
270 + if ( ! is_array( $out ) ) {
271 + return $defaults;
272 + }
273 + return array_merge( $defaults, $out );
274 + }
275 +
276 + /**
277 + * Label for the WordPress admin sidebar menu entry — the resolved brand
278 + * name verbatim ("xSpeed Cache" by default, or the white-label name).
279 + *
280 + * @param array{name:string} $brand Resolved branding array.
281 + * @return string
282 + */
283 + private static function menu_label( array $brand ) {
284 + return isset( $brand['name'] ) ? (string) $brand['name'] : 'xSpeed Cache';
285 + }
286 +
287 + /**
35 288 * URL of the SVG menu icon — the official xSpeed brand mark. Uses
36 289 * fill="currentColor" which renders black in <img> context; the inline
37 290 * style below recolors it via CSS filter for the WP admin menu states.
38 291 */
@@ -40,9 +293,24 @@
40 293 return XSPEED_URL . 'assets/icon.svg';
41 294 }
42 295
43 296 public function render() {
44 - echo '<div id="xspeed-app" class="xspeed-root"></div>';
297 + $dark = 'dark' === self::user_theme() ? ' dark' : '';
298 + // Pre-mount skeleton: the React bundle executes a beat after the page
299 + // paints, so without this the mount div is an empty dark void while it
300 + // loads. We render the xSpeed brand mark (pulsing) straight into the
301 + // mount node in PHP; createRoot().render() REPLACES these children the
302 + // instant React boots, so the skeleton disappears with no JS wiring.
303 + // The mark is the bundled icon.svg; `dark:invert` (scoped in
304 + // styles.css) keeps the currentColor mark visible on the dark shell.
305 + printf(
306 + '<div id="xspeed-app" class="xspeed-root%1$s"><div class="xspeed-boot" role="status" aria-label="%2$s"><img class="xspeed-boot-mark" src="%3$s" alt="%4$s" width="48" height="48" /><span class="screen-reader-text">%5$s</span></div></div>',
307 + esc_attr( $dark ),
308 + esc_attr__( 'Loading', 'xspeed' ),
309 + esc_url( XSPEED_URL . 'assets/icon.svg' ),
310 + esc_attr__( 'xSpeed', 'xspeed' ),
311 + esc_html__( 'Loading…', 'xspeed' )
312 + );
45 313 }
46 314
47 315 /**
48 316 * Enqueue the stylesheet that recolors the menu icon to match the WP
@@ -55,8 +323,20 @@
55 323 XSPEED_URL . 'assets/menu-icon.css',
56 324 array(),
57 325 XSPEED_VERSION
58 326 );
327 +
328 + // menu-icon.css recolors the built-in mark (fill=currentColor) to white
329 + // via a brightness/invert filter. A white-label logo is a real image
330 + // (often colored), so cancel the filter for it — otherwise the agency
331 + // logo renders as a white silhouette. (FBS-82222)
332 + $brand = self::branding();
333 + if ( ! empty( $brand['logo_svg'] ) ) {
334 + wp_add_inline_style(
335 + 'xspeed-menu-icon',
336 + '#toplevel_page_' . self::PAGE_SLUG . ' .wp-menu-image img{filter:none;opacity:1}'
337 + );
338 + }
59 339 }
60 340
61 341 public function enqueue( $hook ) {
62 342 if ( 'toplevel_page_' . self::PAGE_SLUG !== $hook ) {
@@ -65,34 +345,110 @@
65 345
66 346 $asset_js = XSPEED_DIR . 'assets/admin.js';
67 347 $asset_css = XSPEED_DIR . 'assets/admin.css';
68 348
349 + // Needed for the media-library picker used by schema 'media' fields
350 + // (e.g. the white-label brand logo). Loads window.wp.media.
351 + wp_enqueue_media();
352 +
69 353 if ( file_exists( $asset_js ) ) {
354 + // filemtime() cache-busts on every rebuild so a stable
355 + // VERSION constant never serves stale JS through browser
356 + // caches.
70 357 wp_enqueue_script(
71 358 'xspeed-admin',
72 359 XSPEED_URL . 'assets/admin.js',
73 - array( 'wp-api-fetch' ),
74 - XSPEED_VERSION,
360 + array( 'wp-api-fetch', 'wp-i18n' ),
361 + XSPEED_VERSION . '.' . filemtime( $asset_js ),
75 362 true
76 363 );
364 + // Loads .mo files for the 'xspeed' text-domain into
365 + // window.wp.i18n so the React `__()` helper resolves.
366 + if ( function_exists( 'wp_set_script_translations' ) ) {
367 + wp_set_script_translations(
368 + 'xspeed-admin',
369 + 'xspeed',
370 + XSPEED_DIR . 'languages'
371 + );
372 + }
77 373 }
78 374
375 + // Redesign v2 design tokens + self-hosted fonts. Hand-written (not
376 + // Vite-bundled) so the @font-face url('./fonts/…') resolve relative to
377 + // assets/. admin.css depends on it so the CSS vars are defined first.
378 + $theme_css = XSPEED_DIR . 'assets/theme.css';
379 + if ( file_exists( $theme_css ) ) {
380 + wp_enqueue_style(
381 + 'xspeed-theme',
382 + XSPEED_URL . 'assets/theme.css',
383 + array(),
384 + XSPEED_VERSION . '.' . filemtime( $theme_css )
385 + );
386 + }
79 387 if ( file_exists( $asset_css ) ) {
80 388 wp_enqueue_style(
81 389 'xspeed-admin',
82 390 XSPEED_URL . 'assets/admin.css',
83 - array(),
84 - XSPEED_VERSION
391 + array( 'xspeed-theme' ),
392 + XSPEED_VERSION . '.' . filemtime( $asset_css )
85 393 );
86 394 }
87 395
88 - wp_localize_script(
89 - 'xspeed-admin',
396 + /**
397 + * Fires after the Free dashboard bundle is enqueued, before its
398 + * config is localized. Pro hooks this to enqueue its own bundle
399 + * with `xspeed-admin` as a dependency, so its panel
400 + * registrations run after `window.XSpeedPro` is installed by
401 + * Free's main.tsx.
402 + *
403 + * @since 1.5.0
404 + */
405 + do_action( 'xspeed_admin_enqueue', $hook );
406 +
407 + // NOT wp_localize_script(). WP_Scripts::localize() casts every scalar
408 + // to a string (wp-includes/class-wp-scripts.php: `(string) $value`),
409 + // so an int arrives in JS as "21600" and a bool as "1" or "".
410 + //
411 + // That silently broke the Pro prewarm scheduler: it guards with
412 + // `typeof gmtOffset === 'number'`, which a string fails, so the site's
413 + // UTC offset was treated as 0 and every one-off warm was scheduled
414 + // against UTC instead of site time — hours late on any non-UTC site,
415 + // under a label that confidently read "Site time (UTC)".
416 + //
417 + // wp_add_inline_script() with wp_json_encode() preserves types, so
418 + // numbers stay numbers and booleans stay booleans. Worth doing beyond
419 + // the one field: every future numeric or boolean config value would
420 + // hit the same trap. (#105)
421 + self::print_config(
90 422 'XSpeedConfig',
91 423 array(
92 424 'restUrl' => esc_url_raw( rest_url( Rest_Api::NAMESPACE_V1 ) ),
93 425 'nonce' => wp_create_nonce( 'wp_rest' ),
94 426 'version' => XSPEED_VERSION,
427 + 'branding' => self::branding(),
428 + // Site's UTC offset in seconds — so datetime pickers (e.g. the
429 + // Pro prewarm scheduler) align with WP's own post-scheduling,
430 + // which is site-time, not the admin's browser-local time.
431 + 'gmtOffset' => (int) round( (float) get_option( 'gmt_offset', 0 ) * HOUR_IN_SECONDS ),
432 + // 'pro' when xspeed-pro is active + speaks our API
433 + // version (see Tier_Registry); 'free' otherwise.
434 + // 'trial' reserved for future license-server work.
435 + 'tier' => class_exists( '\\XSpeed\\Tier_Registry' ) && Tier_Registry::pro_active() ? 'pro' : 'free',
436 + // Three-state Pro status so gated UI can show the RIGHT message:
437 + // 'not_installed' — Pro plugin not active → "Upgrade to Pro"
438 + // 'unlicensed' — Pro active, no valid license → "Activate license"
439 + // 'active' — Pro active + licensed → (modules unlocked)
440 + // Free can't read Pro's license directly (Free never references
441 + // Pro), so Pro filters this via `xspeed_pro_state`. Default:
442 + // not_installed when Pro is absent; 'active' when Pro is present
443 + // (Pro downgrades to 'unlicensed' when its license isn't valid).
444 + 'proState' => self::pro_state(),
445 + // The xCloud purge plugin is here: the site already has
446 + // Cloudflare Enterprise from its host, so the dashboard does
447 + // not offer the add-on.
448 + 'xcloudPurgePlugin' => Managed_Edge_Purge::installed(),
449 + // Setup Wizard URL, surfaced in the sidebar profile popover.
450 + 'wizardUrl' => admin_url( 'admin.php?page=' . Onboarding::PAGE_SLUG ),
95 451 'bootstrap' => self::bootstrap_payload(),
96 452 )
97 453 );
98 454 }
@@ -97,15 +453,105 @@
97 453 );
98 454 }
99 455
100 456 /**
457 + * Emit a JS global for the admin bundle with types intact.
458 + *
459 + * The type-preserving replacement for wp_localize_script(), which
460 + * stringifies every scalar. Attached to the `xspeed-admin` handle as a
461 + * `before` script so it is defined by the time the bundle executes —
462 + * exactly the ordering guarantee localize gave us.
463 + *
464 + * Shared by the dashboard and the onboarding wizard so neither can drift
465 + * back to the stringifying path.
466 + *
467 + * @param string $var_name JS global to define.
468 + * @param array $data Payload; encoded with wp_json_encode().
469 + */
470 + public static function print_config( string $var_name, array $data ): void {
471 + $json = wp_json_encode( $data );
472 + if ( false === $json ) {
473 + // Never emit a broken assignment — the bundle reads this global
474 + // on mount and a syntax error here blanks the whole screen.
475 + $json = '{}';
476 + }
477 + wp_add_inline_script(
478 + 'xspeed-admin',
479 + 'var ' . $var_name . ' = ' . $json . ';',
480 + 'before'
481 + );
482 + }
483 +
484 + /**
101 485 * Pre-rendered settings + status payload, baked into the page so the
102 486 * React app can mount with real values instead of showing a loading state
103 487 * while it waits for /settings and /status REST calls.
104 488 */
489 + /**
490 + * Three-state Pro status for the gated UI ('not_installed' | 'unlicensed'
491 + * | 'active'). Free cannot read Pro's license (it never references Pro),
492 + * so the authoritative value comes from the `xspeed_pro_state` filter that
493 + * xspeed-pro hooks. The default here only distinguishes installed vs not —
494 + * when Pro is active, Pro itself downgrades the value to 'unlicensed' if
495 + * its license isn't valid.
496 + *
497 + * @return string
498 + */
499 + private static function pro_state(): string {
500 + $pro_present = class_exists( '\\XSpeed\\Tier_Registry' ) && Tier_Registry::pro_active();
501 + $default = $pro_present ? 'active' : 'not_installed';
502 +
503 + /**
504 + * Filter: xspeed_pro_state
505 + *
506 + * Lets the Pro plugin report its real license state so Free's gated
507 + * panels can show "Activate license" (Pro installed, unlicensed) vs
508 + * "Upgrade to Pro" (Pro not installed).
509 + *
510 + * @param string $state One of 'not_installed' | 'unlicensed' | 'active'.
511 + */
512 + $state = (string) apply_filters( 'xspeed_pro_state', $default );
513 +
514 + return in_array( $state, array( 'not_installed', 'unlicensed', 'active' ), true ) ? $state : $default;
515 + }
516 +
105 517 private static function bootstrap_payload() {
106 518 $opts = Settings::get();
107 519 $stats = Cache::get_stats();
520 + // Static-rewrite probe state shipped to the React side so the
521 + // dashboard can show a persistent banner when nginx/Apache
522 + // hasn't been wired to bypass PHP yet. Cache-ONLY here (no $allow_probe
523 + // arg) so the dashboard bootstrap never makes the loopback HTTP probe
524 + // — that could add seconds to every admin page load on hosts that
525 + // stall self-requests. The Health tab runs the live probe on demand;
526 + // here we just surface whatever it last cached. (FBS-82142)
527 + $server_type = Server::type();
528 + // LiteSpeed serves hits via the PHP drop-in by design (its .htaccess
529 + // can't add the HIT header or log a static hit), so the static-rewrite
530 + // probe is N/A there — surfacing it would pop the "PHP fallback" nag
531 + // for a setup working as designed. Only nginx + Apache probe.
532 + $rewrite_capable = ( $server_type === Server::NGINX || $server_type === Server::APACHE );
533 + $rewrite_probe = null;
534 + if ( $opts['cache_enabled'] && $rewrite_capable ) {
535 + $probe = Cache::probe_static_rewrite();
536 + $rewrite_probe = array(
537 + 'active' => (bool) ( $probe['active'] ?? false ),
538 + 'server_type' => $server_type,
539 + 'snippet' => Cache::nginx_snippet(), // null on non-nginx hosts
540 + 'topology' => Server::rewrite_topology(),
541 + // A reverse proxy / CDN in front (X-Forwarded-* present) usually
542 + // means the request-terminating nginx isn't user-editable on this
543 + // host — the banner uses this to switch to honest messaging
544 + // instead of dangling a snippet the user can't apply.
545 + 'behind_proxy' => Server::is_behind_proxy(),
546 + // Mirrors /status (see the note there). On first paint the
547 + // probe is whatever was last cached, so this is usually
548 + // `unknown` until the Health tab runs a live probe — the
549 + // dashboard needs the key present either way.
550 + 'rules' => Cache::rules_state( $probe ),
551 + );
552 + }
553 +
108 554 return array(
109 555 'settings' => $opts,
110 556 'status' => array(
111 557 'enabled' => (bool) $opts['cache_enabled'],
@@ -110,13 +556,182 @@
110 556 'status' => array(
111 557 'enabled' => (bool) $opts['cache_enabled'],
112 558 'stats' => $stats,
113 559 'server' => array(
114 - 'type' => Server::type(),
560 + 'type' => $server_type,
115 561 'gzip_mode' => Server::gzip_mode(),
116 562 'gzip_active' => Gzip::probe_active(),
117 563 'nginx_snippet' => Gzip::nginx_snippet(),
118 564 ),
565 + 'rewrite_probe' => $rewrite_probe,
566 + // Separate Mobile Cache visibility (FBS-83145) — mirrors the
567 + // /status block so the dashboard callout renders on first paint
568 + // without waiting for a status re-fetch.
569 + 'mobile_separate' => array(
570 + 'enabled' => (bool) ( $opts['cache_enabled'] ? ( Settings_Manager::get( 'cache' )['mobile_separate'] ?? false ) : false ),
571 + // Gated to servers that HAVE a static fast path — on IIS or
572 + // an undetected server block_reason still falls through to
573 + // mobile_separate, and reporting that as "blocking" would
574 + // nag about a rewrite that does not exist there (#108).
575 + // LiteSpeed joined the capable set with the Static Fast
576 + // Path opt-in (#509); its own refusal (litespeed_dropin)
577 + // outranks mobile_separate, so this stays false there
578 + // until the opt-in is on.
579 + 'blocking' => ( $rewrite_capable || Server::LITESPEED === $server_type )
580 + && 'mobile_separate' === Cache::static_rewrite_block_reason(),
581 + 'needs_review' => Cache::mobile_separate_needs_review(),
582 + ),
583 + // One consolidated nginx server-block snippet aggregating
584 + // every enabled module's directives (Cache static-rewrite,
585 + // BrowserCache headers, GZIP, …). Null on non-nginx hosts
586 + // or when no module contributes directives. Replaces the
587 + // per-module "paste this snippet" notices.
588 + 'nginx_server_block' => Cache::full_nginx_server_block(),
589 + // Mirrors the /status block so the enable-time disclosure is
590 + // correct on FIRST PAINT. Without it the top-bar switch can be
591 + // clicked before a status fetch lands, and the one moment the
592 + // warning exists for — a leftover drop-in about to be
593 + // replaced — is exactly when it would be missing.
594 + 'dropin' => Page_Cache_Detector::dropin_disclosure(),
119 595 ),
596 + // Registered Modules (Free + Pro). The React app discovers them
597 + // here and renders one sidebar item + one panel per module that
598 + // declares a settings schema. Hidden modules are filtered.
599 + 'modules' => self::modules_payload(),
600 + // xSpeed Hub connection snapshot so the Account panel + header chip
601 + // render the correct connected/not-connected state on FIRST PAINT —
602 + // no fetch, no "not connected → connected" flash. Null when the MCP
603 + // module is unavailable (the panel then falls back to /mcp/hub).
604 + 'hub' => self::hub_payload(),
120 605 );
606 + }
607 +
608 + /**
609 + * xSpeed Hub connection snapshot for the dashboard bootstrap. Guarded so the
610 + * dashboard never hard-depends on the MCP module. Same shape as GET /mcp/hub.
611 + *
612 + * @return array<string,mixed>|null
613 + */
614 + private static function hub_payload() {
615 + if ( ! class_exists( '\XSpeed\Modules\Mcp\Mcp_Hub' ) ) {
616 + return null;
617 + }
618 + return \XSpeed\Modules\Mcp\Mcp_Hub::public_status();
619 + }
620 +
621 + /**
622 + * Serialize every available Module for the React dashboard. Each entry
623 + * carries enough to render: identity (slug + tier), UI metadata (label,
624 + * icon, optional description), current settings, and the typed schema
625 + * the panel uses to render controls.
626 + *
627 + * Modules that declare `hidden => true` in ui_metadata (e.g., engine
628 + * modules with no user-facing settings) are skipped.
629 + */
630 + public static function modules_payload() {
631 + if ( ! class_exists( 'XSpeed\\Module_Registry' ) ) {
632 + return array();
633 + }
634 + $out = array();
635 + foreach ( Module_Registry::available() as $slug => $module ) {
636 + $meta = $module->ui_metadata();
637 + if ( ! empty( $meta['hidden'] ) ) {
638 + continue;
639 + }
640 + $schema = $module->settings_schema();
641 + $custom_panel = $meta['custom_panel'] ?? null;
642 + // Skip only when the module has neither a schema nor a custom
643 + // panel — i.e., truly nothing to render in the dashboard.
644 + if ( empty( $schema ) && empty( $custom_panel ) ) {
645 + continue;
646 + }
647 + $settings = Settings_Manager::get_public( $slug );
648 + // Something else on the site has taken over this module's job. It
649 + // stands down whatever its own switch says, so it is reported off,
650 + // with the reason in place of its usual one.
651 + $blocked = $module->blocked_by();
652 + $active = $module->is_active();
653 +
654 + $entry = array(
655 + 'slug' => $slug,
656 + 'tier' => $module->tier(),
657 + 'version' => $module->version(),
658 + // Promoted from inside `settings` so the payload is
659 + // self-describing. Consumers kept tripping on this — the Hub
660 + // rendered every module "Inactive" until it learned to look
661 + // inside the settings bag. `settings.enabled` is kept below
662 + // for back-compat; this is the same value, not a second
663 + // source of truth. Modules with no `enabled` key (status
664 + // panels like Health) report null rather than a misleading
665 + // false. (#146)
666 + 'enabled' => array_key_exists( 'enabled', $settings )
667 + ? (bool) $settings['enabled']
668 + : null,
669 + // Whether the module is actually DOING something, which is not
670 + // the same question as `enabled` above. "On" has several shapes
671 + // -- page caching lives in the global option, Minify and Lazy
672 + // are on when any flag is set, MCP when it is connected -- so
673 + // each module answers for itself via is_active(). Consumers
674 + // that want "what is switched on?" (the sidebar's "N on" badge)
675 + // must read THIS, not `enabled`, which only ever described the
676 + // modules that happen to store that one key. null means the
677 + // module has no meaningful on/off and should be excluded from
678 + // any count rather than treated as off. (#363)
679 + 'active' => ( null !== $blocked && true === $active ) ? false : $active,
680 + // One sentence explaining the line above, computed next to it
681 + // so the two cannot disagree. The UI shows it behind an (i)
682 + // beside the status pill: "On" is a bare assertion otherwise,
683 + // and least obvious exactly where it matters -- Media
684 + // Optimization reads On while its two most prominent switches
685 + // are off, because three other flags are on. (#363)
686 + 'active_reason' => null !== $blocked ? $blocked : $module->active_reason(),
687 + // A purchase or licence the module still waits on. Shown in the
688 + // page header in place of the On/Off pill. Not while another
689 + // module has taken over: that reason is the one to show.
690 + 'status_label' => null !== $blocked ? null : $module->status_label(),
691 + 'label' => $meta['label'] ?? ucfirst( $slug ),
692 + 'icon' => $meta['icon'] ?? 'Square',
693 + 'description' => $meta['description'] ?? '',
694 + // The dashboard group the module belongs to (cache, performance,
695 + // network, insights, tools, ai-agents, settings), declared by the
696 + // module itself. The Hub kept its own copy of this map, so every
697 + // new module landed in its "Other" bucket until the Hub shipped.
698 + // Must agree with src/components/sidebarGroups.ts; a unit test
699 + // holds the two together.
700 + 'group' => $meta['group'] ?? null,
701 + // Short label for the module's own tab when it hosts a tabbed
702 + // page (FBS-83633). Only set on host modules.
703 + 'tab_label' => $meta['tab_label'] ?? null,
704 + // Public view: real values except secret fields, which are masked.
705 + // The dashboard bundle localizes this into page HTML, so a raw
706 + // credential here would be readable from view-source. (#115)
707 + 'settings' => $settings,
708 + // Where each value actually came from: a wp-config.php constant,
709 + // the option row, or the schema default. The panel renders a
710 + // constant-sourced field read-only and names the constant, so it
711 + // can never present an editable box over a value the site is not
712 + // using. Every module gets this, not just the ones that declare
713 + // constants today. (#398)
714 + 'setting_origins' => Settings_Manager::origins( $slug ),
715 + 'schema' => $schema,
716 + 'notices' => $module->ui_notices(),
717 + 'custom_panel' => $meta['custom_panel'] ?? null,
718 + // Why this module may not be switched on, when something else
719 + // on the site has taken over from it. Null for almost every
720 + // module almost always — Free never blocks anything itself.
721 + 'blocked_by' => $blocked,
722 + );
723 +
724 + /**
725 + * Last-mile descriptor filter. Lets Pro (or third-party
726 + * extensions) override any field before the module is
727 + * shipped to React. Primary use: xspeed-pro hooks this to
728 + * swap `custom_panel` to LicenseLockedPanel for Pro modules
729 + * when the license is invalid, so unlocked modules stay
730 + * visible in the sidebar (good upsell UX) but the panel
731 + * shows an activation prompt instead of the real surface.
732 + */
733 + $out[] = apply_filters( 'xspeed_module_descriptor', $entry, $module );
734 + }
735 + return $out;
121 736 }
122 737 }