PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 1.1.7
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v1.1.7
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 / framework / class-app.php

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

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