PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.1.3
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.1.3
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 1.1.3 1.1.4 1.1.5 1.1.6 1.1.7 1.1.8 All 29 releases
xspeed / includes / class-onboarding.php

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

364 lines 12.3 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 wp_localize_script(
168 'xspeed-admin',
169 'XSpeedConfig',
170 array(
171 'mode' => 'onboarding',
172 'restUrl' => esc_url_raw( rest_url( Rest_Api::NAMESPACE_V1 ) ),
173 'nonce' => wp_create_nonce( 'wp_rest' ),
174 'version' => XSPEED_VERSION,
175 // White-label branding must reach the wizard too — without it
176 // the onboarding chrome shows the default "xSpeed" even when an
177 // agency has rebranded. (FBS white-label-onboarding)
178 'branding' => Admin::branding(),
179 'dashboardUrl' => admin_url( 'admin.php?page=' . Admin::PAGE_SLUG ),
180 'bootstrap' => array(
181 'settings' => Settings::get(),
182 'env' => self::env_payload(),
183 // xSpeed Hub connection snapshot for the wizard's first
184 // "Connect your account" step. Guarded so core onboarding
185 // never hard-depends on the MCP module — if it's absent the
186 // step falls back to its own /mcp/hub fetch (and simply shows
187 // the not-connected invite). See DESIGN.md §24.x.
188 'hub' => self::hub_payload(),
189 ),
190 )
191 );
192 }
193
194 /**
195 * Environment snapshot rendered as Step 1's health rows. Pure read —
196 * never writes to disk, never makes outbound requests.
197 */
198 public static function env_payload() {
199 // Single source of truth for environment checks lives in
200 // XSpeed\Health. Both the Onboarding wizard and the Health
201 // module's dashboard panel consume from there.
202 return Health::env_payload();
203 }
204
205 /**
206 * xSpeed Hub connection snapshot for the wizard's first step. Identical
207 * shape to the MCP panel's `GET /mcp/hub` response so the Connect step and
208 * the panel's Hub card share one contract. Returns null when the MCP module
209 * is unavailable — the step then renders its own not-connected invite and
210 * re-fetches live from /mcp/hub if the route exists.
211 *
212 * @return array<string,mixed>|null
213 */
214 public static function hub_payload() {
215 if ( ! class_exists( '\XSpeed\Modules\Mcp\Mcp_Hub' ) ) {
216 return null;
217 }
218 $status = \XSpeed\Modules\Mcp\Mcp_Hub::public_status();
219 // Override attach_url so the Hub returns the user to the WIZARD (mid-flow)
220 // after they approve — not the default dashboard. The `xspeed_connected`
221 // marker lets the wizard show the connected state + auto-advance on
222 // return. Requires the Hub to honor return_url; if it doesn't yet, the
223 // tab-return reconcile in useHubConnect is the graceful fallback.
224 $return = admin_url( 'admin.php?page=' . self::PAGE_SLUG . '&xspeed_connected=1' );
225 $status['attach_url'] = \XSpeed\Modules\Mcp\Mcp_Hub::attach_url( $return );
226 return $status;
227 }
228
229 public function register_routes() {
230 register_rest_route(
231 Rest_Api::NAMESPACE_V1,
232 '/onboarding/apply',
233 array(
234 'methods' => 'POST',
235 'callback' => array( $this, 'apply' ),
236 'permission_callback' => array( $this, 'permissions' ),
237 )
238 );
239 register_rest_route(
240 Rest_Api::NAMESPACE_V1,
241 '/onboarding/complete',
242 array(
243 'methods' => 'POST',
244 'callback' => array( $this, 'complete' ),
245 'permission_callback' => array( $this, 'permissions' ),
246 )
247 );
248 register_rest_route(
249 Rest_Api::NAMESPACE_V1,
250 '/onboarding/reset',
251 array(
252 'methods' => 'POST',
253 'callback' => array( $this, 'reset' ),
254 'permission_callback' => array( $this, 'permissions' ),
255 )
256 );
257 }
258
259 public function permissions() {
260 return current_user_can( 'manage_options' );
261 }
262
263 /**
264 * Apply the wizard's selected settings + flip the cache drop-in on if
265 * requested. Single REST round-trip so the wizard never lands in a
266 * half-applied state.
267 */
268 public function apply( \WP_REST_Request $request ) {
269 $params = $request->get_json_params();
270 if ( ! is_array( $params ) ) {
271 $params = $request->get_params();
272 }
273
274 $want_cache = ! empty( $params['cache_enabled'] );
275
276 // Per-module settings go through Settings_Manager (the schema-
277 // validated authority for each module). Legacy Settings::update
278 // is reserved for fields still in xspeed_options (cache_expiry,
279 // excluded_urls) until the Cache module migration lands.
280 Settings_Manager::update(
281 'minify',
282 array(
283 'minify_html' => ! empty( $params['minify_html'] ),
284 'minify_css' => ! empty( $params['minify_css'] ),
285 'minify_js' => ! empty( $params['minify_js'] ),
286 'defer_js' => ! empty( $params['defer_js'] ),
287 )
288 );
289 Settings_Manager::update(
290 'gzip',
291 array(
292 'gzip_enabled' => ! empty( $params['gzip_enabled'] ),
293 )
294 );
295 Settings_Manager::update(
296 'cache',
297 array(
298 'cache_expiry' => isset( $params['cache_expiry'] ) ? absint( $params['cache_expiry'] ) : 24,
299 )
300 );
301 // New optional modules surfaced in the wizard — keys are
302 // only updated when present in the payload so legacy
303 // onboarding-complete sites aren't disturbed.
304 if ( array_key_exists( 'lazy_images', $params ) ) {
305 Settings_Manager::update(
306 'lazy',
307 array( 'lazy_images' => ! empty( $params['lazy_images'] ) )
308 );
309 }
310 if ( array_key_exists( 'browser_cache', $params ) ) {
311 Settings_Manager::update(
312 'browser-cache',
313 array( 'enabled' => ! empty( $params['browser_cache'] ) )
314 );
315 }
316 if ( array_key_exists( 'resource_hints', $params ) ) {
317 Settings_Manager::update(
318 'resource-hints',
319 array( 'enabled' => ! empty( $params['resource_hints'] ) )
320 );
321 }
322
323 // Opt-in usage analytics. Only acted on when the key is present in the
324 // payload (legacy onboarding-complete sites are left untouched). The
325 // consent toggle defaults OFF in the wizard, so the common path is
326 // usage_tracking=false → tracker stays dormant, no outbound HTTP.
327 if ( array_key_exists( 'usage_tracking', $params ) ) {
328 $tracker = Plugin::instance()->usage_tracker();
329 if ( $tracker ) {
330 $tracker->opt_in( ! empty( $params['usage_tracking'] ) );
331 }
332 }
333
334 $install_state = Cache::toggle( $want_cache );
335 Settings::update( array( 'cache_enabled' => $install_state['enabled'] ) );
336
337 // Recompute the unified nginx block AFTER cache_enabled is persisted.
338 // Cache::toggle() computes it inline, before the Settings::update()
339 // above writes cache_enabled — so the block in $install_state reflects
340 // the PRE-apply state (CacheModule::nginx_directives() gates on
341 // cache_enabled). Regenerate so the wizard's Done step shows the
342 // snippet for the configuration the user just applied. Mirrors the
343 // same fix in Rest_Api::toggle_cache().
344 $install_state['nginx_server_block'] = Cache::full_nginx_server_block();
345
346 return rest_ensure_response(
347 array(
348 'settings' => Settings::get(),
349 'install_state' => $install_state,
350 )
351 );
352 }
353
354 public function complete() {
355 update_option( self::OPT_COMPLETE, 1, false );
356 return rest_ensure_response( array( 'ok' => true ) );
357 }
358
359 public function reset() {
360 delete_option( self::OPT_COMPLETE );
361 return rest_ensure_response( array( 'ok' => true ) );
362 }
363 }
364