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

pwa.php in OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin 1.1.5, at includes/pwa.php

724 lines 27.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * OpenStation — Progressive Web App support.
4 *
5 * Lets users install the WordPress site as a desktop / mobile app from
6 * the openstation shell. Three concerns live here:
7 *
8 * 1. Web app manifest at `/openstation/manifest.webmanifest` —
9 * served via `parse_request` like the portal URL, no rewrite-rule
10 * registration. Site name, theme color, and icons assembled from
11 * the WordPress Site Icon (when set) with a wp-logo fallback. The
12 * `openstation_pwa_manifest` filter lets plugins mutate any
13 * field before encoding.
14 *
15 * 2. Service worker at `/openstation/sw.js`, served with the
16 * explicit `Service-Worker-Allowed: /` header so a single SW can
17 * scope across `/openstation/` AND `/wp-admin/` (their common
18 * ancestor is `/`). The plugin lives at
19 * `/wp-content/plugins/desktop-mode/`, which is NOT a parent of
20 * `/wp-admin/`, so wp-content-served SWs cannot reach admin pages.
21 * PHP delivery sidesteps that constraint cleanly.
22 *
23 * 3. Two REST routes scoped to the current user:
24 * - `GET/POST /desktop-mode/v1/pwa-state` — dismissal pref for
25 * the install hint, plus notification permission record.
26 * - (future) `POST /desktop-mode/v1/push-subscription` — Web
27 * Push subscription storage. Stub left here in a comment as
28 * a hint for the v2 push PR.
29 *
30 * @package OpenStation
31 */
32
33 defined( 'ABSPATH' ) || exit;
34
35 /**
36 * URL fragment for the manifest endpoint, joined onto the portal path.
37 *
38 * Kept as a constant so the JS-side script localisation and the
39 * `parse_request` matcher cannot drift apart.
40 */
41 const OPENSTATION_PWA_MANIFEST_FRAGMENT = 'manifest.webmanifest';
42
43 /**
44 * URL fragment for the service worker.
45 */
46 const OPENSTATION_PWA_SW_FRAGMENT = 'sw.js';
47
48 /**
49 * Query var for the extensionless service-worker fallback endpoint.
50 *
51 * Some hosts' nginx (WordPress.com among them) short-circuits paths
52 * with a static-file extension straight to the filesystem: a virtual
53 * route like `/openstation/sw.js` 404s at the web server and never
54 * reaches WordPress, so the pretty SW endpoint is unservable there —
55 * while the extensionless manifest route works fine. The fallback
56 * serves the same bytes at `/?openstation_sw=1`: no extension, so the
57 * request always reaches WordPress, and the script URL's *path* is
58 * `/`, which grants root scope without the `Service-Worker-Allowed`
59 * header even mattering. `src/pwa/sw-register.ts` retries with this
60 * URL when registering the pretty URL fails.
61 */
62 const OPENSTATION_PWA_SW_QUERY = 'openstation_sw';
63
64 /**
65 * User-meta key — JSON blob persisting per-user PWA UI state.
66 *
67 * Today: `installHintDismissed` (bool), `notificationsEnabled` (bool).
68 * Future: `pushSubscription` (object) when phase 4 lands.
69 *
70 * The VALUE keeps its pre-rebrand spelling on purpose: it is a
71 * persisted or externally-visible identifier, so renaming it would
72 * orphan data already written by live installs (or break a live
73 * URL). The mismatch between this constant's name and its value is
74 * deliberate — it is NOT a half-finished rename.
75 */
76 const OPENSTATION_PWA_USER_META = 'desktop_mode_pwa_state';
77
78 /**
79 * Builds the absolute manifest URL.
80 *
81 * @return string
82 */
83 function openstation_pwa_manifest_url() {
84 return openstation_portal_url() . OPENSTATION_PWA_MANIFEST_FRAGMENT;
85 }
86
87 /**
88 * Builds the absolute service-worker URL.
89 *
90 * @return string
91 */
92 function openstation_pwa_sw_url() {
93 return openstation_portal_url() . OPENSTATION_PWA_SW_FRAGMENT;
94 }
95
96 /**
97 * Builds the extensionless service-worker fallback URL.
98 *
99 * See {@see OPENSTATION_PWA_SW_QUERY} for why this exists. Kept as a
100 * root-path URL on purpose: the SW script URL's path determines the
101 * default maximum scope, and `/` is exactly the scope we register.
102 *
103 * @return string
104 */
105 function openstation_pwa_sw_fallback_url() {
106 return add_query_arg( OPENSTATION_PWA_SW_QUERY, '1', home_url( '/' ) );
107 }
108
109 /**
110 * Resolves whether openstation should usurp another root-scope SW.
111 *
112 * When `false` (default), `src/pwa/sw-register.ts` bails on registration
113 * if another root-scope service worker is already on the origin — polite
114 * behaviour for sites that intentionally use a different PWA plugin. When
115 * `true`, our registration replaces the existing SW.
116 *
117 * Operators flip this to recover installability on sites where a foreign
118 * SW (Super PWA, Jetpack Boost, etc.) is shadowing the openstation SW
119 * and causing the "Install <site> as an app" tile to surface the
120 * "another app is handling installs" toast.
121 *
122 * @return bool
123 */
124 function openstation_pwa_force_replace_sw() {
125 /**
126 * Filters whether openstation replaces an existing root-scope SW.
127 *
128 * Return `true` to take over from a foreign PWA plugin's service
129 * worker so openstation's "Install as app" affordance works on
130 * sites where another plugin's SW is already active.
131 *
132 * @param bool $force_replace Defaults to `false` (yield to existing SWs).
133 */
134 return (bool) apply_filters( 'openstation_pwa_force_replace_sw', false );
135 }
136
137 /**
138 * Resolves whether the service worker's shared admin-asset cache is on.
139 *
140 * When enabled, the root-scope SW serves versioned admin static assets
141 * (Core CSS/JS, the `load-scripts.php` / `load-styles.php` concat
142 * blobs, plugin/theme assets carrying a `ver` query) from one
143 * origin-wide Cache Storage bucket — so an asset fetched by any window
144 * (shell or chromeless iframe) is answered locally for every later
145 * window, revalidation round-trips included. See `src/pwa/sw-policy.ts`
146 * for the exact classification rules.
147 *
148 * Off by default while the feature proves itself: the failure mode of
149 * cache-first (an asset edited without a `ver` bump staying pinned) is
150 * silent, so users opt in deliberately — via **OpenStation Preferences →
151 * Features → Beta features** (`adminAssetCacheEnabled`, per user), or
152 * site-wide via the filter below.
153 *
154 * Per-user works even though a service worker is origin-wide because
155 * the answer never travels in the worker's own bytes. It is resolved
156 * per request here and pushed to the running worker as an `os-sw-config`
157 * message when the shell boots, and again whenever the preference
158 * changes — see {@see openstation_pwa_sw_config_preamble()} for why
159 * baking it into the script was abandoned. The worker starts with the
160 * cache off, so until that message lands it does less, never more.
161 *
162 * @return bool
163 */
164 function openstation_pwa_admin_asset_cache_enabled() {
165 $settings = openstation_get_os_settings( get_current_user_id() );
166 $enabled = ! empty( $settings['adminAssetCacheEnabled'] );
167
168 /**
169 * Filters whether the SW's shared admin-asset cache is enabled.
170 *
171 * Return `true` to let the service worker cache versioned admin
172 * static assets in a shared, origin-wide bucket, or `false` to
173 * veto it site-wide regardless of per-user opt-ins. The value
174 * reaches the worker as an `os-sw-config` message on the next shell
175 * boot, so a change takes effect without altering the served script
176 * — no SW update, no URL change, no re-registration.
177 *
178 * @param bool $enabled Defaults to the requesting user's
179 * `adminAssetCacheEnabled` OpenStation
180 * preference (`false` until they opt in).
181 */
182 return (bool) apply_filters( 'openstation_pwa_admin_asset_cache', $enabled );
183 }
184
185 /**
186 * Builds the `self.__OS_SW_CONFIG` preamble line injected ahead of the
187 * service-worker bundle bytes by {@see openstation_pwa_serve_service_worker()}.
188 *
189 * The preamble is how per-site PHP state reaches the SW: the script is
190 * a static build artifact, but the *served response* is assembled per
191 * request, and the browser's byte-equality update check treats any
192 * change in these values as a new SW version (`updateViaCache: 'none'`
193 * at registration makes that check unconditional). The SW URL never
194 * changes, so the foreign-SW `scriptURL` comparison in
195 * `src/pwa/sw-register.ts` is unaffected.
196 *
197 * `pluginUrl` also lets the SW resolve its own asset paths on hosts
198 * with a non-default `wp-content` layout (Bedrock, moved
199 * `WP_CONTENT_DIR`) instead of hardcoding the conventional path.
200 *
201 * @return string One line of JavaScript, newline-terminated.
202 */
203 function openstation_pwa_sw_config_preamble() {
204 /*
205 * Site-level values ONLY. Nothing here may depend on who is asking.
206 *
207 * `adminAssetCache` and `windowPrewarm` are per-user preferences,
208 * and a service worker is origin-wide. Putting them in the served
209 * bytes made the body differ between an anonymous and a logged-in
210 * request, so any in-scope logged-out navigation — the interim-login
211 * iframe, logging out — served a different script. The browser
212 * treats different bytes as an update, installs it, activates it,
213 * and the shell's `controllerchange` handler hard-reloads the
214 * desktop out from under the user.
215 *
216 * The shell pushes both flags to the running worker at boot instead
217 * (`os-sw-config`), and the toggle pushes changes as they happen.
218 * The worker starts with both off, so until that message lands it
219 * simply does less — never more.
220 */
221 $config = array(
222 'pluginUrl' => OPENSTATION_URL,
223 );
224 return sprintf( "self.__OS_SW_CONFIG = %s;\n", wp_json_encode( $config ) );
225 }
226
227 /**
228 * Detects which PWA endpoint the current request is targeting, if any.
229 *
230 * Mirrors `openstation_is_portal_request()`'s strategy: read the
231 * unparsed REQUEST_URI rather than relying on rewrite-rule resolution.
232 *
233 * @return string Empty string when not a PWA endpoint, otherwise one
234 * of `'manifest'` | `'sw'`.
235 */
236 function openstation_pwa_endpoint_kind() {
237 // `esc_url_raw` rather than `sanitize_text_field`: the value is a URL
238 // and the latter strips percent-encoded octets, which would corrupt
239 // the path before it can be compared against the endpoint constants.
240 $uri = isset( $_SERVER['REQUEST_URI'] ) ? esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';
241 if ( ! is_string( $uri ) || '' === $uri ) {
242 return '';
243 }
244 $path = (string) wp_parse_url( $uri, PHP_URL_PATH );
245 if ( '' === $path ) {
246 return '';
247 }
248 $home_path = wp_parse_url( home_url( '/' ), PHP_URL_PATH );
249 $home_path = is_string( $home_path ) ? rtrim( $home_path, '/' ) : '';
250 $portal = $home_path . '/' . trim( OPENSTATION_PORTAL_PATH, '/' ) . '/';
251 if ( $path === $portal . OPENSTATION_PWA_MANIFEST_FRAGMENT ) {
252 return 'manifest';
253 }
254 if ( $path === $portal . OPENSTATION_PWA_SW_FRAGMENT ) {
255 return 'sw';
256 }
257 // Extensionless fallback (`/?openstation_sw=1`) for hosts whose web
258 // server 404s virtual `.js` paths before WordPress runs.
259 //
260 // Pinned to the site root — the one URL
261 // {@see openstation_pwa_sw_fallback_url()} builds and the only one
262 // the registration ever requests. Matching the query alone would
263 // have turned *any* path into a service-worker endpoint, which is
264 // harmless in practice (the handler streams a static file from
265 // disk and reflects nothing from the request) but wider than the
266 // contract this function documents, and a service worker's scope
267 // is decided by the path it is served from — so the path is not an
268 // incidental detail here.
269 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- public read-only endpoint selector, same trust level as the path match above.
270 if ( isset( $_GET[ OPENSTATION_PWA_SW_QUERY ] ) && '1' === $_GET[ OPENSTATION_PWA_SW_QUERY ] ) {
271 $home_root = '' === $home_path ? '/' : $home_path . '/';
272 if ( $path === $home_root || $path === $home_path ) {
273 return 'sw';
274 }
275 }
276 return '';
277 }
278
279 /**
280 * Intercepts the manifest and SW endpoints, emitting the response body.
281 *
282 * Hooks at the same `parse_request` priority as the portal handler so
283 * we beat 404 logic but the request environment (auth state, options
284 * cache, etc.) is fully bootstrapped.
285 *
286 * Both endpoints are intentionally **public** (no `is_user_logged_in`
287 * guard). The manifest is loaded by the browser BEFORE login when a
288 * user revisits the install URL; the SW is fetched by the browser
289 * with no cookies on update checks. Both reveal only data already
290 * surfaced by the front-end (site name, blog icon, plugin version).
291 *
292 * @param WP $wp Current WordPress environment instance (unused).
293 */
294 function openstation_pwa_handle_request( $wp ) {
295 unset( $wp );
296
297 $kind = openstation_pwa_endpoint_kind();
298 if ( '' === $kind ) {
299 return;
300 }
301
302 if ( 'manifest' === $kind ) {
303 openstation_pwa_serve_manifest();
304 exit;
305 }
306
307 if ( 'sw' === $kind ) {
308 openstation_pwa_serve_service_worker();
309 exit;
310 }
311 }
312 add_action( 'parse_request', 'openstation_pwa_handle_request' );
313
314 /**
315 * Builds the manifest array, applies the `openstation_pwa_manifest`
316 * filter, encodes as JSON and prints it.
317 */
318 function openstation_pwa_serve_manifest() {
319 $manifest = openstation_pwa_build_manifest();
320
321 /**
322 * Filters the web-app manifest payload before encoding.
323 *
324 * Common edits: replace the icon list with site-specific artwork,
325 * add `shortcuts` so the OS-level app menu offers
326 * deep-link entries, change `display` to `'fullscreen'`. Returning
327 * a non-array silently disables the manifest — no PHP warning, but
328 * the browser will fail the install criterion.
329 *
330 * @param array $manifest Manifest associative array.
331 */
332 $manifest = apply_filters( 'openstation_pwa_manifest', $manifest );
333
334 if ( ! is_array( $manifest ) ) {
335 status_header( 500 );
336 return;
337 }
338
339 header( 'Content-Type: application/manifest+json; charset=utf-8' );
340 // 5-minute browser cache so a site-icon swap propagates quickly,
341 // but the network isn't hit on every shell load.
342 header( 'Cache-Control: public, max-age=300' );
343 echo wp_json_encode( $manifest, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE );
344 }
345
346 /**
347 * Assembles the default manifest fields.
348 *
349 * @return array
350 */
351 function openstation_pwa_build_manifest() {
352 $site_name = get_bloginfo( 'name' );
353 if ( '' === $site_name ) {
354 $site_name = 'WordPress';
355 }
356 $short_name = wp_html_excerpt( $site_name, 12, '' );
357 if ( '' === $short_name ) {
358 $short_name = $site_name;
359 }
360
361 // `start_url` is the actual landing URL after the `/openstation/`
362 // portal redirect — pointing the PWA directly at it lets us narrow
363 // `scope` to `/wp-admin/` without breaking the launch path. The
364 // portal redirect still exists for typed / bookmarked
365 // `/openstation/` visits in regular browser tabs.
366 //
367 // `scope` is `/wp-admin/`, not `/`. The wider `/` scope had two
368 // failure modes that this fixes:
369 //
370 // - Front-end URLs (e.g. `/2026/05/post-123/`) were considered
371 // in-scope, so Chrome's "Open in app" link-capturing redirected
372 // external-link clicks (Comments "In response to" column, etc.)
373 // into the installed PWA window instead of opening a real
374 // browser tab. Excluding the front-end from scope makes those
375 // clicks open in a browser tab as users expect.
376 // - Every same-origin `<a target="_blank">` from inside the PWA
377 // opened a NEW standalone PWA window for the same reason. With
378 // scope narrowed, only `/wp-admin/*` links capture into the
379 // PWA; everything else escapes to the system browser.
380 //
381 // `id` is held at the previous `/openstation/` value so existing
382 // installs aren't treated as a different app and reset by Chrome
383 // after this change ships.
384 // The shell screen, bare: it resolves the entry itself from the
385 // saved session. Installs made when this was
386 // `index.php?desktop_mode_portal=1` still work — that URL is an
387 // alias the admin_init redirect sends here (`includes/portal.php`).
388 $start_url = openstation_shell_url();
389 $scope = admin_url( '/', 'relative' );
390 if ( '' === $scope ) {
391 $scope = '/wp-admin/';
392 }
393
394 $manifest_url = openstation_pwa_manifest_url();
395
396 return array(
397 'name' => $site_name,
398 'short_name' => $short_name,
399 'description' => sprintf(
400 /* translators: %s: site name */
401 __( '%s — installed as a desktop app.', 'desktop-mode' ),
402 $site_name
403 ),
404 'start_url' => $start_url,
405 'scope' => $scope,
406 'id' => openstation_portal_url(),
407 'display' => 'standalone',
408 'display_override' => array( 'standalone', 'minimal-ui' ),
409 'orientation' => 'any',
410 // Match the shell's default surface colour. Filter to override
411 // per-site without redefining the whole manifest.
412 'theme_color' => '#1d2327',
413 'background_color' => '#1d2327',
414 'lang' => get_bloginfo( 'language' ),
415 'dir' => is_rtl() ? 'rtl' : 'ltr',
416 'icons' => openstation_pwa_default_icons(),
417 // Self-reference under `related_applications` so
418 // `navigator.getInstalledRelatedApps()` (Chrome / Edge) returns
419 // a hit when this PWA is installed in the current profile.
420 // `prefer_related_applications: false` keeps the install prompt
421 // pointed at this site itself (not redirected to a related
422 // native app). Without these two fields, a regular browser tab
423 // has no way to detect "already installed in this profile" —
424 // `display-mode: standalone` is only true inside the PWA
425 // window. The detection is what powers the dock-tile click
426 // handler's "X is already installed" toast.
427 'related_applications' => array(
428 array(
429 'platform' => 'webapp',
430 'url' => $manifest_url,
431 'id' => openstation_portal_url(),
432 ),
433 ),
434 'prefer_related_applications' => false,
435 );
436 }
437
438 /**
439 * Resolves the default icon set.
440 *
441 * Priority:
442 * 1. WordPress Site Icon (`Settings → General → Site Icon`) — yields
443 * multiple PNG sizes via `get_site_icon_url()`. Authoritative
444 * when the operator has uploaded a brand mark for their site.
445 * 2. Plugin-bundled icons under `assets/pwa/` — the official
446 * openstation brand mark (the same artwork shown on the
447 * WordPress.org plugin directory listing). Sizes 128 / 192 /
448 * 256 / 512 cover everything from notification badges to splash
449 * screens.
450 *
451 * Purpose is `'any'` rather than `'any maskable'` — the brand icon
452 * has rounded corners + transparent padding that Android's adaptive
453 * mask would crop into. Plugins shipping a full-bleed maskable
454 * variant should replace the array via `openstation_pwa_manifest`.
455 *
456 * @return array<int, array<string, string>>
457 */
458 function openstation_pwa_default_icons() {
459 $icons = array();
460
461 $site_icon_id = (int) get_option( 'site_icon' );
462 if ( $site_icon_id > 0 ) {
463 // `get_site_icon_url()` resolves to a registered intermediate
464 // size. List the canonical PWA sizes (192/512) explicitly so
465 // Chrome's installability heuristic finds an entry whose
466 // `sizes` field matches the returned image.
467 foreach ( array( 192, 512 ) as $size ) {
468 $url = get_site_icon_url( $size );
469 if ( is_string( $url ) && '' !== $url ) {
470 $icons[] = array(
471 'src' => $url,
472 'sizes' => $size . 'x' . $size,
473 'type' => 'image/png',
474 'purpose' => 'any',
475 );
476 }
477 }
478 }
479
480 if ( empty( $icons ) ) {
481 foreach ( array( 128, 192, 256, 512 ) as $size ) {
482 $icons[] = array(
483 'src' => OPENSTATION_URL . "assets/pwa/icon-{$size}.png",
484 'sizes' => "{$size}x{$size}",
485 'type' => 'image/png',
486 'purpose' => 'any',
487 );
488 }
489 }
490
491 return $icons;
492 }
493
494 /**
495 * Serves the service-worker bundle.
496 *
497 * Reads the built `assets/js/sw[.min].js` from disk and streams it back
498 * with the headers a SW needs to be valid:
499 *
500 * - `Content-Type: application/javascript`
501 * - `Service-Worker-Allowed: /` — required for `/`-scoped registration
502 * when the script itself is served from `/openstation/`. Without
503 * this header the browser rejects the `register()` call with
504 * `SecurityError: The path of the provided scope ('/') is not
505 * under the max scope allowed`.
506 * - `Cache-Control: no-cache, must-revalidate` — the browser already
507 * re-checks SW scripts on a 24h cycle, but caching the response
508 * defeats the immediate-update guarantee.
509 *
510 * Falls back to a 503 + log entry when the file is missing (a deploy
511 * that didn't run `npm run build`). Logging gives the operator a
512 * concrete pointer; 503 (vs. 404) tells the browser the SW genuinely
513 * isn't available right now and it should retry later.
514 */
515 function openstation_pwa_serve_service_worker() {
516 $suffix = openstation_asset_suffix();
517 $path = OPENSTATION_DIR . 'assets/js/sw' . $suffix . '.js';
518
519 if ( ! file_exists( $path ) ) {
520 // Guard against hosts that disable error_log() via the
521 // `disable_functions` ini directive.
522 if ( function_exists( 'error_log' ) ) {
523 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
524 }
525 status_header( 503 );
526 header( 'Cache-Control: no-cache, must-revalidate' );
527 return;
528 }
529
530 $body = file_get_contents( $path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
531 if ( false === $body ) {
532 status_header( 503 );
533 return;
534 }
535
536 header( 'Content-Type: application/javascript; charset=utf-8' );
537 header( 'Service-Worker-Allowed: /' );
538 header( 'Cache-Control: no-cache, must-revalidate' );
539 header( 'X-Content-Type-Options: nosniff' );
540
541 // Stamp the SW with a CONTENT HASH so the browser's byte-equality
542 // check on update notices a *real* change.
543 //
544 // Earlier versions stamped with the file's `filemtime()`. Problem:
545 // `npm run build` rewrites `sw.min.js` on every run, bumping its
546 // mtime even when the SW source is byte-identical. Each rebuild
547 // produced a different stamp → different SW response → browser
548 // installed a "new" SW → `controllerchange` fired → the
549 // `bindControllerChangeReload` hook in `src/pwa/sw-register.ts`
550 // auto-reloaded the page. The user observed a "phantom reload"
551 // 2–3s after every `npm run build`, even when only an unrelated
552 // bundle (e.g. `desktop.min.js`) had changed.
553 //
554 // A content hash collapses identical bodies onto identical stamps
555 // — only a *real* change in `src/pwa/sw.ts` triggers the SW
556 // update / reload pipeline. `md5` is plenty for an integrity
557 // stamp here (no security implications) and short enough that the
558 // inline comment stays under one line.
559 $stamp = substr( md5( $body ), 0, 16 );
560 printf( "/* openstation SW build: %s */\n", esc_html( $stamp ) );
561 // Per-request config, injected ahead of the bundle. Deliberately
562 // NOT part of the stamp hash above: the stamp identifies the
563 // *bundle*, while a config change carries itself to the browser's
564 // update check through its own bytes. Don't "fix" the hash to
565 // cover the full response — identical bundles must keep identical
566 // stamps (see the phantom-reload note above).
567 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- JS assembled via wp_json_encode; HTML escaping would corrupt the script.
568 echo openstation_pwa_sw_config_preamble();
569 // `$body` is the SW JavaScript bundle read off disk — escaping
570 // would corrupt the script. Suppress the sniff with the standard
571 // `--` separator (an em-dash silently fails to satisfy phpcs).
572 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- JS bytes from disk.
573 echo $body;
574 }
575
576 /**
577 * Emits the `<link rel="manifest">` tag and the matching theme-color
578 * meta into the admin `<head>` — only when openstation is the active
579 * surface for this request (no chromeless iframes, no classic admin).
580 *
581 * Without these tags the browser never discovers the manifest and the
582 * "install" criterion silently fails. Putting them in `<head>` (rather
583 * than via `wp_localize_script`'s inline script tag) is what the
584 * spec requires.
585 */
586 function openstation_pwa_render_head_tags() {
587 if ( ! is_admin() || ! is_user_logged_in() ) {
588 return;
589 }
590 if ( ! openstation_is_shell_request() ) {
591 return;
592 }
593
594 printf(
595 '<link rel="manifest" href="%s">' . "\n",
596 esc_url( openstation_pwa_manifest_url() )
597 );
598 echo '<meta name="theme-color" content="#1d2327">' . "\n";
599 // `mobile-web-app-capable` is the cross-browser standard;
600 // `apple-mobile-web-app-capable` is the legacy iOS-only spelling
601 // (still required by older Safari versions). Chromium logs a
602 // deprecation warning if only the apple-prefixed form is present.
603 // We emit both so iOS keeps treating the home-screen shortcut as
604 // a standalone app while Chromium stops the warning.
605 echo '<meta name="mobile-web-app-capable" content="yes">' . "\n";
606 echo '<meta name="apple-mobile-web-app-capable" content="yes">' . "\n";
607 echo '<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">' . "\n";
608 printf(
609 '<meta name="apple-mobile-web-app-title" content="%s">' . "\n",
610 esc_attr( get_bloginfo( 'name' ) )
611 );
612 }
613 add_action( 'admin_head', 'openstation_pwa_render_head_tags', 1 );
614
615 /**
616 * Reads the per-user PWA UI state.
617 *
618 * @param int $user_id Defaults to current user.
619 * @return array{installHintDismissed: bool, notificationsEnabled: bool}
620 */
621 function openstation_pwa_get_user_state( $user_id = 0 ) {
622 if ( 0 === $user_id ) {
623 $user_id = get_current_user_id();
624 }
625 $raw = get_user_meta( $user_id, OPENSTATION_PWA_USER_META, true );
626 if ( ! is_array( $raw ) ) {
627 $raw = array();
628 }
629 return array(
630 'installHintDismissed' => ! empty( $raw['installHintDismissed'] ),
631 'notificationsEnabled' => ! empty( $raw['notificationsEnabled'] ),
632 );
633 }
634
635 /**
636 * Writes the per-user PWA UI state, merging with the existing blob so
637 * partial updates from the JS side don't wipe other keys.
638 *
639 * @param array $patch Partial state to merge.
640 * @param int $user_id Defaults to current user.
641 */
642 function openstation_pwa_update_user_state( array $patch, $user_id = 0 ) {
643 if ( 0 === $user_id ) {
644 $user_id = get_current_user_id();
645 }
646 $current = openstation_pwa_get_user_state( $user_id );
647 $next = array_merge( $current, $patch );
648 update_user_meta( $user_id, OPENSTATION_PWA_USER_META, $next );
649 }
650
651 /**
652 * Registers the `/desktop-mode/v1/pwa-state` REST routes.
653 */
654 function openstation_pwa_register_rest_routes() {
655 register_rest_route(
656 'desktop-mode/v1',
657 '/pwa-state',
658 array(
659 array(
660 'methods' => WP_REST_Server::READABLE,
661 'callback' => 'openstation_pwa_rest_get_state',
662 'permission_callback' => 'openstation_pwa_rest_permission',
663 ),
664 array(
665 'methods' => WP_REST_Server::CREATABLE,
666 'callback' => 'openstation_pwa_rest_post_state',
667 'permission_callback' => 'openstation_pwa_rest_permission',
668 'args' => array(
669 'installHintDismissed' => array(
670 'type' => 'boolean',
671 'required' => false,
672 ),
673 'notificationsEnabled' => array(
674 'type' => 'boolean',
675 'required' => false,
676 ),
677 ),
678 ),
679 )
680 );
681
682 // Future: register POST /pwa-push-subscription here when phase 4
683 // lands. The state route is intentionally orthogonal so the v1
684 // surface stays stable when push arrives.
685 }
686 add_action( 'rest_api_init', 'openstation_pwa_register_rest_routes' );
687
688 /**
689 * REST permission gate — same shape as the session routes: logged in
690 * with OpenStation enabled. See
691 * {@see openstation_rest_require_enabled()}.
692 *
693 * @return true|WP_Error
694 */
695 function openstation_pwa_rest_permission() {
696 return openstation_rest_require_enabled();
697 }
698
699 /**
700 * GET handler — returns the current user's PWA state.
701 */
702 function openstation_pwa_rest_get_state() {
703 return rest_ensure_response( openstation_pwa_get_user_state() );
704 }
705
706 /**
707 * POST handler — merges the supplied keys into the user's state.
708 *
709 * @param WP_REST_Request $request REST request.
710 */
711 function openstation_pwa_rest_post_state( $request ) {
712 $patch = array();
713 if ( null !== $request->get_param( 'installHintDismissed' ) ) {
714 $patch['installHintDismissed'] = (bool) $request->get_param( 'installHintDismissed' );
715 }
716 if ( null !== $request->get_param( 'notificationsEnabled' ) ) {
717 $patch['notificationsEnabled'] = (bool) $request->get_param( 'notificationsEnabled' );
718 }
719 if ( ! empty( $patch ) ) {
720 openstation_pwa_update_user_state( $patch );
721 }
722 return rest_ensure_response( openstation_pwa_get_user_state() );
723 }
724