PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.1
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.1
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
xspeed / includes / class-onboarding.php

class-onboarding.php in xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN 1.3.1, at includes/class-onboarding.php

410 lines 14.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * First-run onboarding wizard.
4 *
5 * Owns:
6 * - The hidden submenu page (`xspeed-onboarding`) and its single root <div>.
7 * - The activation-triggered redirect to that page.
8 * - The completion flag.
9 * - The environment-check payload + REST routes (`/onboarding/apply`,
10 * `/onboarding/complete`).
11 *
12 * Behavior contract is documented in DESIGN.md §25.
13 *
14 * @package XSpeed
15 */
16
17 namespace XSpeed;
18
19 defined( 'ABSPATH' ) || exit;
20
21 class Onboarding {
22
23 const PAGE_SLUG = 'xspeed-onboarding';
24 const OPT_REDIRECT = 'xspeed_redirect_to_onboarding';
25 const OPT_COMPLETE = 'xspeed_onboarding_complete';
26
27 public function __construct() {
28 add_action( 'admin_menu', array( $this, 'register_menu' ), 20 );
29 add_action( 'admin_init', array( $this, 'maybe_redirect' ) );
30 add_action( 'admin_enqueue_scripts', array( $this, 'enqueue' ) );
31 add_action( 'rest_api_init', array( $this, 'register_routes' ) );
32 }
33
34 /**
35 * Mark the next admin_init to redirect this user to the wizard. Called
36 * from Plugin::activate(). Idempotent.
37 */
38 public static function flag_redirect() {
39 update_option( self::OPT_REDIRECT, 1, false );
40 }
41
42 public static function is_complete() {
43 return (bool) get_option( self::OPT_COMPLETE, 0 );
44 }
45
46 public function register_menu() {
47 // Visible "Setup Wizard" submenu under xSpeed. Stays in the
48 // menu even after onboarding is complete so users can re-run
49 // the wizard any time (a fresh run sets
50 // xspeed_onboarding_complete = false again on completion).
51 // Use the white-label brand in the page <title> so an agency's
52 // rebrand carries through to the wizard tab/title, not just the
53 // dashboard. (FBS white-label-onboarding)
54 $brand = Admin::branding();
55 /* translators: %s: brand name (xSpeed by default, or the white-label name). */
56 $page_title = sprintf( __( '%s Setup Wizard', 'xspeed' ), $brand['name'] );
57 add_submenu_page(
58 Admin::PAGE_SLUG,
59 $page_title,
60 __( 'Setup Wizard', 'xspeed' ),
61 'manage_options',
62 self::PAGE_SLUG,
63 array( $this, 'render' )
64 );
65 }
66
67 public function render() {
68 // Reuse the dashboard's mount ID so the Tailwind `important` selector
69 // keeps working (utilities are scoped to `#xspeed-app`). The host
70 // class strips the dashboard-only fixed-height/flex shell so the
71 // wizard can lay out as a centered card. See src/styles.css.
72 $dark = 'dark' === Admin::user_theme() ? ' dark' : '';
73 printf(
74 '<div id="xspeed-app" class="xspeed-root xspeed-onboarding-host%s"></div>',
75 esc_attr( $dark )
76 );
77 }
78
79 /**
80 * Send the user to the wizard on the first admin_init after activation.
81 * Skipped for AJAX/REST/cron, bulk-activate flows, and when the wizard
82 * has already been completed.
83 */
84 public function maybe_redirect() {
85 if ( ! get_option( self::OPT_REDIRECT ) ) {
86 return;
87 }
88 if ( wp_doing_ajax() || wp_doing_cron() ) {
89 return;
90 }
91 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only routing decision; we don't process form data here.
92 if ( isset( $_GET['activate-multi'] ) ) {
93 delete_option( self::OPT_REDIRECT );
94 return;
95 }
96 if ( ! current_user_can( 'manage_options' ) ) {
97 return;
98 }
99 if ( self::is_complete() ) {
100 delete_option( self::OPT_REDIRECT );
101 return;
102 }
103
104 delete_option( self::OPT_REDIRECT );
105
106 wp_safe_redirect( admin_url( 'admin.php?page=' . self::PAGE_SLUG ) );
107 exit;
108 }
109
110 public function enqueue( $hook ) {
111 unset( $hook );
112 // Gate on the page SLUG, not the admin hook suffix. WordPress derives
113 // the submenu hook from the *sanitized parent menu title*, so when the
114 // White-Label module renames the menu (e.g. "AcmeSpeed") the hook
115 // becomes "acmespeed_page_xspeed-onboarding" and any check built on
116 // Admin::PAGE_SLUG ("xspeed_page_…") silently stops matching — the
117 // wizard bundle then never enqueues and the page renders blank.
118 // The ?page= slug is brand-independent, so match on that instead.
119 // (FBS-82222)
120 $page = isset( $_GET['page'] ) ? sanitize_key( wp_unslash( $_GET['page'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only screen gate, no state change.
121 if ( self::PAGE_SLUG !== $page ) {
122 return;
123 }
124
125 $asset_js = XSPEED_DIR . 'assets/admin.js';
126 $asset_css = XSPEED_DIR . 'assets/admin.css';
127
128 if ( file_exists( $asset_js ) ) {
129 // filemtime() cache-busts on every rebuild (matches Admin::enqueue)
130 // so a stable version between releases never serves a stale bundle
131 // on the wizard — the connected/login state always reflects the
132 // current build.
133 wp_enqueue_script(
134 'xspeed-admin',
135 XSPEED_URL . 'assets/admin.js',
136 array( 'wp-api-fetch', 'wp-i18n' ),
137 XSPEED_VERSION . '.' . filemtime( $asset_js ),
138 true
139 );
140 // Load .mo translations into window.wp.i18n so the wizard's React
141 // __() calls resolve (mirrors Admin::enqueue). Without this the
142 // onboarding strings render untranslated even when a locale exists.
143 if ( function_exists( 'wp_set_script_translations' ) ) {
144 wp_set_script_translations( 'xspeed-admin', 'xspeed', XSPEED_DIR . 'languages' );
145 }
146 }
147 // Redesign v2 tokens + fonts (see Admin::enqueue) — the wizard shares
148 // them so it matches the dashboard identity.
149 $theme_css = XSPEED_DIR . 'assets/theme.css';
150 if ( file_exists( $theme_css ) ) {
151 wp_enqueue_style(
152 'xspeed-theme',
153 XSPEED_URL . 'assets/theme.css',
154 array(),
155 XSPEED_VERSION . '.' . filemtime( $theme_css )
156 );
157 }
158 if ( file_exists( $asset_css ) ) {
159 wp_enqueue_style(
160 'xspeed-admin',
161 XSPEED_URL . 'assets/admin.css',
162 array( 'xspeed-theme' ),
163 XSPEED_VERSION . '.' . filemtime( $asset_css )
164 );
165 }
166
167 // Same type-preserving path as the dashboard — see
168 // Admin::print_config(). wp_localize_script() would stringify every
169 // scalar in this payload too. (#105)
170 Admin::print_config(
171 'XSpeedConfig',
172 array(
173 'mode' => 'onboarding',
174 'restUrl' => esc_url_raw( rest_url( Rest_Api::NAMESPACE_V1 ) ),
175 'nonce' => wp_create_nonce( 'wp_rest' ),
176 'version' => XSPEED_VERSION,
177 // White-label branding must reach the wizard too — without it
178 // the onboarding chrome shows the default "xSpeed" even when an
179 // agency has rebranded. (FBS white-label-onboarding)
180 'branding' => Admin::branding(),
181 'dashboardUrl' => admin_url( 'admin.php?page=' . Admin::PAGE_SLUG ),
182 'bootstrap' => array(
183 'settings' => Settings::get(),
184 // Live values for every wizard toggle, so re-running the
185 // wizard reflects the site instead of overwriting it.
186 'current' => self::current_choices(),
187 'env' => self::env_payload(),
188 // xSpeed Hub connection snapshot for the wizard's first
189 // "Connect your account" step. Guarded so core onboarding
190 // never hard-depends on the MCP module — if it's absent the
191 // step falls back to its own /mcp/hub fetch (and simply shows
192 // the not-connected invite). See DESIGN.md §24.x.
193 'hub' => self::hub_payload(),
194 ),
195 )
196 );
197 }
198
199 /**
200 * The site's CURRENT values for every toggle the wizard can write,
201 * in the same shape as the wizard's OnboardingChoices.
202 *
203 * The wizard used to seed its toggles from hard-coded preset constants,
204 * so re-running it on a configured site showed a fiction: options the
205 * admin had deliberately switched on rendered as off, and Apply wrote
206 * that fiction back, silently undoing their configuration. Nothing
207 * warned them, and the completion screen still reported success.
208 *
209 * The wizard couldn't have done better on its own — the bootstrap only
210 * carried Settings::get(), which is just cache_enabled. Every other
211 * toggle lives in a per-module option the payload never included, so
212 * this method is what makes "show the site as it actually is" possible.
213 *
214 * Pure read. Mirrors the keys apply() writes, so the two stay in step.
215 *
216 * @return array<string,bool|int>
217 */
218 public static function current_choices() {
219 $minify = Settings_Manager::get( 'minify' );
220 $gzip = Settings_Manager::get( 'gzip' );
221 $cache = Settings_Manager::get( 'cache' );
222 $lazy = Settings_Manager::get( 'lazy' );
223 $browser = Settings_Manager::get( 'browser-cache' );
224 $hints = Settings_Manager::get( 'resource-hints' );
225 $settings = Settings::get();
226
227 return array(
228 'cache_enabled' => ! empty( $settings['cache_enabled'] ),
229 'minify_html' => ! empty( $minify['minify_html'] ),
230 'minify_css' => ! empty( $minify['minify_css'] ),
231 'minify_js' => ! empty( $minify['minify_js'] ),
232 'defer_js' => ! empty( $minify['defer_js'] ),
233 'gzip_enabled' => ! empty( $gzip['gzip_enabled'] ),
234 'lazy_images' => ! empty( $lazy['lazy_images'] ),
235 'browser_cache' => ! empty( $browser['enabled'] ),
236 'resource_hints' => ! empty( $hints['enabled'] ),
237 'cache_expiry' => isset( $cache['cache_expiry'] ) ? absint( $cache['cache_expiry'] ) : \XSpeed\Modules\Cache\CacheModule::DEFAULT_EXPIRY_HOURS,
238 );
239 }
240
241 /**
242 * Environment snapshot rendered as Step 1's health rows. Pure read —
243 * never writes to disk, never makes outbound requests.
244 */
245 public static function env_payload() {
246 // Single source of truth for environment checks lives in
247 // XSpeed\Health. Both the Onboarding wizard and the Health
248 // module's dashboard panel consume from there.
249 return Health::env_payload();
250 }
251
252 /**
253 * xSpeed Hub connection snapshot for the wizard's first step. Identical
254 * shape to the MCP panel's `GET /mcp/hub` response so the Connect step and
255 * the panel's Hub card share one contract. Returns null when the MCP module
256 * is unavailable — the step then renders its own not-connected invite and
257 * re-fetches live from /mcp/hub if the route exists.
258 *
259 * @return array<string,mixed>|null
260 */
261 public static function hub_payload() {
262 if ( ! class_exists( '\XSpeed\Modules\Mcp\Mcp_Hub' ) ) {
263 return null;
264 }
265 $status = \XSpeed\Modules\Mcp\Mcp_Hub::public_status();
266 // Override attach_url so the Hub returns the user to the WIZARD (mid-flow)
267 // after they approve — not the default dashboard. The `xspeed_connected`
268 // marker lets the wizard show the connected state + auto-advance on
269 // return. Requires the Hub to honor return_url; if it doesn't yet, the
270 // tab-return reconcile in useHubConnect is the graceful fallback.
271 $return = admin_url( 'admin.php?page=' . self::PAGE_SLUG . '&xspeed_connected=1' );
272 $status['attach_url'] = \XSpeed\Modules\Mcp\Mcp_Hub::attach_url( $return );
273 return $status;
274 }
275
276 public function register_routes() {
277 register_rest_route(
278 Rest_Api::NAMESPACE_V1,
279 '/onboarding/apply',
280 array(
281 'methods' => 'POST',
282 'callback' => array( $this, 'apply' ),
283 'permission_callback' => array( $this, 'permissions' ),
284 )
285 );
286 register_rest_route(
287 Rest_Api::NAMESPACE_V1,
288 '/onboarding/complete',
289 array(
290 'methods' => 'POST',
291 'callback' => array( $this, 'complete' ),
292 'permission_callback' => array( $this, 'permissions' ),
293 )
294 );
295 register_rest_route(
296 Rest_Api::NAMESPACE_V1,
297 '/onboarding/reset',
298 array(
299 'methods' => 'POST',
300 'callback' => array( $this, 'reset' ),
301 'permission_callback' => array( $this, 'permissions' ),
302 )
303 );
304 }
305
306 public function permissions() {
307 return current_user_can( 'manage_options' );
308 }
309
310 /**
311 * Apply the wizard's selected settings + flip the cache drop-in on if
312 * requested. Single REST round-trip so the wizard never lands in a
313 * half-applied state.
314 */
315 public function apply( \WP_REST_Request $request ) {
316 $params = $request->get_json_params();
317 if ( ! is_array( $params ) ) {
318 $params = $request->get_params();
319 }
320
321 $want_cache = ! empty( $params['cache_enabled'] );
322
323 // Per-module settings go through Settings_Manager (the schema-
324 // validated authority for each module). Legacy Settings::update
325 // is reserved for fields still in xspeed_options (cache_expiry,
326 // excluded_urls) until the Cache module migration lands.
327 Settings_Manager::update(
328 'minify',
329 array(
330 'minify_html' => ! empty( $params['minify_html'] ),
331 'minify_css' => ! empty( $params['minify_css'] ),
332 'minify_js' => ! empty( $params['minify_js'] ),
333 'defer_js' => ! empty( $params['defer_js'] ),
334 )
335 );
336 Settings_Manager::update(
337 'gzip',
338 array(
339 'gzip_enabled' => ! empty( $params['gzip_enabled'] ),
340 )
341 );
342 Settings_Manager::update(
343 'cache',
344 array(
345 'cache_expiry' => isset( $params['cache_expiry'] ) ? absint( $params['cache_expiry'] ) : 24,
346 )
347 );
348 // New optional modules surfaced in the wizard — keys are
349 // only updated when present in the payload so legacy
350 // onboarding-complete sites aren't disturbed.
351 if ( array_key_exists( 'lazy_images', $params ) ) {
352 Settings_Manager::update(
353 'lazy',
354 array( 'lazy_images' => ! empty( $params['lazy_images'] ) )
355 );
356 }
357 if ( array_key_exists( 'browser_cache', $params ) ) {
358 Settings_Manager::update(
359 'browser-cache',
360 array( 'enabled' => ! empty( $params['browser_cache'] ) )
361 );
362 }
363 if ( array_key_exists( 'resource_hints', $params ) ) {
364 Settings_Manager::update(
365 'resource-hints',
366 array( 'enabled' => ! empty( $params['resource_hints'] ) )
367 );
368 }
369
370 // Opt-in usage analytics. Only acted on when the key is present in the
371 // payload (legacy onboarding-complete sites are left untouched). The
372 // consent toggle defaults OFF in the wizard, so the common path is
373 // usage_tracking=false → tracker stays dormant, no outbound HTTP.
374 if ( array_key_exists( 'usage_tracking', $params ) ) {
375 $tracker = Plugin::instance()->usage_tracker();
376 if ( $tracker ) {
377 $tracker->opt_in( ! empty( $params['usage_tracking'] ) );
378 }
379 }
380
381 $install_state = Cache::toggle( $want_cache );
382
383 // Recompute the unified nginx block AFTER cache_enabled is persisted.
384 // Cache::toggle() computes it inline, before the Settings::update()
385 // above writes cache_enabled — so the block in $install_state reflects
386 // the PRE-apply state (CacheModule::nginx_directives() gates on
387 // cache_enabled). Regenerate so the wizard's Done step shows the
388 // snippet for the configuration the user just applied. Mirrors the
389 // same fix in Rest_Api::toggle_cache().
390 $install_state['nginx_server_block'] = Cache::full_nginx_server_block();
391
392 return rest_ensure_response(
393 array(
394 'settings' => Settings::get(),
395 'install_state' => $install_state,
396 )
397 );
398 }
399
400 public function complete() {
401 update_option( self::OPT_COMPLETE, 1, false );
402 return rest_ensure_response( array( 'ok' => true ) );
403 }
404
405 public function reset() {
406 delete_option( self::OPT_COMPLETE );
407 return rest_ensure_response( array( 'ok' => true ) );
408 }
409 }
410