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

977 lines 37.3 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 * home-path URL on purpose: the SW script URL's path determines the
101 * default maximum scope, and the home path is exactly the scope we
102 * register ({@see openstation_pwa_sw_scope()}).
103 *
104 * @return string
105 */
106 function openstation_pwa_sw_fallback_url() {
107 return add_query_arg( OPENSTATION_PWA_SW_QUERY, '1', home_url( '/' ) );
108 }
109
110 /**
111 * The service worker's registration scope: the SITE's home path.
112 *
113 * `/` everywhere except a subdirectory network's subsites, where it is
114 * the site path (`/site2/`). One scope per site is what makes the PWA
115 * work across a subdirectory network at all — every site registers its
116 * own worker, the browser routes each page to the longest matching
117 * scope, and the worker derives its portal and admin prefixes from the
118 * scope it was given (see `scopePath` in `src/pwa/sw.ts`) instead of
119 * assuming it owns the origin root.
120 *
121 * @return string Path with a trailing slash.
122 */
123 function openstation_pwa_sw_scope() {
124 $path = wp_parse_url( home_url( '/' ), PHP_URL_PATH );
125 return is_string( $path ) && '' !== $path ? $path : '/';
126 }
127
128 /**
129 * Resolves whether openstation should usurp another root-scope SW.
130 *
131 * When `false` (default), `src/pwa/sw-register.ts` bails on registration
132 * if another root-scope service worker is already on the origin — polite
133 * behaviour for sites that intentionally use a different PWA plugin. When
134 * `true`, our registration replaces the existing SW.
135 *
136 * Operators flip this to recover installability on sites where a foreign
137 * SW (Super PWA, Jetpack Boost, etc.) is shadowing the openstation SW
138 * and causing the "Install <site> as an app" tile to surface the
139 * "another app is handling installs" toast.
140 *
141 * @return bool
142 */
143 function openstation_pwa_force_replace_sw() {
144 /**
145 * Filters whether openstation replaces an existing root-scope SW.
146 *
147 * Return `true` to take over from a foreign PWA plugin's service
148 * worker so openstation's "Install as app" affordance works on
149 * sites where another plugin's SW is already active.
150 *
151 * @param bool $force_replace Defaults to `false` (yield to existing SWs).
152 */
153 return (bool) apply_filters( 'openstation_pwa_force_replace_sw', false );
154 }
155
156 /**
157 * Resolves whether the service worker's shared admin-asset cache is on.
158 *
159 * When enabled, the root-scope SW serves versioned admin static assets
160 * (Core CSS/JS, the `load-scripts.php` / `load-styles.php` concat
161 * blobs, plugin/theme assets carrying a `ver` query) from one
162 * origin-wide Cache Storage bucket — so an asset fetched by any window
163 * (shell or chromeless iframe) is answered locally for every later
164 * window, revalidation round-trips included. See `src/pwa/sw-policy.ts`
165 * for the exact classification rules.
166 *
167 * Off by default while the feature proves itself: the failure mode of
168 * cache-first (an asset edited without a `ver` bump staying pinned) is
169 * silent, so users opt in deliberately — via **OpenStation Preferences →
170 * Features → Beta features** (`adminAssetCacheEnabled`, per user), or
171 * site-wide via the filter below.
172 *
173 * Per-user works even though a service worker is origin-wide because
174 * the answer never travels in the worker's own bytes. It is resolved
175 * per request here and pushed to the running worker as an `os-sw-config`
176 * message when the shell boots, and again whenever the preference
177 * changes — see {@see openstation_pwa_sw_config_preamble()} for why
178 * baking it into the script was abandoned. The worker starts with the
179 * cache off, so until that message lands it does less, never more.
180 *
181 * @return bool
182 */
183 function openstation_pwa_admin_asset_cache_enabled() {
184 $settings = openstation_get_os_settings( get_current_user_id() );
185 $enabled = ! empty( $settings['adminAssetCacheEnabled'] );
186
187 /**
188 * Filters whether the SW's shared admin-asset cache is enabled.
189 *
190 * Return `true` to let the service worker cache versioned admin
191 * static assets in a shared, origin-wide bucket, or `false` to
192 * veto it site-wide regardless of per-user opt-ins. The value
193 * reaches the worker as an `os-sw-config` message on the next shell
194 * boot, so a change takes effect without altering the served script
195 * — no SW update, no URL change, no re-registration.
196 *
197 * @param bool $enabled Defaults to the requesting user's
198 * `adminAssetCacheEnabled` OpenStation
199 * preference (`false` until they opt in).
200 */
201 return (bool) apply_filters( 'openstation_pwa_admin_asset_cache', $enabled );
202 }
203
204 /**
205 * Builds the `self.__OS_SW_CONFIG` preamble line injected ahead of the
206 * service-worker bundle bytes by {@see openstation_pwa_serve_service_worker()}.
207 *
208 * The preamble is how per-site PHP state reaches the SW: the script is
209 * a static build artifact, but the *served response* is assembled per
210 * request, and the browser's byte-equality update check treats any
211 * change in these values as a new SW version (`updateViaCache: 'none'`
212 * at registration makes that check unconditional). The SW URL never
213 * changes, so the foreign-SW `scriptURL` comparison in
214 * `src/pwa/sw-register.ts` is unaffected.
215 *
216 * `pluginUrl` also lets the SW resolve its own asset paths on hosts
217 * with a non-default `wp-content` layout (Bedrock, moved
218 * `WP_CONTENT_DIR`) instead of hardcoding the conventional path.
219 *
220 * @return string One line of JavaScript, newline-terminated.
221 */
222 function openstation_pwa_sw_config_preamble() {
223 /*
224 * Site-level values ONLY. Nothing here may depend on who is asking.
225 *
226 * `adminAssetCache` and `windowPrewarm` are per-user preferences,
227 * and a service worker is origin-wide. Putting them in the served
228 * bytes made the body differ between an anonymous and a logged-in
229 * request, so any in-scope logged-out navigation — the interim-login
230 * iframe, logging out — served a different script. The browser
231 * treats different bytes as an update, installs it, activates it,
232 * and the shell's `controllerchange` handler hard-reloads the
233 * desktop out from under the user.
234 *
235 * The shell pushes both flags to the running worker at boot instead
236 * (`os-sw-config`), and the toggle pushes changes as they happen.
237 * The worker starts with both off, so until that message lands it
238 * simply does less — never more.
239 *
240 * `version` is the plugin's, and it is here so that a release is a
241 * byte change in the served script. The bundle itself is stamped
242 * with a content hash (see the serving function), so a release that
243 * touched nothing under `src/pwa/` would otherwise serve the very
244 * same bytes, and the browser — which only ever installs a worker
245 * whose bytes differ — would have nothing to install. An installed
246 * app on a phone rarely navigates; the shell re-checks the script on
247 * every return to the foreground (`src/pwa/sw-register.ts`), and the
248 * version in the preamble is what makes that check find a release.
249 *
250 * `shellBuild` is the content hash of the shell's own built files
251 * ({@see openstation_shell_build_stamp()}). It makes a deploy that
252 * changed the shell a new worker too, and — more importantly — it
253 * tells the shell, when that worker takes over mid-session, whether
254 * the shell it is running is the one the server now serves. A new
255 * worker is never a reason to reload on its own: a release that
256 * changed nothing under `assets/` produces a worker whose
257 * `shellBuild` equals the running shell's, and the shell stays put.
258 */
259 $config = array(
260 'pluginUrl' => OPENSTATION_URL,
261 'version' => OPENSTATION_VERSION,
262 'shellBuild' => openstation_shell_build_stamp(),
263 );
264 return sprintf( "self.__OS_SW_CONFIG = %s;\n", wp_json_encode( $config ) );
265 }
266
267 /**
268 * Content hash of the shell's built front-end: every stylesheet under
269 * `assets/css/` and every bundle under `assets/js/`.
270 *
271 * "Did the shell change?" answered from bytes, not clocks. A deploy
272 * rewrites every file's mtime whether or not its contents moved, and
273 * the plugin version moves on releases that never touched the shell;
274 * neither is a reason to disturb a desktop someone is working in. The
275 * stamp changes exactly when a shell file's bytes do.
276 *
277 * Two readers: `openStationConfig.pwa.shellBuild`, which the shell
278 * boots with, and the served service worker's preamble, so the worker
279 * knows which shell it was served alongside. When a worker takes over
280 * a running shell the two are compared, and only a difference — a real
281 * change in the shell files — earns the user an offer to reload. See
282 * `src/pwa/sw-register.ts`.
283 *
284 * Hashing a few megabytes of bundles on every shell request would be
285 * wasteful, so the stamp is memoised in one transient behind the cheap
286 * signature of the same files (path, size, mtime). A deploy changes
287 * the signature and the hash is recomputed once; identical bytes come
288 * out as the identical stamp, and a touched-but-unchanged file costs a
289 * single rehash.
290 *
291 * @param string|null $dir Plugin directory to read. `OPENSTATION_DIR` by
292 * default; tests hand in a fixture.
293 * @return string Sixteen hex characters, or '' when nothing is built.
294 */
295 function openstation_shell_build_stamp( $dir = null ) {
296 static $memo = array();
297
298 $dir = null === $dir ? OPENSTATION_DIR : trailingslashit( $dir );
299
300 $files = array();
301 foreach ( array( 'assets/css/*.css', 'assets/js/*.js' ) as $pattern ) {
302 $matches = glob( $dir . $pattern );
303 if ( is_array( $matches ) ) {
304 $files = array_merge( $files, $matches );
305 }
306 }
307 sort( $files );
308 if ( empty( $files ) ) {
309 return '';
310 }
311
312 $signature = array( $dir );
313 foreach ( $files as $file ) {
314 $signature[] = substr( $file, strlen( $dir ) ) . ':' . filesize( $file ) . ':' . filemtime( $file );
315 }
316 $signature = md5( implode( "\n", $signature ) );
317
318 if ( isset( $memo[ $signature ] ) ) {
319 return $memo[ $signature ];
320 }
321
322 $cached = get_transient( 'openstation_shell_build' );
323 if ( is_array( $cached ) && isset( $cached['signature'], $cached['stamp'] ) && $cached['signature'] === $signature && is_string( $cached['stamp'] ) ) {
324 $memo[ $signature ] = $cached['stamp'];
325 return $cached['stamp'];
326 }
327
328 $hashes = array();
329 foreach ( $files as $file ) {
330 $hashes[] = substr( $file, strlen( $dir ) ) . ':' . md5_file( $file );
331 }
332 $stamp = substr( md5( implode( "\n", $hashes ) ), 0, 16 );
333
334 $memo[ $signature ] = $stamp;
335 set_transient(
336 'openstation_shell_build',
337 array(
338 'signature' => $signature,
339 'stamp' => $stamp,
340 ),
341 DAY_IN_SECONDS
342 );
343 return $stamp;
344 }
345
346 /**
347 * Detects which PWA endpoint the current request is targeting, if any.
348 *
349 * Mirrors `openstation_is_portal_request()`'s strategy: read the
350 * unparsed REQUEST_URI rather than relying on rewrite-rule resolution.
351 *
352 * @return string Empty string when not a PWA endpoint, otherwise one
353 * of `'manifest'` | `'sw'`.
354 */
355 function openstation_pwa_endpoint_kind() {
356 // `esc_url_raw` rather than `sanitize_text_field`: the value is a URL
357 // and the latter strips percent-encoded octets, which would corrupt
358 // the path before it can be compared against the endpoint constants.
359 $uri = isset( $_SERVER['REQUEST_URI'] ) ? esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';
360 if ( ! is_string( $uri ) || '' === $uri ) {
361 return '';
362 }
363 $path = (string) wp_parse_url( $uri, PHP_URL_PATH );
364 if ( '' === $path ) {
365 return '';
366 }
367 $home_path = wp_parse_url( home_url( '/' ), PHP_URL_PATH );
368 $home_path = is_string( $home_path ) ? rtrim( $home_path, '/' ) : '';
369 $portal = $home_path . '/' . trim( OPENSTATION_PORTAL_PATH, '/' ) . '/';
370 if ( $path === $portal . OPENSTATION_PWA_MANIFEST_FRAGMENT ) {
371 return 'manifest';
372 }
373 if ( $path === $portal . OPENSTATION_PWA_SW_FRAGMENT ) {
374 return 'sw';
375 }
376 // Extensionless fallback (`/?openstation_sw=1`) for hosts whose web
377 // server 404s virtual `.js` paths before WordPress runs.
378 //
379 // Pinned to the site root — the one URL
380 // {@see openstation_pwa_sw_fallback_url()} builds and the only one
381 // the registration ever requests. Matching the query alone would
382 // have turned *any* path into a service-worker endpoint, which is
383 // harmless in practice (the handler streams a static file from
384 // disk and reflects nothing from the request) but wider than the
385 // contract this function documents, and a service worker's scope
386 // is decided by the path it is served from — so the path is not an
387 // incidental detail here.
388 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- public read-only endpoint selector, same trust level as the path match above.
389 if ( isset( $_GET[ OPENSTATION_PWA_SW_QUERY ] ) && '1' === $_GET[ OPENSTATION_PWA_SW_QUERY ] ) {
390 $home_root = '' === $home_path ? '/' : $home_path . '/';
391 if ( $path === $home_root || $path === $home_path ) {
392 return 'sw';
393 }
394 }
395 return '';
396 }
397
398 /**
399 * Intercepts the manifest and SW endpoints, emitting the response body.
400 *
401 * Hooks at the same `parse_request` priority as the portal handler so
402 * we beat 404 logic but the request environment (auth state, options
403 * cache, etc.) is fully bootstrapped.
404 *
405 * Both endpoints are intentionally **public** (no `is_user_logged_in`
406 * guard). The manifest is loaded by the browser BEFORE login when a
407 * user revisits the install URL; the SW is fetched by the browser
408 * with no cookies on update checks. Both reveal only data already
409 * surfaced by the front-end (site name, blog icon, plugin version).
410 *
411 * @param WP $wp Current WordPress environment instance (unused).
412 */
413 function openstation_pwa_handle_request( $wp ) {
414 unset( $wp );
415
416 $kind = openstation_pwa_endpoint_kind();
417 if ( '' === $kind ) {
418 return;
419 }
420
421 if ( 'manifest' === $kind ) {
422 openstation_pwa_serve_manifest();
423 exit;
424 }
425
426 if ( 'sw' === $kind ) {
427 openstation_pwa_serve_service_worker();
428 exit;
429 }
430 }
431 add_action( 'parse_request', 'openstation_pwa_handle_request' );
432
433 /**
434 * Builds the manifest array, applies the `openstation_pwa_manifest`
435 * filter, encodes as JSON and prints it.
436 */
437 function openstation_pwa_serve_manifest() {
438 $manifest = openstation_pwa_build_manifest();
439
440 /**
441 * Filters the web-app manifest payload before encoding.
442 *
443 * Common edits: replace the icon list with site-specific artwork,
444 * add `shortcuts` so the OS-level app menu offers
445 * deep-link entries, change `display` to `'fullscreen'`. Returning
446 * a non-array silently disables the manifest — no PHP warning, but
447 * the browser will fail the install criterion.
448 *
449 * @param array $manifest Manifest associative array.
450 */
451 $manifest = apply_filters( 'openstation_pwa_manifest', $manifest );
452
453 if ( ! is_array( $manifest ) ) {
454 status_header( 500 );
455 return;
456 }
457
458 header( 'Content-Type: application/manifest+json; charset=utf-8' );
459 // 5-minute browser cache so a site-icon swap propagates quickly,
460 // but the network isn't hit on every shell load.
461 header( 'Cache-Control: public, max-age=300' );
462 echo wp_json_encode( $manifest, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE );
463 }
464
465 /**
466 * Assembles the default manifest fields.
467 *
468 * @return array
469 */
470 function openstation_pwa_build_manifest() {
471 $site_name = get_bloginfo( 'name' );
472 if ( '' === $site_name ) {
473 $site_name = 'WordPress';
474 }
475 $short_name = wp_html_excerpt( $site_name, 12, '' );
476 if ( '' === $short_name ) {
477 $short_name = $site_name;
478 }
479
480 // `start_url` is the actual landing URL after the `/openstation/`
481 // portal redirect — pointing the PWA directly at it lets us narrow
482 // `scope` to `/wp-admin/` without breaking the launch path. The
483 // portal redirect still exists for typed / bookmarked
484 // `/openstation/` visits in regular browser tabs.
485 //
486 // `scope` is `/wp-admin/`, not `/`. The wider `/` scope had two
487 // failure modes that this fixes:
488 //
489 // - Front-end URLs (e.g. `/2026/05/post-123/`) were considered
490 // in-scope, so Chrome's "Open in app" link-capturing redirected
491 // external-link clicks (Comments "In response to" column, etc.)
492 // into the installed PWA window instead of opening a real
493 // browser tab. Excluding the front-end from scope makes those
494 // clicks open in a browser tab as users expect.
495 // - Every same-origin `<a target="_blank">` from inside the PWA
496 // opened a NEW standalone PWA window for the same reason. With
497 // scope narrowed, only `/wp-admin/*` links capture into the
498 // PWA; everything else escapes to the system browser.
499 //
500 // `id` is held at the previous `/openstation/` value so existing
501 // installs aren't treated as a different app and reset by Chrome
502 // after this change ships.
503 // The shell screen, bare: it resolves the entry itself from the
504 // saved session. Installs made when this was
505 // `index.php?desktop_mode_portal=1` still work — that URL is an
506 // alias the admin_init redirect sends here (`includes/portal.php`).
507 $start_url = openstation_shell_url();
508 $scope = admin_url( '/', 'relative' );
509 if ( '' === $scope ) {
510 $scope = '/wp-admin/';
511 }
512
513 $manifest_url = openstation_pwa_manifest_url();
514
515 return array(
516 'name' => $site_name,
517 'short_name' => $short_name,
518 'description' => sprintf(
519 /* translators: %s: site name */
520 __( '%s — installed as a desktop app.', 'desktop-mode' ),
521 $site_name
522 ),
523 'start_url' => $start_url,
524 'scope' => $scope,
525 'id' => openstation_portal_url(),
526 'display' => 'standalone',
527 'display_override' => array( 'standalone', 'minimal-ui' ),
528 'orientation' => 'any',
529 // The shell's backstop (`--os-backstop`): the floor under the
530 // wallpaper, and what the splash and the status bar are painted
531 // with. Filter to override per-site without redefining the
532 // whole manifest.
533 'theme_color' => OPENSTATION_PWA_THEME_COLOR,
534 'background_color' => OPENSTATION_PWA_THEME_COLOR,
535 'lang' => get_bloginfo( 'language' ),
536 'dir' => is_rtl() ? 'rtl' : 'ltr',
537 'icons' => openstation_pwa_default_icons(),
538 // Self-reference under `related_applications` so
539 // `navigator.getInstalledRelatedApps()` (Chrome / Edge) returns
540 // a hit when this PWA is installed in the current profile.
541 // `prefer_related_applications: false` keeps the install prompt
542 // pointed at this site itself (not redirected to a related
543 // native app). Without these two fields, a regular browser tab
544 // has no way to detect "already installed in this profile" —
545 // `display-mode: standalone` is only true inside the PWA
546 // window. The detection is what powers the dock-tile click
547 // handler's "X is already installed" toast.
548 'related_applications' => array(
549 array(
550 'platform' => 'webapp',
551 'url' => $manifest_url,
552 'id' => openstation_portal_url(),
553 ),
554 ),
555 'prefer_related_applications' => false,
556 );
557 }
558
559 /**
560 * Resolves the default icon set.
561 *
562 * Priority:
563 * 1. WordPress Site Icon (`Settings → General → Site Icon`) — yields
564 * multiple PNG sizes via `get_site_icon_url()`. Authoritative
565 * when the operator has uploaded a brand mark for their site.
566 * 2. Plugin-bundled icons under `assets/pwa/` — the official
567 * openstation brand mark (the same artwork shown on the
568 * WordPress.org plugin directory listing).
569 *
570 * **The bundled artwork is full-bleed, opaque and square.** Every
571 * platform masks a home-screen tile itself, and it fills any
572 * transparency first: iOS fills with white, then rounds. Artwork that
573 * rounds its own corners therefore installs as a mark floating on a
574 * white square, which is exactly how the pre-full-bleed set installed
575 * on iOS. Do not re-round these files, and do not reintroduce alpha.
576 *
577 * Three purposes go out for the bundled set, because the platforms
578 * genuinely want three different pictures:
579 *
580 * - `any` the tile as drawn.
581 * - `maskable` the same tile at 80%, so Android's adaptive masks
582 * (circle, squircle, teardrop, depending on the
583 * launcher) crop into margin rather than into the
584 * mark.
585 * - `monochrome` the silhouette alone, for Android 13+ themed
586 * icons, which recolour it to the wallpaper palette.
587 *
588 * A Site Icon gets `any` only. The other two purposes describe how a
589 * specific piece of artwork is composed, and we know that about ours
590 * and not about theirs — declaring someone's logo maskable when it is
591 * not is how you get a cropped logo, and pairing their `any` with our
592 * `monochrome` would put the OpenStation mark on their app.
593 *
594 * @return array<int, array<string, string>>
595 */
596 function openstation_pwa_default_icons() {
597 $icons = array();
598
599 $site_icon_id = (int) get_option( 'site_icon' );
600 if ( $site_icon_id > 0 ) {
601 // `get_site_icon_url()` resolves to a registered intermediate
602 // size. List the canonical PWA sizes (192/512) explicitly so
603 // Chrome's installability heuristic finds an entry whose
604 // `sizes` field matches the returned image.
605 foreach ( array( 192, 512 ) as $size ) {
606 $url = get_site_icon_url( $size );
607 if ( is_string( $url ) && '' !== $url ) {
608 $icons[] = array(
609 'src' => $url,
610 'sizes' => $size . 'x' . $size,
611 'type' => 'image/png',
612 'purpose' => 'any',
613 );
614 }
615 }
616 }
617
618 if ( ! empty( $icons ) ) {
619 return $icons;
620 }
621
622 $bundled = array(
623 'any' => array( 128, 180, 192, 256, 512 ),
624 'maskable' => array( 192, 512 ),
625 'monochrome' => array( 192, 512 ),
626 );
627
628 foreach ( $bundled as $purpose => $sizes ) {
629 foreach ( $sizes as $size ) {
630 $icons[] = array(
631 'src' => openstation_pwa_bundled_icon_url( $size, $purpose ),
632 'sizes' => "{$size}x{$size}",
633 'type' => 'image/png',
634 'purpose' => $purpose,
635 );
636 }
637 }
638
639 return $icons;
640 }
641
642 /**
643 * Builds the URL of one bundled icon file.
644 *
645 * The three purposes are three different files, and the filenames say
646 * which: `icon-192.png`, `icon-maskable-192.png`, `icon-mono-192.png`.
647 * Kept in one place so the head tags and the manifest cannot drift
648 * apart on a rename.
649 *
650 * @param int $size Square pixel size.
651 * @param string $purpose One of `any` | `maskable` | `monochrome`.
652 * @return string Absolute URL.
653 */
654 function openstation_pwa_bundled_icon_url( $size, $purpose = 'any' ) {
655 $infix = '';
656 if ( 'maskable' === $purpose ) {
657 $infix = 'maskable-';
658 } elseif ( 'monochrome' === $purpose ) {
659 $infix = 'mono-';
660 }
661
662 return OPENSTATION_URL . "assets/pwa/icon-{$infix}{$size}.png";
663 }
664
665 /**
666 * Serves the service-worker bundle.
667 *
668 * Reads the built `assets/js/sw[.min].js` from disk and streams it back
669 * with the headers a SW needs to be valid:
670 *
671 * - `Content-Type: application/javascript`
672 * - `Service-Worker-Allowed: <home path>` — required for a
673 * home-path-scoped registration when the script itself is served
674 * from `<home>/openstation/`. Without this header the browser
675 * rejects the `register()` call with `SecurityError: The path of
676 * the provided scope is not under the max scope allowed`.
677 * - `Cache-Control: no-cache, must-revalidate` — the browser already
678 * re-checks SW scripts on a 24h cycle, but caching the response
679 * defeats the immediate-update guarantee.
680 *
681 * Falls back to a 503 + log entry when the file is missing (a deploy
682 * that didn't run `npm run build`). Logging gives the operator a
683 * concrete pointer; 503 (vs. 404) tells the browser the SW genuinely
684 * isn't available right now and it should retry later.
685 */
686 function openstation_pwa_serve_service_worker() {
687 $suffix = openstation_asset_suffix();
688 $path = OPENSTATION_DIR . 'assets/js/sw' . $suffix . '.js';
689
690 if ( ! file_exists( $path ) ) {
691 // Guard against hosts that disable error_log() via the
692 // `disable_functions` ini directive.
693 if ( function_exists( 'error_log' ) ) {
694 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
695 }
696 status_header( 503 );
697 header( 'Cache-Control: no-cache, must-revalidate' );
698 return;
699 }
700
701 $body = file_get_contents( $path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
702 if ( false === $body ) {
703 status_header( 503 );
704 return;
705 }
706
707 header( 'Content-Type: application/javascript; charset=utf-8' );
708 // The site's own home path — root everywhere except a subdirectory
709 // network's subsites, whose workers are scoped to the site path so
710 // every site of the network can register its own.
711 header( 'Service-Worker-Allowed: ' . openstation_pwa_sw_scope() );
712 header( 'Cache-Control: no-cache, must-revalidate' );
713 header( 'X-Content-Type-Options: nosniff' );
714
715 // Stamp the SW with a CONTENT HASH so the browser's byte-equality
716 // check on update notices a *real* change.
717 //
718 // Earlier versions stamped with the file's `filemtime()`. Problem:
719 // `npm run build` rewrites `sw.min.js` on every run, bumping its
720 // mtime even when the SW source is byte-identical. Each rebuild
721 // produced a different stamp → different SW response → browser
722 // installed a "new" SW → `controllerchange` fired → the shell of
723 // the day auto-reloaded the page. The user observed a "phantom
724 // reload" 2–3s after every `npm run build`, even when only an
725 // unrelated bundle (e.g. `desktop.min.js`) had changed.
726 //
727 // A content hash collapses identical bodies onto identical stamps
728 // — only a *real* change in `src/pwa/sw.ts` installs a new worker.
729 // (The shell no longer reloads on a new worker at all; see
730 // `src/pwa/sw-register.ts`.) `md5` is plenty for an integrity
731 // stamp here (no security implications) and short enough that the
732 // inline comment stays under one line.
733 $stamp = substr( md5( $body ), 0, 16 );
734 printf( "/* openstation SW build: %s */\n", esc_html( $stamp ) );
735 // Per-request config, injected ahead of the bundle. Deliberately
736 // NOT part of the stamp hash above: the stamp identifies the
737 // *bundle*, while a config change carries itself to the browser's
738 // update check through its own bytes. Don't "fix" the hash to
739 // cover the full response — identical bundles must keep identical
740 // stamps (see the phantom-reload note above).
741 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- JS assembled via wp_json_encode; HTML escaping would corrupt the script.
742 echo openstation_pwa_sw_config_preamble();
743 // `$body` is the SW JavaScript bundle read off disk — escaping
744 // would corrupt the script. Suppress the sniff with the standard
745 // `--` separator (an em-dash silently fails to satisfy phpcs).
746 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- JS bytes from disk.
747 echo $body;
748 }
749
750 /**
751 * The colour the app is painted with outside the page: the manifest's
752 * `theme_color` and `background_color`, and the `theme-color` meta.
753 * The shell's backstop, `--os-backstop` in `variables.css`.
754 */
755 const OPENSTATION_PWA_THEME_COLOR = '#0c0b0f';
756
757 /**
758 * The iOS status-bar styles a home-screen web app may ask for.
759 *
760 * `black`: an opaque bar above the page, white glyphs; the page
761 * starts below it and `env( safe-area-inset-top )` is 0.
762 * `black-translucent`: the page runs under the bar and reads
763 * `env( safe-area-inset-top )` to keep out of it; on current iOS the
764 * bar is drawn as a translucent band over the page's top edge.
765 * `default`: the system's own bar for the appearance in force.
766 */
767 const OPENSTATION_PWA_STATUS_BAR_STYLES = array( 'black', 'black-translucent', 'default' );
768
769 /**
770 * The iOS status-bar style for the installed app.
771 *
772 * Defaults to `black`: the shell is near-black to its edges, so an
773 * opaque black bar above it is one continuous surface and the page
774 * is laid out below it, unambiguously. Under `black-translucent` the
775 * page extends under the bar and iOS paints the bar as a translucent
776 * band over the shell's top edge — a strip that reads as misplaced
777 * chrome rather than as immersion, on a surface that is already the
778 * bar's colour.
779 *
780 * @return string One of `black`, `black-translucent`, `default`.
781 */
782 function openstation_pwa_status_bar_style() {
783 /**
784 * Filters the iOS status-bar style for the installed app.
785 *
786 * @param string $style One of `black`, `black-translucent`, `default`.
787 */
788 $style = apply_filters( 'openstation_pwa_status_bar_style', 'black' );
789 return in_array( $style, OPENSTATION_PWA_STATUS_BAR_STYLES, true ) ? $style : 'black';
790 }
791
792 /**
793 * Emits the `<link rel="manifest">` tag and the matching theme-color
794 * meta into the admin `<head>` — only when openstation is the active
795 * surface for this request (no chromeless iframes, no classic admin).
796 *
797 * Without these tags the browser never discovers the manifest and the
798 * "install" criterion silently fails. Putting them in `<head>` (rather
799 * than via `wp_localize_script`'s inline script tag) is what the
800 * spec requires.
801 */
802 function openstation_pwa_render_head_tags() {
803 if ( ! is_admin() || ! is_user_logged_in() ) {
804 return;
805 }
806 if ( ! openstation_is_shell_request() ) {
807 return;
808 }
809
810 printf(
811 '<link rel="manifest" href="%s">' . "\n",
812 esc_url( openstation_pwa_manifest_url() )
813 );
814 printf(
815 '<meta name="theme-color" content="%s">' . "\n",
816 esc_attr( OPENSTATION_PWA_THEME_COLOR )
817 );
818 // `mobile-web-app-capable` is the cross-browser standard;
819 // `apple-mobile-web-app-capable` is the legacy iOS-only spelling
820 // (still required by older Safari versions). Chromium logs a
821 // deprecation warning if only the apple-prefixed form is present.
822 // We emit both so iOS keeps treating the home-screen shortcut as
823 // a standalone app while Chromium stops the warning.
824 echo '<meta name="mobile-web-app-capable" content="yes">' . "\n";
825 echo '<meta name="apple-mobile-web-app-capable" content="yes">' . "\n";
826 printf(
827 '<meta name="apple-mobile-web-app-status-bar-style" content="%s">' . "\n",
828 esc_attr( openstation_pwa_status_bar_style() )
829 );
830 printf(
831 '<meta name="apple-mobile-web-app-title" content="%s">' . "\n",
832 esc_attr( get_bloginfo( 'name' ) )
833 );
834 printf(
835 '<link rel="apple-touch-icon" sizes="180x180" href="%s">' . "\n",
836 esc_url( openstation_pwa_apple_touch_icon_url() )
837 );
838 }
839 add_action( 'admin_head', 'openstation_pwa_render_head_tags', 1 );
840
841 /**
842 * Resolves the 180×180 tile iOS uses for a home-screen install.
843 *
844 * Core does emit an `apple-touch-icon` from the Site Icon, but only on
845 * `wp_head` and `login_head` — `wp_site_icon()` is not hooked to
846 * `admin_head` at all. So inside wp-admin, which is the only place
847 * anyone installs this app from, there is no tile unless we emit one.
848 * That is why four bundled PNGs could sit in `assets/pwa/` and still
849 * never reach an iPhone.
850 *
851 * 180 is iPhone @3x and the size iOS downscales from for everything
852 * smaller, so one link covers the family.
853 *
854 * @return string Absolute URL.
855 */
856 function openstation_pwa_apple_touch_icon_url() {
857 $site_icon_id = (int) get_option( 'site_icon' );
858 if ( $site_icon_id > 0 ) {
859 $url = get_site_icon_url( 180 );
860 if ( is_string( $url ) && '' !== $url ) {
861 return $url;
862 }
863 }
864
865 return openstation_pwa_bundled_icon_url( 180 );
866 }
867
868 /**
869 * Reads the per-user PWA UI state.
870 *
871 * @param int $user_id Defaults to current user.
872 * @return array{installHintDismissed: bool, notificationsEnabled: bool}
873 */
874 function openstation_pwa_get_user_state( $user_id = 0 ) {
875 if ( 0 === $user_id ) {
876 $user_id = get_current_user_id();
877 }
878 $raw = get_user_meta( $user_id, OPENSTATION_PWA_USER_META, true );
879 if ( ! is_array( $raw ) ) {
880 $raw = array();
881 }
882 return array(
883 'installHintDismissed' => ! empty( $raw['installHintDismissed'] ),
884 'notificationsEnabled' => ! empty( $raw['notificationsEnabled'] ),
885 );
886 }
887
888 /**
889 * Writes the per-user PWA UI state, merging with the existing blob so
890 * partial updates from the JS side don't wipe other keys.
891 *
892 * @param array $patch Partial state to merge.
893 * @param int $user_id Defaults to current user.
894 */
895 function openstation_pwa_update_user_state( array $patch, $user_id = 0 ) {
896 if ( 0 === $user_id ) {
897 $user_id = get_current_user_id();
898 }
899 $current = openstation_pwa_get_user_state( $user_id );
900 $next = array_merge( $current, $patch );
901 update_user_meta( $user_id, OPENSTATION_PWA_USER_META, $next );
902 }
903
904 /**
905 * Registers the `/desktop-mode/v1/pwa-state` REST routes.
906 */
907 function openstation_pwa_register_rest_routes() {
908 register_rest_route(
909 'desktop-mode/v1',
910 '/pwa-state',
911 array(
912 array(
913 'methods' => WP_REST_Server::READABLE,
914 'callback' => 'openstation_pwa_rest_get_state',
915 'permission_callback' => 'openstation_pwa_rest_permission',
916 ),
917 array(
918 'methods' => WP_REST_Server::CREATABLE,
919 'callback' => 'openstation_pwa_rest_post_state',
920 'permission_callback' => 'openstation_pwa_rest_permission',
921 'args' => array(
922 'installHintDismissed' => array(
923 'type' => 'boolean',
924 'required' => false,
925 ),
926 'notificationsEnabled' => array(
927 'type' => 'boolean',
928 'required' => false,
929 ),
930 ),
931 ),
932 )
933 );
934
935 // Future: register POST /pwa-push-subscription here when phase 4
936 // lands. The state route is intentionally orthogonal so the v1
937 // surface stays stable when push arrives.
938 }
939 add_action( 'rest_api_init', 'openstation_pwa_register_rest_routes' );
940
941 /**
942 * REST permission gate — same shape as the session routes: logged in
943 * with OpenStation enabled. See
944 * {@see openstation_rest_require_enabled()}.
945 *
946 * @return true|WP_Error
947 */
948 function openstation_pwa_rest_permission() {
949 return openstation_rest_require_enabled();
950 }
951
952 /**
953 * GET handler — returns the current user's PWA state.
954 */
955 function openstation_pwa_rest_get_state() {
956 return rest_ensure_response( openstation_pwa_get_user_state() );
957 }
958
959 /**
960 * POST handler — merges the supplied keys into the user's state.
961 *
962 * @param WP_REST_Request $request REST request.
963 */
964 function openstation_pwa_rest_post_state( $request ) {
965 $patch = array();
966 if ( null !== $request->get_param( 'installHintDismissed' ) ) {
967 $patch['installHintDismissed'] = (bool) $request->get_param( 'installHintDismissed' );
968 }
969 if ( null !== $request->get_param( 'notificationsEnabled' ) ) {
970 $patch['notificationsEnabled'] = (bool) $request->get_param( 'notificationsEnabled' );
971 }
972 if ( ! empty( $patch ) ) {
973 openstation_pwa_update_user_state( $patch );
974 }
975 return rest_ensure_response( openstation_pwa_get_user_state() );
976 }
977