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

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

1,072 lines 29.5 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 * @var callable|null
118 */
119 private $mount = null;
120
121 /**
122 * @var array<string,callable>
123 */
124 private $actions = array();
125
126 /**
127 * @var callable|null
128 */
129 private $view = null;
130
131 /**
132 * @var callable|null
133 */
134 private $data = null;
135
136 /**
137 * Built client-view script, absolute path.
138 *
139 * @var string
140 */
141 private $client = '';
142
143 /**
144 * Whether `data()` ships with the window config. See `prefetch()`.
145 *
146 * @var bool
147 */
148 private $prefetch = false;
149
150 /**
151 * @var array<int,array<string,mixed>>
152 */
153 private $title_bar_buttons = array();
154
155 /**
156 * @var array<int,array<string,mixed>>
157 */
158 private $window_actions = array();
159
160 /**
161 * Per-window chrome: `theme` tokens, `controls`, `slots`.
162 *
163 * @var array<string,mixed>
164 */
165 private $appearance = array();
166
167 /**
168 * @var array<string,mixed>
169 */
170 private $config = array();
171
172 /**
173 * Config callables resolved when the manifest is built.
174 *
175 * @var callable[]
176 */
177 private $config_lazy = array();
178
179 /**
180 * Extra tabs: `value => array( label, view, position )`.
181 *
182 * @var array<string,array<string,mixed>>
183 */
184 private $tabs = array();
185
186 /**
187 * Channel subscriptions: `channel => action`.
188 *
189 * @var array<string,string>
190 */
191 private $channels = array();
192
193 /**
194 * Content types whose `os.<type>.changed` broadcasts re-render
195 * this app.
196 *
197 * @var string[]
198 */
199 private $watch = array();
200
201 /**
202 * @var string
203 */
204 private $dir = '';
205
206 /**
207 * @var string
208 */
209 private $file = '';
210
211 /**
212 * @param string $id App id — also the window id and the icon id.
213 * @throws \InvalidArgumentException When the id is not a slug.
214 */
215 private function __construct( $id ) {
216 $id = strtolower( trim( (string) $id ) );
217 if ( ! preg_match( '/^[a-z0-9][a-z0-9_-]*$/', $id ) ) {
218 // 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`.
219 throw new \InvalidArgumentException( sprintf( 'Invalid app id "%s": use lowercase letters, digits, "-" and "_".', esc( $id ) ) );
220 }
221 $this->id = $id;
222 }
223
224 /**
225 * Start a definition.
226 *
227 * @param string $id App id.
228 * @return self
229 */
230 public static function define( $id ) {
231 return new self( $id );
232 }
233
234 // -------------------------------------------------------- identity
235
236 /**
237 * Window title (also the desktop icon's label by default).
238 *
239 * @param string $title Title.
240 * @return self
241 */
242 public function title( $title ) {
243 $this->title = (string) $title;
244 return $this;
245 }
246
247 /**
248 * Icon: a Dashicons class, an image URL, or raw `<svg>` markup
249 * drawn in `currentColor` (the shell paints it as a mask).
250 *
251 * @param string $icon Icon reference or SVG markup.
252 * @return self
253 */
254 public function icon( $icon ) {
255 $icon = trim( (string) $icon );
256 if ( 0 === strpos( $icon, '<svg' ) ) {
257 $this->icon_svg = $icon;
258 // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- Building a data: URI for an inline SVG icon, not obfuscating anything.
259 $this->icon = 'data:image/svg+xml;base64,' . base64_encode( $icon );
260 } else {
261 $this->icon_svg = '';
262 $this->icon = $icon;
263 }
264 return $this;
265 }
266
267 /**
268 * Initial window size.
269 *
270 * @param int $width Pixels.
271 * @param int $height Pixels.
272 * @return self
273 */
274 public function size( $width, $height ) {
275 $this->size['width'] = max( 1, (int) $width );
276 $this->size['height'] = max( 1, (int) $height );
277 return $this;
278 }
279
280 /**
281 * Smallest size the user can drag the window to.
282 *
283 * @param int $width Pixels.
284 * @param int $height Pixels.
285 * @return self
286 */
287 public function min_size( $width, $height ) {
288 $this->size['min_width'] = max( 1, (int) $width );
289 $this->size['min_height'] = max( 1, (int) $height );
290 return $this;
291 }
292
293 /**
294 * Where the launcher goes by default: `'dock'` for a tile in the
295 * dock, `'none'` to rely on a desktop icon or another opener.
296 *
297 * @param string $placement `dock` | `none`.
298 * @return self
299 */
300 public function placement( $placement ) {
301 $this->nav['placement'] = 'none' === $placement ? 'none' : 'dock';
302 return $this;
303 }
304
305 /**
306 * Which admin's shell offers the window. `site` (the default) is
307 * every site's shell and never the network admin's — right for an
308 * app that reads the current site; `network` is the network admin's
309 * shell only; `any` is both. On a single-site install the network
310 * admin does not exist, so `network` there offers the app nowhere.
311 *
312 * @param string $admin `site` | `network` | `any`.
313 * @return self
314 */
315 public function admin( $admin ) {
316 $this->nav['admin'] = in_array( $admin, array( 'site', 'network', 'any' ), true ) ? $admin : 'site';
317 return $this;
318 }
319
320 /**
321 * What the window is to the navigation model.
322 *
323 * @param string $kind `app` | `control`.
324 * @return self
325 */
326 public function nav_kind( $kind ) {
327 $this->nav['nav_kind'] = 'control' === $kind ? 'control' : 'app';
328 return $this;
329 }
330
331 /**
332 * Sort key among dock tiles.
333 *
334 * @param int $order Ascending.
335 * @return self
336 */
337 public function dock_order( $order ) {
338 $this->nav['dock_order'] = (int) $order;
339 return $this;
340 }
341
342 /**
343 * Let the user move or hide the dock tile from Preferences.
344 *
345 * @param bool $placeable Default true.
346 * @return self
347 */
348 public function placeable( $placeable = true ) {
349 $this->nav['placeable'] = (bool) $placeable;
350 return $this;
351 }
352
353 /**
354 * Focus the body (or a selector inside it) once the window opens.
355 *
356 * @param bool|string $autofocus `true`, or a CSS selector.
357 * @return self
358 */
359 public function autofocus( $autofocus = true ) {
360 $this->nav['autofocus'] = is_string( $autofocus ) ? $autofocus : (bool) $autofocus;
361 return $this;
362 }
363
364 /**
365 * Also put a shortcut on the wallpaper.
366 *
367 * @param array<string,mixed> $args `position` (int), `pinned` (bool), `title`, `icon`.
368 * @return self
369 */
370 public function desktop_icon( array $args = array() ) {
371 $this->desktop_icon = $args;
372 return $this;
373 }
374
375 // ------------------------------------------------------------ access
376
377 /**
378 * Gate the whole app — window, icon, dispatch — behind a predicate.
379 * Combined with `capabilities()`; both must pass.
380 *
381 * @param callable $gate `function ( Os $os ): bool`.
382 * @return self
383 */
384 public function can( callable $gate ) {
385 $this->gate = $gate;
386 return $this;
387 }
388
389 /**
390 * Capabilities the acting user must ALL hold.
391 *
392 * @param string ...$capabilities Capability slugs.
393 * @return self
394 */
395 public function capabilities( ...$capabilities ) {
396 $this->capabilities = array_values( array_filter( array_map( 'strval', $capabilities ) ) );
397 return $this;
398 }
399
400 /**
401 * Whether the acting user may use this app.
402 *
403 * @param Os $os Host handle.
404 * @return bool
405 */
406 public function allows( Os $os ) {
407 if ( ! $os->auth->is_logged_in() ) {
408 return false;
409 }
410 foreach ( $this->capabilities as $capability ) {
411 if ( ! $os->auth->can( $capability ) ) {
412 return false;
413 }
414 }
415 if ( null !== $this->gate ) {
416 return (bool) call_user_func( $this->gate, $os );
417 }
418 return true;
419 }
420
421 // ------------------------------------------------------------- assets
422
423 /**
424 * Stylesheet for the window body. Resolved by convention when
425 * omitted: `<app dir>/<id>.css` if that file exists.
426 *
427 * @param string $path Absolute path to a CSS file.
428 * @return self
429 */
430 public function style( $path ) {
431 $this->style = (string) $path;
432 return $this;
433 }
434
435 /**
436 * Absolute stylesheet path, or '' when the app has none. By
437 * convention `<dir>/<id>.css`, else `<dir>/<file>.css` where
438 * `<file>` is the definition file's name without `.os.php`.
439 *
440 * @return string
441 */
442 public function style_path() {
443 if ( '' !== $this->style ) {
444 return $this->style;
445 }
446 if ( '' === $this->dir ) {
447 return '';
448 }
449 $candidates = array( $this->dir . '/' . $this->id . '.css' );
450 if ( '' !== $this->file_base() ) {
451 $candidates[] = $this->dir . '/' . $this->file_base() . '.css';
452 }
453 foreach ( $candidates as $candidate ) {
454 if ( is_file( $candidate ) ) {
455 return $candidate;
456 }
457 }
458 return '';
459 }
460
461 /**
462 * Record where the definition file lives. Called by the loader.
463 *
464 * @param string $dir Directory.
465 * @param string $file File path.
466 * @return self
467 */
468 public function located_at( $dir, $file = '' ) {
469 $this->dir = rtrim( (string) $dir, '/\\' );
470 $this->file = (string) $file;
471 return $this;
472 }
473
474 /**
475 * Directory the definition was loaded from ('' when defined inline).
476 *
477 * @return string
478 */
479 public function dir() {
480 return $this->dir;
481 }
482
483 /**
484 * File the definition was loaded from ('' when defined inline).
485 *
486 * @return string
487 */
488 public function file() {
489 return $this->file;
490 }
491
492 // ---------------------------------------------------- state & logic
493
494 /**
495 * Declare the state and its defaults. The defaults are the schema:
496 * only these keys exist, and each keeps its declared type.
497 *
498 * @param array<string,mixed> $defaults Key → default value.
499 * @return self
500 */
501 public function state( array $defaults ) {
502 $this->defaults = $defaults;
503 return $this;
504 }
505
506 /**
507 * Runs once, before the first render, with the fresh state.
508 *
509 * @param callable $mount `function ( State $state, Os $os )`.
510 * @return self
511 */
512 public function mount( callable $mount ) {
513 $this->mount = $mount;
514 return $this;
515 }
516
517 /**
518 * Declare an action. Markup triggers it with `os-action="<name>"`;
519 * the handler mutates the state and the view re-renders.
520 *
521 * @param string $name Action name (`[a-z0-9_-]`).
522 * @param callable $handler `function ( State $state, Os $os, array $args )`.
523 * @return self
524 * @throws \InvalidArgumentException When the name is not a slug or is reserved.
525 */
526 public function action( $name, callable $handler ) {
527 $name = strtolower( trim( (string) $name ) );
528 if ( ! preg_match( '/^[a-z0-9_-]+$/', $name ) || App\Runtime::ACTION_MOUNT === $name ) {
529 // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Escaped by `Html\esc()`; see the note on the id exception above.
530 throw new \InvalidArgumentException( sprintf( 'Invalid action name "%s".', esc( $name ) ) );
531 }
532 $this->actions[ $name ] = $handler;
533 return $this;
534 }
535
536 /**
537 * The view: paints the body for a state. Echo markup, return a
538 * string, or both.
539 *
540 * @param callable $view `function ( State $state, Os $os )`.
541 * @return self
542 */
543 public function view( callable $view ) {
544 $this->view = $view;
545 return $this;
546 }
547
548 /**
549 * The data a client view (`.os.ts`) renders from — everything the
550 * browser needs to paint the body for any state without asking
551 * the server again: rows, options, environment facts. Computed
552 * after every server action, from the same `( State, Os )` a view
553 * gets, and shipped as `data` in the response. Keep it to what
554 * the view reads; it travels on every round trip.
555 *
556 * @param callable $data `function ( State $state, Os $os ): array`.
557 * @return self
558 */
559 public function data( callable $data ) {
560 $this->data = $data;
561 return $this;
562 }
563
564 /**
565 * Compute `data()` once at registration and ship it with the
566 * window config, so a client view paints from the declared state
567 * the moment the window opens instead of behind a spinner for the
568 * length of the `mount` round trip (a WordPress request — hundreds
569 * of milliseconds, and the first click on a spinner is a click
570 * lost). `mount` still runs and refreshes both state and data.
571 *
572 * Opt-in, because the cost is paid on every shell page load for
573 * every user who may open the app: right for a `data()` that is a
574 * handful of capability checks and options, wrong for one that
575 * runs queries.
576 *
577 * @param bool $prefetch Default true.
578 * @return self
579 */
580 public function prefetch( $prefetch = true ) {
581 $this->prefetch = (bool) $prefetch;
582 return $this;
583 }
584
585 /**
586 * Whether `data()` is prefetched at registration.
587 *
588 * @return bool
589 */
590 public function prefetches() {
591 return $this->prefetch && null !== $this->data;
592 }
593
594 /**
595 * The built client-view script for this app. By convention an app
596 * with `<dir>/<file>.os.ts` beside its `.os.php` needs no call —
597 * the host resolves the built bundle. Third-party apps that build
598 * their own pass the absolute path of the built file here.
599 *
600 * @param string $path Absolute path to a built `.js`.
601 * @return self
602 */
603 public function client( $path ) {
604 $this->client = (string) $path;
605 return $this;
606 }
607
608 /**
609 * An extra tab in the window's tab strip, with its own view. The
610 * main view is the first tab (labelled with the window title).
611 * Each tab runs as its own session: same declared state shape,
612 * separate values, and `$os->view` tells an action which tab
613 * dispatched it.
614 *
615 * @param string $value Tab slug (not `main`).
616 * @param array<string,mixed> $args `label` (required), `view` (callable, required), `position` (int, default 100).
617 * @return self
618 * @throws \InvalidArgumentException When the slug, label or view is missing.
619 */
620 public function tab( $value, array $args ) {
621 $value = strtolower( (string) preg_replace( '/[^a-zA-Z0-9_-]/', '', (string) $value ) );
622 if ( '' === $value || 'main' === $value || empty( $args['label'] ) || empty( $args['view'] ) || ! is_callable( $args['view'] ) ) {
623 throw new \InvalidArgumentException( 'A tab needs a slug other than "main", a label and a callable view.' );
624 }
625 $this->tabs[ $value ] = array(
626 'value' => $value,
627 'label' => (string) $args['label'],
628 'view' => $args['view'],
629 'position' => isset( $args['position'] ) ? (int) $args['position'] : 100,
630 );
631 return $this;
632 }
633
634 /**
635 * Dispatch an action whenever a peer publishes on one of this
636 * window's channels (`wp.os.connect( id ).send( channel, payload )`
637 * or `Window.send()`). The payload arrives as `$args['payload']`.
638 *
639 * @param string $channel Channel name.
640 * @param string $action Declared action to run.
641 * @return self
642 */
643 public function on_channel( $channel, $action ) {
644 $this->channels[ (string) $channel ] = (string) $action;
645 return $this;
646 }
647
648 /**
649 * Re-render whenever the named content changes ANYWHERE on the
650 * desktop — another window trashing a post, the Recycle Bin
651 * restoring one, a plugin announcing its own type.
652 *
653 * The runtime subscribes to the shell's `os.<type>.changed`
654 * broadcasts (see `wp.os.announceContentChange`) and re-dispatches
655 * the built-in `set` — state kept, `data()` recomputed, view
656 * repainted. A minimized window skips the refresh and catches up
657 * when it is restored. This is the read half of the pair whose
658 * write half is the `$os->announce()` effect.
659 *
660 * @param string ...$types Content-type slugs (`post`, `page`,
661 * `attachment`, or a plugin's own), or `'*'`
662 * for any content change — the choice when
663 * the types the app shows are only known at
664 * render time (a dynamic post-type list).
665 * @return self
666 */
667 public function watch( ...$types ) {
668 foreach ( $types as $type ) {
669 $type = '*' === $type ? '*' : strtolower( trim( (string) $type ) );
670 if ( '' !== $type && ! in_array( $type, $this->watch, true ) ) {
671 $this->watch[] = $type;
672 }
673 }
674 return $this;
675 }
676
677 // ------------------------------------------------------------ chrome
678
679 /**
680 * A button in the window's title bar that dispatches an action.
681 *
682 * @param string $id Button id, unique within the app.
683 * @param array<string,mixed> $args `label` (required), `action` (required), `icon`
684 * (Dashicons class, inline SVG, or a built-in key such
685 * as `reload`), `placement` (`left` | `right`, default
686 * `right`), `order` (int), `confirm` (see `window_action()`).
687 * @return self
688 */
689 public function title_bar_button( $id, array $args ) {
690 $this->title_bar_buttons[] = self::normalise_control( $id, $args, 'right' );
691 return $this;
692 }
693
694 /**
695 * A row in the window's ⋯ menu that dispatches an action.
696 *
697 * `confirm` may be a string (the question) or an array with
698 * `title`, `message`, `label`, `danger`; the shell asks before
699 * dispatching.
700 *
701 * @param string $id Row id, unique within the app.
702 * @param array<string,mixed> $args `label` (required), `action` (required), `icon`, `order`, `confirm`.
703 * @return self
704 */
705 public function window_action( $id, array $args ) {
706 $this->window_actions[] = self::normalise_control( $id, $args, '' );
707 return $this;
708 }
709
710 /**
711 * Per-window CSS variables (`--os-window-…` tokens) — a window theme.
712 *
713 * @param array<string,string> $tokens Token → value.
714 * @return self
715 */
716 public function theme( array $tokens ) {
717 $clean = array();
718 foreach ( $tokens as $name => $value ) {
719 if ( 0 === strpos( (string) $name, '--' ) ) {
720 $clean[ (string) $name ] = (string) $value;
721 }
722 }
723 $this->appearance['theme'] = $clean;
724 return $this;
725 }
726
727 /**
728 * Reorder or hide the standard window controls.
729 *
730 * @param array<string,mixed> $controls `order` (string[]), `hide` (string[]), `placement`.
731 * @return self
732 */
733 public function controls( array $controls ) {
734 $this->appearance['controls'] = $controls;
735 return $this;
736 }
737
738 /**
739 * Static HTML for one of the title-bar slots (`before-titlebar`,
740 * `after-titlebar`, `after-title`, …).
741 *
742 * @param string $slot Slot name.
743 * @param string $html Markup.
744 * @return self
745 */
746 public function slot( $slot, $html ) {
747 $this->appearance['slots'][ (string) $slot ] = array( 'html' => (string) $html );
748 return $this;
749 }
750
751 /**
752 * Extra values shipped to the client runtime, readable as
753 * `wp.os.getWindowConfig( id ).extra`.
754 *
755 * Pass a callable — `function ( App $app ): array` — for values
756 * that depend on who is asking (capability flags, the viewer's id,
757 * a filtered option): it runs when the manifest is built, for the
758 * acting user at that moment, rather than once when the definition
759 * file loads. Keep it cheap: the manifest is built on every request
760 * that registers windows, so memoise anything that scans.
761 *
762 * @param array<string,mixed>|callable $config Serialisable values, or a callable returning them.
763 * @return self
764 */
765 public function config( $config ) {
766 if ( is_callable( $config ) ) {
767 $this->config_lazy[] = $config;
768 return $this;
769 }
770 $this->config = array_merge( $this->config, (array) $config );
771 return $this;
772 }
773
774 /**
775 * The config extra: the static values, with every lazy callable's
776 * result merged over them in declaration order.
777 *
778 * @return array<string,mixed>
779 */
780 public function resolved_config() {
781 $config = $this->config;
782 foreach ( $this->config_lazy as $callable ) {
783 $config = array_merge( $config, (array) call_user_func( $callable, $this ) );
784 }
785 return $config;
786 }
787
788 // ----------------------------------------------------------- readers
789
790 /**
791 * App id.
792 *
793 * @return string
794 */
795 public function id() {
796 return $this->id;
797 }
798
799 /**
800 * Declared state defaults.
801 *
802 * @return array<string,mixed>
803 */
804 public function defaults() {
805 return $this->defaults;
806 }
807
808 /**
809 * Whether an action is declared.
810 *
811 * @param string $name Action name.
812 * @return bool
813 */
814 public function has_action( $name ) {
815 return isset( $this->actions[ (string) $name ] );
816 }
817
818 /**
819 * Declared action names.
820 *
821 * @return string[]
822 */
823 public function action_names() {
824 return array_keys( $this->actions );
825 }
826
827 /**
828 * Run the mount hook, if any.
829 *
830 * @param State $state State.
831 * @param Os $os Host handle.
832 * @return void
833 */
834 public function run_mount( State $state, Os $os ) {
835 if ( null !== $this->mount ) {
836 call_user_func( $this->mount, $state, $os );
837 }
838 }
839
840 /**
841 * Run an action.
842 *
843 * @param string $name Action name.
844 * @param State $state State.
845 * @param Os $os Host handle.
846 * @param array<string,mixed> $args Arguments from the trigger.
847 * @param bool $required Throw when the action is undeclared. Default true.
848 * @return void
849 * @throws \RuntimeException When `$required` and the action is undeclared.
850 */
851 public function run_action( $name, State $state, Os $os, array $args = array(), $required = true ) {
852 if ( ! isset( $this->actions[ $name ] ) ) {
853 if ( $required ) {
854 // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Escaped by `Html\esc()`; see the note on the id exception above.
855 throw new \RuntimeException( sprintf( 'Unknown action "%s".', esc( $name ) ) );
856 }
857 return;
858 }
859 call_user_func( $this->actions[ $name ], $state, $os, $args );
860 }
861
862 /**
863 * Whether the app declared a `data()` callback.
864 *
865 * @return bool
866 */
867 public function has_data() {
868 return null !== $this->data;
869 }
870
871 /**
872 * Compute the client data for a state.
873 *
874 * @param State $state State.
875 * @param Os $os Host handle.
876 * @return array<string,mixed>
877 */
878 public function compute_data( State $state, Os $os ) {
879 if ( null === $this->data ) {
880 return array();
881 }
882 $data = call_user_func( $this->data, $state, $os );
883 return is_array( $data ) ? $data : array();
884 }
885
886 /**
887 * Explicit built client script path, or ''.
888 *
889 * @return string
890 */
891 public function client_path() {
892 return $this->client;
893 }
894
895 /**
896 * The `.os.ts` source beside the definition file, or '' when the
897 * app has none. The host maps it to its built bundle.
898 *
899 * @return string
900 */
901 public function client_source() {
902 if ( '' === $this->dir || '' === $this->file_base() ) {
903 return '';
904 }
905 $candidate = $this->dir . '/' . $this->file_base() . '.os.ts';
906 return is_file( $candidate ) ? $candidate : '';
907 }
908
909 /**
910 * The definition file's name without `.os.php` — the base every
911 * by-convention sibling (`<base>.css`, `<base>.os.ts`) is named
912 * after. '' for an app built in code rather than loaded from disk.
913 *
914 * @return string
915 */
916 private function file_base() {
917 if ( '' === $this->file ) {
918 return '';
919 }
920 return (string) preg_replace( '/\.os\.php$/', '', basename( $this->file ) );
921 }
922
923 /**
924 * Whether a view exists: `main`, or a declared tab slug.
925 *
926 * @param string $view View name.
927 * @return bool
928 */
929 public function has_view( $view ) {
930 return 'main' === $view || isset( $this->tabs[ (string) $view ] );
931 }
932
933 /**
934 * Paint a view for a state.
935 *
936 * @param State $state State.
937 * @param Os $os Host handle.
938 * @param string $view `main` (default) or a tab slug.
939 * @return string HTML.
940 */
941 public function render( State $state, Os $os, $view = 'main' ) {
942 $callable = 'main' === $view || '' === (string) $view
943 ? $this->view
944 : ( isset( $this->tabs[ $view ] ) ? $this->tabs[ $view ]['view'] : null );
945 if ( null === $callable ) {
946 return '';
947 }
948 return View::capture( $callable, $state, $os );
949 }
950
951 /**
952 * Declared tabs, without their callables, by position.
953 *
954 * @return array<int,array{value:string,label:string,position:int}>
955 */
956 public function tabs() {
957 $tabs = array_values(
958 array_map(
959 static function ( $tab ) {
960 return array(
961 'value' => $tab['value'],
962 'label' => $tab['label'],
963 'position' => $tab['position'],
964 );
965 },
966 $this->tabs
967 )
968 );
969 usort(
970 $tabs,
971 static function ( $a, $b ) {
972 return $a['position'] <=> $b['position'];
973 }
974 );
975 return $tabs;
976 }
977
978 /**
979 * Lifecycle moments the runtime reports as actions — only when
980 * the app declared a handler of that name.
981 *
982 * `reopen` fires when the window is asked to open while it is
983 * already open — `wp.os.openWindow( id, { params } )` on a live
984 * singleton, a deep link landing on a window that exists. The
985 * shell writes the NEW params onto the window first, so the
986 * handler reads them through `$os->params` and retargets.
987 */
988 const LIFECYCLE_ACTIONS = array( 'resize', 'show', 'hide', 'focus', 'blur', 'reopen' );
989
990 /**
991 * The whole window as data — everything a host needs to register
992 * it and everything the client runtime needs to drive it.
993 *
994 * @return array<string,mixed>
995 */
996 public function manifest() {
997 return array(
998 'id' => $this->id,
999 'title' => $this->title,
1000 'icon' => $this->icon,
1001 'icon_svg' => $this->icon_svg,
1002 'width' => $this->size['width'],
1003 'height' => $this->size['height'],
1004 'min_width' => $this->size['min_width'],
1005 'min_height' => $this->size['min_height'],
1006 'admin' => $this->nav['admin'],
1007 'placement' => $this->nav['placement'],
1008 'nav_kind' => $this->nav['nav_kind'],
1009 'dock_order' => $this->nav['dock_order'],
1010 'placeable' => $this->nav['placeable'],
1011 'autofocus' => $this->nav['autofocus'],
1012 'desktop_icon' => $this->desktop_icon,
1013 'capabilities' => $this->capabilities,
1014 'style' => $this->style_path(),
1015 'state' => $this->defaults,
1016 'actions' => $this->action_names(),
1017 'title_bar_buttons' => $this->title_bar_buttons,
1018 'window_actions' => $this->window_actions,
1019 'appearance' => $this->appearance,
1020 'config' => $this->resolved_config(),
1021 'tabs' => $this->tabs(),
1022 'channels' => $this->channels,
1023 'watch' => $this->watch,
1024 'client' => $this->client_path(),
1025 'client_source' => $this->client_source(),
1026 'file' => $this->file,
1027 'has_data' => $this->has_data(),
1028 'prefetch' => $this->prefetches(),
1029 'lifecycle' => array_values( array_intersect( self::LIFECYCLE_ACTIONS, $this->action_names() ) ),
1030 );
1031 }
1032
1033 /**
1034 * Normalise a title-bar button / window-action declaration.
1035 *
1036 * @param string $id Control id.
1037 * @param array<string,mixed> $args Declaration.
1038 * @param string $default_placement `right` for buttons, '' for menu rows.
1039 * @return array<string,mixed>
1040 * @throws \InvalidArgumentException When the id, label or action is missing.
1041 */
1042 private static function normalise_control( $id, array $args, $default_placement ) {
1043 $id = strtolower( (string) preg_replace( '/[^a-zA-Z0-9_-]/', '', (string) $id ) );
1044 if ( '' === $id ) {
1045 throw new \InvalidArgumentException( 'A title-bar button or window action needs an id.' );
1046 }
1047 if ( empty( $args['label'] ) || empty( $args['action'] ) ) {
1048 // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Escaped by `Html\esc()`; see the note on the id exception above.
1049 throw new \InvalidArgumentException( sprintf( 'Control "%s" needs both a label and an action.', esc( $id ) ) );
1050 }
1051
1052 $confirm = null;
1053 if ( ! empty( $args['confirm'] ) ) {
1054 $confirm = is_array( $args['confirm'] ) ? $args['confirm'] : array( 'message' => (string) $args['confirm'] );
1055 }
1056
1057 $control = array(
1058 'id' => $id,
1059 'label' => (string) $args['label'],
1060 'action' => (string) $args['action'],
1061 'icon' => isset( $args['icon'] ) ? (string) $args['icon'] : 'dashicons-admin-generic',
1062 'order' => isset( $args['order'] ) ? (int) $args['order'] : 100,
1063 'confirm' => $confirm,
1064 'args' => isset( $args['args'] ) && is_array( $args['args'] ) ? $args['args'] : array(),
1065 );
1066 if ( '' !== $default_placement ) {
1067 $control['placement'] = isset( $args['placement'] ) && 'left' === $args['placement'] ? 'left' : $default_placement;
1068 }
1069 return $control;
1070 }
1071 }
1072