PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 1.1.12
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v1.1.12
1.1.12 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 All 36 releases
desktop-mode / includes / framework / class-app.php

class-app.php in OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin 1.1.12, at includes/framework/class-app.php

1,222 lines 34.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * OpenStation App Framework — the App definition.
4 *
5 * One object describes a whole OpenStation window: what it is
6 * called, how big it opens, which buttons sit in its title bar and
7 * its ⋯ menu, what state it keeps, what happens on each action, and
8 * how it paints. An `.os.php` file is nothing but a `return` of one
9 * of these:
10 *
11 * return App::define( 'hello' )
12 * ->title( 'Hello' )
13 * ->size( 480, 320 )
14 * ->state( array( 'count' => 0 ) )
15 * ->action( 'bump', function ( State $state ) {
16 * $state->set( 'count', $state->get( 'count' ) + 1 );
17 * } )
18 * ->view( function ( State $state ) { ?>
19 * <os-display value="<?php echo esc( $state->get( 'count' ) ); ?>"></os-display>
20 * <os-button variant="primary" os-action="bump">Bump</os-button>
21 * <?php } );
22 *
23 * The host asks for `manifest()` to learn the window and for
24 * `render()` to get its body; `App\Runtime` turns client actions into
25 * state changes and re-renders. No JavaScript is written per app.
26 *
27 * @package OpenStation
28 */
29
30 namespace OpenStation;
31
32 use OpenStation\App\Os;
33 use OpenStation\App\State;
34 use OpenStation\App\View;
35 use function OpenStation\App\Html\esc;
36
37 // Direct access, unless a standalone host is booting on bare PHP.
38 if ( ! defined( 'ABSPATH' ) ) {
39 defined( 'OPENSTATION_STANDALONE' ) || exit;
40 }
41
42 /**
43 * A whole OpenStation window, declared in PHP.
44 */
45 final class App {
46
47 /**
48 * @var string
49 */
50 private $id;
51
52 /**
53 * @var string
54 */
55 private $title = '';
56
57 /**
58 * @var string
59 */
60 private $icon = 'dashicons-admin-generic';
61
62 /**
63 * Icon as raw SVG markup, when the app drew its own.
64 *
65 * @var string
66 */
67 private $icon_svg = '';
68
69 /**
70 * @var array{width:int,height:int,min_width:int,min_height:int}
71 */
72 private $size = array(
73 'width' => 520,
74 'height' => 400,
75 'min_width' => 280,
76 'min_height' => 220,
77 );
78
79 /**
80 * @var array<string,mixed>
81 */
82 private $nav = array(
83 'admin' => 'site',
84 'placement' => 'dock',
85 'nav_kind' => 'app',
86 'dock_order' => 0,
87 'placeable' => false,
88 'autofocus' => false,
89 );
90
91 /**
92 * @var array<string,mixed>|null
93 */
94 private $desktop_icon = null;
95
96 /**
97 * @var callable|null
98 */
99 private $gate = null;
100
101 /**
102 * @var string[]
103 */
104 private $capabilities = array();
105
106 /**
107 * @var string
108 */
109 private $style = '';
110
111 /**
112 * @var array<string,mixed>
113 */
114 private $defaults = array();
115
116 /**
117 * The admin menu this window answers for, and the tabs that ARE
118 * that menu's pages. See {@see App::menu()}.
119 *
120 * @var array<string,mixed>|null
121 */
122 private $menu = null;
123
124 /**
125 * @var callable|null
126 */
127 private $mount = null;
128
129 /**
130 * @var array<string,callable>
131 */
132 private $actions = array();
133
134 /**
135 * @var callable|null
136 */
137 private $view = null;
138
139 /**
140 * @var callable|null
141 */
142 private $data = null;
143
144 /**
145 * Built client-view script, absolute path.
146 *
147 * @var string
148 */
149 private $client = '';
150
151 /**
152 * Whether `data()` ships with the window config. See `prefetch()`.
153 *
154 * @var bool
155 */
156 private $prefetch = false;
157
158 /**
159 * @var array<int,array<string,mixed>>
160 */
161 private $title_bar_buttons = array();
162
163 /**
164 * @var array<int,array<string,mixed>>
165 */
166 private $window_actions = array();
167
168 /**
169 * Per-window chrome: `theme` tokens, `controls`, `slots`.
170 *
171 * @var array<string,mixed>
172 */
173 private $appearance = array();
174
175 /**
176 * @var array<string,mixed>
177 */
178 private $config = array();
179
180 /**
181 * Config callables resolved when the manifest is built.
182 *
183 * @var callable[]
184 */
185 private $config_lazy = array();
186
187 /**
188 * Extra tabs: `value => array( label, view, position )`.
189 *
190 * @var array<string,array<string,mixed>>
191 */
192 private $tabs = array();
193
194 /**
195 * Channel subscriptions: `channel => action`.
196 *
197 * @var array<string,string>
198 */
199 private $channels = array();
200
201 /**
202 * Content types whose `os.<type>.changed` broadcasts re-render
203 * this app.
204 *
205 * @var string[]
206 */
207 private $watch = array();
208
209 /**
210 * @var string
211 */
212 private $dir = '';
213
214 /**
215 * @var string
216 */
217 private $file = '';
218
219 /**
220 * @param string $id App id — also the window id and the icon id.
221 * @throws \InvalidArgumentException When the id is not a slug.
222 */
223 private function __construct( $id ) {
224 $id = strtolower( trim( (string) $id ) );
225 if ( ! preg_match( '/^[a-z0-9][a-z0-9_-]*$/', $id ) ) {
226 // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Escaped by `Html\esc()`; the host-agnostic core cannot call `esc_html()`, and Plugin Check runs its own ruleset without our `customEscapingFunctions`.
227 throw new \InvalidArgumentException( sprintf( 'Invalid app id "%s": use lowercase letters, digits, "-" and "_".', esc( $id ) ) );
228 }
229 $this->id = $id;
230 }
231
232 /**
233 * Start a definition.
234 *
235 * @param string $id App id.
236 * @return self
237 */
238 public static function define( $id ) {
239 return new self( $id );
240 }
241
242 // -------------------------------------------------------- identity
243
244 /**
245 * Window title (also the desktop icon's label by default).
246 *
247 * @param string $title Title.
248 * @return self
249 */
250 public function title( $title ) {
251 $this->title = (string) $title;
252 return $this;
253 }
254
255 /**
256 * Icon: a Dashicons class, an image URL, or raw `<svg>` markup
257 * drawn in `currentColor` (the shell paints it as a mask).
258 *
259 * @param string $icon Icon reference or SVG markup.
260 * @return self
261 */
262 public function icon( $icon ) {
263 $icon = trim( (string) $icon );
264 if ( 0 === strpos( $icon, '<svg' ) ) {
265 $this->icon_svg = $icon;
266 // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- Building a data: URI for an inline SVG icon, not obfuscating anything.
267 $this->icon = 'data:image/svg+xml;base64,' . base64_encode( $icon );
268 } else {
269 $this->icon_svg = '';
270 $this->icon = $icon;
271 }
272 return $this;
273 }
274
275 /**
276 * Initial window size.
277 *
278 * @param int $width Pixels.
279 * @param int $height Pixels.
280 * @return self
281 */
282 public function size( $width, $height ) {
283 $this->size['width'] = max( 1, (int) $width );
284 $this->size['height'] = max( 1, (int) $height );
285 return $this;
286 }
287
288 /**
289 * Smallest size the user can drag the window to.
290 *
291 * @param int $width Pixels.
292 * @param int $height Pixels.
293 * @return self
294 */
295 public function min_size( $width, $height ) {
296 $this->size['min_width'] = max( 1, (int) $width );
297 $this->size['min_height'] = max( 1, (int) $height );
298 return $this;
299 }
300
301 /**
302 * Where the launcher goes by default: `'dock'` for a tile in the
303 * dock, `'none'` to rely on a desktop icon or another opener.
304 *
305 * @param string $placement `dock` | `none`.
306 * @return self
307 */
308 public function placement( $placement ) {
309 $this->nav['placement'] = 'none' === $placement ? 'none' : 'dock';
310 return $this;
311 }
312
313 /**
314 * Which admin's shell offers the window. `site` (the default) is
315 * every site's shell and never the network admin's — right for an
316 * app that reads the current site; `network` is the network admin's
317 * shell only; `any` is both. On a single-site install the network
318 * admin does not exist, so `network` there offers the app nowhere.
319 *
320 * @param string $admin `site` | `network` | `any`.
321 * @return self
322 */
323 public function admin( $admin ) {
324 $this->nav['admin'] = in_array( $admin, array( 'site', 'network', 'any' ), true ) ? $admin : 'site';
325 return $this;
326 }
327
328 /**
329 * What the window is to the navigation model.
330 *
331 * @param string $kind `app` | `control`.
332 * @return self
333 */
334 public function nav_kind( $kind ) {
335 $this->nav['nav_kind'] = 'control' === $kind ? 'control' : 'app';
336 return $this;
337 }
338
339 /**
340 * Sort key among dock tiles.
341 *
342 * @param int $order Ascending.
343 * @return self
344 */
345 public function dock_order( $order ) {
346 $this->nav['dock_order'] = (int) $order;
347 return $this;
348 }
349
350 /**
351 * Let the user move or hide the dock tile from Preferences.
352 *
353 * @param bool $placeable Default true.
354 * @return self
355 */
356 public function placeable( $placeable = true ) {
357 $this->nav['placeable'] = (bool) $placeable;
358 return $this;
359 }
360
361 /**
362 * Focus the body (or a selector inside it) once the window opens.
363 *
364 * @param bool|string $autofocus `true`, or a CSS selector.
365 * @return self
366 */
367 public function autofocus( $autofocus = true ) {
368 $this->nav['autofocus'] = is_string( $autofocus ) ? $autofocus : (bool) $autofocus;
369 return $this;
370 }
371
372 /**
373 * Also put a shortcut on the wallpaper.
374 *
375 * @param array<string,mixed> $args `position` (int), `pinned` (bool), `title`, `icon`.
376 * @return self
377 */
378 public function desktop_icon( array $args = array() ) {
379 $this->desktop_icon = $args;
380 return $this;
381 }
382
383 // ------------------------------------------------------------ access
384
385 /**
386 * Gate the whole app — window, icon, dispatch — behind a predicate.
387 * Combined with `capabilities()`; both must pass.
388 *
389 * @param callable $gate `function ( Os $os ): bool`.
390 * @return self
391 */
392 public function can( callable $gate ) {
393 $this->gate = $gate;
394 return $this;
395 }
396
397 /**
398 * Capabilities the acting user must ALL hold.
399 *
400 * @param string ...$capabilities Capability slugs.
401 * @return self
402 */
403 public function capabilities( ...$capabilities ) {
404 $this->capabilities = array_values( array_filter( array_map( 'strval', $capabilities ) ) );
405 return $this;
406 }
407
408 /**
409 * Whether the acting user may use this app.
410 *
411 * @param Os $os Host handle.
412 * @return bool
413 */
414 public function allows( Os $os ) {
415 if ( ! $os->auth->is_logged_in() ) {
416 return false;
417 }
418 foreach ( $this->capabilities as $capability ) {
419 if ( ! $os->auth->can( $capability ) ) {
420 return false;
421 }
422 }
423 if ( null !== $this->gate ) {
424 return (bool) call_user_func( $this->gate, $os );
425 }
426 return true;
427 }
428
429 // ------------------------------------------------------------- assets
430
431 /**
432 * Stylesheet for the window body. Resolved by convention when
433 * omitted: `<app dir>/<id>.css` if that file exists.
434 *
435 * @param string $path Absolute path to a CSS file.
436 * @return self
437 */
438 public function style( $path ) {
439 $this->style = (string) $path;
440 return $this;
441 }
442
443 /**
444 * Absolute stylesheet path, or '' when the app has none. By
445 * convention `<dir>/<id>.css`, else `<dir>/<file>.css` where
446 * `<file>` is the definition file's name without `.os.php`.
447 *
448 * @return string
449 */
450 public function style_path() {
451 if ( '' !== $this->style ) {
452 return $this->style;
453 }
454 if ( '' === $this->dir ) {
455 return '';
456 }
457 $candidates = array( $this->dir . '/' . $this->id . '.css' );
458 if ( '' !== $this->file_base() ) {
459 $candidates[] = $this->dir . '/' . $this->file_base() . '.css';
460 }
461 foreach ( $candidates as $candidate ) {
462 if ( is_file( $candidate ) ) {
463 return $candidate;
464 }
465 }
466 return '';
467 }
468
469 /**
470 * Record where the definition file lives. Called by the loader.
471 *
472 * @param string $dir Directory.
473 * @param string $file File path.
474 * @return self
475 */
476 public function located_at( $dir, $file = '' ) {
477 $this->dir = rtrim( (string) $dir, '/\\' );
478 $this->file = (string) $file;
479 return $this;
480 }
481
482 /**
483 * Directory the definition was loaded from ('' when defined inline).
484 *
485 * @return string
486 */
487 public function dir() {
488 return $this->dir;
489 }
490
491 /**
492 * File the definition was loaded from ('' when defined inline).
493 *
494 * @return string
495 */
496 public function file() {
497 return $this->file;
498 }
499
500 // ---------------------------------------------------- state & logic
501
502 /**
503 * Declare the state and its defaults. The defaults are the schema:
504 * only these keys exist, and each keeps its declared type.
505 *
506 * @param array<string,mixed> $defaults Key → default value.
507 * @return self
508 */
509 public function state( array $defaults ) {
510 $this->defaults = $defaults;
511 return $this;
512 }
513
514 /**
515 * Declare the admin menu this window answers for, and the tabs
516 * that ARE that menu's pages.
517 *
518 * A window that replaces an admin screen replaces its menu too:
519 * whatever the window offers as a tab, the dock offers as a row,
520 * with the same label and in the same order, and picking a row
521 * opens the window on that tab. One declaration drives all three
522 * halves of that:
523 *
524 * - the dock's submenu for `$slug` becomes these tabs (the first
525 * is the menu's own page, so it becomes the tile's label);
526 * - each row's URL is the menu's own tagged `os_tab=<id>`, which
527 * the shell's remap reads back as the tab to open on;
528 * - `tab` becomes declared state, written on mount and reopen;
529 * - the tabs reach the client view as `menuTabs`, which its strip
530 * renders from, so the two lists cannot drift.
531 *
532 * A tab's value is its label, or `array( 'label' => …, 'page' => … )`
533 * naming the submenu slug it replaces (`user-new.php`). That page
534 * is which of wp-admin's own rows this window answers for — the
535 * rest are kept, so a plugin's page under this menu stays
536 * reachable — and what the shell routes here from anywhere else.
537 *
538 * @param string $slug Admin menu slug, e.g. `users.php`.
539 * @param array<string,mixed>|callable $tabs Ordered `id => label|array`, or a
540 * callable returning one (for tabs
541 * that depend on capabilities).
542 * @param callable|null $enabled Optional gate — the opt-in that
543 * decides whether this window answers
544 * for the menu at all. Default: always.
545 * @return self
546 */
547 public function menu( $slug, $tabs, $enabled = null ) {
548 $this->menu = array(
549 'slug' => (string) $slug,
550 'tabs' => $tabs,
551 'enabled' => $enabled,
552 );
553 return $this;
554 }
555
556 /**
557 * The declared menu's tabs for the CURRENT user, as an ordered
558 * list of `array( 'id', 'label' )`. Empty when no menu is declared.
559 *
560 * Not gated: these are the window's tabs whoever opened it and
561 * whatever the opt-in says, and the client view renders its strip
562 * from them. The gate decides only whether the DOCK's submenu
563 * becomes this list — see {@see App::menu_owns_dock()}.
564 *
565 * @return array<int,array<string,string>>
566 */
567 public function menu_tabs() {
568 if ( ! $this->menu ) {
569 return array();
570 }
571 $tabs = is_callable( $this->menu['tabs'] )
572 ? (array) call_user_func( $this->menu['tabs'] )
573 : (array) $this->menu['tabs'];
574 $out = array();
575 foreach ( $tabs as $id => $tab ) {
576 $id = strtolower( (string) preg_replace( '/[^a-zA-Z0-9_-]/', '', (string) $id ) );
577 $label = is_array( $tab ) ? (string) ( $tab['label'] ?? '' ) : (string) $tab;
578 if ( '' === $id || '' === $label ) {
579 continue;
580 }
581 $out[] = array(
582 'id' => $id,
583 'label' => $label,
584 // The wp-admin submenu slug this tab stands in for,
585 // '' for a tab wp-admin has no page for.
586 'page' => is_array( $tab ) ? (string) ( $tab['page'] ?? '' ) : '',
587 );
588 }
589 return $out;
590 }
591
592 /**
593 * The admin menu slug this window answers for, `''` when none.
594 *
595 * @return string
596 */
597 public function menu_slug() {
598 return $this->menu ? (string) $this->menu['slug'] : '';
599 }
600
601 /**
602 * Whether this window is the one answering for its menu right
603 * now — the per-user opt-in that chooses between it and the
604 * classic screen. Only then does the dock's submenu become the
605 * window's tabs.
606 *
607 * @return bool
608 */
609 public function menu_owns_dock() {
610 if ( ! $this->menu ) {
611 return false;
612 }
613 return ! is_callable( $this->menu['enabled'] ) || (bool) call_user_func( $this->menu['enabled'] );
614 }
615
616 /**
617 * Runs once, before the first render, with the fresh state.
618 *
619 * @param callable $mount `function ( State $state, Os $os )`.
620 * @return self
621 */
622 public function mount( callable $mount ) {
623 $this->mount = $mount;
624 return $this;
625 }
626
627 /**
628 * Declare an action. Markup triggers it with `os-action="<name>"`;
629 * the handler mutates the state and the view re-renders.
630 *
631 * @param string $name Action name (`[a-z0-9_-]`).
632 * @param callable $handler `function ( State $state, Os $os, array $args )`.
633 * @return self
634 * @throws \InvalidArgumentException When the name is not a slug or is reserved.
635 */
636 public function action( $name, callable $handler ) {
637 $name = strtolower( trim( (string) $name ) );
638 if ( ! preg_match( '/^[a-z0-9_-]+$/', $name ) || App\Runtime::ACTION_MOUNT === $name ) {
639 // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Escaped by `Html\esc()`; see the note on the id exception above.
640 throw new \InvalidArgumentException( sprintf( 'Invalid action name "%s".', esc( $name ) ) );
641 }
642 $this->actions[ $name ] = $handler;
643 return $this;
644 }
645
646 /**
647 * The view: paints the body for a state. Echo markup, return a
648 * string, or both.
649 *
650 * @param callable $view `function ( State $state, Os $os )`.
651 * @return self
652 */
653 public function view( callable $view ) {
654 $this->view = $view;
655 return $this;
656 }
657
658 /**
659 * The data a client view (`.os.ts`) renders from — everything the
660 * browser needs to paint the body for any state without asking
661 * the server again: rows, options, environment facts. Computed
662 * after every server action, from the same `( State, Os )` a view
663 * gets, and shipped as `data` in the response. Keep it to what
664 * the view reads; it travels on every round trip.
665 *
666 * @param callable $data `function ( State $state, Os $os ): array`.
667 * @return self
668 */
669 public function data( callable $data ) {
670 $this->data = $data;
671 return $this;
672 }
673
674 /**
675 * Compute `data()` once at registration and ship it with the
676 * window config, so a client view paints from the declared state
677 * the moment the window opens instead of behind a spinner for the
678 * length of the `mount` round trip (a WordPress request — hundreds
679 * of milliseconds, and the first click on a spinner is a click
680 * lost). `mount` still runs and refreshes both state and data.
681 *
682 * Opt-in, because the cost is paid on every shell page load for
683 * every user who may open the app: right for a `data()` that is a
684 * handful of capability checks and options, wrong for one that
685 * runs queries.
686 *
687 * @param bool $prefetch Default true.
688 * @return self
689 */
690 public function prefetch( $prefetch = true ) {
691 $this->prefetch = (bool) $prefetch;
692 return $this;
693 }
694
695 /**
696 * Whether `data()` is prefetched at registration.
697 *
698 * @return bool
699 */
700 public function prefetches() {
701 return $this->prefetch && null !== $this->data;
702 }
703
704 /**
705 * The built client-view script for this app. By convention an app
706 * with `<dir>/<file>.os.ts` beside its `.os.php` needs no call —
707 * the host resolves the built bundle. Third-party apps that build
708 * their own pass the absolute path of the built file here.
709 *
710 * @param string $path Absolute path to a built `.js`.
711 * @return self
712 */
713 public function client( $path ) {
714 $this->client = (string) $path;
715 return $this;
716 }
717
718 /**
719 * An extra tab in the window's tab strip, with its own view. The
720 * main view is the first tab (labelled with the window title).
721 * Each tab runs as its own session: same declared state shape,
722 * separate values, and `$os->view` tells an action which tab
723 * dispatched it.
724 *
725 * @param string $value Tab slug (not `main`).
726 * @param array<string,mixed> $args `label` (required), `view` (callable, required), `position` (int, default 100).
727 * @return self
728 * @throws \InvalidArgumentException When the slug, label or view is missing.
729 */
730 public function tab( $value, array $args ) {
731 $value = strtolower( (string) preg_replace( '/[^a-zA-Z0-9_-]/', '', (string) $value ) );
732 if ( '' === $value || 'main' === $value || empty( $args['label'] ) || empty( $args['view'] ) || ! is_callable( $args['view'] ) ) {
733 throw new \InvalidArgumentException( 'A tab needs a slug other than "main", a label and a callable view.' );
734 }
735 $this->tabs[ $value ] = array(
736 'value' => $value,
737 'label' => (string) $args['label'],
738 'view' => $args['view'],
739 'position' => isset( $args['position'] ) ? (int) $args['position'] : 100,
740 );
741 return $this;
742 }
743
744 /**
745 * Dispatch an action whenever a peer publishes on one of this
746 * window's channels (`wp.os.connect( id ).send( channel, payload )`
747 * or `Window.send()`). The payload arrives as `$args['payload']`.
748 *
749 * @param string $channel Channel name.
750 * @param string $action Declared action to run.
751 * @return self
752 */
753 public function on_channel( $channel, $action ) {
754 $this->channels[ (string) $channel ] = (string) $action;
755 return $this;
756 }
757
758 /**
759 * Re-render whenever the named content changes ANYWHERE on the
760 * desktop — another window trashing a post, the Recycle Bin
761 * restoring one, a plugin announcing its own type.
762 *
763 * The runtime subscribes to the shell's `os.<type>.changed`
764 * broadcasts (see `wp.os.announceContentChange`) and re-dispatches
765 * the built-in `set` — state kept, `data()` recomputed, view
766 * repainted. A minimized window skips the refresh and catches up
767 * when it is restored. This is the read half of the pair whose
768 * write half is the `$os->announce()` effect.
769 *
770 * @param string ...$types Content-type slugs (`post`, `page`,
771 * `attachment`, or a plugin's own), or `'*'`
772 * for any content change — the choice when
773 * the types the app shows are only known at
774 * render time (a dynamic post-type list).
775 * @return self
776 */
777 public function watch( ...$types ) {
778 foreach ( $types as $type ) {
779 $type = '*' === $type ? '*' : strtolower( trim( (string) $type ) );
780 if ( '' !== $type && ! in_array( $type, $this->watch, true ) ) {
781 $this->watch[] = $type;
782 }
783 }
784 return $this;
785 }
786
787 // ------------------------------------------------------------ chrome
788
789 /**
790 * A button in the window's title bar that dispatches an action.
791 *
792 * @param string $id Button id, unique within the app.
793 * @param array<string,mixed> $args `label` (required), `action` (required), `icon`
794 * (Dashicons class, inline SVG, or a built-in key such
795 * as `reload`), `placement` (`left` | `right`, default
796 * `right`), `order` (int), `confirm` (see `window_action()`).
797 * @return self
798 */
799 public function title_bar_button( $id, array $args ) {
800 $this->title_bar_buttons[] = self::normalise_control( $id, $args, 'right' );
801 return $this;
802 }
803
804 /**
805 * A row in the window's ⋯ menu that dispatches an action.
806 *
807 * `confirm` may be a string (the question) or an array with
808 * `title`, `message`, `label`, `danger`; the shell asks before
809 * dispatching.
810 *
811 * @param string $id Row id, unique within the app.
812 * @param array<string,mixed> $args `label` (required), `action` (required), `icon`, `order`, `confirm`.
813 * @return self
814 */
815 public function window_action( $id, array $args ) {
816 $this->window_actions[] = self::normalise_control( $id, $args, '' );
817 return $this;
818 }
819
820 /**
821 * Per-window CSS variables (`--os-window-…` tokens) — a window theme.
822 *
823 * @param array<string,string> $tokens Token → value.
824 * @return self
825 */
826 public function theme( array $tokens ) {
827 $clean = array();
828 foreach ( $tokens as $name => $value ) {
829 if ( 0 === strpos( (string) $name, '--' ) ) {
830 $clean[ (string) $name ] = (string) $value;
831 }
832 }
833 $this->appearance['theme'] = $clean;
834 return $this;
835 }
836
837 /**
838 * Reorder or hide the standard window controls.
839 *
840 * @param array<string,mixed> $controls `order` (string[]), `hide` (string[]), `placement`.
841 * @return self
842 */
843 public function controls( array $controls ) {
844 $this->appearance['controls'] = $controls;
845 return $this;
846 }
847
848 /**
849 * Static HTML for one of the title-bar slots (`before-titlebar`,
850 * `after-titlebar`, `after-title`, …).
851 *
852 * @param string $slot Slot name.
853 * @param string $html Markup.
854 * @return self
855 */
856 public function slot( $slot, $html ) {
857 $this->appearance['slots'][ (string) $slot ] = array( 'html' => (string) $html );
858 return $this;
859 }
860
861 /**
862 * Extra values shipped to the client runtime, readable as
863 * `wp.os.getWindowConfig( id ).extra`.
864 *
865 * Pass a callable — `function ( App $app ): array` — for values
866 * that depend on who is asking (capability flags, the viewer's id,
867 * a filtered option): it runs when the manifest is built, for the
868 * acting user at that moment, rather than once when the definition
869 * file loads. Keep it cheap: the manifest is built on every request
870 * that registers windows, so memoise anything that scans.
871 *
872 * @param array<string,mixed>|callable $config Serialisable values, or a callable returning them.
873 * @return self
874 */
875 public function config( $config ) {
876 if ( is_callable( $config ) ) {
877 $this->config_lazy[] = $config;
878 return $this;
879 }
880 $this->config = array_merge( $this->config, (array) $config );
881 return $this;
882 }
883
884 /**
885 * The config extra: the static values, with every lazy callable's
886 * result merged over them in declaration order.
887 *
888 * @return array<string,mixed>
889 */
890 public function resolved_config() {
891 $config = $this->config;
892 foreach ( $this->config_lazy as $callable ) {
893 $config = array_merge( $config, (array) call_user_func( $callable, $this ) );
894 }
895 // The declared menu's tabs, for a client view that renders its
896 // strip from them — the only way the strip and the dock's
897 // submenu cannot drift, since both read this list.
898 $tabs = $this->menu_tabs();
899 if ( $tabs ) {
900 $config['menuTabs'] = $tabs;
901 }
902 return $config;
903 }
904
905 // ----------------------------------------------------------- readers
906
907 /**
908 * App id.
909 *
910 * @return string
911 */
912 public function id() {
913 return $this->id;
914 }
915
916 /**
917 * Declared state defaults.
918 *
919 * @return array<string,mixed>
920 */
921 public function defaults() {
922 if ( ! $this->menu || array_key_exists( 'tab', $this->defaults ) ) {
923 return $this->defaults;
924 }
925 // Declared here rather than by every app that declares a menu:
926 // the runtime writes this key from the `tab` open-time param,
927 // and a key the schema does not carry would be dropped.
928 $tabs = $this->menu_tabs();
929 return array_merge(
930 array( 'tab' => $tabs ? $tabs[0]['id'] : '' ),
931 $this->defaults
932 );
933 }
934
935 /**
936 * Whether an action is declared.
937 *
938 * @param string $name Action name.
939 * @return bool
940 */
941 public function has_action( $name ) {
942 $this->ensure_menu_reopen();
943 return isset( $this->actions[ (string) $name ] );
944 }
945
946 /**
947 * Declared action names.
948 *
949 * @return string[]
950 */
951 public function action_names() {
952 $this->ensure_menu_reopen();
953 return array_keys( $this->actions );
954 }
955
956 /**
957 * A window that declares a menu answers `reopen`, whether or not
958 * it wrote a handler for one.
959 *
960 * The client dispatches a lifecycle action only when the manifest
961 * says the app declared it, so without this the runtime's own
962 * "land on the tab the opener named" never runs on a window that
963 * is already open — the case a dock row for another tab IS.
964 * Registered here rather than in {@see App::menu()} so an app's
965 * own actions keep the order they were declared in.
966 *
967 * @return void
968 */
969 private function ensure_menu_reopen() {
970 if ( $this->menu && ! isset( $this->actions['reopen'] ) ) {
971 $this->actions['reopen'] = static function () {};
972 }
973 }
974
975 /**
976 * Run the mount hook, if any.
977 *
978 * @param State $state State.
979 * @param Os $os Host handle.
980 * @return void
981 */
982 public function run_mount( State $state, Os $os ) {
983 if ( null !== $this->mount ) {
984 call_user_func( $this->mount, $state, $os );
985 }
986 }
987
988 /**
989 * Run an action.
990 *
991 * @param string $name Action name.
992 * @param State $state State.
993 * @param Os $os Host handle.
994 * @param array<string,mixed> $args Arguments from the trigger.
995 * @param bool $required Throw when the action is undeclared. Default true.
996 * @return void
997 * @throws \RuntimeException When `$required` and the action is undeclared.
998 */
999 public function run_action( $name, State $state, Os $os, array $args = array(), $required = true ) {
1000 if ( ! isset( $this->actions[ $name ] ) ) {
1001 if ( $required ) {
1002 // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Escaped by `Html\esc()`; see the note on the id exception above.
1003 throw new \RuntimeException( sprintf( 'Unknown action "%s".', esc( $name ) ) );
1004 }
1005 return;
1006 }
1007 call_user_func( $this->actions[ $name ], $state, $os, $args );
1008 }
1009
1010 /**
1011 * Whether the app declared a `data()` callback.
1012 *
1013 * @return bool
1014 */
1015 public function has_data() {
1016 return null !== $this->data;
1017 }
1018
1019 /**
1020 * Compute the client data for a state.
1021 *
1022 * @param State $state State.
1023 * @param Os $os Host handle.
1024 * @return array<string,mixed>
1025 */
1026 public function compute_data( State $state, Os $os ) {
1027 if ( null === $this->data ) {
1028 return array();
1029 }
1030 $data = call_user_func( $this->data, $state, $os );
1031 return is_array( $data ) ? $data : array();
1032 }
1033
1034 /**
1035 * Explicit built client script path, or ''.
1036 *
1037 * @return string
1038 */
1039 public function client_path() {
1040 return $this->client;
1041 }
1042
1043 /**
1044 * The `.os.ts` source beside the definition file, or '' when the
1045 * app has none. The host maps it to its built bundle.
1046 *
1047 * @return string
1048 */
1049 public function client_source() {
1050 if ( '' === $this->dir || '' === $this->file_base() ) {
1051 return '';
1052 }
1053 $candidate = $this->dir . '/' . $this->file_base() . '.os.ts';
1054 return is_file( $candidate ) ? $candidate : '';
1055 }
1056
1057 /**
1058 * The definition file's name without `.os.php` — the base every
1059 * by-convention sibling (`<base>.css`, `<base>.os.ts`) is named
1060 * after. '' for an app built in code rather than loaded from disk.
1061 *
1062 * @return string
1063 */
1064 private function file_base() {
1065 if ( '' === $this->file ) {
1066 return '';
1067 }
1068 return (string) preg_replace( '/\.os\.php$/', '', basename( $this->file ) );
1069 }
1070
1071 /**
1072 * Whether a view exists: `main`, or a declared tab slug.
1073 *
1074 * @param string $view View name.
1075 * @return bool
1076 */
1077 public function has_view( $view ) {
1078 return 'main' === $view || isset( $this->tabs[ (string) $view ] );
1079 }
1080
1081 /**
1082 * Paint a view for a state.
1083 *
1084 * @param State $state State.
1085 * @param Os $os Host handle.
1086 * @param string $view `main` (default) or a tab slug.
1087 * @return string HTML.
1088 */
1089 public function render( State $state, Os $os, $view = 'main' ) {
1090 $callable = 'main' === $view || '' === (string) $view
1091 ? $this->view
1092 : ( isset( $this->tabs[ $view ] ) ? $this->tabs[ $view ]['view'] : null );
1093 if ( null === $callable ) {
1094 return '';
1095 }
1096 return View::capture( $callable, $state, $os );
1097 }
1098
1099 /**
1100 * Declared tabs, without their callables, by position.
1101 *
1102 * @return array<int,array{value:string,label:string,position:int}>
1103 */
1104 public function tabs() {
1105 $tabs = array_values(
1106 array_map(
1107 static function ( $tab ) {
1108 return array(
1109 'value' => $tab['value'],
1110 'label' => $tab['label'],
1111 'position' => $tab['position'],
1112 );
1113 },
1114 $this->tabs
1115 )
1116 );
1117 usort(
1118 $tabs,
1119 static function ( $a, $b ) {
1120 return $a['position'] <=> $b['position'];
1121 }
1122 );
1123 return $tabs;
1124 }
1125
1126 /**
1127 * Lifecycle moments the runtime reports as actions — only when
1128 * the app declared a handler of that name.
1129 *
1130 * `reopen` fires when the window is asked to open while it is
1131 * already open — `wp.os.openWindow( id, { params } )` on a live
1132 * singleton, a deep link landing on a window that exists. The
1133 * shell writes the NEW params onto the window first, so the
1134 * handler reads them through `$os->params` and retargets.
1135 */
1136 const LIFECYCLE_ACTIONS = array( 'resize', 'show', 'hide', 'focus', 'blur', 'reopen' );
1137
1138 /**
1139 * The whole window as data — everything a host needs to register
1140 * it and everything the client runtime needs to drive it.
1141 *
1142 * @return array<string,mixed>
1143 */
1144 public function manifest() {
1145 return array(
1146 'id' => $this->id,
1147 'title' => $this->title,
1148 'icon' => $this->icon,
1149 'icon_svg' => $this->icon_svg,
1150 'width' => $this->size['width'],
1151 'height' => $this->size['height'],
1152 'min_width' => $this->size['min_width'],
1153 'min_height' => $this->size['min_height'],
1154 'admin' => $this->nav['admin'],
1155 'placement' => $this->nav['placement'],
1156 'nav_kind' => $this->nav['nav_kind'],
1157 'dock_order' => $this->nav['dock_order'],
1158 'placeable' => $this->nav['placeable'],
1159 'autofocus' => $this->nav['autofocus'],
1160 'desktop_icon' => $this->desktop_icon,
1161 'capabilities' => $this->capabilities,
1162 'style' => $this->style_path(),
1163 'state' => $this->defaults,
1164 'actions' => $this->action_names(),
1165 'title_bar_buttons' => $this->title_bar_buttons,
1166 'window_actions' => $this->window_actions,
1167 'appearance' => $this->appearance,
1168 'config' => $this->resolved_config(),
1169 'menu' => $this->menu_slug(),
1170 'menu_tabs' => $this->menu_tabs(),
1171 'tabs' => $this->tabs(),
1172 'channels' => $this->channels,
1173 'watch' => $this->watch,
1174 'client' => $this->client_path(),
1175 'client_source' => $this->client_source(),
1176 'file' => $this->file,
1177 'has_data' => $this->has_data(),
1178 'prefetch' => $this->prefetches(),
1179 'lifecycle' => array_values( array_intersect( self::LIFECYCLE_ACTIONS, $this->action_names() ) ),
1180 );
1181 }
1182
1183 /**
1184 * Normalise a title-bar button / window-action declaration.
1185 *
1186 * @param string $id Control id.
1187 * @param array<string,mixed> $args Declaration.
1188 * @param string $default_placement `right` for buttons, '' for menu rows.
1189 * @return array<string,mixed>
1190 * @throws \InvalidArgumentException When the id, label or action is missing.
1191 */
1192 private static function normalise_control( $id, array $args, $default_placement ) {
1193 $id = strtolower( (string) preg_replace( '/[^a-zA-Z0-9_-]/', '', (string) $id ) );
1194 if ( '' === $id ) {
1195 throw new \InvalidArgumentException( 'A title-bar button or window action needs an id.' );
1196 }
1197 if ( empty( $args['label'] ) || empty( $args['action'] ) ) {
1198 // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Escaped by `Html\esc()`; see the note on the id exception above.
1199 throw new \InvalidArgumentException( sprintf( 'Control "%s" needs both a label and an action.', esc( $id ) ) );
1200 }
1201
1202 $confirm = null;
1203 if ( ! empty( $args['confirm'] ) ) {
1204 $confirm = is_array( $args['confirm'] ) ? $args['confirm'] : array( 'message' => (string) $args['confirm'] );
1205 }
1206
1207 $control = array(
1208 'id' => $id,
1209 'label' => (string) $args['label'],
1210 'action' => (string) $args['action'],
1211 'icon' => isset( $args['icon'] ) ? (string) $args['icon'] : 'dashicons-admin-generic',
1212 'order' => isset( $args['order'] ) ? (int) $args['order'] : 100,
1213 'confirm' => $confirm,
1214 'args' => isset( $args['args'] ) && is_array( $args['args'] ) ? $args['args'] : array(),
1215 );
1216 if ( '' !== $default_placement ) {
1217 $control['placement'] = isset( $args['placement'] ) && 'left' === $args['placement'] ? 'left' : $default_placement;
1218 }
1219 return $control;
1220 }
1221 }
1222