/dispatch` moves a * dispatch in and a response out of `App\Runtime`. * * Every app shares ONE script: `assets/js/app-runtime[.min].js`. It * mounts the window, sends actions, morphs the returned markup into * place and performs effects. An app ships no JavaScript of its own. * * @package OpenStation */ defined( 'ABSPATH' ) || exit; require_once __DIR__ . '/autoload.php'; use OpenStation\App; use OpenStation\App\Os; use OpenStation\App\Registry; use OpenStation\App\Runtime; /** Script handle of the shared client runtime. */ const OPENSTATION_APP_RUNTIME_HANDLE = 'openstation-app-runtime'; /** * The app registry — one per request. * * @return Registry */ function openstation_apps_registry() { static $registry = null; if ( null === $registry ) { $registry = new Registry(); } return $registry; } /** * The dispatch runtime bound to {@see openstation_apps_registry()}. * * @return Runtime */ function openstation_apps_runtime() { static $runtime = null; if ( null === $runtime ) { $runtime = new Runtime( openstation_apps_registry() ); } return $runtime; } /** * The `$os` handle for the current request: WordPress adapters all * the way down. * * @return Os */ function openstation_apps_os() { static $os = null; if ( null === $os ) { $os = new Os( new App\WordPress\Auth(), new App\WordPress\Settings(), new App\WordPress\Hooks(), new App\WordPress\Cache(), new App\WordPress\Env(), new App\WordPress\Store() ); } return $os; } /** * Look a registered app up by id. * * @param string $id App id. * @return App|null */ function openstation_app( $id ) { return openstation_apps_registry()->get( $id ); } /** * The whole window as a value: manifest, state after `mount`, body * HTML and effects — what a host calls to render an app somewhere * other than the desktop (a REST consumer, a CLI, a test). * * @param string $id App id. * @param array $state Partial state; declared defaults fill the rest. * @return array See {@see Runtime::describe()}. */ function openstation_app_render( $id, array $state = array() ) { return openstation_apps_runtime()->describe( $id, $state, openstation_apps_os() ); } /** * Directories scanned for `.os.php` files. * * @return string[] Absolute paths. */ function openstation_apps_directories() { $dirs = array( rtrim( OPENSTATION_DIR, '/\\' ) . '/apps' ); /** * Filter the directories the App Framework loads `.os.php` * files from. Append your plugin's folder to ship apps as files. * * @param string[] $dirs Absolute directory paths. */ return array_values( array_unique( array_filter( array_map( 'strval', (array) apply_filters( 'openstation_apps_directories', $dirs ) ) ) ) ); } /** * Whether this request is an app dispatch (`POST …/apps//dispatch`). * * Sniffed from the request URI because callers need the answer DURING * `init` — before the REST server has parsed the route. Both REST URL * shapes are covered (`/wp-json/…` and `?rest_route=…`). * * @return bool */ function openstation_apps_is_dispatch_request() { $uri = isset( $_SERVER['REQUEST_URI'] ) ? (string) wp_unslash( $_SERVER['REQUEST_URI'] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Substring probe only; never stored or echoed. return false !== strpos( $uri, 'desktop-mode/v1/apps/' ); } /** * An app dispatch renders admin UI, so request-scoped facts that are * normally collected on admin requests only must be collected here * too. First case: the CPT/taxonomy → registering-plugin map * (`openstation_track_type_registrants` defaults to `is_admin()`), * which My WordPress reads to fold plugin CPTs into plugin folders — * without this, every CPT rendered loose in a dispatch while the same * site grouped them on an admin page load. * * @param bool $track Whether to track. * @return bool */ function openstation_apps_track_registrants( $track ) { return $track || openstation_apps_is_dispatch_request(); } add_filter( 'openstation_track_type_registrants', 'openstation_apps_track_registrants' ); /** * Load every app file, then let plugins add apps built in code. */ function openstation_apps_load() { $registry = openstation_apps_registry(); foreach ( openstation_apps_directories() as $dir ) { $registry->load_dir( $dir ); } /** * Fires once every `.os.php` has been loaded. Add an `App` * defined in code with `$registry->add( App::define( … ) )`. * * @param Registry $registry The app registry. */ do_action( 'openstation_apps_loaded', $registry ); } add_action( 'init', 'openstation_apps_load', 10 ); /** * Register the shared runtime script. Never enqueued eagerly — the * native-window sync loads it the first time any app window opens. */ function openstation_apps_register_assets() { $suffix = openstation_asset_suffix(); $js_path = OPENSTATION_DIR . 'assets/js/app-runtime' . $suffix . '.js'; wp_register_script( OPENSTATION_APP_RUNTIME_HANDLE, OPENSTATION_URL . 'assets/js/app-runtime' . $suffix . '.js', array( 'wp-i18n' ), file_exists( $js_path ) ? (string) filemtime( $js_path ) : OPENSTATION_VERSION, true ); wp_set_script_translations( OPENSTATION_APP_RUNTIME_HANDLE, 'desktop-mode', OPENSTATION_DIR . 'languages' ); // The root every app mounts into, and its first-paint spinner. $css_path = OPENSTATION_DIR . 'assets/css/app-runtime.css'; wp_register_style( OPENSTATION_APP_RUNTIME_HANDLE, OPENSTATION_URL . 'assets/css/app-runtime.css', array( 'os-variables' ), file_exists( $css_path ) ? (string) filemtime( $css_path ) : OPENSTATION_VERSION ); } add_action( 'init', 'openstation_apps_register_assets', 5 ); /** * Map an absolute path inside the install to its URL, or '' when the * file lives outside anything WordPress serves. * * @param string $path Absolute file path. * @return string URL or ''. */ function openstation_apps_path_to_url( $path ) { $path = wp_normalize_path( (string) $path ); $content = rtrim( wp_normalize_path( WP_CONTENT_DIR ), '/' ); $root = rtrim( wp_normalize_path( ABSPATH ), '/' ); if ( '' !== $content && 0 === strpos( $path, $content . '/' ) ) { return content_url( substr( $path, strlen( $content ) ) ); } if ( '' !== $root && 0 === strpos( $path, $root . '/' ) ) { return site_url( substr( $path, strlen( $root ) ) ); } return ''; } /** * The style handle an app's stylesheet registers under. * * @param string $id App id. * @return string */ function openstation_apps_style_handle( $id ) { return 'openstation-app-' . (string) $id; } /** * The built client-view bundle for an app, or '' when it has none. * * An explicit `App::client( $path )` wins. Otherwise an app inside * this plugin's own `apps/` is looked up by convention: `npm run * build:apps` compiles `apps//.os.ts` into * `assets/js/apps/[.min].js`, and that bundle is what ships. * * @param array $manifest Filtered manifest. * @return string Absolute path of the built script, or ''. */ function openstation_apps_client_bundle( array $manifest ) { if ( ! empty( $manifest['client'] ) ) { return is_file( $manifest['client'] ) ? (string) $manifest['client'] : ''; } $base = openstation_apps_client_base( $manifest ); if ( '' === $base ) { return ''; } $built = OPENSTATION_DIR . 'assets/js/apps/' . $base . openstation_asset_suffix() . '.js'; return is_file( $built ) ? $built : ''; } /** * The name an app's by-convention client bundle is built under, or '' * for an app that has no such bundle. * * The bundle is named after the definition file: `.os.php` and * `.os.ts` share a base, and the build writes * `assets/js/apps/[.min].js`. So the name is read off the * `.os.php`, the one file a release install is guaranteed to have. * The `.os.ts` is source: `.gitattributes` export-ignores every `.ts` * under `apps/`, and `bin/package.sh` splices the built bundle into * the zip in its place. Keying the lookup on the source's presence is * how every client-view window (Preferences, WP Explorer, Code Blue, * the Recycle Bin) came to open empty on a packaged site: the host * shipped `client: false`, and the runtime asked the server for a * view those apps do not have. * * Only apps under this plugin's `apps/` qualify. That is the directory * the build walks, and an app another plugin ships through * `openstation_apps_directories` declares its bundle with * `App::client()`: a shared file name must never hand it ours. * * @param array $manifest Filtered manifest. * @return string Bundle base name (`code-blue`), or ''. */ function openstation_apps_client_base( array $manifest ) { $file = ''; foreach ( array( 'client_source', 'file' ) as $key ) { if ( ! empty( $manifest[ $key ] ) && is_string( $manifest[ $key ] ) ) { $file = $manifest[ $key ]; break; } } if ( '' === $file ) { return ''; } $apps = realpath( OPENSTATION_DIR . 'apps' ); $dir = realpath( dirname( $file ) ); if ( false === $apps || false === $dir ) { return ''; } $apps = trailingslashit( wp_normalize_path( $apps ) ); $dir = trailingslashit( wp_normalize_path( $dir ) ); if ( 0 !== strpos( $dir, $apps ) ) { return ''; } return (string) preg_replace( '/\.os\.(php|ts)$/', '', basename( $file ) ); } /** * The config blob the client runtime reads through * `wp.os.getWindowConfig( id )`. * * @param array $manifest Filtered manifest. * @param string $bundle Resolved client bundle path, from * {@see openstation_apps_client_bundle()}. * @param App|null $app The app, for a prefetched `data()` * (`App::prefetch()`); null ships none. * @return array */ function openstation_apps_client_config( array $manifest, $bundle = '', $app = null ) { $prefetched = array(); if ( $app instanceof App && ! empty( $manifest['prefetch'] ) && '' !== $bundle ) { // The declared state and the request's host handle — the same // inputs `mount` gets, minus the open-time params a deep link // carries (the runtime waits for `mount` in that case). $prefetched['data'] = $app->compute_data( new App\State( $app->defaults() ), openstation_apps_os() ); } return $prefetched + array( 'client' => '' !== $bundle, 'osApp' => true, 'id' => $manifest['id'], 'title' => $manifest['title'], 'endpoint' => esc_url_raw( rest_url( 'desktop-mode/v1/apps/' . $manifest['id'] . '/dispatch' ) ), 'restRoot' => esc_url_raw( rest_url() ), 'restNonce' => wp_create_nonce( 'wp_rest' ), 'state' => $manifest['state'], 'titleBarButtons' => $manifest['title_bar_buttons'], 'windowActions' => $manifest['window_actions'], 'appearance' => (object) $manifest['appearance'], 'extra' => (object) $manifest['config'], 'actions' => array_values( (array) $manifest['actions'] ), 'lifecycle' => array_values( (array) $manifest['lifecycle'] ), 'channels' => (object) $manifest['channels'], 'watch' => array_values( (array) $manifest['watch'] ), 'tabs' => array_values( (array) $manifest['tabs'] ), ); } /** * The static template the shell clones on open: a root the runtime * mounts into, showing a spinner until the first render lands. One * per view — the main body and each tab panel get their own. * * @param string $id App id. * @param string $view `main` or a tab slug. */ function openstation_apps_render_template( $id, $view = 'main' ) { printf( '
', esc_attr( $id ), esc_attr( $view ) ); } /** * Turn every allowed app into a native window (+ desktop icon). */ function openstation_apps_register_windows() { $os = openstation_apps_os(); foreach ( openstation_apps_registry()->all() as $app ) { if ( ! $app->allows( $os ) ) { continue; } /** * Filter an app's manifest before it is registered with the * shell — size, icon, title-bar buttons, chrome, anything. * * @param array $manifest See `App::manifest()`. * @param string $id App id. * @param App $app The app. */ $manifest = (array) apply_filters( 'openstation_app_manifest', $app->manifest(), $app->id(), $app ); $id = $app->id(); $styles = array(); if ( ! empty( $manifest['style'] ) && is_file( $manifest['style'] ) ) { $url = openstation_apps_path_to_url( $manifest['style'] ); if ( '' !== $url ) { wp_register_style( openstation_apps_style_handle( $id ), $url, array( 'os-variables' ), (string) filemtime( $manifest['style'] ) ); $styles[] = openstation_apps_style_handle( $id ); } } // The `.os.ts` half rides as a companion script: loaded with the // window, before the runtime mounts it, never at boot. $scripts = array(); $bundle = openstation_apps_client_bundle( $manifest ); if ( '' !== $bundle ) { $url = openstation_apps_path_to_url( $bundle ); if ( '' !== $url ) { $handle = 'openstation-app-' . $id . '-client'; wp_register_script( $handle, $url, array( 'wp-i18n' ), (string) filemtime( $bundle ), true ); wp_set_script_translations( $handle, 'desktop-mode', OPENSTATION_DIR . 'languages' ); $scripts[] = $handle; } } $window_args = array( 'title' => $manifest['title'], 'icon' => $manifest['icon'], 'template' => static function () use ( $id ) { openstation_apps_render_template( $id ); }, 'script' => OPENSTATION_APP_RUNTIME_HANDLE, 'scripts' => $scripts, // Both sheets travel as first-open companions — nothing // an app window paints is needed on a page that never // opens it (see tests/phpunit/tests/deferredWindowStyles.php). 'styles' => array_merge( array( OPENSTATION_APP_RUNTIME_HANDLE ), $styles ), 'width' => $manifest['width'], 'height' => $manifest['height'], 'min_width' => $manifest['min_width'], 'min_height' => $manifest['min_height'], 'placement' => $manifest['placement'], 'nav_kind' => $manifest['nav_kind'], 'dock_order' => $manifest['dock_order'], 'placeable' => $manifest['placeable'], 'autofocus' => $manifest['autofocus'], 'admin' => isset( $manifest['admin'] ) ? $manifest['admin'] : 'site', 'config' => openstation_apps_client_config( $manifest, $bundle, $app ), ); /** * Filter the window-registration args an app's manifest * produced, just before `openstation_register_window()` runs. * * The seam a companion plugin uses to ride an app window it * doesn't own — appending registered `scripts` / `styles` * handles (an integration bundle that decorates the app * through its JS hook seams, loaded on first open and never * sooner) — or to tune any other registration arg. * * **Status: Experimental** * * @param array $window_args `openstation_register_window()` args. * @param string $id App id. * @param App $app The app. */ $window_args = (array) apply_filters( 'openstation_app_window_args', $window_args, $id, $app ); $registered = openstation_register_window( $id, $window_args ); if ( is_wp_error( $registered ) ) { // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log error_log( sprintf( '[openstation] App "%s" failed to register: %s', $id, $registered->get_error_message() ) ); continue; } foreach ( (array) $manifest['tabs'] as $tab ) { $tab_value = (string) $tab['value']; openstation_register_window_tab( $id, array( 'value' => $tab_value, 'label' => (string) $tab['label'], 'position' => (int) $tab['position'], 'template' => static function () use ( $id, $tab_value ) { openstation_apps_render_template( $id, $tab_value ); }, ) ); } if ( is_array( $manifest['desktop_icon'] ) ) { $icon = $manifest['desktop_icon']; openstation_register_icon( $id, array( 'title' => isset( $icon['title'] ) ? (string) $icon['title'] : $manifest['title'], 'icon' => isset( $icon['icon'] ) ? (string) $icon['icon'] : $manifest['icon'], 'icon_svg' => isset( $icon['icon'] ) ? '' : (string) $manifest['icon_svg'], 'window' => $id, 'position' => isset( $icon['position'] ) ? (int) $icon['position'] : 100, 'pinned' => ! empty( $icon['pinned'] ), ) ); } /** * Fires after an app has been registered as a native window. * * @param string $id App id. * @param array $manifest The manifest as registered. */ do_action( 'openstation_app_registered', $id, $manifest ); } } add_action( 'init', 'openstation_apps_register_windows', 20 ); /** * Admit the runtime's attributes on every tag kses sees in a * native-window template, so a plugin that renders an app-style * body straight into a `template` callback keeps its triggers. * (`wp_kses` only wildcards `data-*`, so `os-arg-` attributes * survive kses solely on the dispatch path, which is not kses'd — * where every app body normally comes from.) * * @param array $allowed kses allowlist. * @return array */ function openstation_apps_allowed_html( $allowed ) { $runtime_attrs = array( 'os-action', 'os-bind', 'os-on', 'os-debounce', 'os-confirm', 'os-confirm-title', 'os-confirm-label', 'os-confirm-danger', 'os-poll', 'os-key', 'os-preserve', ); foreach ( (array) $allowed as $tag => $attrs ) { if ( ! is_array( $attrs ) ) { continue; } foreach ( $runtime_attrs as $attr ) { $allowed[ $tag ][ $attr ] = true; } } return $allowed; } add_filter( 'openstation_native_window_allowed_html', 'openstation_apps_allowed_html' ); // ------------------------------------------------------------------ REST /** * Run a REST request in-process and hand back what the browser would * have received: the same controller, the same permission checks, * every `register_rest_field()` a plugin added, `_fields` applied and * `_embed` expanded — minus the HTTP round trip. * * This is how a list app's `data()` reads the collections WordPress * already knows how to serve (`wp/v2/posts`, `wp/v2/users`, * `wp/v2/comments`, `wp/v2/plugins`) instead of re-implementing a * query per window: the filters plugin authors already hook * (`rest_post_query`, the REST fields, the `_fields` projections the * `openstation_*_window_query_args` filters shape) keep working * because the request IS a REST request. `rest_do_request()` alone * skips `rest_post_dispatch`, which is where `_fields` is applied, * and never embeds; this helper does both, the way Core's own * `embed_links()` replays them for a sub-request. * * Two things to know: it needs the REST server (`rest_get_server()` * boots it on demand, so call it from a `data()` or an action — a * `prefetch()`ed `data()` would boot it on every admin page load); * and because `_fields` runs before the embed, a projected collection * keeps its `_embedded` only when `_fields` names `_links,_embedded`. * * @param string $method `GET` | `POST` | `DELETE` | …. * @param string $route Route below the REST root (`wp/v2/posts`). * @param array $query Query params (`per_page`, `_fields`, `_embed`, …). * @param array $body Body params for a write. * @return array{ok:bool,status:int,data:mixed,total:int,pages:int,error:string,code:string} */ function openstation_app_rest( $method, $route, array $query = array(), array $body = array() ) { $request = new WP_REST_Request( strtoupper( (string) $method ), '/' . ltrim( (string) $route, '/' ) ); if ( array() !== $query ) { $request->set_query_params( $query ); } if ( array() !== $body ) { $request->set_body_params( $body ); $request->set_header( 'Content-Type', 'application/json' ); $request->set_body( (string) wp_json_encode( $body ) ); } $server = rest_get_server(); $response = rest_do_request( $request ); /** This filter is documented in wp-includes/rest-api/class-wp-rest-server.php */ $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. if ( $response->is_error() ) { $error = $response->as_error(); return array( 'ok' => false, 'status' => (int) $response->get_status(), 'data' => null, 'total' => 0, 'pages' => 0, 'error' => $error ? (string) $error->get_error_message() : '', 'code' => $error ? (string) $error->get_error_code() : '', ); } $embed = isset( $query['_embed'] ) ? rest_parse_embed_param( $query['_embed'] ) : false; $data = $server->response_to_data( $response, $embed ); $headers = $response->get_headers(); return array( 'ok' => true, 'status' => (int) $response->get_status(), 'data' => $data, // A collection reports its total in the header; a single // resource is one thing, however many fields it has. 'total' => isset( $headers['X-WP-Total'] ) ? (int) $headers['X-WP-Total'] : ( wp_is_numeric_array( $data ) ? count( $data ) : 1 ), 'pages' => isset( $headers['X-WP-TotalPages'] ) ? (int) $headers['X-WP-TotalPages'] : 1, 'error' => '', 'code' => '', ); } /** * A REST collection as the paged-list envelope a client view renders * from — {@see \OpenStation\App\Os::page()} — plus `error` and `code` * keys ('' on success) so a list can paint "could not load" instead of * an empty table when the collection refused the request, and tell a * page past the end (`rest_post_invalid_page_number` and its siblings — * {@see openstation_app_rest_page_is_out_of_range()}) from a refusal. * * `page` and `per_page` are read from `$query` and default to 1 / 20; * the defaults are sent with the request too, so the page the envelope * describes is the page the controller served. * * @param string $route Route below the REST root. * @param array $query Query params. * @return array{items:array,total:int,pages:int,page:int,perPage:int,error:string,code:string} */ function openstation_app_rest_page( $route, array $query = array() ) { $page = isset( $query['page'] ) ? max( 1, (int) $query['page'] ) : 1; $per_page = isset( $query['per_page'] ) ? max( 1, (int) $query['per_page'] ) : 20; $query['page'] = $page; $query['per_page'] = $per_page; $result = openstation_app_rest( 'GET', $route, $query ); $items = $result['ok'] && is_array( $result['data'] ) ? array_values( $result['data'] ) : array(); $envelope = Os::page( $items, $result['ok'] ? $result['total'] : 0, $page, $per_page ); if ( $result['ok'] ) { $envelope['pages'] = max( 1, (int) $result['pages'] ); } $envelope['error'] = $result['ok'] ? '' : (string) $result['error']; $envelope['code'] = $result['ok'] ? '' : (string) $result['code']; return $envelope; } /** * Whether a page envelope came back empty because the page is past * the end — Core refuses one outright (`rest_post_invalid_page_number`, * `rest_user_invalid_page_number`, `rest_comment_invalid_page_number`) * — as opposed to a refusal a list must surface. The typical cause is * the user on page 7 raising the page size; the typical answer is to * land on page 1 silently. * * @param array $envelope From {@see openstation_app_rest_page()}. * @return bool */ function openstation_app_rest_page_is_out_of_range( array $envelope ) { if ( array() !== $envelope['items'] ) { return false; } $code = isset( $envelope['code'] ) ? (string) $envelope['code'] : ''; return '' === $code || false !== strpos( $code, 'invalid_page_number' ); } /** * Register the dispatch route. */ function openstation_apps_register_routes() { register_rest_route( 'desktop-mode/v1', '/apps/(?P[a-z0-9][a-z0-9_-]*)/dispatch', array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => 'openstation_apps_rest_dispatch', 'permission_callback' => 'openstation_apps_rest_permission', 'args' => array( 'action' => array( 'description' => 'Action name, or `mount` for the first render.', 'type' => 'string', 'required' => true, ), ), ) ); } add_action( 'rest_api_init', 'openstation_apps_register_routes' ); /** * Permission: the app must exist and admit the acting user. * * @param WP_REST_Request $request Request. * @return true|WP_Error */ function openstation_apps_rest_permission( WP_REST_Request $request ) { if ( ! is_user_logged_in() ) { return new WP_Error( 'openstation_app_unauthorized', __( 'You must be logged in to use this window.', 'desktop-mode' ), array( 'status' => rest_authorization_required_code() ) ); } $app = openstation_app( (string) $request['app'] ); if ( ! $app ) { return new WP_Error( 'openstation_app_not_found', __( 'Unknown app.', 'desktop-mode' ), array( 'status' => 404 ) ); } if ( ! $app->allows( openstation_apps_os() ) ) { return new WP_Error( 'openstation_app_forbidden', __( 'You are not allowed to use this window.', 'desktop-mode' ), array( 'status' => 403 ) ); } return true; } /** * Translate a runtime failure into a `WP_Error`. * * @param array $failure `error`, `message`, `status`. * @return WP_Error */ function openstation_apps_rest_error( array $failure ) { $messages = array( 'not_found' => __( 'Unknown app.', 'desktop-mode' ), 'forbidden' => __( 'You are not allowed to use this window.', 'desktop-mode' ), 'unknown_action' => __( 'This window does not know that action.', 'desktop-mode' ), 'unknown_view' => __( 'This window does not have that tab.', 'desktop-mode' ), ); $code = isset( $failure['error'] ) ? (string) $failure['error'] : 'failed'; $message = isset( $messages[ $code ] ) ? $messages[ $code ] : (string) $failure['message']; return new WP_Error( 'openstation_app_' . $code, $message, array( 'status' => isset( $failure['status'] ) ? (int) $failure['status'] : 500 ) ); } /** * `POST /apps//dispatch`. * * @param WP_REST_Request $request Request. * @return WP_REST_Response|WP_Error */ function openstation_apps_rest_dispatch( WP_REST_Request $request ) { $body = $request->get_json_params(); $body = is_array( $body ) ? $body : array(); $result = openstation_apps_runtime()->dispatch( (string) $request['app'], array( 'action' => (string) $request->get_param( 'action' ), 'view' => isset( $body['view'] ) ? (string) $body['view'] : 'main', 'state' => isset( $body['state'] ) && is_array( $body['state'] ) ? $body['state'] : array(), 'args' => isset( $body['args'] ) && is_array( $body['args'] ) ? $body['args'] : array(), 'params' => isset( $body['params'] ) && is_array( $body['params'] ) ? $body['params'] : array(), 'client' => isset( $body['client'] ) && is_array( $body['client'] ) ? $body['client'] : array(), ), openstation_apps_os() ); if ( empty( $result['ok'] ) ) { return openstation_apps_rest_error( $result ); } return rest_ensure_response( $result ); }