| @@ -73,26 +73,31 @@ | ||
| 73 | 73 | do_action( 'openstation_chromeless_styles' ); |
| 74 | 74 | return; |
| 75 | 75 | } |
| 76 | 76 | |
| 77 | - if ( ! openstation_is_enabled() || openstation_is_classic_request() ) { | |
| 77 | + if ( ! openstation_is_shell_request() ) { | |
| 78 | 78 | return; |
| 79 | 79 | } |
| 80 | 80 | |
| 81 | - // CSS. | |
| 81 | + // CSS. Only the sheets that paint surfaces present at boot — the | |
| 82 | + // shell chrome, the dock, desktop tiles and pinned notes. Sheets | |
| 83 | + // for on-demand surfaces (Preferences panel, AI assistant, bug | |
| 84 | + // report) ship as `deferredStyles` in the config blob below and | |
| 85 | + // inject on first open; a native window's sheet rides its | |
| 86 | + // registration's `styles` companion list the same way. | |
| 82 | 87 | wp_enqueue_style( 'openstation' ); |
| 83 | 88 | wp_enqueue_style( 'os-windows' ); |
| 84 | 89 | wp_enqueue_style( 'os-window-overview' ); |
| 85 | - wp_enqueue_style( 'os-settings' ); | |
| 86 | 90 | wp_enqueue_style( 'os-dock' ); |
| 87 | 91 | wp_enqueue_style( 'os-dock-peek' ); |
| 88 | - wp_enqueue_style( 'os-notch' ); | |
| 92 | + wp_enqueue_style( 'os-workspaces' ); | |
| 89 | 93 | wp_enqueue_style( 'os-shortcuts' ); |
| 90 | 94 | wp_enqueue_style( 'os-openstation-layout' ); |
| 91 | - wp_enqueue_style( 'desktop-mode-ai-assistant' ); | |
| 92 | - wp_enqueue_style( 'desktop-mode-bug-report' ); | |
| 93 | 95 | wp_enqueue_style( 'os-files' ); |
| 94 | 96 | wp_enqueue_style( 'os-notes' ); |
| 97 | + // Unconditional like the layout sheet: a live crossing into the | |
| 98 | + // phone band must not find the phone layer unstyled. | |
| 99 | + wp_enqueue_style( 'os-mobile' ); | |
| 95 | 100 | |
| 96 | 101 | // Solo mode — a single window freed into a native OS window by the |
| 97 | 102 | // desktop host. Same shell, everything but that one window hidden. |
| 98 | 103 | $solo_window = openstation_solo_window_id(); |
| @@ -161,35 +166,35 @@ | ||
| 161 | 166 | // WP normally only calls this on screens that opt in to the native |
| 162 | 167 | // palette; the shell needs it on every admin URL it might wrap. |
| 163 | 168 | // `function_exists` guard for pre-6.9 sites — the harvester gracefully |
| 164 | 169 | // no-ops when the store is missing. |
| 170 | + // See `openstation_defer_core_command_palette()` below for why | |
| 171 | + // Core's own boot-time enqueue is unhooked on shell pages. | |
| 172 | + // | |
| 173 | + // The Core command-palette runtime is NOT enqueued here any more. | |
| 174 | + // Its dependency chain is the whole Gutenberg runtime (~800 KB | |
| 175 | + // gzipped across forty-odd bundles), paid on every boot for a ⌘K | |
| 176 | + // palette most sessions never open. It now ships as an ordered | |
| 177 | + // manifest in the config blob (`commandPalette`, built by | |
| 178 | + // `openstation_build_command_palette_assets_payload()`), and | |
| 179 | + // `src/commands/palette-assets.ts` replays it the first time the | |
| 180 | + // palette is invoked. The shell harvester keeps its idle-time | |
| 181 | + // `install()` — a graceful no-op until the store exists — and | |
| 182 | + // re-installs on `os-command-palette-ready`. | |
| 183 | + $command_palette = openstation_build_command_palette_assets_payload(); | |
| 184 | + | |
| 165 | 185 | if ( function_exists( 'wp_enqueue_command_palette_assets' ) ) { |
| 166 | - // `wp_enqueue_command_palette_assets()` calls | |
| 167 | - // `array_key_exists( $menu_slug, $submenu )` without guarding | |
| 168 | - // the global, so an unset `$submenu` (test contexts, edge-case | |
| 169 | - // admin requests where the menu wasn't built yet) blows up | |
| 170 | - // with a TypeError. Initialize defensively before calling. | |
| 171 | - global $menu, $submenu; | |
| 172 | - // phpcs:disable WordPress.WP.GlobalVariablesOverride.Prohibited -- initializing an unset global to its documented empty shape, not replacing a built menu. | |
| 173 | - if ( ! isset( $submenu ) || ! is_array( $submenu ) ) { | |
| 174 | - $submenu = array(); | |
| 175 | - } | |
| 176 | - if ( ! isset( $menu ) || ! is_array( $menu ) ) { | |
| 177 | - $menu = array(); | |
| 178 | - } | |
| 179 | - // phpcs:enable WordPress.WP.GlobalVariablesOverride.Prohibited | |
| 180 | - wp_enqueue_command_palette_assets(); | |
| 181 | - | |
| 182 | 186 | // Expose the same menu-commands array WP serializes into |
| 183 | 187 | // `wp.coreCommands.initializeCommandPalette(...)` on a window |
| 184 | 188 | // slot the shell harvester can read. Built in PHP from `$menu` |
| 185 | - // / `$submenu` here (we already guarded that they're arrays | |
| 186 | - // above), then injected as a `before` inline on our own bundle | |
| 187 | - // — that runs synchronously before `desktop.min.js` boots the | |
| 188 | - // shell harvester, so the lookup is guaranteed populated by | |
| 189 | - // the time `src/commands/shell-harvester.ts` classifies any | |
| 190 | - // command. Decoupled from WP's command-palette mount timing | |
| 191 | - // (which fires from a core-registered hook we can't reorder). | |
| 189 | + // / `$submenu`, then injected as a `before` inline on our own | |
| 190 | + // bundle — that runs synchronously before `desktop.min.js` | |
| 191 | + // boots the shell harvester, so the lookup is guaranteed | |
| 192 | + // populated by the time `src/commands/shell-harvester.ts` | |
| 193 | + // classifies any command. Decoupled from WP's command-palette | |
| 194 | + // mount timing (which fires from a core-registered hook we | |
| 195 | + // can't reorder) — and, since the palette bundles went lazy, | |
| 196 | + // from whether they have loaded at all. | |
| 192 | 197 | $menu_map = openstation_build_command_menu_map(); |
| 193 | 198 | wp_add_inline_script( |
| 194 | 199 | 'openstation', |
| 195 | 200 | 'window.__openStationMenuCommands = ' . wp_json_encode( $menu_map ) . ';', |
| @@ -197,9 +202,9 @@ | ||
| 197 | 202 | ); |
| 198 | 203 | } |
| 199 | 204 | |
| 200 | 205 | // Pass configuration to JavaScript. |
| 201 | - global $title, $pagenow, $parent_file, $menu; | |
| 206 | + global $title, $parent_file, $menu; | |
| 202 | 207 | |
| 203 | 208 | $menu_icon = 'dashicons-admin-generic'; |
| 204 | 209 | if ( ! empty( $parent_file ) && ! empty( $menu ) ) { |
| 205 | 210 | foreach ( $menu as $item ) { |
| @@ -215,13 +220,32 @@ | ||
| 215 | 220 | // plugin-contributed top-level routes. `openstation_dock_placement` |
| 216 | 221 | // is the per-item filter escape hatch for hiding. Shared with the |
| 217 | 222 | // REST menu endpoint so live refreshes (post plugin-activation) |
| 218 | 223 | // produce the same ordering as the boot payload. |
| 219 | - $menu_payload = openstation_build_menu_payload(); | |
| 220 | - $dock_items = $menu_payload['dockItems']; | |
| 221 | - $native_windows = isset( $menu_payload['nativeWindows'] ) | |
| 224 | + $menu_payload = openstation_build_menu_payload(); | |
| 225 | + $dock_items = $menu_payload['dockItems']; | |
| 226 | + $native_windows = isset( $menu_payload['nativeWindows'] ) | |
| 222 | 227 | ? $menu_payload['nativeWindows'] |
| 223 | 228 | : array(); |
| 229 | + | |
| 230 | + // The BOOT page prints every registry window's template as a real | |
| 231 | + // `<template>` tag (`openstation_render_native_window_templates()`, | |
| 232 | + // admin_footer @ 20 — before footer scripts, so the tags are in | |
| 233 | + // the DOM before the shell boots and `ensureTemplate()` adopts | |
| 234 | + // them by id). The payload's `templateHtml` copy exists for the | |
| 235 | + // MID-SESSION path — a bridge or probe payload delivering a | |
| 236 | + // window whose plugin activated after the page rendered — so on | |
| 237 | + // the boot config it is ~27 KB of the same markup twice. Strip it | |
| 238 | + // here, and only here: the bridge and probe payloads keep theirs. | |
| 239 | + foreach ( $native_windows as &$native_window_row ) { | |
| 240 | + if ( is_array( $native_window_row ) ) { | |
| 241 | + $native_window_row['templateHtml'] = ''; | |
| 242 | + } | |
| 243 | + } | |
| 244 | + unset( $native_window_row ); | |
| 245 | + $native_window_script_data = isset( $menu_payload['nativeWindowScriptData'] ) | |
| 246 | + ? $menu_payload['nativeWindowScriptData'] | |
| 247 | + : array(); | |
| 224 | 248 | $server_widgets = isset( $menu_payload['serverWidgets'] ) |
| 225 | 249 | ? $menu_payload['serverWidgets'] |
| 226 | 250 | : array(); |
| 227 | 251 | $server_wallpapers = isset( $menu_payload['serverWallpapers'] ) |
| @@ -288,9 +312,29 @@ | ||
| 288 | 312 | // dedupes on an `activeId` that was never set. |
| 289 | 313 | $server_desktop_themes = isset( $menu_payload['serverDesktopThemes'] ) |
| 290 | 314 | ? $menu_payload['serverDesktopThemes'] |
| 291 | 315 | : array(); |
| 292 | - $desktop_icons = isset( $menu_payload['desktopIcons'] ) | |
| 316 | + | |
| 317 | + // Slim the theme library for BOOT: `cssText` and `tokens` are | |
| 318 | + // each ~20 KB per theme, and neither is read at boot — the ACTIVE | |
| 319 | + // theme's stylesheet is server-delivered (see | |
| 320 | + // `openstation_enqueue_desktop_theme_style()`, whose stamp | |
| 321 | + // `bootAlreadyApplied()` detects), and an inactive theme's CSS | |
| 322 | + // only matters at the moment the user picks it in the Preferences | |
| 323 | + // picker — which fetches the full entries from | |
| 324 | + // `GET desktop-mode/v1/desktop-themes` (`ensureFullDesktopThemes()` | |
| 325 | + // client-side). `cssDeferred` marks the gap so the shell can tell | |
| 326 | + // a slimmed entry from a theme that genuinely ships no CSS. | |
| 327 | + // Bridge and probe payloads keep full entries. | |
| 328 | + foreach ( $server_desktop_themes as &$desktop_theme_row ) { | |
| 329 | + if ( is_array( $desktop_theme_row ) ) { | |
| 330 | + $desktop_theme_row['cssText'] = ''; | |
| 331 | + $desktop_theme_row['tokens'] = new stdClass(); | |
| 332 | + $desktop_theme_row['cssDeferred'] = true; | |
| 333 | + } | |
| 334 | + } | |
| 335 | + unset( $desktop_theme_row ); | |
| 336 | + $desktop_icons = isset( $menu_payload['desktopIcons'] ) | |
| 293 | 337 | ? $menu_payload['desktopIcons'] |
| 294 | 338 | : array(); |
| 295 | 339 | |
| 296 | 340 | // Files-on-the-Desktop payload (Phase 0+1). Plugin-registered |
| @@ -301,8 +345,16 @@ | ||
| 301 | 345 | : array(); |
| 302 | 346 | $server_file_openers = function_exists( 'openstation_build_file_openers_payload' ) |
| 303 | 347 | ? openstation_build_file_openers_payload() |
| 304 | 348 | : array(); |
| 349 | + // Entries in the menu payload carry dependency handles; their | |
| 350 | + // payloads ride once in `scriptDepPayloads` (GH#892). The file | |
| 351 | + // lists stay whole: no sync module reads them on the client, and | |
| 352 | + // compacting them here was the only write to the map after the | |
| 353 | + // menu payload froze its own, so a refresh could not match it. | |
| 354 | + $script_dep_payloads = isset( $menu_payload['scriptDepPayloads'] ) | |
| 355 | + ? (array) $menu_payload['scriptDepPayloads'] | |
| 356 | + : array(); | |
| 305 | 357 | $user_file_associations = function_exists( 'openstation_get_user_file_associations' ) |
| 306 | 358 | ? openstation_get_user_file_associations( get_current_user_id() ) |
| 307 | 359 | : array(); |
| 308 | 360 | $server_wallpaper_menu_items = function_exists( 'openstation_build_wallpaper_menu_items' ) |
| @@ -391,18 +443,33 @@ | ||
| 391 | 443 | OPENSTATION_URL . 'assets/js/' . $base . $suffix . '.js?ver=' . $ver |
| 392 | 444 | ); |
| 393 | 445 | }; |
| 394 | 446 | |
| 395 | - // Build the current page URL from $pagenow + $_GET. Strip the portal | |
| 396 | - // markers so the derived window ID matches what the dock would produce | |
| 397 | - // for the same page — otherwise auto-opening the entry window and | |
| 398 | - // clicking the same dock icon would create a duplicate. | |
| 399 | - $current_query = $_GET; // phpcs:ignore WordPress.Security.NonceVerification.Recommended | |
| 400 | - unset( $current_query[ OPENSTATION_PORTAL_FLAG ], $current_query[ OPENSTATION_PORTAL_INTENT_FLAG ] ); | |
| 401 | - $current_page = admin_url( $pagenow ) . ( ! empty( $current_query ) ? '?' . http_build_query( $current_query ) : '' ); | |
| 447 | + // The page the shell opens first. On the shell screen it is the | |
| 448 | + // validated `target` query arg (else the session's focused window, | |
| 449 | + // the default window, the Dashboard); on a solo boot it is the | |
| 450 | + // request's own URL. Either way the frozen portal flags are gone | |
| 451 | + // from it, so the derived window id matches what the dock would | |
| 452 | + // produce for the same page — otherwise auto-opening the entry | |
| 453 | + // window and clicking the same dock icon would create a duplicate. | |
| 454 | + $boot_target = openstation_shell_boot_target(); | |
| 455 | + $current_page = $boot_target['url']; | |
| 456 | + $from_portal = $boot_target['fromPortal']; | |
| 457 | + $from_portal_intent = $boot_target['fromPortalIntent']; | |
| 402 | 458 | |
| 403 | - $from_portal = ! empty( $_GET[ OPENSTATION_PORTAL_FLAG ] ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended | |
| 404 | - $from_portal_intent = ! empty( $_GET[ OPENSTATION_PORTAL_INTENT_FLAG ] ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended | |
| 459 | + // On the shell screen `$title` and `$parent_file` describe the | |
| 460 | + // screen ("OpenStation", no menu), not the page about to open. The | |
| 461 | + // dock entry for that page is the identity the entry window folds | |
| 462 | + // into, so its title and icon are the right first paint; the iframe | |
| 463 | + // reports its own title once it lands either way. | |
| 464 | + $current_title = wp_strip_all_tags( (string) $title ); | |
| 465 | + if ( openstation_is_shell_screen_request() ) { | |
| 466 | + $boot_meta = openstation_shell_boot_target_meta( $current_page, $dock_items ); | |
| 467 | + $current_title = wp_strip_all_tags( $boot_meta['title'] ); | |
| 468 | + if ( '' !== $boot_meta['icon'] ) { | |
| 469 | + $menu_icon = $boot_meta['icon']; | |
| 470 | + } | |
| 471 | + } | |
| 405 | 472 | |
| 406 | 473 | /** |
| 407 | 474 | * Filters the desktop shell configuration passed to JavaScript. |
| 408 | 475 | * |
| @@ -442,9 +509,14 @@ | ||
| 442 | 509 | * @type bool $fromPortal Whether the shell was reached via the portal. |
| 443 | 510 | * @type bool $fromPortalIntent Whether the portal redirect resolved from an explicit `?target=…` (user navigation intent) rather than the session's focused window or the default-window fallback. Distinguishes a bare `/openstation/` visit from a portal-redirected admin-bar click so the shell can honour the URL the user actually asked for. |
| 444 | 511 | * @type array $seenIntros Slugs of one-time announcements the user has dismissed (e.g. `['openstation-rebrand']`). |
| 445 | 512 | * @type string $seenIntrosUrl REST endpoint for the seen-intros surface — POST `/seen` to mark, DELETE the base to reset. |
| 513 | + * @type bool $shellTour Whether this site offers the first-boot shell tour (`openstation_show_shell_tour`). Whether this user already had it is `seenIntros` containing `shell-tour`. | |
| 514 | + * @type string $shellTourBundleUrl URL of the lazy shell-tour bundle, injected on first use. | |
| 515 | + * @type array $firstRun `{ installedAt, firstEnabledAt, enabledAt }`, epoch seconds, 0 when unknown — the first-run stamps, read-only. | |
| 446 | 516 | * @type bool $rebrandNotice Whether to offer this user the one-off announcement explaining the rename from Desktop Mode to OpenStation. True only when migration 5 flagged this user as a Desktop Mode user from before the rename AND they haven't dismissed the `openstation-rebrand` intro. Only ever present in the shell config, so the announcement never reaches the classic admin. |
| 517 | + * @type array|null $usageFeedback What the one-time usage feedback prompt needs (`restUrl`), or `null` when this user is not owed it: the feature is off, they have had OpenStation on for fewer than seven days by the `openstation_enabled_at` stamp, or they already answered or dismissed the `usage-feedback` intro. Carries no user data. | |
| 518 | + * @type string $usageFeedbackBundleUrl URL of the lazy `usage-feedback` bundle, the form the prompt opens. | |
| 447 | 519 | * } |
| 448 | 520 | */ |
| 449 | 521 | $config = apply_filters( |
| 450 | 522 | 'openstation_shell_config', |
| @@ -449,11 +521,15 @@ | ||
| 449 | 521 | $config = apply_filters( |
| 450 | 522 | 'openstation_shell_config', |
| 451 | 523 | array( |
| 452 | 524 | 'currentPage' => esc_url( $current_page ), |
| 453 | - 'currentTitle' => wp_strip_all_tags( $title ), | |
| 525 | + 'currentTitle' => $current_title, | |
| 454 | 526 | 'currentIcon' => sanitize_html_class( $menu_icon ), |
| 455 | - 'adminUrl' => esc_url( admin_url() ), | |
| 527 | + // `self_admin_url()`: the base a window id is derived from, | |
| 528 | + // and the URL the shell leaves for on exit. Both want the | |
| 529 | + // admin the screen is in, which is the network one when the | |
| 530 | + // network shell screen is what rendered. | |
| 531 | + 'adminUrl' => esc_url( self_admin_url() ), | |
| 456 | 532 | 'homeUrl' => esc_url( home_url( '/' ) ), |
| 457 | 533 | // Decoded: the shell assigns this to `window.location`, |
| 458 | 534 | // where `&` would make `_wpnonce` arrive as |
| 459 | 535 | // `amp;_wpnonce` and fail the nonce check. |
| @@ -466,8 +542,12 @@ | ||
| 466 | 542 | // signature from this so the first off-allowlist menu change |
| 467 | 543 | // (vs. this boot state) is caught without a wasted probe. GH#325. |
| 468 | 544 | 'menuSig' => isset( $menu_payload['menuSig'] ) ? (string) $menu_payload['menuSig'] : '', |
| 469 | 545 | 'nativeWindows' => $native_windows, |
| 546 | + // Handle-keyed script data the entries above reference — | |
| 547 | + // one copy per bundle, not one per window. See | |
| 548 | + // `openstation_collect_native_windows_payload()`. | |
| 549 | + 'nativeWindowScriptData' => $native_window_script_data, | |
| 470 | 550 | 'serverWidgets' => $server_widgets, |
| 471 | 551 | 'serverWallpapers' => $server_wallpapers, |
| 472 | 552 | 'serverCommandScripts' => $server_command_scripts, |
| 473 | 553 | 'serverCommands' => $server_commands, |
| @@ -493,8 +573,11 @@ | ||
| 493 | 573 | 'serverDesktopThemes' => $server_desktop_themes, |
| 494 | 574 | 'desktopIcons' => $desktop_icons, |
| 495 | 575 | 'serverFileTypes' => $server_file_types, |
| 496 | 576 | 'serverFileOpeners' => $server_file_openers, |
| 577 | + // Handle => dependency payload for every `scriptDeps` list in | |
| 578 | + // this config; see `openstation_compact_script_deps()`. | |
| 579 | + 'scriptDepPayloads' => (object) $script_dep_payloads, | |
| 497 | 580 | 'userFileAssociations' => $user_file_associations, |
| 498 | 581 | 'filesUrl' => esc_url_raw( rest_url( 'desktop-mode/v1/files' ) ), |
| 499 | 582 | // Pinned-notes REST base (`includes/notes/rest.php`). The |
| 500 | 583 | // notes layer boots only when this is present. |
| @@ -510,9 +593,17 @@ | ||
| 510 | 593 | 'coreNotices' => openstation_get_core_notices(), |
| 511 | 594 | 'pluginNotices' => openstation_get_plugin_notices(), |
| 512 | 595 | 'defaultWallpaper' => openstation_get_default_wallpaper(), |
| 513 | 596 | 'session' => openstation_get_session( get_current_user_id() ), |
| 514 | - 'sessionUrl' => esc_url_raw( rest_url( 'desktop-mode/v1/session' ) ), | |
| 597 | + // The session route runs in the main site's blog context | |
| 598 | + // whichever desktop posts to it, so the network screen's | |
| 599 | + // URL says which session it is addressing — see | |
| 600 | + // `openstation_rest_session_network()`. | |
| 601 | + 'sessionUrl' => esc_url_raw( | |
| 602 | + is_network_admin() | |
| 603 | + ? add_query_arg( 'network', '1', rest_url( 'desktop-mode/v1/session' ) ) | |
| 604 | + : rest_url( 'desktop-mode/v1/session' ) | |
| 605 | + ), | |
| 515 | 606 | 'restUrl' => esc_url_raw( rest_url() ), |
| 516 | 607 | 'mediaUrl' => esc_url_raw( rest_url( 'wp/v2/media' ) ), |
| 517 | 608 | 'dropConfig' => $drop_config, |
| 518 | 609 | 'defaultWindowUrl' => esc_url_raw( rest_url( 'desktop-mode/v1/default-window' ) ), |
| @@ -535,20 +626,29 @@ | ||
| 535 | 626 | // stub `<script>`-injects this URL the first time the user |
| 536 | 627 | // opens the assistant. Picking `.js` vs `.min.js` here keeps |
| 537 | 628 | // the SCRIPT_DEBUG gate server-side, matching iframeBridgeUrl. |
| 538 | 629 | 'aiAssistantBundleUrl' => $lazy_bundle_url( 'ai-assistant' ), |
| 539 | - // URL of the OS Settings panel lazy bundle. Injected by | |
| 540 | - // the main bundle's `OsSettings.renderPanel()` stub on | |
| 541 | - // the user's first Settings open. Holds every section | |
| 542 | - // renderer + the `<os-*>` components only the panel | |
| 543 | - // uses, so nothing about Settings ships in | |
| 544 | - // `desktop.min.js` for users who never open it. | |
| 545 | - 'osSettingsPanelBundleUrl' => $lazy_bundle_url( 'os-settings-panel' ), | |
| 546 | 630 | // URL of the shell-overlays lazy bundle. Pre-loaded by |
| 547 | 631 | // the main bundle after first paint so action-triggered |
| 548 | 632 | // overlays (toast, confirm dialog, context menus) feel |
| 549 | 633 | // instant the first time they fire. |
| 550 | 634 | 'shellOverlaysBundleUrl' => $lazy_bundle_url( 'shell-overlays' ), |
| 635 | + // The shell-bundle diet: features whose right moment is a | |
| 636 | + // user gesture (or a presence signal) ride their own | |
| 637 | + // bundles instead of the boot-critical `desktop[.min].js`. | |
| 638 | + // Each sentinel in the shell loads its bundle at that | |
| 639 | + // moment — see the entry file each bundle names. | |
| 640 | + 'fileDropBundleUrl' => $lazy_bundle_url( 'file-drop' ), | |
| 641 | + 'filesOverlaysBundleUrl' => $lazy_bundle_url( 'files-overlays' ), | |
| 642 | + 'notesBundleUrl' => $lazy_bundle_url( 'notes' ), | |
| 643 | + 'dockConstellationBundleUrl' => $lazy_bundle_url( 'dock-constellation' ), | |
| 644 | + 'windowLinkVisualsBundleUrl' => $lazy_bundle_url( 'window-link-visuals' ), | |
| 645 | + // Presence hint for the notes sentinel: a desktop with no | |
| 646 | + // notes skips the notes bundle AND the boot-time list | |
| 647 | + // request. Two id-only existence probes at most. | |
| 648 | + 'hasNotes' => function_exists( 'openstation_notes_user_has_any' ) | |
| 649 | + ? openstation_notes_user_has_any() | |
| 650 | + : false, | |
| 551 | 651 | // URL of the full `<os-*>` component kit. The shell |
| 552 | 652 | // never loads this — its own bundles import the |
| 553 | 653 | // components they render. It exists for |
| 554 | 654 | // `wp.os.loadComponents()`, i.e. for plugin code that |
| @@ -582,12 +682,29 @@ | ||
| 582 | 682 | // / `openNew()` call (both async); pre-loaded |
| 583 | 683 | // by the shell after first paint when no session is being |
| 584 | 684 | // restored and no `openCurrentPage` will fire. |
| 585 | 685 | 'windowSystemBundleUrl' => $lazy_bundle_url( 'window-system' ), |
| 686 | + // URL of the lazy phone-layer bundle. Injected by the main | |
| 687 | + // bundle only when the mode resolves to `mobile`, so a | |
| 688 | + // desktop never fetches it. `mode` carries the preference | |
| 689 | + // and breakpoints the first-paint head stamp already used, | |
| 690 | + // plus the server's default tab-bar pins — see | |
| 691 | + // `includes/mobile.php`. | |
| 692 | + 'mobileBundleUrl' => $lazy_bundle_url( 'mobile' ), | |
| 693 | + 'mode' => openstation_mode_config( get_current_user_id() ), | |
| 586 | 694 | // URL of the item-visibility-menu lazy bundle — the |
| 587 | 695 | // right-click "hide from dock / desktop" menu. Injected by |
| 588 | 696 | // the main bundle's loader shim on the first right-click. |
| 589 | 697 | 'itemVisibilityMenuBundleUrl' => $lazy_bundle_url( 'item-visibility-menu' ), |
| 698 | + // URL of the workspace-wizard lazy bundle — the modal | |
| 699 | + // behind "Edit this workspace…". Injected by the main | |
| 700 | + // bundle's loader shim on first open. | |
| 701 | + 'workspaceWizardBundleUrl' => $lazy_bundle_url( 'workspace-wizard' ), | |
| 702 | + // Server-side view of the workspace templates, so a plugin | |
| 703 | + // can add or drop one from PHP. The client merges these | |
| 704 | + // with its own built-ins by id — see | |
| 705 | + // `src/workspaces/server-sync.ts`. | |
| 706 | + 'workspacePresets' => openstation_workspace_presets(), | |
| 590 | 707 | // URL of the release-card lazy bundle — the vinyl core- |
| 591 | 708 | // update announcement. Injected by `maybeShowUpdate()` only |
| 592 | 709 | // when a core update is actually pending. |
| 593 | 710 | 'releaseCardBundleUrl' => $lazy_bundle_url( 'release-card' ), |
| @@ -604,8 +721,26 @@ | ||
| 604 | 721 | // announcement yet. Same value that gated `os-announce` |
| 605 | 722 | // above; the dialog cannot paint without that stylesheet, so |
| 606 | 723 | // the two must not diverge. |
| 607 | 724 | 'rebrandNotice' => $show_rebrand_notice, |
| 725 | + // The first-boot shell tour: whether this site offers it | |
| 726 | + // (the `openstation_show_shell_tour` filter), and the lazy | |
| 727 | + // bundle that runs it. Whether THIS user already had it is | |
| 728 | + // `seenIntros` containing `shell-tour`; the shell reads that | |
| 729 | + // itself so a reset can replay the tour without a new boot. | |
| 730 | + 'shellTour' => openstation_should_offer_shell_tour( get_current_user_id() ), | |
| 731 | + 'shellTourBundleUrl' => $lazy_bundle_url( 'shell-tour' ), | |
| 732 | + // The first-run stamps, read-only, epoch seconds (0 when | |
| 733 | + // unknown): when the plugin was installed, when anyone first | |
| 734 | + // enabled it, and when this user did. See | |
| 735 | + // `includes/first-run/stamps.php`. | |
| 736 | + 'firstRun' => openstation_first_run_config( get_current_user_id() ), | |
| 737 | + // Null for everyone but a user who has had OpenStation on | |
| 738 | + // long enough and has not answered yet; the gate lives in | |
| 739 | + // `includes/feedback/usage.php`. The form is a lazy bundle | |
| 740 | + // fetched only when the user says yes to the prompt. | |
| 741 | + 'usageFeedback' => function_exists( 'openstation_usage_feedback_config' ) ? openstation_usage_feedback_config() : null, | |
| 742 | + 'usageFeedbackBundleUrl' => $lazy_bundle_url( 'usage-feedback' ), | |
| 608 | 743 | 'aiSearchUrl' => esc_url_raw( rest_url( 'desktop-mode/v1/ai/search' ) ), |
| 609 | 744 | // AI assistant availability + per-user toggle. Drives whether the |
| 610 | 745 | // Cmd+K palette and admin-bar icon appear, and the setup placeholder. |
| 611 | 746 | 'aiAssistant' => function_exists( 'openstation_ai_assistant_config' ) |
| @@ -619,41 +754,57 @@ | ||
| 619 | 754 | // Site-wide games kill switch (Extended options). Exposed to |
| 620 | 755 | // every user — the shell skips the challenges Heartbeat |
| 621 | 756 | // channel when the framework is off. |
| 622 | 757 | 'gamesEnabled' => openstation_games_enabled(), |
| 623 | - // Comments-window AI moderation toggle — surfaced at the | |
| 624 | - // shell level so the OS Settings → Features tab can render | |
| 625 | - // the toggle without depending on the Comments window | |
| 626 | - // being registered for this user. URL is the same | |
| 627 | - // endpoint the comments-window config exposes; state is | |
| 628 | - // `null` for non-admins (the UI hides the row entirely). | |
| 629 | - 'commentsAiUrl' => esc_url_raw( rest_url( 'desktop-mode/v1/comments/ai-settings' ) ), | |
| 630 | - // Non-null only for admins on a site where the Core AI stack is | |
| 631 | - // present. Comment scoring routes through the AI Client (WP 7.0+), | |
| 632 | - // so on older WordPress the whole row is hidden — same as the | |
| 633 | - // assistant toggle — rather than shown disabled pointing at a | |
| 634 | - // Settings → Connectors screen that doesn't exist there. | |
| 635 | - 'commentsAi' => ( | |
| 636 | - current_user_can( 'manage_options' ) | |
| 637 | - && function_exists( 'openstation_ai_is_available' ) | |
| 638 | - && openstation_ai_is_available() | |
| 639 | - ) | |
| 640 | - ? array( | |
| 641 | - 'enabled' => function_exists( 'openstation_comments_ai_is_enabled' ) | |
| 642 | - ? openstation_comments_ai_is_enabled() | |
| 643 | - : false, | |
| 644 | - 'providerConfigured' => function_exists( 'openstation_comments_ai_provider_configured' ) | |
| 645 | - ? openstation_comments_ai_provider_configured() | |
| 646 | - : false, | |
| 647 | - ) | |
| 648 | - : null, | |
| 649 | 758 | 'currentUserIsAdmin' => current_user_can( 'manage_options' ), |
| 759 | + // Null on single-site installs; its `networkAdmin` null | |
| 760 | + // without `manage_network`, which is what keeps the dock | |
| 761 | + // tile from registering. | |
| 762 | + 'multisite' => openstation_multisite_payload(), | |
| 650 | 763 | 'portalUrl' => esc_url( openstation_portal_url() ), |
| 651 | 764 | 'fromPortal' => $from_portal, |
| 652 | 765 | 'fromPortalIntent' => $from_portal_intent, |
| 766 | + // One-shot like the boot target: a switch from another | |
| 767 | + // site's overview lands in this one's. | |
| 768 | + 'landInOverview' => openstation_shell_lands_in_overview(), | |
| 769 | + 'arrivalDirection' => openstation_shell_arrival_direction(), | |
| 770 | + 'hopLinkOffer' => function_exists( 'openstation_network_link_offer' ) ? openstation_network_link_offer() : null, | |
| 653 | 771 | 'pwa' => array( |
| 654 | 772 | 'manifestUrl' => esc_url_raw( openstation_pwa_manifest_url() ), |
| 655 | 773 | 'swUrl' => esc_url_raw( openstation_pwa_sw_url() ), |
| 774 | + // Extensionless retry target for hosts whose nginx 404s | |
| 775 | + // virtual .js paths before WordPress runs (WordPress.com). | |
| 776 | + 'swFallbackUrl' => esc_url_raw( openstation_pwa_sw_fallback_url() ), | |
| 777 | + // The site's home path — the scope the registration | |
| 778 | + // asks for, so a subdirectory network's sites each get | |
| 779 | + // their own worker instead of fighting over the root. | |
| 780 | + 'swScope' => openstation_pwa_sw_scope(), | |
| 781 | + // The worker's per-user flags, computed HERE rather than | |
| 782 | + // baked into the served `sw.js`. | |
| 783 | + // | |
| 784 | + // A service worker is origin-wide but these are per-user | |
| 785 | + // preferences, so putting them in the script bytes made | |
| 786 | + // the body differ between an anonymous and a logged-in | |
| 787 | + // request — and any in-scope logged-out navigation then | |
| 788 | + // installed a "new" worker, which at the time reloaded | |
| 789 | + // the shell. The bytes are identical for everyone now; | |
| 790 | + // the shell posts these to the worker at boot. | |
| 791 | + // | |
| 792 | + // Computed server-side, not read from the settings | |
| 793 | + // snapshot client-side, because | |
| 794 | + // `openstation_pwa_admin_asset_cache_enabled()` applies | |
| 795 | + // the `openstation_pwa_admin_asset_cache` filter — an | |
| 796 | + // operator's site-wide veto has to keep working. | |
| 797 | + 'swConfig' => array( | |
| 798 | + 'adminAssetCache' => (bool) openstation_pwa_admin_asset_cache_enabled(), | |
| 799 | + 'windowPrewarm' => ! empty( openstation_get_os_settings( get_current_user_id() )['windowPrewarmEnabled'] ), | |
| 800 | + ), | |
| 801 | + // The build this shell document belongs to. When a new | |
| 802 | + // worker takes over mid-session the shell asks it for | |
| 803 | + // the stamp it was served with and compares; only a | |
| 804 | + // difference — the shell's own files changed on the | |
| 805 | + // server — offers the user a reload. Never automatic. | |
| 806 | + 'shellBuild' => openstation_shell_build_stamp(), | |
| 656 | 807 | 'stateUrl' => esc_url_raw( rest_url( 'desktop-mode/v1/pwa-state' ) ), |
| 657 | 808 | 'state' => openstation_pwa_get_user_state( get_current_user_id() ), |
| 658 | 809 | // Mirrors the manifest's `name` field — used by the |
| 659 | 810 | // install pill so the button reads "Install <site>" |
| @@ -669,8 +820,54 @@ | ||
| 669 | 820 | // `src/pwa/sw-register.ts`). Default `false` preserves |
| 670 | 821 | // the polite behaviour where we yield to existing PWAs. |
| 671 | 822 | 'forceReplaceSw' => openstation_pwa_force_replace_sw(), |
| 672 | 823 | ), |
| 824 | + // Ordered Core command-palette asset manifest, replayed on | |
| 825 | + // first palette invocation. `null` on pre-6.9 sites. | |
| 826 | + 'commandPalette' => $command_palette, | |
| 827 | + // Stylesheets for shell surfaces that render on demand — | |
| 828 | + // the Preferences panel, the AI assistant, the bug-report | |
| 829 | + // window. None of them is a server-registered native | |
| 830 | + // window (they are built client-side by the shell | |
| 831 | + // bundle), so the `styles` companion mechanism can't | |
| 832 | + // carry their CSS; instead the shell injects each sheet | |
| 833 | + // the first time its surface opens, via | |
| 834 | + // `ensureDeferredStyle()` in `src/deferred-styles.ts`. | |
| 835 | + // Same resolved shape a native window's `styleUrl` / | |
| 836 | + // `styleInline` travels in. | |
| 837 | + // Which of the `deferredStyles` entries a game needs. | |
| 838 | + // `launchGame()` injects these before the window paints. | |
| 839 | + 'gameStyleHandles' => function_exists( 'openstation_games_style_handles' ) | |
| 840 | + ? openstation_games_style_handles() | |
| 841 | + : array(), | |
| 842 | + 'deferredStyles' => openstation_build_deferred_styles( | |
| 843 | + array_merge( | |
| 844 | + array( | |
| 845 | + 'desktop-mode-ai-assistant', | |
| 846 | + 'desktop-mode-bug-report', | |
| 847 | + // The explorer's shared sheet. It rides the WP | |
| 848 | + // Explorer APP as a companion style, but the | |
| 849 | + // desktop FOLDER window paints its preview pane | |
| 850 | + // with the same `os-my-wordpress__*` classes and | |
| 851 | + // — being a native window opened straight from | |
| 852 | + // JS — carries no companion styles of its own. | |
| 853 | + // Without this, the pane rendered unstyled until | |
| 854 | + // the explorer had been opened once in the | |
| 855 | + // session. | |
| 856 | + 'desktop-mode-my-wordpress', | |
| 857 | + ), | |
| 858 | + // The Games sheets. They also ride the hub window as | |
| 859 | + // companion styles, but a game is reachable without | |
| 860 | + // the hub — the challenge toast, solo mode, and | |
| 861 | + // `wp.os.games.launch()` all land in `launchGame()` | |
| 862 | + // with no hub window in the tab. Listing them here | |
| 863 | + // costs a URL each in the boot config and no CSS | |
| 864 | + // until `launchGame()` asks. | |
| 865 | + function_exists( 'openstation_games_style_handles' ) | |
| 866 | + ? openstation_games_style_handles() | |
| 867 | + : array() | |
| 868 | + ) | |
| 869 | + ), | |
| 673 | 870 | ) |
| 674 | 871 | ); |
| 675 | 872 | |
| 676 | 873 | wp_localize_script( 'openstation', 'openStationConfig', $config ); |
| @@ -682,8 +879,46 @@ | ||
| 682 | 879 | } |
| 683 | 880 | add_action( 'admin_enqueue_scripts', 'openstation_enqueue_assets' ); |
| 684 | 881 | |
| 685 | 882 | /** |
| 883 | + * Keep Core's boot-time command-palette enqueue off shell pages. | |
| 884 | + * | |
| 885 | + * WordPress 7.0 hooks `wp_enqueue_command_palette_assets()` on | |
| 886 | + * `admin_enqueue_scripts` by default, which puts the palette's whole | |
| 887 | + * dependency chain — the Gutenberg runtime, ~800 KB gzipped — on | |
| 888 | + * every admin page. On a SHELL page that is pure dead weight: the | |
| 889 | + * shell suppresses Core's palette unconditionally (the ⌘K keystroke | |
| 890 | + * and the admin-bar icon both route to the shell's own palette), so | |
| 891 | + * the runtime it powers can never be shown. Unhooking here lets the | |
| 892 | + * deferred manifest (`openstation_build_command_palette_assets_payload()`) | |
| 893 | + * capture the chain instead, and the shell loads it on the first | |
| 894 | + * palette invocation. | |
| 895 | + * | |
| 896 | + * Deliberately scoped: classic-mode requests keep Core's default, | |
| 897 | + * because a classic page is Core's own UI where Core's palette is the | |
| 898 | + * right one. | |
| 899 | + * | |
| 900 | + * Windows are handled separately by | |
| 901 | + * {@see openstation_chromeless_should_trim_command_palette()} in | |
| 902 | + * `includes/render/chromeless-trim.php` — same idea, but it has to | |
| 903 | + * drop the whole palette *family* rather than just unhook Core's | |
| 904 | + * callback, and it exempts block-editor screens. Unhooking alone is | |
| 905 | + * not enough there: a third-party palette extension that declares | |
| 906 | + * `wp-commands` keeps the entire chain queued as its dependency. | |
| 907 | + * | |
| 908 | + * Priority 0, ahead of Core's default 10, so the removal lands | |
| 909 | + * before the callback fires. On WP 6.9 (function exists, no default | |
| 910 | + * hook) the `remove_action()` is a harmless no-op. | |
| 911 | + */ | |
| 912 | +function openstation_defer_core_command_palette() { | |
| 913 | + if ( ! openstation_is_shell_request() ) { | |
| 914 | + return; | |
| 915 | + } | |
| 916 | + remove_action( 'admin_enqueue_scripts', 'wp_enqueue_command_palette_assets' ); | |
| 917 | +} | |
| 918 | +add_action( 'admin_enqueue_scripts', 'openstation_defer_core_command_palette', 0 ); | |
| 919 | + | |
| 920 | +/** | |
| 686 | 921 | * Emits `<link rel="preload">` hints for the shell's critical-path |
| 687 | 922 | * assets so the browser starts fetching them as soon as it parses |
| 688 | 923 | * the document `<head>`. |
| 689 | 924 | * |
| @@ -726,14 +961,9 @@ | ||
| 726 | 961 | * supply absolute URLs through the filter; in that case the consumer |
| 727 | 962 | * is responsible for the `crossorigin` semantics. |
| 728 | 963 | */ |
| 729 | 964 | function openstation_print_preload_hints() { |
| 730 | - if ( | |
| 731 | - ! is_admin() | |
| 732 | - || ! openstation_is_enabled() | |
| 733 | - || openstation_is_chromeless_request() | |
| 734 | - || openstation_is_classic_request() | |
| 735 | - ) { | |
| 965 | + if ( ! openstation_is_shell_request() ) { | |
| 736 | 966 | return; |
| 737 | 967 | } |
| 738 | 968 | |
| 739 | 969 | $suffix = openstation_asset_suffix(); |
| @@ -776,8 +1006,21 @@ | ||
| 776 | 1006 | 'rel' => 'prefetch', |
| 777 | 1007 | ), |
| 778 | 1008 | ); |
| 779 | 1009 | |
| 1010 | + // The phone layer is needed at boot on a phone and never on a | |
| 1011 | + // desktop; the server cannot see the viewport, so the user agent | |
| 1012 | + // decides whether the hint is worth its bytes. A wrong guess costs | |
| 1013 | + // one low-priority fetch, never a wrong layout — the stamp and the | |
| 1014 | + // bundle loader read the real viewport. | |
| 1015 | + if ( openstation_mode_hint_is_mobile( get_current_user_id() ) ) { | |
| 1016 | + $hints[] = array( | |
| 1017 | + 'href' => $build_url( 'assets/js/mobile' . $suffix . '.js' ), | |
| 1018 | + 'as' => 'script', | |
| 1019 | + 'rel' => 'prefetch', | |
| 1020 | + ); | |
| 1021 | + } | |
| 1022 | + | |
| 780 | 1023 | /** |
| 781 | 1024 | * Filters the list of resource preload hints emitted in `<head>`. |
| 782 | 1025 | * |
| 783 | 1026 | * Each entry is a `{ 'href' => string, 'as' => string, |
| @@ -894,9 +1137,8 @@ | ||
| 894 | 1137 | 'os-openstation-layout', |
| 895 | 1138 | 'desktop-mode-ai-assistant', |
| 896 | 1139 | 'desktop-mode-bug-report', |
| 897 | 1140 | 'os-window-overview', |
| 898 | - 'os-settings', | |
| 899 | 1141 | ) |
| 900 | 1142 | ); |
| 901 | 1143 | |
| 902 | 1144 | if ( ! in_array( $handle, (array) $deferred, true ) ) { |