PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 1.1.4
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v1.1.4
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.4, at includes/pwa.php

723 lines 27.5 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 $start_url = admin_url( 'index.php?desktop_mode_portal=1' );
385 $scope = admin_url( '/', 'relative' );
386 if ( '' === $scope ) {
387 $scope = '/wp-admin/';
388 }
389
390 $manifest_url = openstation_pwa_manifest_url();
391
392 return array(
393 'name' => $site_name,
394 'short_name' => $short_name,
395 'description' => sprintf(
396 /* translators: %s: site name */
397 __( '%s — installed as a desktop app.', 'desktop-mode' ),
398 $site_name
399 ),
400 'start_url' => $start_url,
401 'scope' => $scope,
402 'id' => openstation_portal_url(),
403 'display' => 'standalone',
404 'display_override' => array( 'standalone', 'minimal-ui' ),
405 'orientation' => 'any',
406 // Match the shell's default surface colour. Filter to override
407 // per-site without redefining the whole manifest.
408 'theme_color' => '#1d2327',
409 'background_color' => '#1d2327',
410 'lang' => get_bloginfo( 'language' ),
411 'dir' => is_rtl() ? 'rtl' : 'ltr',
412 'icons' => openstation_pwa_default_icons(),
413 // Self-reference under `related_applications` so
414 // `navigator.getInstalledRelatedApps()` (Chrome / Edge) returns
415 // a hit when this PWA is installed in the current profile.
416 // `prefer_related_applications: false` keeps the install prompt
417 // pointed at this site itself (not redirected to a related
418 // native app). Without these two fields, a regular browser tab
419 // has no way to detect "already installed in this profile" —
420 // `display-mode: standalone` is only true inside the PWA
421 // window. The detection is what powers the dock-tile click
422 // handler's "X is already installed" toast.
423 'related_applications' => array(
424 array(
425 'platform' => 'webapp',
426 'url' => $manifest_url,
427 'id' => openstation_portal_url(),
428 ),
429 ),
430 'prefer_related_applications' => false,
431 );
432 }
433
434 /**
435 * Resolves the default icon set.
436 *
437 * Priority:
438 * 1. WordPress Site Icon (`Settings → General → Site Icon`) — yields
439 * multiple PNG sizes via `get_site_icon_url()`. Authoritative
440 * when the operator has uploaded a brand mark for their site.
441 * 2. Plugin-bundled icons under `assets/pwa/` — the official
442 * openstation brand mark (the same artwork shown on the
443 * WordPress.org plugin directory listing). Sizes 128 / 192 /
444 * 256 / 512 cover everything from notification badges to splash
445 * screens.
446 *
447 * Purpose is `'any'` rather than `'any maskable'` — the brand icon
448 * has rounded corners + transparent padding that Android's adaptive
449 * mask would crop into. Plugins shipping a full-bleed maskable
450 * variant should replace the array via `openstation_pwa_manifest`.
451 *
452 * @return array<int, array<string, string>>
453 */
454 function openstation_pwa_default_icons() {
455 $icons = array();
456
457 $site_icon_id = (int) get_option( 'site_icon' );
458 if ( $site_icon_id > 0 ) {
459 // `get_site_icon_url()` resolves to a registered intermediate
460 // size. List the canonical PWA sizes (192/512) explicitly so
461 // Chrome's installability heuristic finds an entry whose
462 // `sizes` field matches the returned image.
463 foreach ( array( 192, 512 ) as $size ) {
464 $url = get_site_icon_url( $size );
465 if ( is_string( $url ) && '' !== $url ) {
466 $icons[] = array(
467 'src' => $url,
468 'sizes' => $size . 'x' . $size,
469 'type' => 'image/png',
470 'purpose' => 'any',
471 );
472 }
473 }
474 }
475
476 if ( empty( $icons ) ) {
477 foreach ( array( 128, 192, 256, 512 ) as $size ) {
478 $icons[] = array(
479 'src' => OPENSTATION_URL . "assets/pwa/icon-{$size}.png",
480 'sizes' => "{$size}x{$size}",
481 'type' => 'image/png',
482 'purpose' => 'any',
483 );
484 }
485 }
486
487 return $icons;
488 }
489
490 /**
491 * Serves the service-worker bundle.
492 *
493 * Reads the built `assets/js/sw[.min].js` from disk and streams it back
494 * with the headers a SW needs to be valid:
495 *
496 * - `Content-Type: application/javascript`
497 * - `Service-Worker-Allowed: /` — required for `/`-scoped registration
498 * when the script itself is served from `/openstation/`. Without
499 * this header the browser rejects the `register()` call with
500 * `SecurityError: The path of the provided scope ('/') is not
501 * under the max scope allowed`.
502 * - `Cache-Control: no-cache, must-revalidate` — the browser already
503 * re-checks SW scripts on a 24h cycle, but caching the response
504 * defeats the immediate-update guarantee.
505 *
506 * Falls back to a 503 + log entry when the file is missing (a deploy
507 * that didn't run `npm run build`). Logging gives the operator a
508 * concrete pointer; 503 (vs. 404) tells the browser the SW genuinely
509 * isn't available right now and it should retry later.
510 */
511 function openstation_pwa_serve_service_worker() {
512 $suffix = openstation_asset_suffix();
513 $path = OPENSTATION_DIR . 'assets/js/sw' . $suffix . '.js';
514
515 if ( ! file_exists( $path ) ) {
516 // Guard against hosts that disable error_log() via the
517 // `disable_functions` ini directive.
518 if ( function_exists( 'error_log' ) ) {
519 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
520 }
521 status_header( 503 );
522 header( 'Cache-Control: no-cache, must-revalidate' );
523 return;
524 }
525
526 $body = file_get_contents( $path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
527 if ( false === $body ) {
528 status_header( 503 );
529 return;
530 }
531
532 header( 'Content-Type: application/javascript; charset=utf-8' );
533 header( 'Service-Worker-Allowed: /' );
534 header( 'Cache-Control: no-cache, must-revalidate' );
535 header( 'X-Content-Type-Options: nosniff' );
536
537 // Stamp the SW with a CONTENT HASH so the browser's byte-equality
538 // check on update notices a *real* change.
539 //
540 // Earlier versions stamped with the file's `filemtime()`. Problem:
541 // `npm run build` rewrites `sw.min.js` on every run, bumping its
542 // mtime even when the SW source is byte-identical. Each rebuild
543 // produced a different stamp → different SW response → browser
544 // installed a "new" SW → `controllerchange` fired → the
545 // `bindControllerChangeReload` hook in `src/pwa/sw-register.ts`
546 // auto-reloaded the page. The user observed a "phantom reload"
547 // 2–3s after every `npm run build`, even when only an unrelated
548 // bundle (e.g. `desktop.min.js`) had changed.
549 //
550 // A content hash collapses identical bodies onto identical stamps
551 // — only a *real* change in `src/pwa/sw.ts` triggers the SW
552 // update / reload pipeline. `md5` is plenty for an integrity
553 // stamp here (no security implications) and short enough that the
554 // inline comment stays under one line.
555 $stamp = substr( md5( $body ), 0, 16 );
556 printf( "/* openstation SW build: %s */\n", esc_html( $stamp ) );
557 // Per-request config, injected ahead of the bundle. Deliberately
558 // NOT part of the stamp hash above: the stamp identifies the
559 // *bundle*, while a config change carries itself to the browser's
560 // update check through its own bytes. Don't "fix" the hash to
561 // cover the full response — identical bundles must keep identical
562 // stamps (see the phantom-reload note above).
563 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- JS assembled via wp_json_encode; HTML escaping would corrupt the script.
564 echo openstation_pwa_sw_config_preamble();
565 // `$body` is the SW JavaScript bundle read off disk — escaping
566 // would corrupt the script. Suppress the sniff with the standard
567 // `--` separator (an em-dash silently fails to satisfy phpcs).
568 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- JS bytes from disk.
569 echo $body;
570 }
571
572 /**
573 * Emits the `<link rel="manifest">` tag and the matching theme-color
574 * meta into the admin `<head>` — only when openstation is the active
575 * surface for this request (no chromeless iframes, no classic admin).
576 *
577 * Without these tags the browser never discovers the manifest and the
578 * "install" criterion silently fails. Putting them in `<head>` (rather
579 * than via `wp_localize_script`'s inline script tag) is what the
580 * spec requires.
581 */
582 function openstation_pwa_render_head_tags() {
583 if ( ! is_admin() || ! is_user_logged_in() ) {
584 return;
585 }
586 if ( openstation_is_chromeless_request() ) {
587 return;
588 }
589 if ( ! openstation_is_enabled() || openstation_is_classic_request() ) {
590 return;
591 }
592
593 printf(
594 '<link rel="manifest" href="%s">' . "\n",
595 esc_url( openstation_pwa_manifest_url() )
596 );
597 echo '<meta name="theme-color" content="#1d2327">' . "\n";
598 // `mobile-web-app-capable` is the cross-browser standard;
599 // `apple-mobile-web-app-capable` is the legacy iOS-only spelling
600 // (still required by older Safari versions). Chromium logs a
601 // deprecation warning if only the apple-prefixed form is present.
602 // We emit both so iOS keeps treating the home-screen shortcut as
603 // a standalone app while Chromium stops the warning.
604 echo '<meta name="mobile-web-app-capable" content="yes">' . "\n";
605 echo '<meta name="apple-mobile-web-app-capable" content="yes">' . "\n";
606 echo '<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">' . "\n";
607 printf(
608 '<meta name="apple-mobile-web-app-title" content="%s">' . "\n",
609 esc_attr( get_bloginfo( 'name' ) )
610 );
611 }
612 add_action( 'admin_head', 'openstation_pwa_render_head_tags', 1 );
613
614 /**
615 * Reads the per-user PWA UI state.
616 *
617 * @param int $user_id Defaults to current user.
618 * @return array{installHintDismissed: bool, notificationsEnabled: bool}
619 */
620 function openstation_pwa_get_user_state( $user_id = 0 ) {
621 if ( 0 === $user_id ) {
622 $user_id = get_current_user_id();
623 }
624 $raw = get_user_meta( $user_id, OPENSTATION_PWA_USER_META, true );
625 if ( ! is_array( $raw ) ) {
626 $raw = array();
627 }
628 return array(
629 'installHintDismissed' => ! empty( $raw['installHintDismissed'] ),
630 'notificationsEnabled' => ! empty( $raw['notificationsEnabled'] ),
631 );
632 }
633
634 /**
635 * Writes the per-user PWA UI state, merging with the existing blob so
636 * partial updates from the JS side don't wipe other keys.
637 *
638 * @param array $patch Partial state to merge.
639 * @param int $user_id Defaults to current user.
640 */
641 function openstation_pwa_update_user_state( array $patch, $user_id = 0 ) {
642 if ( 0 === $user_id ) {
643 $user_id = get_current_user_id();
644 }
645 $current = openstation_pwa_get_user_state( $user_id );
646 $next = array_merge( $current, $patch );
647 update_user_meta( $user_id, OPENSTATION_PWA_USER_META, $next );
648 }
649
650 /**
651 * Registers the `/desktop-mode/v1/pwa-state` REST routes.
652 */
653 function openstation_pwa_register_rest_routes() {
654 register_rest_route(
655 'desktop-mode/v1',
656 '/pwa-state',
657 array(
658 array(
659 'methods' => WP_REST_Server::READABLE,
660 'callback' => 'openstation_pwa_rest_get_state',
661 'permission_callback' => 'openstation_pwa_rest_permission',
662 ),
663 array(
664 'methods' => WP_REST_Server::CREATABLE,
665 'callback' => 'openstation_pwa_rest_post_state',
666 'permission_callback' => 'openstation_pwa_rest_permission',
667 'args' => array(
668 'installHintDismissed' => array(
669 'type' => 'boolean',
670 'required' => false,
671 ),
672 'notificationsEnabled' => array(
673 'type' => 'boolean',
674 'required' => false,
675 ),
676 ),
677 ),
678 )
679 );
680
681 // Future: register POST /pwa-push-subscription here when phase 4
682 // lands. The state route is intentionally orthogonal so the v1
683 // surface stays stable when push arrives.
684 }
685 add_action( 'rest_api_init', 'openstation_pwa_register_rest_routes' );
686
687 /**
688 * REST permission gate — same shape as the session routes: logged in
689 * with OpenStation enabled. See
690 * {@see openstation_rest_require_enabled()}.
691 *
692 * @return true|WP_Error
693 */
694 function openstation_pwa_rest_permission() {
695 return openstation_rest_require_enabled();
696 }
697
698 /**
699 * GET handler — returns the current user's PWA state.
700 */
701 function openstation_pwa_rest_get_state() {
702 return rest_ensure_response( openstation_pwa_get_user_state() );
703 }
704
705 /**
706 * POST handler — merges the supplied keys into the user's state.
707 *
708 * @param WP_REST_Request $request REST request.
709 */
710 function openstation_pwa_rest_post_state( $request ) {
711 $patch = array();
712 if ( null !== $request->get_param( 'installHintDismissed' ) ) {
713 $patch['installHintDismissed'] = (bool) $request->get_param( 'installHintDismissed' );
714 }
715 if ( null !== $request->get_param( 'notificationsEnabled' ) ) {
716 $patch['notificationsEnabled'] = (bool) $request->get_param( 'notificationsEnabled' );
717 }
718 if ( ! empty( $patch ) ) {
719 openstation_pwa_update_user_state( $patch );
720 }
721 return rest_ensure_response( openstation_pwa_get_user_state() );
722 }
723