PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 1.1.5
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v1.1.5
1.1.10 1.1.9 1.1.8 1.1.7 1.1.6 1.1.5 1.1.4 1.1.3 1.1.2 1.1.1 1.1.0 1.0.1 1.0.0 0.9.8 0.9.7 0.9.6 0.9.4 0.9.5 0.9.3 0.9.2 0.9.1 0.9.0 0.8.9 0.8.8 0.8.7 All 34 releases
desktop-mode / includes / shell-screen.php

shell-screen.php in OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin 1.1.5, at includes/shell-screen.php

540 lines 20.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * OpenStation — the shell screen.
4 *
5 * The desktop shell is served by an admin screen OpenStation owns:
6 * `admin.php?page=openstation`, a menu-hidden page whose only enqueues
7 * are OpenStation's own plus the every-admin-page baseline. The shell
8 * used to be painted OVER whatever admin screen the portal forwarded
9 * to — the Dashboard by default, the last-focused window's URL
10 * otherwise — and so inherited that screen's entire script and style
11 * queue, its server-side render, and its hidden HTML. On a site running
12 * the Gutenberg plugin that meant the whole editor closure printed,
13 * parsed and executed in the shell's realm, where nothing ever rendered
14 * it (162 requests / 20 MB raw on the QA instance, against 32 requests
15 * / 1.8 MB for OpenStation's own assets).
16 *
17 * The portal keeps its URL and its frozen query vars. The target it
18 * already resolves becomes a parameter the shell screen reads instead
19 * of a screen the shell rides on:
20 *
21 * /openstation/?target=…
22 * → admin.php?page=openstation&target=<admin path>&intent=1
23 * /openstation/
24 * → admin.php?page=openstation (screen resolves the entry)
25 * /wp-admin/edit.php (plain admin GET)
26 * → admin.php?page=openstation&target=/wp-admin/edit.php&intent=1
27 * /wp-admin/index.php?desktop_mode_portal=1 (pre-screen bookmark)
28 * → admin.php?page=openstation&target=/wp-admin/index.php
29 *
30 * Why an admin page rather than a standalone document served from
31 * `parse_request`: `is_admin()` must be true and `admin_menu` /
32 * `admin_enqueue_scripts` must fire, because those are the documented
33 * contract behind every `openstation_register_*` call, the menu-payload
34 * harvest, and every plugin that gates its registration on `is_admin()`
35 * at load time. The admin page keeps all of that for free.
36 *
37 * `openstation_is_shell_request()` is the one predicate for "this
38 * request paints the shell". It replaces the implicit "enabled, not
39 * chromeless, not classic" that several render hooks used to spell out
40 * on their own, each meaning "shell" without saying so.
41 *
42 * @package OpenStation
43 */
44
45 defined( 'ABSPATH' ) || exit;
46
47 /**
48 * The `page=` slug of the shell screen.
49 */
50 const OPENSTATION_SHELL_PAGE_SLUG = 'openstation';
51
52 /**
53 * The screen id WordPress assigns to the shell screen —
54 * `get_current_screen()->id` and the `$hook_suffix` passed to
55 * `admin_enqueue_scripts` on a shell boot.
56 *
57 * A submenu page registered under an empty parent gets the `admin_`
58 * prefix, so this is `admin_page_openstation` rather than a
59 * `toplevel_page_*` or `<parent>_page_*` name.
60 */
61 const OPENSTATION_SHELL_SCREEN_ID = 'admin_page_openstation';
62
63 /**
64 * Query arg on the shell screen carrying the admin URL to open first.
65 * Same name and same value shape as the portal's own `target`, so the
66 * two are validated by the same sanitiser.
67 */
68 const OPENSTATION_SHELL_TARGET_ARG = 'target';
69
70 /**
71 * Query arg on the shell screen marking `target` as the user's own
72 * navigation intent (a followed link, a bookmark) rather than a
73 * destination the portal picked. Mirrors the portal's intent flag; the
74 * shell reads it as `fromPortalIntent`.
75 */
76 const OPENSTATION_SHELL_INTENT_ARG = 'intent';
77
78 /**
79 * Builds the shell screen URL, optionally carrying a target.
80 *
81 * `$target` is an absolute same-origin admin URL or a request-URI-shaped
82 * path (`/wp-admin/edit.php?post_type=page`). Either way only its path
83 * and query travel: the screen re-validates the value on read through
84 * {@see openstation_sanitize_portal_target()}, so the parameter is
85 * never trusted from the URL alone. An empty target yields the bare
86 * screen URL and the screen resolves the entry itself.
87 *
88 * @param string $target Admin URL to open first, or '' for none.
89 * @param bool $intent Whether the target is the user's navigation intent.
90 * @return string Absolute shell screen URL.
91 */
92 function openstation_shell_url( $target = '', $intent = false ) {
93 $url = admin_url( 'admin.php?page=' . OPENSTATION_SHELL_PAGE_SLUG );
94
95 $target = is_string( $target ) ? openstation_shell_normalize_admin_url( $target ) : '';
96 if ( '' !== $target ) {
97 $path = wp_parse_url( $target, PHP_URL_PATH );
98 $query = wp_parse_url( $target, PHP_URL_QUERY );
99 if ( is_string( $path ) && '' !== $path ) {
100 $relative = $path . ( is_string( $query ) && '' !== $query ? '?' . $query : '' );
101 $url = add_query_arg( OPENSTATION_SHELL_TARGET_ARG, rawurlencode( $relative ), $url );
102 if ( $intent ) {
103 $url = add_query_arg( OPENSTATION_SHELL_INTENT_ARG, '1', $url );
104 }
105 }
106 }
107
108 return $url;
109 }
110
111 /**
112 * Rebuilds a URL's query through `http_build_query()`, so every value
113 * is percent-encoded exactly once.
114 *
115 * The portal sanitiser hands back a URL whose query values are decoded
116 * (`plugin=dir/file.php`): `add_query_arg()` re-encodes what was already
117 * in a query string but not the args it is given. WordPress's own links
118 * spell that value `dir%2Ffile.php`, and the shell used to build
119 * `currentPage` with `http_build_query( $_GET )`, which does too. Both
120 * the redirect the screen is reached by and the page it opens go through
121 * here, so the same URL reads the same on every hop.
122 *
123 * @param string $url URL, absolute or request-URI-shaped.
124 * @return string The URL with a normalised query; '' for a non-string.
125 */
126 function openstation_shell_normalize_admin_url( $url ) {
127 if ( ! is_string( $url ) || '' === $url ) {
128 return '';
129 }
130 $query = wp_parse_url( $url, PHP_URL_QUERY );
131 if ( ! is_string( $query ) || '' === $query ) {
132 return $url;
133 }
134 parse_str( $query, $args );
135 $base = substr( $url, 0, (int) strpos( $url, '?' ) );
136 $hash = wp_parse_url( $url, PHP_URL_FRAGMENT );
137
138 return $base
139 . ( ! empty( $args ) ? '?' . http_build_query( $args ) : '' )
140 . ( is_string( $hash ) && '' !== $hash ? '#' . $hash : '' );
141 }
142
143 /**
144 * Whether `$url` addresses the shell screen.
145 *
146 * Accepts absolute URLs and request-URI-shaped paths. Used wherever a
147 * URL is about to become a window or a redirect target: the shell must
148 * never open itself inside a window, and a saved session or a `target`
149 * pointing at the screen must fall back rather than loop.
150 *
151 * @param string $url URL or path to test.
152 * @return bool
153 */
154 function openstation_url_is_shell_screen( $url ) {
155 if ( ! is_string( $url ) || '' === $url ) {
156 return false;
157 }
158 $path = wp_parse_url( $url, PHP_URL_PATH );
159 $query = wp_parse_url( $url, PHP_URL_QUERY );
160 if ( ! is_string( $path ) || ! is_string( $query ) ) {
161 return false;
162 }
163 if ( 'admin.php' !== basename( $path ) ) {
164 return false;
165 }
166 parse_str( $query, $args );
167 return isset( $args['page'] ) && OPENSTATION_SHELL_PAGE_SLUG === $args['page'];
168 }
169
170 /**
171 * Whether the current request is for the shell screen.
172 *
173 * Reads the current screen once it exists. Before `set_current_screen()`
174 * — on `admin_init`, where the portal redirect runs — the screen is
175 * not there yet, so the `$plugin_page` global (populated from `?page=`
176 * by `admin.php` before `admin_menu`) is the early answer.
177 *
178 * Says nothing about whether the shell renders here: a disabled user, a
179 * chromeless load or a classic-flagged request can all address this
180 * screen. {@see openstation_is_shell_request()} is that answer.
181 *
182 * @return bool
183 */
184 function openstation_is_shell_screen_request() {
185 if ( ! is_admin() ) {
186 return false;
187 }
188 if ( function_exists( 'get_current_screen' ) ) {
189 $screen = get_current_screen();
190 if ( $screen instanceof WP_Screen ) {
191 return OPENSTATION_SHELL_SCREEN_ID === $screen->id;
192 }
193 }
194 global $pagenow, $plugin_page;
195 return 'admin.php' === $pagenow
196 && isset( $plugin_page )
197 && OPENSTATION_SHELL_PAGE_SLUG === $plugin_page;
198 }
199
200 /**
201 * Whether the current request paints the desktop shell.
202 *
203 * True on the shell screen for a user with OpenStation enabled, and on
204 * a solo request (`?openstation_solo=<id>`, the native host's
205 * one-window boot, which renders in place wherever it lands). Never
206 * inside a window (chromeless) and never on a classic-flagged request.
207 *
208 * Every hook that used to gate on "enabled, not chromeless, not
209 * classic" reads this instead: the shell markup, its assets, the
210 * `os-active` body class, native-window templates, the palette
211 * deferral, the PWA head tags, desktop-theme styles.
212 *
213 * @return bool
214 */
215 function openstation_is_shell_request() {
216 if ( ! is_admin() ) {
217 return false;
218 }
219 if ( ! openstation_is_enabled() ) {
220 return false;
221 }
222 if ( openstation_is_chromeless_request() || openstation_is_classic_request() ) {
223 return false;
224 }
225 if ( openstation_is_shell_screen_request() ) {
226 return true;
227 }
228 return function_exists( 'openstation_is_solo_request' ) && openstation_is_solo_request();
229 }
230
231 /**
232 * Registers the shell screen.
233 *
234 * An empty parent slug keeps the page out of the menu: WordPress only
235 * paints submenus of entries that exist in `$menu`, so `$submenu['']`
236 * is registered, routable and highlighted nowhere. The `read`
237 * capability is the same floor the portal applies, so every user who
238 * can enter the desktop can reach its screen.
239 */
240 function openstation_register_shell_screen() {
241 $hook = add_submenu_page(
242 '',
243 __( 'OpenStation', 'desktop-mode' ),
244 __( 'OpenStation', 'desktop-mode' ),
245 'read',
246 OPENSTATION_SHELL_PAGE_SLUG,
247 'openstation_render_shell_screen'
248 );
249
250 if ( $hook ) {
251 add_action( "load-{$hook}", 'openstation_shell_screen_set_title' );
252 }
253 }
254 add_action( 'admin_menu', 'openstation_register_shell_screen' );
255
256 /**
257 * Names the shell document, before `admin-header.php` asks for a name.
258 *
259 * `get_admin_page_title()` finds no title for a page whose parent is the
260 * empty menu — it walks `$menu` and `$submenu` for an entry that paints,
261 * and this screen deliberately has none. So the global `$title` stayed
262 * null, and `admin-header.php` line 41 runs `strip_tags( $title )` on it
263 * unconditionally.
264 *
265 * On PHP 8.1+ that is a deprecation notice, and with `WP_DEBUG_DISPLAY`
266 * on it PRINTS — before `<!DOCTYPE html>`, because the header has not
267 * emitted it yet. A document whose first bytes are not the doctype loads
268 * in QUIRKS MODE, and quirks mode is not a cosmetic difference here: the
269 * quirks UA stylesheet stops `<table>` inheriting `color` and `font-*`
270 * from its ancestors. Every `<os-table>` in a native window therefore
271 * dropped the palette's `--os-ui-fg` and fell back to core's
272 * `body { color: #3c434a }` — near-black text on the station's dark
273 * surfaces, at 1.3:1 against a table header (#697 → the Pages window).
274 *
275 * `load-{$hook}` fires in `admin.php` before `admin-header.php` is
276 * required, so a real string is in place by the time core reads it.
277 * `get_admin_page_title()` then returns early on its own `! empty()`
278 * check, which is also what gives the document the word before the
279 * chevron: "OpenStation ‹ Site — WordPress".
280 *
281 * Set unconditionally: the screen wants its name whether or not the
282 * shell paints on this request ({@see openstation_render_shell_screen()}
283 * answers with a pointer at the portal when it does not).
284 */
285 function openstation_shell_screen_set_title() {
286 $GLOBALS['title'] = __( 'OpenStation', 'desktop-mode' ); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- naming an admin screen IS writing $title; every core screen does it (options-general.php, edit.php), and admin-header.php reads it moments later.
287 }
288
289 /**
290 * The shell screen's page callback.
291 *
292 * Prints nothing when the shell renders: the markup goes out from
293 * `in_admin_header @ 5` ({@see openstation_render_shell()}), the same
294 * hook as always, so it lands before the notices and the admin bar
295 * rather than after them — moving it here would change stacking and
296 * the timing of the `os-active` body class. The callback only speaks
297 * when the screen is reached without the shell: a user with
298 * OpenStation off, or a classic-flagged request. Then it points at the
299 * portal, which is the opt-in surface.
300 */
301 function openstation_render_shell_screen() {
302 if ( openstation_is_shell_request() ) {
303 return;
304 }
305 ?>
306 <div class="wrap">
307 <h1><?php esc_html_e( 'OpenStation', 'desktop-mode' ); ?></h1>
308 <p>
309 <?php esc_html_e( 'OpenStation is not active for your account on this request.', 'desktop-mode' ); ?>
310 <a href="<?php echo esc_url( openstation_portal_url() ); ?>"><?php esc_html_e( 'Open the desktop', 'desktop-mode' ); ?></a>
311 </p>
312 </div>
313 <?php
314 }
315
316 /**
317 * Resolves what the shell boots with on this request.
318 *
319 * On the shell screen the boot page comes from the `target` query arg
320 * — validated through the portal's sanitiser and refused when it names
321 * the shell screen itself — and falls back to
322 * {@see openstation_portal_entry_url()} exactly as the portal used to:
323 * the session's focused window, else the default window, else the
324 * Dashboard. `fromPortal` is true by construction there (the screen is
325 * only ever reached through a redirect), and `fromPortalIntent` is the
326 * `intent` arg, honoured only when the target was valid.
327 *
328 * Off the screen — a solo boot rendering in place — the page is the
329 * request's own URL, built from `$pagenow` and `$_GET` with the frozen
330 * portal flags stripped so the derived window id matches the dock's.
331 *
332 * @return array {
333 * @type string $url Absolute admin URL the shell opens first.
334 * @type bool $fromPortal Whether the shell was reached through a redirect.
335 * @type bool $fromPortalIntent Whether `url` is the user's own navigation intent.
336 * }
337 */
338 function openstation_shell_boot_target() {
339 if ( openstation_is_shell_screen_request() ) {
340 $target = '';
341 $intent = false;
342 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only routing arg, validated below.
343 if ( ! empty( $_GET[ OPENSTATION_SHELL_TARGET_ARG ] ) && is_scalar( $_GET[ OPENSTATION_SHELL_TARGET_ARG ] ) ) {
344 // `esc_url_raw`, not `sanitize_text_field`, for the reason
345 // recorded on the portal handler: the latter strips every
346 // percent-encoded sequence and mangles `plugin=dir%2Ffile.php`.
347 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only routing arg, validated below.
348 $target = openstation_sanitize_portal_target( esc_url_raw( wp_unslash( $_GET[ OPENSTATION_SHELL_TARGET_ARG ] ) ) );
349 if ( '' !== $target ) {
350 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only routing arg.
351 $intent = ! empty( $_GET[ OPENSTATION_SHELL_INTENT_ARG ] );
352 }
353 }
354 if ( '' === $target ) {
355 $target = openstation_portal_entry_url( get_current_user_id() );
356 }
357 $target = openstation_shell_normalize_admin_url( $target );
358
359 // `admin_url()` alone names the directory; `$pagenow` on that
360 // request is `index.php`, and the dock derives the Dashboard's
361 // window id from the file. Keep both sides deriving the same id.
362 $path = wp_parse_url( $target, PHP_URL_PATH );
363 if ( is_string( $path ) && '/' === substr( $path, -1 ) ) {
364 $query = wp_parse_url( $target, PHP_URL_QUERY );
365 $target = admin_url( 'index.php' ) . ( is_string( $query ) && '' !== $query ? '?' . $query : '' );
366 }
367
368 return array(
369 'url' => $target,
370 'fromPortal' => true,
371 'fromPortalIntent' => $intent,
372 );
373 }
374
375 global $pagenow;
376 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only, rebuilds the request's own URL.
377 $query = $_GET;
378 unset( $query[ OPENSTATION_PORTAL_FLAG ], $query[ OPENSTATION_PORTAL_INTENT_FLAG ] );
379
380 return array(
381 'url' => admin_url( (string) $pagenow ) . ( ! empty( $query ) ? '?' . http_build_query( $query ) : '' ),
382 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only request flag.
383 'fromPortal' => ! empty( $_GET[ OPENSTATION_PORTAL_FLAG ] ),
384 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only request flag.
385 'fromPortalIntent' => ! empty( $_GET[ OPENSTATION_PORTAL_INTENT_FLAG ] ),
386 );
387 }
388
389 /**
390 * Finds the dock entry — a top-level item or one of its submenu
391 * children — whose URL is the boot page, so the entry window opens
392 * with that entry's title and icon rather than the screen's own.
393 *
394 * On the shell screen `$title` is "OpenStation" and `$parent_file` is
395 * empty, which used to be the host screen's title and menu icon: the
396 * first window would flash "OpenStation" until the iframe reported its
397 * own title. Matching against the dock is the same identity the shell
398 * uses to fold the entry window into its tile.
399 *
400 * @param string $url Absolute admin URL the shell opens first.
401 * @param array $dock_items Dock payload from `openstation_build_dock_items()`.
402 * @return array{title:string,icon:string} Empty strings when nothing matches.
403 */
404 function openstation_shell_boot_target_meta( $url, $dock_items ) {
405 $none = array(
406 'title' => '',
407 'icon' => '',
408 );
409 if ( ! is_string( $url ) || '' === $url || ! is_array( $dock_items ) ) {
410 return $none;
411 }
412 $key = openstation_shell_url_match_key( $url );
413 if ( '' === $key ) {
414 return $none;
415 }
416 foreach ( $dock_items as $item ) {
417 if ( ! is_array( $item ) ) {
418 continue;
419 }
420 $icon = isset( $item['icon'] ) && is_string( $item['icon'] ) ? $item['icon'] : '';
421 if ( isset( $item['url'] ) && openstation_shell_url_match_key( $item['url'] ) === $key ) {
422 return array(
423 'title' => isset( $item['title'] ) ? (string) $item['title'] : '',
424 'icon' => $icon,
425 );
426 }
427 if ( empty( $item['submenu'] ) || ! is_array( $item['submenu'] ) ) {
428 continue;
429 }
430 foreach ( $item['submenu'] as $sub ) {
431 if ( is_array( $sub ) && isset( $sub['url'] ) && openstation_shell_url_match_key( $sub['url'] ) === $key ) {
432 return array(
433 'title' => isset( $sub['title'] ) ? (string) $sub['title'] : '',
434 'icon' => $icon,
435 );
436 }
437 }
438 }
439 return $none;
440 }
441
442 /**
443 * Comparable key for two admin URLs: path plus sorted query, with the
444 * chromeless and portal flags dropped — the PHP twin of the shell's
445 * `urlMatchKey()`.
446 *
447 * @param string $url URL to key.
448 * @return string '' when the URL has no path.
449 */
450 function openstation_shell_url_match_key( $url ) {
451 if ( ! is_string( $url ) ) {
452 return '';
453 }
454 $path = wp_parse_url( $url, PHP_URL_PATH );
455 if ( ! is_string( $path ) || '' === $path ) {
456 return '';
457 }
458 $query = wp_parse_url( $url, PHP_URL_QUERY );
459 $args = array();
460 if ( is_string( $query ) && '' !== $query ) {
461 parse_str( $query, $args );
462 unset( $args['openstation_chromeless'], $args[ OPENSTATION_PORTAL_FLAG ], $args[ OPENSTATION_PORTAL_INTENT_FLAG ] );
463 ksort( $args );
464 }
465 return rtrim( $path, '/' ) . '?' . http_build_query( $args );
466 }
467
468 /**
469 * Drops operator-named handles from the shell screen's queues.
470 *
471 * With no host screen, what still prints on the shell is OpenStation's
472 * own assets, Core's every-admin-page set, and whatever plugins enqueue
473 * on every admin page — a global nag, a tracker, a chat bubble. The
474 * framework does not guess which of those "belongs" in the shell; the
475 * site says so, through `openstation_shell_dequeue_handles`.
476 *
477 * Runs at `PHP_INT_MAX` so every plugin has enqueued, and only on a
478 * shell boot: windows keep the chromeless trims, classic pages keep
479 * everything. A named handle that a surviving script or style still
480 * depends on is refused with a `_doing_it_wrong()` rather than dropped,
481 * the same closure rule the chromeless trim applies — dequeuing it
482 * would strand the dependent. Dequeue, never deregister: a handle that
483 * stays registered can still be resolved as a dependency.
484 */
485 function openstation_shell_dequeue_assets() {
486 if ( ! openstation_is_shell_request() || ! openstation_is_shell_screen_request() ) {
487 return;
488 }
489
490 foreach ( array( 'script', 'style' ) as $kind ) {
491 /**
492 * Filters the handles dequeued from the shell screen.
493 *
494 * Called once for scripts and once for styles. Default empty:
495 * the shell removes nothing it did not put there unless told
496 * to. A handle a surviving asset depends on is refused.
497 *
498 * @param string[] $handles Handles to dequeue. Default empty.
499 * @param string $kind `script` or `style`.
500 */
501 $handles = apply_filters( 'openstation_shell_dequeue_handles', array(), $kind );
502 $handles = array_values( array_unique( array_filter( (array) $handles, 'is_string' ) ) );
503 if ( empty( $handles ) ) {
504 continue;
505 }
506
507 $registry = 'script' === $kind ? wp_scripts() : wp_styles();
508 if ( ! $registry ) {
509 continue;
510 }
511
512 $drops = array_values( array_intersect( $handles, (array) $registry->queue ) );
513 if ( empty( $drops ) ) {
514 continue;
515 }
516 $safe = openstation_protect_survivor_dependencies( $registry, $registry->queue, $drops );
517 $refused = array_diff( $drops, $safe );
518 foreach ( $refused as $handle ) {
519 _doing_it_wrong(
520 __FUNCTION__,
521 sprintf(
522 /* translators: 1: script or style handle, 2: script or style */
523 esc_html__( 'The %2$s handle "%1$s" cannot leave the shell screen: something still enqueued depends on it.', 'desktop-mode' ),
524 esc_html( $handle ),
525 esc_html( $kind )
526 ),
527 ''
528 );
529 }
530 foreach ( $safe as $handle ) {
531 if ( 'script' === $kind ) {
532 wp_dequeue_script( $handle );
533 } else {
534 wp_dequeue_style( $handle );
535 }
536 }
537 }
538 }
539 add_action( 'admin_enqueue_scripts', 'openstation_shell_dequeue_assets', PHP_INT_MAX );
540