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

wordpress.php in OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin 1.1.11, at includes/framework/wordpress.php

827 lines 29.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 WordPress host.
4 *
5 * Everything that couples the host-agnostic framework to WordPress
6 * lives in this one file plus the adapters in `app/wordpress/`:
7 *
8 * - `init` @5 registers the shared client runtime script.
9 * - `init` @10 loads every `.os.php` under the app directories
10 * (`apps/` in this plugin, more via the
11 * `openstation_apps_directories` filter) and fires
12 * `openstation_apps_loaded` so plugins can add
13 * `App` objects built in code.
14 * - `init` @20 turns each allowed app into a native window (and
15 * a desktop icon when it asked for one) through the
16 * same `openstation_register_window()` /
17 * `openstation_register_icon()` any plugin uses.
18 * - REST `POST desktop-mode/v1/apps/<id>/dispatch` moves a
19 * dispatch in and a response out of `App\Runtime`.
20 *
21 * Every app shares ONE script: `assets/js/app-runtime[.min].js`. It
22 * mounts the window, sends actions, morphs the returned markup into
23 * place and performs effects. An app ships no JavaScript of its own.
24 *
25 * @package OpenStation
26 */
27
28 defined( 'ABSPATH' ) || exit;
29
30 require_once __DIR__ . '/autoload.php';
31
32 use OpenStation\App;
33 use OpenStation\App\Os;
34 use OpenStation\App\Registry;
35 use OpenStation\App\Runtime;
36
37 /** Script handle of the shared client runtime. */
38 const OPENSTATION_APP_RUNTIME_HANDLE = 'openstation-app-runtime';
39
40 /**
41 * The app registry — one per request.
42 *
43 * @return Registry
44 */
45 function openstation_apps_registry() {
46 static $registry = null;
47 if ( null === $registry ) {
48 $registry = new Registry();
49 }
50 return $registry;
51 }
52
53 /**
54 * The dispatch runtime bound to {@see openstation_apps_registry()}.
55 *
56 * @return Runtime
57 */
58 function openstation_apps_runtime() {
59 static $runtime = null;
60 if ( null === $runtime ) {
61 $runtime = new Runtime( openstation_apps_registry() );
62 }
63 return $runtime;
64 }
65
66 /**
67 * The `$os` handle for the current request: WordPress adapters all
68 * the way down.
69 *
70 * @return Os
71 */
72 function openstation_apps_os() {
73 static $os = null;
74 if ( null === $os ) {
75 $os = new Os(
76 new App\WordPress\Auth(),
77 new App\WordPress\Settings(),
78 new App\WordPress\Hooks(),
79 new App\WordPress\Cache(),
80 new App\WordPress\Env(),
81 new App\WordPress\Store()
82 );
83 }
84 return $os;
85 }
86
87 /**
88 * Look a registered app up by id.
89 *
90 * @param string $id App id.
91 * @return App|null
92 */
93 function openstation_app( $id ) {
94 return openstation_apps_registry()->get( $id );
95 }
96
97 /**
98 * The tabs of the window in charge of an admin menu, if any.
99 *
100 * A window that declares `App::menu( $slug, … )` answers for that
101 * menu whenever its gate says so — the per-user opt-in that decides
102 * between the native window and the classic screen. The dock builds
103 * that menu's submenu from this list, so the rows the user sees and
104 * the tabs the window shows are one list by construction.
105 *
106 * Returns an empty array when no window declares the menu, when the
107 * gate is off, or when this user may not use the window at all.
108 *
109 * @param string $menu_slug Admin menu slug, e.g. `users.php`.
110 * @return array<int,array<string,string>> Ordered `id` + `label` pairs.
111 */
112 function openstation_app_menu_tabs( $menu_slug ) {
113 $menu_slug = (string) $menu_slug;
114 if ( '' === $menu_slug ) {
115 return array();
116 }
117 foreach ( openstation_apps_registry()->all() as $app ) {
118 if ( $app->menu_slug() !== $menu_slug ) {
119 continue;
120 }
121 if ( ! $app->menu_owns_dock() || ! $app->allows( openstation_apps_os() ) ) {
122 return array();
123 }
124 return $app->menu_tabs();
125 }
126 return array();
127 }
128
129 /**
130 * The whole window as a value: manifest, state after `mount`, body
131 * HTML and effects — what a host calls to render an app somewhere
132 * other than the desktop (a REST consumer, a CLI, a test).
133 *
134 * @param string $id App id.
135 * @param array<string,mixed> $state Partial state; declared defaults fill the rest.
136 * @return array<string,mixed> See {@see Runtime::describe()}.
137 */
138 function openstation_app_render( $id, array $state = array() ) {
139 return openstation_apps_runtime()->describe( $id, $state, openstation_apps_os() );
140 }
141
142 /**
143 * Directories scanned for `.os.php` files.
144 *
145 * @return string[] Absolute paths.
146 */
147 function openstation_apps_directories() {
148 $dirs = array( rtrim( OPENSTATION_DIR, '/\\' ) . '/apps' );
149
150 /**
151 * Filter the directories the App Framework loads `.os.php`
152 * files from. Append your plugin's folder to ship apps as files.
153 *
154 * @param string[] $dirs Absolute directory paths.
155 */
156 return array_values( array_unique( array_filter( array_map( 'strval', (array) apply_filters( 'openstation_apps_directories', $dirs ) ) ) ) );
157 }
158
159 /**
160 * Whether this request is an app dispatch (`POST …/apps/<id>/dispatch`).
161 *
162 * Sniffed from the request URI because callers need the answer DURING
163 * `init` — before the REST server has parsed the route. Both REST URL
164 * shapes are covered (`/wp-json/…` and `?rest_route=…`).
165 *
166 * @return bool
167 */
168 function openstation_apps_is_dispatch_request() {
169 $uri = isset( $_SERVER['REQUEST_URI'] ) ? (string) wp_unslash( $_SERVER['REQUEST_URI'] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Substring probe only; never stored or echoed.
170 return false !== strpos( $uri, 'desktop-mode/v1/apps/' );
171 }
172
173 /**
174 * An app dispatch renders admin UI, so request-scoped facts that are
175 * normally collected on admin requests only must be collected here
176 * too. First case: the CPT/taxonomy → registering-plugin map
177 * (`openstation_track_type_registrants` defaults to `is_admin()`),
178 * which My WordPress reads to fold plugin CPTs into plugin folders —
179 * without this, every CPT rendered loose in a dispatch while the same
180 * site grouped them on an admin page load.
181 *
182 * @param bool $track Whether to track.
183 * @return bool
184 */
185 function openstation_apps_track_registrants( $track ) {
186 return $track || openstation_apps_is_dispatch_request();
187 }
188 add_filter( 'openstation_track_type_registrants', 'openstation_apps_track_registrants' );
189
190 /**
191 * Load every app file, then let plugins add apps built in code.
192 */
193 function openstation_apps_load() {
194 $registry = openstation_apps_registry();
195 foreach ( openstation_apps_directories() as $dir ) {
196 $registry->load_dir( $dir );
197 }
198
199 /**
200 * Fires once every `.os.php` has been loaded. Add an `App`
201 * defined in code with `$registry->add( App::define( … ) )`.
202 *
203 * @param Registry $registry The app registry.
204 */
205 do_action( 'openstation_apps_loaded', $registry );
206 }
207 add_action( 'init', 'openstation_apps_load', 10 );
208
209 /**
210 * Register the shared runtime script. Never enqueued eagerly — the
211 * native-window sync loads it the first time any app window opens.
212 */
213 function openstation_apps_register_assets() {
214 $suffix = openstation_asset_suffix();
215 $js_path = OPENSTATION_DIR . 'assets/js/app-runtime' . $suffix . '.js';
216 wp_register_script(
217 OPENSTATION_APP_RUNTIME_HANDLE,
218 OPENSTATION_URL . 'assets/js/app-runtime' . $suffix . '.js',
219 array( 'wp-i18n' ),
220 file_exists( $js_path ) ? (string) filemtime( $js_path ) : OPENSTATION_VERSION,
221 true
222 );
223 wp_set_script_translations( OPENSTATION_APP_RUNTIME_HANDLE, 'desktop-mode', OPENSTATION_DIR . 'languages' );
224
225 // The root every app mounts into, and its first-paint spinner.
226 $css_path = OPENSTATION_DIR . 'assets/css/app-runtime.css';
227 wp_register_style(
228 OPENSTATION_APP_RUNTIME_HANDLE,
229 OPENSTATION_URL . 'assets/css/app-runtime.css',
230 array( 'os-variables' ),
231 file_exists( $css_path ) ? (string) filemtime( $css_path ) : OPENSTATION_VERSION
232 );
233 }
234 add_action( 'init', 'openstation_apps_register_assets', 5 );
235
236 /**
237 * Map an absolute path inside the install to its URL, or '' when the
238 * file lives outside anything WordPress serves.
239 *
240 * @param string $path Absolute file path.
241 * @return string URL or ''.
242 */
243 function openstation_apps_path_to_url( $path ) {
244 $path = wp_normalize_path( (string) $path );
245 $content = rtrim( wp_normalize_path( WP_CONTENT_DIR ), '/' );
246 $root = rtrim( wp_normalize_path( ABSPATH ), '/' );
247 if ( '' !== $content && 0 === strpos( $path, $content . '/' ) ) {
248 return content_url( substr( $path, strlen( $content ) ) );
249 }
250 if ( '' !== $root && 0 === strpos( $path, $root . '/' ) ) {
251 return site_url( substr( $path, strlen( $root ) ) );
252 }
253 return '';
254 }
255
256 /**
257 * The style handle an app's stylesheet registers under.
258 *
259 * @param string $id App id.
260 * @return string
261 */
262 function openstation_apps_style_handle( $id ) {
263 return 'openstation-app-' . (string) $id;
264 }
265
266 /**
267 * The built client-view bundle for an app, or '' when it has none.
268 *
269 * An explicit `App::client( $path )` wins. Otherwise an app inside
270 * this plugin's own `apps/` is looked up by convention: `npm run
271 * build:apps` compiles `apps/<dir>/<file>.os.ts` into
272 * `assets/js/apps/<file>[.min].js`, and that bundle is what ships.
273 *
274 * @param array<string,mixed> $manifest Filtered manifest.
275 * @return string Absolute path of the built script, or ''.
276 */
277 function openstation_apps_client_bundle( array $manifest ) {
278 if ( ! empty( $manifest['client'] ) ) {
279 return is_file( $manifest['client'] ) ? (string) $manifest['client'] : '';
280 }
281 $base = openstation_apps_client_base( $manifest );
282 if ( '' === $base ) {
283 return '';
284 }
285 $built = OPENSTATION_DIR . 'assets/js/apps/' . $base . openstation_asset_suffix() . '.js';
286 return is_file( $built ) ? $built : '';
287 }
288
289 /**
290 * The name an app's by-convention client bundle is built under, or ''
291 * for an app that has no such bundle.
292 *
293 * The bundle is named after the definition file: `<file>.os.php` and
294 * `<file>.os.ts` share a base, and the build writes
295 * `assets/js/apps/<file>[.min].js`. So the name is read off the
296 * `.os.php`, the one file a release install is guaranteed to have.
297 * The `.os.ts` is source: `.gitattributes` export-ignores every `.ts`
298 * under `apps/`, and `bin/package.sh` splices the built bundle into
299 * the zip in its place. Keying the lookup on the source's presence is
300 * how every client-view window (Preferences, WP Explorer, Code Blue,
301 * the Recycle Bin) came to open empty on a packaged site: the host
302 * shipped `client: false`, and the runtime asked the server for a
303 * view those apps do not have.
304 *
305 * Only apps under this plugin's `apps/` qualify. That is the directory
306 * the build walks, and an app another plugin ships through
307 * `openstation_apps_directories` declares its bundle with
308 * `App::client()`: a shared file name must never hand it ours.
309 *
310 * @param array<string,mixed> $manifest Filtered manifest.
311 * @return string Bundle base name (`code-blue`), or ''.
312 */
313 function openstation_apps_client_base( array $manifest ) {
314 $file = '';
315 foreach ( array( 'client_source', 'file' ) as $key ) {
316 if ( ! empty( $manifest[ $key ] ) && is_string( $manifest[ $key ] ) ) {
317 $file = $manifest[ $key ];
318 break;
319 }
320 }
321 if ( '' === $file ) {
322 return '';
323 }
324
325 $apps = realpath( OPENSTATION_DIR . 'apps' );
326 $dir = realpath( dirname( $file ) );
327 if ( false === $apps || false === $dir ) {
328 return '';
329 }
330 $apps = trailingslashit( wp_normalize_path( $apps ) );
331 $dir = trailingslashit( wp_normalize_path( $dir ) );
332 if ( 0 !== strpos( $dir, $apps ) ) {
333 return '';
334 }
335
336 return (string) preg_replace( '/\.os\.(php|ts)$/', '', basename( $file ) );
337 }
338
339 /**
340 * The config blob the client runtime reads through
341 * `wp.os.getWindowConfig( id )`.
342 *
343 * @param array<string,mixed> $manifest Filtered manifest.
344 * @param string $bundle Resolved client bundle path, from
345 * {@see openstation_apps_client_bundle()}.
346 * @param App|null $app The app, for a prefetched `data()`
347 * (`App::prefetch()`); null ships none.
348 * @return array<string,mixed>
349 */
350 function openstation_apps_client_config( array $manifest, $bundle = '', $app = null ) {
351 $prefetched = array();
352 if ( $app instanceof App && ! empty( $manifest['prefetch'] ) && '' !== $bundle ) {
353 // The declared state and the request's host handle — the same
354 // inputs `mount` gets, minus the open-time params a deep link
355 // carries (the runtime waits for `mount` in that case).
356 $prefetched['data'] = $app->compute_data( new App\State( $app->defaults() ), openstation_apps_os() );
357 }
358 return $prefetched + array(
359 'client' => '' !== $bundle,
360 'osApp' => true,
361 'id' => $manifest['id'],
362 'title' => $manifest['title'],
363 'endpoint' => esc_url_raw( rest_url( 'desktop-mode/v1/apps/' . $manifest['id'] . '/dispatch' ) ),
364 'restRoot' => esc_url_raw( rest_url() ),
365 'restNonce' => wp_create_nonce( 'wp_rest' ),
366 'state' => $manifest['state'],
367 'titleBarButtons' => $manifest['title_bar_buttons'],
368 'windowActions' => $manifest['window_actions'],
369 'appearance' => (object) $manifest['appearance'],
370 'extra' => (object) $manifest['config'],
371 'actions' => array_values( (array) $manifest['actions'] ),
372 'lifecycle' => array_values( (array) $manifest['lifecycle'] ),
373 'channels' => (object) $manifest['channels'],
374 'watch' => array_values( (array) $manifest['watch'] ),
375 'tabs' => array_values( (array) $manifest['tabs'] ),
376 );
377 }
378
379 /**
380 * The wp-admin pages a window answers for while it is the one in
381 * charge of its menu, as `array( 'id' => <tab>, 'page' => <slug> )`.
382 *
383 * The shell routes those URLs to the window wherever they are
384 * clicked, not only from the dock: a link inside another window, the
385 * admin bar's "+ New", a workspace's launch list. Empty when no menu
386 * is declared or the opt-in is off, and re-sent on the menu refresh
387 * that flipping the opt-in spends.
388 *
389 * @param App $app The app.
390 * @return array<int,array<string,string>>
391 */
392 function openstation_apps_menu_pages( App $app ) {
393 if ( ! $app->menu_owns_dock() ) {
394 return array();
395 }
396 $pages = array();
397 foreach ( $app->menu_tabs() as $tab ) {
398 if ( '' !== $tab['page'] ) {
399 $pages[] = array(
400 'id' => $tab['id'],
401 'page' => $tab['page'],
402 );
403 }
404 }
405 return $pages;
406 }
407
408 /**
409 * The static template the shell clones on open: a root the runtime
410 * mounts into, showing a spinner until the first render lands. One
411 * per view — the main body and each tab panel get their own.
412 *
413 * @param string $id App id.
414 * @param string $view `main` or a tab slug.
415 */
416 function openstation_apps_render_template( $id, $view = 'main' ) {
417 printf(
418 '<div class="os-app" data-os-app="%s" data-os-view="%s"><div class="os-app__loading"><os-spinner></os-spinner></div></div>',
419 esc_attr( $id ),
420 esc_attr( $view )
421 );
422 }
423
424 /**
425 * Turn every allowed app into a native window (+ desktop icon).
426 */
427 function openstation_apps_register_windows() {
428 $os = openstation_apps_os();
429
430 foreach ( openstation_apps_registry()->all() as $app ) {
431 if ( ! $app->allows( $os ) ) {
432 continue;
433 }
434
435 /**
436 * Filter an app's manifest before it is registered with the
437 * shell — size, icon, title-bar buttons, chrome, anything.
438 *
439 * @param array<string,mixed> $manifest See `App::manifest()`.
440 * @param string $id App id.
441 * @param App $app The app.
442 */
443 $manifest = (array) apply_filters( 'openstation_app_manifest', $app->manifest(), $app->id(), $app );
444 $id = $app->id();
445
446 $styles = array();
447 if ( ! empty( $manifest['style'] ) && is_file( $manifest['style'] ) ) {
448 $url = openstation_apps_path_to_url( $manifest['style'] );
449 if ( '' !== $url ) {
450 wp_register_style(
451 openstation_apps_style_handle( $id ),
452 $url,
453 array( 'os-variables' ),
454 (string) filemtime( $manifest['style'] )
455 );
456 $styles[] = openstation_apps_style_handle( $id );
457 }
458 }
459
460 // The `.os.ts` half rides as a companion script: loaded with the
461 // window, before the runtime mounts it, never at boot.
462 $scripts = array();
463 $bundle = openstation_apps_client_bundle( $manifest );
464 if ( '' !== $bundle ) {
465 $url = openstation_apps_path_to_url( $bundle );
466 if ( '' !== $url ) {
467 $handle = 'openstation-app-' . $id . '-client';
468 wp_register_script( $handle, $url, array( 'wp-i18n' ), (string) filemtime( $bundle ), true );
469 wp_set_script_translations( $handle, 'desktop-mode', OPENSTATION_DIR . 'languages' );
470 $scripts[] = $handle;
471 }
472 }
473
474 $window_args = array(
475 'title' => $manifest['title'],
476 'icon' => $manifest['icon'],
477 'template' => static function () use ( $id ) {
478 openstation_apps_render_template( $id );
479 },
480 'script' => OPENSTATION_APP_RUNTIME_HANDLE,
481 'scripts' => $scripts,
482 // Both sheets travel as first-open companions — nothing
483 // an app window paints is needed on a page that never
484 // opens it (see tests/phpunit/tests/deferredWindowStyles.php).
485 'styles' => array_merge( array( OPENSTATION_APP_RUNTIME_HANDLE ), $styles ),
486 'width' => $manifest['width'],
487 'height' => $manifest['height'],
488 'min_width' => $manifest['min_width'],
489 'min_height' => $manifest['min_height'],
490 'placement' => $manifest['placement'],
491 'nav_kind' => $manifest['nav_kind'],
492 'dock_order' => $manifest['dock_order'],
493 'placeable' => $manifest['placeable'],
494 'autofocus' => $manifest['autofocus'],
495 'admin' => isset( $manifest['admin'] ) ? $manifest['admin'] : 'site',
496 'menu_pages' => openstation_apps_menu_pages( $app ),
497 'config' => openstation_apps_client_config( $manifest, $bundle, $app ),
498 );
499
500 /**
501 * Filter the window-registration args an app's manifest
502 * produced, just before `openstation_register_window()` runs.
503 *
504 * The seam a companion plugin uses to ride an app window it
505 * doesn't own — appending registered `scripts` / `styles`
506 * handles (an integration bundle that decorates the app
507 * through its JS hook seams, loaded on first open and never
508 * sooner) — or to tune any other registration arg.
509 *
510 * **Status: Experimental**
511 *
512 * @param array<string,mixed> $window_args `openstation_register_window()` args.
513 * @param string $id App id.
514 * @param App $app The app.
515 */
516 $window_args = (array) apply_filters( 'openstation_app_window_args', $window_args, $id, $app );
517
518 $registered = openstation_register_window( $id, $window_args );
519 if ( is_wp_error( $registered ) ) {
520 // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
521 error_log( sprintf( '[openstation] App "%s" failed to register: %s', $id, $registered->get_error_message() ) );
522 continue;
523 }
524
525 foreach ( (array) $manifest['tabs'] as $tab ) {
526 $tab_value = (string) $tab['value'];
527 openstation_register_window_tab(
528 $id,
529 array(
530 'value' => $tab_value,
531 'label' => (string) $tab['label'],
532 'position' => (int) $tab['position'],
533 'template' => static function () use ( $id, $tab_value ) {
534 openstation_apps_render_template( $id, $tab_value );
535 },
536 )
537 );
538 }
539
540 if ( is_array( $manifest['desktop_icon'] ) ) {
541 $icon = $manifest['desktop_icon'];
542 openstation_register_icon(
543 $id,
544 array(
545 'title' => isset( $icon['title'] ) ? (string) $icon['title'] : $manifest['title'],
546 'icon' => isset( $icon['icon'] ) ? (string) $icon['icon'] : $manifest['icon'],
547 'icon_svg' => isset( $icon['icon'] ) ? '' : (string) $manifest['icon_svg'],
548 'window' => $id,
549 'position' => isset( $icon['position'] ) ? (int) $icon['position'] : 100,
550 'pinned' => ! empty( $icon['pinned'] ),
551 )
552 );
553 }
554
555 /**
556 * Fires after an app has been registered as a native window.
557 *
558 * @param string $id App id.
559 * @param array<string,mixed> $manifest The manifest as registered.
560 */
561 do_action( 'openstation_app_registered', $id, $manifest );
562 }
563 }
564 add_action( 'init', 'openstation_apps_register_windows', 20 );
565
566 /**
567 * Admit the runtime's attributes on every tag kses sees in a
568 * native-window template, so a plugin that renders an app-style
569 * body straight into a `template` callback keeps its triggers.
570 * (`wp_kses` only wildcards `data-*`, so `os-arg-<name>` attributes
571 * survive kses solely on the dispatch path, which is not kses'd —
572 * where every app body normally comes from.)
573 *
574 * @param array $allowed kses allowlist.
575 * @return array
576 */
577 function openstation_apps_allowed_html( $allowed ) {
578 $runtime_attrs = array(
579 'os-action',
580 'os-bind',
581 'os-on',
582 'os-debounce',
583 'os-confirm',
584 'os-confirm-title',
585 'os-confirm-label',
586 'os-confirm-danger',
587 'os-poll',
588 'os-key',
589 'os-preserve',
590 );
591 foreach ( (array) $allowed as $tag => $attrs ) {
592 if ( ! is_array( $attrs ) ) {
593 continue;
594 }
595 foreach ( $runtime_attrs as $attr ) {
596 $allowed[ $tag ][ $attr ] = true;
597 }
598 }
599 return $allowed;
600 }
601 add_filter( 'openstation_native_window_allowed_html', 'openstation_apps_allowed_html' );
602
603 // ------------------------------------------------------------------ REST
604
605 /**
606 * Run a REST request in-process and hand back what the browser would
607 * have received: the same controller, the same permission checks,
608 * every `register_rest_field()` a plugin added, `_fields` applied and
609 * `_embed` expanded — minus the HTTP round trip.
610 *
611 * This is how a list app's `data()` reads the collections WordPress
612 * already knows how to serve (`wp/v2/posts`, `wp/v2/users`,
613 * `wp/v2/comments`, `wp/v2/plugins`) instead of re-implementing a
614 * query per window: the filters plugin authors already hook
615 * (`rest_post_query`, the REST fields, the `_fields` projections the
616 * `openstation_*_window_query_args` filters shape) keep working
617 * because the request IS a REST request. `rest_do_request()` alone
618 * skips `rest_post_dispatch`, which is where `_fields` is applied,
619 * and never embeds; this helper does both, the way Core's own
620 * `embed_links()` replays them for a sub-request.
621 *
622 * Two things to know: it needs the REST server (`rest_get_server()`
623 * boots it on demand, so call it from a `data()` or an action — a
624 * `prefetch()`ed `data()` would boot it on every admin page load);
625 * and because `_fields` runs before the embed, a projected collection
626 * keeps its `_embedded` only when `_fields` names `_links,_embedded`.
627 *
628 * @param string $method `GET` | `POST` | `DELETE` | ….
629 * @param string $route Route below the REST root (`wp/v2/posts`).
630 * @param array<string,mixed> $query Query params (`per_page`, `_fields`, `_embed`, …).
631 * @param array<string,mixed> $body Body params for a write.
632 * @return array{ok:bool,status:int,data:mixed,total:int,pages:int,error:string,code:string}
633 */
634 function openstation_app_rest( $method, $route, array $query = array(), array $body = array() ) {
635 $request = new WP_REST_Request( strtoupper( (string) $method ), '/' . ltrim( (string) $route, '/' ) );
636 if ( array() !== $query ) {
637 $request->set_query_params( $query );
638 }
639 if ( array() !== $body ) {
640 $request->set_body_params( $body );
641 $request->set_header( 'Content-Type', 'application/json' );
642 $request->set_body( (string) wp_json_encode( $body ) );
643 }
644
645 $server = rest_get_server();
646 $response = rest_do_request( $request );
647 /** This filter is documented in wp-includes/rest-api/class-wp-rest-server.php */
648 $response = apply_filters( 'rest_post_dispatch', rest_ensure_response( $response ), $server, $request ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Core's own post-dispatch pass (`_fields`), replayed for an in-process request.
649
650 if ( $response->is_error() ) {
651 $error = $response->as_error();
652 return array(
653 'ok' => false,
654 'status' => (int) $response->get_status(),
655 'data' => null,
656 'total' => 0,
657 'pages' => 0,
658 'error' => $error ? (string) $error->get_error_message() : '',
659 'code' => $error ? (string) $error->get_error_code() : '',
660 );
661 }
662
663 $embed = isset( $query['_embed'] ) ? rest_parse_embed_param( $query['_embed'] ) : false;
664 $data = $server->response_to_data( $response, $embed );
665 $headers = $response->get_headers();
666 return array(
667 'ok' => true,
668 'status' => (int) $response->get_status(),
669 'data' => $data,
670 // A collection reports its total in the header; a single
671 // resource is one thing, however many fields it has.
672 'total' => isset( $headers['X-WP-Total'] ) ? (int) $headers['X-WP-Total'] : ( wp_is_numeric_array( $data ) ? count( $data ) : 1 ),
673 'pages' => isset( $headers['X-WP-TotalPages'] ) ? (int) $headers['X-WP-TotalPages'] : 1,
674 'error' => '',
675 'code' => '',
676 );
677 }
678
679 /**
680 * A REST collection as the paged-list envelope a client view renders
681 * from — {@see \OpenStation\App\Os::page()} — plus `error` and `code`
682 * keys ('' on success) so a list can paint "could not load" instead of
683 * an empty table when the collection refused the request, and tell a
684 * page past the end (`rest_post_invalid_page_number` and its siblings —
685 * {@see openstation_app_rest_page_is_out_of_range()}) from a refusal.
686 *
687 * `page` and `per_page` are read from `$query` and default to 1 / 20;
688 * the defaults are sent with the request too, so the page the envelope
689 * describes is the page the controller served.
690 *
691 * @param string $route Route below the REST root.
692 * @param array<string,mixed> $query Query params.
693 * @return array{items:array<int,mixed>,total:int,pages:int,page:int,perPage:int,error:string,code:string}
694 */
695 function openstation_app_rest_page( $route, array $query = array() ) {
696 $page = isset( $query['page'] ) ? max( 1, (int) $query['page'] ) : 1;
697 $per_page = isset( $query['per_page'] ) ? max( 1, (int) $query['per_page'] ) : 20;
698 $query['page'] = $page;
699 $query['per_page'] = $per_page;
700 $result = openstation_app_rest( 'GET', $route, $query );
701 $items = $result['ok'] && is_array( $result['data'] ) ? array_values( $result['data'] ) : array();
702 $envelope = Os::page( $items, $result['ok'] ? $result['total'] : 0, $page, $per_page );
703 if ( $result['ok'] ) {
704 $envelope['pages'] = max( 1, (int) $result['pages'] );
705 }
706 $envelope['error'] = $result['ok'] ? '' : (string) $result['error'];
707 $envelope['code'] = $result['ok'] ? '' : (string) $result['code'];
708 return $envelope;
709 }
710
711 /**
712 * Whether a page envelope came back empty because the page is past
713 * the end — Core refuses one outright (`rest_post_invalid_page_number`,
714 * `rest_user_invalid_page_number`, `rest_comment_invalid_page_number`)
715 * — as opposed to a refusal a list must surface. The typical cause is
716 * the user on page 7 raising the page size; the typical answer is to
717 * land on page 1 silently.
718 *
719 * @param array<string,mixed> $envelope From {@see openstation_app_rest_page()}.
720 * @return bool
721 */
722 function openstation_app_rest_page_is_out_of_range( array $envelope ) {
723 if ( array() !== $envelope['items'] ) {
724 return false;
725 }
726 $code = isset( $envelope['code'] ) ? (string) $envelope['code'] : '';
727 return '' === $code || false !== strpos( $code, 'invalid_page_number' );
728 }
729
730 /**
731 * Register the dispatch route.
732 */
733 function openstation_apps_register_routes() {
734 register_rest_route(
735 'desktop-mode/v1',
736 '/apps/(?P<app>[a-z0-9][a-z0-9_-]*)/dispatch',
737 array(
738 'methods' => WP_REST_Server::CREATABLE,
739 'callback' => 'openstation_apps_rest_dispatch',
740 'permission_callback' => 'openstation_apps_rest_permission',
741 'args' => array(
742 'action' => array(
743 'description' => 'Action name, or `mount` for the first render.',
744 'type' => 'string',
745 'required' => true,
746 ),
747 ),
748 )
749 );
750 }
751 add_action( 'rest_api_init', 'openstation_apps_register_routes' );
752
753 /**
754 * Permission: the app must exist and admit the acting user.
755 *
756 * @param WP_REST_Request $request Request.
757 * @return true|WP_Error
758 */
759 function openstation_apps_rest_permission( WP_REST_Request $request ) {
760 if ( ! is_user_logged_in() ) {
761 return new WP_Error(
762 'openstation_app_unauthorized',
763 __( 'You must be logged in to use this window.', 'desktop-mode' ),
764 array( 'status' => rest_authorization_required_code() )
765 );
766 }
767 $app = openstation_app( (string) $request['app'] );
768 if ( ! $app ) {
769 return new WP_Error( 'openstation_app_not_found', __( 'Unknown app.', 'desktop-mode' ), array( 'status' => 404 ) );
770 }
771 if ( ! $app->allows( openstation_apps_os() ) ) {
772 return new WP_Error( 'openstation_app_forbidden', __( 'You are not allowed to use this window.', 'desktop-mode' ), array( 'status' => 403 ) );
773 }
774 return true;
775 }
776
777 /**
778 * Translate a runtime failure into a `WP_Error`.
779 *
780 * @param array<string,mixed> $failure `error`, `message`, `status`.
781 * @return WP_Error
782 */
783 function openstation_apps_rest_error( array $failure ) {
784 $messages = array(
785 'not_found' => __( 'Unknown app.', 'desktop-mode' ),
786 'forbidden' => __( 'You are not allowed to use this window.', 'desktop-mode' ),
787 'unknown_action' => __( 'This window does not know that action.', 'desktop-mode' ),
788 'unknown_view' => __( 'This window does not have that tab.', 'desktop-mode' ),
789 );
790 $code = isset( $failure['error'] ) ? (string) $failure['error'] : 'failed';
791 $message = isset( $messages[ $code ] ) ? $messages[ $code ] : (string) $failure['message'];
792 return new WP_Error(
793 'openstation_app_' . $code,
794 $message,
795 array( 'status' => isset( $failure['status'] ) ? (int) $failure['status'] : 500 )
796 );
797 }
798
799 /**
800 * `POST /apps/<id>/dispatch`.
801 *
802 * @param WP_REST_Request $request Request.
803 * @return WP_REST_Response|WP_Error
804 */
805 function openstation_apps_rest_dispatch( WP_REST_Request $request ) {
806 $body = $request->get_json_params();
807 $body = is_array( $body ) ? $body : array();
808
809 $result = openstation_apps_runtime()->dispatch(
810 (string) $request['app'],
811 array(
812 'action' => (string) $request->get_param( 'action' ),
813 'view' => isset( $body['view'] ) ? (string) $body['view'] : 'main',
814 'state' => isset( $body['state'] ) && is_array( $body['state'] ) ? $body['state'] : array(),
815 'args' => isset( $body['args'] ) && is_array( $body['args'] ) ? $body['args'] : array(),
816 'params' => isset( $body['params'] ) && is_array( $body['params'] ) ? $body['params'] : array(),
817 'client' => isset( $body['client'] ) && is_array( $body['client'] ) ? $body['client'] : array(),
818 ),
819 openstation_apps_os()
820 );
821
822 if ( empty( $result['ok'] ) ) {
823 return openstation_apps_rest_error( $result );
824 }
825 return rest_ensure_response( $result );
826 }
827