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

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