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/core/payload.php +1426 -305 0.9.81.1.10 View file →
@@ -1,7 +1,7 @@
1 1 <?php
2 2 /**
3 - * Desktop Mode — payload building helpers.
3 + * OpenStation — payload building helpers.
4 4 *
5 5 * Dock-item construction, native-window payload assembly, menu
6 6 * payload (the data the shell shows in the dock + on bootstrap),
7 7 * and the script/style handle resolvers used by the live-refresh
@@ -13,9 +13,9 @@
13 13 * still fires with the same shape — PHP looks function references
14 14 * up by name at hook-fire time, so existing callers continue to
15 15 * resolve regardless of which file owns the definition.
16 16 *
17 - * @package Desktop_Mode
17 + * @package OpenStation
18 18 */
19 19
20 20 defined( 'ABSPATH' ) || exit;
21 21
@@ -29,9 +29,9 @@
29 29 *
30 30 * @return array[] Array of dock item arrays, each containing:
31 31 * id, title, icon, url, badge, submenu.
32 32 */
33 -function desktop_mode_build_dock_items() {
33 +function openstation_build_dock_items() {
34 34 global $menu, $submenu;
35 35
36 36 if ( empty( $menu ) ) {
37 37 return array();
@@ -54,12 +54,18 @@
54 54 if ( ! empty( $item[1] ) && ! current_user_can( $item[1] ) ) {
55 55 continue;
56 56 }
57 57
58 - // Extract the clean title: strip badge spans first, then strip remaining tags.
59 - $raw_title = preg_replace( '/<span[^>]*>.*?<\/span>/s', '', $item[0] );
60 - $title = trim( wp_strip_all_tags( $raw_title ) );
58 + // Skip menus something took out of the classic sidebar. A dock
59 + // that shows what wp-admin hides isn't a faithful mirror of the
60 + // menu, and on WordPress.com it double-renders every entry
61 + // Jetpack replaced with a Calypso link.
62 + if ( openstation_menu_item_is_hidden( $item ) ) {
63 + continue;
64 + }
61 65
66 + $title = openstation_menu_item_title( $item[0] );
67 +
62 68 // Extract badge count from the title HTML.
63 69 $badge = 0;
64 70 if ( preg_match( '/class="(?:update-plugins|awaiting-mod)[^"]*count-(\d+)"/', $item[0], $matches ) ) {
65 71 $badge = (int) $matches[1];
@@ -77,17 +83,29 @@
77 83 // dock count always agrees with what the window shows (GH#258).
78 84 if (
79 85 'plugins.php' === $item[2] &&
80 86 ! is_multisite() &&
81 - function_exists( 'desktop_mode_plugins_window_count_visible_updates' )
87 + function_exists( 'openstation_plugins_window_count_visible_updates' )
82 88 ) {
83 - $badge = desktop_mode_plugins_window_count_visible_updates();
89 + $badge = openstation_plugins_window_count_visible_updates();
84 90 }
85 91
86 92 // Determine the icon. Menu entries can set `$item[6]` to anything
87 93 // — a dashicon class, a remote URL, a data:URI, 'none', or 'div'
88 94 // — so normalize before we serialize it for the shell JS.
89 - $icon = desktop_mode_sanitize_dock_icon( $item[6] ?? '' );
95 + //
96 + // A blanked value falls back to whatever the row carried before
97 + // anything on `admin_menu` rewrote it, which is how plugin
98 + // artwork survives Jetpack's SVG-to-stylesheet move on
99 + // WordPress.com — see `openstation_snapshot_menu_icons()`.
100 + $raw_icon = (string) ( $item[6] ?? '' );
101 + if ( '' === $raw_icon || 'none' === $raw_icon || 'div' === $raw_icon ) {
102 + $snapshot = openstation_menu_icon_snapshot();
103 + if ( isset( $snapshot[ $item[2] ] ) ) {
104 + $raw_icon = $snapshot[ $item[2] ];
105 + }
106 + }
107 + $icon = openstation_sanitize_dock_icon( $raw_icon );
90 108
91 109 // Build the full URL for the menu item.
92 110 //
93 111 // `$parent_url` is the slug-derived URL (`admin.php?page=<slug>`
@@ -95,11 +113,20 @@
95 113 // reference value the self-link strip below compares against.
96 114 // The effective `$url` we ship to the shell can be rewritten
97 115 // further down to the first visible submenu's URL — see the
98 116 // note after the loop.
99 - $parent_url = desktop_mode_menu_item_url( $item[2] );
100 - $url = $parent_url;
117 + $parent_url = openstation_menu_item_url( $item[2] );
118 + $parent_external = openstation_menu_item_is_external( $parent_url );
101 119
120 + // A menu owned by a regular plugin is allowed to keep off-site
121 + // children — a docs or support link under a plugin's own menu is
122 + // a normal thing to ship, and the flyout marks it as leaving the
123 + // site. Everything else drops them: a Core menu whose child was
124 + // repointed off-site (WordPress.com does this to Appearance →
125 + // Themes) gets its wp-admin original back instead, below.
126 + $plugin_file = openstation_resolve_menu_plugin_file( $item[2] );
127 + $allow_external_subs = null !== $plugin_file && ! $parent_external;
128 +
102 129 // Build submenu items.
103 130 //
104 131 // WordPress auto-prepends a self-link entry to every parent
105 132 // menu's `$submenu[$slug]` (the first child shares the parent's
@@ -106,19 +133,27 @@
106 133 // slug + URL — that's what `add_menu_page()` generates so the
107 134 // admin UI can render a clickable parent in the sidebar). For
108 135 // the shell's JS surface we strip this entry so:
109 136 //
110 - // - `submenu.length === 0` reliably means "no real children"
111 - // (the right-click submenu popover stays suppressed; the
112 - // in-window tab strip stays hidden).
113 - // - `submenu.length > 0` reliably means "has real child links"
114 - // — every entry points at a distinct URL.
137 + // - `submenu.length === 0` reliably means "no real children"
138 + // (the right-click submenu popover stays suppressed; the
139 + // in-window tab strip stays hidden).
140 + // - `submenu.length > 0` reliably means "has real child links"
141 + // — every entry points at a distinct URL.
115 142 //
116 - // Detection by URL (post-`desktop_mode_menu_item_url()` normalize)
143 + // Detection by URL (post-`openstation_menu_item_url()` normalize)
117 144 // rather than slug equality covers plugins that register a child
118 145 // at a different slug pointing at the parent's URL.
119 - $sub_items = array();
120 - $first_visible_sub_url = null;
146 + //
147 + // Two passes, because the second decision depends on the first:
148 + // a `hide-if-js` row is normally noise, but when it is the
149 + // wp-admin original of an off-site row we just dropped, it is
150 + // the route back to the page Core intended. The original takes
151 + // the replacement's place in the list, so the menu reads the way
152 + // it would have if nothing had swapped the row out.
153 + $rows = array();
154 + $restore_slots = array();
155 + $dropped_off_site = 0;
121 156 if ( ! empty( $submenu[ $item[2] ] ) ) {
122 157 foreach ( $submenu[ $item[2] ] as $sub_item ) {
123 158 if ( ! empty( $sub_item[1] ) && ! current_user_can( $sub_item[1] ) ) {
124 159 continue;
@@ -128,42 +163,208 @@
128 163 // class; the semantics are "shown by default; hide only
129 164 // when `<body class=\"no-customize-support\">`". The
130 165 // Customizer is supported inside chromeless iframes, so
131 166 // these entries belong in the dock.
132 - $sub_url = desktop_mode_menu_item_url( $sub_item[2] );
133 - // Capture the first capability-passing submenu URL so
134 - // we can use it as the parent's effective URL below
135 - // (mirrors `wp-admin/menu-header.php`). Captured BEFORE
136 - // the self-link strip so plugins whose first submenu IS
137 - // the auto-prepended self-link land on the parent URL
138 - // (a no-op rewrite — preserves existing behavior).
139 - if ( null === $first_visible_sub_url ) {
140 - $first_visible_sub_url = $sub_url;
167 + $sub_url = openstation_menu_item_url( $sub_item[2] );
168 + $sub_external = openstation_menu_item_is_external( $sub_url );
169 +
170 + if ( $sub_external && ! $allow_external_subs ) {
171 + ++$dropped_off_site;
172 + // Leave a slot behind, in case the wp-admin row this
173 + // entry displaced is still in the list.
174 + $dropped_title = openstation_menu_item_title( $sub_item[0] );
175 + if ( '' !== $dropped_title && ! isset( $restore_slots[ $dropped_title ] ) ) {
176 + $rows[] = array( 'restore' => $dropped_title );
177 + $restore_slots[ $dropped_title ] = count( $rows ) - 1;
178 + }
179 + continue;
141 180 }
142 - // Self-link strip — `$sub_url === $parent_url` covers
143 - // WP's auto-prepended entry AND any plugin-registered
144 - // alias that happens to land on the parent URL.
145 - if ( $sub_url === $parent_url ) {
146 - continue;
181 +
182 + $rows[] = array(
183 + 'raw_title' => $sub_item[0],
184 + 'slug' => (string) $sub_item[2],
185 + 'url' => $sub_url,
186 + 'external' => $sub_external,
187 + 'hidden' => openstation_menu_item_is_hidden( $sub_item ),
188 + );
189 + }
190 + }
191 +
192 + // Second pass. A hidden row moves into the slot its replacement
193 + // left; one whose replacement was the top-level slug itself
194 + // stays where it is (there is no slot — the menu row is not part
195 + // of this list). Every other hidden row, and every slot nothing
196 + // claimed, drops out.
197 + $restored = array();
198 + $keep = array_fill( 0, count( $rows ), true );
199 + foreach ( $rows as $i => $row ) {
200 + if ( isset( $row['restore'] ) || ! $row['hidden'] ) {
201 + continue;
202 + }
203 + $keep[ $i ] = false;
204 + $row_title = openstation_menu_item_title( $row['raw_title'] );
205 + if ( '' === $row_title || isset( $restored[ $row_title ] ) ) {
206 + continue;
207 + }
208 + if ( isset( $restore_slots[ $row_title ] ) ) {
209 + $rows[ $restore_slots[ $row_title ] ] = $row;
210 + $restored[ $row_title ] = true;
211 + } elseif ( $parent_external && $row_title === $title ) {
212 + // The menu's own row, hidden in place. WordPress builds
213 + // a parent's self-link by copying the menu row's first
214 + // four fields, so its label is the menu's label, which
215 + // is what makes the comparison hold.
216 + $keep[ $i ] = true;
217 + $restored[ $row_title ] = true;
218 + }
219 + }
220 +
221 + // Last resort for a menu whose own slug points off-site: if
222 + // nothing on-site survived, take the first hidden on-site row
223 + // rather than lose the menu. The label comparison above is the
224 + // precise answer and covers the ordinary case, but it breaks the
225 + // moment a host relabels the menu row without relabelling the
226 + // self-link it already generated. Showing a row someone hid
227 + // beats dropping a working menu off the dock.
228 + if ( $parent_external ) {
229 + $has_on_site = false;
230 + foreach ( $rows as $i => $row ) {
231 + if ( ! isset( $row['restore'] ) && $keep[ $i ] && ! $row['external'] ) {
232 + $has_on_site = true;
233 + break;
147 234 }
148 - $sub_raw_title = preg_replace( '/<span[^>]*>.*?<\/span>/s', '', (string) $sub_item[0] );
149 - $sub_title = trim( wp_strip_all_tags( $sub_raw_title ) );
150 - // Skip entries with no resolvable title. Plugins (e.g.
151 - // WooCommerce's `wc-addons` Extensions row) register
152 - // `menu_title => null` to hide a row from classic admin's
153 - // left menu while keeping the page reachable. Without
154 - // this guard the dock renders an empty, label-less tab
155 - // that visually duplicates a sibling entry.
156 - if ( '' === $sub_title ) {
157 - continue;
235 + }
236 + if ( ! $has_on_site ) {
237 + foreach ( $rows as $i => $row ) {
238 + if ( isset( $row['restore'] ) || ! $row['hidden'] || $row['external'] ) {
239 + continue;
240 + }
241 + $keep[ $i ] = true;
242 + break;
158 243 }
159 - $sub_items[] = array(
160 - 'title' => $sub_title,
161 - 'url' => $sub_url,
162 - );
163 244 }
164 245 }
165 246
247 + $kept_rows = array();
248 + foreach ( $rows as $i => $row ) {
249 + if ( isset( $row['restore'] ) || ! $keep[ $i ] ) {
250 + continue;
251 + }
252 + $kept_rows[] = $row;
253 + }
254 + $rows = $kept_rows;
255 +
256 + // When the top-level slug itself points off-site, the menu's
257 + // identity is now whichever child survived — adopt it before the
258 + // self-link strip runs, so a restored original collapses into
259 + // `selfLabel` instead of becoming a child that duplicates its
260 + // own parent.
261 + //
262 + // Identity travels with it. Everything below keys off the menu's
263 + // slug — whether it's a Core menu, whether a plugin owns it,
264 + // whether it opens more than one window, and which slug the
265 + // `openstation_dock_item` filter is told about. Left on the
266 + // off-site slug, a rescued Plugins tile reads as a plugin menu
267 + // owned by whoever registered the replacement, sorts to the far
268 + // end of the dock, and offers to deactivate them.
269 + $identity_slug = (string) $item[2];
270 + if ( $parent_external ) {
271 + foreach ( $rows as $row ) {
272 + if ( ! $row['external'] ) {
273 + $parent_url = $row['url'];
274 + $identity_slug = $row['slug'];
275 + break;
276 + }
277 + }
278 + }
279 +
280 + // A menu that only ever pointed at its children, and whose
281 + // children we just took away. Checked only for menus the
282 + // off-site rule actually touched, so a menu registering its page
283 + // hook in some way we don't recognise is left exactly as it was.
284 + $parent_is_container = $dropped_off_site > 0
285 + && ! $parent_external
286 + && ! openstation_menu_slug_has_page( $item[2] );
287 +
288 + $url = $parent_url;
289 + $sub_items = array();
290 + $first_visible_sub_url = null;
291 + $has_self_link = false;
292 + $self_label = '';
293 + foreach ( $rows as $row ) {
294 + $sub_url = $row['url'];
295 + if ( $parent_is_container && $sub_url === $parent_url ) {
296 + // A row pointing back at a menu with no page is a dead
297 + // end, not a way back — it can't name the menu and it
298 + // can't stand in for it.
299 + continue;
300 + }
301 + // Capture the first capability-passing submenu URL so
302 + // we can use it as the parent's effective URL below
303 + // (mirrors `wp-admin/menu-header.php`). Captured BEFORE
304 + // the self-link strip so plugins whose first submenu IS
305 + // the auto-prepended self-link land on the parent URL
306 + // (a no-op rewrite — preserves existing behavior). Never
307 + // an off-site child, which would take the whole tile with
308 + // it when the final external check runs.
309 + if ( null === $first_visible_sub_url && ! $row['external'] ) {
310 + $first_visible_sub_url = $sub_url;
311 + }
312 + // Self-link strip — `$sub_url === $parent_url` covers
313 + // WP's auto-prepended entry AND any plugin-registered
314 + // alias that happens to land on the parent URL.
315 + if ( $sub_url === $parent_url ) {
316 + $has_self_link = true;
317 + // Keep its LABEL, though. The stripped entry is a
318 + // real row in wp-admin's own menu ("All Posts",
319 + // "All Pages"), and the constellation flyout lists
320 + // it as the first thing the menu opens — a list of
321 + // a menu's pages that omits its main page reads as
322 + // a bug.
323 + //
324 + // Carried separately rather than left in `submenu`
325 + // because `submenu` has two other consumers that
326 + // need it to mean "distinct child links only": the
327 + // in-window tab strip, which would grow a duplicate
328 + // first tab, and the right-click popover, which is
329 + // suppressed on `length === 0`.
330 + //
331 + // First one only — a plugin can register several
332 + // aliases onto the parent URL, and the canonical
333 + // self-link is the one WordPress prepends.
334 + if ( '' === $self_label ) {
335 + $self_label = openstation_menu_item_title( $row['raw_title'] );
336 + }
337 + continue;
338 + }
339 + // Skip entries with no resolvable title. Plugins (e.g.
340 + // WooCommerce's `wc-addons` Extensions row) register
341 + // `menu_title => null` to hide a row from classic admin's
342 + // left menu while keeping the page reachable. Without
343 + // this guard the dock renders an empty, label-less tab
344 + // that visually duplicates a sibling entry.
345 + $sub_title = openstation_menu_item_title( $row['raw_title'] );
346 + if ( '' === $sub_title ) {
347 + continue;
348 + }
349 + $sub_entry = array(
350 + 'title' => $sub_title,
351 + 'url' => $sub_url,
352 + );
353 + if ( $row['external'] ) {
354 + // Consumers that route a URL into a window skip these;
355 + // the ones that can hand a link to the browser mark
356 + // them as leaving the site.
357 + //
358 + // `offSite` rather than `external`: the window's tab
359 + // strip already calls plugin-opened sub-iframe tabs
360 + // "external" (`data-kind="external"`), and that is a
361 + // different thing entirely.
362 + $sub_entry['offSite'] = true;
363 + }
364 + $sub_items[] = $sub_entry;
365 + }
366 +
166 367 // Mirror `wp-admin/menu-header.php`: when a parent menu has any
167 368 // visible submenu, classic admin rewrites the parent's
168 369 // clickable URL to the first submenu's URL. Plugins like
169 370 // WooCommerce rely on this — their top-level slug
@@ -171,27 +372,57 @@
171 372 // directly. The real landing page is the first submenu
172 373 // (`?page=wc-admin` for WC). Without this rewrite the dock
173 374 // icon points users at a broken URL that classic admin would
174 375 // never have linked to.
175 - if ( null !== $first_visible_sub_url ) {
376 + //
377 + // A menu that registered a self-link has a working page of its
378 + // own and keeps it, wherever in the list that link sits. Only
379 + // the WooCommerce shape — no self-link at all — needs a child to
380 + // stand in. Position matters here because a restored wp-admin
381 + // row inherits the slot its off-site replacement held, which on
382 + // WordPress.com puts `plugin-install.php` first under Plugins.
383 + if ( null !== $first_visible_sub_url && ! $has_self_link ) {
176 384 $url = $first_visible_sub_url;
177 385 }
178 386
387 + // Nothing on this menu resolves to a page we can open. Hosts
388 + // that link their own control panel from the admin menu
389 + // (WordPress.com's My Home, Theme Showcase, Hosting) land here,
390 + // and so does a Core menu whose slug was repointed off-site with
391 + // no wp-admin child left to fall back to.
392 + if ( openstation_menu_item_is_external( $url ) ) {
393 + continue;
394 + }
395 +
396 + // A container menu with nothing left to stand in for it. Its
397 + // URL resolves to core's "Cannot load <slug>." page, which is a
398 + // worse tile than no tile.
399 + if ( $parent_is_container && $url === $parent_url ) {
400 + continue;
401 + }
402 +
179 403 $dock_item = array(
180 - 'id' => sanitize_key( $item[5] ?? $item[2] ),
181 - 'title' => $title,
182 - 'icon' => $icon,
183 - 'url' => $url,
184 - 'badge' => $badge,
185 - 'submenu' => $sub_items,
186 - 'multi' => desktop_mode_dock_item_is_multi( $item[2] ),
187 - 'placement' => desktop_mode_dock_placement( $item[2] ),
188 - 'isCore' => desktop_mode_is_core_menu_slug( $item[2] ),
189 - 'pluginFile' => desktop_mode_resolve_menu_plugin_file( $item[2] ),
404 + 'id' => sanitize_key( $item[5] ?? $item[2] ),
405 + 'title' => $title,
406 + 'icon' => $icon,
407 + 'url' => $url,
408 + 'badge' => $badge,
409 + 'submenu' => $sub_items,
410 + // Label of the stripped self-link ("All Posts"), for
411 + // surfaces that list a menu's pages and want its main page
412 + // named the way wp-admin names it. Empty when the menu had
413 + // no self-link to strip.
414 + 'selfLabel' => $self_label,
415 + 'multi' => openstation_dock_item_is_multi( $identity_slug ),
416 + 'placement' => openstation_dock_placement( $identity_slug ),
417 + 'isCore' => openstation_is_core_menu_slug( $identity_slug ),
418 + 'pluginFile' => $identity_slug === (string) $item[2]
419 + ? $plugin_file
420 + : openstation_resolve_menu_plugin_file( $identity_slug ),
190 421 'pluginName' => null,
191 422 );
192 423 if ( $dock_item['pluginFile'] ) {
193 - $dock_item['pluginName'] = desktop_mode_plugin_display_name( $dock_item['pluginFile'] );
424 + $dock_item['pluginName'] = openstation_plugin_display_name( $dock_item['pluginFile'] );
194 425 }
195 426
196 427 /**
197 428 * Filters a single dock item's data.
@@ -198,9 +429,9 @@
198 429 *
199 430 * @param array $dock_item The dock item data.
200 431 * @param string $menu_slug The menu slug.
201 432 */
202 - $dock_item = apply_filters( 'desktop_mode_dock_item', $dock_item, $item[2] );
433 + $dock_item = apply_filters( 'openstation_dock_item', $dock_item, $identity_slug );
203 434
204 435 $items[] = $dock_item;
205 436 }
206 437
@@ -208,12 +439,181 @@
208 439 * Filters the dock items before they are passed to JavaScript.
209 440 *
210 441 * @param array[] $items Array of dock item arrays.
211 442 */
212 - return apply_filters( 'desktop_mode_dock_items', $items );
443 + return apply_filters( 'openstation_dock_items', $items );
213 444 }
214 445
215 446 /**
447 + * Whether a resolved menu URL points at a host other than this site's.
448 + *
449 + * OpenStation opens admin pages inside iframes, and an off-site URL
450 + * cannot load in one — the remote origin's `X-Frame-Options` /
451 + * `frame-ancestors` header refuses it. Hosts that extend the admin
452 + * menu with links to their own control panel (WordPress.com registers
453 + * My Home, Theme Showcase, Hosting and friends as `wordpress.com`
454 + * URLs) would therefore fill the dock with tiles that can only ever
455 + * escape to a browser tab, which breaks the shell's navigation model.
456 + * Those entries are dropped from the payload instead.
457 + *
458 + * The menu's own admin (`openstation_menu_admin_url()`), `admin_url()`
459 + * and `home_url()` hosts all count as ours: a site can run its admin on
460 + * a different domain than its front end, and the network admin lives on
461 + * the network's own.
462 + *
463 + * @param string $url Absolute URL, as returned by `openstation_menu_item_url()`.
464 + * @return bool True when the URL is off-site.
465 + */
466 +function openstation_menu_item_is_external( $url ) {
467 + $host = wp_parse_url( (string) $url, PHP_URL_HOST );
468 + $external = false;
469 +
470 + if ( $host ) {
471 + $ours = array();
472 + foreach ( array( openstation_menu_admin_url(), admin_url(), home_url() ) as $known ) {
473 + $known_host = wp_parse_url( $known, PHP_URL_HOST );
474 + if ( $known_host ) {
475 + $ours[] = strtolower( $known_host );
476 + }
477 + }
478 + $external = ! in_array( strtolower( $host ), $ours, true );
479 + }
480 +
481 + /**
482 + * Filters whether an admin-menu URL counts as off-site.
483 + *
484 + * @param bool $external Whether the URL points off-site.
485 + * @param string $url The resolved menu URL.
486 + */
487 + return (bool) apply_filters( 'openstation_menu_item_is_external', $external, $url );
488 +}
489 +
490 +/**
491 + * Whether a `$menu` / `$submenu` row carries the `hide-if-js` class.
492 + *
493 + * Core never sets it on a menu row, so it reads as "some other code
494 + * took this entry out of the sidebar". Jetpack's admin-menu
495 + * customisation on WordPress.com uses it heavily: rather than replace
496 + * a Core entry with its wordpress.com counterpart, it marks the
497 + * original `hide-if-js` and appends a duplicate pointing at Calypso.
498 + * Honouring the class is what keeps those pairs from rendering twice
499 + * in the dock.
500 + *
501 + * @param array $item A `$menu` or `$submenu` row.
502 + * @return bool True when the row is hidden from the classic sidebar.
503 + */
504 +function openstation_menu_item_is_hidden( $item ) {
505 + return ! empty( $item[4] ) && false !== strpos( (string) $item[4], 'hide-if-js' );
506 +}
507 +
508 +/**
509 + * Whether a top-level menu slug has a page of its own behind it.
510 + *
511 + * `add_menu_page()` accepts a `null` callback, which registers a menu
512 + * that is nothing but a container for its children — WordPress links
513 + * such a parent to its first submenu and `admin.php` refuses the slug
514 + * directly with "Cannot load <slug>." WordPress.com's Upgrades menu is
515 + * one: `paid-upgrades.php` has no callback and no self-link, and every
516 + * child is a wordpress.com URL. Drop the children and the tile is left
517 + * pointing at core's error page.
518 + *
519 + * Two ways a slug earns a page: it names a real file under `wp-admin/`,
520 + * or something is listening on its page hook — the same `has_action()`
521 + * test `get_plugin_page_hook()` makes before `admin.php` gives up.
522 + * Anything we can't answer counts as a page, so an unusual registration
523 + * costs a menu nothing.
524 + *
525 + * @param string $slug The menu slug from `$menu[$i][2]`.
526 + * @return bool False only when the slug is provably a container.
527 + */
528 +function openstation_menu_slug_has_page( $slug ) {
529 + if ( openstation_is_admin_file_slug( $slug ) ) {
530 + return true;
531 + }
532 +
533 + if ( ! function_exists( 'get_plugin_page_hookname' ) ) {
534 + return true;
535 + }
536 +
537 + $hookname = get_plugin_page_hookname( $slug, '' );
538 + if ( empty( $hookname ) ) {
539 + return true;
540 + }
541 +
542 + return has_action( $hookname );
543 +}
544 +
545 +/**
546 + * Lazy accessor for the pre-rewrite menu icon snapshot: `slug → icon`.
547 + *
548 + * Populated by {@see openstation_snapshot_menu_icons()}.
549 + *
550 + * @return array<string,string>
551 + */
552 +function &openstation_menu_icon_snapshot() {
553 + static $map = null;
554 + if ( null === $map ) {
555 + $map = array();
556 + }
557 + return $map;
558 +}
559 +
560 +/**
561 + * Record the first real icon each menu row is seen wearing.
562 + *
563 + * A menu row's icon is not final when it is registered. Anything on
564 + * `admin_menu` can rewrite `$menu[ $i ][6]`, and the rewrite that hurts
565 + * is to `'none'` — the row keeps its picture in the sidebar, painted
566 + * from a stylesheet instead, and the menu array stops carrying it. The
567 + * dock reads the array, so those menus arrived wearing a generic gear.
568 + * Jetpack's `override_svg_icons()` does this to every SVG-data-URI icon
569 + * on WordPress.com, which is where it was found, but nothing about the
570 + * move is specific to that host.
571 + *
572 + * Rather than sit at one priority chosen to undercut one known rewriter,
573 + * sample repeatedly and **never overwrite**: the map keeps the earliest
574 + * real icon each slug had, whenever it appeared and whoever blanked it
575 + * afterwards. Write-once is safe because the map is only ever consulted
576 + * as a fallback — a menu that genuinely changes its icon still ships the
577 + * live value.
578 + *
579 + * A slug that had no real icon at any sample point is simply absent, and
580 + * the caller lands on the generic fallback it would have had anyway.
581 + */
582 +function openstation_snapshot_menu_icons() {
583 + global $menu;
584 +
585 + if ( ! is_array( $menu ) ) {
586 + return;
587 + }
588 +
589 + $map = &openstation_menu_icon_snapshot();
590 +
591 + foreach ( $menu as $item ) {
592 + if ( empty( $item[2] ) || empty( $item[6] ) ) {
593 + continue;
594 + }
595 + $slug = (string) $item[2];
596 + if ( isset( $map[ $slug ] ) ) {
597 + continue;
598 + }
599 + $icon = (string) $item[6];
600 + if ( 'none' === $icon || 'div' === $icon ) {
601 + continue;
602 + }
603 + $map[ $slug ] = $icon;
604 + }
605 +}
606 +// Spread across the hook rather than parked just below any one
607 +// rewriter: registrations and rewrites both happen at arbitrary
608 +// priorities, and only a sample taken before a given rewrite can see
609 +// what it overwrote.
610 +foreach ( array( 11, 100, 1000, 99998, PHP_INT_MAX ) as $openstation_icon_snapshot_priority ) {
611 + add_action( 'admin_menu', 'openstation_snapshot_menu_icons', $openstation_icon_snapshot_priority );
612 +}
613 +unset( $openstation_icon_snapshot_priority );
614 +
615 +/**
216 616 * Sanitizes a dock icon value for safe injection into the shell JS.
217 617 *
218 618 * Menu items can set their icon to one of:
219 619 *
@@ -243,9 +643,9 @@
243 643 *
244 644 * @param mixed $icon Raw icon value from the menu registration.
245 645 * @return string Sanitized icon string.
246 646 */
247 -function desktop_mode_sanitize_dock_icon( $icon ) {
647 +function openstation_sanitize_dock_icon( $icon ) {
248 648 $fallback = 'dashicons-admin-generic';
249 649 if ( ! is_string( $icon ) || '' === $icon ) {
250 650 return $fallback;
251 651 }
@@ -304,9 +704,9 @@
304 704 * have a single logical state — opening two makes no sense.
305 705 *
306 706 * The default rule matches the base filename of the menu slug against a
307 707 * known list. Plugin authors can override via the
308 - * `desktop_mode_dock_item_multi` filter to mark any custom page as multi
708 + * `openstation_dock_item_multi` filter to mark any custom page as multi
309 709 * (or force a stock list page into singleton mode).
310 710 *
311 711 * @param string $menu_slug The raw menu slug (e.g. `edit.php`, `upload.php`,
312 712 * or `my-plugin-page`). Query strings are preserved
@@ -312,9 +712,9 @@
312 712 * or `my-plugin-page`). Query strings are preserved
313 713 * so `edit.php?post_type=page` resolves correctly.
314 714 * @return bool True if this page supports multiple simultaneous windows.
315 715 */
316 -function desktop_mode_dock_item_is_multi( $menu_slug ) {
716 +function openstation_dock_item_is_multi( $menu_slug ) {
317 717 // Multi-capable admin files. Match by the base file regardless of
318 718 // any query string (post_type, taxonomy, page, paged, etc.) so every
319 719 // CPT and every taxonomy inherits the same rule as their parent.
320 720 $multi_files = array(
@@ -324,9 +724,9 @@
324 724 'users.php',
325 725 'edit-comments.php',
326 726 );
327 727
328 - $base = strtok( (string) $menu_slug, '?' );
728 + $base = strtok( (string) $menu_slug, '?' );
329 729 $multi = in_array( $base, $multi_files, true );
330 730
331 731 /**
332 732 * Filters whether a dock item supports multiple open windows.
@@ -338,9 +738,9 @@
338 738 *
339 739 * @param bool $multi Whether this page is multi-capable.
340 740 * @param string $menu_slug The menu slug (e.g. `edit.php?post_type=page`).
341 741 */
342 - return (bool) apply_filters( 'desktop_mode_dock_item_multi', $multi, $menu_slug );
742 + return (bool) apply_filters( 'openstation_dock_item_multi', $multi, $menu_slug );
343 743 }
344 744
345 745 /**
346 746 * Returns true when `$menu_slug` maps to a first-party WordPress
@@ -364,13 +764,13 @@
364 764 * with custom top-level files can still opt in via the filter
365 765 * below).
366 766 *
367 767 * Plugins + site admins can override any answer via
368 - * `desktop_mode_dock_placement`:
768 + * `openstation_dock_placement`:
369 769 *
370 770 * ```php
371 771 * // Keep Jetpack on the left dock:
372 - * add_filter( 'desktop_mode_dock_placement', function ( $placement, $slug ) {
772 + * add_filter( 'openstation_dock_placement', function ( $placement, $slug ) {
373 773 * return 'jetpack' === $slug ? 'dock' : $placement;
374 774 * }, 10, 2 );
375 775 * ```
376 776 *
@@ -376,9 +776,9 @@
376 776 *
377 777 * @param string $menu_slug Menu item slug (e.g. `edit.php`, `edit.php?post_type=foo`, `woocommerce`).
378 778 * @return bool True when the slug is a core admin page.
379 779 */
380 -function desktop_mode_is_core_menu_slug( $menu_slug ) {
780 +function openstation_is_core_menu_slug( $menu_slug ) {
381 781 $slug = (string) $menu_slug;
382 782 $base = strtok( $slug, '?' );
383 783
384 784 // Known top-level core admin files. Stable across WP versions —
@@ -419,8 +819,19 @@
419 819 'link-manager.php', // Link manager (legacy)
420 820 'update-core.php', // Dashboard > Updates
421 821 );
422 822
823 + // The two top-level network menus the site admin has no filename
824 + // for: without them, Sites and Settings sat in the apps zone while
825 + // Dashboard, Users, Themes and Plugins — whose filenames the site
826 + // admin shares — grouped correctly. Gated on the context, since
827 + // `settings.php` is plausible enough as a plugin's own top-level
828 + // slug that claiming it everywhere would misfile it.
829 + if ( is_network_admin() ) {
830 + $core_files[] = 'sites.php';
831 + $core_files[] = 'settings.php';
832 + }
833 +
423 834 return in_array( $base, $core_files, true );
424 835 }
425 836
426 837 /**
@@ -429,9 +840,9 @@
429 840 * registered for the menu's page hook.
430 841 *
431 842 * Returns the plugin's main file path (relative to `WP_PLUGIN_DIR`) when
432 843 * the menu was registered by a regular plugin, `null` otherwise. Core
433 - * menus, mu-plugins, drop-ins, theme-registered menus, and Desktop Mode
844 + * menus, mu-plugins, drop-ins, theme-registered menus, and OpenStation
434 845 * itself all return `null` — none of these are deactivatable through the
435 846 * `wp/v2/plugins` REST route, so the dock right-click menu should not
436 847 * offer a deactivate action for them.
437 848 *
@@ -445,9 +856,9 @@
445 856 * lives here.
446 857 * 3. Reflect each callback to find its declaring file. Match the file
447 858 * path against `WP_PLUGIN_DIR/<folder>/…` and use `<folder>` to look
448 859 * up an entry in `get_plugins()`. Return the matching `<folder>/<file>.php`.
449 - * 4. Exclude Desktop Mode itself — deactivating from inside the shell
860 + * 4. Exclude OpenStation itself — deactivating from inside the shell
450 861 * is handled by the plugins-window's self-deactivate path.
451 862 *
452 863 * @param string $menu_slug The menu slug from `$menu[$i][2]` (e.g. `woocommerce`,
453 864 * `admin.php?page=jetpack`, `edit.php?post_type=foo`).
@@ -453,9 +864,9 @@
453 864 * `admin.php?page=jetpack`, `edit.php?post_type=foo`).
454 865 * @return string|null Plugin file path relative to `WP_PLUGIN_DIR`, or null
455 866 * when the slug isn't owned by a deactivatable plugin.
456 867 */
457 -function desktop_mode_resolve_menu_plugin_file( $menu_slug ) {
868 +function openstation_resolve_menu_plugin_file( $menu_slug ) {
458 869 $slug = (string) $menu_slug;
459 870
460 871 // `get_plugin_page_hookname` + `get_plugins` come from
461 872 // `wp-admin/includes/plugin.php`, which Core loads itself on
@@ -466,12 +877,12 @@
466 877 if ( ! function_exists( 'get_plugin_page_hookname' ) || ! function_exists( 'get_plugins' ) ) {
467 878 return null;
468 879 }
469 880
470 - $self_basename = defined( 'DESKTOP_MODE_FILE' ) ? plugin_basename( DESKTOP_MODE_FILE ) : '';
881 + $self_basename = defined( 'OPENSTATION_FILE' ) ? plugin_basename( OPENSTATION_FILE ) : '';
471 882
472 883 // Strategy 1 — registration-time attribution. The admin_menu hook
473 - // wrapper (see `desktop_mode_install_menu_attribution_tracker`) snapshots
884 + // wrapper (see `openstation_install_menu_attribution_tracker`) snapshots
474 885 // `$menu`/`$submenu` around every admin_menu callback and records
475 886 // "this plugin file added this slug". This is the authoritative
476 887 // source — it captures menus whose page hook isn't predictable from
477 888 // the slug (e.g. WC's `wc-admin&path=/marketing`) and handles
@@ -476,9 +887,9 @@
476 887 // source — it captures menus whose page hook isn't predictable from
477 888 // the slug (e.g. WC's `wc-admin&path=/marketing`) and handles
478 889 // callbacks that simply forward to a shared renderer (which
479 890 // reflection would mis-attribute).
480 - $map = desktop_mode_menu_attribution_map();
891 + $map = openstation_menu_attribution_map();
481 892 if ( isset( $map[ $slug ] ) ) {
482 893 $plugin_file = $map[ $slug ];
483 894 if ( $self_basename && $plugin_file === $self_basename ) {
484 895 return null;
@@ -490,9 +901,9 @@
490 901 // / `edit-tags.php` handle the render, so the page hook would never
491 902 // point at the registering plugin. We caught the plugin at
492 903 // `register_post_type()` / `register_taxonomy()` time via
493 904 // `debug_backtrace()`.
494 - $tracked = desktop_mode_lookup_taxonomy_or_post_type_plugin_file( $slug );
905 + $tracked = openstation_lookup_taxonomy_or_post_type_plugin_file( $slug );
495 906 if ( null !== $tracked ) {
496 907 if ( $self_basename && $tracked === $self_basename ) {
497 908 return null;
498 909 }
@@ -502,12 +913,12 @@
502 913 $base = strtok( $slug, '?' );
503 914
504 915 // Cheap reject: literal core PHP files with no `?page=` parameter
505 916 // (the universal "a plugin registered an admin route" signal). We
506 - // can't reuse `desktop_mode_is_core_menu_slug()` here — that
917 + // can't reuse `openstation_is_core_menu_slug()` here — that
507 918 // classifier strtok's the query string and treats `admin.php?page=foo`
508 919 // as core, which would hide every plugin-registered top-level tile.
509 - if ( desktop_mode_is_pure_core_file( $base ) && false === strpos( $slug, '?page=' ) ) {
920 + if ( openstation_is_pure_core_file( $base ) && false === strpos( $slug, '?page=' ) ) {
510 921 return null;
511 922 }
512 923
513 924 // Strategy 3 — page-hook reflection fallback. The earlier strategies
@@ -523,9 +934,9 @@
523 934
524 935 $hook = $wp_filter[ $hookname ];
525 936 foreach ( $hook->callbacks as $cbs ) {
526 937 foreach ( $cbs as $cb ) {
527 - $plugin_file = desktop_mode_plugin_file_for_callback( $cb['function'] ?? null );
938 + $plugin_file = openstation_plugin_file_for_callback( $cb['function'] ?? null );
528 939 if ( ! $plugin_file ) {
529 940 continue;
530 941 }
531 942 if ( $self_basename && $plugin_file === $self_basename ) {
@@ -546,11 +957,12 @@
546 957 *
547 958 * @param string $plugin_file Plugin file relative to `WP_PLUGIN_DIR`.
548 959 * @return string Display name.
549 960 */
550 -function desktop_mode_plugin_display_name( $plugin_file ) {
961 +function openstation_plugin_display_name( $plugin_file ) {
551 962 if ( ! function_exists( 'get_plugins' ) ) {
552 - return strtok( $plugin_file, '/' ) ?: $plugin_file;
963 + $dir = strtok( $plugin_file, '/' );
964 + return $dir ? $dir : $plugin_file;
553 965 }
554 966 $installed = get_plugins();
555 967 if ( isset( $installed[ $plugin_file ]['Name'] ) && '' !== $installed[ $plugin_file ]['Name'] ) {
556 968 return (string) $installed[ $plugin_file ]['Name'];
@@ -567,9 +979,9 @@
567 979 *
568 980 * @param string $file Absolute filesystem path.
569 981 * @return string|null Plugin file (`<folder>/<file>.php`) or null.
570 982 */
571 -function desktop_mode_plugin_file_for_path( $file ) {
983 +function openstation_plugin_file_for_path( $file ) {
572 984 if ( ! is_string( $file ) || '' === $file ) {
573 985 return null;
574 986 }
575 987 $plugins_dir = wp_normalize_path( WP_PLUGIN_DIR );
@@ -598,16 +1010,16 @@
598 1010
599 1011 /**
600 1012 * Convenience wrapper: reflect on a callback to find its declaring
601 1013 * file, then map that file to an active plugin via
602 - * {@see desktop_mode_plugin_file_for_path()}.
1014 + * {@see openstation_plugin_file_for_path()}.
603 1015 *
604 1016 * @param mixed $callback A WP-style callback.
605 1017 * @return string|null Plugin file or null.
606 1018 */
607 -function desktop_mode_plugin_file_for_callback( $callback ) {
608 - $file = desktop_mode_callback_source_file( $callback );
609 - return $file ? desktop_mode_plugin_file_for_path( $file ) : null;
1019 +function openstation_plugin_file_for_callback( $callback ) {
1020 + $file = openstation_callback_source_file( $callback );
1021 + return $file ? openstation_plugin_file_for_path( $file ) : null;
610 1022 }
611 1023
612 1024 /**
613 1025 * Lazy accessor + lazy initializer for the registration-time menu
@@ -612,13 +1024,13 @@
612 1024 /**
613 1025 * Lazy accessor + lazy initializer for the registration-time menu
614 1026 * attribution map: `slug → plugin_file`. The map is populated by the
615 1027 * wrapped admin_menu callbacks installed by
616 - * {@see desktop_mode_install_menu_attribution_tracker()}.
1028 + * {@see openstation_install_menu_attribution_tracker()}.
617 1029 *
618 1030 * @return array<string,string>
619 1031 */
620 -function &desktop_mode_menu_attribution_map() {
1032 +function &openstation_menu_attribution_map() {
621 1033 static $map = null;
622 1034 if ( null === $map ) {
623 1035 $map = array();
624 1036 }
@@ -644,9 +1056,9 @@
644 1056 *
645 1057 * This is the source of truth for plugin → menu ownership because it
646 1058 * captures menus regardless of slug shape, hook name predictability,
647 1059 * or whether the plugin shares a render callback. Reflection on the
648 - * page hook (in `desktop_mode_resolve_menu_plugin_file`) is now a
1060 + * page hook (in `openstation_resolve_menu_plugin_file`) is now a
649 1061 * fallback for the rare cases where the tracker wasn't able to install
650 1062 * in time.
651 1063 *
652 1064 * Idempotent — runs at most once per request via a static `$installed`
@@ -653,9 +1065,9 @@
653 1065 * flag.
654 1066 *
655 1067 * @return void
656 1068 */
657 -function desktop_mode_install_menu_attribution_tracker() {
1069 +function openstation_install_menu_attribution_tracker() {
658 1070 static $installed = false;
659 1071 if ( $installed ) {
660 1072 return;
661 1073 }
@@ -669,9 +1081,9 @@
669 1081
670 1082 foreach ( $hook->callbacks as $priority => $cbs ) {
671 1083 foreach ( $cbs as $id => $cb ) {
672 1084 $orig = $cb['function'] ?? null;
673 - $plugin_file = desktop_mode_plugin_file_for_callback( $orig );
1085 + $plugin_file = openstation_plugin_file_for_callback( $orig );
674 1086 if ( ! $plugin_file || ! is_callable( $orig ) ) {
675 1087 continue;
676 1088 }
677 1089 $accepted_args = (int) ( $cb['accepted_args'] ?? 1 );
@@ -703,9 +1115,9 @@
703 1115
704 1116 $args = func_get_args();
705 1117 $return = call_user_func_array( $orig, $args );
706 1118
707 - $map = &desktop_mode_menu_attribution_map();
1119 + $map = &openstation_menu_attribution_map();
708 1120
709 1121 if ( is_array( $menu ) ) {
710 1122 foreach ( $menu as $entry ) {
711 1123 if ( ! isset( $entry[2] ) ) {
@@ -761,14 +1173,14 @@
761 1173 }
762 1174 }
763 1175 }
764 1176
765 -add_action( '_admin_menu', 'desktop_mode_install_menu_attribution_tracker', -PHP_INT_MAX );
766 -add_action( '_network_admin_menu', 'desktop_mode_install_menu_attribution_tracker', -PHP_INT_MAX );
767 -add_action( '_user_admin_menu', 'desktop_mode_install_menu_attribution_tracker', -PHP_INT_MAX );
1177 +add_action( '_admin_menu', 'openstation_install_menu_attribution_tracker', -PHP_INT_MAX );
1178 +add_action( '_network_admin_menu', 'openstation_install_menu_attribution_tracker', -PHP_INT_MAX );
1179 +add_action( '_user_admin_menu', 'openstation_install_menu_attribution_tracker', -PHP_INT_MAX );
768 1180
769 1181 /**
770 - * The subset of `desktop_mode_is_core_menu_slug`'s "core files" that's
1182 + * The subset of `openstation_is_core_menu_slug`'s "core files" that's
771 1183 * actually owned by Core regardless of any query string — this is what
772 1184 * we use inside the plugin-file resolver to reject Posts / Pages / etc.
773 1185 * without rejecting `admin.php?page=…` (a universal plugin signal that
774 1186 * the public is_core classifier also incorrectly treats as core for
@@ -779,9 +1191,9 @@
779 1191 *
780 1192 * @param string $base Slug with query string already stripped.
781 1193 * @return bool True when the base filename is a Core admin handler.
782 1194 */
783 -function desktop_mode_is_pure_core_file( $base ) {
1195 +function openstation_is_pure_core_file( $base ) {
784 1196 $core_files = array(
785 1197 'index.php',
786 1198 'edit-comments.php',
787 1199 'upload.php',
@@ -831,9 +1243,9 @@
831 1243 *
832 1244 * @param string $slug Menu slug.
833 1245 * @return string|null Plugin file or null.
834 1246 */
835 -function desktop_mode_lookup_taxonomy_or_post_type_plugin_file( $slug ) {
1247 +function openstation_lookup_taxonomy_or_post_type_plugin_file( $slug ) {
836 1248 if ( false !== strpos( $slug, 'edit.php?' ) && false !== strpos( $slug, 'post_type=' ) ) {
837 1249 $qs = wp_parse_url( 'http://x/' . ltrim( $slug, '/' ), PHP_URL_QUERY );
838 1250 parse_str( (string) $qs, $args );
839 1251 $pt = isset( $args['post_type'] ) ? (string) $args['post_type'] : '';
@@ -839,10 +1251,10 @@
839 1251 $pt = isset( $args['post_type'] ) ? (string) $args['post_type'] : '';
840 1252 if ( '' === $pt ) {
841 1253 return null;
842 1254 }
843 - $map = desktop_mode_get_typed_plugin_map();
844 - return $map['post_type'][ $pt ] ?? null;
1255 + $file = openstation_type_registrant_file( $pt, 'post_type' );
1256 + return null === $file ? null : openstation_plugin_file_for_path( $file );
845 1257 }
846 1258 if ( false !== strpos( $slug, 'edit-tags.php?' ) && false !== strpos( $slug, 'taxonomy=' ) ) {
847 1259 $qs = wp_parse_url( 'http://x/' . ltrim( $slug, '/' ), PHP_URL_QUERY );
848 1260 parse_str( (string) $qs, $args );
@@ -849,25 +1261,36 @@
849 1261 $tx = isset( $args['taxonomy'] ) ? (string) $args['taxonomy'] : '';
850 1262 if ( '' === $tx ) {
851 1263 return null;
852 1264 }
853 - $map = desktop_mode_get_typed_plugin_map();
854 - return $map['taxonomy'][ $tx ] ?? null;
1265 + $file = openstation_type_registrant_file( $tx, 'taxonomy' );
1266 + return null === $file ? null : openstation_plugin_file_for_path( $file );
855 1267 }
856 1268 return null;
857 1269 }
858 1270
859 1271 /**
860 - * Lazy accessor for the CPT/taxonomy → plugin file map. The map is
861 - * populated by `desktop_mode_record_type_registrant()` (hooked early on
1272 + * Lazy accessor for the CPT/taxonomy → registering-file map. The map is
1273 + * populated by `openstation_record_type_registrant()` (hooked on
1274 + * `registered_post_type` / `registered_taxonomy`, which fire during
862 1275 * `init`), so by the time the dock payload is built — on
863 - * `admin_enqueue_scripts`, well after `init` — every plugin-registered
864 - * non-builtin type has an entry. Stored in a static so repeated
865 - * lookups during a single request don't trigger the populator twice.
1276 + * `admin_enqueue_scripts`, well after `init` — every non-builtin type
1277 + * registered from an extension has an entry. Stored in a static so
1278 + * repeated lookups during a single request don't trigger the populator
1279 + * twice.
866 1280 *
1281 + * Values are **absolute filesystem paths**, not plugin files. Core does
1282 + * not load `wp-admin/includes/plugin.php` (where `get_plugins()` lives)
1283 + * until `wp-admin/admin.php` runs it *after* `wp-load.php` has already
1284 + * fired `init` — so a plugin file cannot be resolved at record time.
1285 + * Callers resolve the path lazily instead:
1286 + * `openstation_lookup_taxonomy_or_post_type_plugin_file()` for the
1287 + * dock's plugin attribution, and the My WordPress group resolver for
1288 + * the plugin / mu-plugin / theme split.
1289 + *
867 1290 * @return array{post_type: array<string,string>, taxonomy: array<string,string>}
868 1291 */
869 -function &desktop_mode_get_typed_plugin_map() {
1292 +function &openstation_get_typed_registrant_map() {
870 1293 static $map = null;
871 1294 if ( null === $map ) {
872 1295 $map = array(
873 1296 'post_type' => array(),
@@ -877,9 +1300,58 @@
877 1300 return $map;
878 1301 }
879 1302
880 1303 /**
881 - * Record the registering plugin file for a CPT or taxonomy. Hooked at
1304 + * Read the recorded registering file for a CPT or taxonomy.
1305 + *
1306 + * @param string $type Type name (CPT or taxonomy).
1307 + * @param string $kind Either `'post_type'` or `'taxonomy'`.
1308 + * @return string|null Absolute normalized path, or null when unrecorded.
1309 + */
1310 +function openstation_type_registrant_file( $type, $kind ) {
1311 + $map = openstation_get_typed_registrant_map();
1312 + return $map[ $kind ][ $type ] ?? null;
1313 +}
1314 +
1315 +/**
1316 + * Whether this request will ever read the CPT / taxonomy attribution
1317 + * map, and is therefore worth paying a `debug_backtrace()` per
1318 + * non-builtin type registration to build it.
1319 + *
1320 + * Only admin-side surfaces consume it: the dock payload (built on
1321 + * `admin_enqueue_scripts`) and the site window's section list (built
1322 + * on `init`, admin only). A front-end page view registers exactly the
1323 + * same types — WooCommerce alone brings several — and would pay the
1324 + * whole cost for a map nothing reads.
1325 + *
1326 + * The predecessor of this function got the same effect by accident:
1327 + * it bailed when `get_plugins()` was undefined, which is every
1328 + * front-end request. That guard went away when the resolution moved to
1329 + * lazy path recording, so the gate is now explicit.
1330 + *
1331 + * @return bool
1332 + */
1333 +function openstation_should_track_type_registrants() {
1334 + $track = is_admin();
1335 +
1336 + /**
1337 + * Filter whether to record which extension registered each CPT and
1338 + * taxonomy this request.
1339 + *
1340 + * The map drives the dock's "Deactivate <plugin>" action and the
1341 + * site window's plugin folders. Return true on a front-end request
1342 + * only if something there reads it — building it costs one bounded
1343 + * backtrace per non-builtin type registration.
1344 + *
1345 + * **Status: Experimental**
1346 + *
1347 + * @param bool $track Default: admin requests only.
1348 + */
1349 + return (bool) apply_filters( 'openstation_track_type_registrants', $track );
1350 +}
1351 +
1352 +/**
1353 + * Record the registering file for a CPT or taxonomy. Hooked at
882 1354 * `registered_post_type` / `registered_taxonomy` priority 9999 so we
883 1355 * fire after every other listener has run (lets a plugin re-register
884 1356 * its own type on top of someone else's — last writer wins, which
885 1357 * matches WP's runtime semantics).
@@ -884,20 +1356,23 @@
884 1356 * its own type on top of someone else's — last writer wins, which
885 1357 * matches WP's runtime semantics).
886 1358 *
887 1359 * Resolution is via `debug_backtrace()`: walk frames until we hit one
888 - * whose `file` lives under `WP_PLUGIN_DIR`, then map the folder back
889 - * to a `get_plugins()` entry. Cheap — the backtrace is bounded to 12
890 - * frames and runs once per type registration, all during `init`.
1360 + * whose `file` lives inside an extension directory (plugins, mu-plugins,
1361 + * or a theme root). Cheap — the backtrace is bounded and runs once per
1362 + * type registration, all during `init`.
891 1363 *
892 1364 * @param string $type_or_post_type Type name (CPT or taxonomy).
893 1365 * @param string $kind Either `'post_type'` or `'taxonomy'`.
894 1366 * @return void
895 1367 */
896 -function desktop_mode_record_type_registrant( $type_or_post_type, $kind ) {
1368 +function openstation_record_type_registrant( $type_or_post_type, $kind ) {
897 1369 if ( '' === (string) $type_or_post_type ) {
898 1370 return;
899 1371 }
1372 + if ( ! openstation_should_track_type_registrants() ) {
1373 + return;
1374 + }
900 1375 // Skip Core builtin types — they're registered from Core itself
901 1376 // (Posts, Pages, Categories, …) and the backtrace would never land
902 1377 // inside WP_PLUGIN_DIR anyway. Cheap pre-filter.
903 1378 if ( 'post_type' === $kind ) {
@@ -911,42 +1386,82 @@
911 1386 return;
912 1387 }
913 1388 }
914 1389
915 - $plugin_file = desktop_mode_plugin_file_for_callback_backtrace();
916 - if ( null === $plugin_file ) {
1390 + $file = openstation_registrant_file_from_backtrace();
1391 + if ( null === $file ) {
917 1392 return;
918 1393 }
919 - $map = &desktop_mode_get_typed_plugin_map();
920 - $map[ $kind ][ $type_or_post_type ] = $plugin_file;
1394 + $map = &openstation_get_typed_registrant_map();
1395 + $map[ $kind ][ $type_or_post_type ] = $file;
921 1396 }
922 1397
923 1398 /**
924 - * Walk the current PHP backtrace and return the plugin file owning
925 - * the closest frame inside `WP_PLUGIN_DIR`. Returns null when no
926 - * frame qualifies or when `get_plugins()` isn't available (Core
927 - * hasn't loaded `wp-admin/includes/plugin.php` yet — true on
928 - * non-admin requests and very early admin bootstrap).
1399 + * The extension directories a registration can legitimately come from,
1400 + * normalized and trailing-slashed. Anything else (Core itself, a
1401 + * drop-in, `wp-config.php`) is not attributable to an extension.
929 1402 *
1403 + * @return string[] Normalized directory prefixes.
1404 + */
1405 +function openstation_extension_dirs() {
1406 + static $dirs = null;
1407 + if ( null !== $dirs ) {
1408 + return $dirs;
1409 + }
1410 + $dirs = array();
1411 + if ( defined( 'WP_PLUGIN_DIR' ) ) {
1412 + $dirs[] = wp_normalize_path( WP_PLUGIN_DIR ) . '/';
1413 + }
1414 + if ( defined( 'WPMU_PLUGIN_DIR' ) ) {
1415 + $dirs[] = wp_normalize_path( WPMU_PLUGIN_DIR ) . '/';
1416 + }
1417 + foreach ( (array) get_theme_roots() as $theme_root ) {
1418 + // `get_theme_roots()` returns roots relative to `wp-content`
1419 + // when there's only one; `get_theme_root()` normalizes that.
1420 + $dirs[] = wp_normalize_path( get_theme_root( (string) $theme_root ) ) . '/';
1421 + }
1422 + $dirs = array_values( array_unique( array_filter( $dirs ) ) );
1423 + return $dirs;
1424 +}
1425 +
1426 +/**
1427 + * Walk the current PHP backtrace and return the closest frame that
1428 + * lives inside an extension directory (plugin, mu-plugin, or theme).
1429 + *
1430 + * Frames belonging to OpenStation itself are skipped: this function is
1431 + * called from `payload.php`, which is under `WP_PLUGIN_DIR`, so the two
1432 + * innermost frames would otherwise match and attribute every registered
1433 + * type to us.
1434 + *
930 1435 * Used by the CPT / taxonomy registration tracker to attribute
931 - * `register_post_type()` / `register_taxonomy()` calls without
932 - * forcing Core to load its admin include earlier than it would.
1436 + * `register_post_type()` / `register_taxonomy()` calls without forcing
1437 + * Core to load `wp-admin/includes/plugin.php` earlier than it would —
1438 + * `get_plugins()` does not exist yet at `init`.
933 1439 *
934 - * @return string|null Plugin file or null.
1440 + * @return string|null Normalized absolute path, or null.
935 1441 */
936 -function desktop_mode_plugin_file_for_callback_backtrace() {
937 - if ( ! function_exists( 'get_plugins' ) ) {
1442 +function openstation_registrant_file_from_backtrace() {
1443 + $self_dir = defined( 'OPENSTATION_DIR' ) ? wp_normalize_path( OPENSTATION_DIR ) : '';
1444 + $self_dir = $self_dir ? trailingslashit( $self_dir ) : '';
1445 + $dirs = openstation_extension_dirs();
1446 + if ( empty( $dirs ) ) {
938 1447 return null;
939 1448 }
940 - $bt = debug_backtrace( DEBUG_BACKTRACE_IGNORE_ARGS, 12 );
1449 +
1450 + $bt = debug_backtrace( DEBUG_BACKTRACE_IGNORE_ARGS, 20 );
941 1451 foreach ( $bt as $frame ) {
942 1452 if ( empty( $frame['file'] ) ) {
943 1453 continue;
944 1454 }
945 - $plugin_file = desktop_mode_plugin_file_for_path( (string) $frame['file'] );
946 - if ( null !== $plugin_file ) {
947 - return $plugin_file;
1455 + $norm = wp_normalize_path( (string) $frame['file'] );
1456 + if ( '' !== $self_dir && 0 === strpos( $norm, $self_dir ) ) {
1457 + continue;
948 1458 }
1459 + foreach ( $dirs as $dir ) {
1460 + if ( 0 === strpos( $norm, $dir ) ) {
1461 + return $norm;
1462 + }
1463 + }
949 1464 }
950 1465 return null;
951 1466 }
952 1467
@@ -952,9 +1467,9 @@
952 1467
953 1468 add_action(
954 1469 'registered_post_type',
955 1470 static function ( $post_type ) {
956 - desktop_mode_record_type_registrant( $post_type, 'post_type' );
1471 + openstation_record_type_registrant( $post_type, 'post_type' );
957 1472 },
958 1473 9999,
959 1474 1
960 1475 );
@@ -961,9 +1476,9 @@
961 1476
962 1477 add_action(
963 1478 'registered_taxonomy',
964 1479 static function ( $taxonomy ) {
965 - desktop_mode_record_type_registrant( $taxonomy, 'taxonomy' );
1480 + openstation_record_type_registrant( $taxonomy, 'taxonomy' );
966 1481 },
967 1482 9999,
968 1483 1
969 1484 );
@@ -977,9 +1492,9 @@
977 1492 *
978 1493 * @param mixed $callback A callback as stored in `WP_Hook::$callbacks[$prio][$id]['function']`.
979 1494 * @return string|null Absolute filesystem path of the declaring file, or null.
980 1495 */
981 -function desktop_mode_callback_source_file( $callback ) {
1496 +function openstation_callback_source_file( $callback ) {
982 1497 if ( empty( $callback ) ) {
983 1498 return null;
984 1499 }
985 1500 try {
@@ -984,9 +1499,9 @@
984 1499 }
985 1500 try {
986 1501 if ( is_string( $callback ) && false !== strpos( $callback, '::' ) ) {
987 1502 list( $class, $method ) = explode( '::', $callback, 2 );
988 - $ref = new ReflectionMethod( $class, $method );
1503 + $ref = new ReflectionMethod( $class, $method );
989 1504 } elseif ( is_array( $callback ) && isset( $callback[0], $callback[1] ) ) {
990 1505 $ref = new ReflectionMethod( $callback[0], (string) $callback[1] );
991 1506 } elseif ( is_object( $callback ) && ! ( $callback instanceof Closure ) && method_exists( $callback, '__invoke' ) ) {
992 1507 $ref = new ReflectionMethod( $callback, '__invoke' );
@@ -1012,14 +1527,14 @@
1012 1527 * exists server-side; this only suppresses the
1013 1528 * desktop-shell tile.
1014 1529 *
1015 1530 * Default is `'dock'` for every menu item. Plugins + site admins can
1016 - * hide individual items via the `desktop_mode_dock_placement` filter.
1531 + * hide individual items via the `openstation_dock_placement` filter.
1017 1532 *
1018 1533 * @param string $menu_slug The menu slug (e.g. `edit.php`, `woocommerce`).
1019 1534 * @return string `'dock'` or `'hidden'`.
1020 1535 */
1021 -function desktop_mode_dock_placement( $menu_slug ) {
1536 +function openstation_dock_placement( $menu_slug ) {
1022 1537 /**
1023 1538 * Filter whether a specific menu item is shown in the dock.
1024 1539 *
1025 1540 * Return `'dock'` to render the item on the dock (default) or
@@ -1029,9 +1544,9 @@
1029 1544 *
1030 1545 * @param string $placement Default — always `'dock'`.
1031 1546 * @param string $menu_slug The menu slug triggering the lookup.
1032 1547 */
1033 - $filtered = apply_filters( 'desktop_mode_dock_placement', 'dock', $menu_slug );
1548 + $filtered = apply_filters( 'openstation_dock_placement', 'dock', $menu_slug );
1034 1549 return 'hidden' === $filtered ? 'hidden' : 'dock';
1035 1550 }
1036 1551
1037 1552 /**
@@ -1043,16 +1558,16 @@
1043 1558 * `'hidden'` are dropped entirely.
1044 1559 *
1045 1560 * Extracted out of `includes/render.php` so both the initial PHP
1046 1561 * localize AND the chromeless bridge's live-refresh emit (including
1047 - * the hidden-iframe probe spawned by `wp.desktop.refreshMenu()`)
1562 + * the hidden-iframe probe spawned by `wp.os.refreshMenu()`)
1048 1563 * read from a single source of truth — any drift would desync the
1049 1564 * live refresh.
1050 1565 *
1051 1566 * @return array{dockItems: array[]} Menu payload.
1052 1567 */
1053 -function desktop_mode_build_menu_payload() {
1054 - $all = desktop_mode_build_dock_items();
1568 +function openstation_build_menu_payload() {
1569 + $all = openstation_build_dock_items();
1055 1570
1056 1571 // Drop hidden items; preserve the default "core first, plugins
1057 1572 // after" ordering by partitioning on the core classifier.
1058 1573 $visible = array_values(
@@ -1064,14 +1579,14 @@
1064 1579 )
1065 1580 );
1066 1581
1067 1582 // Partition on the per-item `isCore` flag set in
1068 - // desktop_mode_build_dock_items — that classifier ran against the
1583 + // openstation_build_dock_items — that classifier ran against the
1069 1584 // raw menu slug ($item[2]), which is what
1070 - // desktop_mode_is_core_menu_slug actually compares. The outer 'id'
1585 + // openstation_is_core_menu_slug actually compares. The outer 'id'
1071 1586 // field is a sanitized CSS id (e.g. `toplevel_page_jetpack`) and
1072 1587 // would never match.
1073 - $core = array();
1588 + $core = array();
1074 1589 $plugin = array();
1075 1590 foreach ( $visible as $item ) {
1076 1591 if ( ! empty( $item['isCore'] ) ) {
1077 1592 $core[] = $item;
@@ -1081,39 +1596,45 @@
1081 1596 }
1082 1597
1083 1598 $dock = array_merge( $core, $plugin );
1084 1599
1600 + // One collector call feeds both halves: the slim entry list and
1601 + // the handle-keyed script data the shell joins them with.
1602 + $native_windows = openstation_collect_native_windows_payload();
1603 +
1085 1604 $payload = array(
1086 - 'dockItems' => $dock,
1087 - 'nativeWindows' => desktop_mode_build_native_windows_payload(),
1605 + 'dockItems' => $dock,
1606 + 'nativeWindows' => $native_windows['windows'],
1607 + 'nativeWindowScriptData' => $native_windows['scriptData'],
1088 1608 );
1089 1609
1090 1610 // Optional per-surface payload builders — each module ships a
1091 - // zero-arg `desktop_mode_build_*_payload()`; modules that aren't
1611 + // zero-arg `openstation_build_*_payload()`; modules that aren't
1092 1612 // loaded this request contribute an empty array.
1093 1613 $builders = array(
1094 - 'serverWidgets' => 'desktop_mode_build_desktop_widgets_payload',
1095 - 'serverWallpapers' => 'desktop_mode_build_desktop_wallpapers_payload',
1096 - 'serverCommandScripts' => 'desktop_mode_build_desktop_command_scripts_payload',
1097 - 'serverCommands' => 'desktop_mode_build_desktop_commands_payload',
1098 - 'serverSettingsTabScripts' => 'desktop_mode_build_desktop_settings_tab_scripts_payload',
1099 - 'serverSettingsTabs' => 'desktop_mode_build_desktop_settings_tabs_payload',
1100 - 'serverDockRailRendererScripts' => 'desktop_mode_build_dock_rail_renderer_scripts_payload',
1101 - 'serverTitleBarButtonScripts' => 'desktop_mode_build_desktop_titlebar_button_scripts_payload',
1102 - 'serverUnfocusEffectScripts' => 'desktop_mode_build_desktop_unfocus_effect_scripts_payload',
1103 - 'serverWindowLinkRendererScripts' => 'desktop_mode_build_window_link_renderer_scripts_payload',
1104 - 'serverWindowThemeScripts' => 'desktop_mode_build_window_theme_scripts_payload',
1105 - 'serverWindowThemes' => 'desktop_mode_build_window_themes_payload',
1106 - 'serverWindowControlScripts' => 'desktop_mode_build_window_control_scripts_payload',
1107 - 'serverWindowControls' => 'desktop_mode_build_window_controls_payload',
1108 - 'serverWindowSlotScripts' => 'desktop_mode_build_window_slot_scripts_payload',
1109 - 'serverWindowSlots' => 'desktop_mode_build_window_slots_payload',
1110 - 'serverWindowChromeScripts' => 'desktop_mode_build_window_chrome_scripts_payload',
1111 - 'serverWindowChromes' => 'desktop_mode_build_window_chromes_payload',
1112 - 'serverWindowNotices' => 'desktop_mode_build_window_notices_payload',
1113 - 'serverGames' => 'desktop_mode_build_desktop_games_payload',
1114 - 'serverDesktopThemes' => 'desktop_mode_build_desktop_themes_payload',
1115 - 'desktopIcons' => 'desktop_mode_build_desktop_icons_payload',
1614 + 'serverWidgets' => 'openstation_build_desktop_widgets_payload',
1615 + 'serverWallpapers' => 'openstation_build_desktop_wallpapers_payload',
1616 + 'serverCommandScripts' => 'openstation_build_desktop_command_scripts_payload',
1617 + 'serverCommands' => 'openstation_build_desktop_commands_payload',
1618 + 'serverSettingsTabScripts' => 'openstation_build_desktop_settings_tab_scripts_payload',
1619 + 'serverSettingsTabs' => 'openstation_build_desktop_settings_tabs_payload',
1620 + 'serverDockRailRendererScripts' => 'openstation_build_dock_rail_renderer_scripts_payload',
1621 + 'serverTitleBarButtonScripts' => 'openstation_build_desktop_titlebar_button_scripts_payload',
1622 + 'serverWindowActionScripts' => 'openstation_build_desktop_window_action_scripts_payload',
1623 + 'serverUnfocusEffectScripts' => 'openstation_build_desktop_unfocus_effect_scripts_payload',
1624 + 'serverWindowLinkRendererScripts' => 'openstation_build_window_link_renderer_scripts_payload',
1625 + 'serverWindowThemeScripts' => 'openstation_build_window_theme_scripts_payload',
1626 + 'serverWindowThemes' => 'openstation_build_window_themes_payload',
1627 + 'serverWindowControlScripts' => 'openstation_build_window_control_scripts_payload',
1628 + 'serverWindowControls' => 'openstation_build_window_controls_payload',
1629 + 'serverWindowSlotScripts' => 'openstation_build_window_slot_scripts_payload',
1630 + 'serverWindowSlots' => 'openstation_build_window_slots_payload',
1631 + 'serverWindowChromeScripts' => 'openstation_build_window_chrome_scripts_payload',
1632 + 'serverWindowChromes' => 'openstation_build_window_chromes_payload',
1633 + 'serverWindowNotices' => 'openstation_build_window_notices_payload',
1634 + 'serverGames' => 'openstation_build_desktop_games_payload',
1635 + 'serverDesktopThemes' => 'openstation_build_desktop_themes_payload',
1636 + 'desktopIcons' => 'openstation_build_desktop_icons_payload',
1116 1637 );
1117 1638
1118 1639 foreach ( $builders as $key => $builder ) {
1119 1640 $payload[ $key ] = function_exists( $builder ) ? $builder() : array();
@@ -1142,15 +1663,22 @@
1142 1663 'url' => network_admin_url( 'update-core.php' ),
1143 1664 );
1144 1665 }
1145 1666
1667 + // The site switcher's rows: on a network, the instances this shell
1668 + // may switch to (`openstation_multisite_payload()`), null elsewhere.
1669 + // The Network app spends a menu refresh after every action that
1670 + // changes them (add, remove, join, leave, sync), so the row above
1671 + // overview's desktop tiles follows the registry without a reload.
1672 + $payload['multisite'] = openstation_multisite_payload();
1673 +
1146 1674 // A cheap structural fingerprint of the admin menu the shell uses to
1147 1675 // decide whether a live refresh is warranted. Shipped in every full
1148 1676 // payload so the shell can seed / update its last-known signature
1149 1677 // without recomputing it client-side (which would risk drift from
1150 1678 // the server's capability-gated view). See
1151 - // desktop_mode_menu_signature().
1152 - $payload['menuSig'] = desktop_mode_menu_signature();
1679 + // openstation_menu_signature().
1680 + $payload['menuSig'] = openstation_menu_signature();
1153 1681
1154 1682 return $payload;
1155 1683 }
1156 1684
@@ -1169,9 +1697,9 @@
1169 1697 * Building the full payload on *every* chromeless page just to catch
1170 1698 * that case would be wasteful — most navigations don't touch the menu.
1171 1699 * Instead every chromeless page ships this lightweight signature; the
1172 1700 * shell compares it against its last-known value and only spends a
1173 - * `wp.desktop.refreshMenu()` probe when it actually changed.
1701 + * `wp.os.refreshMenu()` probe when it actually changed.
1174 1702 *
1175 1703 * The hash covers the capability-passing top-level + submenu slugs and
1176 1704 * their (badge-stripped) titles — i.e. exactly the add / remove /
1177 1705 * rename events the dock cares about. Transient badge counts (update
@@ -1180,9 +1708,9 @@
1180 1708 *
1181 1709 * @return string 32-char md5 fingerprint, or '' when the menu is
1182 1710 * unavailable (non-admin context).
1183 1711 */
1184 -function desktop_mode_menu_signature() {
1712 +function openstation_menu_signature() {
1185 1713 global $menu, $submenu;
1186 1714
1187 1715 if ( empty( $menu ) || ! is_array( $menu ) ) {
1188 1716 return '';
@@ -1188,9 +1716,9 @@
1188 1716 return '';
1189 1717 }
1190 1718
1191 1719 $clean_title = static function ( $raw ) {
1192 - // Mirror desktop_mode_build_dock_items(): drop badge spans first,
1720 + // Mirror openstation_build_dock_items(): drop badge spans first,
1193 1721 // then any remaining markup, so update counts don't move the hash.
1194 1722 $stripped = preg_replace( '/<span[^>]*>.*?<\/span>/s', '', (string) $raw );
1195 1723 return trim( wp_strip_all_tags( (string) $stripped ) );
1196 1724 };
@@ -1226,8 +1754,153 @@
1226 1754 return md5( implode( "\n", $parts ) );
1227 1755 }
1228 1756
1229 1757 /**
1758 + * A handle's dependency closure, in load order.
1759 + *
1760 + * Post-order depth-first: a handle is emitted only after everything it
1761 + * declares, which is the order `WP_Scripts::do_item()` would have
1762 + * printed them in. A handle is marked visited *before* its own
1763 + * dependencies are walked, so a dependency cycle unwinds instead of
1764 + * recursing forever, and an unregistered handle is skipped rather than
1765 + * being fatal — it contributes nothing and stops nothing.
1766 + *
1767 + * **Deliberately not `WP_Dependencies::all_deps()`.** Three reasons,
1768 + * each of which has bitten this codebase:
1769 + *
1770 + * 1. `WP_Scripts::all_deps()` applies `print_scripts_array` to its
1771 + * result whenever `$recursion` is falsy. That filter is where the
1772 + * chromeless palette trim and the asset guard live, so resolving a
1773 + * payload through it would run a print-time trim across a dependency
1774 + * list and let the guard splice this plugin's own bundles into it.
1775 + * Called from inside one of those filters it is an infinite loop.
1776 + *
1777 + * 2. Passing `$recursion = true` silences that filter but changes the
1778 + * contract: the first handle that fails aborts the entire call
1779 + * (`return false`), abandoning every handle after it in the list. The
1780 + * caller is left with a `$to_do` that is a truncated prefix of the real
1781 + * closure and indistinguishable from a complete one — a silent, partial
1782 + * answer conditional on unrelated registrations elsewhere on the page.
1783 + * A lazily-delivered bundle resolved that way loses packages it
1784 + * declared and throws on an undefined global at mount, which is the
1785 + * exact bug this whole mechanism exists to prevent.
1786 + *
1787 + * 3. `all_deps()` reports missing dependencies through
1788 + * `_doing_it_wrong()`. This is read-only analysis; the real print pass
1789 + * raises those anyway, and raising them twice turns someone else's
1790 + * pre-existing warning into our noise.
1791 + *
1792 + * O(V+E) over the graph, allocates one set, and clones nothing.
1793 + *
1794 + * @param WP_Dependencies $dependencies The scripts or styles registry.
1795 + * @param string[] $handles Roots to walk.
1796 + * @return string[] Registered handles, dependencies before dependents.
1797 + */
1798 +function openstation_script_dependency_closure( $dependencies, $handles ) {
1799 + $seen = array();
1800 + $out = array();
1801 + openstation_collect_script_dependency_closure( $dependencies, (array) $handles, $seen, $out );
1802 +
1803 + return $out;
1804 +}
1805 +
1806 +/**
1807 + * Recursive half of {@see openstation_script_dependency_closure()}.
1808 + *
1809 + * @param WP_Dependencies $dependencies The scripts or styles registry.
1810 + * @param string[] $handles Handles to walk.
1811 + * @param array $seen Handle => true, by reference.
1812 + * @param string[] $out Ordered result, by reference.
1813 + */
1814 +function openstation_collect_script_dependency_closure( $dependencies, $handles, &$seen, &$out ) {
1815 + foreach ( (array) $handles as $handle ) {
1816 + if ( isset( $seen[ $handle ] ) ) {
1817 + continue;
1818 + }
1819 + // Marked BEFORE recursing, so a cycle meets itself as visited
1820 + // and unwinds rather than recursing forever.
1821 + $seen[ $handle ] = true;
1822 + if ( ! isset( $dependencies->registered[ $handle ] ) ) {
1823 + continue;
1824 + }
1825 + openstation_collect_script_dependency_closure(
1826 + $dependencies,
1827 + $dependencies->registered[ $handle ]->deps,
1828 + $seen,
1829 + $out
1830 + );
1831 + $out[] = $handle;
1832 + }
1833 +}
1834 +
1835 +/**
1836 + * Resolve a handle's dependency closure, in load order.
1837 + *
1838 + * **Why a lazily-delivered handle needs this at all.** WordPress
1839 + * normally resolves a script's dependencies when it enqueues it — the
1840 + * packages a bundle declares are on the page before its own body runs.
1841 + * A handle that is only ever delivered lazily never goes through that:
1842 + * `loadVendorScript()` injects one URL, and a bundle declaring
1843 + * `wp-api-fetch` found `wp.apiFetch` undefined at mount.
1844 + *
1845 + * That used to work by accident. Core's ⌘K palette was enqueued on
1846 + * every admin page and its closure is the whole Gutenberg runtime, so
1847 + * `wp.apiFetch`, `wp.element` and friends happened to be globals.
1848 + * Deferring the palette took the accident away and left the contract
1849 + * exposed — see `docs/migration-wp-package-globals.md`.
1850 + *
1851 + * The closure comes from {@see openstation_script_dependency_closure()}
1852 + * rather than `WP_Dependencies::all_deps()`; that function's docblock
1853 + * records why, and the short version is that `all_deps()` answers a
1854 + * question like this one with a silently truncated list. The handle
1855 + * itself is excluded — the caller loads it separately, after these.
1856 + *
1857 + * @param string $handle Script handle.
1858 + * @return array<int,array<string,mixed>> Ordered dependency payloads.
1859 + */
1860 +function openstation_resolve_script_dependencies( $handle ) {
1861 + $handle = (string) $handle;
1862 + $wp_scripts = wp_scripts();
1863 + if ( '' === $handle || ! $wp_scripts || ! isset( $wp_scripts->registered[ $handle ] ) ) {
1864 + return array();
1865 + }
1866 + $deps = $wp_scripts->registered[ $handle ]->deps;
1867 + if ( empty( $deps ) ) {
1868 + return array();
1869 + }
1870 +
1871 + $out = array();
1872 + foreach ( openstation_script_dependency_closure( $wp_scripts, $deps ) as $dep_handle ) {
1873 + if ( $dep_handle === $handle ) {
1874 + continue;
1875 + }
1876 + $payload = openstation_resolve_script_payload( $dep_handle );
1877 + // An alias (no `src`) stays in the list when it carries inline
1878 + // data — that data is the whole reason it was declared, and a
1879 + // plugin's config blob commonly rides one. Nothing to fetch
1880 + // AND nothing to run is the only thing dropped.
1881 + if ( '' === $payload['url']
1882 + && empty( $payload['before'] )
1883 + && empty( $payload['after'] )
1884 + && empty( $payload['l10n'] ) ) {
1885 + continue;
1886 + }
1887 + // The handle rides along because the shell needs it to decide
1888 + // whether the page already has this package. A URL is not
1889 + // enough: with Core's script concatenation on — the wp-admin
1890 + // default — every package below `wp-includes/js/` is served
1891 + // from one `load-scripts.php` blob and has no `<script src>`
1892 + // of its own to match against. Re-running `wp-hooks` because
1893 + // we could not see it replaces `window.wp.hooks`, and every
1894 + // subscriber registered at boot goes deaf. See
1895 + // `src/script-presence.ts`.
1896 + $payload['handle'] = (string) $dep_handle;
1897 + $out[] = $payload;
1898 + }
1899 + return $out;
1900 +}
1901 +
1902 +/**
1230 1903 * Resolve a registered WP script handle into the full payload the
1231 1904 * shell needs to lazy-load it without going through `wp_print_scripts()`.
1232 1905 *
1233 1906 * Returns:
@@ -1251,13 +1924,17 @@
1251 1924 * around the lazy `<script src>` in the same order
1252 1925 * `WP_Scripts::do_item()` would have used.
1253 1926 *
1254 1927 * Returns an empty payload (`array( 'url' => '' )`) when the handle
1255 - * is unregistered or has no source — callers treat that as "no
1256 - * script to load."
1928 + * is unregistered. A registered handle with no source — an alias
1929 + * carrying only inline data — also comes back with an empty `url`,
1930 + * but its `before` / `after` / `l10n` are kept: callers that load a
1931 + * bundle treat an empty `url` as "nothing to fetch", and the
1932 + * dependency walk ({@see openstation_resolve_script_dependencies()})
1933 + * still replays what the alias would have printed.
1257 1934 *
1258 - * Shared between `desktop_mode_register_window()` and
1259 - * `desktop_mode_register_widget()` (and every other registration that
1935 + * Shared between `openstation_register_window()` and
1936 + * `openstation_register_widget()` (and every other registration that
1260 1937 * relies on lazy script loading in the shell) because all of them
1261 1938 * need identical handle→payload plumbing to power mid-session dynamic
1262 1939 * script loading without the `wp_print_scripts` lifecycle.
1263 1940 *
@@ -1263,9 +1940,9 @@
1263 1940 *
1264 1941 * @param string $handle WP script handle.
1265 1942 * @return array{ url:string, before:string[], after:string[], l10n:string[], translations:string } Payload (empty `url` on miss).
1266 1943 */
1267 -function desktop_mode_resolve_script_payload( $handle ) {
1944 +function openstation_resolve_script_payload( $handle ) {
1268 1945 $empty = array(
1269 1946 'url' => '',
1270 1947 'before' => array(),
1271 1948 'after' => array(),
@@ -1282,20 +1959,31 @@
1282 1959 return $empty;
1283 1960 }
1284 1961 $registered = $wp_scripts->registered[ $handle ];
1285 1962 $src = is_string( $registered->src ) ? $registered->src : '';
1286 - if ( '' === $src ) {
1287 - return $empty;
1288 - }
1289 1963
1290 - // Normalize relative paths + attach cache-bust ver.
1291 - $resolved = $src;
1292 - if ( 0 === strpos( $resolved, '/' ) && 0 !== strpos( $resolved, '//' ) ) {
1293 - $resolved = site_url( $resolved );
1964 + // A handle with no `src` is an ALIAS — WordPress's supported way
1965 + // to ship inline-only JavaScript (`wp_register_script( $h, false )`
1966 + // plus `wp_add_inline_script()`), and a common home for a plugin's
1967 + // config blob: registering it as a *dependency* of every bundle is
1968 + // what guarantees the config runs first, whatever the enqueue
1969 + // order. `WP_Scripts::do_item()` prints an alias's localized data
1970 + // and its before/after snippets and returns before the `<script
1971 + // src>` it does not have. The payload mirrors that: `url` stays
1972 + // empty (there is nothing to fetch) and the inline data is kept,
1973 + // so a dependency walk can replay it. Translations are not: Core
1974 + // only prints those for a handle it printed a tag for.
1975 + $resolved = '';
1976 + if ( '' !== $src ) {
1977 + // Normalize relative paths + attach cache-bust ver.
1978 + $resolved = $src;
1979 + if ( 0 === strpos( $resolved, '/' ) && 0 !== strpos( $resolved, '//' ) ) {
1980 + $resolved = site_url( $resolved );
1981 + }
1982 + if ( ! empty( $registered->ver ) ) {
1983 + $resolved = add_query_arg( 'ver', $registered->ver, $resolved );
1984 + }
1294 1985 }
1295 - if ( ! empty( $registered->ver ) ) {
1296 - $resolved = add_query_arg( 'ver', $registered->ver, $resolved );
1297 - }
1298 1986
1299 1987 // Harvest `extra` data the lazy-load path would otherwise drop.
1300 1988 $before = array();
1301 1989 $after = array();
@@ -1330,9 +2018,9 @@
1330 2018 // `wp.i18n.setLocaleData( JSON, 'domain' )` snippet that the print
1331 2019 // pipeline emits before the script body. `print_translations(
1332 2020 // $handle, false )` returns the snippet without echoing.
1333 2021 $translations = '';
1334 - if ( method_exists( $wp_scripts, 'print_translations' ) ) {
2022 + if ( '' !== $resolved && method_exists( $wp_scripts, 'print_translations' ) ) {
1335 2023 $captured = $wp_scripts->print_translations( $handle, false );
1336 2024 if ( is_string( $captured ) ) {
1337 2025 $translations = $captured;
1338 2026 }
@@ -1349,9 +2037,9 @@
1349 2037
1350 2038 /**
1351 2039 * Resolves a registered style handle to its print-time URL + harvested
1352 2040 * inline CSS, the styles-side mirror of
1353 - * {@see desktop_mode_resolve_script_payload()}.
2041 + * {@see openstation_resolve_script_payload()}.
1354 2042 *
1355 2043 * Why this exists: when a plugin's native window (or window-chrome
1356 2044 * theme/control/slot/chrome) is activated mid-session — i.e. the user
1357 2045 * activates the plugin from inside an open desktop shell — the parent
@@ -1367,9 +2055,9 @@
1367 2055 *
1368 2056 * @param string $handle WP style handle.
1369 2057 * @return array{ url:string, inline:string[] } Payload (empty `url` on miss).
1370 2058 */
1371 -function desktop_mode_resolve_style_payload( $handle ) {
2059 +function openstation_resolve_style_payload( $handle ) {
1372 2060 $empty = array(
1373 2061 'url' => '',
1374 2062 'inline' => array(),
1375 2063 );
@@ -1419,22 +2107,206 @@
1419 2107 );
1420 2108 }
1421 2109
1422 2110 /**
2111 + * Build the deferred command-palette asset manifest.
2112 + *
2113 + * `wp_enqueue_command_palette_assets()` (WP 6.9+) enqueues
2114 + * `wp-commands` + `wp-core-commands` and attaches the inline
2115 + * `wp.coreCommands.initializeCommandPalette( … )` call that seeds the
2116 + * `core/commands` store. Its transitive dependency chain is the whole
2117 + * Gutenberg runtime — `wp-block-editor`, `wp-components`, React,
2118 + * `wp-core-data`, some forty bundles, ~800 KB gzipped — which the
2119 + * shell used to pay on EVERY boot so that the ⌘K palette's baseline
2120 + * commands existed if the user ever opened it.
2121 + *
2122 + * This builder lets Core do exactly what it would have done — the
2123 + * menu-command serialization and the inline init included — then
2124 + * UNWINDS the enqueue: it snapshots the script/style queues, calls
2125 + * the Core function, diffs out the roots it added, restores the
2126 + * queues so nothing prints at boot, and resolves the full ordered
2127 + * dependency chain on CLONES (the live `$to_do` is never touched).
2128 + * Each handle in the chain is harvested into the same
2129 + * url/before/after/l10n/translations shape the native-window lazy
2130 + * loader uses, and the shell replays the list — in order — the first
2131 + * time the palette is invoked (`src/commands/palette-assets.ts`).
2132 + *
2133 + * Handles with no `src` (pure aggregators) are kept whenever they
2134 + * carry inline data; dropping them would lose middleware and locale
2135 + * setup the chain depends on. Handles the boot page already printed
2136 + * are skipped client-side, by handle as well as by path so that a
2137 + * package Core concatenated into `load-scripts.php` is recognized
2138 + * (`src/script-presence.ts`) — the manifest deliberately lists them
2139 + * anyway, because which ones those are differs per site and per
2140 + * screen. Each entry therefore carries its `handle`, and that is
2141 + * load-bearing rather than informational.
2142 + *
2143 + * Returns `null` on pre-6.9 sites (no Core palette to defer).
2144 + *
2145 + * @return array{scripts:array<int,array<string,mixed>>,styles:array<int,array<string,mixed>>}|null
2146 + */
2147 +function openstation_build_command_palette_assets_payload() {
2148 + if ( ! function_exists( 'wp_enqueue_command_palette_assets' ) ) {
2149 + return null;
2150 + }
2151 + $scripts = wp_scripts();
2152 + $styles = wp_styles();
2153 + if ( ! $scripts || ! $styles ) {
2154 + return null;
2155 + }
2156 +
2157 + // `wp_enqueue_command_palette_assets()` reads `$submenu` without
2158 + // guarding the global — initialize defensively (test contexts,
2159 + // edge-case admin requests where the menu wasn't built yet).
2160 + global $menu, $submenu;
2161 + // phpcs:disable WordPress.WP.GlobalVariablesOverride.Prohibited -- initializing an unset global to its documented empty shape, not replacing a built menu.
2162 + if ( ! isset( $submenu ) || ! is_array( $submenu ) ) {
2163 + $submenu = array();
2164 + }
2165 + if ( ! isset( $menu ) || ! is_array( $menu ) ) {
2166 + $menu = array();
2167 + }
2168 + // phpcs:enable WordPress.WP.GlobalVariablesOverride.Prohibited
2169 +
2170 + $script_queue_before = $scripts->queue;
2171 + $style_queue_before = $styles->queue;
2172 +
2173 + wp_enqueue_command_palette_assets();
2174 +
2175 + $script_roots = array_values( array_diff( $scripts->queue, $script_queue_before ) );
2176 + $style_roots = array_values( array_diff( $styles->queue, $style_queue_before ) );
2177 +
2178 + // Unwind: the boot page must not print any of it. The inline init
2179 + // stays attached to the `wp-core-commands` HANDLE — that is the
2180 + // point: the harvest below captures it, and if some other screen
2181 + // legitimately enqueues the handle, it prints as Core intended.
2182 + $scripts->queue = $script_queue_before;
2183 + $styles->queue = $style_queue_before;
2184 +
2185 + $out = array(
2186 + 'scripts' => array(),
2187 + 'styles' => array(),
2188 + );
2189 +
2190 + // Ordered dependency chains, resolved on clones so the request's
2191 + // real `$to_do` / `$done` state is untouched.
2192 + $script_probe = clone $scripts;
2193 + $script_probe->to_do = array();
2194 + $script_probe->done = array();
2195 + $script_probe->all_deps( $script_roots );
2196 + foreach ( $script_probe->to_do as $handle ) {
2197 + $payload = openstation_resolve_script_payload( $handle );
2198 + // A src-less aggregator is kept only for its inline data — the
2199 + // resolver harvests that for an alias — and dropped when it
2200 + // carries none.
2201 + if ( '' === $payload['url']
2202 + && empty( $payload['before'] )
2203 + && empty( $payload['after'] )
2204 + && empty( $payload['l10n'] ) ) {
2205 + continue;
2206 + }
2207 + // Core's `initializeCommandPalette( {…} )` inline embeds the
2208 + // serialized admin-menu command list — ~20 KB that the boot
2209 + // page ALREADY carries as `window.__openStationMenuCommands`
2210 + // (the shell harvester's lookup, attached as a `before`
2211 + // inline on the main bundle, and the richer of the two: its
2212 + // URL derivation routes legacy file-path slugs through
2213 + // `menu_page_url()` where Core's regex takes them literally).
2214 + // Ship the list once: strip Core's embedded copy and
2215 + // synthesize the same call against the global, which is
2216 + // guaranteed present long before the manifest replays — it
2217 + // prints at boot, the replay waits for the first ⌘K.
2218 + if ( 'wp-core-commands' === $handle ) {
2219 + foreach ( array( 'before', 'after' ) as $position ) {
2220 + $payload[ $position ] = array_values(
2221 + array_filter(
2222 + $payload[ $position ],
2223 + static function ( $snippet ) {
2224 + return false === strpos( (string) $snippet, 'initializeCommandPalette(' );
2225 + }
2226 + )
2227 + );
2228 + }
2229 + $payload['after'][] = sprintf(
2230 + 'wp.coreCommands.initializeCommandPalette({"is_network_admin":%s,"menu_commands":window.__openStationMenuCommands||[]});',
2231 + is_network_admin() ? 'true' : 'false'
2232 + );
2233 + }
2234 +
2235 + $out['scripts'][] = array(
2236 + 'handle' => (string) $handle,
2237 + 'url' => $payload['url'],
2238 + 'before' => $payload['before'],
2239 + 'after' => $payload['after'],
2240 + 'l10n' => $payload['l10n'],
2241 + 'translations' => $payload['translations'],
2242 + );
2243 + }
2244 +
2245 + $style_probe = clone $styles;
2246 + $style_probe->to_do = array();
2247 + $style_probe->done = array();
2248 + $style_probe->all_deps( $style_roots );
2249 + foreach ( $style_probe->to_do as $handle ) {
2250 + $style_payload = openstation_resolve_style_payload( $handle );
2251 + if ( '' === $style_payload['url'] ) {
2252 + continue;
2253 + }
2254 + $out['styles'][] = array(
2255 + 'handle' => (string) $handle,
2256 + 'url' => $style_payload['url'],
2257 + 'inline' => $style_payload['inline'],
2258 + );
2259 + }
2260 +
2261 + return $out;
2262 +}
2263 +
2264 +/**
2265 + * Resolve a list of style handles into the `deferredStyles` config
2266 + * map: handle → `array( 'url' => …, 'inline' => string[] )`.
2267 + *
2268 + * For shell surfaces that render on demand but are NOT native
2269 + * windows — the Preferences panel, the AI assistant, the bug-report
2270 + * window — so the `styles` companion mechanism can't carry their
2271 + * CSS. The shell reads this map off `openStationConfig.deferredStyles`
2272 + * and injects each sheet the first time its surface opens
2273 + * (`ensureDeferredStyle()` in `src/deferred-styles.ts`).
2274 + *
2275 + * Handles that resolve to nothing (never registered) are dropped, so
2276 + * the client map only ever holds injectable entries.
2277 + *
2278 + * @param string[] $handles Registered style handles.
2279 + * @return array<string, array{url:string, inline:string[]}>
2280 + */
2281 +function openstation_build_deferred_styles( $handles ) {
2282 + $out = array();
2283 + foreach ( (array) $handles as $handle ) {
2284 + $handle = (string) $handle;
2285 + $payload = openstation_resolve_style_payload( $handle );
2286 + if ( '' === $payload['url'] ) {
2287 + continue;
2288 + }
2289 + $out[ $handle ] = $payload;
2290 + }
2291 + return $out;
2292 +}
2293 +
2294 +/**
1423 2295 * Fire a `_doing_it_wrong()` notice exactly once per handle per
1424 - * request. Shared by every `desktop_mode_build_desktop_*_scripts_payload()`
2296 + * request. Shared by every `openstation_build_desktop_*_scripts_payload()`
1425 2297 * caller — payload builders run on every shell-config rebuild
1426 2298 * (multiple times per page load via REST + admin-bar refresh +
1427 2299 * tests), so undeduped notices spam the error log AND trip
1428 2300 * `expectedIncorrectUsage` assertions in unrelated tests.
1429 2301 *
1430 - * @param string $function_name `desktop_mode_register_*_script` — passed verbatim to `_doing_it_wrong`.
2302 + * @param string $function_name `openstation_register_*_script` — passed verbatim to `_doing_it_wrong`.
1431 2303 * @param string $kind Human label: `Command`, `Settings-tab`, `Title-bar button`.
1432 2304 * @param string $handle Offending script handle.
1433 2305 */
1434 -function desktop_mode_warn_unresolvable_script_handle( $function_name, $kind, $handle ) {
2306 +function openstation_warn_unresolvable_script_handle( $function_name, $kind, $handle ) {
1435 2307 static $warned = array();
1436 - $cache_key = $function_name . '|' . $handle;
2308 + $cache_key = $function_name . '|' . $handle;
1437 2309 if ( isset( $warned[ $cache_key ] ) ) {
1438 2310 return;
1439 2311 }
1440 2312 $warned[ $cache_key ] = true;
@@ -1449,9 +2321,9 @@
1449 2321 _doing_it_wrong(
1450 2322 esc_html( $function_name ),
1451 2323 sprintf(
1452 2324 /* translators: 1: kind ("Command"/"Settings-tab"/"Title-bar button"), 2: handle. */
1453 - esc_html__( '%1$s script handle "%2$s" is not registered with WordPress (no `wp_register_script` call found). The script will not load.', 'desktop-mode' ),
2325 + esc_html__( '%1$s script handle "%2$s" could not be resolved: no `wp_register_script( \'%2$s\', … )` call had run by the time the shell harvested its payload. Register the handle on `admin_enqueue_scripts` at priority 5 or earlier — the harvest itself runs at priority 10, and a handle registered alongside it may or may not exist yet depending on plugin load order. Until then the script will not load.', 'desktop-mode' ),
1454 2326 esc_html( $kind ),
1455 2327 esc_html( $handle )
1456 2328 ),
1457 2329 '0.8.1'
@@ -1463,25 +2335,26 @@
1463 2335 * cache for the unresolvable-handle notice. Tests call this in
1464 2336 * `set_up` so prior tests' synthetic handles can't leak into
1465 2337 * later assertions about payload shape.
1466 2338 */
1467 -function desktop_mode_flush_script_handle_registries() {
2339 +function openstation_flush_script_handle_registries() {
1468 2340 $flushers = array(
1469 - 'desktop_mode_flush_desktop_command_script_registry',
1470 - 'desktop_mode_flush_desktop_settings_tab_script_registry',
1471 - 'desktop_mode_flush_dock_rail_renderer_script_registry',
1472 - 'desktop_mode_flush_desktop_titlebar_button_script_registry',
1473 - 'desktop_mode_flush_desktop_unfocus_effect_script_registry',
1474 - 'desktop_mode_flush_window_link_renderer_script_registry',
1475 - 'desktop_mode_flush_window_theme_script_registry',
1476 - 'desktop_mode_flush_window_theme_registry',
1477 - 'desktop_mode_flush_window_control_script_registry',
1478 - 'desktop_mode_flush_window_control_registry',
1479 - 'desktop_mode_flush_window_slot_script_registry',
1480 - 'desktop_mode_flush_window_slot_registry',
1481 - 'desktop_mode_flush_window_chrome_script_registry',
1482 - 'desktop_mode_flush_window_chrome_registry',
1483 - 'desktop_mode_flush_window_notice_registry',
2341 + 'openstation_flush_desktop_command_script_registry',
2342 + 'openstation_flush_desktop_settings_tab_script_registry',
2343 + 'openstation_flush_dock_rail_renderer_script_registry',
2344 + 'openstation_flush_desktop_titlebar_button_script_registry',
2345 + 'openstation_flush_desktop_window_action_script_registry',
2346 + 'openstation_flush_desktop_unfocus_effect_script_registry',
2347 + 'openstation_flush_window_link_renderer_script_registry',
2348 + 'openstation_flush_window_theme_script_registry',
2349 + 'openstation_flush_window_theme_registry',
2350 + 'openstation_flush_window_control_script_registry',
2351 + 'openstation_flush_window_control_registry',
2352 + 'openstation_flush_window_slot_script_registry',
2353 + 'openstation_flush_window_slot_registry',
2354 + 'openstation_flush_window_chrome_script_registry',
2355 + 'openstation_flush_window_chrome_registry',
2356 + 'openstation_flush_window_notice_registry',
1484 2357 );
1485 2358
1486 2359 foreach ( $flushers as $flusher ) {
1487 2360 if ( function_exists( $flusher ) ) {
@@ -1488,32 +2361,157 @@
1488 2361 $flusher();
1489 2362 }
1490 2363 }
1491 2364
1492 - desktop_mode_warn_unresolvable_script_handle( '', '', '__flush__' );
2365 + openstation_warn_unresolvable_script_handle( '', '', '__flush__' );
1493 2366 }
1494 2367
1495 2368 /**
1496 - * Serialize the server-declared native-window registry into the
1497 - * payload shape the shell consumes. For each entry registered via
1498 - * `desktop_mode_register_window()`, we capture: the window's
1499 - * metadata (id/title/icon/placement/dimensions/autofocus), the
1500 - * rendered template HTML (by running the template callback into an
1501 - * output buffer), and the URL of the enqueued script handle (so
1502 - * mid-session activations can load the plugin's JS dynamically
1503 - * without a full shell reload).
2369 + * Collect the native-window payload: slim per-window entries plus a
2370 + * handle-keyed script-data map.
1504 2371 *
1505 - * @return array[]
2372 + * For each entry registered via `openstation_register_window()` the
2373 + * `windows` list captures the window's metadata
2374 + * (id/title/icon/placement/dimensions/autofocus), the rendered
2375 + * template HTML, and the HANDLE NAMES of its script, companions and
2376 + * tab scripts. The resolved data those handles stand for — URL plus
2377 + * harvested `wp_localize_script` / `wp_add_inline_script` /
2378 + * translations, see `openstation_resolve_script_payload()` — lives
2379 + * ONCE per handle in `scriptData`, and the shell joins the two on
2380 + * receipt (`hydrateServerEntries()` in `src/native-windows.ts`).
2381 + * Each loadable handle's entry also names its dependency closure in
2382 + * `deps` (ordered handles, every one of them a key of the same map)
2383 + * so the lazy loader can bring a bundle's declared packages — and
2384 + * a src-less alias carrying its config — into the tab before it.
2385 + *
2386 + * The split exists because script data is a property of the HANDLE,
2387 + * not of the window: every App Framework window rides
2388 + * `openstation-app-runtime`, and inlining each entry's resolved copy
2389 + * serialized the same localize blobs and the same shared config set
2390 + * four times over — `scriptL10n` alone was ~100 KB of the boot
2391 + * payload, most of it repetition. The synthesized
2392 + * `openStationWindowConfig[ id ]` assignments group by handle for
2393 + * the same reason they used to ride every sharing entry: the shell
2394 + * fetches a URL once, and a bundle can serve one window from inside
2395 + * another (the Users window mounts the Profile form, which reads the
2396 + * user-edit config), so whichever entry loads the bundle must
2397 + * deliver the whole handle's config set.
2398 + *
2399 + * Style data stays inline on the entries — it never had a
2400 + * duplication problem worth a second map ( companion styles across
2401 + * the whole registry total ~2 KB ).
2402 + *
2403 + * @return array{windows:array[],scriptData:array<string,array{url:string,before:string[],after:string[],l10n:string[],translations:string,deps:string[]}>}
1506 2404 */
1507 -function desktop_mode_build_native_windows_payload() {
1508 - if ( ! function_exists( 'desktop_mode_native_window_registry' ) ) {
1509 - return array();
2405 +function openstation_collect_native_windows_payload() {
2406 + $empty = array(
2407 + 'windows' => array(),
2408 + 'scriptData' => array(),
2409 + );
2410 + if ( ! function_exists( 'openstation_native_window_registry' ) ) {
2411 + return $empty;
1510 2412 }
1511 - $registry = desktop_mode_native_window_registry();
2413 +
2414 + $registry = openstation_native_window_registry();
1512 2415 if ( ! is_array( $registry ) ) {
1513 - return array();
2416 + return $empty;
1514 2417 }
1515 2418
2419 + // A window says which admin offers it (`admin` in its registration:
2420 + // `site`, `network` or `any`). Every native window OpenStation
2421 + // ships is site-scoped, reading the current site's REST API, so in
2422 + // the network admin a `users.php` tile meaning "everyone on the
2423 + // network" would open one site's user list; those stay off the
2424 + // network shell. A window that declares `network` (the Network app)
2425 + // is offered there and nowhere else.
2426 + //
2427 + // Dropping the site windows there is also what disarms the
2428 + // client-side URL remaps: they match on the tail of a pathname
2429 + // (`endsWith( '/users.php' )`) and the network admin serves
2430 + // same-named files one directory down, but with nothing registered
2431 + // `openById()` finds no window and the remap falls through to the
2432 + // iframe.
2433 + $registry = array_filter( $registry, 'openstation_native_window_offered_here' );
2434 +
2435 + $script_data = array();
2436 +
2437 + // Handles resolved as a bundle to LOAD (a window's script, a
2438 + // companion, a tab) and what that visit answered — the handle, or
2439 + // '' for nothing to load — as opposed to reached only as
2440 + // somebody's dependency. A handle can be both — resolved as a
2441 + // dependency first, then named as a window's own script — and
2442 + // only the bundle visit computes its own closure.
2443 + $resolved_as_bundle = array();
2444 +
2445 + // Resolve a handle into the map, once. Returns the handle when it
2446 + // resolved to something loadable, '' when it did not (never
2447 + // registered, no src) — the same silent drop the inline shape
2448 + // applied to companions and tab scripts.
2449 + //
2450 + // The handle's dependency closure rides along as `deps`: an
2451 + // ordered handle list, each of which lands in the same map. A
2452 + // bundle delivered lazily never goes through WordPress's own
2453 + // dependency resolution — the loader injects one URL — so a
2454 + // window declaring `wp-api-fetch` found `wp.apiFetch` undefined,
2455 + // and one whose config rides a src-less alias handle (a common
2456 + // shape: `wp_register_script( $h, false )` plus
2457 + // `wp_add_inline_script()`, declared as the bundle's dependency
2458 + // so it always runs first) booted with no config at all. Anything
2459 + // the document already ran is skipped on the client, so a page
2460 + // that carried the packages anyway pays nothing.
2461 + $collect_handle = static function ( $handle ) use ( &$script_data, &$resolved_as_bundle ) {
2462 + $handle = (string) $handle;
2463 + if ( '' === $handle ) {
2464 + return '';
2465 + }
2466 + if ( isset( $resolved_as_bundle[ $handle ] ) ) {
2467 + return $resolved_as_bundle[ $handle ];
2468 + }
2469 + $payload = isset( $script_data[ $handle ] )
2470 + ? $script_data[ $handle ]
2471 + : openstation_resolve_script_payload( $handle );
2472 + if ( '' === $payload['url'] ) {
2473 + $resolved_as_bundle[ $handle ] = '';
2474 + return '';
2475 + }
2476 + $resolved_as_bundle[ $handle ] = $handle;
2477 + $deps = array();
2478 + foreach ( openstation_resolve_script_dependencies( $handle ) as $dep ) {
2479 + $dep_handle = (string) $dep['handle'];
2480 + unset( $dep['handle'] );
2481 + if ( ! isset( $script_data[ $dep_handle ] ) ) {
2482 + $dep['deps'] = array();
2483 + $script_data[ $dep_handle ] = $dep;
2484 + }
2485 + $deps[] = $dep_handle;
2486 + }
2487 + $payload['deps'] = $deps;
2488 + $script_data[ $handle ] = $payload;
2489 + return $handle;
2490 + };
2491 +
2492 + // Synthesized `openStationWindowConfig[ id ]` assignments, grouped
2493 + // by script handle (see the function docblock). Collected first so
2494 + // they can be appended to each handle's map entry exactly once,
2495 + // after its own harvested data — the same order the print pipeline
2496 + // would have used.
2497 + $config_snippets_by_handle = array();
2498 + foreach ( $registry as $entry ) {
2499 + $handle = isset( $entry['script'] ) ? (string) $entry['script'] : '';
2500 + if ( '' === $handle || ! is_callable( $entry['template'] ) ) {
2501 + continue;
2502 + }
2503 + $window_config = openstation_filter_native_window_config( $entry );
2504 + if ( empty( $window_config ) ) {
2505 + continue;
2506 + }
2507 + $config_snippets_by_handle[ $handle ][ $entry['id'] ] = sprintf(
2508 + 'window.openStationWindowConfig=window.openStationWindowConfig||{};window.openStationWindowConfig[%s]=%s;',
2509 + wp_json_encode( $entry['id'] ),
2510 + wp_json_encode( $window_config )
2511 + );
2512 + }
2513 +
1516 2514 $out = array();
1517 2515 foreach ( $registry as $entry ) {
1518 2516 if ( ! is_callable( $entry['template'] ) ) {
1519 2517 continue;
@@ -1520,23 +2518,43 @@
1520 2518 }
1521 2519
1522 2520 // Capture the template HTML (tab-wrapped when any
1523 2521 // additional tabs are registered via
1524 - // `desktop_mode_register_window_tab()`; flat otherwise).
2522 + // `openstation_register_window_tab()`; flat otherwise).
1525 2523 // Captured as a string so the shell can inject it as a
1526 2524 // `<template>` at mid-session plugin activation without a
1527 2525 // reload.
1528 - $template_html = desktop_mode_build_native_window_template_html( $entry );
2526 + $template_html = openstation_build_native_window_template_html( $entry );
1529 2527
1530 - // Resolve script handle → full payload (URL + harvested
1531 - // `extra` data) so the shell can inject a `<script>` tag
1532 - // dynamically on mid-session activation WITHOUT dropping
1533 - // `wp_localize_script` / `wp_add_inline_script` data the way
1534 - // the bare `<script src>` lazy-load path would. See
1535 - // `desktop_mode_resolve_script_payload()` for shape.
1536 - $script_handle = isset( $entry['script'] ) ? (string) $entry['script'] : '';
1537 - $script_payload = desktop_mode_resolve_script_payload( $script_handle );
2528 + // `$collect_handle()` answers "is there a bundle to fetch?", and
2529 + // returns '' when the handle resolves to no URL — a src-less
2530 + // alias handle registered only to carry `preload_script` or
2531 + // inline data, for instance. That is the right answer for
2532 + // `scriptHandle`, which names something to load. It is the
2533 + // wrong answer for `ownerHandle`, which names WHO the window
2534 + // belongs to: attribution does not depend on whether the owner
2535 + // happens to ship a file. Shipping '' there broke the
2536 + // documented "always populated" contract and blanked
2537 + // `wp.os.debug.window()`.
2538 + $declared_script = isset( $entry['script'] ) ? (string) $entry['script'] : '';
2539 + $script_handle = $collect_handle( $declared_script );
2540 + $owner_handle = '' !== $script_handle ? $script_handle : $declared_script;
1538 2541
2542 + // Companion handles (`scripts` arg) — bundles that extend the
2543 + // window from outside it and must be in the tab before its
2544 + // render callback paints. Kept as an ordered handle list; the
2545 + // shell loads them in declared order ahead of the window's
2546 + // own script, resolving each through `scriptData`.
2547 + $companion_scripts = array();
2548 + if ( ! empty( $entry['scripts'] ) && is_array( $entry['scripts'] ) ) {
2549 + foreach ( $entry['scripts'] as $companion_handle ) {
2550 + $companion_handle = $collect_handle( $companion_handle );
2551 + if ( '' !== $companion_handle ) {
2552 + $companion_scripts[] = $companion_handle;
2553 + }
2554 + }
2555 + }
2556 +
1539 2557 // Resolve the optional style handle alongside the script so the
1540 2558 // shell's lazy-loader can inject a `<link rel="stylesheet">`
1541 2559 // (and any `wp_add_inline_style()` blobs) on mid-session
1542 2560 // activation. Empty payload when no handle was declared OR the
@@ -1541,77 +2559,148 @@
1541 2559 // (and any `wp_add_inline_style()` blobs) on mid-session
1542 2560 // activation. Empty payload when no handle was declared OR the
1543 2561 // handle isn't registered — both treated as "no styles to load."
1544 2562 $style_handle = isset( $entry['style'] ) ? (string) $entry['style'] : '';
1545 - $style_payload = desktop_mode_resolve_style_payload( $style_handle );
2563 + $style_payload = openstation_resolve_style_payload( $style_handle );
1546 2564
1547 - // `config` arg on `desktop_mode_register_window()` — discoverable
1548 - // alternative to `wp_localize_script`. We synthesize a localize
1549 - // snippet so it lands through the same delivery path as native
1550 - // `wp_localize_script`. The bundle reads
1551 - // `window.desktopModeWindowConfig[id]` (or via
1552 - // `wp.desktop.getWindowConfig(id)`).
1553 - if ( ! empty( $entry['config'] ) && is_array( $entry['config'] ) ) {
1554 - $script_payload['l10n'][] = sprintf(
1555 - 'window.desktopModeWindowConfig=window.desktopModeWindowConfig||{};window.desktopModeWindowConfig[%s]=%s;',
1556 - wp_json_encode( $entry['id'] ),
1557 - wp_json_encode( $entry['config'] )
1558 - );
2565 + // Companion style handles (`styles` arg) — stylesheets the
2566 + // shell injects on the window's FIRST OPEN, after the window's
2567 + // own style, in declared order. The styles-side mirror of
2568 + // `companionScripts`, with different timing on purpose: the
2569 + // window's own `style` lands when the window registers so a
2570 + // mid-session activation paints, but a companion exists to be
2571 + // deferred — it costs nothing until the window is actually
2572 + // shown. Unregistered handles drop, same as script companions.
2573 + $companion_styles = array();
2574 + if ( ! empty( $entry['styles'] ) && is_array( $entry['styles'] ) ) {
2575 + foreach ( $entry['styles'] as $companion_style_handle ) {
2576 + $companion_style_handle = (string) $companion_style_handle;
2577 + $companion_style_payload = openstation_resolve_style_payload( $companion_style_handle );
2578 + if ( '' === $companion_style_payload['url'] ) {
2579 + continue;
2580 + }
2581 + $companion_styles[] = array(
2582 + 'styleUrl' => $companion_style_payload['url'],
2583 + 'styleHandle' => $companion_style_handle,
2584 + 'styleInline' => $companion_style_payload['inline'],
2585 + );
2586 + }
1559 2587 }
1560 2588
1561 - // Tab metadata (label + extra script payloads) ships alongside
1562 - // the template so the shell can render a picker UI or load
1563 - // additional tab scripts when a tab's activation is late.
2589 + // Tab metadata ships alongside the template so the shell can
2590 + // render a picker UI, and each tab's script handle joins the
2591 + // map so a late tab activation can still load its bundle.
1564 2592 $tab_descriptors = array();
1565 - if ( function_exists( 'desktop_mode_get_native_window_tabs' ) ) {
1566 - foreach ( desktop_mode_get_native_window_tabs( $entry['id'] ) as $tab ) {
1567 - // The resolver returns the empty payload shape itself
1568 - // for an empty handle — no need to hand-write it here.
1569 - $tab_payload = desktop_mode_resolve_script_payload( $tab['script'] );
2593 + if ( function_exists( 'openstation_get_native_window_tabs' ) ) {
2594 + foreach ( openstation_get_native_window_tabs( $entry['id'] ) as $tab ) {
1570 2595 $tab_descriptors[] = array(
1571 - 'value' => $tab['value'],
1572 - 'label' => $tab['label'],
1573 - 'isMain' => $tab['is_main'],
1574 - 'scriptUrl' => $tab_payload['url'],
1575 - 'scriptHandle' => $tab['script'],
1576 - 'scriptBefore' => $tab_payload['before'],
1577 - 'scriptAfter' => $tab_payload['after'],
1578 - 'scriptL10n' => $tab_payload['l10n'],
1579 - 'scriptTranslations' => $tab_payload['translations'],
2596 + 'value' => $tab['value'],
2597 + 'label' => $tab['label'],
2598 + 'isMain' => $tab['is_main'],
2599 + 'scriptHandle' => $collect_handle( $tab['script'] ),
1580 2600 );
1581 2601 }
1582 2602 }
1583 2603
1584 2604 $out[] = array(
1585 - 'id' => $entry['id'],
1586 - 'title' => $entry['title'],
1587 - 'icon' => $entry['icon'],
1588 - 'placement' => $entry['placement'],
1589 - 'width' => $entry['width'],
1590 - 'height' => $entry['height'],
1591 - 'minWidth' => $entry['min_width'],
1592 - 'minHeight' => $entry['min_height'],
1593 - 'autofocus' => $entry['autofocus'],
1594 - 'templateId' => 'desktop-mode-native-window-' . $entry['id'],
1595 - 'templateHtml' => $template_html,
1596 - 'scriptUrl' => $script_payload['url'],
1597 - 'scriptHandle' => $script_handle,
1598 - 'ownerHandle' => $script_handle,
1599 - 'scriptBefore' => $script_payload['before'],
1600 - 'scriptAfter' => $script_payload['after'],
1601 - 'scriptL10n' => $script_payload['l10n'],
1602 - 'scriptTranslations' => $script_payload['translations'],
1603 - 'styleUrl' => $style_payload['url'],
1604 - 'styleHandle' => $style_handle,
1605 - 'styleInline' => $style_payload['inline'],
1606 - 'tabs' => $tab_descriptors,
2605 + 'id' => $entry['id'],
2606 + 'title' => $entry['title'],
2607 + 'icon' => $entry['icon'],
2608 + 'placement' => $entry['placement'],
2609 + // `'app'` or `'control'` — the navigation kind, which
2610 + // decides the launcher's default placement and its dock
2611 + // zone. See `src/nav/defaults.ts`.
2612 + 'navKind' => isset( $entry['nav_kind'] ) ? $entry['nav_kind'] : 'app',
2613 + // Sort key among system tiles. Absent / 0 puts a plugin's
2614 + // launcher ahead of the shell's own trailing cluster.
2615 + 'dockOrder' => isset( $entry['dock_order'] ) ? (int) $entry['dock_order'] : 0,
2616 + 'placeable' => ! empty( $entry['placeable'] ),
2617 + 'width' => $entry['width'],
2618 + 'height' => $entry['height'],
2619 + 'minWidth' => $entry['min_width'],
2620 + 'minHeight' => $entry['min_height'],
2621 + 'autofocus' => $entry['autofocus'],
2622 + 'templateId' => 'os-native-window-' . $entry['id'],
2623 + 'templateHtml' => $template_html,
2624 + 'scriptHandle' => $script_handle,
2625 + 'ownerHandle' => $owner_handle,
2626 + 'companionScripts' => $companion_scripts,
2627 + // Whether the shell loads the bundle at boot rather than on
2628 + // first open. Off by default: a window's script is dead
2629 + // weight on every admin page until the window is actually
2630 + // opened.
2631 + 'preloadScript' => ! empty( $entry['preload_script'] ),
2632 + 'styleUrl' => $style_payload['url'],
2633 + 'styleHandle' => $style_handle,
2634 + 'styleInline' => $style_payload['inline'],
2635 + 'companionStyles' => $companion_styles,
2636 + 'tabs' => $tab_descriptors,
1607 2637 );
1608 2638 }
1609 2639
1610 - return $out;
2640 + // Append each handle's synthesized config set to its map entry —
2641 + // once, after the handle's own harvested data. The snippets land
2642 + // in REGISTRY-ITERATION order for every consumer of the handle;
2643 + // the old per-entry shape put each window's own config first, an
2644 + // ordering nothing could observe (each snippet assigns a distinct
2645 + // `openStationWindowConfig[ id ]` key and none reads another), so
2646 + // it is deliberately not preserved. Configs for handles that
2647 + // resolved to nothing are undeliverable and drop, exactly as they
2648 + // always did.
2649 + foreach ( $config_snippets_by_handle as $handle => $snippets ) {
2650 + if ( ! isset( $script_data[ $handle ] ) ) {
2651 + continue;
2652 + }
2653 + foreach ( $snippets as $snippet ) {
2654 + $script_data[ $handle ]['l10n'][] = $snippet;
2655 + }
2656 + }
2657 +
2658 + return array(
2659 + 'windows' => $out,
2660 + 'scriptData' => $script_data,
2661 + );
1611 2662 }
1612 2663
1613 2664 /**
2665 + * The `windows` half of {@see openstation_collect_native_windows_payload()}.
2666 + *
2667 + * Kept as the historical entry point — tests and older call sites
2668 + * ask for the entry list alone. Anything that also needs the
2669 + * script-data map (everything that actually LOADS a bundle) should
2670 + * call the collector and take both halves from one build.
2671 + *
2672 + * @return array[]
2673 + */
2674 +function openstation_build_native_windows_payload() {
2675 + $bundle = openstation_collect_native_windows_payload();
2676 + return $bundle['windows'];
2677 +}
2678 +
2679 +/**
2680 + * Cleans a `$menu` / `$submenu` title for display.
2681 + *
2682 + * Strips badge spans first (`<span class="update-plugins count-3">`),
2683 + * then any remaining markup. An empty result means the entry has no
2684 + * usable label: plugins register `menu_title => null` to keep a page
2685 + * reachable while hiding its row from classic admin's left menu, and
2686 + * those must not become tabs.
2687 + *
2688 + * Shared so everything deciding "is this a visible tab?" agrees.
2689 + * {@see openstation_chromeless_submenu_tab_urls()} hides an in-page
2690 + * button on the strength of a tab existing, so a divergence here would
2691 + * hide a button with nothing on screen to replace it.
2692 + *
2693 + * @param string $raw_title Raw `$menu[$i][0]` / `$submenu[$p][$i][0]` value.
2694 + * @return string Cleaned title, empty when there is none.
2695 + */
2696 +function openstation_menu_item_title( $raw_title ) {
2697 + $stripped = preg_replace( '/<span[^>]*>.*?<\/span>/s', '', (string) $raw_title );
2698 +
2699 + return trim( wp_strip_all_tags( $stripped ) );
2700 +}
2701 +
2702 +/**
1614 2703 * Determines whether a menu slug references a real file under `wp-admin/`.
1615 2704 *
1616 2705 * Mirrors the decision core's `wp-admin/menu-header.php` makes when
1617 2706 * linking menu items: strip the query portion, then check whether the
@@ -1631,9 +2720,9 @@
1631 2720 *
1632 2721 * @param string $slug The raw menu item slug.
1633 2722 * @return bool True when the query-stripped slug is a file under `wp-admin/`.
1634 2723 */
1635 -function desktop_mode_is_admin_file_slug( $slug ) {
2724 +function openstation_is_admin_file_slug( $slug ) {
1636 2725 $file = $slug;
1637 2726 $pos = strpos( $file, '?' );
1638 2727 if ( false !== $pos ) {
1639 2728 $file = substr( $file, 0, $pos );
@@ -1646,13 +2735,45 @@
1646 2735 return file_exists( ABSPATH . 'wp-admin/' . $file );
1647 2736 }
1648 2737
1649 2738 /**
2739 + * The admin URL a menu slug resolves against.
2740 + *
2741 + * Follows the admin the request is in: the network admin's own URL there,
2742 + * because its globals carry network slugs (`sites.php`, `settings.php`)
2743 + * that exist only under `wp-admin/network/`, and the site admin's
2744 + * everywhere else.
2745 + *
2746 + * The same answer `self_admin_url()` gives, without its filter. That
2747 + * filter receives the path, so a host can use it to send one screen
2748 + * somewhere else, and WordPress.com points `plugin-install.php` at its own
2749 + * installer. Resolved through it, the wp-admin original of a menu row the
2750 + * host replaced reads as off-site, and the dock drops it along with the
2751 + * replacement, which is how Plugins > Add Plugin disappears there.
2752 + *
2753 + * @param string $path Optional. Path relative to the admin URL.
2754 + * @return string Absolute admin URL.
2755 + */
2756 +function openstation_menu_admin_url( $path = '' ) {
2757 + if ( is_network_admin() ) {
2758 + return network_admin_url( $path );
2759 + }
2760 + if ( is_user_admin() ) {
2761 + return user_admin_url( $path );
2762 + }
2763 + return admin_url( $path );
2764 +}
2765 +
2766 +/**
1650 2767 * Converts a menu item slug to a full admin URL.
1651 2768 *
2769 + * Resolution goes through {@see openstation_menu_admin_url()}, which
2770 + * follows the admin the request is in without passing through the
2771 + * filterable `self_admin_url()`.
2772 + *
1652 2773 * Handles three slug shapes:
1653 2774 * 1. Direct file references (`edit.php`, `upload.php`) — passed
1654 - * through `admin_url()` as-is.
2775 + * through `openstation_menu_admin_url()` as-is.
1655 2776 * 2. Plain plugin page slugs (`my-plugin`) — routed through
1656 2777 * `admin.php?page=<slug>` with the slug `rawurlencode()`d.
1657 2778 * 3. Plugin page slugs that embed extra query parameters
1658 2779 * (`wc-admin&path=/customers`) — split on the first `&`, the
@@ -1680,9 +2801,9 @@
1680 2801 *
1681 2802 * @param string $slug The menu item slug or URL.
1682 2803 * @return string The full admin URL, sanitized via `esc_url_raw()`.
1683 2804 */
1684 -function desktop_mode_menu_item_url( $slug ) {
2805 +function openstation_menu_item_url( $slug ) {
1685 2806 // Already a full URL.
1686 2807 if ( str_starts_with( $slug, 'http://' ) || str_starts_with( $slug, 'https://' ) ) {
1687 2808 return esc_url_raw( $slug );
1688 2809 }
@@ -1711,11 +2832,11 @@
1711 2832 // file check wins over the registration check, same as classic
1712 2833 // admin's `menu-header.php`.
1713 2834 if (
1714 2835 false !== strpos( $slug, '.php' ) &&
1715 - ( ! isset( $_parent_pages[ $slug ] ) || desktop_mode_is_admin_file_slug( $slug ) )
2836 + ( ! isset( $_parent_pages[ $slug ] ) || openstation_is_admin_file_slug( $slug ) )
1716 2837 ) {
1717 - return esc_url_raw( admin_url( $slug ) );
2838 + return esc_url_raw( openstation_menu_admin_url( $slug ) );
1718 2839 }
1719 2840
1720 2841 // Plugin page slug with embedded query parameters
1721 2842 // (e.g., 'wc-admin&path=/customers'). Split the page slug from
@@ -1739,16 +2860,16 @@
1739 2860 // `&` separators we need to keep raw for the downstream
1740 2861 // `add_query_arg()` and the JS slug compare).
1741 2862 //
1742 2863 // Resolution rules, identical to core:
1743 - // 1. Slug registered under a `.php` parent that itself isn't
1744 - // a parent (Tools → `tools.php?page=…`, Settings →
1745 - // `options-general.php?page=…`).
1746 - // 2. Slug registered as a top-level menu, OR under a slug-
1747 - // based parent (WC: `woocommerce` → `admin.php?page=…`).
1748 - // 3. Slug not registered at all → fall back to `admin.php`
1749 - // so the URL still targets a real dispatcher (matches the
1750 - // pre-resolver behavior callers depended on).
2864 + // 1. Slug registered under a `.php` parent that itself isn't
2865 + // a parent (Tools → `tools.php?page=…`, Settings →
2866 + // `options-general.php?page=…`).
2867 + // 2. Slug registered as a top-level menu, OR under a slug-
2868 + // based parent (WC: `woocommerce` → `admin.php?page=…`).
2869 + // 3. Slug not registered at all → fall back to `admin.php`
2870 + // so the URL still targets a real dispatcher (matches the
2871 + // pre-resolver behavior callers depended on).
1751 2872 $host = 'admin.php?page=' . rawurlencode( $slug );
1752 2873 if ( isset( $_parent_pages[ $slug ] ) ) {
1753 2874 $parent_slug = $_parent_pages[ $slug ];
1754 2875 if ( $parent_slug && ! isset( $_parent_pages[ $parent_slug ] ) ) {
@@ -1755,9 +2876,9 @@
1755 2876 $host = add_query_arg( 'page', $slug, $parent_slug );
1756 2877 }
1757 2878 }
1758 2879
1759 - $url = admin_url( $host );
2880 + $url = openstation_menu_admin_url( $host );
1760 2881 if ( ! empty( $extra_args ) ) {
1761 2882 $url = add_query_arg( $extra_args, $url );
1762 2883 }
1763 2884 return esc_url_raw( $url );