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

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

765 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 'admin' => isset( $manifest['admin'] ) ? $manifest['admin'] : 'site',
435 'config' => openstation_apps_client_config( $manifest, $bundle, $app ),
436 );
437
438 /**
439 * Filter the window-registration args an app's manifest
440 * produced, just before `openstation_register_window()` runs.
441 *
442 * The seam a companion plugin uses to ride an app window it
443 * doesn't own — appending registered `scripts` / `styles`
444 * handles (an integration bundle that decorates the app
445 * through its JS hook seams, loaded on first open and never
446 * sooner) — or to tune any other registration arg.
447 *
448 * **Status: Experimental**
449 *
450 * @param array<string,mixed> $window_args `openstation_register_window()` args.
451 * @param string $id App id.
452 * @param App $app The app.
453 */
454 $window_args = (array) apply_filters( 'openstation_app_window_args', $window_args, $id, $app );
455
456 $registered = openstation_register_window( $id, $window_args );
457 if ( is_wp_error( $registered ) ) {
458 // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
459 error_log( sprintf( '[openstation] App "%s" failed to register: %s', $id, $registered->get_error_message() ) );
460 continue;
461 }
462
463 foreach ( (array) $manifest['tabs'] as $tab ) {
464 $tab_value = (string) $tab['value'];
465 openstation_register_window_tab(
466 $id,
467 array(
468 'value' => $tab_value,
469 'label' => (string) $tab['label'],
470 'position' => (int) $tab['position'],
471 'template' => static function () use ( $id, $tab_value ) {
472 openstation_apps_render_template( $id, $tab_value );
473 },
474 )
475 );
476 }
477
478 if ( is_array( $manifest['desktop_icon'] ) ) {
479 $icon = $manifest['desktop_icon'];
480 openstation_register_icon(
481 $id,
482 array(
483 'title' => isset( $icon['title'] ) ? (string) $icon['title'] : $manifest['title'],
484 'icon' => isset( $icon['icon'] ) ? (string) $icon['icon'] : $manifest['icon'],
485 'icon_svg' => isset( $icon['icon'] ) ? '' : (string) $manifest['icon_svg'],
486 'window' => $id,
487 'position' => isset( $icon['position'] ) ? (int) $icon['position'] : 100,
488 'pinned' => ! empty( $icon['pinned'] ),
489 )
490 );
491 }
492
493 /**
494 * Fires after an app has been registered as a native window.
495 *
496 * @param string $id App id.
497 * @param array<string,mixed> $manifest The manifest as registered.
498 */
499 do_action( 'openstation_app_registered', $id, $manifest );
500 }
501 }
502 add_action( 'init', 'openstation_apps_register_windows', 20 );
503
504 /**
505 * Admit the runtime's attributes on every tag kses sees in a
506 * native-window template, so a plugin that renders an app-style
507 * body straight into a `template` callback keeps its triggers.
508 * (`wp_kses` only wildcards `data-*`, so `os-arg-<name>` attributes
509 * survive kses solely on the dispatch path, which is not kses'd —
510 * where every app body normally comes from.)
511 *
512 * @param array $allowed kses allowlist.
513 * @return array
514 */
515 function openstation_apps_allowed_html( $allowed ) {
516 $runtime_attrs = array(
517 'os-action',
518 'os-bind',
519 'os-on',
520 'os-debounce',
521 'os-confirm',
522 'os-confirm-title',
523 'os-confirm-label',
524 'os-confirm-danger',
525 'os-poll',
526 'os-key',
527 'os-preserve',
528 );
529 foreach ( (array) $allowed as $tag => $attrs ) {
530 if ( ! is_array( $attrs ) ) {
531 continue;
532 }
533 foreach ( $runtime_attrs as $attr ) {
534 $allowed[ $tag ][ $attr ] = true;
535 }
536 }
537 return $allowed;
538 }
539 add_filter( 'openstation_native_window_allowed_html', 'openstation_apps_allowed_html' );
540
541 // ------------------------------------------------------------------ REST
542
543 /**
544 * Run a REST request in-process and hand back what the browser would
545 * have received: the same controller, the same permission checks,
546 * every `register_rest_field()` a plugin added, `_fields` applied and
547 * `_embed` expanded — minus the HTTP round trip.
548 *
549 * This is how a list app's `data()` reads the collections WordPress
550 * already knows how to serve (`wp/v2/posts`, `wp/v2/users`,
551 * `wp/v2/comments`, `wp/v2/plugins`) instead of re-implementing a
552 * query per window: the filters plugin authors already hook
553 * (`rest_post_query`, the REST fields, the `_fields` projections the
554 * `openstation_*_window_query_args` filters shape) keep working
555 * because the request IS a REST request. `rest_do_request()` alone
556 * skips `rest_post_dispatch`, which is where `_fields` is applied,
557 * and never embeds; this helper does both, the way Core's own
558 * `embed_links()` replays them for a sub-request.
559 *
560 * Two things to know: it needs the REST server (`rest_get_server()`
561 * boots it on demand, so call it from a `data()` or an action — a
562 * `prefetch()`ed `data()` would boot it on every admin page load);
563 * and because `_fields` runs before the embed, a projected collection
564 * keeps its `_embedded` only when `_fields` names `_links,_embedded`.
565 *
566 * @param string $method `GET` | `POST` | `DELETE` | ….
567 * @param string $route Route below the REST root (`wp/v2/posts`).
568 * @param array<string,mixed> $query Query params (`per_page`, `_fields`, `_embed`, …).
569 * @param array<string,mixed> $body Body params for a write.
570 * @return array{ok:bool,status:int,data:mixed,total:int,pages:int,error:string,code:string}
571 */
572 function openstation_app_rest( $method, $route, array $query = array(), array $body = array() ) {
573 $request = new WP_REST_Request( strtoupper( (string) $method ), '/' . ltrim( (string) $route, '/' ) );
574 if ( array() !== $query ) {
575 $request->set_query_params( $query );
576 }
577 if ( array() !== $body ) {
578 $request->set_body_params( $body );
579 $request->set_header( 'Content-Type', 'application/json' );
580 $request->set_body( (string) wp_json_encode( $body ) );
581 }
582
583 $server = rest_get_server();
584 $response = rest_do_request( $request );
585 /** This filter is documented in wp-includes/rest-api/class-wp-rest-server.php */
586 $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.
587
588 if ( $response->is_error() ) {
589 $error = $response->as_error();
590 return array(
591 'ok' => false,
592 'status' => (int) $response->get_status(),
593 'data' => null,
594 'total' => 0,
595 'pages' => 0,
596 'error' => $error ? (string) $error->get_error_message() : '',
597 'code' => $error ? (string) $error->get_error_code() : '',
598 );
599 }
600
601 $embed = isset( $query['_embed'] ) ? rest_parse_embed_param( $query['_embed'] ) : false;
602 $data = $server->response_to_data( $response, $embed );
603 $headers = $response->get_headers();
604 return array(
605 'ok' => true,
606 'status' => (int) $response->get_status(),
607 'data' => $data,
608 // A collection reports its total in the header; a single
609 // resource is one thing, however many fields it has.
610 'total' => isset( $headers['X-WP-Total'] ) ? (int) $headers['X-WP-Total'] : ( wp_is_numeric_array( $data ) ? count( $data ) : 1 ),
611 'pages' => isset( $headers['X-WP-TotalPages'] ) ? (int) $headers['X-WP-TotalPages'] : 1,
612 'error' => '',
613 'code' => '',
614 );
615 }
616
617 /**
618 * A REST collection as the paged-list envelope a client view renders
619 * from — {@see \OpenStation\App\Os::page()} — plus `error` and `code`
620 * keys ('' on success) so a list can paint "could not load" instead of
621 * an empty table when the collection refused the request, and tell a
622 * page past the end (`rest_post_invalid_page_number` and its siblings —
623 * {@see openstation_app_rest_page_is_out_of_range()}) from a refusal.
624 *
625 * `page` and `per_page` are read from `$query` and default to 1 / 20;
626 * the defaults are sent with the request too, so the page the envelope
627 * describes is the page the controller served.
628 *
629 * @param string $route Route below the REST root.
630 * @param array<string,mixed> $query Query params.
631 * @return array{items:array<int,mixed>,total:int,pages:int,page:int,perPage:int,error:string,code:string}
632 */
633 function openstation_app_rest_page( $route, array $query = array() ) {
634 $page = isset( $query['page'] ) ? max( 1, (int) $query['page'] ) : 1;
635 $per_page = isset( $query['per_page'] ) ? max( 1, (int) $query['per_page'] ) : 20;
636 $query['page'] = $page;
637 $query['per_page'] = $per_page;
638 $result = openstation_app_rest( 'GET', $route, $query );
639 $items = $result['ok'] && is_array( $result['data'] ) ? array_values( $result['data'] ) : array();
640 $envelope = Os::page( $items, $result['ok'] ? $result['total'] : 0, $page, $per_page );
641 if ( $result['ok'] ) {
642 $envelope['pages'] = max( 1, (int) $result['pages'] );
643 }
644 $envelope['error'] = $result['ok'] ? '' : (string) $result['error'];
645 $envelope['code'] = $result['ok'] ? '' : (string) $result['code'];
646 return $envelope;
647 }
648
649 /**
650 * Whether a page envelope came back empty because the page is past
651 * the end — Core refuses one outright (`rest_post_invalid_page_number`,
652 * `rest_user_invalid_page_number`, `rest_comment_invalid_page_number`)
653 * — as opposed to a refusal a list must surface. The typical cause is
654 * the user on page 7 raising the page size; the typical answer is to
655 * land on page 1 silently.
656 *
657 * @param array<string,mixed> $envelope From {@see openstation_app_rest_page()}.
658 * @return bool
659 */
660 function openstation_app_rest_page_is_out_of_range( array $envelope ) {
661 if ( array() !== $envelope['items'] ) {
662 return false;
663 }
664 $code = isset( $envelope['code'] ) ? (string) $envelope['code'] : '';
665 return '' === $code || false !== strpos( $code, 'invalid_page_number' );
666 }
667
668 /**
669 * Register the dispatch route.
670 */
671 function openstation_apps_register_routes() {
672 register_rest_route(
673 'desktop-mode/v1',
674 '/apps/(?P<app>[a-z0-9][a-z0-9_-]*)/dispatch',
675 array(
676 'methods' => WP_REST_Server::CREATABLE,
677 'callback' => 'openstation_apps_rest_dispatch',
678 'permission_callback' => 'openstation_apps_rest_permission',
679 'args' => array(
680 'action' => array(
681 'description' => 'Action name, or `mount` for the first render.',
682 'type' => 'string',
683 'required' => true,
684 ),
685 ),
686 )
687 );
688 }
689 add_action( 'rest_api_init', 'openstation_apps_register_routes' );
690
691 /**
692 * Permission: the app must exist and admit the acting user.
693 *
694 * @param WP_REST_Request $request Request.
695 * @return true|WP_Error
696 */
697 function openstation_apps_rest_permission( WP_REST_Request $request ) {
698 if ( ! is_user_logged_in() ) {
699 return new WP_Error(
700 'openstation_app_unauthorized',
701 __( 'You must be logged in to use this window.', 'desktop-mode' ),
702 array( 'status' => rest_authorization_required_code() )
703 );
704 }
705 $app = openstation_app( (string) $request['app'] );
706 if ( ! $app ) {
707 return new WP_Error( 'openstation_app_not_found', __( 'Unknown app.', 'desktop-mode' ), array( 'status' => 404 ) );
708 }
709 if ( ! $app->allows( openstation_apps_os() ) ) {
710 return new WP_Error( 'openstation_app_forbidden', __( 'You are not allowed to use this window.', 'desktop-mode' ), array( 'status' => 403 ) );
711 }
712 return true;
713 }
714
715 /**
716 * Translate a runtime failure into a `WP_Error`.
717 *
718 * @param array<string,mixed> $failure `error`, `message`, `status`.
719 * @return WP_Error
720 */
721 function openstation_apps_rest_error( array $failure ) {
722 $messages = array(
723 'not_found' => __( 'Unknown app.', 'desktop-mode' ),
724 'forbidden' => __( 'You are not allowed to use this window.', 'desktop-mode' ),
725 'unknown_action' => __( 'This window does not know that action.', 'desktop-mode' ),
726 'unknown_view' => __( 'This window does not have that tab.', 'desktop-mode' ),
727 );
728 $code = isset( $failure['error'] ) ? (string) $failure['error'] : 'failed';
729 $message = isset( $messages[ $code ] ) ? $messages[ $code ] : (string) $failure['message'];
730 return new WP_Error(
731 'openstation_app_' . $code,
732 $message,
733 array( 'status' => isset( $failure['status'] ) ? (int) $failure['status'] : 500 )
734 );
735 }
736
737 /**
738 * `POST /apps/<id>/dispatch`.
739 *
740 * @param WP_REST_Request $request Request.
741 * @return WP_REST_Response|WP_Error
742 */
743 function openstation_apps_rest_dispatch( WP_REST_Request $request ) {
744 $body = $request->get_json_params();
745 $body = is_array( $body ) ? $body : array();
746
747 $result = openstation_apps_runtime()->dispatch(
748 (string) $request['app'],
749 array(
750 'action' => (string) $request->get_param( 'action' ),
751 'view' => isset( $body['view'] ) ? (string) $body['view'] : 'main',
752 'state' => isset( $body['state'] ) && is_array( $body['state'] ) ? $body['state'] : array(),
753 'args' => isset( $body['args'] ) && is_array( $body['args'] ) ? $body['args'] : array(),
754 'params' => isset( $body['params'] ) && is_array( $body['params'] ) ? $body['params'] : array(),
755 'client' => isset( $body['client'] ) && is_array( $body['client'] ) ? $body['client'] : array(),
756 ),
757 openstation_apps_os()
758 );
759
760 if ( empty( $result['ok'] ) ) {
761 return openstation_apps_rest_error( $result );
762 }
763 return rest_ensure_response( $result );
764 }
765