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