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 / wordpress.php

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

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