as an app" tile to surface the * "another app is handling installs" toast. * * @return bool */ function openstation_pwa_force_replace_sw() { /** * Filters whether openstation replaces an existing root-scope SW. * * Return `true` to take over from a foreign PWA plugin's service * worker so openstation's "Install as app" affordance works on * sites where another plugin's SW is already active. * * @param bool $force_replace Defaults to `false` (yield to existing SWs). */ return (bool) apply_filters( 'openstation_pwa_force_replace_sw', false ); } /** * Resolves whether the service worker's shared admin-asset cache is on. * * When enabled, the root-scope SW serves versioned admin static assets * (Core CSS/JS, the `load-scripts.php` / `load-styles.php` concat * blobs, plugin/theme assets carrying a `ver` query) from one * origin-wide Cache Storage bucket — so an asset fetched by any window * (shell or chromeless iframe) is answered locally for every later * window, revalidation round-trips included. See `src/pwa/sw-policy.ts` * for the exact classification rules. * * Off by default while the feature proves itself: the failure mode of * cache-first (an asset edited without a `ver` bump staying pinned) is * silent, so users opt in deliberately — via **OpenStation Preferences → * Features → Beta features** (`adminAssetCacheEnabled`, per user), or * site-wide via the filter below. * * Per-user works even though a service worker is origin-wide because * the answer never travels in the worker's own bytes. It is resolved * per request here and pushed to the running worker as an `os-sw-config` * message when the shell boots, and again whenever the preference * changes — see {@see openstation_pwa_sw_config_preamble()} for why * baking it into the script was abandoned. The worker starts with the * cache off, so until that message lands it does less, never more. * * @return bool */ function openstation_pwa_admin_asset_cache_enabled() { $settings = openstation_get_os_settings( get_current_user_id() ); $enabled = ! empty( $settings['adminAssetCacheEnabled'] ); /** * Filters whether the SW's shared admin-asset cache is enabled. * * Return `true` to let the service worker cache versioned admin * static assets in a shared, origin-wide bucket, or `false` to * veto it site-wide regardless of per-user opt-ins. The value * reaches the worker as an `os-sw-config` message on the next shell * boot, so a change takes effect without altering the served script * — no SW update, no URL change, no re-registration. * * @param bool $enabled Defaults to the requesting user's * `adminAssetCacheEnabled` OpenStation * preference (`false` until they opt in). */ return (bool) apply_filters( 'openstation_pwa_admin_asset_cache', $enabled ); } /** * Builds the `self.__OS_SW_CONFIG` preamble line injected ahead of the * service-worker bundle bytes by {@see openstation_pwa_serve_service_worker()}. * * The preamble is how per-site PHP state reaches the SW: the script is * a static build artifact, but the *served response* is assembled per * request, and the browser's byte-equality update check treats any * change in these values as a new SW version (`updateViaCache: 'none'` * at registration makes that check unconditional). The SW URL never * changes, so the foreign-SW `scriptURL` comparison in * `src/pwa/sw-register.ts` is unaffected. * * `pluginUrl` also lets the SW resolve its own asset paths on hosts * with a non-default `wp-content` layout (Bedrock, moved * `WP_CONTENT_DIR`) instead of hardcoding the conventional path. * * @return string One line of JavaScript, newline-terminated. */ function openstation_pwa_sw_config_preamble() { /* * Site-level values ONLY. Nothing here may depend on who is asking. * * `adminAssetCache` and `windowPrewarm` are per-user preferences, * and a service worker is origin-wide. Putting them in the served * bytes made the body differ between an anonymous and a logged-in * request, so any in-scope logged-out navigation — the interim-login * iframe, logging out — served a different script. The browser * treats different bytes as an update, installs it, activates it, * and the shell's `controllerchange` handler hard-reloads the * desktop out from under the user. * * The shell pushes both flags to the running worker at boot instead * (`os-sw-config`), and the toggle pushes changes as they happen. * The worker starts with both off, so until that message lands it * simply does less — never more. */ $config = array( 'pluginUrl' => OPENSTATION_URL, ); return sprintf( "self.__OS_SW_CONFIG = %s;\n", wp_json_encode( $config ) ); } /** * Detects which PWA endpoint the current request is targeting, if any. * * Mirrors `openstation_is_portal_request()`'s strategy: read the * unparsed REQUEST_URI rather than relying on rewrite-rule resolution. * * @return string Empty string when not a PWA endpoint, otherwise one * of `'manifest'` | `'sw'`. */ function openstation_pwa_endpoint_kind() { // `esc_url_raw` rather than `sanitize_text_field`: the value is a URL // and the latter strips percent-encoded octets, which would corrupt // the path before it can be compared against the endpoint constants. $uri = isset( $_SERVER['REQUEST_URI'] ) ? esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : ''; if ( ! is_string( $uri ) || '' === $uri ) { return ''; } $path = (string) wp_parse_url( $uri, PHP_URL_PATH ); if ( '' === $path ) { return ''; } $home_path = wp_parse_url( home_url( '/' ), PHP_URL_PATH ); $home_path = is_string( $home_path ) ? rtrim( $home_path, '/' ) : ''; $portal = $home_path . '/' . trim( OPENSTATION_PORTAL_PATH, '/' ) . '/'; if ( $path === $portal . OPENSTATION_PWA_MANIFEST_FRAGMENT ) { return 'manifest'; } if ( $path === $portal . OPENSTATION_PWA_SW_FRAGMENT ) { return 'sw'; } // Extensionless fallback (`/?openstation_sw=1`) for hosts whose web // server 404s virtual `.js` paths before WordPress runs. // // Pinned to the site root — the one URL // {@see openstation_pwa_sw_fallback_url()} builds and the only one // the registration ever requests. Matching the query alone would // have turned *any* path into a service-worker endpoint, which is // harmless in practice (the handler streams a static file from // disk and reflects nothing from the request) but wider than the // contract this function documents, and a service worker's scope // is decided by the path it is served from — so the path is not an // incidental detail here. // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- public read-only endpoint selector, same trust level as the path match above. if ( isset( $_GET[ OPENSTATION_PWA_SW_QUERY ] ) && '1' === $_GET[ OPENSTATION_PWA_SW_QUERY ] ) { $home_root = '' === $home_path ? '/' : $home_path . '/'; if ( $path === $home_root || $path === $home_path ) { return 'sw'; } } return ''; } /** * Intercepts the manifest and SW endpoints, emitting the response body. * * Hooks at the same `parse_request` priority as the portal handler so * we beat 404 logic but the request environment (auth state, options * cache, etc.) is fully bootstrapped. * * Both endpoints are intentionally **public** (no `is_user_logged_in` * guard). The manifest is loaded by the browser BEFORE login when a * user revisits the install URL; the SW is fetched by the browser * with no cookies on update checks. Both reveal only data already * surfaced by the front-end (site name, blog icon, plugin version). * * @param WP $wp Current WordPress environment instance (unused). */ function openstation_pwa_handle_request( $wp ) { unset( $wp ); $kind = openstation_pwa_endpoint_kind(); if ( '' === $kind ) { return; } if ( 'manifest' === $kind ) { openstation_pwa_serve_manifest(); exit; } if ( 'sw' === $kind ) { openstation_pwa_serve_service_worker(); exit; } } add_action( 'parse_request', 'openstation_pwa_handle_request' ); /** * Builds the manifest array, applies the `openstation_pwa_manifest` * filter, encodes as JSON and prints it. */ function openstation_pwa_serve_manifest() { $manifest = openstation_pwa_build_manifest(); /** * Filters the web-app manifest payload before encoding. * * Common edits: replace the icon list with site-specific artwork, * add `shortcuts` so the OS-level app menu offers * deep-link entries, change `display` to `'fullscreen'`. Returning * a non-array silently disables the manifest — no PHP warning, but * the browser will fail the install criterion. * * @param array $manifest Manifest associative array. */ $manifest = apply_filters( 'openstation_pwa_manifest', $manifest ); if ( ! is_array( $manifest ) ) { status_header( 500 ); return; } header( 'Content-Type: application/manifest+json; charset=utf-8' ); // 5-minute browser cache so a site-icon swap propagates quickly, // but the network isn't hit on every shell load. header( 'Cache-Control: public, max-age=300' ); echo wp_json_encode( $manifest, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); } /** * Assembles the default manifest fields. * * @return array */ function openstation_pwa_build_manifest() { $site_name = get_bloginfo( 'name' ); if ( '' === $site_name ) { $site_name = 'WordPress'; } $short_name = wp_html_excerpt( $site_name, 12, '' ); if ( '' === $short_name ) { $short_name = $site_name; } // `start_url` is the actual landing URL after the `/openstation/` // portal redirect — pointing the PWA directly at it lets us narrow // `scope` to `/wp-admin/` without breaking the launch path. The // portal redirect still exists for typed / bookmarked // `/openstation/` visits in regular browser tabs. // // `scope` is `/wp-admin/`, not `/`. The wider `/` scope had two // failure modes that this fixes: // // - Front-end URLs (e.g. `/2026/05/post-123/`) were considered // in-scope, so Chrome's "Open in app" link-capturing redirected // external-link clicks (Comments "In response to" column, etc.) // into the installed PWA window instead of opening a real // browser tab. Excluding the front-end from scope makes those // clicks open in a browser tab as users expect. // - Every same-origin `` from inside the PWA // opened a NEW standalone PWA window for the same reason. With // scope narrowed, only `/wp-admin/*` links capture into the // PWA; everything else escapes to the system browser. // // `id` is held at the previous `/openstation/` value so existing // installs aren't treated as a different app and reset by Chrome // after this change ships. $start_url = admin_url( 'index.php?desktop_mode_portal=1' ); $scope = admin_url( '/', 'relative' ); if ( '' === $scope ) { $scope = '/wp-admin/'; } $manifest_url = openstation_pwa_manifest_url(); return array( 'name' => $site_name, 'short_name' => $short_name, 'description' => sprintf( /* translators: %s: site name */ __( '%s — installed as a desktop app.', 'desktop-mode' ), $site_name ), 'start_url' => $start_url, 'scope' => $scope, 'id' => openstation_portal_url(), 'display' => 'standalone', 'display_override' => array( 'standalone', 'minimal-ui' ), 'orientation' => 'any', // Match the shell's default surface colour. Filter to override // per-site without redefining the whole manifest. 'theme_color' => '#1d2327', 'background_color' => '#1d2327', 'lang' => get_bloginfo( 'language' ), 'dir' => is_rtl() ? 'rtl' : 'ltr', 'icons' => openstation_pwa_default_icons(), // Self-reference under `related_applications` so // `navigator.getInstalledRelatedApps()` (Chrome / Edge) returns // a hit when this PWA is installed in the current profile. // `prefer_related_applications: false` keeps the install prompt // pointed at this site itself (not redirected to a related // native app). Without these two fields, a regular browser tab // has no way to detect "already installed in this profile" — // `display-mode: standalone` is only true inside the PWA // window. The detection is what powers the dock-tile click // handler's "X is already installed" toast. 'related_applications' => array( array( 'platform' => 'webapp', 'url' => $manifest_url, 'id' => openstation_portal_url(), ), ), 'prefer_related_applications' => false, ); } /** * Resolves the default icon set. * * Priority: * 1. WordPress Site Icon (`Settings → General → Site Icon`) — yields * multiple PNG sizes via `get_site_icon_url()`. Authoritative * when the operator has uploaded a brand mark for their site. * 2. Plugin-bundled icons under `assets/pwa/` — the official * openstation brand mark (the same artwork shown on the * WordPress.org plugin directory listing). Sizes 128 / 192 / * 256 / 512 cover everything from notification badges to splash * screens. * * Purpose is `'any'` rather than `'any maskable'` — the brand icon * has rounded corners + transparent padding that Android's adaptive * mask would crop into. Plugins shipping a full-bleed maskable * variant should replace the array via `openstation_pwa_manifest`. * * @return array> */ function openstation_pwa_default_icons() { $icons = array(); $site_icon_id = (int) get_option( 'site_icon' ); if ( $site_icon_id > 0 ) { // `get_site_icon_url()` resolves to a registered intermediate // size. List the canonical PWA sizes (192/512) explicitly so // Chrome's installability heuristic finds an entry whose // `sizes` field matches the returned image. foreach ( array( 192, 512 ) as $size ) { $url = get_site_icon_url( $size ); if ( is_string( $url ) && '' !== $url ) { $icons[] = array( 'src' => $url, 'sizes' => $size . 'x' . $size, 'type' => 'image/png', 'purpose' => 'any', ); } } } if ( empty( $icons ) ) { foreach ( array( 128, 192, 256, 512 ) as $size ) { $icons[] = array( 'src' => OPENSTATION_URL . "assets/pwa/icon-{$size}.png", 'sizes' => "{$size}x{$size}", 'type' => 'image/png', 'purpose' => 'any', ); } } return $icons; } /** * Serves the service-worker bundle. * * Reads the built `assets/js/sw[.min].js` from disk and streams it back * with the headers a SW needs to be valid: * * - `Content-Type: application/javascript` * - `Service-Worker-Allowed: /` — required for `/`-scoped registration * when the script itself is served from `/openstation/`. Without * this header the browser rejects the `register()` call with * `SecurityError: The path of the provided scope ('/') is not * under the max scope allowed`. * - `Cache-Control: no-cache, must-revalidate` — the browser already * re-checks SW scripts on a 24h cycle, but caching the response * defeats the immediate-update guarantee. * * Falls back to a 503 + log entry when the file is missing (a deploy * that didn't run `npm run build`). Logging gives the operator a * concrete pointer; 503 (vs. 404) tells the browser the SW genuinely * isn't available right now and it should retry later. */ function openstation_pwa_serve_service_worker() { $suffix = openstation_asset_suffix(); $path = OPENSTATION_DIR . 'assets/js/sw' . $suffix . '.js'; if ( ! file_exists( $path ) ) { // Guard against hosts that disable error_log() via the // `disable_functions` ini directive. if ( function_exists( 'error_log' ) ) { error_log( '[openstation] service worker bundle missing at ' . $path . ' — run `npm run build` to generate it.' ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log } status_header( 503 ); header( 'Cache-Control: no-cache, must-revalidate' ); return; } $body = file_get_contents( $path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents if ( false === $body ) { status_header( 503 ); return; } header( 'Content-Type: application/javascript; charset=utf-8' ); header( 'Service-Worker-Allowed: /' ); header( 'Cache-Control: no-cache, must-revalidate' ); header( 'X-Content-Type-Options: nosniff' ); // Stamp the SW with a CONTENT HASH so the browser's byte-equality // check on update notices a *real* change. // // Earlier versions stamped with the file's `filemtime()`. Problem: // `npm run build` rewrites `sw.min.js` on every run, bumping its // mtime even when the SW source is byte-identical. Each rebuild // produced a different stamp → different SW response → browser // installed a "new" SW → `controllerchange` fired → the // `bindControllerChangeReload` hook in `src/pwa/sw-register.ts` // auto-reloaded the page. The user observed a "phantom reload" // 2–3s after every `npm run build`, even when only an unrelated // bundle (e.g. `desktop.min.js`) had changed. // // A content hash collapses identical bodies onto identical stamps // — only a *real* change in `src/pwa/sw.ts` triggers the SW // update / reload pipeline. `md5` is plenty for an integrity // stamp here (no security implications) and short enough that the // inline comment stays under one line. $stamp = substr( md5( $body ), 0, 16 ); printf( "/* openstation SW build: %s */\n", esc_html( $stamp ) ); // Per-request config, injected ahead of the bundle. Deliberately // NOT part of the stamp hash above: the stamp identifies the // *bundle*, while a config change carries itself to the browser's // update check through its own bytes. Don't "fix" the hash to // cover the full response — identical bundles must keep identical // stamps (see the phantom-reload note above). // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- JS assembled via wp_json_encode; HTML escaping would corrupt the script. echo openstation_pwa_sw_config_preamble(); // `$body` is the SW JavaScript bundle read off disk — escaping // would corrupt the script. Suppress the sniff with the standard // `--` separator (an em-dash silently fails to satisfy phpcs). // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- JS bytes from disk. echo $body; } /** * Emits the `` tag and the matching theme-color * meta into the admin `` — only when openstation is the active * surface for this request (no chromeless iframes, no classic admin). * * Without these tags the browser never discovers the manifest and the * "install" criterion silently fails. Putting them in `` (rather * than via `wp_localize_script`'s inline script tag) is what the * spec requires. */ function openstation_pwa_render_head_tags() { if ( ! is_admin() || ! is_user_logged_in() ) { return; } if ( openstation_is_chromeless_request() ) { return; } if ( ! openstation_is_enabled() || openstation_is_classic_request() ) { return; } printf( '' . "\n", esc_url( openstation_pwa_manifest_url() ) ); echo '' . "\n"; // `mobile-web-app-capable` is the cross-browser standard; // `apple-mobile-web-app-capable` is the legacy iOS-only spelling // (still required by older Safari versions). Chromium logs a // deprecation warning if only the apple-prefixed form is present. // We emit both so iOS keeps treating the home-screen shortcut as // a standalone app while Chromium stops the warning. echo '' . "\n"; echo '' . "\n"; echo '' . "\n"; printf( '' . "\n", esc_attr( get_bloginfo( 'name' ) ) ); } add_action( 'admin_head', 'openstation_pwa_render_head_tags', 1 ); /** * Reads the per-user PWA UI state. * * @param int $user_id Defaults to current user. * @return array{installHintDismissed: bool, notificationsEnabled: bool} */ function openstation_pwa_get_user_state( $user_id = 0 ) { if ( 0 === $user_id ) { $user_id = get_current_user_id(); } $raw = get_user_meta( $user_id, OPENSTATION_PWA_USER_META, true ); if ( ! is_array( $raw ) ) { $raw = array(); } return array( 'installHintDismissed' => ! empty( $raw['installHintDismissed'] ), 'notificationsEnabled' => ! empty( $raw['notificationsEnabled'] ), ); } /** * Writes the per-user PWA UI state, merging with the existing blob so * partial updates from the JS side don't wipe other keys. * * @param array $patch Partial state to merge. * @param int $user_id Defaults to current user. */ function openstation_pwa_update_user_state( array $patch, $user_id = 0 ) { if ( 0 === $user_id ) { $user_id = get_current_user_id(); } $current = openstation_pwa_get_user_state( $user_id ); $next = array_merge( $current, $patch ); update_user_meta( $user_id, OPENSTATION_PWA_USER_META, $next ); } /** * Registers the `/desktop-mode/v1/pwa-state` REST routes. */ function openstation_pwa_register_rest_routes() { register_rest_route( 'desktop-mode/v1', '/pwa-state', array( array( 'methods' => WP_REST_Server::READABLE, 'callback' => 'openstation_pwa_rest_get_state', 'permission_callback' => 'openstation_pwa_rest_permission', ), array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => 'openstation_pwa_rest_post_state', 'permission_callback' => 'openstation_pwa_rest_permission', 'args' => array( 'installHintDismissed' => array( 'type' => 'boolean', 'required' => false, ), 'notificationsEnabled' => array( 'type' => 'boolean', 'required' => false, ), ), ), ) ); // Future: register POST /pwa-push-subscription here when phase 4 // lands. The state route is intentionally orthogonal so the v1 // surface stays stable when push arrives. } add_action( 'rest_api_init', 'openstation_pwa_register_rest_routes' ); /** * REST permission gate — same shape as the session routes: logged in * with OpenStation enabled. See * {@see openstation_rest_require_enabled()}. * * @return true|WP_Error */ function openstation_pwa_rest_permission() { return openstation_rest_require_enabled(); } /** * GET handler — returns the current user's PWA state. */ function openstation_pwa_rest_get_state() { return rest_ensure_response( openstation_pwa_get_user_state() ); } /** * POST handler — merges the supplied keys into the user's state. * * @param WP_REST_Request $request REST request. */ function openstation_pwa_rest_post_state( $request ) { $patch = array(); if ( null !== $request->get_param( 'installHintDismissed' ) ) { $patch['installHintDismissed'] = (bool) $request->get_param( 'installHintDismissed' ); } if ( null !== $request->get_param( 'notificationsEnabled' ) ) { $patch['notificationsEnabled'] = (bool) $request->get_param( 'notificationsEnabled' ); } if ( ! empty( $patch ) ) { openstation_pwa_update_user_state( $patch ); } return rest_ensure_response( openstation_pwa_get_user_state() ); }