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 +1603 -423 0.9.01.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,10 +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
18 - * @since 0.8.1
17 + * @package OpenStation
19 18 */
20 19
21 20 defined( 'ABSPATH' ) || exit;
22 21
@@ -27,14 +26,12 @@
27 26 * Iterates through the global $menu and $submenu arrays, filters out
28 27 * separators and items the current user can't access, and returns a
29 28 * clean array of dock items ready for JSON serialization.
30 29 *
31 - * @since 0.1.0
32 - *
33 30 * @return array[] Array of dock item arrays, each containing:
34 31 * id, title, icon, url, badge, submenu.
35 32 */
36 -function desktop_mode_build_dock_items() {
33 +function openstation_build_dock_items() {
37 34 global $menu, $submenu;
38 35
39 36 if ( empty( $menu ) ) {
40 37 return array();
@@ -57,12 +54,18 @@
57 54 if ( ! empty( $item[1] ) && ! current_user_can( $item[1] ) ) {
58 55 continue;
59 56 }
60 57
61 - // Extract the clean title: strip badge spans first, then strip remaining tags.
62 - $raw_title = preg_replace( '/<span[^>]*>.*?<\/span>/s', '', $item[0] );
63 - $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 + }
64 65
66 + $title = openstation_menu_item_title( $item[0] );
67 +
65 68 // Extract badge count from the title HTML.
66 69 $badge = 0;
67 70 if ( preg_match( '/class="(?:update-plugins|awaiting-mod)[^"]*count-(\d+)"/', $item[0], $matches ) ) {
68 71 $badge = (int) $matches[1];
@@ -80,17 +83,29 @@
80 83 // dock count always agrees with what the window shows (GH#258).
81 84 if (
82 85 'plugins.php' === $item[2] &&
83 86 ! is_multisite() &&
84 - function_exists( 'desktop_mode_plugins_window_count_visible_updates' )
87 + function_exists( 'openstation_plugins_window_count_visible_updates' )
85 88 ) {
86 - $badge = desktop_mode_plugins_window_count_visible_updates();
89 + $badge = openstation_plugins_window_count_visible_updates();
87 90 }
88 91
89 92 // Determine the icon. Menu entries can set `$item[6]` to anything
90 93 // — a dashicon class, a remote URL, a data:URI, 'none', or 'div'
91 94 // — so normalize before we serialize it for the shell JS.
92 - $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 );
93 108
94 109 // Build the full URL for the menu item.
95 110 //
96 111 // `$parent_url` is the slug-derived URL (`admin.php?page=<slug>`
@@ -98,11 +113,20 @@
98 113 // reference value the self-link strip below compares against.
99 114 // The effective `$url` we ship to the shell can be rewritten
100 115 // further down to the first visible submenu's URL — see the
101 116 // note after the loop.
102 - $parent_url = desktop_mode_menu_item_url( $item[2] );
103 - $url = $parent_url;
117 + $parent_url = openstation_menu_item_url( $item[2] );
118 + $parent_external = openstation_menu_item_is_external( $parent_url );
104 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 +
105 129 // Build submenu items.
106 130 //
107 131 // WordPress auto-prepends a self-link entry to every parent
108 132 // menu's `$submenu[$slug]` (the first child shares the parent's
@@ -109,19 +133,27 @@
109 133 // slug + URL — that's what `add_menu_page()` generates so the
110 134 // admin UI can render a clickable parent in the sidebar). For
111 135 // the shell's JS surface we strip this entry so:
112 136 //
113 - // - `submenu.length === 0` reliably means "no real children"
114 - // (the right-click submenu popover stays suppressed; the
115 - // in-window tab strip stays hidden).
116 - // - `submenu.length > 0` reliably means "has real child links"
117 - // — 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.
118 142 //
119 - // Detection by URL (post-`desktop_mode_menu_item_url()` normalize)
143 + // Detection by URL (post-`openstation_menu_item_url()` normalize)
120 144 // rather than slug equality covers plugins that register a child
121 145 // at a different slug pointing at the parent's URL.
122 - $sub_items = array();
123 - $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;
124 156 if ( ! empty( $submenu[ $item[2] ] ) ) {
125 157 foreach ( $submenu[ $item[2] ] as $sub_item ) {
126 158 if ( ! empty( $sub_item[1] ) && ! current_user_can( $sub_item[1] ) ) {
127 159 continue;
@@ -131,42 +163,208 @@
131 163 // class; the semantics are "shown by default; hide only
132 164 // when `<body class=\"no-customize-support\">`". The
133 165 // Customizer is supported inside chromeless iframes, so
134 166 // these entries belong in the dock.
135 - $sub_url = desktop_mode_menu_item_url( $sub_item[2] );
136 - // Capture the first capability-passing submenu URL so
137 - // we can use it as the parent's effective URL below
138 - // (mirrors `wp-admin/menu-header.php`). Captured BEFORE
139 - // the self-link strip so plugins whose first submenu IS
140 - // the auto-prepended self-link land on the parent URL
141 - // (a no-op rewrite — preserves existing behavior).
142 - if ( null === $first_visible_sub_url ) {
143 - $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;
144 180 }
145 - // Self-link strip — `$sub_url === $parent_url` covers
146 - // WP's auto-prepended entry AND any plugin-registered
147 - // alias that happens to land on the parent URL.
148 - if ( $sub_url === $parent_url ) {
149 - 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;
150 234 }
151 - $sub_raw_title = preg_replace( '/<span[^>]*>.*?<\/span>/s', '', (string) $sub_item[0] );
152 - $sub_title = trim( wp_strip_all_tags( $sub_raw_title ) );
153 - // Skip entries with no resolvable title. Plugins (e.g.
154 - // WooCommerce's `wc-addons` Extensions row) register
155 - // `menu_title => null` to hide a row from classic admin's
156 - // left menu while keeping the page reachable. Without
157 - // this guard the dock renders an empty, label-less tab
158 - // that visually duplicates a sibling entry.
159 - if ( '' === $sub_title ) {
160 - 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;
161 243 }
162 - $sub_items[] = array(
163 - 'title' => $sub_title,
164 - 'url' => $sub_url,
165 - );
166 244 }
167 245 }
168 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 +
169 367 // Mirror `wp-admin/menu-header.php`: when a parent menu has any
170 368 // visible submenu, classic admin rewrites the parent's
171 369 // clickable URL to the first submenu's URL. Plugins like
172 370 // WooCommerce rely on this — their top-level slug
@@ -174,38 +372,66 @@
174 372 // directly. The real landing page is the first submenu
175 373 // (`?page=wc-admin` for WC). Without this rewrite the dock
176 374 // icon points users at a broken URL that classic admin would
177 375 // never have linked to.
178 - 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 ) {
179 384 $url = $first_visible_sub_url;
180 385 }
181 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 +
182 403 $dock_item = array(
183 - 'id' => sanitize_key( $item[5] ?? $item[2] ),
184 - 'title' => $title,
185 - 'icon' => $icon,
186 - 'url' => $url,
187 - 'badge' => $badge,
188 - 'submenu' => $sub_items,
189 - 'multi' => desktop_mode_dock_item_is_multi( $item[2] ),
190 - 'placement' => desktop_mode_dock_placement( $item[2] ),
191 - 'isCore' => desktop_mode_is_core_menu_slug( $item[2] ),
192 - '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 ),
193 421 'pluginName' => null,
194 422 );
195 423 if ( $dock_item['pluginFile'] ) {
196 - $dock_item['pluginName'] = desktop_mode_plugin_display_name( $dock_item['pluginFile'] );
424 + $dock_item['pluginName'] = openstation_plugin_display_name( $dock_item['pluginFile'] );
197 425 }
198 426
199 427 /**
200 428 * Filters a single dock item's data.
201 429 *
202 - * @since 0.1.0
203 - *
204 430 * @param array $dock_item The dock item data.
205 431 * @param string $menu_slug The menu slug.
206 432 */
207 - $dock_item = apply_filters( 'desktop_mode_dock_item', $dock_item, $item[2] );
433 + $dock_item = apply_filters( 'openstation_dock_item', $dock_item, $identity_slug );
208 434
209 435 $items[] = $dock_item;
210 436 }
211 437
@@ -211,16 +437,183 @@
211 437
212 438 /**
213 439 * Filters the dock items before they are passed to JavaScript.
214 440 *
215 - * @since 0.1.0
441 + * @param array[] $items Array of dock item arrays.
442 + */
443 + return apply_filters( 'openstation_dock_items', $items );
444 +}
445 +
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.
216 483 *
217 - * @param array[] $items Array of dock item arrays.
484 + * @param bool $external Whether the URL points off-site.
485 + * @param string $url The resolved menu URL.
218 486 */
219 - return apply_filters( 'desktop_mode_dock_items', $items );
487 + return (bool) apply_filters( 'openstation_menu_item_is_external', $external, $url );
220 488 }
221 489
222 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 +/**
223 616 * Sanitizes a dock icon value for safe injection into the shell JS.
224 617 *
225 618 * Menu items can set their icon to one of:
226 619 *
@@ -247,19 +640,12 @@
247 640 *
248 641 * The return value is always a string safe to drop into an `img.src`,
249 642 * a CSS class, or a CSS `url()` background without further escaping.
250 643 *
251 - * @since 0.4.0
252 - * @since 0.11.0 Rejected `data:` URIs outright (regression — see 0.18.x).
253 - * @since 0.18.x Re-allowed `data:image/svg+xml{;base64,|,}` so plugin
254 - * icons (Yoast, WooCommerce, Jetpack, etc.) appear on the
255 - * dock instead of collapsing to the gear fallback.
256 - * Other `data:` schemes still rejected.
257 - *
258 644 * @param mixed $icon Raw icon value from the menu registration.
259 645 * @return string Sanitized icon string.
260 646 */
261 -function desktop_mode_sanitize_dock_icon( $icon ) {
647 +function openstation_sanitize_dock_icon( $icon ) {
262 648 $fallback = 'dashicons-admin-generic';
263 649 if ( ! is_string( $icon ) || '' === $icon ) {
264 650 return $fallback;
265 651 }
@@ -318,19 +704,17 @@
318 704 * have a single logical state — opening two makes no sense.
319 705 *
320 706 * The default rule matches the base filename of the menu slug against a
321 707 * known list. Plugin authors can override via the
322 - * `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
323 709 * (or force a stock list page into singleton mode).
324 710 *
325 - * @since 0.5.0
326 - *
327 711 * @param string $menu_slug The raw menu slug (e.g. `edit.php`, `upload.php`,
328 712 * or `my-plugin-page`). Query strings are preserved
329 713 * so `edit.php?post_type=page` resolves correctly.
330 714 * @return bool True if this page supports multiple simultaneous windows.
331 715 */
332 -function desktop_mode_dock_item_is_multi( $menu_slug ) {
716 +function openstation_dock_item_is_multi( $menu_slug ) {
333 717 // Multi-capable admin files. Match by the base file regardless of
334 718 // any query string (post_type, taxonomy, page, paged, etc.) so every
335 719 // CPT and every taxonomy inherits the same rule as their parent.
336 720 $multi_files = array(
@@ -340,9 +724,9 @@
340 724 'users.php',
341 725 'edit-comments.php',
342 726 );
343 727
344 - $base = strtok( (string) $menu_slug, '?' );
728 + $base = strtok( (string) $menu_slug, '?' );
345 729 $multi = in_array( $base, $multi_files, true );
346 730
347 731 /**
348 732 * Filters whether a dock item supports multiple open windows.
@@ -351,14 +735,12 @@
351 735 * A "+" affordance appears on the dock icon and a "Open another" action
352 736 * becomes available in the window's title-bar menu. Singletons (false)
353 737 * always focus the existing window when re-opened.
354 738 *
355 - * @since 0.5.0
356 - *
357 739 * @param bool $multi Whether this page is multi-capable.
358 740 * @param string $menu_slug The menu slug (e.g. `edit.php?post_type=page`).
359 741 */
360 - return (bool) apply_filters( 'desktop_mode_dock_item_multi', $multi, $menu_slug );
742 + return (bool) apply_filters( 'openstation_dock_item_multi', $multi, $menu_slug );
361 743 }
362 744
363 745 /**
364 746 * Returns true when `$menu_slug` maps to a first-party WordPress
@@ -382,23 +764,21 @@
382 764 * with custom top-level files can still opt in via the filter
383 765 * below).
384 766 *
385 767 * Plugins + site admins can override any answer via
386 - * `desktop_mode_dock_placement`:
768 + * `openstation_dock_placement`:
387 769 *
388 770 * ```php
389 771 * // Keep Jetpack on the left dock:
390 - * add_filter( 'desktop_mode_dock_placement', function ( $placement, $slug ) {
772 + * add_filter( 'openstation_dock_placement', function ( $placement, $slug ) {
391 773 * return 'jetpack' === $slug ? 'dock' : $placement;
392 774 * }, 10, 2 );
393 775 * ```
394 776 *
395 - * @since 0.9.0
396 - *
397 777 * @param string $menu_slug Menu item slug (e.g. `edit.php`, `edit.php?post_type=foo`, `woocommerce`).
398 778 * @return bool True when the slug is a core admin page.
399 779 */
400 -function desktop_mode_is_core_menu_slug( $menu_slug ) {
780 +function openstation_is_core_menu_slug( $menu_slug ) {
401 781 $slug = (string) $menu_slug;
402 782 $base = strtok( $slug, '?' );
403 783
404 784 // Known top-level core admin files. Stable across WP versions —
@@ -439,8 +819,19 @@
439 819 'link-manager.php', // Link manager (legacy)
440 820 'update-core.php', // Dashboard > Updates
441 821 );
442 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 +
443 834 return in_array( $base, $core_files, true );
444 835 }
445 836
446 837 /**
@@ -449,9 +840,9 @@
449 840 * registered for the menu's page hook.
450 841 *
451 842 * Returns the plugin's main file path (relative to `WP_PLUGIN_DIR`) when
452 843 * the menu was registered by a regular plugin, `null` otherwise. Core
453 - * menus, mu-plugins, drop-ins, theme-registered menus, and Desktop Mode
844 + * menus, mu-plugins, drop-ins, theme-registered menus, and OpenStation
454 845 * itself all return `null` — none of these are deactivatable through the
455 846 * `wp/v2/plugins` REST route, so the dock right-click menu should not
456 847 * offer a deactivate action for them.
457 848 *
@@ -465,19 +856,17 @@
465 856 * lives here.
466 857 * 3. Reflect each callback to find its declaring file. Match the file
467 858 * path against `WP_PLUGIN_DIR/<folder>/…` and use `<folder>` to look
468 859 * up an entry in `get_plugins()`. Return the matching `<folder>/<file>.php`.
469 - * 4. Exclude Desktop Mode itself — deactivating from inside the shell
860 + * 4. Exclude OpenStation itself — deactivating from inside the shell
470 861 * is handled by the plugins-window's self-deactivate path.
471 862 *
472 - * @since 0.27.0
473 - *
474 863 * @param string $menu_slug The menu slug from `$menu[$i][2]` (e.g. `woocommerce`,
475 864 * `admin.php?page=jetpack`, `edit.php?post_type=foo`).
476 865 * @return string|null Plugin file path relative to `WP_PLUGIN_DIR`, or null
477 866 * when the slug isn't owned by a deactivatable plugin.
478 867 */
479 -function desktop_mode_resolve_menu_plugin_file( $menu_slug ) {
868 +function openstation_resolve_menu_plugin_file( $menu_slug ) {
480 869 $slug = (string) $menu_slug;
481 870
482 871 // `get_plugin_page_hookname` + `get_plugins` come from
483 872 // `wp-admin/includes/plugin.php`, which Core loads itself on
@@ -488,12 +877,12 @@
488 877 if ( ! function_exists( 'get_plugin_page_hookname' ) || ! function_exists( 'get_plugins' ) ) {
489 878 return null;
490 879 }
491 880
492 - $self_basename = defined( 'DESKTOP_MODE_FILE' ) ? plugin_basename( DESKTOP_MODE_FILE ) : '';
881 + $self_basename = defined( 'OPENSTATION_FILE' ) ? plugin_basename( OPENSTATION_FILE ) : '';
493 882
494 883 // Strategy 1 — registration-time attribution. The admin_menu hook
495 - // wrapper (see `desktop_mode_install_menu_attribution_tracker`) snapshots
884 + // wrapper (see `openstation_install_menu_attribution_tracker`) snapshots
496 885 // `$menu`/`$submenu` around every admin_menu callback and records
497 886 // "this plugin file added this slug". This is the authoritative
498 887 // source — it captures menus whose page hook isn't predictable from
499 888 // the slug (e.g. WC's `wc-admin&path=/marketing`) and handles
@@ -498,9 +887,9 @@
498 887 // source — it captures menus whose page hook isn't predictable from
499 888 // the slug (e.g. WC's `wc-admin&path=/marketing`) and handles
500 889 // callbacks that simply forward to a shared renderer (which
501 890 // reflection would mis-attribute).
502 - $map = desktop_mode_menu_attribution_map();
891 + $map = openstation_menu_attribution_map();
503 892 if ( isset( $map[ $slug ] ) ) {
504 893 $plugin_file = $map[ $slug ];
505 894 if ( $self_basename && $plugin_file === $self_basename ) {
506 895 return null;
@@ -512,9 +901,9 @@
512 901 // / `edit-tags.php` handle the render, so the page hook would never
513 902 // point at the registering plugin. We caught the plugin at
514 903 // `register_post_type()` / `register_taxonomy()` time via
515 904 // `debug_backtrace()`.
516 - $tracked = desktop_mode_lookup_taxonomy_or_post_type_plugin_file( $slug );
905 + $tracked = openstation_lookup_taxonomy_or_post_type_plugin_file( $slug );
517 906 if ( null !== $tracked ) {
518 907 if ( $self_basename && $tracked === $self_basename ) {
519 908 return null;
520 909 }
@@ -524,12 +913,12 @@
524 913 $base = strtok( $slug, '?' );
525 914
526 915 // Cheap reject: literal core PHP files with no `?page=` parameter
527 916 // (the universal "a plugin registered an admin route" signal). We
528 - // can't reuse `desktop_mode_is_core_menu_slug()` here — that
917 + // can't reuse `openstation_is_core_menu_slug()` here — that
529 918 // classifier strtok's the query string and treats `admin.php?page=foo`
530 919 // as core, which would hide every plugin-registered top-level tile.
531 - if ( desktop_mode_is_pure_core_file( $base ) && false === strpos( $slug, '?page=' ) ) {
920 + if ( openstation_is_pure_core_file( $base ) && false === strpos( $slug, '?page=' ) ) {
532 921 return null;
533 922 }
534 923
535 924 // Strategy 3 — page-hook reflection fallback. The earlier strategies
@@ -545,9 +934,9 @@
545 934
546 935 $hook = $wp_filter[ $hookname ];
547 936 foreach ( $hook->callbacks as $cbs ) {
548 937 foreach ( $cbs as $cb ) {
549 - $plugin_file = desktop_mode_plugin_file_for_callback( $cb['function'] ?? null );
938 + $plugin_file = openstation_plugin_file_for_callback( $cb['function'] ?? null );
550 939 if ( ! $plugin_file ) {
551 940 continue;
552 941 }
553 942 if ( $self_basename && $plugin_file === $self_basename ) {
@@ -565,16 +954,15 @@
565 954 * the plugin folder name as a last-resort fallback if `get_plugins()`
566 955 * has no entry (extremely rare — would mean the plugin file isn't
567 956 * installed but somehow registered a menu).
568 957 *
569 - * @since 0.27.0
570 - *
571 958 * @param string $plugin_file Plugin file relative to `WP_PLUGIN_DIR`.
572 959 * @return string Display name.
573 960 */
574 -function desktop_mode_plugin_display_name( $plugin_file ) {
961 +function openstation_plugin_display_name( $plugin_file ) {
575 962 if ( ! function_exists( 'get_plugins' ) ) {
576 - return strtok( $plugin_file, '/' ) ?: $plugin_file;
963 + $dir = strtok( $plugin_file, '/' );
964 + return $dir ? $dir : $plugin_file;
577 965 }
578 966 $installed = get_plugins();
579 967 if ( isset( $installed[ $plugin_file ]['Name'] ) && '' !== $installed[ $plugin_file ]['Name'] ) {
580 968 return (string) $installed[ $plugin_file ]['Name'];
@@ -588,14 +976,12 @@
588 976 * corresponding plugin file in `get_plugins()`. Returns null when the
589 977 * path isn't under the plugins directory, or doesn't match any active
590 978 * plugin folder.
591 979 *
592 - * @since 0.27.0
593 - *
594 980 * @param string $file Absolute filesystem path.
595 981 * @return string|null Plugin file (`<folder>/<file>.php`) or null.
596 982 */
597 -function desktop_mode_plugin_file_for_path( $file ) {
983 +function openstation_plugin_file_for_path( $file ) {
598 984 if ( ! is_string( $file ) || '' === $file ) {
599 985 return null;
600 986 }
601 987 $plugins_dir = wp_normalize_path( WP_PLUGIN_DIR );
@@ -624,18 +1010,16 @@
624 1010
625 1011 /**
626 1012 * Convenience wrapper: reflect on a callback to find its declaring
627 1013 * file, then map that file to an active plugin via
628 - * {@see desktop_mode_plugin_file_for_path()}.
1014 + * {@see openstation_plugin_file_for_path()}.
629 1015 *
630 - * @since 0.27.0
631 - *
632 1016 * @param mixed $callback A WP-style callback.
633 1017 * @return string|null Plugin file or null.
634 1018 */
635 -function desktop_mode_plugin_file_for_callback( $callback ) {
636 - $file = desktop_mode_callback_source_file( $callback );
637 - 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;
638 1022 }
639 1023
640 1024 /**
641 1025 * Lazy accessor + lazy initializer for the registration-time menu
@@ -640,15 +1024,13 @@
640 1024 /**
641 1025 * Lazy accessor + lazy initializer for the registration-time menu
642 1026 * attribution map: `slug → plugin_file`. The map is populated by the
643 1027 * wrapped admin_menu callbacks installed by
644 - * {@see desktop_mode_install_menu_attribution_tracker()}.
1028 + * {@see openstation_install_menu_attribution_tracker()}.
645 1029 *
646 - * @since 0.27.0
647 - *
648 1030 * @return array<string,string>
649 1031 */
650 -function &desktop_mode_menu_attribution_map() {
1032 +function &openstation_menu_attribution_map() {
651 1033 static $map = null;
652 1034 if ( null === $map ) {
653 1035 $map = array();
654 1036 }
@@ -674,9 +1056,9 @@
674 1056 *
675 1057 * This is the source of truth for plugin → menu ownership because it
676 1058 * captures menus regardless of slug shape, hook name predictability,
677 1059 * or whether the plugin shares a render callback. Reflection on the
678 - * page hook (in `desktop_mode_resolve_menu_plugin_file`) is now a
1060 + * page hook (in `openstation_resolve_menu_plugin_file`) is now a
679 1061 * fallback for the rare cases where the tracker wasn't able to install
680 1062 * in time.
681 1063 *
682 1064 * Idempotent — runs at most once per request via a static `$installed`
@@ -681,13 +1063,11 @@
681 1063 *
682 1064 * Idempotent — runs at most once per request via a static `$installed`
683 1065 * flag.
684 1066 *
685 - * @since 0.27.0
686 - *
687 1067 * @return void
688 1068 */
689 -function desktop_mode_install_menu_attribution_tracker() {
1069 +function openstation_install_menu_attribution_tracker() {
690 1070 static $installed = false;
691 1071 if ( $installed ) {
692 1072 return;
693 1073 }
@@ -701,9 +1081,9 @@
701 1081
702 1082 foreach ( $hook->callbacks as $priority => $cbs ) {
703 1083 foreach ( $cbs as $id => $cb ) {
704 1084 $orig = $cb['function'] ?? null;
705 - $plugin_file = desktop_mode_plugin_file_for_callback( $orig );
1085 + $plugin_file = openstation_plugin_file_for_callback( $orig );
706 1086 if ( ! $plugin_file || ! is_callable( $orig ) ) {
707 1087 continue;
708 1088 }
709 1089 $accepted_args = (int) ( $cb['accepted_args'] ?? 1 );
@@ -735,9 +1115,9 @@
735 1115
736 1116 $args = func_get_args();
737 1117 $return = call_user_func_array( $orig, $args );
738 1118
739 - $map = &desktop_mode_menu_attribution_map();
1119 + $map = &openstation_menu_attribution_map();
740 1120
741 1121 if ( is_array( $menu ) ) {
742 1122 foreach ( $menu as $entry ) {
743 1123 if ( ! isset( $entry[2] ) ) {
@@ -793,14 +1173,14 @@
793 1173 }
794 1174 }
795 1175 }
796 1176
797 -add_action( '_admin_menu', 'desktop_mode_install_menu_attribution_tracker', -PHP_INT_MAX );
798 -add_action( '_network_admin_menu', 'desktop_mode_install_menu_attribution_tracker', -PHP_INT_MAX );
799 -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 );
800 1180
801 1181 /**
802 - * 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
803 1183 * actually owned by Core regardless of any query string — this is what
804 1184 * we use inside the plugin-file resolver to reject Posts / Pages / etc.
805 1185 * without rejecting `admin.php?page=…` (a universal plugin signal that
806 1186 * the public is_core classifier also incorrectly treats as core for
@@ -808,14 +1188,12 @@
808 1188 *
809 1189 * The list intentionally drops `admin.php` so plugin-registered
810 1190 * top-level pages can still be resolved.
811 1191 *
812 - * @since 0.27.0
813 - *
814 1192 * @param string $base Slug with query string already stripped.
815 1193 * @return bool True when the base filename is a Core admin handler.
816 1194 */
817 -function desktop_mode_is_pure_core_file( $base ) {
1195 +function openstation_is_pure_core_file( $base ) {
818 1196 $core_files = array(
819 1197 'index.php',
820 1198 'edit-comments.php',
821 1199 'upload.php',
@@ -862,14 +1240,12 @@
862 1240 * Returns null when the slug isn't a CPT / taxonomy URL, when the
863 1241 * registered type is builtin, or when the registrant lives outside
864 1242 * `WP_PLUGIN_DIR` (theme-registered or mu-plugin).
865 1243 *
866 - * @since 0.27.0
867 - *
868 1244 * @param string $slug Menu slug.
869 1245 * @return string|null Plugin file or null.
870 1246 */
871 -function desktop_mode_lookup_taxonomy_or_post_type_plugin_file( $slug ) {
1247 +function openstation_lookup_taxonomy_or_post_type_plugin_file( $slug ) {
872 1248 if ( false !== strpos( $slug, 'edit.php?' ) && false !== strpos( $slug, 'post_type=' ) ) {
873 1249 $qs = wp_parse_url( 'http://x/' . ltrim( $slug, '/' ), PHP_URL_QUERY );
874 1250 parse_str( (string) $qs, $args );
875 1251 $pt = isset( $args['post_type'] ) ? (string) $args['post_type'] : '';
@@ -875,10 +1251,10 @@
875 1251 $pt = isset( $args['post_type'] ) ? (string) $args['post_type'] : '';
876 1252 if ( '' === $pt ) {
877 1253 return null;
878 1254 }
879 - $map = desktop_mode_get_typed_plugin_map();
880 - 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 );
881 1257 }
882 1258 if ( false !== strpos( $slug, 'edit-tags.php?' ) && false !== strpos( $slug, 'taxonomy=' ) ) {
883 1259 $qs = wp_parse_url( 'http://x/' . ltrim( $slug, '/' ), PHP_URL_QUERY );
884 1260 parse_str( (string) $qs, $args );
@@ -885,27 +1261,36 @@
885 1261 $tx = isset( $args['taxonomy'] ) ? (string) $args['taxonomy'] : '';
886 1262 if ( '' === $tx ) {
887 1263 return null;
888 1264 }
889 - $map = desktop_mode_get_typed_plugin_map();
890 - return $map['taxonomy'][ $tx ] ?? null;
1265 + $file = openstation_type_registrant_file( $tx, 'taxonomy' );
1266 + return null === $file ? null : openstation_plugin_file_for_path( $file );
891 1267 }
892 1268 return null;
893 1269 }
894 1270
895 1271 /**
896 - * Lazy accessor for the CPT/taxonomy → plugin file map. The map is
897 - * 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
898 1275 * `init`), so by the time the dock payload is built — on
899 - * `admin_enqueue_scripts`, well after `init` — every plugin-registered
900 - * non-builtin type has an entry. Stored in a static so repeated
901 - * 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.
902 1280 *
903 - * @since 0.27.0
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.
904 1289 *
905 1290 * @return array{post_type: array<string,string>, taxonomy: array<string,string>}
906 1291 */
907 -function &desktop_mode_get_typed_plugin_map() {
1292 +function &openstation_get_typed_registrant_map() {
908 1293 static $map = null;
909 1294 if ( null === $map ) {
910 1295 $map = array(
911 1296 'post_type' => array(),
@@ -915,9 +1300,58 @@
915 1300 return $map;
916 1301 }
917 1302
918 1303 /**
919 - * 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
920 1354 * `registered_post_type` / `registered_taxonomy` priority 9999 so we
921 1355 * fire after every other listener has run (lets a plugin re-register
922 1356 * its own type on top of someone else's — last writer wins, which
923 1357 * matches WP's runtime semantics).
@@ -922,22 +1356,23 @@
922 1356 * its own type on top of someone else's — last writer wins, which
923 1357 * matches WP's runtime semantics).
924 1358 *
925 1359 * Resolution is via `debug_backtrace()`: walk frames until we hit one
926 - * whose `file` lives under `WP_PLUGIN_DIR`, then map the folder back
927 - * to a `get_plugins()` entry. Cheap — the backtrace is bounded to 12
928 - * 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`.
929 1363 *
930 - * @since 0.27.0
931 - *
932 1364 * @param string $type_or_post_type Type name (CPT or taxonomy).
933 1365 * @param string $kind Either `'post_type'` or `'taxonomy'`.
934 1366 * @return void
935 1367 */
936 -function desktop_mode_record_type_registrant( $type_or_post_type, $kind ) {
1368 +function openstation_record_type_registrant( $type_or_post_type, $kind ) {
937 1369 if ( '' === (string) $type_or_post_type ) {
938 1370 return;
939 1371 }
1372 + if ( ! openstation_should_track_type_registrants() ) {
1373 + return;
1374 + }
940 1375 // Skip Core builtin types — they're registered from Core itself
941 1376 // (Posts, Pages, Categories, …) and the backtrace would never land
942 1377 // inside WP_PLUGIN_DIR anyway. Cheap pre-filter.
943 1378 if ( 'post_type' === $kind ) {
@@ -951,44 +1386,82 @@
951 1386 return;
952 1387 }
953 1388 }
954 1389
955 - $plugin_file = desktop_mode_plugin_file_for_callback_backtrace();
956 - if ( null === $plugin_file ) {
1390 + $file = openstation_registrant_file_from_backtrace();
1391 + if ( null === $file ) {
957 1392 return;
958 1393 }
959 - $map = &desktop_mode_get_typed_plugin_map();
960 - $map[ $kind ][ $type_or_post_type ] = $plugin_file;
1394 + $map = &openstation_get_typed_registrant_map();
1395 + $map[ $kind ][ $type_or_post_type ] = $file;
961 1396 }
962 1397
963 1398 /**
964 - * Walk the current PHP backtrace and return the plugin file owning
965 - * the closest frame inside `WP_PLUGIN_DIR`. Returns null when no
966 - * frame qualifies or when `get_plugins()` isn't available (Core
967 - * hasn't loaded `wp-admin/includes/plugin.php` yet — true on
968 - * 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.
969 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 + *
970 1435 * Used by the CPT / taxonomy registration tracker to attribute
971 - * `register_post_type()` / `register_taxonomy()` calls without
972 - * 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`.
973 1439 *
974 - * @since 0.27.0
975 - *
976 - * @return string|null Plugin file or null.
1440 + * @return string|null Normalized absolute path, or null.
977 1441 */
978 -function desktop_mode_plugin_file_for_callback_backtrace() {
979 - 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 ) ) {
980 1447 return null;
981 1448 }
982 - $bt = debug_backtrace( DEBUG_BACKTRACE_IGNORE_ARGS, 12 );
1449 +
1450 + $bt = debug_backtrace( DEBUG_BACKTRACE_IGNORE_ARGS, 20 );
983 1451 foreach ( $bt as $frame ) {
984 1452 if ( empty( $frame['file'] ) ) {
985 1453 continue;
986 1454 }
987 - $plugin_file = desktop_mode_plugin_file_for_path( (string) $frame['file'] );
988 - if ( null !== $plugin_file ) {
989 - return $plugin_file;
1455 + $norm = wp_normalize_path( (string) $frame['file'] );
1456 + if ( '' !== $self_dir && 0 === strpos( $norm, $self_dir ) ) {
1457 + continue;
990 1458 }
1459 + foreach ( $dirs as $dir ) {
1460 + if ( 0 === strpos( $norm, $dir ) ) {
1461 + return $norm;
1462 + }
1463 + }
991 1464 }
992 1465 return null;
993 1466 }
994 1467
@@ -994,9 +1467,9 @@
994 1467
995 1468 add_action(
996 1469 'registered_post_type',
997 1470 static function ( $post_type ) {
998 - desktop_mode_record_type_registrant( $post_type, 'post_type' );
1471 + openstation_record_type_registrant( $post_type, 'post_type' );
999 1472 },
1000 1473 9999,
1001 1474 1
1002 1475 );
@@ -1003,9 +1476,9 @@
1003 1476
1004 1477 add_action(
1005 1478 'registered_taxonomy',
1006 1479 static function ( $taxonomy ) {
1007 - desktop_mode_record_type_registrant( $taxonomy, 'taxonomy' );
1480 + openstation_record_type_registrant( $taxonomy, 'taxonomy' );
1008 1481 },
1009 1482 9999,
1010 1483 1
1011 1484 );
@@ -1016,14 +1489,12 @@
1016 1489 * and `'Class::method'` strings. Returns null when reflection fails or
1017 1490 * the callback shape isn't reflectable (rare — e.g. an invocable object
1018 1491 * whose `__invoke` lives in PHP core).
1019 1492 *
1020 - * @since 0.27.0
1021 - *
1022 1493 * @param mixed $callback A callback as stored in `WP_Hook::$callbacks[$prio][$id]['function']`.
1023 1494 * @return string|null Absolute filesystem path of the declaring file, or null.
1024 1495 */
1025 -function desktop_mode_callback_source_file( $callback ) {
1496 +function openstation_callback_source_file( $callback ) {
1026 1497 if ( empty( $callback ) ) {
1027 1498 return null;
1028 1499 }
1029 1500 try {
@@ -1028,9 +1499,9 @@
1028 1499 }
1029 1500 try {
1030 1501 if ( is_string( $callback ) && false !== strpos( $callback, '::' ) ) {
1031 1502 list( $class, $method ) = explode( '::', $callback, 2 );
1032 - $ref = new ReflectionMethod( $class, $method );
1503 + $ref = new ReflectionMethod( $class, $method );
1033 1504 } elseif ( is_array( $callback ) && isset( $callback[0], $callback[1] ) ) {
1034 1505 $ref = new ReflectionMethod( $callback[0], (string) $callback[1] );
1035 1506 } elseif ( is_object( $callback ) && ! ( $callback instanceof Closure ) && method_exists( $callback, '__invoke' ) ) {
1036 1507 $ref = new ReflectionMethod( $callback, '__invoke' );
@@ -1056,16 +1527,14 @@
1056 1527 * exists server-side; this only suppresses the
1057 1528 * desktop-shell tile.
1058 1529 *
1059 1530 * Default is `'dock'` for every menu item. Plugins + site admins can
1060 - * hide individual items via the `desktop_mode_dock_placement` filter.
1531 + * hide individual items via the `openstation_dock_placement` filter.
1061 1532 *
1062 - * @since 0.9.0
1063 - *
1064 1533 * @param string $menu_slug The menu slug (e.g. `edit.php`, `woocommerce`).
1065 1534 * @return string `'dock'` or `'hidden'`.
1066 1535 */
1067 -function desktop_mode_dock_placement( $menu_slug ) {
1536 +function openstation_dock_placement( $menu_slug ) {
1068 1537 /**
1069 1538 * Filter whether a specific menu item is shown in the dock.
1070 1539 *
1071 1540 * Return `'dock'` to render the item on the dock (default) or
@@ -1072,14 +1541,12 @@
1072 1541 * `'hidden'` to suppress it entirely. Any other value coerces to
1073 1542 * `'dock'` — a defensive guard so a misbehaving filter can't
1074 1543 * corrupt the dock with `null` / `false` / arbitrary strings.
1075 1544 *
1076 - * @since 0.9.0
1077 - *
1078 1545 * @param string $placement Default — always `'dock'`.
1079 1546 * @param string $menu_slug The menu slug triggering the lookup.
1080 1547 */
1081 - $filtered = apply_filters( 'desktop_mode_dock_placement', 'dock', $menu_slug );
1548 + $filtered = apply_filters( 'openstation_dock_placement', 'dock', $menu_slug );
1082 1549 return 'hidden' === $filtered ? 'hidden' : 'dock';
1083 1550 }
1084 1551
1085 1552 /**
@@ -1091,18 +1558,16 @@
1091 1558 * `'hidden'` are dropped entirely.
1092 1559 *
1093 1560 * Extracted out of `includes/render.php` so both the initial PHP
1094 1561 * localize AND the chromeless bridge's live-refresh emit (including
1095 - * the hidden-iframe probe spawned by `wp.desktop.refreshMenu()`)
1562 + * the hidden-iframe probe spawned by `wp.os.refreshMenu()`)
1096 1563 * read from a single source of truth — any drift would desync the
1097 1564 * live refresh.
1098 1565 *
1099 - * @since 0.9.0
1100 - *
1101 1566 * @return array{dockItems: array[]} Menu payload.
1102 1567 */
1103 -function desktop_mode_build_menu_payload() {
1104 - $all = desktop_mode_build_dock_items();
1568 +function openstation_build_menu_payload() {
1569 + $all = openstation_build_dock_items();
1105 1570
1106 1571 // Drop hidden items; preserve the default "core first, plugins
1107 1572 // after" ordering by partitioning on the core classifier.
1108 1573 $visible = array_values(
@@ -1114,14 +1579,14 @@
1114 1579 )
1115 1580 );
1116 1581
1117 1582 // Partition on the per-item `isCore` flag set in
1118 - // desktop_mode_build_dock_items — that classifier ran against the
1583 + // openstation_build_dock_items — that classifier ran against the
1119 1584 // raw menu slug ($item[2]), which is what
1120 - // desktop_mode_is_core_menu_slug actually compares. The outer 'id'
1585 + // openstation_is_core_menu_slug actually compares. The outer 'id'
1121 1586 // field is a sanitized CSS id (e.g. `toplevel_page_jetpack`) and
1122 1587 // would never match.
1123 - $core = array();
1588 + $core = array();
1124 1589 $plugin = array();
1125 1590 foreach ( $visible as $item ) {
1126 1591 if ( ! empty( $item['isCore'] ) ) {
1127 1592 $core[] = $item;
@@ -1131,69 +1596,311 @@
1131 1596 }
1132 1597
1133 1598 $dock = array_merge( $core, $plugin );
1134 1599
1135 - return array(
1136 - 'dockItems' => $dock,
1137 - 'nativeWindows' => desktop_mode_build_native_windows_payload(),
1138 - 'serverWidgets' => function_exists( 'desktop_mode_build_desktop_widgets_payload' )
1139 - ? desktop_mode_build_desktop_widgets_payload()
1140 - : array(),
1141 - 'serverWallpapers' => function_exists( 'desktop_mode_build_desktop_wallpapers_payload' )
1142 - ? desktop_mode_build_desktop_wallpapers_payload()
1143 - : array(),
1144 - 'serverCommandScripts' => function_exists( 'desktop_mode_build_desktop_command_scripts_payload' )
1145 - ? desktop_mode_build_desktop_command_scripts_payload()
1146 - : array(),
1147 - 'serverCommands' => function_exists( 'desktop_mode_build_desktop_commands_payload' )
1148 - ? desktop_mode_build_desktop_commands_payload()
1149 - : array(),
1150 - 'serverSettingsTabScripts' => function_exists( 'desktop_mode_build_desktop_settings_tab_scripts_payload' )
1151 - ? desktop_mode_build_desktop_settings_tab_scripts_payload()
1152 - : array(),
1153 - 'serverSettingsTabs' => function_exists( 'desktop_mode_build_desktop_settings_tabs_payload' )
1154 - ? desktop_mode_build_desktop_settings_tabs_payload()
1155 - : array(),
1156 - 'serverDockRailRendererScripts' => function_exists( 'desktop_mode_build_dock_rail_renderer_scripts_payload' )
1157 - ? desktop_mode_build_dock_rail_renderer_scripts_payload()
1158 - : array(),
1159 - 'serverTitleBarButtonScripts' => function_exists( 'desktop_mode_build_desktop_titlebar_button_scripts_payload' )
1160 - ? desktop_mode_build_desktop_titlebar_button_scripts_payload()
1161 - : array(),
1162 - 'serverWindowThemeScripts' => function_exists( 'desktop_mode_build_window_theme_scripts_payload' )
1163 - ? desktop_mode_build_window_theme_scripts_payload()
1164 - : array(),
1165 - 'serverWindowThemes' => function_exists( 'desktop_mode_build_window_themes_payload' )
1166 - ? desktop_mode_build_window_themes_payload()
1167 - : array(),
1168 - 'serverWindowControlScripts' => function_exists( 'desktop_mode_build_window_control_scripts_payload' )
1169 - ? desktop_mode_build_window_control_scripts_payload()
1170 - : array(),
1171 - 'serverWindowControls' => function_exists( 'desktop_mode_build_window_controls_payload' )
1172 - ? desktop_mode_build_window_controls_payload()
1173 - : array(),
1174 - 'serverWindowSlotScripts' => function_exists( 'desktop_mode_build_window_slot_scripts_payload' )
1175 - ? desktop_mode_build_window_slot_scripts_payload()
1176 - : array(),
1177 - 'serverWindowSlots' => function_exists( 'desktop_mode_build_window_slots_payload' )
1178 - ? desktop_mode_build_window_slots_payload()
1179 - : array(),
1180 - 'serverWindowChromeScripts' => function_exists( 'desktop_mode_build_window_chrome_scripts_payload' )
1181 - ? desktop_mode_build_window_chrome_scripts_payload()
1182 - : array(),
1183 - 'serverWindowChromes' => function_exists( 'desktop_mode_build_window_chromes_payload' )
1184 - ? desktop_mode_build_window_chromes_payload()
1185 - : array(),
1186 - 'serverWindowNotices' => function_exists( 'desktop_mode_build_window_notices_payload' )
1187 - ? desktop_mode_build_window_notices_payload()
1188 - : array(),
1189 - 'desktopIcons' => function_exists( 'desktop_mode_build_desktop_icons_payload' )
1190 - ? desktop_mode_build_desktop_icons_payload()
1191 - : array(),
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 +
1604 + $payload = array(
1605 + 'dockItems' => $dock,
1606 + 'nativeWindows' => $native_windows['windows'],
1607 + 'nativeWindowScriptData' => $native_windows['scriptData'],
1192 1608 );
1609 +
1610 + // Optional per-surface payload builders — each module ships a
1611 + // zero-arg `openstation_build_*_payload()`; modules that aren't
1612 + // loaded this request contribute an empty array.
1613 + $builders = array(
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',
1637 + );
1638 +
1639 + foreach ( $builders as $key => $builder ) {
1640 + $payload[ $key ] = function_exists( $builder ) ? $builder() : array();
1641 + }
1642 +
1643 + // Aggregate update counts for the admin bar's "updates" notifier
1644 + // (the circle-arrows badge Core renders top-left). The node is
1645 + // static server HTML on the shell page, so after an in-window
1646 + // update run the shell needs fresh numbers to repaint it — GH#296.
1647 + // `wp_get_update_data()` is capability-aware (plugins / themes /
1648 + // core each gated), so the count matches what this user can act
1649 + // on. Strings are prebuilt here so the client repaint stays
1650 + // locale-correct without shipping translations to JS.
1651 + if ( function_exists( 'wp_get_update_data' ) ) {
1652 + $update_data = wp_get_update_data();
1653 + $update_total = isset( $update_data['counts']['total'] ) ? (int) $update_data['counts']['total'] : 0;
1654 +
1655 + $payload['updateCounts'] = array(
1656 + 'total' => $update_total,
1657 + 'formatted' => number_format_i18n( $update_total ),
1658 + 'text' => sprintf(
1659 + /* translators: %s: number of pending updates. */
1660 + _n( '%s update available', '%s updates available', $update_total, 'desktop-mode' ),
1661 + number_format_i18n( $update_total )
1662 + ),
1663 + 'url' => network_admin_url( 'update-core.php' ),
1664 + );
1665 + }
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 +
1674 + // A cheap structural fingerprint of the admin menu the shell uses to
1675 + // decide whether a live refresh is warranted. Shipped in every full
1676 + // payload so the shell can seed / update its last-known signature
1677 + // without recomputing it client-side (which would risk drift from
1678 + // the server's capability-gated view). See
1679 + // openstation_menu_signature().
1680 + $payload['menuSig'] = openstation_menu_signature();
1681 +
1682 + return $payload;
1193 1683 }
1194 1684
1195 1685 /**
1686 + * Cheap structural fingerprint of the current admin menu.
1687 + *
1688 + * The chromeless bridge emits the *full* menu payload only from the
1689 + * handful of pages whose completion commonly mutates the admin menu
1690 + * (activation / install / theme switch). That leaves a gap: a custom
1691 + * post type registered through a settings-based tool (CPT UI, Pods,
1692 + * ACF, …) saves on its own `admin.php?page=…` / `options.php` screen,
1693 + * none of which is in that list, so the new top-level menu never
1694 + * reaches the live dock until a full browser reload rebuilds the shell
1695 + * (GH#325).
1696 + *
1697 + * Building the full payload on *every* chromeless page just to catch
1698 + * that case would be wasteful — most navigations don't touch the menu.
1699 + * Instead every chromeless page ships this lightweight signature; the
1700 + * shell compares it against its last-known value and only spends a
1701 + * `wp.os.refreshMenu()` probe when it actually changed.
1702 + *
1703 + * The hash covers the capability-passing top-level + submenu slugs and
1704 + * their (badge-stripped) titles — i.e. exactly the add / remove /
1705 + * rename events the dock cares about. Transient badge counts (update
1706 + * notifications, moderation queues) are stripped so they don't churn
1707 + * the signature; those have their own refresh path.
1708 + *
1709 + * @return string 32-char md5 fingerprint, or '' when the menu is
1710 + * unavailable (non-admin context).
1711 + */
1712 +function openstation_menu_signature() {
1713 + global $menu, $submenu;
1714 +
1715 + if ( empty( $menu ) || ! is_array( $menu ) ) {
1716 + return '';
1717 + }
1718 +
1719 + $clean_title = static function ( $raw ) {
1720 + // Mirror openstation_build_dock_items(): drop badge spans first,
1721 + // then any remaining markup, so update counts don't move the hash.
1722 + $stripped = preg_replace( '/<span[^>]*>.*?<\/span>/s', '', (string) $raw );
1723 + return trim( wp_strip_all_tags( (string) $stripped ) );
1724 + };
1725 +
1726 + $parts = array();
1727 +
1728 + foreach ( $menu as $item ) {
1729 + if ( empty( $item[2] ) ) {
1730 + continue;
1731 + }
1732 + if ( ! empty( $item[4] ) && false !== strpos( $item[4], 'wp-menu-separator' ) ) {
1733 + continue;
1734 + }
1735 + if ( ! empty( $item[1] ) && ! current_user_can( $item[1] ) ) {
1736 + continue;
1737 + }
1738 +
1739 + $slug = (string) $item[2];
1740 + $parts[] = $slug . '|' . $clean_title( $item[0] ?? '' );
1741 +
1742 + if ( empty( $submenu[ $slug ] ) || ! is_array( $submenu[ $slug ] ) ) {
1743 + continue;
1744 + }
1745 + foreach ( $submenu[ $slug ] as $sub_item ) {
1746 + if ( ! empty( $sub_item[1] ) && ! current_user_can( $sub_item[1] ) ) {
1747 + continue;
1748 + }
1749 + $parts[] = "\t" . ( isset( $sub_item[2] ) ? (string) $sub_item[2] : '' )
1750 + . '|' . $clean_title( $sub_item[0] ?? '' );
1751 + }
1752 + }
1753 +
1754 + return md5( implode( "\n", $parts ) );
1755 +}
1756 +
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 +/**
1196 1903 * Resolve a registered WP script handle into the full payload the
1197 1904 * shell needs to lazy-load it without going through `wp_print_scripts()`.
1198 1905 *
1199 1906 * Returns:
@@ -1217,25 +1924,25 @@
1217 1924 * around the lazy `<script src>` in the same order
1218 1925 * `WP_Scripts::do_item()` would have used.
1219 1926 *
1220 1927 * Returns an empty payload (`array( 'url' => '' )`) when the handle
1221 - * is unregistered or has no source — callers treat that as "no
1222 - * 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.
1223 1934 *
1224 - * Shared between `desktop_mode_register_window()` and
1225 - * `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
1226 1937 * relies on lazy script loading in the shell) because all of them
1227 1938 * need identical handle→payload plumbing to power mid-session dynamic
1228 1939 * script loading without the `wp_print_scripts` lifecycle.
1229 1940 *
1230 - * @since 0.10.0
1231 - * @since 0.6.0 Returns full payload (was `string` URL only). Renamed
1232 - * from `desktop_mode_resolve_script_url`.
1233 - *
1234 1941 * @param string $handle WP script handle.
1235 1942 * @return array{ url:string, before:string[], after:string[], l10n:string[], translations:string } Payload (empty `url` on miss).
1236 1943 */
1237 -function desktop_mode_resolve_script_payload( $handle ) {
1944 +function openstation_resolve_script_payload( $handle ) {
1238 1945 $empty = array(
1239 1946 'url' => '',
1240 1947 'before' => array(),
1241 1948 'after' => array(),
@@ -1252,20 +1959,31 @@
1252 1959 return $empty;
1253 1960 }
1254 1961 $registered = $wp_scripts->registered[ $handle ];
1255 1962 $src = is_string( $registered->src ) ? $registered->src : '';
1256 - if ( '' === $src ) {
1257 - return $empty;
1258 - }
1259 1963
1260 - // Normalize relative paths + attach cache-bust ver.
1261 - $resolved = $src;
1262 - if ( 0 === strpos( $resolved, '/' ) && 0 !== strpos( $resolved, '//' ) ) {
1263 - $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 + }
1264 1985 }
1265 - if ( ! empty( $registered->ver ) ) {
1266 - $resolved = add_query_arg( 'ver', $registered->ver, $resolved );
1267 - }
1268 1986
1269 1987 // Harvest `extra` data the lazy-load path would otherwise drop.
1270 1988 $before = array();
1271 1989 $after = array();
@@ -1300,9 +2018,9 @@
1300 2018 // `wp.i18n.setLocaleData( JSON, 'domain' )` snippet that the print
1301 2019 // pipeline emits before the script body. `print_translations(
1302 2020 // $handle, false )` returns the snippet without echoing.
1303 2021 $translations = '';
1304 - if ( method_exists( $wp_scripts, 'print_translations' ) ) {
2022 + if ( '' !== $resolved && method_exists( $wp_scripts, 'print_translations' ) ) {
1305 2023 $captured = $wp_scripts->print_translations( $handle, false );
1306 2024 if ( is_string( $captured ) ) {
1307 2025 $translations = $captured;
1308 2026 }
@@ -1319,9 +2037,9 @@
1319 2037
1320 2038 /**
1321 2039 * Resolves a registered style handle to its print-time URL + harvested
1322 2040 * inline CSS, the styles-side mirror of
1323 - * {@see desktop_mode_resolve_script_payload()}.
2041 + * {@see openstation_resolve_script_payload()}.
1324 2042 *
1325 2043 * Why this exists: when a plugin's native window (or window-chrome
1326 2044 * theme/control/slot/chrome) is activated mid-session — i.e. the user
1327 2045 * activates the plugin from inside an open desktop shell — the parent
@@ -1334,14 +2052,12 @@
1334 2052 * Captures both the resolved `src` and any `wp_add_inline_style()`
1335 2053 * blobs attached to the handle so the shell can replay the same data
1336 2054 * the print pipeline would have written.
1337 2055 *
1338 - * @since 0.18.1
1339 - *
1340 2056 * @param string $handle WP style handle.
1341 2057 * @return array{ url:string, inline:string[] } Payload (empty `url` on miss).
1342 2058 */
1343 -function desktop_mode_resolve_style_payload( $handle ) {
2059 +function openstation_resolve_style_payload( $handle ) {
1344 2060 $empty = array(
1345 2061 'url' => '',
1346 2062 'inline' => array(),
1347 2063 );
@@ -1391,24 +2107,206 @@
1391 2107 );
1392 2108 }
1393 2109
1394 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 +/**
1395 2295 * Fire a `_doing_it_wrong()` notice exactly once per handle per
1396 - * request. Shared by every `desktop_mode_build_desktop_*_scripts_payload()`
2296 + * request. Shared by every `openstation_build_desktop_*_scripts_payload()`
1397 2297 * caller — payload builders run on every shell-config rebuild
1398 2298 * (multiple times per page load via REST + admin-bar refresh +
1399 2299 * tests), so undeduped notices spam the error log AND trip
1400 2300 * `expectedIncorrectUsage` assertions in unrelated tests.
1401 2301 *
1402 - * @since 0.18.0
1403 - *
1404 - * @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`.
1405 2303 * @param string $kind Human label: `Command`, `Settings-tab`, `Title-bar button`.
1406 2304 * @param string $handle Offending script handle.
1407 2305 */
1408 -function desktop_mode_warn_unresolvable_script_handle( $function_name, $kind, $handle ) {
2306 +function openstation_warn_unresolvable_script_handle( $function_name, $kind, $handle ) {
1409 2307 static $warned = array();
1410 - $cache_key = $function_name . '|' . $handle;
2308 + $cache_key = $function_name . '|' . $handle;
1411 2309 if ( isset( $warned[ $cache_key ] ) ) {
1412 2310 return;
1413 2311 }
1414 2312 $warned[ $cache_key ] = true;
@@ -1423,13 +2321,13 @@
1423 2321 _doing_it_wrong(
1424 2322 esc_html( $function_name ),
1425 2323 sprintf(
1426 2324 /* translators: 1: kind ("Command"/"Settings-tab"/"Title-bar button"), 2: handle. */
1427 - 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' ),
1428 2326 esc_html( $kind ),
1429 2327 esc_html( $handle )
1430 2328 ),
1431 - '0.18.0'
2329 + '0.8.1'
1432 2330 );
1433 2331 }
1434 2332
1435 2333 /**
@@ -1436,74 +2334,184 @@
1436 2334 * Test-only: clear every script-handle registry + the dedupe
1437 2335 * cache for the unresolvable-handle notice. Tests call this in
1438 2336 * `set_up` so prior tests' synthetic handles can't leak into
1439 2337 * later assertions about payload shape.
1440 - *
1441 - * @since 0.18.0
1442 2338 */
1443 -function desktop_mode_flush_script_handle_registries() {
1444 - if ( function_exists( 'desktop_mode_flush_desktop_command_script_registry' ) ) {
1445 - desktop_mode_flush_desktop_command_script_registry();
2339 +function openstation_flush_script_handle_registries() {
2340 + $flushers = array(
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',
2357 + );
2358 +
2359 + foreach ( $flushers as $flusher ) {
2360 + if ( function_exists( $flusher ) ) {
2361 + $flusher();
2362 + }
1446 2363 }
1447 - if ( function_exists( 'desktop_mode_flush_desktop_settings_tab_script_registry' ) ) {
1448 - desktop_mode_flush_desktop_settings_tab_script_registry();
1449 - }
1450 - if ( function_exists( 'desktop_mode_flush_desktop_titlebar_button_script_registry' ) ) {
1451 - desktop_mode_flush_desktop_titlebar_button_script_registry();
1452 - }
1453 - if ( function_exists( 'desktop_mode_flush_window_theme_script_registry' ) ) {
1454 - desktop_mode_flush_window_theme_script_registry();
1455 - }
1456 - if ( function_exists( 'desktop_mode_flush_window_theme_registry' ) ) {
1457 - desktop_mode_flush_window_theme_registry();
1458 - }
1459 - if ( function_exists( 'desktop_mode_flush_window_control_script_registry' ) ) {
1460 - desktop_mode_flush_window_control_script_registry();
1461 - }
1462 - if ( function_exists( 'desktop_mode_flush_window_control_registry' ) ) {
1463 - desktop_mode_flush_window_control_registry();
1464 - }
1465 - if ( function_exists( 'desktop_mode_flush_window_slot_script_registry' ) ) {
1466 - desktop_mode_flush_window_slot_script_registry();
1467 - }
1468 - if ( function_exists( 'desktop_mode_flush_window_slot_registry' ) ) {
1469 - desktop_mode_flush_window_slot_registry();
1470 - }
1471 - if ( function_exists( 'desktop_mode_flush_window_chrome_script_registry' ) ) {
1472 - desktop_mode_flush_window_chrome_script_registry();
1473 - }
1474 - if ( function_exists( 'desktop_mode_flush_window_chrome_registry' ) ) {
1475 - desktop_mode_flush_window_chrome_registry();
1476 - }
1477 - if ( function_exists( 'desktop_mode_flush_window_notice_registry' ) ) {
1478 - desktop_mode_flush_window_notice_registry();
1479 - }
1480 - desktop_mode_warn_unresolvable_script_handle( '', '', '__flush__' );
2364 +
2365 + openstation_warn_unresolvable_script_handle( '', '', '__flush__' );
1481 2366 }
1482 2367
1483 2368 /**
1484 - * Serialize the server-declared native-window registry into the
1485 - * payload shape the shell consumes. For each entry registered via
1486 - * `desktop_mode_register_window()`, we capture: the window's
1487 - * metadata (id/title/icon/placement/dimensions/autofocus), the
1488 - * rendered template HTML (by running the template callback into an
1489 - * output buffer), and the URL of the enqueued script handle (so
1490 - * mid-session activations can load the plugin's JS dynamically
1491 - * without a full shell reload).
2369 + * Collect the native-window payload: slim per-window entries plus a
2370 + * handle-keyed script-data map.
1492 2371 *
1493 - * @since 0.10.0
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.
1494 2385 *
1495 - * @return array[]
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[]}>}
1496 2404 */
1497 -function desktop_mode_build_native_windows_payload() {
1498 - if ( ! function_exists( 'desktop_mode_native_window_registry' ) ) {
1499 - 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;
1500 2412 }
1501 - $registry = desktop_mode_native_window_registry();
2413 +
2414 + $registry = openstation_native_window_registry();
1502 2415 if ( ! is_array( $registry ) ) {
1503 - return array();
2416 + return $empty;
1504 2417 }
1505 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 +
1506 2514 $out = array();
1507 2515 foreach ( $registry as $entry ) {
1508 2516 if ( ! is_callable( $entry['template'] ) ) {
1509 2517 continue;
@@ -1510,23 +2518,43 @@
1510 2518 }
1511 2519
1512 2520 // Capture the template HTML (tab-wrapped when any
1513 2521 // additional tabs are registered via
1514 - // `desktop_mode_register_window_tab()`; flat otherwise).
2522 + // `openstation_register_window_tab()`; flat otherwise).
1515 2523 // Captured as a string so the shell can inject it as a
1516 2524 // `<template>` at mid-session plugin activation without a
1517 2525 // reload.
1518 - $template_html = desktop_mode_build_native_window_template_html( $entry );
2526 + $template_html = openstation_build_native_window_template_html( $entry );
1519 2527
1520 - // Resolve script handle → full payload (URL + harvested
1521 - // `extra` data) so the shell can inject a `<script>` tag
1522 - // dynamically on mid-session activation WITHOUT dropping
1523 - // `wp_localize_script` / `wp_add_inline_script` data the way
1524 - // the bare `<script src>` lazy-load path would. See
1525 - // `desktop_mode_resolve_script_payload()` for shape.
1526 - $script_handle = isset( $entry['script'] ) ? (string) $entry['script'] : '';
1527 - $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;
1528 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 +
1529 2557 // Resolve the optional style handle alongside the script so the
1530 2558 // shell's lazy-loader can inject a `<link rel="stylesheet">`
1531 2559 // (and any `wp_add_inline_style()` blobs) on mid-session
1532 2560 // activation. Empty payload when no handle was declared OR the
@@ -1531,88 +2559,221 @@
1531 2559 // (and any `wp_add_inline_style()` blobs) on mid-session
1532 2560 // activation. Empty payload when no handle was declared OR the
1533 2561 // handle isn't registered — both treated as "no styles to load."
1534 2562 $style_handle = isset( $entry['style'] ) ? (string) $entry['style'] : '';
1535 - $style_payload = desktop_mode_resolve_style_payload( $style_handle );
2563 + $style_payload = openstation_resolve_style_payload( $style_handle );
1536 2564
1537 - // `config` arg on `desktop_mode_register_window()` — discoverable
1538 - // alternative to `wp_localize_script`. We synthesize a localize
1539 - // snippet so it lands through the same delivery path as native
1540 - // `wp_localize_script`. The bundle reads
1541 - // `window.desktopModeWindowConfig[id]` (or via
1542 - // `wp.desktop.getWindowConfig(id)`).
1543 - if ( ! empty( $entry['config'] ) && is_array( $entry['config'] ) ) {
1544 - $script_payload['l10n'][] = sprintf(
1545 - 'window.desktopModeWindowConfig=window.desktopModeWindowConfig||{};window.desktopModeWindowConfig[%s]=%s;',
1546 - wp_json_encode( $entry['id'] ),
1547 - wp_json_encode( $entry['config'] )
1548 - );
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 + }
1549 2587 }
1550 2588
1551 - // Tab metadata (label + extra script payloads) ships alongside
1552 - // the template so the shell can render a picker UI or load
1553 - // 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.
1554 2592 $tab_descriptors = array();
1555 - if ( function_exists( 'desktop_mode_get_native_window_tabs' ) ) {
1556 - foreach ( desktop_mode_get_native_window_tabs( $entry['id'] ) as $tab ) {
1557 - $tab_payload = '' !== $tab['script']
1558 - ? desktop_mode_resolve_script_payload( $tab['script'] )
1559 - : array(
1560 - 'url' => '',
1561 - 'before' => array(),
1562 - 'after' => array(),
1563 - 'l10n' => array(),
1564 - 'translations' => '',
1565 - );
1566 - $tab_descriptors[] = array(
1567 - 'value' => $tab['value'],
1568 - 'label' => $tab['label'],
1569 - 'isMain' => $tab['is_main'],
1570 - 'scriptUrl' => $tab_payload['url'],
1571 - 'scriptHandle' => $tab['script'],
1572 - 'scriptBefore' => $tab_payload['before'],
1573 - 'scriptAfter' => $tab_payload['after'],
1574 - 'scriptL10n' => $tab_payload['l10n'],
1575 - 'scriptTranslations' => $tab_payload['translations'],
2593 + if ( function_exists( 'openstation_get_native_window_tabs' ) ) {
2594 + foreach ( openstation_get_native_window_tabs( $entry['id'] ) as $tab ) {
2595 + $tab_descriptors[] = array(
2596 + 'value' => $tab['value'],
2597 + 'label' => $tab['label'],
2598 + 'isMain' => $tab['is_main'],
2599 + 'scriptHandle' => $collect_handle( $tab['script'] ),
1576 2600 );
1577 2601 }
1578 2602 }
1579 2603
1580 2604 $out[] = array(
1581 - 'id' => $entry['id'],
1582 - 'title' => $entry['title'],
1583 - 'icon' => $entry['icon'],
1584 - 'placement' => $entry['placement'],
1585 - 'width' => $entry['width'],
1586 - 'height' => $entry['height'],
1587 - 'minWidth' => $entry['min_width'],
1588 - 'minHeight' => $entry['min_height'],
1589 - 'autofocus' => $entry['autofocus'],
1590 - 'templateId' => 'desktop-mode-native-window-' . $entry['id'],
1591 - 'templateHtml' => $template_html,
1592 - 'scriptUrl' => $script_payload['url'],
1593 - 'scriptHandle' => $script_handle,
1594 - 'ownerHandle' => $script_handle,
1595 - 'scriptBefore' => $script_payload['before'],
1596 - 'scriptAfter' => $script_payload['after'],
1597 - 'scriptL10n' => $script_payload['l10n'],
1598 - 'scriptTranslations' => $script_payload['translations'],
1599 - 'styleUrl' => $style_payload['url'],
1600 - 'styleHandle' => $style_handle,
1601 - 'styleInline' => $style_payload['inline'],
1602 - '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,
1603 2637 );
1604 2638 }
1605 2639
1606 - 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 + );
1607 2662 }
1608 2663
1609 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 +/**
2703 + * Determines whether a menu slug references a real file under `wp-admin/`.
2704 + *
2705 + * Mirrors the decision core's `wp-admin/menu-header.php` makes when
2706 + * linking menu items: strip the query portion, then check whether the
2707 + * remaining path exists inside `wp-admin/`. Two registered-slug shapes
2708 + * hinge on this distinction:
2709 + *
2710 + * - URL-style slugs — ACF registers its top-level menu as
2711 + * `edit.php?post_type=acf-field-group` via `add_menu_page()`. The
2712 + * slug lands in `$_parent_pages`, but `edit.php` is a real admin
2713 + * file: classic admin links it directly, and routing it through
2714 + * `admin.php?page=…` makes core's dispatcher `wp_die()` with
2715 + * "Cannot load edit.php?post_type=acf-field-group."
2716 + * - Legacy file-path slugs — WP-Sweep registers
2717 + * `wp-sweep/admin.php` via `add_management_page()`. No such file
2718 + * exists under `wp-admin/`, so it must resolve as a plugin page
2719 + * (`tools.php?page=wp-sweep/admin.php`).
2720 + *
2721 + * @param string $slug The raw menu item slug.
2722 + * @return bool True when the query-stripped slug is a file under `wp-admin/`.
2723 + */
2724 +function openstation_is_admin_file_slug( $slug ) {
2725 + $file = $slug;
2726 + $pos = strpos( $file, '?' );
2727 + if ( false !== $pos ) {
2728 + $file = substr( $file, 0, $pos );
2729 + }
2730 +
2731 + if ( '' === $file || 0 !== validate_file( $file ) ) {
2732 + return false;
2733 + }
2734 +
2735 + return file_exists( ABSPATH . 'wp-admin/' . $file );
2736 +}
2737 +
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 +/**
1610 2767 * Converts a menu item slug to a full admin URL.
1611 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 + *
1612 2773 * Handles three slug shapes:
1613 2774 * 1. Direct file references (`edit.php`, `upload.php`) — passed
1614 - * through `admin_url()` as-is.
2775 + * through `openstation_menu_admin_url()` as-is.
1615 2776 * 2. Plain plugin page slugs (`my-plugin`) — routed through
1616 2777 * `admin.php?page=<slug>` with the slug `rawurlencode()`d.
1617 2778 * 3. Plugin page slugs that embed extra query parameters
1618 2779 * (`wc-admin&path=/customers`) — split on the first `&`, the
@@ -1637,14 +2798,12 @@
1637 2798 * contexts — the resulting iframe load would treat `&#038;path`
1638 2799 * as a literal query key and miss the `path` parameter, sending
1639 2800 * WC's router back to home instead of the requested route.
1640 2801 *
1641 - * @since 0.1.0
1642 - *
1643 2802 * @param string $slug The menu item slug or URL.
1644 2803 * @return string The full admin URL, sanitized via `esc_url_raw()`.
1645 2804 */
1646 -function desktop_mode_menu_item_url( $slug ) {
2805 +function openstation_menu_item_url( $slug ) {
1647 2806 // Already a full URL.
1648 2807 if ( str_starts_with( $slug, 'http://' ) || str_starts_with( $slug, 'https://' ) ) {
1649 2808 return esc_url_raw( $slug );
1650 2809 }
@@ -1651,11 +2810,33 @@
1651 2810
1652 2811 // Strip path traversal sequences.
1653 2812 $slug = str_replace( '..', '', $slug );
1654 2813
1655 - // Direct file reference (e.g., 'edit.php', 'upload.php').
1656 - if ( false !== strpos( $slug, '.php' ) ) {
1657 - return esc_url_raw( admin_url( $slug ) );
2814 + global $_parent_pages;
2815 +
2816 + // Direct file reference (e.g., 'edit.php', 'upload.php') — but
2817 + // NOT a registered plugin page that merely looks like one.
2818 + // Legacy file-path slugs (WP-Sweep's 'wp-sweep/admin.php',
2819 + // registered via add_management_page()) contain '.php' yet are
2820 + // page slugs, not admin-root files; `$_parent_pages` is keyed by
2821 + // the raw registered slug, so a hit there routes the slug to the
2822 + // canonical resolver below (→ `tools.php?page=wp-sweep/admin.php`,
2823 + // byte-identical to what core's menu_page_url() builds) instead
2824 + // of a 404 at `admin_url( 'wp-sweep/admin.php' )`.
2825 + //
2826 + // The reverse also happens: URL-style slugs registered through
2827 + // `add_menu_page()` / `add_submenu_page()` (ACF's
2828 + // 'edit.php?post_type=acf-field-group') sit in `$_parent_pages`
2829 + // too, yet reference a real `wp-admin/` file — those must stay
2830 + // direct links, or core's `admin.php` dispatcher dies with
2831 + // "Cannot load edit.php?post_type=acf-field-group." The admin-
2832 + // file check wins over the registration check, same as classic
2833 + // admin's `menu-header.php`.
2834 + if (
2835 + false !== strpos( $slug, '.php' ) &&
2836 + ( ! isset( $_parent_pages[ $slug ] ) || openstation_is_admin_file_slug( $slug ) )
2837 + ) {
2838 + return esc_url_raw( openstation_menu_admin_url( $slug ) );
1658 2839 }
1659 2840
1660 2841 // Plugin page slug with embedded query parameters
1661 2842 // (e.g., 'wc-admin&path=/customers'). Split the page slug from
@@ -1679,17 +2860,16 @@
1679 2860 // `&` separators we need to keep raw for the downstream
1680 2861 // `add_query_arg()` and the JS slug compare).
1681 2862 //
1682 2863 // Resolution rules, identical to core:
1683 - // 1. Slug registered under a `.php` parent that itself isn't
1684 - // a parent (Tools → `tools.php?page=…`, Settings →
1685 - // `options-general.php?page=…`).
1686 - // 2. Slug registered as a top-level menu, OR under a slug-
1687 - // based parent (WC: `woocommerce` → `admin.php?page=…`).
1688 - // 3. Slug not registered at all → fall back to `admin.php`
1689 - // so the URL still targets a real dispatcher (matches the
1690 - // pre-resolver behavior callers depended on).
1691 - global $_parent_pages;
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).
1692 2872 $host = 'admin.php?page=' . rawurlencode( $slug );
1693 2873 if ( isset( $_parent_pages[ $slug ] ) ) {
1694 2874 $parent_slug = $_parent_pages[ $slug ];
1695 2875 if ( $parent_slug && ! isset( $_parent_pages[ $parent_slug ] ) ) {
@@ -1696,9 +2876,9 @@
1696 2876 $host = add_query_arg( 'page', $slug, $parent_slug );
1697 2877 }
1698 2878 }
1699 2879
1700 - $url = admin_url( $host );
2880 + $url = openstation_menu_admin_url( $host );
1701 2881 if ( ! empty( $extra_args ) ) {
1702 2882 $url = add_query_arg( $extra_args, $url );
1703 2883 }
1704 2884 return esc_url_raw( $url );