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 +1619 -421 0.8.71.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];
@@ -67,12 +70,42 @@
67 70 if ( preg_match( '/class="(?:update-plugins|awaiting-mod)[^"]*count-(\d+)"/', $item[0], $matches ) ) {
68 71 $badge = (int) $matches[1];
69 72 }
70 73
74 + // The Plugins menu badge in `wp-admin/menu.php` is built from
75 + // `count( $update_plugins->response )` — a raw transient count
76 + // that can include orphan rows (deleted plugin files, entries
77 + // injected by third-party update servers for plugins that
78 + // aren't installed locally). Our Plugins window's "Update
79 + // available" filter only counts updates whose key intersects
80 + // `get_plugins()`, because every row in the window comes from
81 + // REST `/wp/v2/plugins` which iterates `get_plugins()`.
82 + // Recompute the dock badge from the same intersection so the
83 + // dock count always agrees with what the window shows (GH#258).
84 + if (
85 + 'plugins.php' === $item[2] &&
86 + ! is_multisite() &&
87 + function_exists( 'openstation_plugins_window_count_visible_updates' )
88 + ) {
89 + $badge = openstation_plugins_window_count_visible_updates();
90 + }
91 +
71 92 // Determine the icon. Menu entries can set `$item[6]` to anything
72 93 // — a dashicon class, a remote URL, a data:URI, 'none', or 'div'
73 94 // — so normalize before we serialize it for the shell JS.
74 - $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 );
75 108
76 109 // Build the full URL for the menu item.
77 110 //
78 111 // `$parent_url` is the slug-derived URL (`admin.php?page=<slug>`
@@ -80,11 +113,20 @@
80 113 // reference value the self-link strip below compares against.
81 114 // The effective `$url` we ship to the shell can be rewritten
82 115 // further down to the first visible submenu's URL — see the
83 116 // note after the loop.
84 - $parent_url = desktop_mode_menu_item_url( $item[2] );
85 - $url = $parent_url;
117 + $parent_url = openstation_menu_item_url( $item[2] );
118 + $parent_external = openstation_menu_item_is_external( $parent_url );
86 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 +
87 129 // Build submenu items.
88 130 //
89 131 // WordPress auto-prepends a self-link entry to every parent
90 132 // menu's `$submenu[$slug]` (the first child shares the parent's
@@ -91,19 +133,27 @@
91 133 // slug + URL — that's what `add_menu_page()` generates so the
92 134 // admin UI can render a clickable parent in the sidebar). For
93 135 // the shell's JS surface we strip this entry so:
94 136 //
95 - // - `submenu.length === 0` reliably means "no real children"
96 - // (the right-click submenu popover stays suppressed; the
97 - // in-window tab strip stays hidden).
98 - // - `submenu.length > 0` reliably means "has real child links"
99 - // — 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.
100 142 //
101 - // Detection by URL (post-`desktop_mode_menu_item_url()` normalize)
143 + // Detection by URL (post-`openstation_menu_item_url()` normalize)
102 144 // rather than slug equality covers plugins that register a child
103 145 // at a different slug pointing at the parent's URL.
104 - $sub_items = array();
105 - $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;
106 156 if ( ! empty( $submenu[ $item[2] ] ) ) {
107 157 foreach ( $submenu[ $item[2] ] as $sub_item ) {
108 158 if ( ! empty( $sub_item[1] ) && ! current_user_can( $sub_item[1] ) ) {
109 159 continue;
@@ -113,42 +163,208 @@
113 163 // class; the semantics are "shown by default; hide only
114 164 // when `<body class=\"no-customize-support\">`". The
115 165 // Customizer is supported inside chromeless iframes, so
116 166 // these entries belong in the dock.
117 - $sub_url = desktop_mode_menu_item_url( $sub_item[2] );
118 - // Capture the first capability-passing submenu URL so
119 - // we can use it as the parent's effective URL below
120 - // (mirrors `wp-admin/menu-header.php`). Captured BEFORE
121 - // the self-link strip so plugins whose first submenu IS
122 - // the auto-prepended self-link land on the parent URL
123 - // (a no-op rewrite — preserves existing behavior).
124 - if ( null === $first_visible_sub_url ) {
125 - $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;
126 180 }
127 - // Self-link strip — `$sub_url === $parent_url` covers
128 - // WP's auto-prepended entry AND any plugin-registered
129 - // alias that happens to land on the parent URL.
130 - if ( $sub_url === $parent_url ) {
131 - 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;
132 234 }
133 - $sub_raw_title = preg_replace( '/<span[^>]*>.*?<\/span>/s', '', (string) $sub_item[0] );
134 - $sub_title = trim( wp_strip_all_tags( $sub_raw_title ) );
135 - // Skip entries with no resolvable title. Plugins (e.g.
136 - // WooCommerce's `wc-addons` Extensions row) register
137 - // `menu_title => null` to hide a row from classic admin's
138 - // left menu while keeping the page reachable. Without
139 - // this guard the dock renders an empty, label-less tab
140 - // that visually duplicates a sibling entry.
141 - if ( '' === $sub_title ) {
142 - 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;
143 243 }
144 - $sub_items[] = array(
145 - 'title' => $sub_title,
146 - 'url' => $sub_url,
147 - );
148 244 }
149 245 }
150 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 +
151 367 // Mirror `wp-admin/menu-header.php`: when a parent menu has any
152 368 // visible submenu, classic admin rewrites the parent's
153 369 // clickable URL to the first submenu's URL. Plugins like
154 370 // WooCommerce rely on this — their top-level slug
@@ -156,38 +372,66 @@
156 372 // directly. The real landing page is the first submenu
157 373 // (`?page=wc-admin` for WC). Without this rewrite the dock
158 374 // icon points users at a broken URL that classic admin would
159 375 // never have linked to.
160 - 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 ) {
161 384 $url = $first_visible_sub_url;
162 385 }
163 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 +
164 403 $dock_item = array(
165 - 'id' => sanitize_key( $item[5] ?? $item[2] ),
166 - 'title' => $title,
167 - 'icon' => $icon,
168 - 'url' => $url,
169 - 'badge' => $badge,
170 - 'submenu' => $sub_items,
171 - 'multi' => desktop_mode_dock_item_is_multi( $item[2] ),
172 - 'placement' => desktop_mode_dock_placement( $item[2] ),
173 - 'isCore' => desktop_mode_is_core_menu_slug( $item[2] ),
174 - '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 ),
175 421 'pluginName' => null,
176 422 );
177 423 if ( $dock_item['pluginFile'] ) {
178 - $dock_item['pluginName'] = desktop_mode_plugin_display_name( $dock_item['pluginFile'] );
424 + $dock_item['pluginName'] = openstation_plugin_display_name( $dock_item['pluginFile'] );
179 425 }
180 426
181 427 /**
182 428 * Filters a single dock item's data.
183 429 *
184 - * @since 0.1.0
185 - *
186 430 * @param array $dock_item The dock item data.
187 431 * @param string $menu_slug The menu slug.
188 432 */
189 - $dock_item = apply_filters( 'desktop_mode_dock_item', $dock_item, $item[2] );
433 + $dock_item = apply_filters( 'openstation_dock_item', $dock_item, $identity_slug );
190 434
191 435 $items[] = $dock_item;
192 436 }
193 437
@@ -193,16 +437,183 @@
193 437
194 438 /**
195 439 * Filters the dock items before they are passed to JavaScript.
196 440 *
197 - * @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.
198 483 *
199 - * @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.
200 486 */
201 - return apply_filters( 'desktop_mode_dock_items', $items );
487 + return (bool) apply_filters( 'openstation_menu_item_is_external', $external, $url );
202 488 }
203 489
204 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 +/**
205 616 * Sanitizes a dock icon value for safe injection into the shell JS.
206 617 *
207 618 * Menu items can set their icon to one of:
208 619 *
@@ -229,19 +640,12 @@
229 640 *
230 641 * The return value is always a string safe to drop into an `img.src`,
231 642 * a CSS class, or a CSS `url()` background without further escaping.
232 643 *
233 - * @since 0.4.0
234 - * @since 0.11.0 Rejected `data:` URIs outright (regression — see 0.18.x).
235 - * @since 0.18.x Re-allowed `data:image/svg+xml{;base64,|,}` so plugin
236 - * icons (Yoast, WooCommerce, Jetpack, etc.) appear on the
237 - * dock instead of collapsing to the gear fallback.
238 - * Other `data:` schemes still rejected.
239 - *
240 644 * @param mixed $icon Raw icon value from the menu registration.
241 645 * @return string Sanitized icon string.
242 646 */
243 -function desktop_mode_sanitize_dock_icon( $icon ) {
647 +function openstation_sanitize_dock_icon( $icon ) {
244 648 $fallback = 'dashicons-admin-generic';
245 649 if ( ! is_string( $icon ) || '' === $icon ) {
246 650 return $fallback;
247 651 }
@@ -300,19 +704,17 @@
300 704 * have a single logical state — opening two makes no sense.
301 705 *
302 706 * The default rule matches the base filename of the menu slug against a
303 707 * known list. Plugin authors can override via the
304 - * `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
305 709 * (or force a stock list page into singleton mode).
306 710 *
307 - * @since 0.5.0
308 - *
309 711 * @param string $menu_slug The raw menu slug (e.g. `edit.php`, `upload.php`,
310 712 * or `my-plugin-page`). Query strings are preserved
311 713 * so `edit.php?post_type=page` resolves correctly.
312 714 * @return bool True if this page supports multiple simultaneous windows.
313 715 */
314 -function desktop_mode_dock_item_is_multi( $menu_slug ) {
716 +function openstation_dock_item_is_multi( $menu_slug ) {
315 717 // Multi-capable admin files. Match by the base file regardless of
316 718 // any query string (post_type, taxonomy, page, paged, etc.) so every
317 719 // CPT and every taxonomy inherits the same rule as their parent.
318 720 $multi_files = array(
@@ -322,9 +724,9 @@
322 724 'users.php',
323 725 'edit-comments.php',
324 726 );
325 727
326 - $base = strtok( (string) $menu_slug, '?' );
728 + $base = strtok( (string) $menu_slug, '?' );
327 729 $multi = in_array( $base, $multi_files, true );
328 730
329 731 /**
330 732 * Filters whether a dock item supports multiple open windows.
@@ -333,14 +735,12 @@
333 735 * A "+" affordance appears on the dock icon and a "Open another" action
334 736 * becomes available in the window's title-bar menu. Singletons (false)
335 737 * always focus the existing window when re-opened.
336 738 *
337 - * @since 0.5.0
338 - *
339 739 * @param bool $multi Whether this page is multi-capable.
340 740 * @param string $menu_slug The menu slug (e.g. `edit.php?post_type=page`).
341 741 */
342 - return (bool) apply_filters( 'desktop_mode_dock_item_multi', $multi, $menu_slug );
742 + return (bool) apply_filters( 'openstation_dock_item_multi', $multi, $menu_slug );
343 743 }
344 744
345 745 /**
346 746 * Returns true when `$menu_slug` maps to a first-party WordPress
@@ -364,23 +764,21 @@
364 764 * with custom top-level files can still opt in via the filter
365 765 * below).
366 766 *
367 767 * Plugins + site admins can override any answer via
368 - * `desktop_mode_dock_placement`:
768 + * `openstation_dock_placement`:
369 769 *
370 770 * ```php
371 771 * // Keep Jetpack on the left dock:
372 - * add_filter( 'desktop_mode_dock_placement', function ( $placement, $slug ) {
772 + * add_filter( 'openstation_dock_placement', function ( $placement, $slug ) {
373 773 * return 'jetpack' === $slug ? 'dock' : $placement;
374 774 * }, 10, 2 );
375 775 * ```
376 776 *
377 - * @since 0.9.0
378 - *
379 777 * @param string $menu_slug Menu item slug (e.g. `edit.php`, `edit.php?post_type=foo`, `woocommerce`).
380 778 * @return bool True when the slug is a core admin page.
381 779 */
382 -function desktop_mode_is_core_menu_slug( $menu_slug ) {
780 +function openstation_is_core_menu_slug( $menu_slug ) {
383 781 $slug = (string) $menu_slug;
384 782 $base = strtok( $slug, '?' );
385 783
386 784 // Known top-level core admin files. Stable across WP versions —
@@ -421,8 +819,19 @@
421 819 'link-manager.php', // Link manager (legacy)
422 820 'update-core.php', // Dashboard > Updates
423 821 );
424 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 +
425 834 return in_array( $base, $core_files, true );
426 835 }
427 836
428 837 /**
@@ -431,9 +840,9 @@
431 840 * registered for the menu's page hook.
432 841 *
433 842 * Returns the plugin's main file path (relative to `WP_PLUGIN_DIR`) when
434 843 * the menu was registered by a regular plugin, `null` otherwise. Core
435 - * menus, mu-plugins, drop-ins, theme-registered menus, and Desktop Mode
844 + * menus, mu-plugins, drop-ins, theme-registered menus, and OpenStation
436 845 * itself all return `null` — none of these are deactivatable through the
437 846 * `wp/v2/plugins` REST route, so the dock right-click menu should not
438 847 * offer a deactivate action for them.
439 848 *
@@ -447,19 +856,17 @@
447 856 * lives here.
448 857 * 3. Reflect each callback to find its declaring file. Match the file
449 858 * path against `WP_PLUGIN_DIR/<folder>/…` and use `<folder>` to look
450 859 * up an entry in `get_plugins()`. Return the matching `<folder>/<file>.php`.
451 - * 4. Exclude Desktop Mode itself — deactivating from inside the shell
860 + * 4. Exclude OpenStation itself — deactivating from inside the shell
452 861 * is handled by the plugins-window's self-deactivate path.
453 862 *
454 - * @since 0.27.0
455 - *
456 863 * @param string $menu_slug The menu slug from `$menu[$i][2]` (e.g. `woocommerce`,
457 864 * `admin.php?page=jetpack`, `edit.php?post_type=foo`).
458 865 * @return string|null Plugin file path relative to `WP_PLUGIN_DIR`, or null
459 866 * when the slug isn't owned by a deactivatable plugin.
460 867 */
461 -function desktop_mode_resolve_menu_plugin_file( $menu_slug ) {
868 +function openstation_resolve_menu_plugin_file( $menu_slug ) {
462 869 $slug = (string) $menu_slug;
463 870
464 871 // `get_plugin_page_hookname` + `get_plugins` come from
465 872 // `wp-admin/includes/plugin.php`, which Core loads itself on
@@ -470,12 +877,12 @@
470 877 if ( ! function_exists( 'get_plugin_page_hookname' ) || ! function_exists( 'get_plugins' ) ) {
471 878 return null;
472 879 }
473 880
474 - $self_basename = defined( 'DESKTOP_MODE_FILE' ) ? plugin_basename( DESKTOP_MODE_FILE ) : '';
881 + $self_basename = defined( 'OPENSTATION_FILE' ) ? plugin_basename( OPENSTATION_FILE ) : '';
475 882
476 883 // Strategy 1 — registration-time attribution. The admin_menu hook
477 - // wrapper (see `desktop_mode_install_menu_attribution_tracker`) snapshots
884 + // wrapper (see `openstation_install_menu_attribution_tracker`) snapshots
478 885 // `$menu`/`$submenu` around every admin_menu callback and records
479 886 // "this plugin file added this slug". This is the authoritative
480 887 // source — it captures menus whose page hook isn't predictable from
481 888 // the slug (e.g. WC's `wc-admin&path=/marketing`) and handles
@@ -480,9 +887,9 @@
480 887 // source — it captures menus whose page hook isn't predictable from
481 888 // the slug (e.g. WC's `wc-admin&path=/marketing`) and handles
482 889 // callbacks that simply forward to a shared renderer (which
483 890 // reflection would mis-attribute).
484 - $map = desktop_mode_menu_attribution_map();
891 + $map = openstation_menu_attribution_map();
485 892 if ( isset( $map[ $slug ] ) ) {
486 893 $plugin_file = $map[ $slug ];
487 894 if ( $self_basename && $plugin_file === $self_basename ) {
488 895 return null;
@@ -494,9 +901,9 @@
494 901 // / `edit-tags.php` handle the render, so the page hook would never
495 902 // point at the registering plugin. We caught the plugin at
496 903 // `register_post_type()` / `register_taxonomy()` time via
497 904 // `debug_backtrace()`.
498 - $tracked = desktop_mode_lookup_taxonomy_or_post_type_plugin_file( $slug );
905 + $tracked = openstation_lookup_taxonomy_or_post_type_plugin_file( $slug );
499 906 if ( null !== $tracked ) {
500 907 if ( $self_basename && $tracked === $self_basename ) {
501 908 return null;
502 909 }
@@ -506,12 +913,12 @@
506 913 $base = strtok( $slug, '?' );
507 914
508 915 // Cheap reject: literal core PHP files with no `?page=` parameter
509 916 // (the universal "a plugin registered an admin route" signal). We
510 - // can't reuse `desktop_mode_is_core_menu_slug()` here — that
917 + // can't reuse `openstation_is_core_menu_slug()` here — that
511 918 // classifier strtok's the query string and treats `admin.php?page=foo`
512 919 // as core, which would hide every plugin-registered top-level tile.
513 - if ( desktop_mode_is_pure_core_file( $base ) && false === strpos( $slug, '?page=' ) ) {
920 + if ( openstation_is_pure_core_file( $base ) && false === strpos( $slug, '?page=' ) ) {
514 921 return null;
515 922 }
516 923
517 924 // Strategy 3 — page-hook reflection fallback. The earlier strategies
@@ -527,9 +934,9 @@
527 934
528 935 $hook = $wp_filter[ $hookname ];
529 936 foreach ( $hook->callbacks as $cbs ) {
530 937 foreach ( $cbs as $cb ) {
531 - $plugin_file = desktop_mode_plugin_file_for_callback( $cb['function'] ?? null );
938 + $plugin_file = openstation_plugin_file_for_callback( $cb['function'] ?? null );
532 939 if ( ! $plugin_file ) {
533 940 continue;
534 941 }
535 942 if ( $self_basename && $plugin_file === $self_basename ) {
@@ -547,16 +954,15 @@
547 954 * the plugin folder name as a last-resort fallback if `get_plugins()`
548 955 * has no entry (extremely rare — would mean the plugin file isn't
549 956 * installed but somehow registered a menu).
550 957 *
551 - * @since 0.27.0
552 - *
553 958 * @param string $plugin_file Plugin file relative to `WP_PLUGIN_DIR`.
554 959 * @return string Display name.
555 960 */
556 -function desktop_mode_plugin_display_name( $plugin_file ) {
961 +function openstation_plugin_display_name( $plugin_file ) {
557 962 if ( ! function_exists( 'get_plugins' ) ) {
558 - return strtok( $plugin_file, '/' ) ?: $plugin_file;
963 + $dir = strtok( $plugin_file, '/' );
964 + return $dir ? $dir : $plugin_file;
559 965 }
560 966 $installed = get_plugins();
561 967 if ( isset( $installed[ $plugin_file ]['Name'] ) && '' !== $installed[ $plugin_file ]['Name'] ) {
562 968 return (string) $installed[ $plugin_file ]['Name'];
@@ -570,14 +976,12 @@
570 976 * corresponding plugin file in `get_plugins()`. Returns null when the
571 977 * path isn't under the plugins directory, or doesn't match any active
572 978 * plugin folder.
573 979 *
574 - * @since 0.27.0
575 - *
576 980 * @param string $file Absolute filesystem path.
577 981 * @return string|null Plugin file (`<folder>/<file>.php`) or null.
578 982 */
579 -function desktop_mode_plugin_file_for_path( $file ) {
983 +function openstation_plugin_file_for_path( $file ) {
580 984 if ( ! is_string( $file ) || '' === $file ) {
581 985 return null;
582 986 }
583 987 $plugins_dir = wp_normalize_path( WP_PLUGIN_DIR );
@@ -606,18 +1010,16 @@
606 1010
607 1011 /**
608 1012 * Convenience wrapper: reflect on a callback to find its declaring
609 1013 * file, then map that file to an active plugin via
610 - * {@see desktop_mode_plugin_file_for_path()}.
1014 + * {@see openstation_plugin_file_for_path()}.
611 1015 *
612 - * @since 0.27.0
613 - *
614 1016 * @param mixed $callback A WP-style callback.
615 1017 * @return string|null Plugin file or null.
616 1018 */
617 -function desktop_mode_plugin_file_for_callback( $callback ) {
618 - $file = desktop_mode_callback_source_file( $callback );
619 - 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;
620 1022 }
621 1023
622 1024 /**
623 1025 * Lazy accessor + lazy initializer for the registration-time menu
@@ -622,15 +1024,13 @@
622 1024 /**
623 1025 * Lazy accessor + lazy initializer for the registration-time menu
624 1026 * attribution map: `slug → plugin_file`. The map is populated by the
625 1027 * wrapped admin_menu callbacks installed by
626 - * {@see desktop_mode_install_menu_attribution_tracker()}.
1028 + * {@see openstation_install_menu_attribution_tracker()}.
627 1029 *
628 - * @since 0.27.0
629 - *
630 1030 * @return array<string,string>
631 1031 */
632 -function &desktop_mode_menu_attribution_map() {
1032 +function &openstation_menu_attribution_map() {
633 1033 static $map = null;
634 1034 if ( null === $map ) {
635 1035 $map = array();
636 1036 }
@@ -656,9 +1056,9 @@
656 1056 *
657 1057 * This is the source of truth for plugin → menu ownership because it
658 1058 * captures menus regardless of slug shape, hook name predictability,
659 1059 * or whether the plugin shares a render callback. Reflection on the
660 - * page hook (in `desktop_mode_resolve_menu_plugin_file`) is now a
1060 + * page hook (in `openstation_resolve_menu_plugin_file`) is now a
661 1061 * fallback for the rare cases where the tracker wasn't able to install
662 1062 * in time.
663 1063 *
664 1064 * Idempotent — runs at most once per request via a static `$installed`
@@ -663,13 +1063,11 @@
663 1063 *
664 1064 * Idempotent — runs at most once per request via a static `$installed`
665 1065 * flag.
666 1066 *
667 - * @since 0.27.0
668 - *
669 1067 * @return void
670 1068 */
671 -function desktop_mode_install_menu_attribution_tracker() {
1069 +function openstation_install_menu_attribution_tracker() {
672 1070 static $installed = false;
673 1071 if ( $installed ) {
674 1072 return;
675 1073 }
@@ -683,9 +1081,9 @@
683 1081
684 1082 foreach ( $hook->callbacks as $priority => $cbs ) {
685 1083 foreach ( $cbs as $id => $cb ) {
686 1084 $orig = $cb['function'] ?? null;
687 - $plugin_file = desktop_mode_plugin_file_for_callback( $orig );
1085 + $plugin_file = openstation_plugin_file_for_callback( $orig );
688 1086 if ( ! $plugin_file || ! is_callable( $orig ) ) {
689 1087 continue;
690 1088 }
691 1089 $accepted_args = (int) ( $cb['accepted_args'] ?? 1 );
@@ -717,9 +1115,9 @@
717 1115
718 1116 $args = func_get_args();
719 1117 $return = call_user_func_array( $orig, $args );
720 1118
721 - $map = &desktop_mode_menu_attribution_map();
1119 + $map = &openstation_menu_attribution_map();
722 1120
723 1121 if ( is_array( $menu ) ) {
724 1122 foreach ( $menu as $entry ) {
725 1123 if ( ! isset( $entry[2] ) ) {
@@ -775,14 +1173,14 @@
775 1173 }
776 1174 }
777 1175 }
778 1176
779 -add_action( '_admin_menu', 'desktop_mode_install_menu_attribution_tracker', -PHP_INT_MAX );
780 -add_action( '_network_admin_menu', 'desktop_mode_install_menu_attribution_tracker', -PHP_INT_MAX );
781 -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 );
782 1180
783 1181 /**
784 - * 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
785 1183 * actually owned by Core regardless of any query string — this is what
786 1184 * we use inside the plugin-file resolver to reject Posts / Pages / etc.
787 1185 * without rejecting `admin.php?page=…` (a universal plugin signal that
788 1186 * the public is_core classifier also incorrectly treats as core for
@@ -790,14 +1188,12 @@
790 1188 *
791 1189 * The list intentionally drops `admin.php` so plugin-registered
792 1190 * top-level pages can still be resolved.
793 1191 *
794 - * @since 0.27.0
795 - *
796 1192 * @param string $base Slug with query string already stripped.
797 1193 * @return bool True when the base filename is a Core admin handler.
798 1194 */
799 -function desktop_mode_is_pure_core_file( $base ) {
1195 +function openstation_is_pure_core_file( $base ) {
800 1196 $core_files = array(
801 1197 'index.php',
802 1198 'edit-comments.php',
803 1199 'upload.php',
@@ -844,14 +1240,12 @@
844 1240 * Returns null when the slug isn't a CPT / taxonomy URL, when the
845 1241 * registered type is builtin, or when the registrant lives outside
846 1242 * `WP_PLUGIN_DIR` (theme-registered or mu-plugin).
847 1243 *
848 - * @since 0.27.0
849 - *
850 1244 * @param string $slug Menu slug.
851 1245 * @return string|null Plugin file or null.
852 1246 */
853 -function desktop_mode_lookup_taxonomy_or_post_type_plugin_file( $slug ) {
1247 +function openstation_lookup_taxonomy_or_post_type_plugin_file( $slug ) {
854 1248 if ( false !== strpos( $slug, 'edit.php?' ) && false !== strpos( $slug, 'post_type=' ) ) {
855 1249 $qs = wp_parse_url( 'http://x/' . ltrim( $slug, '/' ), PHP_URL_QUERY );
856 1250 parse_str( (string) $qs, $args );
857 1251 $pt = isset( $args['post_type'] ) ? (string) $args['post_type'] : '';
@@ -857,10 +1251,10 @@
857 1251 $pt = isset( $args['post_type'] ) ? (string) $args['post_type'] : '';
858 1252 if ( '' === $pt ) {
859 1253 return null;
860 1254 }
861 - $map = desktop_mode_get_typed_plugin_map();
862 - 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 );
863 1257 }
864 1258 if ( false !== strpos( $slug, 'edit-tags.php?' ) && false !== strpos( $slug, 'taxonomy=' ) ) {
865 1259 $qs = wp_parse_url( 'http://x/' . ltrim( $slug, '/' ), PHP_URL_QUERY );
866 1260 parse_str( (string) $qs, $args );
@@ -867,27 +1261,36 @@
867 1261 $tx = isset( $args['taxonomy'] ) ? (string) $args['taxonomy'] : '';
868 1262 if ( '' === $tx ) {
869 1263 return null;
870 1264 }
871 - $map = desktop_mode_get_typed_plugin_map();
872 - return $map['taxonomy'][ $tx ] ?? null;
1265 + $file = openstation_type_registrant_file( $tx, 'taxonomy' );
1266 + return null === $file ? null : openstation_plugin_file_for_path( $file );
873 1267 }
874 1268 return null;
875 1269 }
876 1270
877 1271 /**
878 - * Lazy accessor for the CPT/taxonomy → plugin file map. The map is
879 - * 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
880 1275 * `init`), so by the time the dock payload is built — on
881 - * `admin_enqueue_scripts`, well after `init` — every plugin-registered
882 - * non-builtin type has an entry. Stored in a static so repeated
883 - * 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.
884 1280 *
885 - * @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.
886 1289 *
887 1290 * @return array{post_type: array<string,string>, taxonomy: array<string,string>}
888 1291 */
889 -function &desktop_mode_get_typed_plugin_map() {
1292 +function &openstation_get_typed_registrant_map() {
890 1293 static $map = null;
891 1294 if ( null === $map ) {
892 1295 $map = array(
893 1296 'post_type' => array(),
@@ -897,9 +1300,58 @@
897 1300 return $map;
898 1301 }
899 1302
900 1303 /**
901 - * 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
902 1354 * `registered_post_type` / `registered_taxonomy` priority 9999 so we
903 1355 * fire after every other listener has run (lets a plugin re-register
904 1356 * its own type on top of someone else's — last writer wins, which
905 1357 * matches WP's runtime semantics).
@@ -904,22 +1356,23 @@
904 1356 * its own type on top of someone else's — last writer wins, which
905 1357 * matches WP's runtime semantics).
906 1358 *
907 1359 * Resolution is via `debug_backtrace()`: walk frames until we hit one
908 - * whose `file` lives under `WP_PLUGIN_DIR`, then map the folder back
909 - * to a `get_plugins()` entry. Cheap — the backtrace is bounded to 12
910 - * 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`.
911 1363 *
912 - * @since 0.27.0
913 - *
914 1364 * @param string $type_or_post_type Type name (CPT or taxonomy).
915 1365 * @param string $kind Either `'post_type'` or `'taxonomy'`.
916 1366 * @return void
917 1367 */
918 -function desktop_mode_record_type_registrant( $type_or_post_type, $kind ) {
1368 +function openstation_record_type_registrant( $type_or_post_type, $kind ) {
919 1369 if ( '' === (string) $type_or_post_type ) {
920 1370 return;
921 1371 }
1372 + if ( ! openstation_should_track_type_registrants() ) {
1373 + return;
1374 + }
922 1375 // Skip Core builtin types — they're registered from Core itself
923 1376 // (Posts, Pages, Categories, …) and the backtrace would never land
924 1377 // inside WP_PLUGIN_DIR anyway. Cheap pre-filter.
925 1378 if ( 'post_type' === $kind ) {
@@ -933,44 +1386,82 @@
933 1386 return;
934 1387 }
935 1388 }
936 1389
937 - $plugin_file = desktop_mode_plugin_file_for_callback_backtrace();
938 - if ( null === $plugin_file ) {
1390 + $file = openstation_registrant_file_from_backtrace();
1391 + if ( null === $file ) {
939 1392 return;
940 1393 }
941 - $map = &desktop_mode_get_typed_plugin_map();
942 - $map[ $kind ][ $type_or_post_type ] = $plugin_file;
1394 + $map = &openstation_get_typed_registrant_map();
1395 + $map[ $kind ][ $type_or_post_type ] = $file;
943 1396 }
944 1397
945 1398 /**
946 - * Walk the current PHP backtrace and return the plugin file owning
947 - * the closest frame inside `WP_PLUGIN_DIR`. Returns null when no
948 - * frame qualifies or when `get_plugins()` isn't available (Core
949 - * hasn't loaded `wp-admin/includes/plugin.php` yet — true on
950 - * 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.
951 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 + *
952 1435 * Used by the CPT / taxonomy registration tracker to attribute
953 - * `register_post_type()` / `register_taxonomy()` calls without
954 - * 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`.
955 1439 *
956 - * @since 0.27.0
957 - *
958 - * @return string|null Plugin file or null.
1440 + * @return string|null Normalized absolute path, or null.
959 1441 */
960 -function desktop_mode_plugin_file_for_callback_backtrace() {
961 - 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 ) ) {
962 1447 return null;
963 1448 }
964 - $bt = debug_backtrace( DEBUG_BACKTRACE_IGNORE_ARGS, 12 );
1449 +
1450 + $bt = debug_backtrace( DEBUG_BACKTRACE_IGNORE_ARGS, 20 );
965 1451 foreach ( $bt as $frame ) {
966 1452 if ( empty( $frame['file'] ) ) {
967 1453 continue;
968 1454 }
969 - $plugin_file = desktop_mode_plugin_file_for_path( (string) $frame['file'] );
970 - if ( null !== $plugin_file ) {
971 - return $plugin_file;
1455 + $norm = wp_normalize_path( (string) $frame['file'] );
1456 + if ( '' !== $self_dir && 0 === strpos( $norm, $self_dir ) ) {
1457 + continue;
972 1458 }
1459 + foreach ( $dirs as $dir ) {
1460 + if ( 0 === strpos( $norm, $dir ) ) {
1461 + return $norm;
1462 + }
1463 + }
973 1464 }
974 1465 return null;
975 1466 }
976 1467
@@ -976,9 +1467,9 @@
976 1467
977 1468 add_action(
978 1469 'registered_post_type',
979 1470 static function ( $post_type ) {
980 - desktop_mode_record_type_registrant( $post_type, 'post_type' );
1471 + openstation_record_type_registrant( $post_type, 'post_type' );
981 1472 },
982 1473 9999,
983 1474 1
984 1475 );
@@ -985,9 +1476,9 @@
985 1476
986 1477 add_action(
987 1478 'registered_taxonomy',
988 1479 static function ( $taxonomy ) {
989 - desktop_mode_record_type_registrant( $taxonomy, 'taxonomy' );
1480 + openstation_record_type_registrant( $taxonomy, 'taxonomy' );
990 1481 },
991 1482 9999,
992 1483 1
993 1484 );
@@ -998,14 +1489,12 @@
998 1489 * and `'Class::method'` strings. Returns null when reflection fails or
999 1490 * the callback shape isn't reflectable (rare — e.g. an invocable object
1000 1491 * whose `__invoke` lives in PHP core).
1001 1492 *
1002 - * @since 0.27.0
1003 - *
1004 1493 * @param mixed $callback A callback as stored in `WP_Hook::$callbacks[$prio][$id]['function']`.
1005 1494 * @return string|null Absolute filesystem path of the declaring file, or null.
1006 1495 */
1007 -function desktop_mode_callback_source_file( $callback ) {
1496 +function openstation_callback_source_file( $callback ) {
1008 1497 if ( empty( $callback ) ) {
1009 1498 return null;
1010 1499 }
1011 1500 try {
@@ -1010,9 +1499,9 @@
1010 1499 }
1011 1500 try {
1012 1501 if ( is_string( $callback ) && false !== strpos( $callback, '::' ) ) {
1013 1502 list( $class, $method ) = explode( '::', $callback, 2 );
1014 - $ref = new ReflectionMethod( $class, $method );
1503 + $ref = new ReflectionMethod( $class, $method );
1015 1504 } elseif ( is_array( $callback ) && isset( $callback[0], $callback[1] ) ) {
1016 1505 $ref = new ReflectionMethod( $callback[0], (string) $callback[1] );
1017 1506 } elseif ( is_object( $callback ) && ! ( $callback instanceof Closure ) && method_exists( $callback, '__invoke' ) ) {
1018 1507 $ref = new ReflectionMethod( $callback, '__invoke' );
@@ -1038,16 +1527,14 @@
1038 1527 * exists server-side; this only suppresses the
1039 1528 * desktop-shell tile.
1040 1529 *
1041 1530 * Default is `'dock'` for every menu item. Plugins + site admins can
1042 - * hide individual items via the `desktop_mode_dock_placement` filter.
1531 + * hide individual items via the `openstation_dock_placement` filter.
1043 1532 *
1044 - * @since 0.9.0
1045 - *
1046 1533 * @param string $menu_slug The menu slug (e.g. `edit.php`, `woocommerce`).
1047 1534 * @return string `'dock'` or `'hidden'`.
1048 1535 */
1049 -function desktop_mode_dock_placement( $menu_slug ) {
1536 +function openstation_dock_placement( $menu_slug ) {
1050 1537 /**
1051 1538 * Filter whether a specific menu item is shown in the dock.
1052 1539 *
1053 1540 * Return `'dock'` to render the item on the dock (default) or
@@ -1054,14 +1541,12 @@
1054 1541 * `'hidden'` to suppress it entirely. Any other value coerces to
1055 1542 * `'dock'` — a defensive guard so a misbehaving filter can't
1056 1543 * corrupt the dock with `null` / `false` / arbitrary strings.
1057 1544 *
1058 - * @since 0.9.0
1059 - *
1060 1545 * @param string $placement Default — always `'dock'`.
1061 1546 * @param string $menu_slug The menu slug triggering the lookup.
1062 1547 */
1063 - $filtered = apply_filters( 'desktop_mode_dock_placement', 'dock', $menu_slug );
1548 + $filtered = apply_filters( 'openstation_dock_placement', 'dock', $menu_slug );
1064 1549 return 'hidden' === $filtered ? 'hidden' : 'dock';
1065 1550 }
1066 1551
1067 1552 /**
@@ -1073,18 +1558,16 @@
1073 1558 * `'hidden'` are dropped entirely.
1074 1559 *
1075 1560 * Extracted out of `includes/render.php` so both the initial PHP
1076 1561 * localize AND the chromeless bridge's live-refresh emit (including
1077 - * the hidden-iframe probe spawned by `wp.desktop.refreshMenu()`)
1562 + * the hidden-iframe probe spawned by `wp.os.refreshMenu()`)
1078 1563 * read from a single source of truth — any drift would desync the
1079 1564 * live refresh.
1080 1565 *
1081 - * @since 0.9.0
1082 - *
1083 1566 * @return array{dockItems: array[]} Menu payload.
1084 1567 */
1085 -function desktop_mode_build_menu_payload() {
1086 - $all = desktop_mode_build_dock_items();
1568 +function openstation_build_menu_payload() {
1569 + $all = openstation_build_dock_items();
1087 1570
1088 1571 // Drop hidden items; preserve the default "core first, plugins
1089 1572 // after" ordering by partitioning on the core classifier.
1090 1573 $visible = array_values(
@@ -1096,14 +1579,14 @@
1096 1579 )
1097 1580 );
1098 1581
1099 1582 // Partition on the per-item `isCore` flag set in
1100 - // desktop_mode_build_dock_items — that classifier ran against the
1583 + // openstation_build_dock_items — that classifier ran against the
1101 1584 // raw menu slug ($item[2]), which is what
1102 - // desktop_mode_is_core_menu_slug actually compares. The outer 'id'
1585 + // openstation_is_core_menu_slug actually compares. The outer 'id'
1103 1586 // field is a sanitized CSS id (e.g. `toplevel_page_jetpack`) and
1104 1587 // would never match.
1105 - $core = array();
1588 + $core = array();
1106 1589 $plugin = array();
1107 1590 foreach ( $visible as $item ) {
1108 1591 if ( ! empty( $item['isCore'] ) ) {
1109 1592 $core[] = $item;
@@ -1113,69 +1596,311 @@
1113 1596 }
1114 1597
1115 1598 $dock = array_merge( $core, $plugin );
1116 1599
1117 - return array(
1118 - 'dockItems' => $dock,
1119 - 'nativeWindows' => desktop_mode_build_native_windows_payload(),
1120 - 'serverWidgets' => function_exists( 'desktop_mode_build_desktop_widgets_payload' )
1121 - ? desktop_mode_build_desktop_widgets_payload()
1122 - : array(),
1123 - 'serverWallpapers' => function_exists( 'desktop_mode_build_desktop_wallpapers_payload' )
1124 - ? desktop_mode_build_desktop_wallpapers_payload()
1125 - : array(),
1126 - 'serverCommandScripts' => function_exists( 'desktop_mode_build_desktop_command_scripts_payload' )
1127 - ? desktop_mode_build_desktop_command_scripts_payload()
1128 - : array(),
1129 - 'serverCommands' => function_exists( 'desktop_mode_build_desktop_commands_payload' )
1130 - ? desktop_mode_build_desktop_commands_payload()
1131 - : array(),
1132 - 'serverSettingsTabScripts' => function_exists( 'desktop_mode_build_desktop_settings_tab_scripts_payload' )
1133 - ? desktop_mode_build_desktop_settings_tab_scripts_payload()
1134 - : array(),
1135 - 'serverSettingsTabs' => function_exists( 'desktop_mode_build_desktop_settings_tabs_payload' )
1136 - ? desktop_mode_build_desktop_settings_tabs_payload()
1137 - : array(),
1138 - 'serverDockRailRendererScripts' => function_exists( 'desktop_mode_build_dock_rail_renderer_scripts_payload' )
1139 - ? desktop_mode_build_dock_rail_renderer_scripts_payload()
1140 - : array(),
1141 - 'serverTitleBarButtonScripts' => function_exists( 'desktop_mode_build_desktop_titlebar_button_scripts_payload' )
1142 - ? desktop_mode_build_desktop_titlebar_button_scripts_payload()
1143 - : array(),
1144 - 'serverWindowThemeScripts' => function_exists( 'desktop_mode_build_window_theme_scripts_payload' )
1145 - ? desktop_mode_build_window_theme_scripts_payload()
1146 - : array(),
1147 - 'serverWindowThemes' => function_exists( 'desktop_mode_build_window_themes_payload' )
1148 - ? desktop_mode_build_window_themes_payload()
1149 - : array(),
1150 - 'serverWindowControlScripts' => function_exists( 'desktop_mode_build_window_control_scripts_payload' )
1151 - ? desktop_mode_build_window_control_scripts_payload()
1152 - : array(),
1153 - 'serverWindowControls' => function_exists( 'desktop_mode_build_window_controls_payload' )
1154 - ? desktop_mode_build_window_controls_payload()
1155 - : array(),
1156 - 'serverWindowSlotScripts' => function_exists( 'desktop_mode_build_window_slot_scripts_payload' )
1157 - ? desktop_mode_build_window_slot_scripts_payload()
1158 - : array(),
1159 - 'serverWindowSlots' => function_exists( 'desktop_mode_build_window_slots_payload' )
1160 - ? desktop_mode_build_window_slots_payload()
1161 - : array(),
1162 - 'serverWindowChromeScripts' => function_exists( 'desktop_mode_build_window_chrome_scripts_payload' )
1163 - ? desktop_mode_build_window_chrome_scripts_payload()
1164 - : array(),
1165 - 'serverWindowChromes' => function_exists( 'desktop_mode_build_window_chromes_payload' )
1166 - ? desktop_mode_build_window_chromes_payload()
1167 - : array(),
1168 - 'serverWindowNotices' => function_exists( 'desktop_mode_build_window_notices_payload' )
1169 - ? desktop_mode_build_window_notices_payload()
1170 - : array(),
1171 - 'desktopIcons' => function_exists( 'desktop_mode_build_desktop_icons_payload' )
1172 - ? desktop_mode_build_desktop_icons_payload()
1173 - : 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'],
1174 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;
1175 1683 }
1176 1684
1177 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 +/**
1178 1903 * Resolve a registered WP script handle into the full payload the
1179 1904 * shell needs to lazy-load it without going through `wp_print_scripts()`.
1180 1905 *
1181 1906 * Returns:
@@ -1199,25 +1924,25 @@
1199 1924 * around the lazy `<script src>` in the same order
1200 1925 * `WP_Scripts::do_item()` would have used.
1201 1926 *
1202 1927 * Returns an empty payload (`array( 'url' => '' )`) when the handle
1203 - * is unregistered or has no source — callers treat that as "no
1204 - * 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.
1205 1934 *
1206 - * Shared between `desktop_mode_register_window()` and
1207 - * `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
1208 1937 * relies on lazy script loading in the shell) because all of them
1209 1938 * need identical handle→payload plumbing to power mid-session dynamic
1210 1939 * script loading without the `wp_print_scripts` lifecycle.
1211 1940 *
1212 - * @since 0.10.0
1213 - * @since 0.6.0 Returns full payload (was `string` URL only). Renamed
1214 - * from `desktop_mode_resolve_script_url`.
1215 - *
1216 1941 * @param string $handle WP script handle.
1217 1942 * @return array{ url:string, before:string[], after:string[], l10n:string[], translations:string } Payload (empty `url` on miss).
1218 1943 */
1219 -function desktop_mode_resolve_script_payload( $handle ) {
1944 +function openstation_resolve_script_payload( $handle ) {
1220 1945 $empty = array(
1221 1946 'url' => '',
1222 1947 'before' => array(),
1223 1948 'after' => array(),
@@ -1234,20 +1959,31 @@
1234 1959 return $empty;
1235 1960 }
1236 1961 $registered = $wp_scripts->registered[ $handle ];
1237 1962 $src = is_string( $registered->src ) ? $registered->src : '';
1238 - if ( '' === $src ) {
1239 - return $empty;
1240 - }
1241 1963
1242 - // Normalize relative paths + attach cache-bust ver.
1243 - $resolved = $src;
1244 - if ( 0 === strpos( $resolved, '/' ) && 0 !== strpos( $resolved, '//' ) ) {
1245 - $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 + }
1246 1985 }
1247 - if ( ! empty( $registered->ver ) ) {
1248 - $resolved = add_query_arg( 'ver', $registered->ver, $resolved );
1249 - }
1250 1986
1251 1987 // Harvest `extra` data the lazy-load path would otherwise drop.
1252 1988 $before = array();
1253 1989 $after = array();
@@ -1282,9 +2018,9 @@
1282 2018 // `wp.i18n.setLocaleData( JSON, 'domain' )` snippet that the print
1283 2019 // pipeline emits before the script body. `print_translations(
1284 2020 // $handle, false )` returns the snippet without echoing.
1285 2021 $translations = '';
1286 - if ( method_exists( $wp_scripts, 'print_translations' ) ) {
2022 + if ( '' !== $resolved && method_exists( $wp_scripts, 'print_translations' ) ) {
1287 2023 $captured = $wp_scripts->print_translations( $handle, false );
1288 2024 if ( is_string( $captured ) ) {
1289 2025 $translations = $captured;
1290 2026 }
@@ -1301,9 +2037,9 @@
1301 2037
1302 2038 /**
1303 2039 * Resolves a registered style handle to its print-time URL + harvested
1304 2040 * inline CSS, the styles-side mirror of
1305 - * {@see desktop_mode_resolve_script_payload()}.
2041 + * {@see openstation_resolve_script_payload()}.
1306 2042 *
1307 2043 * Why this exists: when a plugin's native window (or window-chrome
1308 2044 * theme/control/slot/chrome) is activated mid-session — i.e. the user
1309 2045 * activates the plugin from inside an open desktop shell — the parent
@@ -1316,14 +2052,12 @@
1316 2052 * Captures both the resolved `src` and any `wp_add_inline_style()`
1317 2053 * blobs attached to the handle so the shell can replay the same data
1318 2054 * the print pipeline would have written.
1319 2055 *
1320 - * @since 0.18.1
1321 - *
1322 2056 * @param string $handle WP style handle.
1323 2057 * @return array{ url:string, inline:string[] } Payload (empty `url` on miss).
1324 2058 */
1325 -function desktop_mode_resolve_style_payload( $handle ) {
2059 +function openstation_resolve_style_payload( $handle ) {
1326 2060 $empty = array(
1327 2061 'url' => '',
1328 2062 'inline' => array(),
1329 2063 );
@@ -1373,24 +2107,206 @@
1373 2107 );
1374 2108 }
1375 2109
1376 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 +/**
1377 2295 * Fire a `_doing_it_wrong()` notice exactly once per handle per
1378 - * request. Shared by every `desktop_mode_build_desktop_*_scripts_payload()`
2296 + * request. Shared by every `openstation_build_desktop_*_scripts_payload()`
1379 2297 * caller — payload builders run on every shell-config rebuild
1380 2298 * (multiple times per page load via REST + admin-bar refresh +
1381 2299 * tests), so undeduped notices spam the error log AND trip
1382 2300 * `expectedIncorrectUsage` assertions in unrelated tests.
1383 2301 *
1384 - * @since 0.18.0
1385 - *
1386 - * @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`.
1387 2303 * @param string $kind Human label: `Command`, `Settings-tab`, `Title-bar button`.
1388 2304 * @param string $handle Offending script handle.
1389 2305 */
1390 -function desktop_mode_warn_unresolvable_script_handle( $function_name, $kind, $handle ) {
2306 +function openstation_warn_unresolvable_script_handle( $function_name, $kind, $handle ) {
1391 2307 static $warned = array();
1392 - $cache_key = $function_name . '|' . $handle;
2308 + $cache_key = $function_name . '|' . $handle;
1393 2309 if ( isset( $warned[ $cache_key ] ) ) {
1394 2310 return;
1395 2311 }
1396 2312 $warned[ $cache_key ] = true;
@@ -1405,13 +2321,13 @@
1405 2321 _doing_it_wrong(
1406 2322 esc_html( $function_name ),
1407 2323 sprintf(
1408 2324 /* translators: 1: kind ("Command"/"Settings-tab"/"Title-bar button"), 2: handle. */
1409 - 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' ),
1410 2326 esc_html( $kind ),
1411 2327 esc_html( $handle )
1412 2328 ),
1413 - '0.18.0'
2329 + '0.8.1'
1414 2330 );
1415 2331 }
1416 2332
1417 2333 /**
@@ -1418,74 +2334,184 @@
1418 2334 * Test-only: clear every script-handle registry + the dedupe
1419 2335 * cache for the unresolvable-handle notice. Tests call this in
1420 2336 * `set_up` so prior tests' synthetic handles can't leak into
1421 2337 * later assertions about payload shape.
1422 - *
1423 - * @since 0.18.0
1424 2338 */
1425 -function desktop_mode_flush_script_handle_registries() {
1426 - if ( function_exists( 'desktop_mode_flush_desktop_command_script_registry' ) ) {
1427 - 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 + }
1428 2363 }
1429 - if ( function_exists( 'desktop_mode_flush_desktop_settings_tab_script_registry' ) ) {
1430 - desktop_mode_flush_desktop_settings_tab_script_registry();
1431 - }
1432 - if ( function_exists( 'desktop_mode_flush_desktop_titlebar_button_script_registry' ) ) {
1433 - desktop_mode_flush_desktop_titlebar_button_script_registry();
1434 - }
1435 - if ( function_exists( 'desktop_mode_flush_window_theme_script_registry' ) ) {
1436 - desktop_mode_flush_window_theme_script_registry();
1437 - }
1438 - if ( function_exists( 'desktop_mode_flush_window_theme_registry' ) ) {
1439 - desktop_mode_flush_window_theme_registry();
1440 - }
1441 - if ( function_exists( 'desktop_mode_flush_window_control_script_registry' ) ) {
1442 - desktop_mode_flush_window_control_script_registry();
1443 - }
1444 - if ( function_exists( 'desktop_mode_flush_window_control_registry' ) ) {
1445 - desktop_mode_flush_window_control_registry();
1446 - }
1447 - if ( function_exists( 'desktop_mode_flush_window_slot_script_registry' ) ) {
1448 - desktop_mode_flush_window_slot_script_registry();
1449 - }
1450 - if ( function_exists( 'desktop_mode_flush_window_slot_registry' ) ) {
1451 - desktop_mode_flush_window_slot_registry();
1452 - }
1453 - if ( function_exists( 'desktop_mode_flush_window_chrome_script_registry' ) ) {
1454 - desktop_mode_flush_window_chrome_script_registry();
1455 - }
1456 - if ( function_exists( 'desktop_mode_flush_window_chrome_registry' ) ) {
1457 - desktop_mode_flush_window_chrome_registry();
1458 - }
1459 - if ( function_exists( 'desktop_mode_flush_window_notice_registry' ) ) {
1460 - desktop_mode_flush_window_notice_registry();
1461 - }
1462 - desktop_mode_warn_unresolvable_script_handle( '', '', '__flush__' );
2364 +
2365 + openstation_warn_unresolvable_script_handle( '', '', '__flush__' );
1463 2366 }
1464 2367
1465 2368 /**
1466 - * Serialize the server-declared native-window registry into the
1467 - * payload shape the shell consumes. For each entry registered via
1468 - * `desktop_mode_register_window()`, we capture: the window's
1469 - * metadata (id/title/icon/placement/dimensions/autofocus), the
1470 - * rendered template HTML (by running the template callback into an
1471 - * output buffer), and the URL of the enqueued script handle (so
1472 - * mid-session activations can load the plugin's JS dynamically
1473 - * without a full shell reload).
2369 + * Collect the native-window payload: slim per-window entries plus a
2370 + * handle-keyed script-data map.
1474 2371 *
1475 - * @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.
1476 2385 *
1477 - * @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[]}>}
1478 2404 */
1479 -function desktop_mode_build_native_windows_payload() {
1480 - if ( ! function_exists( 'desktop_mode_native_window_registry' ) ) {
1481 - 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;
1482 2412 }
1483 - $registry = desktop_mode_native_window_registry();
2413 +
2414 + $registry = openstation_native_window_registry();
1484 2415 if ( ! is_array( $registry ) ) {
1485 - return array();
2416 + return $empty;
1486 2417 }
1487 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 +
1488 2514 $out = array();
1489 2515 foreach ( $registry as $entry ) {
1490 2516 if ( ! is_callable( $entry['template'] ) ) {
1491 2517 continue;
@@ -1492,23 +2518,43 @@
1492 2518 }
1493 2519
1494 2520 // Capture the template HTML (tab-wrapped when any
1495 2521 // additional tabs are registered via
1496 - // `desktop_mode_register_window_tab()`; flat otherwise).
2522 + // `openstation_register_window_tab()`; flat otherwise).
1497 2523 // Captured as a string so the shell can inject it as a
1498 2524 // `<template>` at mid-session plugin activation without a
1499 2525 // reload.
1500 - $template_html = desktop_mode_build_native_window_template_html( $entry );
2526 + $template_html = openstation_build_native_window_template_html( $entry );
1501 2527
1502 - // Resolve script handle → full payload (URL + harvested
1503 - // `extra` data) so the shell can inject a `<script>` tag
1504 - // dynamically on mid-session activation WITHOUT dropping
1505 - // `wp_localize_script` / `wp_add_inline_script` data the way
1506 - // the bare `<script src>` lazy-load path would. See
1507 - // `desktop_mode_resolve_script_payload()` for shape.
1508 - $script_handle = isset( $entry['script'] ) ? (string) $entry['script'] : '';
1509 - $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;
1510 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 +
1511 2557 // Resolve the optional style handle alongside the script so the
1512 2558 // shell's lazy-loader can inject a `<link rel="stylesheet">`
1513 2559 // (and any `wp_add_inline_style()` blobs) on mid-session
1514 2560 // activation. Empty payload when no handle was declared OR the
@@ -1513,88 +2559,221 @@
1513 2559 // (and any `wp_add_inline_style()` blobs) on mid-session
1514 2560 // activation. Empty payload when no handle was declared OR the
1515 2561 // handle isn't registered — both treated as "no styles to load."
1516 2562 $style_handle = isset( $entry['style'] ) ? (string) $entry['style'] : '';
1517 - $style_payload = desktop_mode_resolve_style_payload( $style_handle );
2563 + $style_payload = openstation_resolve_style_payload( $style_handle );
1518 2564
1519 - // `config` arg on `desktop_mode_register_window()` — discoverable
1520 - // alternative to `wp_localize_script`. We synthesize a localize
1521 - // snippet so it lands through the same delivery path as native
1522 - // `wp_localize_script`. The bundle reads
1523 - // `window.desktopModeWindowConfig[id]` (or via
1524 - // `wp.desktop.getWindowConfig(id)`).
1525 - if ( ! empty( $entry['config'] ) && is_array( $entry['config'] ) ) {
1526 - $script_payload['l10n'][] = sprintf(
1527 - 'window.desktopModeWindowConfig=window.desktopModeWindowConfig||{};window.desktopModeWindowConfig[%s]=%s;',
1528 - wp_json_encode( $entry['id'] ),
1529 - wp_json_encode( $entry['config'] )
1530 - );
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 + }
1531 2587 }
1532 2588
1533 - // Tab metadata (label + extra script payloads) ships alongside
1534 - // the template so the shell can render a picker UI or load
1535 - // 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.
1536 2592 $tab_descriptors = array();
1537 - if ( function_exists( 'desktop_mode_get_native_window_tabs' ) ) {
1538 - foreach ( desktop_mode_get_native_window_tabs( $entry['id'] ) as $tab ) {
1539 - $tab_payload = '' !== $tab['script']
1540 - ? desktop_mode_resolve_script_payload( $tab['script'] )
1541 - : array(
1542 - 'url' => '',
1543 - 'before' => array(),
1544 - 'after' => array(),
1545 - 'l10n' => array(),
1546 - 'translations' => '',
1547 - );
1548 - $tab_descriptors[] = array(
1549 - 'value' => $tab['value'],
1550 - 'label' => $tab['label'],
1551 - 'isMain' => $tab['is_main'],
1552 - 'scriptUrl' => $tab_payload['url'],
1553 - 'scriptHandle' => $tab['script'],
1554 - 'scriptBefore' => $tab_payload['before'],
1555 - 'scriptAfter' => $tab_payload['after'],
1556 - 'scriptL10n' => $tab_payload['l10n'],
1557 - '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'] ),
1558 2600 );
1559 2601 }
1560 2602 }
1561 2603
1562 2604 $out[] = array(
1563 - 'id' => $entry['id'],
1564 - 'title' => $entry['title'],
1565 - 'icon' => $entry['icon'],
1566 - 'placement' => $entry['placement'],
1567 - 'width' => $entry['width'],
1568 - 'height' => $entry['height'],
1569 - 'minWidth' => $entry['min_width'],
1570 - 'minHeight' => $entry['min_height'],
1571 - 'autofocus' => $entry['autofocus'],
1572 - 'templateId' => 'desktop-mode-native-window-' . $entry['id'],
1573 - 'templateHtml' => $template_html,
1574 - 'scriptUrl' => $script_payload['url'],
1575 - 'scriptHandle' => $script_handle,
1576 - 'ownerHandle' => $script_handle,
1577 - 'scriptBefore' => $script_payload['before'],
1578 - 'scriptAfter' => $script_payload['after'],
1579 - 'scriptL10n' => $script_payload['l10n'],
1580 - 'scriptTranslations' => $script_payload['translations'],
1581 - 'styleUrl' => $style_payload['url'],
1582 - 'styleHandle' => $style_handle,
1583 - 'styleInline' => $style_payload['inline'],
1584 - '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,
1585 2637 );
1586 2638 }
1587 2639
1588 - 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 + );
1589 2662 }
1590 2663
1591 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 +/**
1592 2767 * Converts a menu item slug to a full admin URL.
1593 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 + *
1594 2773 * Handles three slug shapes:
1595 2774 * 1. Direct file references (`edit.php`, `upload.php`) — passed
1596 - * through `admin_url()` as-is.
2775 + * through `openstation_menu_admin_url()` as-is.
1597 2776 * 2. Plain plugin page slugs (`my-plugin`) — routed through
1598 2777 * `admin.php?page=<slug>` with the slug `rawurlencode()`d.
1599 2778 * 3. Plugin page slugs that embed extra query parameters
1600 2779 * (`wc-admin&path=/customers`) — split on the first `&`, the
@@ -1619,14 +2798,12 @@
1619 2798 * contexts — the resulting iframe load would treat `&#038;path`
1620 2799 * as a literal query key and miss the `path` parameter, sending
1621 2800 * WC's router back to home instead of the requested route.
1622 2801 *
1623 - * @since 0.1.0
1624 - *
1625 2802 * @param string $slug The menu item slug or URL.
1626 2803 * @return string The full admin URL, sanitized via `esc_url_raw()`.
1627 2804 */
1628 -function desktop_mode_menu_item_url( $slug ) {
2805 +function openstation_menu_item_url( $slug ) {
1629 2806 // Already a full URL.
1630 2807 if ( str_starts_with( $slug, 'http://' ) || str_starts_with( $slug, 'https://' ) ) {
1631 2808 return esc_url_raw( $slug );
1632 2809 }
@@ -1633,11 +2810,33 @@
1633 2810
1634 2811 // Strip path traversal sequences.
1635 2812 $slug = str_replace( '..', '', $slug );
1636 2813
1637 - // Direct file reference (e.g., 'edit.php', 'upload.php').
1638 - if ( false !== strpos( $slug, '.php' ) ) {
1639 - 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 ) );
1640 2839 }
1641 2840
1642 2841 // Plugin page slug with embedded query parameters
1643 2842 // (e.g., 'wc-admin&path=/customers'). Split the page slug from
@@ -1661,17 +2860,16 @@
1661 2860 // `&` separators we need to keep raw for the downstream
1662 2861 // `add_query_arg()` and the JS slug compare).
1663 2862 //
1664 2863 // Resolution rules, identical to core:
1665 - // 1. Slug registered under a `.php` parent that itself isn't
1666 - // a parent (Tools → `tools.php?page=…`, Settings →
1667 - // `options-general.php?page=…`).
1668 - // 2. Slug registered as a top-level menu, OR under a slug-
1669 - // based parent (WC: `woocommerce` → `admin.php?page=…`).
1670 - // 3. Slug not registered at all → fall back to `admin.php`
1671 - // so the URL still targets a real dispatcher (matches the
1672 - // pre-resolver behavior callers depended on).
1673 - 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).
1674 2872 $host = 'admin.php?page=' . rawurlencode( $slug );
1675 2873 if ( isset( $_parent_pages[ $slug ] ) ) {
1676 2874 $parent_slug = $_parent_pages[ $slug ];
1677 2875 if ( $parent_slug && ! isset( $_parent_pages[ $parent_slug ] ) ) {
@@ -1678,9 +2876,9 @@
1678 2876 $host = add_query_arg( 'page', $slug, $parent_slug );
1679 2877 }
1680 2878 }
1681 2879
1682 - $url = admin_url( $host );
2880 + $url = openstation_menu_admin_url( $host );
1683 2881 if ( ! empty( $extra_args ) ) {
1684 2882 $url = add_query_arg( $extra_args, $url );
1685 2883 }
1686 2884 return esc_url_raw( $url );