PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 0.9.7
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v0.9.7
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 0.9.7, at includes/render/assets.php

979 lines 45.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Desktop Mode — Asset enqueue.
4 *
5 * Loads the desktop shell CSS + JS bundles when desktop mode is
6 * active and the request isn't chromeless / classic-overridden.
7 * Owns the entire `desktop_mode_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 Desktop_Mode
15 * @since 0.8.1
16 */
17
18 defined( 'ABSPATH' ) || exit;
19
20 /**
21 * Enqueues the desktop mode shell assets (CSS + JS) when desktop mode is active.
22 *
23 * 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
27 */
28 function desktop_mode_enqueue_assets() {
29 if ( ! is_admin() ) {
30 return;
31 }
32
33 // Auto-enqueue the iframe bridge anywhere a desktop-mode user
34 // might land. The bundle self-bails when not inside an iframe
35 // (`window.parent === window`), so it's a no-op on the parent
36 // shell — but cheap insurance against the failure mode the
37 // 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
40 // auto-enqueue, the API is universally present for any same-
41 // origin admin page a desktop-mode user opens — chromeless or
42 // accidentally classic.
43 if ( desktop_mode_is_enabled() ) {
44 wp_enqueue_script( 'desktop-mode-iframe-bridge' );
45
46 // Block Editor cross-window drop receiver. Listens for
47 // `desktop-mode-drop` postMessages from the parent shell and
48 // inserts the matching block. Only enqueue inside the
49 // post-edit Block Editor screens — every other admin page
50 // would be paying for a bundle it never uses.
51 //
52 // `site-editor.php` (full-site editor) deliberately omitted:
53 // the FSE doesn't expose `wp.data.dispatch('core/block-editor')`
54 // until the user opens a template in the canvas iframe, so
55 // drops arriving before that point would silently time out
56 // after the receiver's 5 s `waitForEditor()` poll. Re-enable
57 // once we have a reliable readiness signal in that context.
58 global $hook_suffix;
59 if ( 'post.php' === $hook_suffix || 'post-new.php' === $hook_suffix ) {
60 wp_enqueue_script( 'desktop-mode-gutenberg-drop-receiver' );
61 }
62 }
63
64 // 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' );
68
69 /**
70 * Fires when chromeless styles are enqueued inside a desktop mode iframe.
71 *
72 * Plugin and theme authors can hook here to enqueue their own CSS
73 * 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
77 */
78 do_action( 'desktop_mode_chromeless_styles' );
79 return;
80 }
81
82 if ( ! desktop_mode_is_enabled() || desktop_mode_is_classic_request() ) {
83 return;
84 }
85
86 // CSS.
87 wp_enqueue_style( 'desktop-mode' );
88 wp_enqueue_style( 'desktop-mode-windows' );
89 wp_enqueue_style( 'desktop-mode-window-overview' );
90 wp_enqueue_style( 'desktop-mode-os-settings' );
91 wp_enqueue_style( 'desktop-mode-dock' );
92 wp_enqueue_style( 'desktop-mode-dock-peek' );
93 wp_enqueue_style( 'desktop-mode-ai-assistant' );
94 wp_enqueue_style( 'desktop-mode-bug-report' );
95 wp_enqueue_style( 'desktop-mode-files' );
96 wp_enqueue_style( 'desktop-mode-notes' );
97
98 // JS.
99 wp_enqueue_script( 'desktop-mode' );
100
101 // `wp_enqueue_command_palette_assets()` (WP 6.9+) enqueues the
102 // `wp-commands` store package, the `wp-core-commands` script that
103 // registers the WordPress-wide baseline (Add new post, Manage
104 // plugins, Switch theme, Browse patterns, …) AND — critically —
105 // the inline `wp.coreCommands.initializeCommandPalette( … )` call
106 // that actually populates the `core/commands` data store with the
107 // admin-menu commands. Without that inline init, the script loads
108 // but the store stays empty and `src/commands/shell-harvester.ts`
109 // finds nothing to publish.
110 //
111 // WP normally only calls this on screens that opt in to the native
112 // palette; the shell needs it on every admin URL it might wrap.
113 // `function_exists` guard for pre-6.9 sites — the harvester gracefully
114 // no-ops when the store is missing.
115 if ( function_exists( 'wp_enqueue_command_palette_assets' ) ) {
116 // `wp_enqueue_command_palette_assets()` calls
117 // `array_key_exists( $menu_slug, $submenu )` without guarding
118 // the global, so an unset `$submenu` (test contexts, edge-case
119 // admin requests where the menu wasn't built yet) blows up
120 // with a TypeError. Initialize defensively before calling.
121 global $menu, $submenu;
122 if ( ! isset( $submenu ) || ! is_array( $submenu ) ) {
123 $submenu = array();
124 }
125 if ( ! isset( $menu ) || ! is_array( $menu ) ) {
126 $menu = array();
127 }
128 wp_enqueue_command_palette_assets();
129
130 // Expose the same menu-commands array WP serializes into
131 // `wp.coreCommands.initializeCommandPalette(...)` on a window
132 // slot the shell harvester can read. Built in PHP from `$menu`
133 // / `$submenu` here (we already guarded that they're arrays
134 // above), then injected as a `before` inline on our own bundle
135 // — that runs synchronously before `desktop.min.js` boots the
136 // shell harvester, so the lookup is guaranteed populated by
137 // the time `src/commands/shell-harvester.ts` classifies any
138 // command. Decoupled from WP's command-palette mount timing
139 // (which fires from a core-registered hook we can't reorder).
140 $menu_map = desktop_mode_build_command_menu_map();
141 wp_add_inline_script(
142 'desktop-mode',
143 'window.__desktopModeMenuCommands = ' . wp_json_encode( $menu_map ) . ';',
144 'before'
145 );
146 }
147
148 // Pass configuration to JavaScript.
149 global $title, $pagenow, $parent_file, $menu;
150
151 $menu_icon = 'dashicons-admin-generic';
152 if ( ! empty( $parent_file ) && ! empty( $menu ) ) {
153 foreach ( $menu as $item ) {
154 if ( ! empty( $item[2] ) && $item[2] === $parent_file && ! empty( $item[6] ) ) {
155 $menu_icon = $item[6];
156 break;
157 }
158 }
159 }
160
161 // Build dock items from the admin menu. Core pages are ordered
162 // first (Dashboard, Posts, Plugins, Users, Settings, …), then
163 // plugin-contributed top-level routes. `desktop_mode_dock_placement`
164 // is the per-item filter escape hatch for hiding. Shared with the
165 // REST menu endpoint so live refreshes (post plugin-activation)
166 // produce the same ordering as the boot payload.
167 $menu_payload = desktop_mode_build_menu_payload();
168 $dock_items = $menu_payload['dockItems'];
169 $native_windows = isset( $menu_payload['nativeWindows'] )
170 ? $menu_payload['nativeWindows']
171 : array();
172 $server_widgets = isset( $menu_payload['serverWidgets'] )
173 ? $menu_payload['serverWidgets']
174 : array();
175 $server_wallpapers = isset( $menu_payload['serverWallpapers'] )
176 ? $menu_payload['serverWallpapers']
177 : array();
178 $server_command_scripts = isset( $menu_payload['serverCommandScripts'] )
179 ? $menu_payload['serverCommandScripts']
180 : array();
181 $server_commands = isset( $menu_payload['serverCommands'] )
182 ? $menu_payload['serverCommands']
183 : array();
184 $server_settings_tab_scripts = isset( $menu_payload['serverSettingsTabScripts'] )
185 ? $menu_payload['serverSettingsTabScripts']
186 : array();
187 $server_settings_tabs = isset( $menu_payload['serverSettingsTabs'] )
188 ? $menu_payload['serverSettingsTabs']
189 : array();
190 $server_dock_rail_renderer_scripts = isset( $menu_payload['serverDockRailRendererScripts'] )
191 ? $menu_payload['serverDockRailRendererScripts']
192 : array();
193 $server_titlebar_button_scripts = isset( $menu_payload['serverTitleBarButtonScripts'] )
194 ? $menu_payload['serverTitleBarButtonScripts']
195 : array();
196 $server_window_theme_scripts = isset( $menu_payload['serverWindowThemeScripts'] )
197 ? $menu_payload['serverWindowThemeScripts']
198 : array();
199 $server_window_themes = isset( $menu_payload['serverWindowThemes'] )
200 ? $menu_payload['serverWindowThemes']
201 : array();
202 $server_window_control_scripts = isset( $menu_payload['serverWindowControlScripts'] )
203 ? $menu_payload['serverWindowControlScripts']
204 : array();
205 $server_window_controls = isset( $menu_payload['serverWindowControls'] )
206 ? $menu_payload['serverWindowControls']
207 : array();
208 $server_window_slot_scripts = isset( $menu_payload['serverWindowSlotScripts'] )
209 ? $menu_payload['serverWindowSlotScripts']
210 : array();
211 $server_window_slots = isset( $menu_payload['serverWindowSlots'] )
212 ? $menu_payload['serverWindowSlots']
213 : array();
214 $server_window_chrome_scripts = isset( $menu_payload['serverWindowChromeScripts'] )
215 ? $menu_payload['serverWindowChromeScripts']
216 : array();
217 $server_window_chromes = isset( $menu_payload['serverWindowChromes'] )
218 ? $menu_payload['serverWindowChromes']
219 : array();
220 $server_window_notices = isset( $menu_payload['serverWindowNotices'] )
221 ? $menu_payload['serverWindowNotices']
222 : array();
223 $server_games = isset( $menu_payload['serverGames'] )
224 ? $menu_payload['serverGames']
225 : array();
226 $desktop_icons = isset( $menu_payload['desktopIcons'] )
227 ? $menu_payload['desktopIcons']
228 : array();
229
230 // Files-on-the-Desktop payload (Phase 0+1). Plugin-registered
231 // file types and openers ship as metadata only; the JS side
232 // holds the executable handlers and resolves on double-click.
233 $server_file_types = function_exists( 'desktop_mode_build_file_types_payload' )
234 ? desktop_mode_build_file_types_payload()
235 : array();
236 $server_file_openers = function_exists( 'desktop_mode_build_file_openers_payload' )
237 ? desktop_mode_build_file_openers_payload()
238 : array();
239 $user_file_associations = function_exists( 'desktop_mode_get_user_file_associations' )
240 ? desktop_mode_get_user_file_associations( get_current_user_id() )
241 : array();
242 $server_wallpaper_menu_items = function_exists( 'desktop_mode_build_wallpaper_menu_items' )
243 ? desktop_mode_build_wallpaper_menu_items()
244 : array();
245
246 /*
247 * OS-file drop config — what the browser drop manager will
248 * accept when the user drags a file from their native desktop
249 * onto any surface inside Desktop Mode (wallpaper, a folder,
250 * a window, or a chromeless iframe). The allowed-mimes list is
251 * the user-scoped `get_allowed_mime_types()` (already capability
252 * gated by WordPress); the size cap is `wp_max_upload_size()`.
253 *
254 * Both are filterable so plugins can narrow or widen the set —
255 * e.g. a media-only plugin can restrict drops to images, or a
256 * docs plugin can opt PDFs in for a specific role.
257 */
258 $drop_allowed_mimes_map = current_user_can( 'upload_files' )
259 ? get_allowed_mime_types( get_current_user_id() )
260 : array();
261 /**
262 * Filter the allowed-mime map used by the OS-file drop manager.
263 *
264 * @since 0.30.0
265 *
266 * @param array<string,string> $mimes_map `ext => mime-type` map (same shape `get_allowed_mime_types()` returns).
267 * @param int $user_id The current user id.
268 */
269 $drop_allowed_mimes_map = apply_filters( 'desktop_mode_drop_allowed_mimes', $drop_allowed_mimes_map, get_current_user_id() );
270 $drop_allowed_mimes_map = is_array( $drop_allowed_mimes_map ) ? $drop_allowed_mimes_map : array();
271 $drop_allowed_mimes = array_values( array_unique( array_values( $drop_allowed_mimes_map ) ) );
272
273 $drop_max_size = (int) wp_max_upload_size();
274 /**
275 * Filter the per-file size cap (in bytes) used by the OS-file
276 * drop manager. Returning `0` disables the client-side cap —
277 * the server still enforces its own.
278 *
279 * @since 0.30.0
280 *
281 * @param int $max_size Default `wp_max_upload_size()`.
282 * @param int $user_id The current user id.
283 */
284 $drop_max_size = (int) apply_filters( 'desktop_mode_drop_max_size', $drop_max_size, get_current_user_id() );
285
286 /**
287 * Filter the master OS-file drop enable gate. Lets plugins
288 * disable the drop manager by role / capability beyond the
289 * default `upload_files` check (e.g. only for admins, or
290 * only on specific multisite blogs).
291 *
292 * @since 0.30.0
293 *
294 * @param bool $enabled Default — `current_user_can( 'upload_files' )`.
295 * @param int $user_id The current user id.
296 */
297 $drop_enabled = (bool) apply_filters(
298 'desktop_mode_drop_enabled',
299 current_user_can( 'upload_files' ),
300 get_current_user_id()
301 );
302
303 $drop_config = array(
304 'enabled' => $drop_enabled,
305 'allowedMimes' => $drop_allowed_mimes,
306 'extToMime' => $drop_allowed_mimes_map,
307 'maxSize' => $drop_max_size,
308 );
309
310 // Lazy-bundle URL builder. Each lazy-loaded bundle (AI Assistant,
311 // About-scene, OS Settings panel, shell-overlays, window-system)
312 // is `<script>`-injected by the main bundle on demand — they don't
313 // go through `wp_register_script`, so they don't pick up WordPress's
314 // usual `?ver=<filemtime>` cache-buster. Without one, the browser
315 // happily serves a stale cached copy across plugin updates that
316 // don't bump `DESKTOP_MODE_VERSION`, and the main bundle's loader
317 // fires a `<script>`-loaded event for a file that's missing the
318 // fresh `window.desktopMode*` factory the new code expects.
319 //
320 // Mirror the `$built_version( … )` helper in `includes/assets.php`:
321 // prefer the on-disk mtime of the actual file, fall back to the
322 // plugin version when the file is missing (dev environments where
323 // the bundle hasn't been built yet).
324 $suffix = desktop_mode_asset_suffix();
325 $lazy_bundle_url = static function ( $base ) use ( $suffix ) {
326 $path = DESKTOP_MODE_DIR . 'assets/js/' . $base . $suffix . '.js';
327 $ver = file_exists( $path )
328 ? (string) filemtime( $path )
329 : DESKTOP_MODE_VERSION;
330 return esc_url_raw(
331 DESKTOP_MODE_URL . 'assets/js/' . $base . $suffix . '.js?ver=' . $ver
332 );
333 };
334
335 // Build the current page URL from $pagenow + $_GET. Strip the portal
336 // markers so the derived window ID matches what the dock would produce
337 // for the same page — otherwise auto-opening the entry window and
338 // clicking the same dock icon would create a duplicate.
339 $current_query = $_GET; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
340 unset( $current_query[ DESKTOP_MODE_PORTAL_FLAG ], $current_query[ DESKTOP_MODE_PORTAL_INTENT_FLAG ] );
341 $current_page = admin_url( $pagenow ) . ( ! empty( $current_query ) ? '?' . http_build_query( $current_query ) : '' );
342
343 $from_portal = ! empty( $_GET[ DESKTOP_MODE_PORTAL_FLAG ] ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
344 $from_portal_intent = ! empty( $_GET[ DESKTOP_MODE_PORTAL_INTENT_FLAG ] ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
345
346 /**
347 * Filters the desktop shell configuration passed to JavaScript.
348 *
349 * @since 0.1.0
350 *
351 * @param array $config {
352 * Desktop shell configuration.
353 *
354 * @type string $currentPage The current admin page URL.
355 * @type string $currentTitle The current page title.
356 * @type string $currentIcon Dashicon class for the current page.
357 * @type string $adminUrl The base admin URL.
358 * @type string $colorScheme The active admin color scheme.
359 * @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.
360 * @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.
361 * @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.
362 * @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.
363 * @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.
364 * @type array $serverCommands Server-declared command metadata (via `desktop_mode_register_command`). Advisory today — reserved for future pre-registration shims.
365 * @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.
366 * @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`.
367 * @type array $desktopIcons Server-declared desktop icons (via `desktop_mode_register_icon`). Rendered on the wallpaper as clickable shortcut tiles.
368 * @type array $accentColors Swatch list for the OS Settings accent picker. Filterable via `desktop_mode_accent_colors`.
369 * @type array $toastTypes Toast-notification type map. Filterable via `desktop_mode_toast_types`.
370 * @type string $defaultWallpaper Wallpaper slug applied on first boot. Filterable via `desktop_mode_default_wallpaper`.
371 * @type array $session Saved session (windows, focused, updated).
372 * @type string $sessionUrl REST endpoint for saving the session.
373 * @type string $mediaUrl REST endpoint for media uploads (wp/v2/media).
374 * @type string $restUrl REST API root from rest_url(), safe for pretty and plain permalink installs.
375 * @type string $defaultWindowUrl REST endpoint for saving the default-window preference.
376 * @type array $defaultWindow { enabled: bool, url: string } — current default-window preference.
377 * @type bool $canUpload Whether the user holds the `upload_files` capability.
378 * @type string $pluginUrl Plugin base URL (no trailing slash). Used by the shell to locate vendor assets and by plugins to build asset URLs.
379 * @type string $pluginVersion Plugin semver string. Surfaced in the OS Settings → About tab; plugins can read it to gate features by version.
380 * @type string $restNonce Nonce for the session REST endpoint.
381 * @type string $portalUrl Canonical `/desktop-mode/` URL.
382 * @type bool $fromPortal Whether the shell was reached via the portal.
383 * @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.
384 * @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.
385 * @type string $seenIntrosUrl REST endpoint for the seen-intros surface — POST `/seen` to mark, DELETE the base to reset.
386 * @type array $stickyNotes { available: bool } — whether the Gutenberg Guidelines experiment (the `wp_guideline` CPT + `wp_guideline_type` taxonomy) is registered. The shell skips booting the sticky-notes layer when false, avoiding the REST probes that 404 without the experiment. See `desktop_mode_sticky_notes_is_available()`.
387 * }
388 */
389 $config = apply_filters(
390 'desktop_mode_shell_config',
391 array(
392 'currentPage' => esc_url( $current_page ),
393 'currentTitle' => wp_strip_all_tags( $title ),
394 'currentIcon' => sanitize_html_class( $menu_icon ),
395 'adminUrl' => esc_url( admin_url() ),
396 'colorScheme' => sanitize_html_class( get_user_option( 'admin_color' ), 'fresh' ),
397 'dockItems' => $dock_items,
398 // Baseline menu fingerprint. The shell seeds its last-known
399 // signature from this so the first off-allowlist menu change
400 // (vs. this boot state) is caught without a wasted probe. GH#325.
401 'menuSig' => isset( $menu_payload['menuSig'] ) ? (string) $menu_payload['menuSig'] : '',
402 'nativeWindows' => $native_windows,
403 'serverWidgets' => $server_widgets,
404 'serverWallpapers' => $server_wallpapers,
405 'serverCommandScripts' => $server_command_scripts,
406 'serverCommands' => $server_commands,
407 'serverSettingsTabScripts' => $server_settings_tab_scripts,
408 'serverSettingsTabs' => $server_settings_tabs,
409 'serverDockRailRendererScripts' => $server_dock_rail_renderer_scripts,
410 'serverTitleBarButtonScripts' => $server_titlebar_button_scripts,
411 'serverWindowThemeScripts' => $server_window_theme_scripts,
412 'serverWindowThemes' => $server_window_themes,
413 'serverWindowControlScripts' => $server_window_control_scripts,
414 'serverWindowControls' => $server_window_controls,
415 'serverWindowSlotScripts' => $server_window_slot_scripts,
416 'serverWindowSlots' => $server_window_slots,
417 'serverWindowChromeScripts' => $server_window_chrome_scripts,
418 'serverWindowChromes' => $server_window_chromes,
419 'serverWindowNotices' => $server_window_notices,
420 // Boot-time copy of the payload's `serverGames` — the same
421 // list the live-refresh path applies. Without it the games
422 // registry only fills after the first chromeless
423 // full-payload refresh and the Games hub boots empty.
424 'serverGames' => $server_games,
425 'desktopIcons' => $desktop_icons,
426 'serverFileTypes' => $server_file_types,
427 'serverFileOpeners' => $server_file_openers,
428 'userFileAssociations' => $user_file_associations,
429 'filesUrl' => esc_url_raw( rest_url( 'desktop-mode/v1/files' ) ),
430 // Pinned-notes REST base (`includes/notes/rest.php`). The
431 // notes layer boots only when this is present.
432 'notesUrl' => esc_url_raw( rest_url( 'desktop-mode/v1/notes' ) ),
433 // Gates the "Convert to post" note affordance — the convert
434 // route (and its dock drop target) only make sense for users
435 // who can author posts.
436 'canCreatePosts' => current_user_can( 'edit_posts' ),
437 'serverWallpaperMenuItems' => $server_wallpaper_menu_items,
438 'accentColors' => desktop_mode_get_accent_colors(),
439 'toastTypes' => desktop_mode_get_toast_types(),
440 'coreUpdate' => desktop_mode_get_core_update(),
441 'coreNotices' => desktop_mode_get_core_notices(),
442 'pluginNotices' => desktop_mode_get_plugin_notices(),
443 'defaultWallpaper' => desktop_mode_get_default_wallpaper(),
444 'session' => desktop_mode_get_session( get_current_user_id() ),
445 'sessionUrl' => esc_url_raw( rest_url( 'desktop-mode/v1/session' ) ),
446 'restUrl' => esc_url_raw( rest_url() ),
447 'mediaUrl' => esc_url_raw( rest_url( 'wp/v2/media' ) ),
448 'dropConfig' => $drop_config,
449 'defaultWindowUrl' => esc_url_raw( rest_url( 'desktop-mode/v1/default-window' ) ),
450 'defaultWindow' => desktop_mode_get_default_window( get_current_user_id() ),
451 'canUpload' => current_user_can( 'upload_files' ),
452 'pluginUrl' => esc_url_raw( untrailingslashit( DESKTOP_MODE_URL ) ),
453 'pluginVersion' => DESKTOP_MODE_VERSION,
454 'iframeBridgeUrl' => $lazy_bundle_url( 'iframe-bridge' ),
455 // URL of the AI Assistant lazy bundle. The main bundle
456 // ships a stub matching the public `wp.desktop.ai` API; the
457 // stub `<script>`-injects this URL the first time the user
458 // opens the assistant. Picking `.js` vs `.min.js` here keeps
459 // the SCRIPT_DEBUG gate server-side, matching iframeBridgeUrl.
460 'aiAssistantBundleUrl' => $lazy_bundle_url( 'ai-assistant' ),
461 // URL of the About-scene lazy bundle. The OS Settings →
462 // About tab loads this on first mount; ~25 kB PixiJS
463 // particle scene that would otherwise ship in the main
464 // bundle for every shell load.
465 'aboutSceneBundleUrl' => $lazy_bundle_url( 'about-scene' ),
466 // URL of the OS Settings panel lazy bundle. Injected by
467 // the main bundle's `OsSettings.renderPanel()` stub on
468 // the user's first Settings open. Holds every section
469 // renderer + the `<wpd-*>` components only the panel
470 // uses, so nothing about Settings ships in
471 // `desktop.min.js` for users who never open it.
472 'osSettingsPanelBundleUrl' => $lazy_bundle_url( 'os-settings-panel' ),
473 // URL of the shell-overlays lazy bundle. Pre-loaded by
474 // the main bundle after first paint so action-triggered
475 // overlays (toast, confirm dialog, context menus) feel
476 // instant the first time they fire.
477 'shellOverlaysBundleUrl' => $lazy_bundle_url( 'shell-overlays' ),
478 // URL of the lazy window-system bundle (Stage 11).
479 // Holds the `Window` class and its DOM / pointer / tab /
480 // chrome helpers — the single largest module in the pre-
481 // 0.8.4 main bundle. Loaded on first `windowManager.open()`
482 // / `openNew()` call (both async since 0.8.4); pre-loaded
483 // by the shell after first paint when no session is being
484 // restored and no `openCurrentPage` will fire.
485 'windowSystemBundleUrl' => $lazy_bundle_url( 'window-system' ),
486 // URL of the item-visibility-menu lazy bundle — the
487 // right-click "hide from dock / desktop" menu. Injected by
488 // the main bundle's loader shim on the first right-click.
489 'itemVisibilityMenuBundleUrl' => $lazy_bundle_url( 'item-visibility-menu' ),
490 // URL of the release-card lazy bundle — the vinyl core-
491 // update announcement. Injected by `maybeShowUpdate()` only
492 // when a core update is actually pending.
493 'releaseCardBundleUrl' => $lazy_bundle_url( 'release-card' ),
494 'restNonce' => wp_create_nonce( 'wp_rest' ),
495 'osSettings' => desktop_mode_get_os_settings( get_current_user_id() ),
496 'osSettingsUrl' => esc_url_raw( rest_url( 'desktop-mode/v1/os-settings' ) ),
497 'seenIntros' => desktop_mode_get_seen_intros( get_current_user_id() ),
498 'seenIntrosUrl' => esc_url_raw( rest_url( 'desktop-mode/v1/intros' ) ),
499 // Sticky notes ride on Gutenberg's Guidelines experiment
500 // (the `wp_guideline` CPT + `wp_guideline_type` taxonomy).
501 // When that experiment isn't active the `wp/v2/guidelines`
502 // + `wp/v2/wp_guideline_type` probes 404 — harmless but
503 // noisy — so the shell skips booting the layer entirely.
504 'stickyNotes' => array(
505 'available' => desktop_mode_sticky_notes_is_available(),
506 ),
507 'aiSearchUrl' => esc_url_raw( rest_url( 'desktop-mode/v1/ai/search' ) ),
508 'aiSearchStreamUrl' => esc_url_raw( add_query_arg( 'action', 'desktop_mode_ai_search_stream', admin_url( 'admin-ajax.php' ) ) ),
509 // AI assistant availability + per-user toggle. Drives whether the
510 // Cmd+K palette and admin-bar icon appear, and the setup placeholder.
511 'aiAssistant' => function_exists( 'desktop_mode_ai_assistant_config' )
512 ? desktop_mode_ai_assistant_config()
513 : null,
514 // Lets the Features tab re-check provider availability without a
515 // reload after a connector is configured in Settings → Connectors.
516 'aiStatusUrl' => esc_url_raw( rest_url( 'desktop-mode/v1/ai/status' ) ),
517 'extendedOptions' => current_user_can( 'manage_options' ) ? desktop_mode_get_extended_options() : null,
518 'extendedOptionsUrl' => esc_url_raw( rest_url( 'desktop-mode/v1/extended-options' ) ),
519 // Site-wide games kill switch (Extended options). Exposed to
520 // every user — the shell skips the challenges Heartbeat
521 // channel when the framework is off.
522 'gamesEnabled' => desktop_mode_games_enabled(),
523 // Comments-window AI moderation toggle — surfaced at the
524 // shell level so the OS Settings → Features tab can render
525 // the toggle without depending on the Comments window
526 // being registered for this user. URL is the same
527 // endpoint the comments-window config exposes; state is
528 // `null` for non-admins (the UI hides the row entirely).
529 'commentsAiUrl' => esc_url_raw( rest_url( 'desktop-mode/v1/comments/ai-settings' ) ),
530 // Non-null only for admins on a site where the Core AI stack is
531 // present. Comment scoring routes through the AI Client (WP 7.0+),
532 // so on older WordPress the whole row is hidden — same as the
533 // assistant toggle — rather than shown disabled pointing at a
534 // Settings → Connectors screen that doesn't exist there.
535 'commentsAi' => (
536 current_user_can( 'manage_options' )
537 && function_exists( 'desktop_mode_ai_is_available' )
538 && desktop_mode_ai_is_available()
539 )
540 ? array(
541 'enabled' => function_exists( 'desktop_mode_comments_ai_is_enabled' )
542 ? desktop_mode_comments_ai_is_enabled()
543 : false,
544 'providerConfigured' => function_exists( 'desktop_mode_comments_ai_provider_configured' )
545 ? desktop_mode_comments_ai_provider_configured()
546 : false,
547 )
548 : null,
549 'currentUserIsAdmin' => current_user_can( 'manage_options' ),
550 'portalUrl' => esc_url( desktop_mode_portal_url() ),
551 'fromPortal' => $from_portal,
552 'fromPortalIntent' => $from_portal_intent,
553 'pwa' => array(
554 'manifestUrl' => esc_url_raw( desktop_mode_pwa_manifest_url() ),
555 'swUrl' => esc_url_raw( desktop_mode_pwa_sw_url() ),
556 'stateUrl' => esc_url_raw( rest_url( 'desktop-mode/v1/pwa-state' ) ),
557 'state' => desktop_mode_pwa_get_user_state( get_current_user_id() ),
558 // Mirrors the manifest's `name` field — used by the
559 // install pill so the button reads "Install <site>"
560 // rather than "Install <current page>" (which would
561 // be misleading: we install the whole site as an
562 // app, not the dashboard window the user happens to
563 // be viewing).
564 'appName' => get_bloginfo( 'name' ),
565 // Operators set the `desktop_mode_pwa_force_replace_sw`
566 // filter to `true` when another root-scope service
567 // worker on the origin is blocking desktop-mode
568 // installability (foreign-SW guard in
569 // `src/pwa/sw-register.ts`). Default `false` preserves
570 // the polite behaviour where we yield to existing PWAs.
571 'forceReplaceSw' => desktop_mode_pwa_force_replace_sw(),
572 ),
573 )
574 );
575
576 wp_localize_script( 'desktop-mode', 'desktopModeConfig', $config );
577
578 /**
579 * Fires when desktop mode assets are enqueued.
580 *
581 * @since 0.1.0
582 */
583 do_action( 'desktop_mode_mode_init' );
584 }
585 add_action( 'admin_enqueue_scripts', 'desktop_mode_enqueue_assets' );
586
587 /**
588 * Emits `<link rel="preload">` hints for the shell's critical-path
589 * assets so the browser starts fetching them as soon as it parses
590 * the document `<head>`.
591 *
592 * Without this, the browser doesn't discover the main `desktop.min.js`
593 * bundle URL until it parses the footer `<script>` tag — typically
594 * ~1 RTT after the rest of the page has started loading. For a 464 KB
595 * bundle on a midrange phone that's a measurable FCP delay; on a
596 * slow connection it dominates first paint entirely.
597 *
598 * Hooked at `admin_print_styles @ 1` so the preload tags land in
599 * `<head>` BEFORE the regular `<link rel="stylesheet">` tags (which
600 * default to priority 10) and well before the footer `<script>`
601 * tag. The `wp_resource_hints` filter is frontend-only (`wp_head`-
602 * driven) and isn't invoked in admin context, so we emit our own
603 * tags.
604 *
605 * Four targets by default, split across two relationship types:
606 * - `desktop[.min].js` (preload) — the shell bundle (biggest win),
607 * consumed by the footer `<script>` on this very load.
608 * - `desktop.css` (preload) — shell base CSS, needed for first
609 * paint. Its registered handle is `filemtime`-stamped so the
610 * stylesheet URL matches this hint exactly (a `?ver=` mismatch makes
611 * the browser treat the preload as unused).
612 * - `window-system[.min].js` (prefetch) — lazy bundle `<script>`-
613 * injected by the main bundle on the first `open()`.
614 * - `shell-overlays[.min].js` (prefetch) — lazy bundle injected on the
615 * first toast / dialog / context-menu.
616 *
617 * The lazy bundles use `prefetch` rather than `preload`: they're loaded
618 * later (often beyond the ~3s window Chrome allows a `preload` before it
619 * warns "preloaded but not used in time"), so `prefetch` keeps the early
620 * low-priority cache fill without the must-use-now contract.
621 *
622 * Plugins can extend the hint list via the `desktop_mode_preload_hints`
623 * filter — e.g. a settings tab whose bundle the user opens on every
624 * visit can opt its own URL into the preload phase.
625 *
626 * Same-origin resources only — no `crossorigin` attribute. CDN hosts
627 * that serve `wp-content/plugins/` from a different origin should
628 * supply absolute URLs through the filter; in that case the consumer
629 * is responsible for the `crossorigin` semantics.
630 *
631 * @since 0.8.9
632 */
633 function desktop_mode_print_preload_hints() {
634 if (
635 ! is_admin()
636 || ! desktop_mode_is_enabled()
637 || desktop_mode_is_chromeless_request()
638 || desktop_mode_is_classic_request()
639 ) {
640 return;
641 }
642
643 $suffix = desktop_mode_asset_suffix();
644
645 $build_url = static function ( $relative ) {
646 $path = DESKTOP_MODE_DIR . $relative;
647 $ver = file_exists( $path ) ? (string) filemtime( $path ) : DESKTOP_MODE_VERSION;
648 return DESKTOP_MODE_URL . $relative . '?ver=' . $ver;
649 };
650
651 $hints = array(
652 // Critical path — consumed on this very page load (the footer
653 // `<script>` and the shell stylesheet), so `preload` is correct.
654 array(
655 'href' => $build_url( 'assets/js/desktop' . $suffix . '.js' ),
656 'as' => 'script',
657 'rel' => 'preload',
658 ),
659 array(
660 'href' => $build_url( 'assets/css/desktop.css' ),
661 'as' => 'style',
662 'rel' => 'preload',
663 ),
664 // Lazy bundles — `<script>`-injected by the main bundle after
665 // first paint (window-system on the first `open()`, shell-overlays
666 // on the first toast / dialog / context-menu). They are frequently
667 // NOT requested within the ~3s window Chrome allows a `preload`,
668 // which produced "resource was preloaded but not used in time"
669 // warnings. `prefetch` is the right hint: same early, low-priority
670 // fetch into the cache, but no must-use-now contract — so the
671 // injected `<script src>` is served from cache with no warning.
672 array(
673 'href' => $build_url( 'assets/js/window-system' . $suffix . '.js' ),
674 'as' => 'script',
675 'rel' => 'prefetch',
676 ),
677 array(
678 'href' => $build_url( 'assets/js/shell-overlays' . $suffix . '.js' ),
679 'as' => 'script',
680 'rel' => 'prefetch',
681 ),
682 );
683
684 /**
685 * Filters the list of resource preload hints emitted in `<head>`.
686 *
687 * Each entry is a `{ 'href' => string, 'as' => string,
688 * 'rel' => 'preload'|'prefetch' }` array rendered as
689 * `<link rel="<rel>" as="<as>" href="<href>">`. `rel` is optional and
690 * defaults to `preload`; any value other than `prefetch` is coerced
691 * back to `preload`. Unrecognized entries are silently skipped — keep
692 * the contract permissive so a misconfigured plugin can't tank first
693 * paint.
694 *
695 * @since 0.8.9
696 * @since 0.9.1 Entries may carry a `rel` key (`preload` | `prefetch`).
697 *
698 * @param array $hints Default hints (main bundle + base CSS as
699 * `preload`; window-system + shell-overlays as
700 * `prefetch`).
701 */
702 $hints = apply_filters( 'desktop_mode_preload_hints', $hints );
703
704 if ( ! is_array( $hints ) ) {
705 return;
706 }
707
708 foreach ( $hints as $hint ) {
709 if ( ! is_array( $hint ) ) {
710 continue;
711 }
712 $href = isset( $hint['href'] ) ? (string) $hint['href'] : '';
713 $as = isset( $hint['as'] ) ? (string) $hint['as'] : '';
714 if ( '' === $href || '' === $as ) {
715 continue;
716 }
717 // `preload` (critical, used on this load) vs `prefetch` (lazy,
718 // used on a later interaction). Anything else falls back to
719 // `preload` so a typo can't emit an invalid relationship.
720 $rel = isset( $hint['rel'] ) ? (string) $hint['rel'] : 'preload';
721 if ( 'prefetch' !== $rel ) {
722 $rel = 'preload';
723 }
724 printf(
725 '<link rel="%s" as="%s" href="%s" />' . "\n",
726 esc_attr( $rel ),
727 esc_attr( $as ),
728 esc_url( $href )
729 );
730 }
731 }
732 add_action( 'admin_print_styles', 'desktop_mode_print_preload_hints', 1 );
733
734 /**
735 * Defers loading of non-critical desktop-mode stylesheets so they
736 * don't block first paint.
737 *
738 * Three stylesheets in the default enqueue list are only needed
739 * after a user interaction — `dock-peek` (mouseover a dock tile),
740 * `ai-assistant` (Cmd+K palette), `bug-report` (Report-a-bug
741 * window). With the normal `<link rel="stylesheet">` tag they sit
742 * on the critical path and the browser blocks first paint waiting
743 * for them, even though nothing on screen needs them yet.
744 *
745 * The well-known mitigation is the `media="print" onload="…"`
746 * pattern:
747 *
748 * <link rel="stylesheet" media="print"
749 * onload="this.media='all'; this.onload=null" href="…">
750 * <noscript><link rel="stylesheet" href="…"></noscript>
751 *
752 * `media="print"` makes the browser treat the sheet as
753 * non-applicable to the current display, so it downloads with
754 * low priority and doesn't block render. The `onload` handler
755 * swaps `media` to the original value once the bytes arrive
756 * (within ms of page load), making the styles take effect long
757 * before the user clicks anything that needs them. The
758 * `<noscript>` fallback restores critical-path behavior for JS-off
759 * browsers, so accessibility isn't degraded.
760 *
761 * Filterable via `desktop_mode_deferred_styles` so plugins can opt
762 * their own non-critical stylesheets in (or pull a built-in out).
763 * Chromeless iframes are skipped — their CSS pipeline is separate.
764 *
765 * @since 0.8.9
766 *
767 * @param string $html The original <link> tag HTML.
768 * @param string $handle The stylesheet handle WP is printing.
769 * @param string $href The full URL of the stylesheet.
770 * @param string $media The media attribute value WP resolved.
771 * @return string Possibly-rewritten tag.
772 */
773 function desktop_mode_defer_non_critical_styles( $html, $handle, $href, $media ) {
774 // Cheap gates first — `style_loader_tag` fires once per enqueued
775 // stylesheet on EVERY admin page (frontend doesn't go through
776 // this filter, but admin does, including pages where desktop mode
777 // is disabled). The deferred handles only ship when desktop mode
778 // is active, so the in_array check below would always miss on
779 // classic-only admin pages — but the `apply_filters` call still
780 // builds an array and walks subscribers per stylesheet. Short-
781 // circuit on the cheap helper checks (`is_admin` / enabled /
782 // chromeless) so non-desktop-mode users pay nothing.
783 if ( ! desktop_mode_is_enabled() ) {
784 return $html;
785 }
786 if ( desktop_mode_is_chromeless_request() ) {
787 return $html;
788 }
789
790 /**
791 * Filters the list of stylesheet handles that should be loaded
792 * deferred via the media-print-onload pattern. Plugins can add
793 * their own non-critical stylesheets here, or pull a built-in
794 * out (e.g. a plugin that surfaces the AI assistant on every
795 * page might want to keep its CSS critical-path).
796 *
797 * @since 0.8.9
798 *
799 * @param string[] $handles Default deferred handles.
800 */
801 $deferred = apply_filters(
802 'desktop_mode_deferred_styles',
803 array(
804 'desktop-mode-dock-peek',
805 'desktop-mode-ai-assistant',
806 'desktop-mode-bug-report',
807 'desktop-mode-window-overview',
808 'desktop-mode-os-settings',
809 )
810 );
811
812 if ( ! in_array( $handle, (array) $deferred, true ) ) {
813 return $html;
814 }
815
816 $resolved_media = $media ? $media : 'all';
817 $id = $handle . '-css';
818
819 // Two contexts, two escapers for the same `$resolved_media` value:
820 //
821 // - `%3$s` lands inside a JS string literal inside the HTML
822 // `onload="…"` attribute (`this.media='%3$s'`). `esc_attr`
823 // escapes `"` and `&` but NOT single quotes, so a media
824 // value containing `'` would break out of the JS string.
825 // `esc_js` is the correct escaper for "string literal inside
826 // an event-handler attribute" — escapes single quotes, double
827 // quotes, backslashes, newlines. Today `$resolved_media`
828 // comes from `wp_enqueue_style()`'s `$media` parameter (always
829 // a CSS media type / query produced by WordPress core), so
830 // this is pure defense-in-depth, but the cost is one extra
831 // function call.
832 //
833 // - `%4$s` lands inside an HTML attribute in the `<noscript>`
834 // fallback (`media='%4$s'`). That's standard `esc_attr`.
835 //
836 // 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.
837 $markup = sprintf(
838 '<link rel=\'stylesheet\' id=\'%1$s\' href=\'%2$s\' media=\'print\' onload="this.media=\'%3$s\'; this.onload=null;" />' . "\n" .
839 '<noscript><link rel=\'stylesheet\' id=\'%1$s-noscript\' href=\'%2$s\' media=\'%4$s\' /></noscript>' . "\n",
840 esc_attr( $id ),
841 esc_url( $href ),
842 esc_js( $resolved_media ),
843 esc_attr( $resolved_media )
844 );
845 // phpcs:enable WordPress.WP.EnqueuedResources.NonEnqueuedStylesheet
846
847 return $markup;
848 }
849 add_filter( 'style_loader_tag', 'desktop_mode_defer_non_critical_styles', 10, 4 );
850
851 /**
852 * Build the admin-menu command map (name → URL) and expose it on
853 * `window.__desktopModeMenuCommands`. The shell command harvester
854 * (`src/commands/shell-harvester.ts`) reads this slot to resolve URLs
855 * for "Go to: …" commands whose JS callbacks
856 * (`document.location = menuCommand.url`) close over a variable URL
857 * we can't extract from source. Without this map those commands
858 * either get skipped (no URL recoverable) or — if the location
859 * shadow misses — navigate the SHELL out of desktop mode.
860 *
861 * Mirrors what WordPress core's `wp_enqueue_command_palette_assets()`
862 * builds for `wp.coreCommands.initializeCommandPalette(...)`. We
863 * duplicate the logic here (instead of monkey-patching the JS init
864 * which is timing-sensitive — WP registers its hook during core load,
865 * so it always emits its inline before any plugin-added inline on the
866 * same handle) and ship the result through `wp_add_inline_script` on
867 * our own bundle handle. That decouples us entirely from WP's command-
868 * palette mount timing.
869 *
870 * @since 0.8.4
871 *
872 * @global array $menu
873 * @global array $submenu
874 * @return array<int, array{label:string, url:string, name:string}>
875 */
876 function desktop_mode_build_command_menu_map() {
877 global $menu, $submenu, $_parent_pages;
878 if ( ! is_array( $menu ) ) {
879 return array();
880 }
881 $out = array();
882
883 $extract_root_text = static function ( $label ) {
884 if ( '' === $label || ! is_string( $label ) ) {
885 return '';
886 }
887 if ( class_exists( 'WP_HTML_Tag_Processor' ) ) {
888 $processor = new WP_HTML_Tag_Processor( $label );
889 $text = '';
890 $depth = 0;
891 while ( $processor->next_token() ) {
892 $token_type = $processor->get_token_type();
893 if ( '#text' === $token_type && 0 === $depth ) {
894 $text .= $processor->get_modifiable_text();
895 }
896 if ( '#tag' === $token_type ) {
897 if ( $processor->is_tag_closer() ) {
898 if ( $depth > 0 ) {
899 --$depth;
900 }
901 continue;
902 }
903 $name = $processor->get_tag();
904 if ( $name && ! ( class_exists( 'WP_HTML_Processor' ) && WP_HTML_Processor::is_void( $name ) ) ) {
905 ++$depth;
906 }
907 }
908 }
909 return trim( $text );
910 }
911 return trim( wp_strip_all_tags( $label ) );
912 };
913
914 foreach ( $menu as $menu_item ) {
915 if ( empty( $menu_item[0] ) || ! is_string( $menu_item[0] ) ) {
916 continue;
917 }
918 if ( ! empty( $menu_item[1] ) && ! current_user_can( $menu_item[1] ) ) {
919 continue;
920 }
921 $menu_label = $extract_root_text( $menu_item[0] );
922 $menu_slug = $menu_item[2];
923 $menu_url = '';
924 // Registered plugin pages win over the direct-file test: a
925 // legacy file-path slug ('wp-sweep/admin.php') matches the
926 // `.php` regex yet must route through menu_page_url(). The
927 // exception is URL-style slugs referencing a real admin file
928 // (ACF's 'edit.php?post_type=acf-field-group' — also a
929 // registered page) — those stay direct links, matching
930 // classic admin's menu-header.php.
931 if ( ( ! isset( $_parent_pages[ $menu_slug ] ) || desktop_mode_is_admin_file_slug( $menu_slug ) ) && ( preg_match( '/\.php($|\?)/', $menu_slug ) || wp_http_validate_url( $menu_slug ) ) ) {
932 $menu_url = $menu_slug;
933 } elseif ( ! empty( menu_page_url( $menu_slug, false ) ) ) {
934 $menu_url = menu_page_url( $menu_slug, false );
935 }
936 if ( '' !== $menu_url ) {
937 $out[] = array(
938 'label' => $menu_label,
939 'url' => $menu_url,
940 'name' => $menu_slug,
941 );
942 }
943 if ( ! empty( $submenu ) && is_array( $submenu ) && array_key_exists( $menu_slug, $submenu ) ) {
944 foreach ( $submenu[ $menu_slug ] as $submenu_item ) {
945 if ( empty( $submenu_item[0] ) ) {
946 continue;
947 }
948 if ( ! empty( $submenu_item[1] ) && ! current_user_can( $submenu_item[1] ) ) {
949 continue;
950 }
951 $submenu_label = $extract_root_text( $submenu_item[0] );
952 $submenu_slug = $submenu_item[2];
953 $submenu_url = '';
954 // Same registered-page vs admin-file rule as the
955 // top-level loop.
956 if ( ( ! isset( $_parent_pages[ $submenu_slug ] ) || desktop_mode_is_admin_file_slug( $submenu_slug ) ) && ( preg_match( '/\.php($|\?)/', $submenu_slug ) || wp_http_validate_url( $submenu_slug ) ) ) {
957 $submenu_url = $submenu_slug;
958 } elseif ( ! empty( menu_page_url( $submenu_slug, false ) ) ) {
959 $submenu_url = menu_page_url( $submenu_slug, false );
960 }
961 if ( '' === $submenu_url ) {
962 continue;
963 }
964 $out[] = array(
965 'label' => sprintf(
966 /* translators: 1: parent menu label, 2: submenu label */
967 __( '%1$s > %2$s', 'desktop-mode' ),
968 $menu_label,
969 $submenu_label
970 ),
971 'url' => $submenu_url,
972 'name' => $menu_slug . '-' . $submenu_item[2],
973 );
974 }
975 }
976 }
977 return $out;
978 }
979