PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 1.1.2
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v1.1.2
1.1.10 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 All 34 releases
desktop-mode / includes / render / assets.php

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

1,067 lines 51.0 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_enabled() || openstation_is_classic_request() ) {
78 return;
79 }
80
81 // CSS.
82 wp_enqueue_style( 'openstation' );
83 wp_enqueue_style( 'os-windows' );
84 wp_enqueue_style( 'os-window-overview' );
85 wp_enqueue_style( 'os-settings' );
86 wp_enqueue_style( 'os-dock' );
87 wp_enqueue_style( 'os-dock-peek' );
88 wp_enqueue_style( 'os-notch' );
89 wp_enqueue_style( 'os-shortcuts' );
90 wp_enqueue_style( 'os-openstation-layout' );
91 wp_enqueue_style( 'desktop-mode-ai-assistant' );
92 wp_enqueue_style( 'desktop-mode-bug-report' );
93 wp_enqueue_style( 'os-files' );
94 wp_enqueue_style( 'os-notes' );
95
96 // Solo mode — a single window freed into a native OS window by the
97 // desktop host. Same shell, everything but that one window hidden.
98 $solo_window = openstation_solo_window_id();
99 if ( '' !== $solo_window ) {
100 wp_enqueue_style( 'os-solo' );
101
102 /*
103 * Hide every window that is not the one this surface was booted
104 * to paint — from the first frame, before any of them exist.
105 *
106 * Solo mode promises one window. Anything that opens a second
107 * (a game launched from a freed Games hub, a plugin calling
108 * `openWindow`) would otherwise land on top of the first, and
109 * solo's CSS stretches every window to fill the viewport, so it
110 * covers what the user was using.
111 *
112 * This has to be CSS rather than JavaScript, and it has to be
113 * inline. A JS rule can only run once the window exists, which
114 * is a frame too late — the user sees the newcomer flash before
115 * it is dealt with. A static stylesheet cannot express it
116 * either, because the selector depends on which window this is.
117 * So the rule is emitted with the id baked in, and no window but
118 * that one is ever painted.
119 *
120 * `visibility` rather than `display`: a hidden-but-laid-out
121 * window still has a size, which canvas-based windows need in
122 * order to initialise without dividing by zero on the way to
123 * being closed.
124 *
125 * The id is `sanitize_key()`-clean (see `openstation_solo_window_id()`),
126 * so it is safe in a selector; it is escaped again here because
127 * the distance between those two facts is exactly where this
128 * kind of bug lives.
129 */
130 wp_add_inline_style(
131 'os-solo',
132 sprintf(
133 'body.os-solo .os-window:not(#wp-window-%1$s){visibility:hidden !important;pointer-events:none !important;}',
134 esc_attr( $solo_window )
135 )
136 );
137 }
138
139 // The rebrand announcement paints on one visit per user and never
140 // again, so its stylesheet is only worth sending to the users who
141 // are actually going to see it. Computed once here and reused for
142 // the `rebrandNotice` config key below, which reads the same answer.
143 $show_rebrand_notice = openstation_should_show_rebrand_notice();
144 if ( $show_rebrand_notice ) {
145 wp_enqueue_style( 'os-announce' );
146 }
147
148 // JS.
149 wp_enqueue_script( 'openstation' );
150
151 // `wp_enqueue_command_palette_assets()` (WP 6.9+) enqueues the
152 // `wp-commands` store package, the `wp-core-commands` script that
153 // registers the WordPress-wide baseline (Add new post, Manage
154 // plugins, Switch theme, Browse patterns, …) AND — critically —
155 // the inline `wp.coreCommands.initializeCommandPalette( … )` call
156 // that actually populates the `core/commands` data store with the
157 // admin-menu commands. Without that inline init, the script loads
158 // but the store stays empty and `src/commands/shell-harvester.ts`
159 // finds nothing to publish.
160 //
161 // WP normally only calls this on screens that opt in to the native
162 // palette; the shell needs it on every admin URL it might wrap.
163 // `function_exists` guard for pre-6.9 sites — the harvester gracefully
164 // no-ops when the store is missing.
165 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 // Expose the same menu-commands array WP serializes into
183 // `wp.coreCommands.initializeCommandPalette(...)` on a window
184 // 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).
192 $menu_map = openstation_build_command_menu_map();
193 wp_add_inline_script(
194 'openstation',
195 'window.__openStationMenuCommands = ' . wp_json_encode( $menu_map ) . ';',
196 'before'
197 );
198 }
199
200 // Pass configuration to JavaScript.
201 global $title, $pagenow, $parent_file, $menu;
202
203 $menu_icon = 'dashicons-admin-generic';
204 if ( ! empty( $parent_file ) && ! empty( $menu ) ) {
205 foreach ( $menu as $item ) {
206 if ( ! empty( $item[2] ) && $item[2] === $parent_file && ! empty( $item[6] ) ) {
207 $menu_icon = $item[6];
208 break;
209 }
210 }
211 }
212
213 // Build dock items from the admin menu. Core pages are ordered
214 // first (Dashboard, Posts, Plugins, Users, Settings, …), then
215 // plugin-contributed top-level routes. `openstation_dock_placement`
216 // is the per-item filter escape hatch for hiding. Shared with the
217 // REST menu endpoint so live refreshes (post plugin-activation)
218 // 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'] )
222 ? $menu_payload['nativeWindows']
223 : array();
224 $server_widgets = isset( $menu_payload['serverWidgets'] )
225 ? $menu_payload['serverWidgets']
226 : array();
227 $server_wallpapers = isset( $menu_payload['serverWallpapers'] )
228 ? $menu_payload['serverWallpapers']
229 : array();
230 $server_command_scripts = isset( $menu_payload['serverCommandScripts'] )
231 ? $menu_payload['serverCommandScripts']
232 : array();
233 $server_commands = isset( $menu_payload['serverCommands'] )
234 ? $menu_payload['serverCommands']
235 : array();
236 $server_settings_tab_scripts = isset( $menu_payload['serverSettingsTabScripts'] )
237 ? $menu_payload['serverSettingsTabScripts']
238 : array();
239 $server_settings_tabs = isset( $menu_payload['serverSettingsTabs'] )
240 ? $menu_payload['serverSettingsTabs']
241 : array();
242 $server_dock_rail_renderer_scripts = isset( $menu_payload['serverDockRailRendererScripts'] )
243 ? $menu_payload['serverDockRailRendererScripts']
244 : array();
245 $server_titlebar_button_scripts = isset( $menu_payload['serverTitleBarButtonScripts'] )
246 ? $menu_payload['serverTitleBarButtonScripts']
247 : array();
248 $server_window_action_scripts = isset( $menu_payload['serverWindowActionScripts'] )
249 ? $menu_payload['serverWindowActionScripts']
250 : array();
251 $server_window_theme_scripts = isset( $menu_payload['serverWindowThemeScripts'] )
252 ? $menu_payload['serverWindowThemeScripts']
253 : array();
254 $server_window_themes = isset( $menu_payload['serverWindowThemes'] )
255 ? $menu_payload['serverWindowThemes']
256 : array();
257 $server_window_control_scripts = isset( $menu_payload['serverWindowControlScripts'] )
258 ? $menu_payload['serverWindowControlScripts']
259 : array();
260 $server_window_controls = isset( $menu_payload['serverWindowControls'] )
261 ? $menu_payload['serverWindowControls']
262 : array();
263 $server_window_slot_scripts = isset( $menu_payload['serverWindowSlotScripts'] )
264 ? $menu_payload['serverWindowSlotScripts']
265 : array();
266 $server_window_slots = isset( $menu_payload['serverWindowSlots'] )
267 ? $menu_payload['serverWindowSlots']
268 : array();
269 $server_window_chrome_scripts = isset( $menu_payload['serverWindowChromeScripts'] )
270 ? $menu_payload['serverWindowChromeScripts']
271 : array();
272 $server_window_chromes = isset( $menu_payload['serverWindowChromes'] )
273 ? $menu_payload['serverWindowChromes']
274 : array();
275 $server_window_notices = isset( $menu_payload['serverWindowNotices'] )
276 ? $menu_payload['serverWindowNotices']
277 : array();
278 $server_games = isset( $menu_payload['serverGames'] )
279 ? $menu_payload['serverGames']
280 : array();
281 // Boot-time copy of the desktop-theme library. Without it the
282 // shell's registry seeds EMPTY, and the consequences are subtle
283 // rather than obvious: PHP has already applied the user's theme
284 // server-side (stylesheet + shell attribute), but the client
285 // can't resolve the slug to an entry, so it believes nothing is
286 // active. Themed ICONS never paint, and switching back to the
287 // system default no-ops the first time — `applyDesktopTheme()`
288 // dedupes on an `activeId` that was never set.
289 $server_desktop_themes = isset( $menu_payload['serverDesktopThemes'] )
290 ? $menu_payload['serverDesktopThemes']
291 : array();
292 $desktop_icons = isset( $menu_payload['desktopIcons'] )
293 ? $menu_payload['desktopIcons']
294 : array();
295
296 // Files-on-the-Desktop payload (Phase 0+1). Plugin-registered
297 // file types and openers ship as metadata only; the JS side
298 // holds the executable handlers and resolves on double-click.
299 $server_file_types = function_exists( 'openstation_build_file_types_payload' )
300 ? openstation_build_file_types_payload()
301 : array();
302 $server_file_openers = function_exists( 'openstation_build_file_openers_payload' )
303 ? openstation_build_file_openers_payload()
304 : array();
305 $user_file_associations = function_exists( 'openstation_get_user_file_associations' )
306 ? openstation_get_user_file_associations( get_current_user_id() )
307 : array();
308 $server_wallpaper_menu_items = function_exists( 'openstation_build_wallpaper_menu_items' )
309 ? openstation_build_wallpaper_menu_items()
310 : array();
311
312 /*
313 * OS-file drop config — what the browser drop manager will
314 * accept when the user drags a file from their native desktop
315 * onto any surface inside OpenStation (wallpaper, a folder,
316 * a window, or a chromeless iframe). The allowed-mimes list is
317 * the user-scoped `get_allowed_mime_types()` (already capability
318 * gated by WordPress); the size cap is `wp_max_upload_size()`.
319 *
320 * Both are filterable so plugins can narrow or widen the set —
321 * e.g. a media-only plugin can restrict drops to images, or a
322 * docs plugin can opt PDFs in for a specific role.
323 */
324 $drop_allowed_mimes_map = current_user_can( 'upload_files' )
325 ? get_allowed_mime_types( get_current_user_id() )
326 : array();
327 /**
328 * Filter the allowed-mime map used by the OS-file drop manager.
329 *
330 * @param array<string,string> $mimes_map `ext => mime-type` map (same shape `get_allowed_mime_types()` returns).
331 * @param int $user_id The current user id.
332 */
333 $drop_allowed_mimes_map = apply_filters( 'openstation_drop_allowed_mimes', $drop_allowed_mimes_map, get_current_user_id() );
334 $drop_allowed_mimes_map = is_array( $drop_allowed_mimes_map ) ? $drop_allowed_mimes_map : array();
335 $drop_allowed_mimes = array_values( array_unique( array_values( $drop_allowed_mimes_map ) ) );
336
337 $drop_max_size = (int) wp_max_upload_size();
338 /**
339 * Filter the per-file size cap (in bytes) used by the OS-file
340 * drop manager. Returning `0` disables the client-side cap —
341 * the server still enforces its own.
342 *
343 * @param int $max_size Default `wp_max_upload_size()`.
344 * @param int $user_id The current user id.
345 */
346 $drop_max_size = (int) apply_filters( 'openstation_drop_max_size', $drop_max_size, get_current_user_id() );
347
348 /**
349 * Filter the master OS-file drop enable gate. Lets plugins
350 * disable the drop manager by role / capability beyond the
351 * default `upload_files` check (e.g. only for admins, or
352 * only on specific multisite blogs).
353 *
354 * @param bool $enabled Default — `current_user_can( 'upload_files' )`.
355 * @param int $user_id The current user id.
356 */
357 $drop_enabled = (bool) apply_filters(
358 'openstation_drop_enabled',
359 current_user_can( 'upload_files' ),
360 get_current_user_id()
361 );
362
363 $drop_config = array(
364 'enabled' => $drop_enabled,
365 'allowedMimes' => $drop_allowed_mimes,
366 'extToMime' => $drop_allowed_mimes_map,
367 'maxSize' => $drop_max_size,
368 );
369
370 // Lazy-bundle URL builder. Each lazy-loaded bundle (AI Assistant,
371 // OS Settings panel, shell-overlays, window-system)
372 // is `<script>`-injected by the main bundle on demand — they don't
373 // go through `wp_register_script`, so they don't pick up WordPress's
374 // usual `?ver=<filemtime>` cache-buster. Without one, the browser
375 // happily serves a stale cached copy across plugin updates that
376 // don't bump `OPENSTATION_VERSION`, and the main bundle's loader
377 // fires a `<script>`-loaded event for a file that's missing the
378 // fresh `window.openStation*` factory the new code expects.
379 //
380 // Mirror the `$built_version( … )` helper in `includes/assets.php`:
381 // prefer the on-disk mtime of the actual file, fall back to the
382 // plugin version when the file is missing (dev environments where
383 // the bundle hasn't been built yet).
384 $suffix = openstation_asset_suffix();
385 $lazy_bundle_url = static function ( $base ) use ( $suffix ) {
386 $path = OPENSTATION_DIR . 'assets/js/' . $base . $suffix . '.js';
387 $ver = file_exists( $path )
388 ? (string) filemtime( $path )
389 : OPENSTATION_VERSION;
390 return esc_url_raw(
391 OPENSTATION_URL . 'assets/js/' . $base . $suffix . '.js?ver=' . $ver
392 );
393 };
394
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 ) : '' );
402
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
405
406 /**
407 * Filters the desktop shell configuration passed to JavaScript.
408 *
409 * @param array $config {
410 * Desktop shell configuration.
411 *
412 * @type string $currentPage The current admin page URL.
413 * @type string $currentTitle The current page title.
414 * @type string $currentIcon Dashicon class for the current page.
415 * @type string $adminUrl The base admin URL.
416 * @type string $colorScheme The active admin color scheme.
417 * @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.
418 * @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.
419 * @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.
420 * @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.
421 * @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.
422 * @type array $serverCommands Server-declared command metadata (via `openstation_register_command`). Advisory today — reserved for future pre-registration shims.
423 * @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.
424 * @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`.
425 * @type array $desktopIcons Server-declared desktop icons (via `openstation_register_icon`). Rendered on the wallpaper as clickable shortcut tiles.
426 * @type array $accentColors Swatch list for the OS Settings accent picker. Filterable via `openstation_accent_colors`.
427 * @type array $toastTypes Toast-notification type map. Filterable via `openstation_toast_types`.
428 * @type string $defaultWallpaper Wallpaper slug applied on first boot. Filterable via `openstation_default_wallpaper`.
429 * @type array $session Saved session (windows, focused, updated).
430 * @type string $sessionUrl REST endpoint for saving the session.
431 * @type string $mediaUrl REST endpoint for media uploads (wp/v2/media).
432 * @type string $restUrl REST API root from rest_url(), safe for pretty and plain permalink installs.
433 * @type string $defaultWindowUrl REST endpoint for saving the default-window preference.
434 * @type array $defaultWindow { enabled: bool, url: string } — current default-window preference.
435 * @type bool $canUpload Whether the user holds the `upload_files` capability.
436 * @type string $pluginUrl Plugin base URL (no trailing slash). Used by the shell to locate vendor assets and by plugins to build asset URLs.
437 * @type string $pluginVersion Plugin semver string. Surfaced in the OS Settings → About tab; plugins can read it to gate features by version.
438 * @type string $aboutFeedUrl Authenticated admin-AJAX URL that returns the cached OpenStation journal feed for the About tab.
439 * @type string $restNonce Nonce for the session REST endpoint.
440 * @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.
441 * @type string $portalUrl Canonical `/openstation/` URL.
442 * @type bool $fromPortal Whether the shell was reached via the portal.
443 * @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 * @type array $seenIntros Slugs of one-time announcements the user has dismissed (e.g. `['openstation-rebrand']`).
445 * @type string $seenIntrosUrl REST endpoint for the seen-intros surface — POST `/seen` to mark, DELETE the base to reset.
446 * @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.
447 * }
448 */
449 $config = apply_filters(
450 'openstation_shell_config',
451 array(
452 'currentPage' => esc_url( $current_page ),
453 'currentTitle' => wp_strip_all_tags( $title ),
454 'currentIcon' => sanitize_html_class( $menu_icon ),
455 'adminUrl' => esc_url( admin_url() ),
456 'homeUrl' => esc_url( home_url( '/' ) ),
457 // Decoded: the shell assigns this to `window.location`,
458 // where `&amp;` would make `_wpnonce` arrive as
459 // `amp;_wpnonce` and fail the nonce check.
460 'logoutUrl' => esc_url_raw(
461 html_entity_decode( wp_logout_url(), ENT_QUOTES, 'UTF-8' )
462 ),
463 'colorScheme' => sanitize_html_class( get_user_option( 'admin_color' ), 'fresh' ),
464 'dockItems' => $dock_items,
465 // Baseline menu fingerprint. The shell seeds its last-known
466 // signature from this so the first off-allowlist menu change
467 // (vs. this boot state) is caught without a wasted probe. GH#325.
468 'menuSig' => isset( $menu_payload['menuSig'] ) ? (string) $menu_payload['menuSig'] : '',
469 'nativeWindows' => $native_windows,
470 'serverWidgets' => $server_widgets,
471 'serverWallpapers' => $server_wallpapers,
472 'serverCommandScripts' => $server_command_scripts,
473 'serverCommands' => $server_commands,
474 'serverSettingsTabScripts' => $server_settings_tab_scripts,
475 'serverSettingsTabs' => $server_settings_tabs,
476 'serverDockRailRendererScripts' => $server_dock_rail_renderer_scripts,
477 'serverTitleBarButtonScripts' => $server_titlebar_button_scripts,
478 'serverWindowActionScripts' => $server_window_action_scripts,
479 'serverWindowThemeScripts' => $server_window_theme_scripts,
480 'serverWindowThemes' => $server_window_themes,
481 'serverWindowControlScripts' => $server_window_control_scripts,
482 'serverWindowControls' => $server_window_controls,
483 'serverWindowSlotScripts' => $server_window_slot_scripts,
484 'serverWindowSlots' => $server_window_slots,
485 'serverWindowChromeScripts' => $server_window_chrome_scripts,
486 'serverWindowChromes' => $server_window_chromes,
487 'serverWindowNotices' => $server_window_notices,
488 // Boot-time copy of the payload's `serverGames` — the same
489 // list the live-refresh path applies. Without it the games
490 // registry only fills after the first chromeless
491 // full-payload refresh and the Games hub boots empty.
492 'serverGames' => $server_games,
493 'serverDesktopThemes' => $server_desktop_themes,
494 'desktopIcons' => $desktop_icons,
495 'serverFileTypes' => $server_file_types,
496 'serverFileOpeners' => $server_file_openers,
497 'userFileAssociations' => $user_file_associations,
498 'filesUrl' => esc_url_raw( rest_url( 'desktop-mode/v1/files' ) ),
499 // Pinned-notes REST base (`includes/notes/rest.php`). The
500 // notes layer boots only when this is present.
501 'notesUrl' => esc_url_raw( rest_url( 'desktop-mode/v1/notes' ) ),
502 // Gates the "Convert to post" note affordance — the convert
503 // route (and its dock drop target) only make sense for users
504 // who can author posts.
505 'canCreatePosts' => current_user_can( 'edit_posts' ),
506 'serverWallpaperMenuItems' => $server_wallpaper_menu_items,
507 'accentColors' => openstation_get_accent_colors(),
508 'toastTypes' => openstation_get_toast_types(),
509 'coreUpdate' => openstation_get_core_update(),
510 'coreNotices' => openstation_get_core_notices(),
511 'pluginNotices' => openstation_get_plugin_notices(),
512 'defaultWallpaper' => openstation_get_default_wallpaper(),
513 'session' => openstation_get_session( get_current_user_id() ),
514 'sessionUrl' => esc_url_raw( rest_url( 'desktop-mode/v1/session' ) ),
515 'restUrl' => esc_url_raw( rest_url() ),
516 'mediaUrl' => esc_url_raw( rest_url( 'wp/v2/media' ) ),
517 'dropConfig' => $drop_config,
518 'defaultWindowUrl' => esc_url_raw( rest_url( 'desktop-mode/v1/default-window' ) ),
519 'defaultWindow' => openstation_get_default_window( get_current_user_id() ),
520 'canUpload' => current_user_can( 'upload_files' ),
521 'pluginUrl' => esc_url_raw( untrailingslashit( OPENSTATION_URL ) ),
522 'pluginVersion' => OPENSTATION_VERSION,
523 'aboutFeedUrl' => esc_url_raw(
524 add_query_arg(
525 array(
526 'action' => 'openstation_about_feed',
527 'nonce' => wp_create_nonce( 'openstation_about_feed' ),
528 ),
529 admin_url( 'admin-ajax.php' )
530 )
531 ),
532 'iframeBridgeUrl' => $lazy_bundle_url( 'iframe-bridge' ),
533 // URL of the AI Assistant lazy bundle. The main bundle
534 // ships a stub matching the public `wp.os.ai` API; the
535 // stub `<script>`-injects this URL the first time the user
536 // opens the assistant. Picking `.js` vs `.min.js` here keeps
537 // the SCRIPT_DEBUG gate server-side, matching iframeBridgeUrl.
538 '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 // URL of the shell-overlays lazy bundle. Pre-loaded by
547 // the main bundle after first paint so action-triggered
548 // overlays (toast, confirm dialog, context menus) feel
549 // instant the first time they fire.
550 'shellOverlaysBundleUrl' => $lazy_bundle_url( 'shell-overlays' ),
551 // URL of the full `<os-*>` component kit. The shell
552 // never loads this — its own bundles import the
553 // components they render. It exists for
554 // `wp.os.loadComponents()`, i.e. for plugin code that
555 // CANNOT import: a plugin shipped as a zip has no path
556 // to this repo at build time, so before this URL its
557 // only routes to a `<os-switch>` were to bundle a second
558 // copy or hand-roll one. Shipping the URL costs one
559 // string and keeps the SCRIPT_DEBUG choice server-side.
560 'componentsBundleUrl' => $lazy_bundle_url( 'os-components' ),
561 // Mio — the desk companion. `mio` carries the
562 // appearance + physics (see `openstation_mio_config()`);
563 // `mioBundleUrl` is the lazy PixiJS bundle the shell
564 // controller injects the first time a user switches the
565 // Mio on from its dock tile. Shipping the URL
566 // unconditionally costs one short string and keeps the
567 // SCRIPT_DEBUG choice server-side, matching every other
568 // lazy bundle here.
569 //
570 // Both keys ship whether or not the user has Mio on,
571 // and that is the whole of its cost to a shell that doesn't:
572 // ~470 bytes gzipped of config, plus a URL. No script, no
573 // style, no PixiJS. The config has to be here rather than
574 // fetched on first toggle, or the `openstation_mio_config`
575 // filter would silently not apply until the next reload.
576 'mio' => openstation_mio_config(),
577 'mioBundleUrl' => $lazy_bundle_url( 'mio' ),
578 // URL of the lazy window-system bundle (Stage 11).
579 // Holds the `Window` class and its DOM / pointer / tab /
580 // chrome helpers — the single largest module split out of
581 // the main bundle. Loaded on first `windowManager.open()`
582 // / `openNew()` call (both async); pre-loaded
583 // by the shell after first paint when no session is being
584 // restored and no `openCurrentPage` will fire.
585 'windowSystemBundleUrl' => $lazy_bundle_url( 'window-system' ),
586 // URL of the item-visibility-menu lazy bundle — the
587 // right-click "hide from dock / desktop" menu. Injected by
588 // the main bundle's loader shim on the first right-click.
589 'itemVisibilityMenuBundleUrl' => $lazy_bundle_url( 'item-visibility-menu' ),
590 // URL of the release-card lazy bundle — the vinyl core-
591 // update announcement. Injected by `maybeShowUpdate()` only
592 // when a core update is actually pending.
593 'releaseCardBundleUrl' => $lazy_bundle_url( 'release-card' ),
594 'restNonce' => wp_create_nonce( 'wp_rest' ),
595 // Non-empty when the shell was asked to paint exactly one
596 // window and nothing else. See `OPENSTATION_SOLO_FLAG`.
597 'soloWindow' => openstation_solo_window_id(),
598 'osSettings' => openstation_get_os_settings( get_current_user_id() ),
599 'osSettingsUrl' => esc_url_raw( rest_url( 'desktop-mode/v1/os-settings' ) ),
600 'seenIntros' => openstation_get_seen_intros( get_current_user_id() ),
601 'seenIntrosUrl' => esc_url_raw( rest_url( 'desktop-mode/v1/intros' ) ),
602 // True only for a user migration 5 flagged as a Desktop Mode
603 // user from before the rename, who hasn't dismissed the
604 // announcement yet. Same value that gated `os-announce`
605 // above; the dialog cannot paint without that stylesheet, so
606 // the two must not diverge.
607 'rebrandNotice' => $show_rebrand_notice,
608 'aiSearchUrl' => esc_url_raw( rest_url( 'desktop-mode/v1/ai/search' ) ),
609 // AI assistant availability + per-user toggle. Drives whether the
610 // Cmd+K palette and admin-bar icon appear, and the setup placeholder.
611 'aiAssistant' => function_exists( 'openstation_ai_assistant_config' )
612 ? openstation_ai_assistant_config()
613 : null,
614 // Lets the Features tab re-check provider availability without a
615 // reload after a connector is configured in Settings → Connectors.
616 'aiStatusUrl' => esc_url_raw( rest_url( 'desktop-mode/v1/ai/status' ) ),
617 'extendedOptions' => current_user_can( 'manage_options' ) ? openstation_get_extended_options() : null,
618 'extendedOptionsUrl' => esc_url_raw( rest_url( 'desktop-mode/v1/extended-options' ) ),
619 // Site-wide games kill switch (Extended options). Exposed to
620 // every user — the shell skips the challenges Heartbeat
621 // channel when the framework is off.
622 '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 'currentUserIsAdmin' => current_user_can( 'manage_options' ),
650 'portalUrl' => esc_url( openstation_portal_url() ),
651 'fromPortal' => $from_portal,
652 'fromPortalIntent' => $from_portal_intent,
653 'pwa' => array(
654 'manifestUrl' => esc_url_raw( openstation_pwa_manifest_url() ),
655 'swUrl' => esc_url_raw( openstation_pwa_sw_url() ),
656 'stateUrl' => esc_url_raw( rest_url( 'desktop-mode/v1/pwa-state' ) ),
657 'state' => openstation_pwa_get_user_state( get_current_user_id() ),
658 // Mirrors the manifest's `name` field — used by the
659 // install pill so the button reads "Install <site>"
660 // rather than "Install <current page>" (which would
661 // be misleading: we install the whole site as an
662 // app, not the dashboard window the user happens to
663 // be viewing).
664 'appName' => get_bloginfo( 'name' ),
665 // Operators set the `openstation_pwa_force_replace_sw`
666 // filter to `true` when another root-scope service
667 // worker on the origin is blocking openstation
668 // installability (foreign-SW guard in
669 // `src/pwa/sw-register.ts`). Default `false` preserves
670 // the polite behaviour where we yield to existing PWAs.
671 'forceReplaceSw' => openstation_pwa_force_replace_sw(),
672 ),
673 )
674 );
675
676 wp_localize_script( 'openstation', 'openStationConfig', $config );
677
678 /**
679 * Fires when OpenStation assets are enqueued.
680 */
681 do_action( 'openstation_mode_init' );
682 }
683 add_action( 'admin_enqueue_scripts', 'openstation_enqueue_assets' );
684
685 /**
686 * Emits `<link rel="preload">` hints for the shell's critical-path
687 * assets so the browser starts fetching them as soon as it parses
688 * the document `<head>`.
689 *
690 * Without this, the browser doesn't discover the main `desktop.min.js`
691 * bundle URL until it parses the footer `<script>` tag — typically
692 * ~1 RTT after the rest of the page has started loading. For a 464 KB
693 * bundle on a midrange phone that's a measurable FCP delay; on a
694 * slow connection it dominates first paint entirely.
695 *
696 * Hooked at `admin_print_styles @ 1` so the preload tags land in
697 * `<head>` BEFORE the regular `<link rel="stylesheet">` tags (which
698 * default to priority 10) and well before the footer `<script>`
699 * tag. The `wp_resource_hints` filter is frontend-only (`wp_head`-
700 * driven) and isn't invoked in admin context, so we emit our own
701 * tags.
702 *
703 * Four targets by default, split across two relationship types:
704 * - `desktop[.min].js` (preload) — the shell bundle (biggest win),
705 * consumed by the footer `<script>` on this very load.
706 * - `desktop.css` (preload) — shell base CSS, needed for first
707 * paint. Its registered handle is `filemtime`-stamped so the
708 * stylesheet URL matches this hint exactly (a `?ver=` mismatch makes
709 * the browser treat the preload as unused).
710 * - `window-system[.min].js` (prefetch) — lazy bundle `<script>`-
711 * injected by the main bundle on the first `open()`.
712 * - `shell-overlays[.min].js` (prefetch) — lazy bundle injected on the
713 * first toast / dialog / context-menu.
714 *
715 * The lazy bundles use `prefetch` rather than `preload`: they're loaded
716 * later (often beyond the ~3s window Chrome allows a `preload` before it
717 * warns "preloaded but not used in time"), so `prefetch` keeps the early
718 * low-priority cache fill without the must-use-now contract.
719 *
720 * Plugins can extend the hint list via the `openstation_preload_hints`
721 * filter — e.g. a settings tab whose bundle the user opens on every
722 * visit can opt its own URL into the preload phase.
723 *
724 * Same-origin resources only — no `crossorigin` attribute. CDN hosts
725 * that serve `wp-content/plugins/` from a different origin should
726 * supply absolute URLs through the filter; in that case the consumer
727 * is responsible for the `crossorigin` semantics.
728 */
729 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 ) {
736 return;
737 }
738
739 $suffix = openstation_asset_suffix();
740
741 $build_url = static function ( $relative ) {
742 $path = OPENSTATION_DIR . $relative;
743 $ver = file_exists( $path ) ? (string) filemtime( $path ) : OPENSTATION_VERSION;
744 return OPENSTATION_URL . $relative . '?ver=' . $ver;
745 };
746
747 $hints = array(
748 // Critical path — consumed on this very page load (the footer
749 // `<script>` and the shell stylesheet), so `preload` is correct.
750 array(
751 'href' => $build_url( 'assets/js/desktop' . $suffix . '.js' ),
752 'as' => 'script',
753 'rel' => 'preload',
754 ),
755 array(
756 'href' => $build_url( 'assets/css/desktop.css' ),
757 'as' => 'style',
758 'rel' => 'preload',
759 ),
760 // Lazy bundles — `<script>`-injected by the main bundle after
761 // first paint (window-system on the first `open()`, shell-overlays
762 // on the first toast / dialog / context-menu). They are frequently
763 // NOT requested within the ~3s window Chrome allows a `preload`,
764 // which produced "resource was preloaded but not used in time"
765 // warnings. `prefetch` is the right hint: same early, low-priority
766 // fetch into the cache, but no must-use-now contract — so the
767 // injected `<script src>` is served from cache with no warning.
768 array(
769 'href' => $build_url( 'assets/js/window-system' . $suffix . '.js' ),
770 'as' => 'script',
771 'rel' => 'prefetch',
772 ),
773 array(
774 'href' => $build_url( 'assets/js/shell-overlays' . $suffix . '.js' ),
775 'as' => 'script',
776 'rel' => 'prefetch',
777 ),
778 );
779
780 /**
781 * Filters the list of resource preload hints emitted in `<head>`.
782 *
783 * Each entry is a `{ 'href' => string, 'as' => string,
784 * 'rel' => 'preload'|'prefetch' }` array rendered as
785 * `<link rel="<rel>" as="<as>" href="<href>">`. `rel` is optional and
786 * defaults to `preload`; any value other than `prefetch` is coerced
787 * back to `preload`. Unrecognized entries are silently skipped — keep
788 * the contract permissive so a misconfigured plugin can't tank first
789 * paint.
790 *
791 * @param array $hints Default hints (main bundle + base CSS as
792 * `preload`; window-system + shell-overlays as
793 * `prefetch`).
794 */
795 $hints = apply_filters( 'openstation_preload_hints', $hints );
796
797 if ( ! is_array( $hints ) ) {
798 return;
799 }
800
801 foreach ( $hints as $hint ) {
802 if ( ! is_array( $hint ) ) {
803 continue;
804 }
805 $href = isset( $hint['href'] ) ? (string) $hint['href'] : '';
806 $as = isset( $hint['as'] ) ? (string) $hint['as'] : '';
807 if ( '' === $href || '' === $as ) {
808 continue;
809 }
810 // `preload` (critical, used on this load) vs `prefetch` (lazy,
811 // used on a later interaction). Anything else falls back to
812 // `preload` so a typo can't emit an invalid relationship.
813 $rel = isset( $hint['rel'] ) ? (string) $hint['rel'] : 'preload';
814 if ( 'prefetch' !== $rel ) {
815 $rel = 'preload';
816 }
817 printf(
818 '<link rel="%s" as="%s" href="%s" />' . "\n",
819 esc_attr( $rel ),
820 esc_attr( $as ),
821 esc_url( $href )
822 );
823 }
824 }
825 add_action( 'admin_print_styles', 'openstation_print_preload_hints', 1 );
826
827 /**
828 * Defers loading of non-critical openstation stylesheets so they
829 * don't block first paint.
830 *
831 * Three stylesheets in the default enqueue list are only needed
832 * after a user interaction — `dock-peek` (mouseover a dock tile),
833 * `ai-assistant` (Cmd+K palette), `bug-report` (Report-a-bug
834 * window). With the normal `<link rel="stylesheet">` tag they sit
835 * on the critical path and the browser blocks first paint waiting
836 * for them, even though nothing on screen needs them yet.
837 *
838 * The well-known mitigation is the `media="print" onload="…"`
839 * pattern:
840 *
841 * <link rel="stylesheet" media="print"
842 * onload="this.media='all'; this.onload=null" href="…">
843 * <noscript><link rel="stylesheet" href="…"></noscript>
844 *
845 * `media="print"` makes the browser treat the sheet as
846 * non-applicable to the current display, so it downloads with
847 * low priority and doesn't block render. The `onload` handler
848 * swaps `media` to the original value once the bytes arrive
849 * (within ms of page load), making the styles take effect long
850 * before the user clicks anything that needs them. The
851 * `<noscript>` fallback restores critical-path behavior for JS-off
852 * browsers, so accessibility isn't degraded.
853 *
854 * Filterable via `openstation_deferred_styles` so plugins can opt
855 * their own non-critical stylesheets in (or pull a built-in out).
856 * Chromeless iframes are skipped — their CSS pipeline is separate.
857 *
858 * @param string $html The original <link> tag HTML.
859 * @param string $handle The stylesheet handle WP is printing.
860 * @param string $href The full URL of the stylesheet.
861 * @param string $media The media attribute value WP resolved.
862 * @return string Possibly-rewritten tag.
863 */
864 function openstation_defer_non_critical_styles( $html, $handle, $href, $media ) {
865 // Cheap gates first — `style_loader_tag` fires once per enqueued
866 // stylesheet on EVERY admin page (frontend doesn't go through
867 // this filter, but admin does, including pages where OpenStation
868 // is disabled). The deferred handles only ship when OpenStation
869 // is active, so the in_array check below would always miss on
870 // classic-only admin pages — but the `apply_filters` call still
871 // builds an array and walks subscribers per stylesheet. Short-
872 // circuit on the cheap helper checks (`is_admin` / enabled /
873 // chromeless) so non-openstation users pay nothing.
874 if ( ! openstation_is_enabled() ) {
875 return $html;
876 }
877 if ( openstation_is_chromeless_request() ) {
878 return $html;
879 }
880
881 /**
882 * Filters the list of stylesheet handles that should be loaded
883 * deferred via the media-print-onload pattern. Plugins can add
884 * their own non-critical stylesheets here, or pull a built-in
885 * out (e.g. a plugin that surfaces the AI assistant on every
886 * page might want to keep its CSS critical-path).
887 *
888 * @param string[] $handles Default deferred handles.
889 */
890 $deferred = apply_filters(
891 'openstation_deferred_styles',
892 array(
893 'os-dock-peek',
894 'os-openstation-layout',
895 'desktop-mode-ai-assistant',
896 'desktop-mode-bug-report',
897 'os-window-overview',
898 'os-settings',
899 )
900 );
901
902 if ( ! in_array( $handle, (array) $deferred, true ) ) {
903 return $html;
904 }
905
906 $resolved_media = $media ? $media : 'all';
907 $id = $handle . '-css';
908
909 // Two contexts, two escapers for the same `$resolved_media` value:
910 //
911 // - `%3$s` lands inside a JS string literal inside the HTML
912 // `onload="…"` attribute (`this.media='%3$s'`). `esc_attr`
913 // escapes `"` and `&` but NOT single quotes, so a media
914 // value containing `'` would break out of the JS string.
915 // `esc_js` is the correct escaper for "string literal inside
916 // an event-handler attribute" — escapes single quotes, double
917 // quotes, backslashes, newlines. Today `$resolved_media`
918 // comes from `wp_enqueue_style()`'s `$media` parameter (always
919 // a CSS media type / query produced by WordPress core), so
920 // this is pure defense-in-depth, but the cost is one extra
921 // function call.
922 //
923 // - `%4$s` lands inside an HTML attribute in the `<noscript>`
924 // fallback (`media='%4$s'`). That's standard `esc_attr`.
925 //
926 // 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.
927 $markup = sprintf(
928 '<link rel=\'stylesheet\' id=\'%1$s\' href=\'%2$s\' media=\'print\' onload="this.media=\'%3$s\'; this.onload=null;" />' . "\n" .
929 '<noscript><link rel=\'stylesheet\' id=\'%1$s-noscript\' href=\'%2$s\' media=\'%4$s\' /></noscript>' . "\n",
930 esc_attr( $id ),
931 esc_url( $href ),
932 esc_js( $resolved_media ),
933 esc_attr( $resolved_media )
934 );
935 // phpcs:enable WordPress.WP.EnqueuedResources.NonEnqueuedStylesheet
936
937 return $markup;
938 }
939 add_filter( 'style_loader_tag', 'openstation_defer_non_critical_styles', 10, 4 );
940
941 /**
942 * Build the admin-menu command map (name → URL) and expose it on
943 * `window.__openStationMenuCommands`. The shell command harvester
944 * (`src/commands/shell-harvester.ts`) reads this slot to resolve URLs
945 * for "Go to: …" commands whose JS callbacks
946 * (`document.location = menuCommand.url`) close over a variable URL
947 * we can't extract from source. Without this map those commands
948 * either get skipped (no URL recoverable) or — if the location
949 * shadow misses — navigate the SHELL out of OpenStation.
950 *
951 * Mirrors what WordPress core's `wp_enqueue_command_palette_assets()`
952 * builds for `wp.coreCommands.initializeCommandPalette(...)`. We
953 * duplicate the logic here (instead of monkey-patching the JS init
954 * which is timing-sensitive — WP registers its hook during core load,
955 * so it always emits its inline before any plugin-added inline on the
956 * same handle) and ship the result through `wp_add_inline_script` on
957 * our own bundle handle. That decouples us entirely from WP's command-
958 * palette mount timing.
959 *
960 * @global array $menu
961 * @global array $submenu
962 * @return array<int, array{label:string, url:string, name:string}>
963 */
964 function openstation_build_command_menu_map() {
965 global $menu, $submenu, $_parent_pages;
966 if ( ! is_array( $menu ) ) {
967 return array();
968 }
969 $out = array();
970
971 $extract_root_text = static function ( $label ) {
972 if ( '' === $label || ! is_string( $label ) ) {
973 return '';
974 }
975 if ( class_exists( 'WP_HTML_Tag_Processor' ) ) {
976 $processor = new WP_HTML_Tag_Processor( $label );
977 $text = '';
978 $depth = 0;
979 while ( $processor->next_token() ) {
980 $token_type = $processor->get_token_type();
981 if ( '#text' === $token_type && 0 === $depth ) {
982 $text .= $processor->get_modifiable_text();
983 }
984 if ( '#tag' === $token_type ) {
985 if ( $processor->is_tag_closer() ) {
986 if ( $depth > 0 ) {
987 --$depth;
988 }
989 continue;
990 }
991 $name = $processor->get_tag();
992 if ( $name && ! ( class_exists( 'WP_HTML_Processor' ) && WP_HTML_Processor::is_void( $name ) ) ) {
993 ++$depth;
994 }
995 }
996 }
997 return trim( $text );
998 }
999 return trim( wp_strip_all_tags( $label ) );
1000 };
1001
1002 foreach ( $menu as $menu_item ) {
1003 if ( empty( $menu_item[0] ) || ! is_string( $menu_item[0] ) ) {
1004 continue;
1005 }
1006 if ( ! empty( $menu_item[1] ) && ! current_user_can( $menu_item[1] ) ) {
1007 continue;
1008 }
1009 $menu_label = $extract_root_text( $menu_item[0] );
1010 $menu_slug = $menu_item[2];
1011 $menu_url = '';
1012 // Registered plugin pages win over the direct-file test: a
1013 // legacy file-path slug ('wp-sweep/admin.php') matches the
1014 // `.php` regex yet must route through menu_page_url(). The
1015 // exception is URL-style slugs referencing a real admin file
1016 // (ACF's 'edit.php?post_type=acf-field-group' — also a
1017 // registered page) — those stay direct links, matching
1018 // classic admin's menu-header.php.
1019 if ( ( ! isset( $_parent_pages[ $menu_slug ] ) || openstation_is_admin_file_slug( $menu_slug ) ) && ( preg_match( '/\.php($|\?)/', $menu_slug ) || wp_http_validate_url( $menu_slug ) ) ) {
1020 $menu_url = $menu_slug;
1021 } elseif ( ! empty( menu_page_url( $menu_slug, false ) ) ) {
1022 $menu_url = menu_page_url( $menu_slug, false );
1023 }
1024 if ( '' !== $menu_url ) {
1025 $out[] = array(
1026 'label' => $menu_label,
1027 'url' => $menu_url,
1028 'name' => $menu_slug,
1029 );
1030 }
1031 if ( ! empty( $submenu ) && is_array( $submenu ) && array_key_exists( $menu_slug, $submenu ) ) {
1032 foreach ( $submenu[ $menu_slug ] as $submenu_item ) {
1033 if ( empty( $submenu_item[0] ) ) {
1034 continue;
1035 }
1036 if ( ! empty( $submenu_item[1] ) && ! current_user_can( $submenu_item[1] ) ) {
1037 continue;
1038 }
1039 $submenu_label = $extract_root_text( $submenu_item[0] );
1040 $submenu_slug = $submenu_item[2];
1041 $submenu_url = '';
1042 // Same registered-page vs admin-file rule as the
1043 // top-level loop.
1044 if ( ( ! isset( $_parent_pages[ $submenu_slug ] ) || openstation_is_admin_file_slug( $submenu_slug ) ) && ( preg_match( '/\.php($|\?)/', $submenu_slug ) || wp_http_validate_url( $submenu_slug ) ) ) {
1045 $submenu_url = $submenu_slug;
1046 } elseif ( ! empty( menu_page_url( $submenu_slug, false ) ) ) {
1047 $submenu_url = menu_page_url( $submenu_slug, false );
1048 }
1049 if ( '' === $submenu_url ) {
1050 continue;
1051 }
1052 $out[] = array(
1053 'label' => sprintf(
1054 /* translators: 1: parent menu label, 2: submenu label */
1055 __( '%1$s > %2$s', 'desktop-mode' ),
1056 $menu_label,
1057 $submenu_label
1058 ),
1059 'url' => $submenu_url,
1060 'name' => $menu_slug . '-' . $submenu_item[2],
1061 );
1062 }
1063 }
1064 }
1065 return $out;
1066 }
1067