PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 1.1.10
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v1.1.10
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
← All changes | includes/render/assets.php +713 -309 0.8.91.1.10 View file →
@@ -1,11 +1,11 @@
1 1 <?php
2 2 /**
3 - * Desktop Mode — Asset enqueue.
3 + * OpenStation — Asset enqueue.
4 4 *
5 - * Loads the desktop shell CSS + JS bundles when desktop mode is
5 + * Loads the desktop shell CSS + JS bundles when OpenStation is
6 6 * active and the request isn't chromeless / classic-overridden.
7 - * Owns the entire `desktop_mode_enqueue_assets()` body — the
7 + * Owns the entire `openstation_enqueue_assets()` body — the
8 8 * largest hook in the original render.php and the natural seam
9 9 * for "what does the shell ship to the browser today?".
10 10 *
11 11 * Extracted from `render.php` during the architecture-0.8.1 PHP
@@ -10,42 +10,39 @@
10 10 *
11 11 * Extracted from `render.php` during the architecture-0.8.1 PHP
12 12 * slicing (phase 6).
13 13 *
14 - * @package Desktop_Mode
15 - * @since 0.8.1
14 + * @package OpenStation
16 15 */
17 16
18 17 defined( 'ABSPATH' ) || exit;
19 18
20 19 /**
21 - * Enqueues the desktop mode shell assets (CSS + JS) when desktop mode is active.
20 + * Enqueues the OpenStation shell assets (CSS + JS) when OpenStation is active.
22 21 *
23 22 * Only loads the full desktop shell scripts and styles when the user has
24 - * desktop mode enabled and the request is not a chromeless iframe load.
25 - *
26 - * @since 0.1.0
23 + * OpenStation enabled and the request is not a chromeless iframe load.
27 24 */
28 -function desktop_mode_enqueue_assets() {
25 +function openstation_enqueue_assets() {
29 26 if ( ! is_admin() ) {
30 27 return;
31 28 }
32 29
33 - // Auto-enqueue the iframe bridge anywhere a desktop-mode user
30 + // Auto-enqueue the iframe bridge anywhere a openstation user
34 31 // might land. The bundle self-bails when not inside an iframe
35 32 // (`window.parent === window`), so it's a no-op on the parent
36 33 // shell — but cheap insurance against the failure mode the
37 34 // developer hit: an internal admin navigation drops the
38 - // `?desktop_mode_chromeless=1` flag, the chromeless inline bridge doesn't
39 - // run, and `wp.desktop.iframe` silently disappears. With this
35 + // `?openstation_chromeless=1` flag, the chromeless inline bridge doesn't
36 + // run, and `wp.os.iframe` silently disappears. With this
40 37 // auto-enqueue, the API is universally present for any same-
41 - // origin admin page a desktop-mode user opens — chromeless or
38 + // origin admin page a openstation user opens — chromeless or
42 39 // accidentally classic.
43 - if ( desktop_mode_is_enabled() ) {
44 - wp_enqueue_script( 'desktop-mode-iframe-bridge' );
40 + if ( openstation_is_enabled() ) {
41 + wp_enqueue_script( 'os-iframe-bridge' );
45 42
46 43 // Block Editor cross-window drop receiver. Listens for
47 - // `desktop-mode-drop` postMessages from the parent shell and
44 + // `os-drop` postMessages from the parent shell and
48 45 // inserts the matching block. Only enqueue inside the
49 46 // post-edit Block Editor screens — every other admin page
50 47 // would be paying for a bundle it never uses.
51 48 //
@@ -56,45 +53,107 @@
56 53 // after the receiver's 5 s `waitForEditor()` poll. Re-enable
57 54 // once we have a reliable readiness signal in that context.
58 55 global $hook_suffix;
59 56 if ( 'post.php' === $hook_suffix || 'post-new.php' === $hook_suffix ) {
60 - wp_enqueue_script( 'desktop-mode-gutenberg-drop-receiver' );
57 + wp_enqueue_script( 'os-gutenberg-drop-receiver' );
61 58 }
62 59 }
63 60
64 61 // Chromeless requests (iframes) need chromeless styles and overrides.
65 - if ( desktop_mode_is_chromeless_request() ) {
66 - wp_enqueue_style( 'desktop-mode' );
67 - wp_enqueue_style( 'desktop-mode-chromeless' );
62 + if ( openstation_is_chromeless_request() ) {
63 + wp_enqueue_style( 'openstation' );
64 + wp_enqueue_style( 'os-chromeless' );
68 65
69 66 /**
70 - * Fires when chromeless styles are enqueued inside a desktop mode iframe.
67 + * Fires when chromeless styles are enqueued inside a OpenStation iframe.
71 68 *
72 69 * Plugin and theme authors can hook here to enqueue their own CSS
73 70 * overrides for legacy pages rendered in chromeless mode. Use the
74 - * `.desktop-mode-chromeless` body class to scope your rules.
75 - *
76 - * @since 0.1.0
71 + * `.os-chromeless` body class to scope your rules.
77 72 */
78 - do_action( 'desktop_mode_chromeless_styles' );
73 + do_action( 'openstation_chromeless_styles' );
79 74 return;
80 75 }
81 76
82 - if ( ! desktop_mode_is_enabled() || desktop_mode_is_classic_request() ) {
77 + if ( ! openstation_is_shell_request() ) {
83 78 return;
84 79 }
85 80
86 - // CSS.
87 - wp_enqueue_style( 'desktop-mode' );
88 - wp_enqueue_style( 'desktop-mode-windows' );
89 - wp_enqueue_style( 'desktop-mode-dock' );
90 - wp_enqueue_style( 'desktop-mode-dock-peek' );
91 - wp_enqueue_style( 'desktop-mode-ai-assistant' );
92 - wp_enqueue_style( 'desktop-mode-bug-report' );
93 - wp_enqueue_style( 'desktop-mode-files' );
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' );
94 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 +
95 154 // JS.
96 - wp_enqueue_script( 'desktop-mode' );
155 + wp_enqueue_script( 'openstation' );
97 156
98 157 // `wp_enqueue_command_palette_assets()` (WP 6.9+) enqueues the
99 158 // `wp-commands` store package, the `wp-core-commands` script that
100 159 // registers the WordPress-wide baseline (Add new post, Manage
@@ -108,43 +167,45 @@
108 167 // WP normally only calls this on screens that opt in to the native
109 168 // palette; the shell needs it on every admin URL it might wrap.
110 169 // `function_exists` guard for pre-6.9 sites — the harvester gracefully
111 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 +
112 186 if ( function_exists( 'wp_enqueue_command_palette_assets' ) ) {
113 - // `wp_enqueue_command_palette_assets()` calls
114 - // `array_key_exists( $menu_slug, $submenu )` without guarding
115 - // the global, so an unset `$submenu` (test contexts, edge-case
116 - // admin requests where the menu wasn't built yet) blows up
117 - // with a TypeError. Initialize defensively before calling.
118 - global $menu, $submenu;
119 - if ( ! isset( $submenu ) || ! is_array( $submenu ) ) {
120 - $submenu = array();
121 - }
122 - if ( ! isset( $menu ) || ! is_array( $menu ) ) {
123 - $menu = array();
124 - }
125 - wp_enqueue_command_palette_assets();
126 -
127 187 // Expose the same menu-commands array WP serializes into
128 188 // `wp.coreCommands.initializeCommandPalette(...)` on a window
129 189 // slot the shell harvester can read. Built in PHP from `$menu`
130 - // / `$submenu` here (we already guarded that they're arrays
131 - // above), then injected as a `before` inline on our own bundle
132 - // — that runs synchronously before `desktop.min.js` boots the
133 - // shell harvester, so the lookup is guaranteed populated by
134 - // the time `src/commands/shell-harvester.ts` classifies any
135 - // command. Decoupled from WP's command-palette mount timing
136 - // (which fires from a core-registered hook we can't reorder).
137 - $menu_map = desktop_mode_build_command_menu_map();
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();
138 199 wp_add_inline_script(
139 - 'desktop-mode',
140 - 'window.__desktopModeMenuCommands = ' . wp_json_encode( $menu_map ) . ';',
200 + 'openstation',
201 + 'window.__openStationMenuCommands = ' . wp_json_encode( $menu_map ) . ';',
141 202 'before'
142 203 );
143 204 }
144 205
145 206 // Pass configuration to JavaScript.
146 - global $title, $pagenow, $parent_file, $menu;
207 + global $title, $parent_file, $menu;
147 208
148 209 $menu_icon = 'dashicons-admin-generic';
149 210 if ( ! empty( $parent_file ) && ! empty( $menu ) ) {
150 211 foreach ( $menu as $item ) {
@@ -156,69 +217,125 @@
156 217 }
157 218
158 219 // Build dock items from the admin menu. Core pages are ordered
159 220 // first (Dashboard, Posts, Plugins, Users, Settings, …), then
160 - // plugin-contributed top-level routes. `desktop_mode_dock_placement`
221 + // plugin-contributed top-level routes. `openstation_dock_placement`
161 222 // is the per-item filter escape hatch for hiding. Shared with the
162 223 // REST menu endpoint so live refreshes (post plugin-activation)
163 224 // produce the same ordering as the boot payload.
164 - $menu_payload = desktop_mode_build_menu_payload();
165 - $dock_items = $menu_payload['dockItems'];
166 - $native_windows = isset( $menu_payload['nativeWindows'] )
225 + $menu_payload = openstation_build_menu_payload();
226 + $dock_items = $menu_payload['dockItems'];
227 + $native_windows = isset( $menu_payload['nativeWindows'] )
167 228 ? $menu_payload['nativeWindows']
168 229 : array();
169 - $server_widgets = isset( $menu_payload['serverWidgets'] )
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'] )
170 250 ? $menu_payload['serverWidgets']
171 251 : array();
172 - $server_wallpapers = isset( $menu_payload['serverWallpapers'] )
252 + $server_wallpapers = isset( $menu_payload['serverWallpapers'] )
173 253 ? $menu_payload['serverWallpapers']
174 254 : array();
175 - $server_command_scripts = isset( $menu_payload['serverCommandScripts'] )
255 + $server_command_scripts = isset( $menu_payload['serverCommandScripts'] )
176 256 ? $menu_payload['serverCommandScripts']
177 257 : array();
178 - $server_commands = isset( $menu_payload['serverCommands'] )
258 + $server_commands = isset( $menu_payload['serverCommands'] )
179 259 ? $menu_payload['serverCommands']
180 260 : array();
181 - $server_settings_tab_scripts = isset( $menu_payload['serverSettingsTabScripts'] )
261 + $server_settings_tab_scripts = isset( $menu_payload['serverSettingsTabScripts'] )
182 262 ? $menu_payload['serverSettingsTabScripts']
183 263 : array();
184 - $server_settings_tabs = isset( $menu_payload['serverSettingsTabs'] )
264 + $server_settings_tabs = isset( $menu_payload['serverSettingsTabs'] )
185 265 ? $menu_payload['serverSettingsTabs']
186 266 : array();
187 267 $server_dock_rail_renderer_scripts = isset( $menu_payload['serverDockRailRendererScripts'] )
188 268 ? $menu_payload['serverDockRailRendererScripts']
189 269 : array();
190 - $server_titlebar_button_scripts = isset( $menu_payload['serverTitleBarButtonScripts'] )
270 + $server_titlebar_button_scripts = isset( $menu_payload['serverTitleBarButtonScripts'] )
191 271 ? $menu_payload['serverTitleBarButtonScripts']
192 272 : array();
193 - $server_window_theme_scripts = isset( $menu_payload['serverWindowThemeScripts'] )
273 + $server_window_action_scripts = isset( $menu_payload['serverWindowActionScripts'] )
274 + ? $menu_payload['serverWindowActionScripts']
275 + : array();
276 + $server_window_theme_scripts = isset( $menu_payload['serverWindowThemeScripts'] )
194 277 ? $menu_payload['serverWindowThemeScripts']
195 278 : array();
196 - $server_window_themes = isset( $menu_payload['serverWindowThemes'] )
279 + $server_window_themes = isset( $menu_payload['serverWindowThemes'] )
197 280 ? $menu_payload['serverWindowThemes']
198 281 : array();
199 - $server_window_control_scripts = isset( $menu_payload['serverWindowControlScripts'] )
282 + $server_window_control_scripts = isset( $menu_payload['serverWindowControlScripts'] )
200 283 ? $menu_payload['serverWindowControlScripts']
201 284 : array();
202 - $server_window_controls = isset( $menu_payload['serverWindowControls'] )
285 + $server_window_controls = isset( $menu_payload['serverWindowControls'] )
203 286 ? $menu_payload['serverWindowControls']
204 287 : array();
205 - $server_window_slot_scripts = isset( $menu_payload['serverWindowSlotScripts'] )
288 + $server_window_slot_scripts = isset( $menu_payload['serverWindowSlotScripts'] )
206 289 ? $menu_payload['serverWindowSlotScripts']
207 290 : array();
208 - $server_window_slots = isset( $menu_payload['serverWindowSlots'] )
291 + $server_window_slots = isset( $menu_payload['serverWindowSlots'] )
209 292 ? $menu_payload['serverWindowSlots']
210 293 : array();
211 - $server_window_chrome_scripts = isset( $menu_payload['serverWindowChromeScripts'] )
294 + $server_window_chrome_scripts = isset( $menu_payload['serverWindowChromeScripts'] )
212 295 ? $menu_payload['serverWindowChromeScripts']
213 296 : array();
214 - $server_window_chromes = isset( $menu_payload['serverWindowChromes'] )
297 + $server_window_chromes = isset( $menu_payload['serverWindowChromes'] )
215 298 ? $menu_payload['serverWindowChromes']
216 299 : array();
217 - $server_window_notices = isset( $menu_payload['serverWindowNotices'] )
300 + $server_window_notices = isset( $menu_payload['serverWindowNotices'] )
218 301 ? $menu_payload['serverWindowNotices']
219 302 : array();
220 - $desktop_icons = isset( $menu_payload['desktopIcons'] )
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'] )
221 338 ? $menu_payload['desktopIcons']
222 339 : array();
223 340
224 341 // Files-on-the-Desktop payload (Phase 0+1). Plugin-registered
@@ -223,25 +340,25 @@
223 340
224 341 // Files-on-the-Desktop payload (Phase 0+1). Plugin-registered
225 342 // file types and openers ship as metadata only; the JS side
226 343 // holds the executable handlers and resolves on double-click.
227 - $server_file_types = function_exists( 'desktop_mode_build_file_types_payload' )
228 - ? desktop_mode_build_file_types_payload()
344 + $server_file_types = function_exists( 'openstation_build_file_types_payload' )
345 + ? openstation_build_file_types_payload()
229 346 : array();
230 - $server_file_openers = function_exists( 'desktop_mode_build_file_openers_payload' )
231 - ? desktop_mode_build_file_openers_payload()
347 + $server_file_openers = function_exists( 'openstation_build_file_openers_payload' )
348 + ? openstation_build_file_openers_payload()
232 349 : array();
233 - $user_file_associations = function_exists( 'desktop_mode_get_user_file_associations' )
234 - ? desktop_mode_get_user_file_associations( get_current_user_id() )
350 + $user_file_associations = function_exists( 'openstation_get_user_file_associations' )
351 + ? openstation_get_user_file_associations( get_current_user_id() )
235 352 : array();
236 - $server_wallpaper_menu_items = function_exists( 'desktop_mode_build_wallpaper_menu_items' )
237 - ? desktop_mode_build_wallpaper_menu_items()
353 + $server_wallpaper_menu_items = function_exists( 'openstation_build_wallpaper_menu_items' )
354 + ? openstation_build_wallpaper_menu_items()
238 355 : array();
239 356
240 357 /*
241 358 * OS-file drop config — what the browser drop manager will
242 359 * accept when the user drags a file from their native desktop
243 - * onto any surface inside Desktop Mode (wallpaper, a folder,
360 + * onto any surface inside OpenStation (wallpaper, a folder,
244 361 * a window, or a chromeless iframe). The allowed-mimes list is
245 362 * the user-scoped `get_allowed_mime_types()` (already capability
246 363 * gated by WordPress); the size cap is `wp_max_upload_size()`.
247 364 *
@@ -254,14 +371,12 @@
254 371 : array();
255 372 /**
256 373 * Filter the allowed-mime map used by the OS-file drop manager.
257 374 *
258 - * @since 0.30.0
259 - *
260 375 * @param array<string,string> $mimes_map `ext => mime-type` map (same shape `get_allowed_mime_types()` returns).
261 376 * @param int $user_id The current user id.
262 377 */
263 - $drop_allowed_mimes_map = apply_filters( 'desktop_mode_drop_allowed_mimes', $drop_allowed_mimes_map, get_current_user_id() );
378 + $drop_allowed_mimes_map = apply_filters( 'openstation_drop_allowed_mimes', $drop_allowed_mimes_map, get_current_user_id() );
264 379 $drop_allowed_mimes_map = is_array( $drop_allowed_mimes_map ) ? $drop_allowed_mimes_map : array();
265 380 $drop_allowed_mimes = array_values( array_unique( array_values( $drop_allowed_mimes_map ) ) );
266 381
267 382 $drop_max_size = (int) wp_max_upload_size();
@@ -269,14 +384,12 @@
269 384 * Filter the per-file size cap (in bytes) used by the OS-file
270 385 * drop manager. Returning `0` disables the client-side cap —
271 386 * the server still enforces its own.
272 387 *
273 - * @since 0.30.0
274 - *
275 388 * @param int $max_size Default `wp_max_upload_size()`.
276 389 * @param int $user_id The current user id.
277 390 */
278 - $drop_max_size = (int) apply_filters( 'desktop_mode_drop_max_size', $drop_max_size, get_current_user_id() );
391 + $drop_max_size = (int) apply_filters( 'openstation_drop_max_size', $drop_max_size, get_current_user_id() );
279 392
280 393 /**
281 394 * Filter the master OS-file drop enable gate. Lets plugins
282 395 * disable the drop manager by role / capability beyond the
@@ -282,15 +395,13 @@
282 395 * disable the drop manager by role / capability beyond the
283 396 * default `upload_files` check (e.g. only for admins, or
284 397 * only on specific multisite blogs).
285 398 *
286 - * @since 0.30.0
287 - *
288 399 * @param bool $enabled Default — `current_user_can( 'upload_files' )`.
289 400 * @param int $user_id The current user id.
290 401 */
291 402 $drop_enabled = (bool) apply_filters(
292 - 'desktop_mode_drop_enabled',
403 + 'openstation_drop_enabled',
293 404 current_user_can( 'upload_files' ),
294 405 get_current_user_id()
295 406 );
296 407
@@ -301,48 +412,61 @@
301 412 'maxSize' => $drop_max_size,
302 413 );
303 414
304 415 // Lazy-bundle URL builder. Each lazy-loaded bundle (AI Assistant,
305 - // About-scene, OS Settings panel, shell-overlays, window-system)
416 + // OS Settings panel, shell-overlays, window-system)
306 417 // is `<script>`-injected by the main bundle on demand — they don't
307 418 // go through `wp_register_script`, so they don't pick up WordPress's
308 419 // usual `?ver=<filemtime>` cache-buster. Without one, the browser
309 420 // happily serves a stale cached copy across plugin updates that
310 - // don't bump `DESKTOP_MODE_VERSION`, and the main bundle's loader
421 + // don't bump `OPENSTATION_VERSION`, and the main bundle's loader
311 422 // fires a `<script>`-loaded event for a file that's missing the
312 - // fresh `window.desktopMode*` factory the new code expects.
423 + // fresh `window.openStation*` factory the new code expects.
313 424 //
314 425 // Mirror the `$built_version( … )` helper in `includes/assets.php`:
315 426 // prefer the on-disk mtime of the actual file, fall back to the
316 427 // plugin version when the file is missing (dev environments where
317 428 // the bundle hasn't been built yet).
318 - $suffix = ( defined( 'SCRIPT_DEBUG' ) && SCRIPT_DEBUG ) ? '' : '.min';
429 + $suffix = openstation_asset_suffix();
319 430 $lazy_bundle_url = static function ( $base ) use ( $suffix ) {
320 - $path = DESKTOP_MODE_DIR . 'assets/js/' . $base . $suffix . '.js';
431 + $path = OPENSTATION_DIR . 'assets/js/' . $base . $suffix . '.js';
321 432 $ver = file_exists( $path )
322 433 ? (string) filemtime( $path )
323 - : DESKTOP_MODE_VERSION;
434 + : OPENSTATION_VERSION;
324 435 return esc_url_raw(
325 - DESKTOP_MODE_URL . 'assets/js/' . $base . $suffix . '.js?ver=' . $ver
436 + OPENSTATION_URL . 'assets/js/' . $base . $suffix . '.js?ver=' . $ver
326 437 );
327 438 };
328 439
329 - // Build the current page URL from $pagenow + $_GET. Strip the portal
330 - // markers so the derived window ID matches what the dock would produce
331 - // for the same page — otherwise auto-opening the entry window and
332 - // clicking the same dock icon would create a duplicate.
333 - $current_query = $_GET; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
334 - unset( $current_query[ DESKTOP_MODE_PORTAL_FLAG ], $current_query[ DESKTOP_MODE_PORTAL_INTENT_FLAG ] );
335 - $current_page = admin_url( $pagenow ) . ( ! empty( $current_query ) ? '?' . http_build_query( $current_query ) : '' );
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'];
336 451
337 - $from_portal = ! empty( $_GET[ DESKTOP_MODE_PORTAL_FLAG ] ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
338 - $from_portal_intent = ! empty( $_GET[ DESKTOP_MODE_PORTAL_INTENT_FLAG ] ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
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 + }
339 465
340 466 /**
341 467 * Filters the desktop shell configuration passed to JavaScript.
342 468 *
343 - * @since 0.1.0
344 - *
345 469 * @param array $config {
346 470 * Desktop shell configuration.
347 471 *
348 472 * @type string $currentPage The current admin page URL.
@@ -349,20 +473,20 @@
349 473 * @type string $currentTitle The current page title.
350 474 * @type string $currentIcon Dashicon class for the current page.
351 475 * @type string $adminUrl The base admin URL.
352 476 * @type string $colorScheme The active admin color scheme.
353 - * @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 `desktop_mode_dock_placement` are omitted.
354 - * @type array $nativeWindows Server-declared native windows (via `desktop_mode_register_window`). Shell registers + syncs tiles based on this list — activation/deactivation is a diff without shell reload.
355 - * @type array $serverWidgets Server-declared right-column widgets (via `desktop_mode_register_widget`). Shell syncs the widget registry + dynamically loads plugin scripts so widgets appear in the picker without a shell reload.
356 - * @type array $serverWallpapers Server-declared wallpapers (via `desktop_mode_register_wallpaper`). Same lifecycle — shell loads the plugin's JS, reads the full `WallpaperDef` from `window.desktopModeWallpapers[id]`, and registers / unregisters as plugins activate / deactivate.
357 - * @type array $serverCommandScripts Script handles opted-in via `desktop_mode_register_command_script`. Shell injects each URL on activation so commands registered by `wp.desktop.registerCommand` appear in the palette live. Deactivation unregisters any commands whose `owner` matches the departing handle.
358 - * @type array $serverCommands Server-declared command metadata (via `desktop_mode_register_command`). Advisory today — reserved for future pre-registration shims.
359 - * @type array $serverSettingsTabScripts Script handles opted-in via `desktop_mode_register_settings_tab_script`. Shell injects each URL on activation so tabs registered by `wp.desktop.registerSettingsTab` appear in the OS Settings window live. Deactivation unregisters tabs attributable to the departing handle.
360 - * @type array $serverSettingsTabs Server-declared settings-tab metadata (via `desktop_mode_register_settings_tab`). Enables live unregistration on plugin deactivation without requiring JS to set `owner`.
361 - * @type array $desktopIcons Server-declared desktop icons (via `desktop_mode_register_icon`). Rendered on the wallpaper as clickable shortcut tiles.
362 - * @type array $accentColors Swatch list for the OS Settings accent picker. Filterable via `desktop_mode_accent_colors`.
363 - * @type array $toastTypes Toast-notification type map. Filterable via `desktop_mode_toast_types`.
364 - * @type string $defaultWallpaper Wallpaper slug applied on first boot. Filterable via `desktop_mode_default_wallpaper`.
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`.
365 489 * @type array $session Saved session (windows, focused, updated).
366 490 * @type string $sessionUrl REST endpoint for saving the session.
367 491 * @type string $mediaUrl REST endpoint for media uploads (wp/v2/media).
368 492 * @type string $restUrl REST API root from rest_url(), safe for pretty and plain permalink installs.
@@ -370,132 +494,286 @@
370 494 * @type array $defaultWindow { enabled: bool, url: string } — current default-window preference.
371 495 * @type bool $canUpload Whether the user holds the `upload_files` capability.
372 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.
373 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.
374 499 * @type string $restNonce Nonce for the session REST endpoint.
375 - * @type string $portalUrl Canonical `/desktop-mode/` URL.
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.
376 502 * @type bool $fromPortal Whether the shell was reached via the portal.
377 - * @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 `/desktop-mode/` visit from a portal-redirected admin-bar click so the shell can honour the URL the user actually asked for.
378 - * @type array $seenIntros Slugs of one-time intro dialogs the user has dismissed (e.g. `['posts']`). Native windows gate their first-open intro on this list.
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']`).
379 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.
380 507 * }
381 508 */
382 509 $config = apply_filters(
383 - 'desktop_mode_shell_config',
510 + 'openstation_shell_config',
384 511 array(
385 - 'currentPage' => esc_url( $current_page ),
386 - 'currentTitle' => wp_strip_all_tags( $title ),
387 - 'currentIcon' => sanitize_html_class( $menu_icon ),
388 - 'adminUrl' => esc_url( admin_url() ),
389 - 'colorScheme' => sanitize_html_class( get_user_option( 'admin_color' ), 'fresh' ),
390 - 'dockItems' => $dock_items,
391 - 'nativeWindows' => $native_windows,
392 - 'serverWidgets' => $server_widgets,
393 - 'serverWallpapers' => $server_wallpapers,
394 - 'serverCommandScripts' => $server_command_scripts,
395 - 'serverCommands' => $server_commands,
396 - 'serverSettingsTabScripts' => $server_settings_tab_scripts,
397 - 'serverSettingsTabs' => $server_settings_tabs,
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,
398 544 'serverDockRailRendererScripts' => $server_dock_rail_renderer_scripts,
399 - 'serverTitleBarButtonScripts' => $server_titlebar_button_scripts,
400 - 'serverWindowThemeScripts' => $server_window_theme_scripts,
401 - 'serverWindowThemes' => $server_window_themes,
402 - 'serverWindowControlScripts' => $server_window_control_scripts,
403 - 'serverWindowControls' => $server_window_controls,
404 - 'serverWindowSlotScripts' => $server_window_slot_scripts,
405 - 'serverWindowSlots' => $server_window_slots,
406 - 'serverWindowChromeScripts' => $server_window_chrome_scripts,
407 - 'serverWindowChromes' => $server_window_chromes,
408 - 'serverWindowNotices' => $server_window_notices,
409 - 'desktopIcons' => $desktop_icons,
410 - 'serverFileTypes' => $server_file_types,
411 - 'serverFileOpeners' => $server_file_openers,
412 - 'userFileAssociations' => $user_file_associations,
413 - 'filesUrl' => esc_url_raw( rest_url( 'desktop-mode/v1/files' ) ),
414 - 'serverWallpaperMenuItems' => $server_wallpaper_menu_items,
415 - 'accentColors' => desktop_mode_get_accent_colors(),
416 - 'toastTypes' => desktop_mode_get_toast_types(),
417 - 'defaultWallpaper' => desktop_mode_get_default_wallpaper(),
418 - 'session' => desktop_mode_get_session( get_current_user_id() ),
419 - 'sessionUrl' => esc_url_raw( rest_url( 'desktop-mode/v1/session' ) ),
420 - 'restUrl' => esc_url_raw( rest_url() ),
421 - 'mediaUrl' => esc_url_raw( rest_url( 'wp/v2/media' ) ),
422 - 'dropConfig' => $drop_config,
423 - 'defaultWindowUrl' => esc_url_raw( rest_url( 'desktop-mode/v1/default-window' ) ),
424 - 'defaultWindow' => desktop_mode_get_default_window( get_current_user_id() ),
425 - 'canUpload' => current_user_can( 'upload_files' ),
426 - 'pluginUrl' => esc_url_raw( untrailingslashit( DESKTOP_MODE_URL ) ),
427 - 'pluginVersion' => DESKTOP_MODE_VERSION,
428 - 'iframeBridgeUrl' => $lazy_bundle_url( 'iframe-bridge' ),
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' ),
429 609 // URL of the AI Assistant lazy bundle. The main bundle
430 - // ships a stub matching the public `wp.desktop.ai` API; the
610 + // ships a stub matching the public `wp.os.ai` API; the
431 611 // stub `<script>`-injects this URL the first time the user
432 612 // opens the assistant. Picking `.js` vs `.min.js` here keeps
433 613 // the SCRIPT_DEBUG gate server-side, matching iframeBridgeUrl.
434 - 'aiAssistantBundleUrl' => $lazy_bundle_url( 'ai-assistant' ),
435 - // URL of the About-scene lazy bundle. The OS Settings →
436 - // About tab loads this on first mount; ~25 kB PixiJS
437 - // particle scene that would otherwise ship in the main
438 - // bundle for every shell load.
439 - 'aboutSceneBundleUrl' => $lazy_bundle_url( 'about-scene' ),
440 - // URL of the OS Settings panel lazy bundle. Injected by
441 - // the main bundle's `OsSettings.renderPanel()` stub on
442 - // the user's first Settings open. Holds every section
443 - // renderer + the `<wpd-*>` components only the panel
444 - // uses, so nothing about Settings ships in
445 - // `desktop.min.js` for users who never open it.
446 - 'osSettingsPanelBundleUrl' => $lazy_bundle_url( 'os-settings-panel' ),
614 + 'aiAssistantBundleUrl' => $lazy_bundle_url( 'ai-assistant' ),
447 615 // URL of the shell-overlays lazy bundle. Pre-loaded by
448 616 // the main bundle after first paint so action-triggered
449 617 // overlays (toast, confirm dialog, context menus) feel
450 618 // instant the first time they fire.
451 - 'shellOverlaysBundleUrl' => $lazy_bundle_url( 'shell-overlays' ),
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' ),
452 663 // URL of the lazy window-system bundle (Stage 11).
453 664 // Holds the `Window` class and its DOM / pointer / tab /
454 - // chrome helpers — the single largest module in the pre-
455 - // 0.8.4 main bundle. Loaded on first `windowManager.open()`
456 - // / `openNew()` call (both async since 0.8.4); pre-loaded
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
457 668 // by the shell after first paint when no session is being
458 669 // restored and no `openCurrentPage` will fire.
459 - 'windowSystemBundleUrl' => $lazy_bundle_url( 'window-system' ),
460 - 'restNonce' => wp_create_nonce( 'wp_rest' ),
461 - 'osSettings' => desktop_mode_get_os_settings( get_current_user_id() ),
462 - 'osSettingsUrl' => esc_url_raw( rest_url( 'desktop-mode/v1/os-settings' ) ),
463 - 'seenIntros' => desktop_mode_get_seen_intros( get_current_user_id() ),
464 - 'seenIntrosUrl' => esc_url_raw( rest_url( 'desktop-mode/v1/intros' ) ),
465 - 'aiSearchUrl' => esc_url_raw( rest_url( 'desktop-mode/v1/ai/search' ) ),
466 - 'aiSearchStreamUrl' => esc_url_raw( add_query_arg( 'action', 'desktop_mode_ai_search_stream', admin_url( 'admin-ajax.php' ) ) ),
467 - 'aiPlatformSettings' => current_user_can( 'manage_options' ) ? desktop_mode_ai_get_platform_settings() : null,
468 - 'aiPlatformSettingsUrl' => esc_url_raw( rest_url( 'desktop-mode/v1/ai/platform-settings' ) ),
469 - 'aiProviders' => desktop_mode_ai_get_providers_for_config(),
470 - 'extendedOptions' => current_user_can( 'manage_options' ) ? desktop_mode_get_extended_options() : null,
471 - 'extendedOptionsUrl' => esc_url_raw( rest_url( 'desktop-mode/v1/extended-options' ) ),
472 - // Comments-window AI moderation toggle — surfaced at the
473 - // shell level so the OS Settings → Features tab can render
474 - // the toggle without depending on the Comments window
475 - // being registered for this user. URL is the same
476 - // endpoint the comments-window config exposes; state is
477 - // `null` for non-admins (the UI hides the row entirely).
478 - 'commentsAiUrl' => esc_url_raw( rest_url( 'desktop-mode/v1/comments/ai-settings' ) ),
479 - 'commentsAi' => current_user_can( 'manage_options' )
480 - ? array(
481 - 'enabled' => function_exists( 'desktop_mode_comments_ai_is_enabled' )
482 - ? desktop_mode_comments_ai_is_enabled()
483 - : false,
484 - 'providerConfigured' => function_exists( 'desktop_mode_comments_ai_provider_configured' )
485 - ? desktop_mode_comments_ai_provider_configured()
486 - : false,
487 - )
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()
488 715 : null,
489 - 'currentUserIsAdmin' => current_user_can( 'manage_options' ),
490 - 'portalUrl' => esc_url( desktop_mode_portal_url() ),
491 - 'fromPortal' => $from_portal,
492 - 'fromPortalIntent' => $from_portal_intent,
493 - 'pwa' => array(
494 - 'manifestUrl' => esc_url_raw( desktop_mode_pwa_manifest_url() ),
495 - 'swUrl' => esc_url_raw( desktop_mode_pwa_sw_url() ),
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 + 'currentUserIsAdmin' => current_user_can( 'manage_options' ),
726 + // Null on single-site installs; its `networkAdmin` null
727 + // without `manage_network`, which is what keeps the dock
728 + // tile from registering.
729 + 'multisite' => openstation_multisite_payload(),
730 + 'portalUrl' => esc_url( openstation_portal_url() ),
731 + 'fromPortal' => $from_portal,
732 + 'fromPortalIntent' => $from_portal_intent,
733 + // One-shot like the boot target: a switch from another
734 + // site's overview lands in this one's.
735 + 'landInOverview' => openstation_shell_lands_in_overview(),
736 + 'arrivalDirection' => openstation_shell_arrival_direction(),
737 + 'hopLinkOffer' => function_exists( 'openstation_network_link_offer' ) ? openstation_network_link_offer() : null,
738 + 'pwa' => array(
739 + 'manifestUrl' => esc_url_raw( openstation_pwa_manifest_url() ),
740 + 'swUrl' => esc_url_raw( openstation_pwa_sw_url() ),
741 + // Extensionless retry target for hosts whose nginx 404s
742 + // virtual .js paths before WordPress runs (WordPress.com).
743 + 'swFallbackUrl' => esc_url_raw( openstation_pwa_sw_fallback_url() ),
744 + // The site's home path — the scope the registration
745 + // asks for, so a subdirectory network's sites each get
746 + // their own worker instead of fighting over the root.
747 + 'swScope' => openstation_pwa_sw_scope(),
748 + // The worker's per-user flags, computed HERE rather than
749 + // baked into the served `sw.js`.
750 + //
751 + // A service worker is origin-wide but these are per-user
752 + // preferences, so putting them in the script bytes made
753 + // the body differ between an anonymous and a logged-in
754 + // request — and any in-scope logged-out navigation then
755 + // installed a "new" worker, which at the time reloaded
756 + // the shell. The bytes are identical for everyone now;
757 + // the shell posts these to the worker at boot.
758 + //
759 + // Computed server-side, not read from the settings
760 + // snapshot client-side, because
761 + // `openstation_pwa_admin_asset_cache_enabled()` applies
762 + // the `openstation_pwa_admin_asset_cache` filter — an
763 + // operator's site-wide veto has to keep working.
764 + 'swConfig' => array(
765 + 'adminAssetCache' => (bool) openstation_pwa_admin_asset_cache_enabled(),
766 + 'windowPrewarm' => ! empty( openstation_get_os_settings( get_current_user_id() )['windowPrewarmEnabled'] ),
767 + ),
768 + // The build this shell document belongs to. When a new
769 + // worker takes over mid-session the shell asks it for
770 + // the stamp it was served with and compares; only a
771 + // difference — the shell's own files changed on the
772 + // server — offers the user a reload. Never automatic.
773 + 'shellBuild' => openstation_shell_build_stamp(),
496 774 'stateUrl' => esc_url_raw( rest_url( 'desktop-mode/v1/pwa-state' ) ),
497 - 'state' => desktop_mode_pwa_get_user_state( get_current_user_id() ),
775 + 'state' => openstation_pwa_get_user_state( get_current_user_id() ),
498 776 // Mirrors the manifest's `name` field — used by the
499 777 // install pill so the button reads "Install <site>"
500 778 // rather than "Install <current page>" (which would
501 779 // be misleading: we install the whole site as an
@@ -501,31 +779,113 @@
501 779 // be misleading: we install the whole site as an
502 780 // app, not the dashboard window the user happens to
503 781 // be viewing).
504 782 'appName' => get_bloginfo( 'name' ),
505 - // Operators set the `desktop_mode_pwa_force_replace_sw`
783 + // Operators set the `openstation_pwa_force_replace_sw`
506 784 // filter to `true` when another root-scope service
507 - // worker on the origin is blocking desktop-mode
785 + // worker on the origin is blocking openstation
508 786 // installability (foreign-SW guard in
509 787 // `src/pwa/sw-register.ts`). Default `false` preserves
510 788 // the polite behaviour where we yield to existing PWAs.
511 - 'forceReplaceSw' => desktop_mode_pwa_force_replace_sw(),
789 + 'forceReplaceSw' => openstation_pwa_force_replace_sw(),
512 790 ),
791 + // Ordered Core command-palette asset manifest, replayed on
792 + // first palette invocation. `null` on pre-6.9 sites.
793 + 'commandPalette' => $command_palette,
794 + // Stylesheets for shell surfaces that render on demand —
795 + // the Preferences panel, the AI assistant, the bug-report
796 + // window. None of them is a server-registered native
797 + // window (they are built client-side by the shell
798 + // bundle), so the `styles` companion mechanism can't
799 + // carry their CSS; instead the shell injects each sheet
800 + // the first time its surface opens, via
801 + // `ensureDeferredStyle()` in `src/deferred-styles.ts`.
802 + // Same resolved shape a native window's `styleUrl` /
803 + // `styleInline` travels in.
804 + // Which of the `deferredStyles` entries a game needs.
805 + // `launchGame()` injects these before the window paints.
806 + 'gameStyleHandles' => function_exists( 'openstation_games_style_handles' )
807 + ? openstation_games_style_handles()
808 + : array(),
809 + 'deferredStyles' => openstation_build_deferred_styles(
810 + array_merge(
811 + array(
812 + 'desktop-mode-ai-assistant',
813 + 'desktop-mode-bug-report',
814 + // The explorer's shared sheet. It rides the WP
815 + // Explorer APP as a companion style, but the
816 + // desktop FOLDER window paints its preview pane
817 + // with the same `os-my-wordpress__*` classes and
818 + // — being a native window opened straight from
819 + // JS — carries no companion styles of its own.
820 + // Without this, the pane rendered unstyled until
821 + // the explorer had been opened once in the
822 + // session.
823 + 'desktop-mode-my-wordpress',
824 + ),
825 + // The Games sheets. They also ride the hub window as
826 + // companion styles, but a game is reachable without
827 + // the hub — the challenge toast, solo mode, and
828 + // `wp.os.games.launch()` all land in `launchGame()`
829 + // with no hub window in the tab. Listing them here
830 + // costs a URL each in the boot config and no CSS
831 + // until `launchGame()` asks.
832 + function_exists( 'openstation_games_style_handles' )
833 + ? openstation_games_style_handles()
834 + : array()
835 + )
836 + ),
513 837 )
514 838 );
515 839
516 - wp_localize_script( 'desktop-mode', 'desktopModeConfig', $config );
840 + wp_localize_script( 'openstation', 'openStationConfig', $config );
517 841
518 842 /**
519 - * Fires when desktop mode assets are enqueued.
520 - *
521 - * @since 0.1.0
843 + * Fires when OpenStation assets are enqueued.
522 844 */
523 - do_action( 'desktop_mode_mode_init' );
845 + do_action( 'openstation_mode_init' );
524 846 }
525 -add_action( 'admin_enqueue_scripts', 'desktop_mode_enqueue_assets' );
847 +add_action( 'admin_enqueue_scripts', 'openstation_enqueue_assets' );
526 848
527 849 /**
850 + * Keep Core's boot-time command-palette enqueue off shell pages.
851 + *
852 + * WordPress 7.0 hooks `wp_enqueue_command_palette_assets()` on
853 + * `admin_enqueue_scripts` by default, which puts the palette's whole
854 + * dependency chain — the Gutenberg runtime, ~800 KB gzipped — on
855 + * every admin page. On a SHELL page that is pure dead weight: the
856 + * shell suppresses Core's palette unconditionally (the ⌘K keystroke
857 + * and the admin-bar icon both route to the shell's own palette), so
858 + * the runtime it powers can never be shown. Unhooking here lets the
859 + * deferred manifest (`openstation_build_command_palette_assets_payload()`)
860 + * capture the chain instead, and the shell loads it on the first
861 + * palette invocation.
862 + *
863 + * Deliberately scoped: classic-mode requests keep Core's default,
864 + * because a classic page is Core's own UI where Core's palette is the
865 + * right one.
866 + *
867 + * Windows are handled separately by
868 + * {@see openstation_chromeless_should_trim_command_palette()} in
869 + * `includes/render/chromeless-trim.php` — same idea, but it has to
870 + * drop the whole palette *family* rather than just unhook Core's
871 + * callback, and it exempts block-editor screens. Unhooking alone is
872 + * not enough there: a third-party palette extension that declares
873 + * `wp-commands` keeps the entire chain queued as its dependency.
874 + *
875 + * Priority 0, ahead of Core's default 10, so the removal lands
876 + * before the callback fires. On WP 6.9 (function exists, no default
877 + * hook) the `remove_action()` is a harmless no-op.
878 + */
879 +function openstation_defer_core_command_palette() {
880 + if ( ! openstation_is_shell_request() ) {
881 + return;
882 + }
883 + remove_action( 'admin_enqueue_scripts', 'wp_enqueue_command_palette_assets' );
884 +}
885 +add_action( 'admin_enqueue_scripts', 'openstation_defer_core_command_palette', 0 );
886 +
887 +/**
528 888 * Emits `<link rel="preload">` hints for the shell's critical-path
529 889 * assets so the browser starts fetching them as soon as it parses
530 890 * the document `<head>`.
531 891 *
@@ -541,17 +901,26 @@
541 901 * tag. The `wp_resource_hints` filter is frontend-only (`wp_head`-
542 902 * driven) and isn't invoked in admin context, so we emit our own
543 903 * tags.
544 904 *
545 - * Four targets by default:
546 - * - `desktop[.min].js` — the shell bundle (biggest win).
547 - * - `desktop.css` — shell base CSS, needed for first paint.
548 - * - `window-system[.min].js` — lazy bundle preloaded by JS after
549 - * first paint; hinting in HTML lets the browser start the fetch
550 - * in parallel with the main bundle's parse instead of waiting.
551 - * - `shell-overlays[.min].js` — same rationale.
905 + * Four targets by default, split across two relationship types:
906 + * - `desktop[.min].js` (preload) — the shell bundle (biggest win),
907 + * consumed by the footer `<script>` on this very load.
908 + * - `desktop.css` (preload) — shell base CSS, needed for first
909 + * paint. Its registered handle is `filemtime`-stamped so the
910 + * stylesheet URL matches this hint exactly (a `?ver=` mismatch makes
911 + * the browser treat the preload as unused).
912 + * - `window-system[.min].js` (prefetch) — lazy bundle `<script>`-
913 + * injected by the main bundle on the first `open()`.
914 + * - `shell-overlays[.min].js` (prefetch) — lazy bundle injected on the
915 + * first toast / dialog / context-menu.
552 916 *
553 - * Plugins can extend the hint list via the `desktop_mode_preload_hints`
917 + * The lazy bundles use `prefetch` rather than `preload`: they're loaded
918 + * later (often beyond the ~3s window Chrome allows a `preload` before it
919 + * warns "preloaded but not used in time"), so `prefetch` keeps the early
920 + * low-priority cache fill without the must-use-now contract.
921 + *
922 + * Plugins can extend the hint list via the `openstation_preload_hints`
554 923 * filter — e.g. a settings tab whose bundle the user opens on every
555 924 * visit can opt its own URL into the preload phase.
556 925 *
557 926 * Same-origin resources only — no `crossorigin` attribute. CDN hosts
@@ -557,62 +926,84 @@
557 926 * Same-origin resources only — no `crossorigin` attribute. CDN hosts
558 927 * that serve `wp-content/plugins/` from a different origin should
559 928 * supply absolute URLs through the filter; in that case the consumer
560 929 * is responsible for the `crossorigin` semantics.
561 - *
562 - * @since 0.8.9
563 930 */
564 -function desktop_mode_print_preload_hints() {
565 - if (
566 - ! is_admin()
567 - || ! desktop_mode_is_enabled()
568 - || desktop_mode_is_chromeless_request()
569 - || desktop_mode_is_classic_request()
570 - ) {
931 +function openstation_print_preload_hints() {
932 + if ( ! openstation_is_shell_request() ) {
571 933 return;
572 934 }
573 935
574 - $suffix = ( defined( 'SCRIPT_DEBUG' ) && SCRIPT_DEBUG ) ? '' : '.min';
936 + $suffix = openstation_asset_suffix();
575 937
576 938 $build_url = static function ( $relative ) {
577 - $path = DESKTOP_MODE_DIR . $relative;
578 - $ver = file_exists( $path ) ? (string) filemtime( $path ) : DESKTOP_MODE_VERSION;
579 - return DESKTOP_MODE_URL . $relative . '?ver=' . $ver;
939 + $path = OPENSTATION_DIR . $relative;
940 + $ver = file_exists( $path ) ? (string) filemtime( $path ) : OPENSTATION_VERSION;
941 + return OPENSTATION_URL . $relative . '?ver=' . $ver;
580 942 };
581 943
582 944 $hints = array(
945 + // Critical path — consumed on this very page load (the footer
946 + // `<script>` and the shell stylesheet), so `preload` is correct.
583 947 array(
584 948 'href' => $build_url( 'assets/js/desktop' . $suffix . '.js' ),
585 949 'as' => 'script',
950 + 'rel' => 'preload',
586 951 ),
587 952 array(
588 953 'href' => $build_url( 'assets/css/desktop.css' ),
589 954 'as' => 'style',
955 + 'rel' => 'preload',
590 956 ),
957 + // Lazy bundles — `<script>`-injected by the main bundle after
958 + // first paint (window-system on the first `open()`, shell-overlays
959 + // on the first toast / dialog / context-menu). They are frequently
960 + // NOT requested within the ~3s window Chrome allows a `preload`,
961 + // which produced "resource was preloaded but not used in time"
962 + // warnings. `prefetch` is the right hint: same early, low-priority
963 + // fetch into the cache, but no must-use-now contract — so the
964 + // injected `<script src>` is served from cache with no warning.
591 965 array(
592 966 'href' => $build_url( 'assets/js/window-system' . $suffix . '.js' ),
593 967 'as' => 'script',
968 + 'rel' => 'prefetch',
594 969 ),
595 970 array(
596 971 'href' => $build_url( 'assets/js/shell-overlays' . $suffix . '.js' ),
597 972 'as' => 'script',
973 + 'rel' => 'prefetch',
598 974 ),
599 975 );
600 976
977 + // The phone layer is needed at boot on a phone and never on a
978 + // desktop; the server cannot see the viewport, so the user agent
979 + // decides whether the hint is worth its bytes. A wrong guess costs
980 + // one low-priority fetch, never a wrong layout — the stamp and the
981 + // bundle loader read the real viewport.
982 + if ( openstation_mode_hint_is_mobile( get_current_user_id() ) ) {
983 + $hints[] = array(
984 + 'href' => $build_url( 'assets/js/mobile' . $suffix . '.js' ),
985 + 'as' => 'script',
986 + 'rel' => 'prefetch',
987 + );
988 + }
989 +
601 990 /**
602 991 * Filters the list of resource preload hints emitted in `<head>`.
603 992 *
604 - * Each entry is a `{ 'href' => string, 'as' => string }` array
605 - * rendered as `<link rel="preload" as="<as>" href="<href>">`.
606 - * Unrecognized entries are silently skipped — keep the contract
607 - * permissive so a misconfigured plugin can't tank first paint.
993 + * Each entry is a `{ 'href' => string, 'as' => string,
994 + * 'rel' => 'preload'|'prefetch' }` array rendered as
995 + * `<link rel="<rel>" as="<as>" href="<href>">`. `rel` is optional and
996 + * defaults to `preload`; any value other than `prefetch` is coerced
997 + * back to `preload`. Unrecognized entries are silently skipped — keep
998 + * the contract permissive so a misconfigured plugin can't tank first
999 + * paint.
608 1000 *
609 - * @since 0.8.9
610 - *
611 - * @param array $hints Default hints (main bundle, base CSS,
612 - * window-system, shell-overlays).
1001 + * @param array $hints Default hints (main bundle + base CSS as
1002 + * `preload`; window-system + shell-overlays as
1003 + * `prefetch`).
613 1004 */
614 - $hints = apply_filters( 'desktop_mode_preload_hints', $hints );
1005 + $hints = apply_filters( 'openstation_preload_hints', $hints );
615 1006
616 1007 if ( ! is_array( $hints ) ) {
617 1008 return;
618 1009 }
@@ -625,19 +1016,27 @@
625 1016 $as = isset( $hint['as'] ) ? (string) $hint['as'] : '';
626 1017 if ( '' === $href || '' === $as ) {
627 1018 continue;
628 1019 }
1020 + // `preload` (critical, used on this load) vs `prefetch` (lazy,
1021 + // used on a later interaction). Anything else falls back to
1022 + // `preload` so a typo can't emit an invalid relationship.
1023 + $rel = isset( $hint['rel'] ) ? (string) $hint['rel'] : 'preload';
1024 + if ( 'prefetch' !== $rel ) {
1025 + $rel = 'preload';
1026 + }
629 1027 printf(
630 - '<link rel="preload" as="%s" href="%s" />' . "\n",
1028 + '<link rel="%s" as="%s" href="%s" />' . "\n",
1029 + esc_attr( $rel ),
631 1030 esc_attr( $as ),
632 1031 esc_url( $href )
633 1032 );
634 1033 }
635 1034 }
636 -add_action( 'admin_print_styles', 'desktop_mode_print_preload_hints', 1 );
1035 +add_action( 'admin_print_styles', 'openstation_print_preload_hints', 1 );
637 1036
638 1037 /**
639 - * Defers loading of non-critical desktop-mode stylesheets so they
1038 + * Defers loading of non-critical openstation stylesheets so they
640 1039 * don't block first paint.
641 1040 *
642 1041 * Three stylesheets in the default enqueue list are only needed
643 1042 * after a user interaction — `dock-peek` (mouseover a dock tile),
@@ -661,14 +1060,12 @@
661 1060 * before the user clicks anything that needs them. The
662 1061 * `<noscript>` fallback restores critical-path behavior for JS-off
663 1062 * browsers, so accessibility isn't degraded.
664 1063 *
665 - * Filterable via `desktop_mode_deferred_styles` so plugins can opt
1064 + * Filterable via `openstation_deferred_styles` so plugins can opt
666 1065 * their own non-critical stylesheets in (or pull a built-in out).
667 1066 * Chromeless iframes are skipped — their CSS pipeline is separate.
668 1067 *
669 - * @since 0.8.9
670 - *
671 1068 * @param string $html The original <link> tag HTML.
672 1069 * @param string $handle The stylesheet handle WP is printing.
673 1070 * @param string $href The full URL of the stylesheet.
674 1071 * @param string $media The media attribute value WP resolved.
@@ -673,22 +1070,22 @@
673 1070 * @param string $href The full URL of the stylesheet.
674 1071 * @param string $media The media attribute value WP resolved.
675 1072 * @return string Possibly-rewritten tag.
676 1073 */
677 -function desktop_mode_defer_non_critical_styles( $html, $handle, $href, $media ) {
1074 +function openstation_defer_non_critical_styles( $html, $handle, $href, $media ) {
678 1075 // Cheap gates first — `style_loader_tag` fires once per enqueued
679 1076 // stylesheet on EVERY admin page (frontend doesn't go through
680 - // this filter, but admin does, including pages where desktop mode
681 - // is disabled). The deferred handles only ship when desktop mode
1077 + // this filter, but admin does, including pages where OpenStation
1078 + // is disabled). The deferred handles only ship when OpenStation
682 1079 // is active, so the in_array check below would always miss on
683 1080 // classic-only admin pages — but the `apply_filters` call still
684 1081 // builds an array and walks subscribers per stylesheet. Short-
685 1082 // circuit on the cheap helper checks (`is_admin` / enabled /
686 - // chromeless) so non-desktop-mode users pay nothing.
687 - if ( ! desktop_mode_is_enabled() ) {
1083 + // chromeless) so non-openstation users pay nothing.
1084 + if ( ! openstation_is_enabled() ) {
688 1085 return $html;
689 1086 }
690 - if ( desktop_mode_is_chromeless_request() ) {
1087 + if ( openstation_is_chromeless_request() ) {
691 1088 return $html;
692 1089 }
693 1090
694 1091 /**
@@ -697,18 +1094,18 @@
697 1094 * their own non-critical stylesheets here, or pull a built-in
698 1095 * out (e.g. a plugin that surfaces the AI assistant on every
699 1096 * page might want to keep its CSS critical-path).
700 1097 *
701 - * @since 0.8.9
702 - *
703 1098 * @param string[] $handles Default deferred handles.
704 1099 */
705 1100 $deferred = apply_filters(
706 - 'desktop_mode_deferred_styles',
1101 + 'openstation_deferred_styles',
707 1102 array(
708 - 'desktop-mode-dock-peek',
1103 + 'os-dock-peek',
1104 + 'os-openstation-layout',
709 1105 'desktop-mode-ai-assistant',
710 1106 'desktop-mode-bug-report',
1107 + 'os-window-overview',
711 1108 )
712 1109 );
713 1110
714 1111 if ( ! in_array( $handle, (array) $deferred, true ) ) {
@@ -719,22 +1116,22 @@
719 1116 $id = $handle . '-css';
720 1117
721 1118 // Two contexts, two escapers for the same `$resolved_media` value:
722 1119 //
723 - // - `%3$s` lands inside a JS string literal inside the HTML
724 - // `onload="…"` attribute (`this.media='%3$s'`). `esc_attr`
725 - // escapes `"` and `&` but NOT single quotes, so a media
726 - // value containing `'` would break out of the JS string.
727 - // `esc_js` is the correct escaper for "string literal inside
728 - // an event-handler attribute" — escapes single quotes, double
729 - // quotes, backslashes, newlines. Today `$resolved_media`
730 - // comes from `wp_enqueue_style()`'s `$media` parameter (always
731 - // a CSS media type / query produced by WordPress core), so
732 - // this is pure defense-in-depth, but the cost is one extra
733 - // function call.
1120 + // - `%3$s` lands inside a JS string literal inside the HTML
1121 + // `onload="…"` attribute (`this.media='%3$s'`). `esc_attr`
1122 + // escapes `"` and `&` but NOT single quotes, so a media
1123 + // value containing `'` would break out of the JS string.
1124 + // `esc_js` is the correct escaper for "string literal inside
1125 + // an event-handler attribute" — escapes single quotes, double
1126 + // quotes, backslashes, newlines. Today `$resolved_media`
1127 + // comes from `wp_enqueue_style()`'s `$media` parameter (always
1128 + // a CSS media type / query produced by WordPress core), so
1129 + // this is pure defense-in-depth, but the cost is one extra
1130 + // function call.
734 1131 //
735 - // - `%4$s` lands inside an HTML attribute in the `<noscript>`
736 - // fallback (`media='%4$s'`). That's standard `esc_attr`.
1132 + // - `%4$s` lands inside an HTML attribute in the `<noscript>`
1133 + // fallback (`media='%4$s'`). That's standard `esc_attr`.
737 1134 //
738 1135 // 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.
739 1136 $markup = sprintf(
740 1137 '<link rel=\'stylesheet\' id=\'%1$s\' href=\'%2$s\' media=\'print\' onload="this.media=\'%3$s\'; this.onload=null;" />' . "\n" .
@@ -747,19 +1144,19 @@
747 1144 // phpcs:enable WordPress.WP.EnqueuedResources.NonEnqueuedStylesheet
748 1145
749 1146 return $markup;
750 1147 }
751 -add_filter( 'style_loader_tag', 'desktop_mode_defer_non_critical_styles', 10, 4 );
1148 +add_filter( 'style_loader_tag', 'openstation_defer_non_critical_styles', 10, 4 );
752 1149
753 1150 /**
754 1151 * Build the admin-menu command map (name → URL) and expose it on
755 - * `window.__desktopModeMenuCommands`. The shell command harvester
1152 + * `window.__openStationMenuCommands`. The shell command harvester
756 1153 * (`src/commands/shell-harvester.ts`) reads this slot to resolve URLs
757 1154 * for "Go to: …" commands whose JS callbacks
758 1155 * (`document.location = menuCommand.url`) close over a variable URL
759 1156 * we can't extract from source. Without this map those commands
760 1157 * either get skipped (no URL recoverable) or — if the location
761 - * shadow misses — navigate the SHELL out of desktop mode.
1158 + * shadow misses — navigate the SHELL out of OpenStation.
762 1159 *
763 1160 * Mirrors what WordPress core's `wp_enqueue_command_palette_assets()`
764 1161 * builds for `wp.coreCommands.initializeCommandPalette(...)`. We
765 1162 * duplicate the logic here (instead of monkey-patching the JS init
@@ -768,16 +1165,14 @@
768 1165 * same handle) and ship the result through `wp_add_inline_script` on
769 1166 * our own bundle handle. That decouples us entirely from WP's command-
770 1167 * palette mount timing.
771 1168 *
772 - * @since 0.8.4
773 - *
774 1169 * @global array $menu
775 1170 * @global array $submenu
776 1171 * @return array<int, array{label:string, url:string, name:string}>
777 1172 */
778 -function desktop_mode_build_command_menu_map() {
779 - global $menu, $submenu;
1173 +function openstation_build_command_menu_map() {
1174 + global $menu, $submenu, $_parent_pages;
780 1175 if ( ! is_array( $menu ) ) {
781 1176 return array();
782 1177 }
783 1178 $out = array();
@@ -822,9 +1217,16 @@
822 1217 }
823 1218 $menu_label = $extract_root_text( $menu_item[0] );
824 1219 $menu_slug = $menu_item[2];
825 1220 $menu_url = '';
826 - if ( preg_match( '/\.php($|\?)/', $menu_slug ) || wp_http_validate_url( $menu_slug ) ) {
1221 + // Registered plugin pages win over the direct-file test: a
1222 + // legacy file-path slug ('wp-sweep/admin.php') matches the
1223 + // `.php` regex yet must route through menu_page_url(). The
1224 + // exception is URL-style slugs referencing a real admin file
1225 + // (ACF's 'edit.php?post_type=acf-field-group' — also a
1226 + // registered page) — those stay direct links, matching
1227 + // classic admin's menu-header.php.
1228 + if ( ( ! isset( $_parent_pages[ $menu_slug ] ) || openstation_is_admin_file_slug( $menu_slug ) ) && ( preg_match( '/\.php($|\?)/', $menu_slug ) || wp_http_validate_url( $menu_slug ) ) ) {
827 1229 $menu_url = $menu_slug;
828 1230 } elseif ( ! empty( menu_page_url( $menu_slug, false ) ) ) {
829 1231 $menu_url = menu_page_url( $menu_slug, false );
830 1232 }
@@ -845,9 +1247,11 @@
845 1247 }
846 1248 $submenu_label = $extract_root_text( $submenu_item[0] );
847 1249 $submenu_slug = $submenu_item[2];
848 1250 $submenu_url = '';
849 - if ( preg_match( '/\.php($|\?)/', $submenu_slug ) || wp_http_validate_url( $submenu_slug ) ) {
1251 + // Same registered-page vs admin-file rule as the
1252 + // top-level loop.
1253 + if ( ( ! isset( $_parent_pages[ $submenu_slug ] ) || openstation_is_admin_file_slug( $submenu_slug ) ) && ( preg_match( '/\.php($|\?)/', $submenu_slug ) || wp_http_validate_url( $submenu_slug ) ) ) {
850 1254 $submenu_url = $submenu_slug;
851 1255 } elseif ( ! empty( menu_page_url( $submenu_slug, false ) ) ) {
852 1256 $submenu_url = menu_page_url( $submenu_slug, false );
853 1257 }