| 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 |
|