PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 1.1.9
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v1.1.9
1.1.9 1.1.8 1.1.7 1.1.6 1.1.5 1.1.4 1.1.3 1.1.2 1.1.1 1.1.0 1.0.1 1.0.0 0.9.8 0.9.7 0.9.6 0.9.4 0.9.5 0.9.3 0.9.2 0.9.1 0.9.0 0.8.9 0.8.8 0.8.7 0.8.6 All 33 releases
desktop-mode / includes / render / assets.php

assets.php in OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin 1.1.9, at includes/render/assets.php

1,302 lines 62.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * OpenStation — Asset enqueue.
4 *
5 * Loads the desktop shell CSS + JS bundles when OpenStation is
6 * active and the request isn't chromeless / classic-overridden.
7 * Owns the entire `openstation_enqueue_assets()` body — the
8 * largest hook in the original render.php and the natural seam
9 * for "what does the shell ship to the browser today?".
10 *
11 * Extracted from `render.php` during the architecture-0.8.1 PHP
12 * slicing (phase 6).
13 *
14 * @package OpenStation
15 */
16
17 defined( 'ABSPATH' ) || exit;
18
19 /**
20 * Enqueues the OpenStation shell assets (CSS + JS) when OpenStation is active.
21 *
22 * Only loads the full desktop shell scripts and styles when the user has
23 * OpenStation enabled and the request is not a chromeless iframe load.
24 */
25 function openstation_enqueue_assets() {
26 if ( ! is_admin() ) {
27 return;
28 }
29
30 // Auto-enqueue the iframe bridge anywhere a openstation user
31 // might land. The bundle self-bails when not inside an iframe
32 // (`window.parent === window`), so it's a no-op on the parent
33 // shell — but cheap insurance against the failure mode the
34 // developer hit: an internal admin navigation drops the
35 // `?openstation_chromeless=1` flag, the chromeless inline bridge doesn't
36 // run, and `wp.os.iframe` silently disappears. With this
37 // auto-enqueue, the API is universally present for any same-
38 // origin admin page a openstation user opens — chromeless or
39 // accidentally classic.
40 if ( openstation_is_enabled() ) {
41 wp_enqueue_script( 'os-iframe-bridge' );
42
43 // Block Editor cross-window drop receiver. Listens for
44 // `os-drop` postMessages from the parent shell and
45 // inserts the matching block. Only enqueue inside the
46 // post-edit Block Editor screens — every other admin page
47 // would be paying for a bundle it never uses.
48 //
49 // `site-editor.php` (full-site editor) deliberately omitted:
50 // the FSE doesn't expose `wp.data.dispatch('core/block-editor')`
51 // until the user opens a template in the canvas iframe, so
52 // drops arriving before that point would silently time out
53 // after the receiver's 5 s `waitForEditor()` poll. Re-enable
54 // once we have a reliable readiness signal in that context.
55 global $hook_suffix;
56 if ( 'post.php' === $hook_suffix || 'post-new.php' === $hook_suffix ) {
57 wp_enqueue_script( 'os-gutenberg-drop-receiver' );
58 }
59 }
60
61 // Chromeless requests (iframes) need chromeless styles and overrides.
62 if ( openstation_is_chromeless_request() ) {
63 wp_enqueue_style( 'openstation' );
64 wp_enqueue_style( 'os-chromeless' );
65
66 /**
67 * Fires when chromeless styles are enqueued inside a OpenStation iframe.
68 *
69 * Plugin and theme authors can hook here to enqueue their own CSS
70 * overrides for legacy pages rendered in chromeless mode. Use the
71 * `.os-chromeless` body class to scope your rules.
72 */
73 do_action( 'openstation_chromeless_styles' );
74 return;
75 }
76
77 if ( ! openstation_is_shell_request() ) {
78 return;
79 }
80
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.
87 wp_enqueue_style( 'openstation' );
88 wp_enqueue_style( 'os-windows' );
89 wp_enqueue_style( 'os-window-overview' );
90 wp_enqueue_style( 'os-dock' );
91 wp_enqueue_style( 'os-dock-peek' );
92 wp_enqueue_style( 'os-notch' );
93 wp_enqueue_style( 'os-workspaces' );
94 wp_enqueue_style( 'os-shortcuts' );
95 wp_enqueue_style( 'os-openstation-layout' );
96 wp_enqueue_style( 'os-files' );
97 wp_enqueue_style( 'os-notes' );
98 // Unconditional like the layout sheet: a live crossing into the
99 // phone band must not find the phone layer unstyled.
100 wp_enqueue_style( 'os-mobile' );
101
102 // Solo mode — a single window freed into a native OS window by the
103 // desktop host. Same shell, everything but that one window hidden.
104 $solo_window = openstation_solo_window_id();
105 if ( '' !== $solo_window ) {
106 wp_enqueue_style( 'os-solo' );
107
108 /*
109 * Hide every window that is not the one this surface was booted
110 * to paint — from the first frame, before any of them exist.
111 *
112 * Solo mode promises one window. Anything that opens a second
113 * (a game launched from a freed Games hub, a plugin calling
114 * `openWindow`) would otherwise land on top of the first, and
115 * solo's CSS stretches every window to fill the viewport, so it
116 * covers what the user was using.
117 *
118 * This has to be CSS rather than JavaScript, and it has to be
119 * inline. A JS rule can only run once the window exists, which
120 * is a frame too late — the user sees the newcomer flash before
121 * it is dealt with. A static stylesheet cannot express it
122 * either, because the selector depends on which window this is.
123 * So the rule is emitted with the id baked in, and no window but
124 * that one is ever painted.
125 *
126 * `visibility` rather than `display`: a hidden-but-laid-out
127 * window still has a size, which canvas-based windows need in
128 * order to initialise without dividing by zero on the way to
129 * being closed.
130 *
131 * The id is `sanitize_key()`-clean (see `openstation_solo_window_id()`),
132 * so it is safe in a selector; it is escaped again here because
133 * the distance between those two facts is exactly where this
134 * kind of bug lives.
135 */
136 wp_add_inline_style(
137 'os-solo',
138 sprintf(
139 'body.os-solo .os-window:not(#wp-window-%1$s){visibility:hidden !important;pointer-events:none !important;}',
140 esc_attr( $solo_window )
141 )
142 );
143 }
144
145 // The rebrand announcement paints on one visit per user and never
146 // again, so its stylesheet is only worth sending to the users who
147 // are actually going to see it. Computed once here and reused for
148 // the `rebrandNotice` config key below, which reads the same answer.
149 $show_rebrand_notice = openstation_should_show_rebrand_notice();
150 if ( $show_rebrand_notice ) {
151 wp_enqueue_style( 'os-announce' );
152 }
153
154 // JS.
155 wp_enqueue_script( 'openstation' );
156
157 // `wp_enqueue_command_palette_assets()` (WP 6.9+) enqueues the
158 // `wp-commands` store package, the `wp-core-commands` script that
159 // registers the WordPress-wide baseline (Add new post, Manage
160 // plugins, Switch theme, Browse patterns, …) AND — critically —
161 // the inline `wp.coreCommands.initializeCommandPalette( … )` call
162 // that actually populates the `core/commands` data store with the
163 // admin-menu commands. Without that inline init, the script loads
164 // but the store stays empty and `src/commands/shell-harvester.ts`
165 // finds nothing to publish.
166 //
167 // WP normally only calls this on screens that opt in to the native
168 // palette; the shell needs it on every admin URL it might wrap.
169 // `function_exists` guard for pre-6.9 sites — the harvester gracefully
170 // no-ops when the store is missing.
171 // See `openstation_defer_core_command_palette()` below for why
172 // Core's own boot-time enqueue is unhooked on shell pages.
173 //
174 // The Core command-palette runtime is NOT enqueued here any more.
175 // Its dependency chain is the whole Gutenberg runtime (~800 KB
176 // gzipped across forty-odd bundles), paid on every boot for a ⌘K
177 // palette most sessions never open. It now ships as an ordered
178 // manifest in the config blob (`commandPalette`, built by
179 // `openstation_build_command_palette_assets_payload()`), and
180 // `src/commands/palette-assets.ts` replays it the first time the
181 // palette is invoked. The shell harvester keeps its idle-time
182 // `install()` — a graceful no-op until the store exists — and
183 // re-installs on `os-command-palette-ready`.
184 $command_palette = openstation_build_command_palette_assets_payload();
185
186 if ( function_exists( 'wp_enqueue_command_palette_assets' ) ) {
187 // Expose the same menu-commands array WP serializes into
188 // `wp.coreCommands.initializeCommandPalette(...)` on a window
189 // slot the shell harvester can read. Built in PHP from `$menu`
190 // / `$submenu`, then injected as a `before` inline on our own
191 // bundle — that runs synchronously before `desktop.min.js`
192 // boots the shell harvester, so the lookup is guaranteed
193 // populated by the time `src/commands/shell-harvester.ts`
194 // classifies any command. Decoupled from WP's command-palette
195 // mount timing (which fires from a core-registered hook we
196 // can't reorder) — and, since the palette bundles went lazy,
197 // from whether they have loaded at all.
198 $menu_map = openstation_build_command_menu_map();
199 wp_add_inline_script(
200 'openstation',
201 'window.__openStationMenuCommands = ' . wp_json_encode( $menu_map ) . ';',
202 'before'
203 );
204 }
205
206 // Pass configuration to JavaScript.
207 global $title, $parent_file, $menu;
208
209 $menu_icon = 'dashicons-admin-generic';
210 if ( ! empty( $parent_file ) && ! empty( $menu ) ) {
211 foreach ( $menu as $item ) {
212 if ( ! empty( $item[2] ) && $item[2] === $parent_file && ! empty( $item[6] ) ) {
213 $menu_icon = $item[6];
214 break;
215 }
216 }
217 }
218
219 // Build dock items from the admin menu. Core pages are ordered
220 // first (Dashboard, Posts, Plugins, Users, Settings, …), then
221 // plugin-contributed top-level routes. `openstation_dock_placement`
222 // is the per-item filter escape hatch for hiding. Shared with the
223 // REST menu endpoint so live refreshes (post plugin-activation)
224 // produce the same ordering as the boot payload.
225 $menu_payload = openstation_build_menu_payload();
226 $dock_items = $menu_payload['dockItems'];
227 $native_windows = isset( $menu_payload['nativeWindows'] )
228 ? $menu_payload['nativeWindows']
229 : array();
230
231 // The BOOT page prints every registry window's template as a real
232 // `<template>` tag (`openstation_render_native_window_templates()`,
233 // admin_footer @ 20 — before footer scripts, so the tags are in
234 // the DOM before the shell boots and `ensureTemplate()` adopts
235 // them by id). The payload's `templateHtml` copy exists for the
236 // MID-SESSION path — a bridge or probe payload delivering a
237 // window whose plugin activated after the page rendered — so on
238 // the boot config it is ~27 KB of the same markup twice. Strip it
239 // here, and only here: the bridge and probe payloads keep theirs.
240 foreach ( $native_windows as &$native_window_row ) {
241 if ( is_array( $native_window_row ) ) {
242 $native_window_row['templateHtml'] = '';
243 }
244 }
245 unset( $native_window_row );
246 $native_window_script_data = isset( $menu_payload['nativeWindowScriptData'] )
247 ? $menu_payload['nativeWindowScriptData']
248 : array();
249 $server_widgets = isset( $menu_payload['serverWidgets'] )
250 ? $menu_payload['serverWidgets']
251 : array();
252 $server_wallpapers = isset( $menu_payload['serverWallpapers'] )
253 ? $menu_payload['serverWallpapers']
254 : array();
255 $server_command_scripts = isset( $menu_payload['serverCommandScripts'] )
256 ? $menu_payload['serverCommandScripts']
257 : array();
258 $server_commands = isset( $menu_payload['serverCommands'] )
259 ? $menu_payload['serverCommands']
260 : array();
261 $server_settings_tab_scripts = isset( $menu_payload['serverSettingsTabScripts'] )
262 ? $menu_payload['serverSettingsTabScripts']
263 : array();
264 $server_settings_tabs = isset( $menu_payload['serverSettingsTabs'] )
265 ? $menu_payload['serverSettingsTabs']
266 : array();
267 $server_dock_rail_renderer_scripts = isset( $menu_payload['serverDockRailRendererScripts'] )
268 ? $menu_payload['serverDockRailRendererScripts']
269 : array();
270 $server_titlebar_button_scripts = isset( $menu_payload['serverTitleBarButtonScripts'] )
271 ? $menu_payload['serverTitleBarButtonScripts']
272 : array();
273 $server_window_action_scripts = isset( $menu_payload['serverWindowActionScripts'] )
274 ? $menu_payload['serverWindowActionScripts']
275 : array();
276 $server_window_theme_scripts = isset( $menu_payload['serverWindowThemeScripts'] )
277 ? $menu_payload['serverWindowThemeScripts']
278 : array();
279 $server_window_themes = isset( $menu_payload['serverWindowThemes'] )
280 ? $menu_payload['serverWindowThemes']
281 : array();
282 $server_window_control_scripts = isset( $menu_payload['serverWindowControlScripts'] )
283 ? $menu_payload['serverWindowControlScripts']
284 : array();
285 $server_window_controls = isset( $menu_payload['serverWindowControls'] )
286 ? $menu_payload['serverWindowControls']
287 : array();
288 $server_window_slot_scripts = isset( $menu_payload['serverWindowSlotScripts'] )
289 ? $menu_payload['serverWindowSlotScripts']
290 : array();
291 $server_window_slots = isset( $menu_payload['serverWindowSlots'] )
292 ? $menu_payload['serverWindowSlots']
293 : array();
294 $server_window_chrome_scripts = isset( $menu_payload['serverWindowChromeScripts'] )
295 ? $menu_payload['serverWindowChromeScripts']
296 : array();
297 $server_window_chromes = isset( $menu_payload['serverWindowChromes'] )
298 ? $menu_payload['serverWindowChromes']
299 : array();
300 $server_window_notices = isset( $menu_payload['serverWindowNotices'] )
301 ? $menu_payload['serverWindowNotices']
302 : array();
303 $server_games = isset( $menu_payload['serverGames'] )
304 ? $menu_payload['serverGames']
305 : array();
306 // Boot-time copy of the desktop-theme library. Without it the
307 // shell's registry seeds EMPTY, and the consequences are subtle
308 // rather than obvious: PHP has already applied the user's theme
309 // server-side (stylesheet + shell attribute), but the client
310 // can't resolve the slug to an entry, so it believes nothing is
311 // active. Themed ICONS never paint, and switching back to the
312 // system default no-ops the first time — `applyDesktopTheme()`
313 // dedupes on an `activeId` that was never set.
314 $server_desktop_themes = isset( $menu_payload['serverDesktopThemes'] )
315 ? $menu_payload['serverDesktopThemes']
316 : array();
317
318 // Slim the theme library for BOOT: `cssText` and `tokens` are
319 // each ~20 KB per theme, and neither is read at boot — the ACTIVE
320 // theme's stylesheet is server-delivered (see
321 // `openstation_enqueue_desktop_theme_style()`, whose stamp
322 // `bootAlreadyApplied()` detects), and an inactive theme's CSS
323 // only matters at the moment the user picks it in the Preferences
324 // picker — which fetches the full entries from
325 // `GET desktop-mode/v1/desktop-themes` (`ensureFullDesktopThemes()`
326 // client-side). `cssDeferred` marks the gap so the shell can tell
327 // a slimmed entry from a theme that genuinely ships no CSS.
328 // Bridge and probe payloads keep full entries.
329 foreach ( $server_desktop_themes as &$desktop_theme_row ) {
330 if ( is_array( $desktop_theme_row ) ) {
331 $desktop_theme_row['cssText'] = '';
332 $desktop_theme_row['tokens'] = new stdClass();
333 $desktop_theme_row['cssDeferred'] = true;
334 }
335 }
336 unset( $desktop_theme_row );
337 $desktop_icons = isset( $menu_payload['desktopIcons'] )
338 ? $menu_payload['desktopIcons']
339 : array();
340
341 // Files-on-the-Desktop payload (Phase 0+1). Plugin-registered
342 // file types and openers ship as metadata only; the JS side
343 // holds the executable handlers and resolves on double-click.
344 $server_file_types = function_exists( 'openstation_build_file_types_payload' )
345 ? openstation_build_file_types_payload()
346 : array();
347 $server_file_openers = function_exists( 'openstation_build_file_openers_payload' )
348 ? openstation_build_file_openers_payload()
349 : array();
350 $user_file_associations = function_exists( 'openstation_get_user_file_associations' )
351 ? openstation_get_user_file_associations( get_current_user_id() )
352 : array();
353 $server_wallpaper_menu_items = function_exists( 'openstation_build_wallpaper_menu_items' )
354 ? openstation_build_wallpaper_menu_items()
355 : array();
356
357 /*
358 * OS-file drop config — what the browser drop manager will
359 * accept when the user drags a file from their native desktop
360 * onto any surface inside OpenStation (wallpaper, a folder,
361 * a window, or a chromeless iframe). The allowed-mimes list is
362 * the user-scoped `get_allowed_mime_types()` (already capability
363 * gated by WordPress); the size cap is `wp_max_upload_size()`.
364 *
365 * Both are filterable so plugins can narrow or widen the set —
366 * e.g. a media-only plugin can restrict drops to images, or a
367 * docs plugin can opt PDFs in for a specific role.
368 */
369 $drop_allowed_mimes_map = current_user_can( 'upload_files' )
370 ? get_allowed_mime_types( get_current_user_id() )
371 : array();
372 /**
373 * Filter the allowed-mime map used by the OS-file drop manager.
374 *
375 * @param array<string,string> $mimes_map `ext => mime-type` map (same shape `get_allowed_mime_types()` returns).
376 * @param int $user_id The current user id.
377 */
378 $drop_allowed_mimes_map = apply_filters( 'openstation_drop_allowed_mimes', $drop_allowed_mimes_map, get_current_user_id() );
379 $drop_allowed_mimes_map = is_array( $drop_allowed_mimes_map ) ? $drop_allowed_mimes_map : array();
380 $drop_allowed_mimes = array_values( array_unique( array_values( $drop_allowed_mimes_map ) ) );
381
382 $drop_max_size = (int) wp_max_upload_size();
383 /**
384 * Filter the per-file size cap (in bytes) used by the OS-file
385 * drop manager. Returning `0` disables the client-side cap —
386 * the server still enforces its own.
387 *
388 * @param int $max_size Default `wp_max_upload_size()`.
389 * @param int $user_id The current user id.
390 */
391 $drop_max_size = (int) apply_filters( 'openstation_drop_max_size', $drop_max_size, get_current_user_id() );
392
393 /**
394 * Filter the master OS-file drop enable gate. Lets plugins
395 * disable the drop manager by role / capability beyond the
396 * default `upload_files` check (e.g. only for admins, or
397 * only on specific multisite blogs).
398 *
399 * @param bool $enabled Default — `current_user_can( 'upload_files' )`.
400 * @param int $user_id The current user id.
401 */
402 $drop_enabled = (bool) apply_filters(
403 'openstation_drop_enabled',
404 current_user_can( 'upload_files' ),
405 get_current_user_id()
406 );
407
408 $drop_config = array(
409 'enabled' => $drop_enabled,
410 'allowedMimes' => $drop_allowed_mimes,
411 'extToMime' => $drop_allowed_mimes_map,
412 'maxSize' => $drop_max_size,
413 );
414
415 // Lazy-bundle URL builder. Each lazy-loaded bundle (AI Assistant,
416 // OS Settings panel, shell-overlays, window-system)
417 // is `<script>`-injected by the main bundle on demand — they don't
418 // go through `wp_register_script`, so they don't pick up WordPress's
419 // usual `?ver=<filemtime>` cache-buster. Without one, the browser
420 // happily serves a stale cached copy across plugin updates that
421 // don't bump `OPENSTATION_VERSION`, and the main bundle's loader
422 // fires a `<script>`-loaded event for a file that's missing the
423 // fresh `window.openStation*` factory the new code expects.
424 //
425 // Mirror the `$built_version( … )` helper in `includes/assets.php`:
426 // prefer the on-disk mtime of the actual file, fall back to the
427 // plugin version when the file is missing (dev environments where
428 // the bundle hasn't been built yet).
429 $suffix = openstation_asset_suffix();
430 $lazy_bundle_url = static function ( $base ) use ( $suffix ) {
431 $path = OPENSTATION_DIR . 'assets/js/' . $base . $suffix . '.js';
432 $ver = file_exists( $path )
433 ? (string) filemtime( $path )
434 : OPENSTATION_VERSION;
435 return esc_url_raw(
436 OPENSTATION_URL . 'assets/js/' . $base . $suffix . '.js?ver=' . $ver
437 );
438 };
439
440 // The page the shell opens first. On the shell screen it is the
441 // validated `target` query arg (else the session's focused window,
442 // the default window, the Dashboard); on a solo boot it is the
443 // request's own URL. Either way the frozen portal flags are gone
444 // from it, so the derived window id matches what the dock would
445 // produce for the same page — otherwise auto-opening the entry
446 // window and clicking the same dock icon would create a duplicate.
447 $boot_target = openstation_shell_boot_target();
448 $current_page = $boot_target['url'];
449 $from_portal = $boot_target['fromPortal'];
450 $from_portal_intent = $boot_target['fromPortalIntent'];
451
452 // On the shell screen `$title` and `$parent_file` describe the
453 // screen ("OpenStation", no menu), not the page about to open. The
454 // dock entry for that page is the identity the entry window folds
455 // into, so its title and icon are the right first paint; the iframe
456 // reports its own title once it lands either way.
457 $current_title = wp_strip_all_tags( (string) $title );
458 if ( openstation_is_shell_screen_request() ) {
459 $boot_meta = openstation_shell_boot_target_meta( $current_page, $dock_items );
460 $current_title = wp_strip_all_tags( $boot_meta['title'] );
461 if ( '' !== $boot_meta['icon'] ) {
462 $menu_icon = $boot_meta['icon'];
463 }
464 }
465
466 /**
467 * Filters the desktop shell configuration passed to JavaScript.
468 *
469 * @param array $config {
470 * Desktop shell configuration.
471 *
472 * @type string $currentPage The current admin page URL.
473 * @type string $currentTitle The current page title.
474 * @type string $currentIcon Dashicon class for the current page.
475 * @type string $adminUrl The base admin URL.
476 * @type string $colorScheme The active admin color scheme.
477 * @type array $dockItems Dock items derived from the admin menu. Core WordPress pages (Dashboard, Posts, Plugins, Users, Settings, CPTs…) are ordered first; plugin-contributed top-level routes (admin.php?page=*) follow. Items hidden via `openstation_dock_placement` are omitted.
478 * @type array $nativeWindows Server-declared native windows (via `openstation_register_window`). Shell registers + syncs tiles based on this list — activation/deactivation is a diff without shell reload.
479 * @type array $serverWidgets Server-declared right-column widgets (via `openstation_register_widget`). Shell syncs the widget registry + dynamically loads plugin scripts so widgets appear in the picker without a shell reload.
480 * @type array $serverWallpapers Server-declared wallpapers (via `openstation_register_wallpaper`). Same lifecycle — shell loads the plugin's JS, reads the full `WallpaperDef` from `window.openStationWallpapers[id]`, and registers / unregisters as plugins activate / deactivate.
481 * @type array $serverCommandScripts Script handles opted-in via `openstation_register_command_script`. Shell injects each URL on activation so commands registered by `wp.os.registerCommand` appear in the palette live. Deactivation unregisters any commands whose `owner` matches the departing handle.
482 * @type array $serverCommands Server-declared command metadata (via `openstation_register_command`). Advisory today — reserved for future pre-registration shims.
483 * @type array $serverSettingsTabScripts Script handles opted-in via `openstation_register_settings_tab_script`. Shell injects each URL on activation so tabs registered by `wp.os.registerSettingsTab` appear in the OS Settings window live. Deactivation unregisters tabs attributable to the departing handle.
484 * @type array $serverSettingsTabs Server-declared settings-tab metadata (via `openstation_register_settings_tab`). Enables live unregistration on plugin deactivation without requiring JS to set `owner`.
485 * @type array $desktopIcons Server-declared desktop icons (via `openstation_register_icon`). Rendered on the wallpaper as clickable shortcut tiles.
486 * @type array $accentColors Swatch list for the OS Settings accent picker. Filterable via `openstation_accent_colors`.
487 * @type array $toastTypes Toast-notification type map. Filterable via `openstation_toast_types`.
488 * @type string $defaultWallpaper Wallpaper slug applied on first boot. Filterable via `openstation_default_wallpaper`.
489 * @type array $session Saved session (windows, focused, updated).
490 * @type string $sessionUrl REST endpoint for saving the session.
491 * @type string $mediaUrl REST endpoint for media uploads (wp/v2/media).
492 * @type string $restUrl REST API root from rest_url(), safe for pretty and plain permalink installs.
493 * @type string $defaultWindowUrl REST endpoint for saving the default-window preference.
494 * @type array $defaultWindow { enabled: bool, url: string } — current default-window preference.
495 * @type bool $canUpload Whether the user holds the `upload_files` capability.
496 * @type string $pluginUrl Plugin base URL (no trailing slash). Used by the shell to locate vendor assets and by plugins to build asset URLs.
497 * @type string $pluginVersion Plugin semver string. Surfaced in the OS Settings → About tab; plugins can read it to gate features by version.
498 * @type string $aboutFeedUrl Authenticated admin-AJAX URL that returns the cached OpenStation journal feed for the About tab.
499 * @type string $restNonce Nonce for the session REST endpoint.
500 * @type string $soloWindow Window id when the shell was asked to paint exactly one window (`?openstation_solo=<id>`); '' otherwise. No dock, taskbar, wallpaper or desk, and no session restore.
501 * @type string $portalUrl Canonical `/openstation/` URL.
502 * @type bool $fromPortal Whether the shell was reached via the portal.
503 * @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.
504 * @type array $seenIntros Slugs of one-time announcements the user has dismissed (e.g. `['openstation-rebrand']`).
505 * @type string $seenIntrosUrl REST endpoint for the seen-intros surface — POST `/seen` to mark, DELETE the base to reset.
506 * @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.
507 * }
508 */
509 $config = apply_filters(
510 'openstation_shell_config',
511 array(
512 'currentPage' => esc_url( $current_page ),
513 'currentTitle' => $current_title,
514 'currentIcon' => sanitize_html_class( $menu_icon ),
515 // `self_admin_url()`: the base a window id is derived from,
516 // and the URL the shell leaves for on exit. Both want the
517 // admin the screen is in, which is the network one when the
518 // network shell screen is what rendered.
519 'adminUrl' => esc_url( self_admin_url() ),
520 'homeUrl' => esc_url( home_url( '/' ) ),
521 // Decoded: the shell assigns this to `window.location`,
522 // where `&amp;` would make `_wpnonce` arrive as
523 // `amp;_wpnonce` and fail the nonce check.
524 'logoutUrl' => esc_url_raw(
525 html_entity_decode( wp_logout_url(), ENT_QUOTES, 'UTF-8' )
526 ),
527 'colorScheme' => sanitize_html_class( get_user_option( 'admin_color' ), 'fresh' ),
528 'dockItems' => $dock_items,
529 // Baseline menu fingerprint. The shell seeds its last-known
530 // signature from this so the first off-allowlist menu change
531 // (vs. this boot state) is caught without a wasted probe. GH#325.
532 'menuSig' => isset( $menu_payload['menuSig'] ) ? (string) $menu_payload['menuSig'] : '',
533 'nativeWindows' => $native_windows,
534 // Handle-keyed script data the entries above reference —
535 // one copy per bundle, not one per window. See
536 // `openstation_collect_native_windows_payload()`.
537 'nativeWindowScriptData' => $native_window_script_data,
538 'serverWidgets' => $server_widgets,
539 'serverWallpapers' => $server_wallpapers,
540 'serverCommandScripts' => $server_command_scripts,
541 'serverCommands' => $server_commands,
542 'serverSettingsTabScripts' => $server_settings_tab_scripts,
543 'serverSettingsTabs' => $server_settings_tabs,
544 'serverDockRailRendererScripts' => $server_dock_rail_renderer_scripts,
545 'serverTitleBarButtonScripts' => $server_titlebar_button_scripts,
546 'serverWindowActionScripts' => $server_window_action_scripts,
547 'serverWindowThemeScripts' => $server_window_theme_scripts,
548 'serverWindowThemes' => $server_window_themes,
549 'serverWindowControlScripts' => $server_window_control_scripts,
550 'serverWindowControls' => $server_window_controls,
551 'serverWindowSlotScripts' => $server_window_slot_scripts,
552 'serverWindowSlots' => $server_window_slots,
553 'serverWindowChromeScripts' => $server_window_chrome_scripts,
554 'serverWindowChromes' => $server_window_chromes,
555 'serverWindowNotices' => $server_window_notices,
556 // Boot-time copy of the payload's `serverGames` — the same
557 // list the live-refresh path applies. Without it the games
558 // registry only fills after the first chromeless
559 // full-payload refresh and the Games hub boots empty.
560 'serverGames' => $server_games,
561 'serverDesktopThemes' => $server_desktop_themes,
562 'desktopIcons' => $desktop_icons,
563 'serverFileTypes' => $server_file_types,
564 'serverFileOpeners' => $server_file_openers,
565 'userFileAssociations' => $user_file_associations,
566 'filesUrl' => esc_url_raw( rest_url( 'desktop-mode/v1/files' ) ),
567 // Pinned-notes REST base (`includes/notes/rest.php`). The
568 // notes layer boots only when this is present.
569 'notesUrl' => esc_url_raw( rest_url( 'desktop-mode/v1/notes' ) ),
570 // Gates the "Convert to post" note affordance — the convert
571 // route (and its dock drop target) only make sense for users
572 // who can author posts.
573 'canCreatePosts' => current_user_can( 'edit_posts' ),
574 'serverWallpaperMenuItems' => $server_wallpaper_menu_items,
575 'accentColors' => openstation_get_accent_colors(),
576 'toastTypes' => openstation_get_toast_types(),
577 'coreUpdate' => openstation_get_core_update(),
578 'coreNotices' => openstation_get_core_notices(),
579 'pluginNotices' => openstation_get_plugin_notices(),
580 'defaultWallpaper' => openstation_get_default_wallpaper(),
581 'session' => openstation_get_session( get_current_user_id() ),
582 // The session route runs in the main site's blog context
583 // whichever desktop posts to it, so the network screen's
584 // URL says which session it is addressing — see
585 // `openstation_rest_session_network()`.
586 'sessionUrl' => esc_url_raw(
587 is_network_admin()
588 ? add_query_arg( 'network', '1', rest_url( 'desktop-mode/v1/session' ) )
589 : rest_url( 'desktop-mode/v1/session' )
590 ),
591 'restUrl' => esc_url_raw( rest_url() ),
592 'mediaUrl' => esc_url_raw( rest_url( 'wp/v2/media' ) ),
593 'dropConfig' => $drop_config,
594 'defaultWindowUrl' => esc_url_raw( rest_url( 'desktop-mode/v1/default-window' ) ),
595 'defaultWindow' => openstation_get_default_window( get_current_user_id() ),
596 'canUpload' => current_user_can( 'upload_files' ),
597 'pluginUrl' => esc_url_raw( untrailingslashit( OPENSTATION_URL ) ),
598 'pluginVersion' => OPENSTATION_VERSION,
599 'aboutFeedUrl' => esc_url_raw(
600 add_query_arg(
601 array(
602 'action' => 'openstation_about_feed',
603 'nonce' => wp_create_nonce( 'openstation_about_feed' ),
604 ),
605 admin_url( 'admin-ajax.php' )
606 )
607 ),
608 'iframeBridgeUrl' => $lazy_bundle_url( 'iframe-bridge' ),
609 // URL of the AI Assistant lazy bundle. The main bundle
610 // ships a stub matching the public `wp.os.ai` API; the
611 // stub `<script>`-injects this URL the first time the user
612 // opens the assistant. Picking `.js` vs `.min.js` here keeps
613 // the SCRIPT_DEBUG gate server-side, matching iframeBridgeUrl.
614 'aiAssistantBundleUrl' => $lazy_bundle_url( 'ai-assistant' ),
615 // URL of the shell-overlays lazy bundle. Pre-loaded by
616 // the main bundle after first paint so action-triggered
617 // overlays (toast, confirm dialog, context menus) feel
618 // instant the first time they fire.
619 'shellOverlaysBundleUrl' => $lazy_bundle_url( 'shell-overlays' ),
620 // The shell-bundle diet: features whose right moment is a
621 // user gesture (or a presence signal) ride their own
622 // bundles instead of the boot-critical `desktop[.min].js`.
623 // Each sentinel in the shell loads its bundle at that
624 // moment — see the entry file each bundle names.
625 'fileDropBundleUrl' => $lazy_bundle_url( 'file-drop' ),
626 'filesOverlaysBundleUrl' => $lazy_bundle_url( 'files-overlays' ),
627 'notesBundleUrl' => $lazy_bundle_url( 'notes' ),
628 'dockConstellationBundleUrl' => $lazy_bundle_url( 'dock-constellation' ),
629 'windowLinkVisualsBundleUrl' => $lazy_bundle_url( 'window-link-visuals' ),
630 // Presence hint for the notes sentinel: a desktop with no
631 // notes skips the notes bundle AND the boot-time list
632 // request. Two id-only existence probes at most.
633 'hasNotes' => function_exists( 'openstation_notes_user_has_any' )
634 ? openstation_notes_user_has_any()
635 : false,
636 // URL of the full `<os-*>` component kit. The shell
637 // never loads this — its own bundles import the
638 // components they render. It exists for
639 // `wp.os.loadComponents()`, i.e. for plugin code that
640 // CANNOT import: a plugin shipped as a zip has no path
641 // to this repo at build time, so before this URL its
642 // only routes to a `<os-switch>` were to bundle a second
643 // copy or hand-roll one. Shipping the URL costs one
644 // string and keeps the SCRIPT_DEBUG choice server-side.
645 'componentsBundleUrl' => $lazy_bundle_url( 'os-components' ),
646 // Mio — the desk companion. `mio` carries the
647 // appearance + physics (see `openstation_mio_config()`);
648 // `mioBundleUrl` is the lazy PixiJS bundle the shell
649 // controller injects the first time a user switches the
650 // Mio on from its dock tile. Shipping the URL
651 // unconditionally costs one short string and keeps the
652 // SCRIPT_DEBUG choice server-side, matching every other
653 // lazy bundle here.
654 //
655 // Both keys ship whether or not the user has Mio on,
656 // and that is the whole of its cost to a shell that doesn't:
657 // ~470 bytes gzipped of config, plus a URL. No script, no
658 // style, no PixiJS. The config has to be here rather than
659 // fetched on first toggle, or the `openstation_mio_config`
660 // filter would silently not apply until the next reload.
661 'mio' => openstation_mio_config(),
662 'mioBundleUrl' => $lazy_bundle_url( 'mio' ),
663 // URL of the lazy window-system bundle (Stage 11).
664 // Holds the `Window` class and its DOM / pointer / tab /
665 // chrome helpers — the single largest module split out of
666 // the main bundle. Loaded on first `windowManager.open()`
667 // / `openNew()` call (both async); pre-loaded
668 // by the shell after first paint when no session is being
669 // restored and no `openCurrentPage` will fire.
670 'windowSystemBundleUrl' => $lazy_bundle_url( 'window-system' ),
671 // URL of the lazy phone-layer bundle. Injected by the main
672 // bundle only when the mode resolves to `mobile`, so a
673 // desktop never fetches it. `mode` carries the preference
674 // and breakpoints the first-paint head stamp already used,
675 // plus the server's default tab-bar pins — see
676 // `includes/mobile.php`.
677 'mobileBundleUrl' => $lazy_bundle_url( 'mobile' ),
678 'mode' => openstation_mode_config( get_current_user_id() ),
679 // URL of the item-visibility-menu lazy bundle — the
680 // right-click "hide from dock / desktop" menu. Injected by
681 // the main bundle's loader shim on the first right-click.
682 'itemVisibilityMenuBundleUrl' => $lazy_bundle_url( 'item-visibility-menu' ),
683 // URL of the workspace-wizard lazy bundle — the modal
684 // behind "Edit this workspace…". Injected by the main
685 // bundle's loader shim on first open.
686 'workspaceWizardBundleUrl' => $lazy_bundle_url( 'workspace-wizard' ),
687 // Server-side view of the workspace templates, so a plugin
688 // can add or drop one from PHP. The client merges these
689 // with its own built-ins by id — see
690 // `src/workspaces/server-sync.ts`.
691 'workspacePresets' => openstation_workspace_presets(),
692 // URL of the release-card lazy bundle — the vinyl core-
693 // update announcement. Injected by `maybeShowUpdate()` only
694 // when a core update is actually pending.
695 'releaseCardBundleUrl' => $lazy_bundle_url( 'release-card' ),
696 'restNonce' => wp_create_nonce( 'wp_rest' ),
697 // Non-empty when the shell was asked to paint exactly one
698 // window and nothing else. See `OPENSTATION_SOLO_FLAG`.
699 'soloWindow' => openstation_solo_window_id(),
700 'osSettings' => openstation_get_os_settings( get_current_user_id() ),
701 'osSettingsUrl' => esc_url_raw( rest_url( 'desktop-mode/v1/os-settings' ) ),
702 'seenIntros' => openstation_get_seen_intros( get_current_user_id() ),
703 'seenIntrosUrl' => esc_url_raw( rest_url( 'desktop-mode/v1/intros' ) ),
704 // True only for a user migration 5 flagged as a Desktop Mode
705 // user from before the rename, who hasn't dismissed the
706 // announcement yet. Same value that gated `os-announce`
707 // above; the dialog cannot paint without that stylesheet, so
708 // the two must not diverge.
709 'rebrandNotice' => $show_rebrand_notice,
710 'aiSearchUrl' => esc_url_raw( rest_url( 'desktop-mode/v1/ai/search' ) ),
711 // AI assistant availability + per-user toggle. Drives whether the
712 // Cmd+K palette and admin-bar icon appear, and the setup placeholder.
713 'aiAssistant' => function_exists( 'openstation_ai_assistant_config' )
714 ? openstation_ai_assistant_config()
715 : null,
716 // Lets the Features tab re-check provider availability without a
717 // reload after a connector is configured in Settings → Connectors.
718 'aiStatusUrl' => esc_url_raw( rest_url( 'desktop-mode/v1/ai/status' ) ),
719 'extendedOptions' => current_user_can( 'manage_options' ) ? openstation_get_extended_options() : null,
720 'extendedOptionsUrl' => esc_url_raw( rest_url( 'desktop-mode/v1/extended-options' ) ),
721 // Site-wide games kill switch (Extended options). Exposed to
722 // every user — the shell skips the challenges Heartbeat
723 // channel when the framework is off.
724 'gamesEnabled' => openstation_games_enabled(),
725 // Comments-window AI moderation toggle — surfaced at the
726 // shell level so the OS Settings → Features tab can render
727 // the toggle without depending on the Comments window
728 // being registered for this user. URL is the same
729 // endpoint the comments-window config exposes; state is
730 // `null` for non-admins (the UI hides the row entirely).
731 'commentsAiUrl' => esc_url_raw( rest_url( 'desktop-mode/v1/comments/ai-settings' ) ),
732 // Non-null only for admins on a site where the Core AI stack is
733 // present. Comment scoring routes through the AI Client (WP 7.0+),
734 // so on older WordPress the whole row is hidden — same as the
735 // assistant toggle — rather than shown disabled pointing at a
736 // Settings → Connectors screen that doesn't exist there.
737 'commentsAi' => (
738 current_user_can( 'manage_options' )
739 && function_exists( 'openstation_ai_is_available' )
740 && openstation_ai_is_available()
741 )
742 ? array(
743 'enabled' => function_exists( 'openstation_comments_ai_is_enabled' )
744 ? openstation_comments_ai_is_enabled()
745 : false,
746 'providerConfigured' => function_exists( 'openstation_comments_ai_provider_configured' )
747 ? openstation_comments_ai_provider_configured()
748 : false,
749 )
750 : null,
751 'currentUserIsAdmin' => current_user_can( 'manage_options' ),
752 // Null on single-site installs; its `networkAdmin` null
753 // without `manage_network`, which is what keeps the dock
754 // tile from registering.
755 'multisite' => openstation_multisite_payload(),
756 'portalUrl' => esc_url( openstation_portal_url() ),
757 'fromPortal' => $from_portal,
758 'fromPortalIntent' => $from_portal_intent,
759 // One-shot like the boot target: a switch from another
760 // site's overview lands in this one's.
761 'landInOverview' => openstation_shell_lands_in_overview(),
762 'arrivalDirection' => openstation_shell_arrival_direction(),
763 'hopLinkOffer' => function_exists( 'openstation_network_link_offer' ) ? openstation_network_link_offer() : null,
764 'pwa' => array(
765 'manifestUrl' => esc_url_raw( openstation_pwa_manifest_url() ),
766 'swUrl' => esc_url_raw( openstation_pwa_sw_url() ),
767 // Extensionless retry target for hosts whose nginx 404s
768 // virtual .js paths before WordPress runs (WordPress.com).
769 'swFallbackUrl' => esc_url_raw( openstation_pwa_sw_fallback_url() ),
770 // The site's home path — the scope the registration
771 // asks for, so a subdirectory network's sites each get
772 // their own worker instead of fighting over the root.
773 'swScope' => openstation_pwa_sw_scope(),
774 // The worker's per-user flags, computed HERE rather than
775 // baked into the served `sw.js`.
776 //
777 // A service worker is origin-wide but these are per-user
778 // preferences, so putting them in the script bytes made
779 // the body differ between an anonymous and a logged-in
780 // request — and any in-scope logged-out navigation then
781 // installed a "new" worker, which at the time reloaded
782 // the shell. The bytes are identical for everyone now;
783 // the shell posts these to the worker at boot.
784 //
785 // Computed server-side, not read from the settings
786 // snapshot client-side, because
787 // `openstation_pwa_admin_asset_cache_enabled()` applies
788 // the `openstation_pwa_admin_asset_cache` filter — an
789 // operator's site-wide veto has to keep working.
790 'swConfig' => array(
791 'adminAssetCache' => (bool) openstation_pwa_admin_asset_cache_enabled(),
792 'windowPrewarm' => ! empty( openstation_get_os_settings( get_current_user_id() )['windowPrewarmEnabled'] ),
793 ),
794 // The build this shell document belongs to. When a new
795 // worker takes over mid-session the shell asks it for
796 // the stamp it was served with and compares; only a
797 // difference — the shell's own files changed on the
798 // server — offers the user a reload. Never automatic.
799 'shellBuild' => openstation_shell_build_stamp(),
800 'stateUrl' => esc_url_raw( rest_url( 'desktop-mode/v1/pwa-state' ) ),
801 'state' => openstation_pwa_get_user_state( get_current_user_id() ),
802 // Mirrors the manifest's `name` field — used by the
803 // install pill so the button reads "Install <site>"
804 // rather than "Install <current page>" (which would
805 // be misleading: we install the whole site as an
806 // app, not the dashboard window the user happens to
807 // be viewing).
808 'appName' => get_bloginfo( 'name' ),
809 // Operators set the `openstation_pwa_force_replace_sw`
810 // filter to `true` when another root-scope service
811 // worker on the origin is blocking openstation
812 // installability (foreign-SW guard in
813 // `src/pwa/sw-register.ts`). Default `false` preserves
814 // the polite behaviour where we yield to existing PWAs.
815 'forceReplaceSw' => openstation_pwa_force_replace_sw(),
816 ),
817 // Ordered Core command-palette asset manifest, replayed on
818 // first palette invocation. `null` on pre-6.9 sites.
819 'commandPalette' => $command_palette,
820 // Stylesheets for shell surfaces that render on demand —
821 // the Preferences panel, the AI assistant, the bug-report
822 // window. None of them is a server-registered native
823 // window (they are built client-side by the shell
824 // bundle), so the `styles` companion mechanism can't
825 // carry their CSS; instead the shell injects each sheet
826 // the first time its surface opens, via
827 // `ensureDeferredStyle()` in `src/deferred-styles.ts`.
828 // Same resolved shape a native window's `styleUrl` /
829 // `styleInline` travels in.
830 // Which of the `deferredStyles` entries a game needs.
831 // `launchGame()` injects these before the window paints.
832 'gameStyleHandles' => function_exists( 'openstation_games_style_handles' )
833 ? openstation_games_style_handles()
834 : array(),
835 'deferredStyles' => openstation_build_deferred_styles(
836 array_merge(
837 array(
838 'desktop-mode-ai-assistant',
839 'desktop-mode-bug-report',
840 // The explorer's shared sheet. It rides the WP
841 // Explorer APP as a companion style, but the
842 // desktop FOLDER window paints its preview pane
843 // with the same `os-my-wordpress__*` classes and
844 // — being a native window opened straight from
845 // JS — carries no companion styles of its own.
846 // Without this, the pane rendered unstyled until
847 // the explorer had been opened once in the
848 // session.
849 'desktop-mode-my-wordpress',
850 ),
851 // The Games sheets. They also ride the hub window as
852 // companion styles, but a game is reachable without
853 // the hub — the challenge toast, solo mode, and
854 // `wp.os.games.launch()` all land in `launchGame()`
855 // with no hub window in the tab. Listing them here
856 // costs a URL each in the boot config and no CSS
857 // until `launchGame()` asks.
858 function_exists( 'openstation_games_style_handles' )
859 ? openstation_games_style_handles()
860 : array()
861 )
862 ),
863 )
864 );
865
866 wp_localize_script( 'openstation', 'openStationConfig', $config );
867
868 /**
869 * Fires when OpenStation assets are enqueued.
870 */
871 do_action( 'openstation_mode_init' );
872 }
873 add_action( 'admin_enqueue_scripts', 'openstation_enqueue_assets' );
874
875 /**
876 * Keep Core's boot-time command-palette enqueue off shell pages.
877 *
878 * WordPress 7.0 hooks `wp_enqueue_command_palette_assets()` on
879 * `admin_enqueue_scripts` by default, which puts the palette's whole
880 * dependency chain — the Gutenberg runtime, ~800 KB gzipped — on
881 * every admin page. On a SHELL page that is pure dead weight: the
882 * shell suppresses Core's palette unconditionally (the ⌘K keystroke
883 * and the admin-bar icon both route to the shell's own palette), so
884 * the runtime it powers can never be shown. Unhooking here lets the
885 * deferred manifest (`openstation_build_command_palette_assets_payload()`)
886 * capture the chain instead, and the shell loads it on the first
887 * palette invocation.
888 *
889 * Deliberately scoped: classic-mode requests keep Core's default,
890 * because a classic page is Core's own UI where Core's palette is the
891 * right one.
892 *
893 * Windows are handled separately by
894 * {@see openstation_chromeless_should_trim_command_palette()} in
895 * `includes/render/chromeless-trim.php` — same idea, but it has to
896 * drop the whole palette *family* rather than just unhook Core's
897 * callback, and it exempts block-editor screens. Unhooking alone is
898 * not enough there: a third-party palette extension that declares
899 * `wp-commands` keeps the entire chain queued as its dependency.
900 *
901 * Priority 0, ahead of Core's default 10, so the removal lands
902 * before the callback fires. On WP 6.9 (function exists, no default
903 * hook) the `remove_action()` is a harmless no-op.
904 */
905 function openstation_defer_core_command_palette() {
906 if ( ! openstation_is_shell_request() ) {
907 return;
908 }
909 remove_action( 'admin_enqueue_scripts', 'wp_enqueue_command_palette_assets' );
910 }
911 add_action( 'admin_enqueue_scripts', 'openstation_defer_core_command_palette', 0 );
912
913 /**
914 * Emits `<link rel="preload">` hints for the shell's critical-path
915 * assets so the browser starts fetching them as soon as it parses
916 * the document `<head>`.
917 *
918 * Without this, the browser doesn't discover the main `desktop.min.js`
919 * bundle URL until it parses the footer `<script>` tag — typically
920 * ~1 RTT after the rest of the page has started loading. For a 464 KB
921 * bundle on a midrange phone that's a measurable FCP delay; on a
922 * slow connection it dominates first paint entirely.
923 *
924 * Hooked at `admin_print_styles @ 1` so the preload tags land in
925 * `<head>` BEFORE the regular `<link rel="stylesheet">` tags (which
926 * default to priority 10) and well before the footer `<script>`
927 * tag. The `wp_resource_hints` filter is frontend-only (`wp_head`-
928 * driven) and isn't invoked in admin context, so we emit our own
929 * tags.
930 *
931 * Four targets by default, split across two relationship types:
932 * - `desktop[.min].js` (preload) — the shell bundle (biggest win),
933 * consumed by the footer `<script>` on this very load.
934 * - `desktop.css` (preload) — shell base CSS, needed for first
935 * paint. Its registered handle is `filemtime`-stamped so the
936 * stylesheet URL matches this hint exactly (a `?ver=` mismatch makes
937 * the browser treat the preload as unused).
938 * - `window-system[.min].js` (prefetch) — lazy bundle `<script>`-
939 * injected by the main bundle on the first `open()`.
940 * - `shell-overlays[.min].js` (prefetch) — lazy bundle injected on the
941 * first toast / dialog / context-menu.
942 *
943 * The lazy bundles use `prefetch` rather than `preload`: they're loaded
944 * later (often beyond the ~3s window Chrome allows a `preload` before it
945 * warns "preloaded but not used in time"), so `prefetch` keeps the early
946 * low-priority cache fill without the must-use-now contract.
947 *
948 * Plugins can extend the hint list via the `openstation_preload_hints`
949 * filter — e.g. a settings tab whose bundle the user opens on every
950 * visit can opt its own URL into the preload phase.
951 *
952 * Same-origin resources only — no `crossorigin` attribute. CDN hosts
953 * that serve `wp-content/plugins/` from a different origin should
954 * supply absolute URLs through the filter; in that case the consumer
955 * is responsible for the `crossorigin` semantics.
956 */
957 function openstation_print_preload_hints() {
958 if ( ! openstation_is_shell_request() ) {
959 return;
960 }
961
962 $suffix = openstation_asset_suffix();
963
964 $build_url = static function ( $relative ) {
965 $path = OPENSTATION_DIR . $relative;
966 $ver = file_exists( $path ) ? (string) filemtime( $path ) : OPENSTATION_VERSION;
967 return OPENSTATION_URL . $relative . '?ver=' . $ver;
968 };
969
970 $hints = array(
971 // Critical path — consumed on this very page load (the footer
972 // `<script>` and the shell stylesheet), so `preload` is correct.
973 array(
974 'href' => $build_url( 'assets/js/desktop' . $suffix . '.js' ),
975 'as' => 'script',
976 'rel' => 'preload',
977 ),
978 array(
979 'href' => $build_url( 'assets/css/desktop.css' ),
980 'as' => 'style',
981 'rel' => 'preload',
982 ),
983 // Lazy bundles — `<script>`-injected by the main bundle after
984 // first paint (window-system on the first `open()`, shell-overlays
985 // on the first toast / dialog / context-menu). They are frequently
986 // NOT requested within the ~3s window Chrome allows a `preload`,
987 // which produced "resource was preloaded but not used in time"
988 // warnings. `prefetch` is the right hint: same early, low-priority
989 // fetch into the cache, but no must-use-now contract — so the
990 // injected `<script src>` is served from cache with no warning.
991 array(
992 'href' => $build_url( 'assets/js/window-system' . $suffix . '.js' ),
993 'as' => 'script',
994 'rel' => 'prefetch',
995 ),
996 array(
997 'href' => $build_url( 'assets/js/shell-overlays' . $suffix . '.js' ),
998 'as' => 'script',
999 'rel' => 'prefetch',
1000 ),
1001 );
1002
1003 // The phone layer is needed at boot on a phone and never on a
1004 // desktop; the server cannot see the viewport, so the user agent
1005 // decides whether the hint is worth its bytes. A wrong guess costs
1006 // one low-priority fetch, never a wrong layout — the stamp and the
1007 // bundle loader read the real viewport.
1008 if ( openstation_mode_hint_is_mobile( get_current_user_id() ) ) {
1009 $hints[] = array(
1010 'href' => $build_url( 'assets/js/mobile' . $suffix . '.js' ),
1011 'as' => 'script',
1012 'rel' => 'prefetch',
1013 );
1014 }
1015
1016 /**
1017 * Filters the list of resource preload hints emitted in `<head>`.
1018 *
1019 * Each entry is a `{ 'href' => string, 'as' => string,
1020 * 'rel' => 'preload'|'prefetch' }` array rendered as
1021 * `<link rel="<rel>" as="<as>" href="<href>">`. `rel` is optional and
1022 * defaults to `preload`; any value other than `prefetch` is coerced
1023 * back to `preload`. Unrecognized entries are silently skipped — keep
1024 * the contract permissive so a misconfigured plugin can't tank first
1025 * paint.
1026 *
1027 * @param array $hints Default hints (main bundle + base CSS as
1028 * `preload`; window-system + shell-overlays as
1029 * `prefetch`).
1030 */
1031 $hints = apply_filters( 'openstation_preload_hints', $hints );
1032
1033 if ( ! is_array( $hints ) ) {
1034 return;
1035 }
1036
1037 foreach ( $hints as $hint ) {
1038 if ( ! is_array( $hint ) ) {
1039 continue;
1040 }
1041 $href = isset( $hint['href'] ) ? (string) $hint['href'] : '';
1042 $as = isset( $hint['as'] ) ? (string) $hint['as'] : '';
1043 if ( '' === $href || '' === $as ) {
1044 continue;
1045 }
1046 // `preload` (critical, used on this load) vs `prefetch` (lazy,
1047 // used on a later interaction). Anything else falls back to
1048 // `preload` so a typo can't emit an invalid relationship.
1049 $rel = isset( $hint['rel'] ) ? (string) $hint['rel'] : 'preload';
1050 if ( 'prefetch' !== $rel ) {
1051 $rel = 'preload';
1052 }
1053 printf(
1054 '<link rel="%s" as="%s" href="%s" />' . "\n",
1055 esc_attr( $rel ),
1056 esc_attr( $as ),
1057 esc_url( $href )
1058 );
1059 }
1060 }
1061 add_action( 'admin_print_styles', 'openstation_print_preload_hints', 1 );
1062
1063 /**
1064 * Defers loading of non-critical openstation stylesheets so they
1065 * don't block first paint.
1066 *
1067 * Three stylesheets in the default enqueue list are only needed
1068 * after a user interaction — `dock-peek` (mouseover a dock tile),
1069 * `ai-assistant` (Cmd+K palette), `bug-report` (Report-a-bug
1070 * window). With the normal `<link rel="stylesheet">` tag they sit
1071 * on the critical path and the browser blocks first paint waiting
1072 * for them, even though nothing on screen needs them yet.
1073 *
1074 * The well-known mitigation is the `media="print" onload="…"`
1075 * pattern:
1076 *
1077 * <link rel="stylesheet" media="print"
1078 * onload="this.media='all'; this.onload=null" href="…">
1079 * <noscript><link rel="stylesheet" href="…"></noscript>
1080 *
1081 * `media="print"` makes the browser treat the sheet as
1082 * non-applicable to the current display, so it downloads with
1083 * low priority and doesn't block render. The `onload` handler
1084 * swaps `media` to the original value once the bytes arrive
1085 * (within ms of page load), making the styles take effect long
1086 * before the user clicks anything that needs them. The
1087 * `<noscript>` fallback restores critical-path behavior for JS-off
1088 * browsers, so accessibility isn't degraded.
1089 *
1090 * Filterable via `openstation_deferred_styles` so plugins can opt
1091 * their own non-critical stylesheets in (or pull a built-in out).
1092 * Chromeless iframes are skipped — their CSS pipeline is separate.
1093 *
1094 * @param string $html The original <link> tag HTML.
1095 * @param string $handle The stylesheet handle WP is printing.
1096 * @param string $href The full URL of the stylesheet.
1097 * @param string $media The media attribute value WP resolved.
1098 * @return string Possibly-rewritten tag.
1099 */
1100 function openstation_defer_non_critical_styles( $html, $handle, $href, $media ) {
1101 // Cheap gates first — `style_loader_tag` fires once per enqueued
1102 // stylesheet on EVERY admin page (frontend doesn't go through
1103 // this filter, but admin does, including pages where OpenStation
1104 // is disabled). The deferred handles only ship when OpenStation
1105 // is active, so the in_array check below would always miss on
1106 // classic-only admin pages — but the `apply_filters` call still
1107 // builds an array and walks subscribers per stylesheet. Short-
1108 // circuit on the cheap helper checks (`is_admin` / enabled /
1109 // chromeless) so non-openstation users pay nothing.
1110 if ( ! openstation_is_enabled() ) {
1111 return $html;
1112 }
1113 if ( openstation_is_chromeless_request() ) {
1114 return $html;
1115 }
1116
1117 /**
1118 * Filters the list of stylesheet handles that should be loaded
1119 * deferred via the media-print-onload pattern. Plugins can add
1120 * their own non-critical stylesheets here, or pull a built-in
1121 * out (e.g. a plugin that surfaces the AI assistant on every
1122 * page might want to keep its CSS critical-path).
1123 *
1124 * @param string[] $handles Default deferred handles.
1125 */
1126 $deferred = apply_filters(
1127 'openstation_deferred_styles',
1128 array(
1129 'os-dock-peek',
1130 'os-openstation-layout',
1131 'desktop-mode-ai-assistant',
1132 'desktop-mode-bug-report',
1133 'os-window-overview',
1134 )
1135 );
1136
1137 if ( ! in_array( $handle, (array) $deferred, true ) ) {
1138 return $html;
1139 }
1140
1141 $resolved_media = $media ? $media : 'all';
1142 $id = $handle . '-css';
1143
1144 // Two contexts, two escapers for the same `$resolved_media` value:
1145 //
1146 // - `%3$s` lands inside a JS string literal inside the HTML
1147 // `onload="…"` attribute (`this.media='%3$s'`). `esc_attr`
1148 // escapes `"` and `&` but NOT single quotes, so a media
1149 // value containing `'` would break out of the JS string.
1150 // `esc_js` is the correct escaper for "string literal inside
1151 // an event-handler attribute" — escapes single quotes, double
1152 // quotes, backslashes, newlines. Today `$resolved_media`
1153 // comes from `wp_enqueue_style()`'s `$media` parameter (always
1154 // a CSS media type / query produced by WordPress core), so
1155 // this is pure defense-in-depth, but the cost is one extra
1156 // function call.
1157 //
1158 // - `%4$s` lands inside an HTML attribute in the `<noscript>`
1159 // fallback (`media='%4$s'`). That's standard `esc_attr`.
1160 //
1161 // phpcs:disable WordPress.WP.EnqueuedResources.NonEnqueuedStylesheet -- This filter rewrites a tag WordPress is in the process of emitting for an already-registered+enqueued stylesheet handle; the linter doesn't trace the `style_loader_tag` filter context, so the raw <link rel="stylesheet"> output is a false-positive.
1162 $markup = sprintf(
1163 '<link rel=\'stylesheet\' id=\'%1$s\' href=\'%2$s\' media=\'print\' onload="this.media=\'%3$s\'; this.onload=null;" />' . "\n" .
1164 '<noscript><link rel=\'stylesheet\' id=\'%1$s-noscript\' href=\'%2$s\' media=\'%4$s\' /></noscript>' . "\n",
1165 esc_attr( $id ),
1166 esc_url( $href ),
1167 esc_js( $resolved_media ),
1168 esc_attr( $resolved_media )
1169 );
1170 // phpcs:enable WordPress.WP.EnqueuedResources.NonEnqueuedStylesheet
1171
1172 return $markup;
1173 }
1174 add_filter( 'style_loader_tag', 'openstation_defer_non_critical_styles', 10, 4 );
1175
1176 /**
1177 * Build the admin-menu command map (name → URL) and expose it on
1178 * `window.__openStationMenuCommands`. The shell command harvester
1179 * (`src/commands/shell-harvester.ts`) reads this slot to resolve URLs
1180 * for "Go to: …" commands whose JS callbacks
1181 * (`document.location = menuCommand.url`) close over a variable URL
1182 * we can't extract from source. Without this map those commands
1183 * either get skipped (no URL recoverable) or — if the location
1184 * shadow misses — navigate the SHELL out of OpenStation.
1185 *
1186 * Mirrors what WordPress core's `wp_enqueue_command_palette_assets()`
1187 * builds for `wp.coreCommands.initializeCommandPalette(...)`. We
1188 * duplicate the logic here (instead of monkey-patching the JS init
1189 * which is timing-sensitive — WP registers its hook during core load,
1190 * so it always emits its inline before any plugin-added inline on the
1191 * same handle) and ship the result through `wp_add_inline_script` on
1192 * our own bundle handle. That decouples us entirely from WP's command-
1193 * palette mount timing.
1194 *
1195 * @global array $menu
1196 * @global array $submenu
1197 * @return array<int, array{label:string, url:string, name:string}>
1198 */
1199 function openstation_build_command_menu_map() {
1200 global $menu, $submenu, $_parent_pages;
1201 if ( ! is_array( $menu ) ) {
1202 return array();
1203 }
1204 $out = array();
1205
1206 $extract_root_text = static function ( $label ) {
1207 if ( '' === $label || ! is_string( $label ) ) {
1208 return '';
1209 }
1210 if ( class_exists( 'WP_HTML_Tag_Processor' ) ) {
1211 $processor = new WP_HTML_Tag_Processor( $label );
1212 $text = '';
1213 $depth = 0;
1214 while ( $processor->next_token() ) {
1215 $token_type = $processor->get_token_type();
1216 if ( '#text' === $token_type && 0 === $depth ) {
1217 $text .= $processor->get_modifiable_text();
1218 }
1219 if ( '#tag' === $token_type ) {
1220 if ( $processor->is_tag_closer() ) {
1221 if ( $depth > 0 ) {
1222 --$depth;
1223 }
1224 continue;
1225 }
1226 $name = $processor->get_tag();
1227 if ( $name && ! ( class_exists( 'WP_HTML_Processor' ) && WP_HTML_Processor::is_void( $name ) ) ) {
1228 ++$depth;
1229 }
1230 }
1231 }
1232 return trim( $text );
1233 }
1234 return trim( wp_strip_all_tags( $label ) );
1235 };
1236
1237 foreach ( $menu as $menu_item ) {
1238 if ( empty( $menu_item[0] ) || ! is_string( $menu_item[0] ) ) {
1239 continue;
1240 }
1241 if ( ! empty( $menu_item[1] ) && ! current_user_can( $menu_item[1] ) ) {
1242 continue;
1243 }
1244 $menu_label = $extract_root_text( $menu_item[0] );
1245 $menu_slug = $menu_item[2];
1246 $menu_url = '';
1247 // Registered plugin pages win over the direct-file test: a
1248 // legacy file-path slug ('wp-sweep/admin.php') matches the
1249 // `.php` regex yet must route through menu_page_url(). The
1250 // exception is URL-style slugs referencing a real admin file
1251 // (ACF's 'edit.php?post_type=acf-field-group' — also a
1252 // registered page) — those stay direct links, matching
1253 // classic admin's menu-header.php.
1254 if ( ( ! isset( $_parent_pages[ $menu_slug ] ) || openstation_is_admin_file_slug( $menu_slug ) ) && ( preg_match( '/\.php($|\?)/', $menu_slug ) || wp_http_validate_url( $menu_slug ) ) ) {
1255 $menu_url = $menu_slug;
1256 } elseif ( ! empty( menu_page_url( $menu_slug, false ) ) ) {
1257 $menu_url = menu_page_url( $menu_slug, false );
1258 }
1259 if ( '' !== $menu_url ) {
1260 $out[] = array(
1261 'label' => $menu_label,
1262 'url' => $menu_url,
1263 'name' => $menu_slug,
1264 );
1265 }
1266 if ( ! empty( $submenu ) && is_array( $submenu ) && array_key_exists( $menu_slug, $submenu ) ) {
1267 foreach ( $submenu[ $menu_slug ] as $submenu_item ) {
1268 if ( empty( $submenu_item[0] ) ) {
1269 continue;
1270 }
1271 if ( ! empty( $submenu_item[1] ) && ! current_user_can( $submenu_item[1] ) ) {
1272 continue;
1273 }
1274 $submenu_label = $extract_root_text( $submenu_item[0] );
1275 $submenu_slug = $submenu_item[2];
1276 $submenu_url = '';
1277 // Same registered-page vs admin-file rule as the
1278 // top-level loop.
1279 if ( ( ! isset( $_parent_pages[ $submenu_slug ] ) || openstation_is_admin_file_slug( $submenu_slug ) ) && ( preg_match( '/\.php($|\?)/', $submenu_slug ) || wp_http_validate_url( $submenu_slug ) ) ) {
1280 $submenu_url = $submenu_slug;
1281 } elseif ( ! empty( menu_page_url( $submenu_slug, false ) ) ) {
1282 $submenu_url = menu_page_url( $submenu_slug, false );
1283 }
1284 if ( '' === $submenu_url ) {
1285 continue;
1286 }
1287 $out[] = array(
1288 'label' => sprintf(
1289 /* translators: 1: parent menu label, 2: submenu label */
1290 __( '%1$s > %2$s', 'desktop-mode' ),
1291 $menu_label,
1292 $submenu_label
1293 ),
1294 'url' => $submenu_url,
1295 'name' => $menu_slug . '-' . $submenu_item[2],
1296 );
1297 }
1298 }
1299 }
1300 return $out;
1301 }
1302