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

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