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