PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 0.9.1
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v0.9.1
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 0.9.1, at includes/pwa.php

610 lines 20.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Desktop Mode — Progressive Web App support.
4 *
5 * Lets users install the WordPress site as a desktop / mobile app from
6 * the desktop-mode shell. Three concerns live here:
7 *
8 * 1. Web app manifest at `/desktop-mode/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 * `desktop_mode_pwa_manifest` filter lets plugins mutate any
13 * field before encoding.
14 *
15 * 2. Service worker at `/desktop-mode/sw.js`, served with the
16 * explicit `Service-Worker-Allowed: /` header so a single SW can
17 * scope across `/desktop-mode/` 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 WPDesktopMode
31 * @since 0.8.0
32 */
33
34 defined( 'ABSPATH' ) || exit;
35
36 /**
37 * URL fragment for the manifest endpoint, joined onto the portal path.
38 *
39 * Kept as a constant so the JS-side script localisation and the
40 * `parse_request` matcher cannot drift apart.
41 *
42 * @since 0.8.0
43 */
44 const DESKTOP_MODE_PWA_MANIFEST_FRAGMENT = 'manifest.webmanifest';
45
46 /**
47 * URL fragment for the service worker.
48 *
49 * @since 0.8.0
50 */
51 const DESKTOP_MODE_PWA_SW_FRAGMENT = 'sw.js';
52
53 /**
54 * User-meta key — JSON blob persisting per-user PWA UI state.
55 *
56 * Today: `installHintDismissed` (bool), `notificationsEnabled` (bool).
57 * Future: `pushSubscription` (object) when phase 4 lands.
58 *
59 * @since 0.8.0
60 */
61 const DESKTOP_MODE_PWA_USER_META = 'desktop_mode_pwa_state';
62
63 /**
64 * Builds the absolute manifest URL.
65 *
66 * @since 0.8.0
67 *
68 * @return string
69 */
70 function desktop_mode_pwa_manifest_url() {
71 return desktop_mode_portal_url() . DESKTOP_MODE_PWA_MANIFEST_FRAGMENT;
72 }
73
74 /**
75 * Builds the absolute service-worker URL.
76 *
77 * @since 0.8.0
78 *
79 * @return string
80 */
81 function desktop_mode_pwa_sw_url() {
82 return desktop_mode_portal_url() . DESKTOP_MODE_PWA_SW_FRAGMENT;
83 }
84
85 /**
86 * Resolves whether desktop-mode should usurp another root-scope SW.
87 *
88 * When `false` (default), `src/pwa/sw-register.ts` bails on registration
89 * if another root-scope service worker is already on the origin — polite
90 * behaviour for sites that intentionally use a different PWA plugin. When
91 * `true`, our registration replaces the existing SW.
92 *
93 * Operators flip this to recover installability on sites where a foreign
94 * SW (Super PWA, Jetpack Boost, etc.) is shadowing the desktop-mode SW
95 * and causing the "Install <site> as an app" tile to surface the
96 * "another app is handling installs" toast.
97 *
98 * @since 0.8.6
99 *
100 * @return bool
101 */
102 function desktop_mode_pwa_force_replace_sw() {
103 /**
104 * Filters whether desktop-mode replaces an existing root-scope SW.
105 *
106 * Return `true` to take over from a foreign PWA plugin's service
107 * worker so desktop-mode's "Install as app" affordance works on
108 * sites where another plugin's SW is already active.
109 *
110 * @since 0.8.6
111 *
112 * @param bool $force_replace Defaults to `false` (yield to existing SWs).
113 */
114 return (bool) apply_filters( 'desktop_mode_pwa_force_replace_sw', false );
115 }
116
117 /**
118 * Detects which PWA endpoint the current request is targeting, if any.
119 *
120 * Mirrors `desktop_mode_is_portal_request()`'s strategy: read the
121 * unparsed REQUEST_URI rather than relying on rewrite-rule resolution.
122 *
123 * @since 0.8.0
124 *
125 * @return string Empty string when not a PWA endpoint, otherwise one
126 * of `'manifest'` | `'sw'`.
127 */
128 function desktop_mode_pwa_endpoint_kind() {
129 $uri = isset( $_SERVER['REQUEST_URI'] ) ? wp_unslash( $_SERVER['REQUEST_URI'] ) : '';
130 if ( ! is_string( $uri ) || '' === $uri ) {
131 return '';
132 }
133 $path = (string) wp_parse_url( $uri, PHP_URL_PATH );
134 if ( '' === $path ) {
135 return '';
136 }
137 $portal = '/' . trim( DESKTOP_MODE_PORTAL_PATH, '/' ) . '/';
138 if ( $path === $portal . DESKTOP_MODE_PWA_MANIFEST_FRAGMENT ) {
139 return 'manifest';
140 }
141 if ( $path === $portal . DESKTOP_MODE_PWA_SW_FRAGMENT ) {
142 return 'sw';
143 }
144 return '';
145 }
146
147 /**
148 * Intercepts the manifest and SW endpoints, emitting the response body.
149 *
150 * Hooks at the same `parse_request` priority as the portal handler so
151 * we beat 404 logic but the request environment (auth state, options
152 * cache, etc.) is fully bootstrapped.
153 *
154 * Both endpoints are intentionally **public** (no `is_user_logged_in`
155 * guard). The manifest is loaded by the browser BEFORE login when a
156 * user revisits the install URL; the SW is fetched by the browser
157 * with no cookies on update checks. Both reveal only data already
158 * surfaced by the front-end (site name, blog icon, plugin version).
159 *
160 * @since 0.8.0
161 *
162 * @param WP $wp Current WordPress environment instance (unused).
163 */
164 function desktop_mode_pwa_handle_request( $wp ) {
165 unset( $wp );
166
167 $kind = desktop_mode_pwa_endpoint_kind();
168 if ( '' === $kind ) {
169 return;
170 }
171
172 if ( 'manifest' === $kind ) {
173 desktop_mode_pwa_serve_manifest();
174 exit;
175 }
176
177 if ( 'sw' === $kind ) {
178 desktop_mode_pwa_serve_service_worker();
179 exit;
180 }
181 }
182 add_action( 'parse_request', 'desktop_mode_pwa_handle_request' );
183
184 /**
185 * Builds the manifest array, applies the `desktop_mode_pwa_manifest`
186 * filter, encodes as JSON and prints it.
187 *
188 * @since 0.8.0
189 */
190 function desktop_mode_pwa_serve_manifest() {
191 $manifest = desktop_mode_pwa_build_manifest();
192
193 /**
194 * Filters the web-app manifest payload before encoding.
195 *
196 * Common edits: replace the icon list with site-specific artwork,
197 * add `shortcuts` so the OS-level app menu offers
198 * deep-link entries, change `display` to `'fullscreen'`. Returning
199 * a non-array silently disables the manifest — no PHP warning, but
200 * the browser will fail the install criterion.
201 *
202 * @since 0.8.0
203 *
204 * @param array $manifest Manifest associative array.
205 */
206 $manifest = apply_filters( 'desktop_mode_pwa_manifest', $manifest );
207
208 if ( ! is_array( $manifest ) ) {
209 status_header( 500 );
210 return;
211 }
212
213 header( 'Content-Type: application/manifest+json; charset=utf-8' );
214 // 5-minute browser cache so a site-icon swap propagates quickly,
215 // but the network isn't hit on every shell load.
216 header( 'Cache-Control: public, max-age=300' );
217 echo wp_json_encode( $manifest, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE );
218 }
219
220 /**
221 * Assembles the default manifest fields.
222 *
223 * @since 0.8.0
224 *
225 * @return array
226 */
227 function desktop_mode_pwa_build_manifest() {
228 $site_name = get_bloginfo( 'name' );
229 if ( '' === $site_name ) {
230 $site_name = 'WordPress';
231 }
232 $short_name = wp_html_excerpt( $site_name, 12, '' );
233 if ( '' === $short_name ) {
234 $short_name = $site_name;
235 }
236
237 // `start_url` is the actual landing URL after the `/desktop-mode/`
238 // portal redirect — pointing the PWA directly at it lets us narrow
239 // `scope` to `/wp-admin/` without breaking the launch path. The
240 // portal redirect still exists for typed / bookmarked
241 // `/desktop-mode/` visits in regular browser tabs.
242 //
243 // `scope` is `/wp-admin/`, not `/`. The wider `/` scope had two
244 // failure modes that this fixes:
245 //
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.
256 //
257 // `id` is held at the previous `/desktop-mode/` value so existing
258 // installs aren't treated as a different app and reset by Chrome
259 // after this change ships.
260 $start_url = admin_url( 'index.php?desktop_mode_portal=1' );
261 $scope = admin_url( '/', 'relative' );
262 if ( '' === $scope ) {
263 $scope = '/wp-admin/';
264 }
265
266 $manifest_url = desktop_mode_pwa_manifest_url();
267
268 return array(
269 'name' => $site_name,
270 'short_name' => $short_name,
271 'description' => sprintf(
272 /* translators: %s: site name */
273 __( '%s — installed as a desktop app.', 'desktop-mode' ),
274 $site_name
275 ),
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(),
289 // Self-reference under `related_applications` so
290 // `navigator.getInstalledRelatedApps()` (Chrome / Edge) returns
291 // a hit when this PWA is installed in the current profile.
292 // `prefer_related_applications: false` keeps the install prompt
293 // pointed at this site itself (not redirected to a related
294 // native app). Without these two fields, a regular browser tab
295 // has no way to detect "already installed in this profile" —
296 // `display-mode: standalone` is only true inside the PWA
297 // window. The detection is what powers the dock-tile click
298 // handler's "X is already installed" toast.
299 'related_applications' => array(
300 array(
301 'platform' => 'webapp',
302 'url' => $manifest_url,
303 'id' => desktop_mode_portal_url(),
304 ),
305 ),
306 'prefer_related_applications' => false,
307 );
308 }
309
310 /**
311 * Resolves the default icon set.
312 *
313 * Priority:
314 * 1. WordPress Site Icon (`Settings → General → Site Icon`) — yields
315 * multiple PNG sizes via `get_site_icon_url()`. Authoritative
316 * when the operator has uploaded a brand mark for their site.
317 * 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.
322 *
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`.
327 *
328 * @since 0.8.0
329 *
330 * @return array<int, array<string, string>>
331 */
332 function desktop_mode_pwa_default_icons() {
333 $icons = array();
334
335 $site_icon_id = (int) get_option( 'site_icon' );
336 if ( $site_icon_id > 0 ) {
337 // `get_site_icon_url()` resolves to a registered intermediate
338 // size. List the canonical PWA sizes (192/512) explicitly so
339 // Chrome's installability heuristic finds an entry whose
340 // `sizes` field matches the returned image.
341 foreach ( array( 192, 512 ) as $size ) {
342 $url = get_site_icon_url( $size );
343 if ( is_string( $url ) && '' !== $url ) {
344 $icons[] = array(
345 'src' => $url,
346 'sizes' => $size . 'x' . $size,
347 'type' => 'image/png',
348 'purpose' => 'any',
349 );
350 }
351 }
352 }
353
354 if ( empty( $icons ) ) {
355 foreach ( array( 128, 192, 256, 512 ) as $size ) {
356 $icons[] = array(
357 'src' => DESKTOP_MODE_URL . "assets/pwa/icon-{$size}.png",
358 'sizes' => "{$size}x{$size}",
359 'type' => 'image/png',
360 'purpose' => 'any',
361 );
362 }
363 }
364
365 return $icons;
366 }
367
368 /**
369 * Serves the service-worker bundle.
370 *
371 * Reads the built `assets/js/sw[.min].js` from disk and streams it back
372 * with the headers a SW needs to be valid:
373 *
374 * - `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`.
380 * - `Cache-Control: no-cache, must-revalidate` — the browser already
381 * re-checks SW scripts on a 24h cycle, but caching the response
382 * defeats the immediate-update guarantee.
383 *
384 * Falls back to a 503 + log entry when the file is missing (a deploy
385 * that didn't run `npm run build`). Logging gives the operator a
386 * concrete pointer; 503 (vs. 404) tells the browser the SW genuinely
387 * isn't available right now and it should retry later.
388 *
389 * @since 0.8.0
390 */
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';
394
395 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.
398 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
400 }
401 status_header( 503 );
402 header( 'Cache-Control: no-cache, must-revalidate' );
403 return;
404 }
405
406 $body = file_get_contents( $path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
407 if ( false === $body ) {
408 status_header( 503 );
409 return;
410 }
411
412 header( 'Content-Type: application/javascript; charset=utf-8' );
413 header( 'Service-Worker-Allowed: /' );
414 header( 'Cache-Control: no-cache, must-revalidate' );
415 header( 'X-Content-Type-Options: nosniff' );
416
417 // Stamp the SW with a CONTENT HASH so the browser's byte-equality
418 // check on update notices a *real* change.
419 //
420 // Earlier versions stamped with the file's `filemtime()`. Problem:
421 // `npm run build` rewrites `sw.min.js` on every run, bumping its
422 // mtime even when the SW source is byte-identical. Each rebuild
423 // 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.
429 //
430 // 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
433 // stamp here (no security implications) and short enough that the
434 // inline comment stays under one line.
435 $stamp = substr( md5( $body ), 0, 16 );
436 printf( "/* desktop-mode SW build: %s */\n", esc_html( $stamp ) );
437 // `$body` is the SW JavaScript bundle read off disk — escaping
438 // would corrupt the script. Suppress the sniff with the standard
439 // `--` separator (an em-dash silently fails to satisfy phpcs).
440 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- JS bytes from disk.
441 echo $body;
442 }
443
444 /**
445 * Emits the `<link rel="manifest">` tag and the matching theme-color
446 * meta into the admin `<head>` — only when desktop-mode is the active
447 * surface for this request (no chromeless iframes, no classic admin).
448 *
449 * Without these tags the browser never discovers the manifest and the
450 * "install" criterion silently fails. Putting them in `<head>` (rather
451 * than via `wp_localize_script`'s inline script tag) is what the
452 * spec requires.
453 *
454 * @since 0.8.0
455 */
456 function desktop_mode_pwa_render_head_tags() {
457 if ( ! is_admin() || ! is_user_logged_in() ) {
458 return;
459 }
460 if ( desktop_mode_is_chromeless_request() ) {
461 return;
462 }
463 if ( ! desktop_mode_is_enabled() || desktop_mode_is_classic_request() ) {
464 return;
465 }
466
467 printf(
468 '<link rel="manifest" href="%s">' . "\n",
469 esc_url( desktop_mode_pwa_manifest_url() )
470 );
471 echo '<meta name="theme-color" content="#1d2327">' . "\n";
472 // `mobile-web-app-capable` is the cross-browser standard;
473 // `apple-mobile-web-app-capable` is the legacy iOS-only spelling
474 // (still required by older Safari versions). Chromium logs a
475 // deprecation warning if only the apple-prefixed form is present.
476 // We emit both so iOS keeps treating the home-screen shortcut as
477 // a standalone app while Chromium stops the warning.
478 echo '<meta name="mobile-web-app-capable" content="yes">' . "\n";
479 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 printf(
482 '<meta name="apple-mobile-web-app-title" content="%s">' . "\n",
483 esc_attr( get_bloginfo( 'name' ) )
484 );
485 }
486 add_action( 'admin_head', 'desktop_mode_pwa_render_head_tags', 1 );
487
488 /**
489 * Reads the per-user PWA UI state.
490 *
491 * @since 0.8.0
492 *
493 * @param int $user_id Defaults to current user.
494 * @return array{installHintDismissed: bool, notificationsEnabled: bool}
495 */
496 function desktop_mode_pwa_get_user_state( $user_id = 0 ) {
497 if ( 0 === $user_id ) {
498 $user_id = get_current_user_id();
499 }
500 $raw = get_user_meta( $user_id, DESKTOP_MODE_PWA_USER_META, true );
501 if ( ! is_array( $raw ) ) {
502 $raw = array();
503 }
504 return array(
505 'installHintDismissed' => ! empty( $raw['installHintDismissed'] ),
506 'notificationsEnabled' => ! empty( $raw['notificationsEnabled'] ),
507 );
508 }
509
510 /**
511 * Writes the per-user PWA UI state, merging with the existing blob so
512 * partial updates from the JS side don't wipe other keys.
513 *
514 * @since 0.8.0
515 *
516 * @param array $patch Partial state to merge.
517 * @param int $user_id Defaults to current user.
518 */
519 function desktop_mode_pwa_update_user_state( array $patch, $user_id = 0 ) {
520 if ( 0 === $user_id ) {
521 $user_id = get_current_user_id();
522 }
523 $current = desktop_mode_pwa_get_user_state( $user_id );
524 $next = array_merge( $current, $patch );
525 update_user_meta( $user_id, DESKTOP_MODE_PWA_USER_META, $next );
526 }
527
528 /**
529 * Registers the `/desktop-mode/v1/pwa-state` REST routes.
530 *
531 * @since 0.8.0
532 */
533 function desktop_mode_pwa_register_rest_routes() {
534 register_rest_route(
535 'desktop-mode/v1',
536 '/pwa-state',
537 array(
538 array(
539 'methods' => WP_REST_Server::READABLE,
540 'callback' => 'desktop_mode_pwa_rest_get_state',
541 'permission_callback' => 'desktop_mode_pwa_rest_permission',
542 ),
543 array(
544 'methods' => WP_REST_Server::CREATABLE,
545 'callback' => 'desktop_mode_pwa_rest_post_state',
546 'permission_callback' => 'desktop_mode_pwa_rest_permission',
547 'args' => array(
548 'installHintDismissed' => array(
549 'type' => 'boolean',
550 'required' => false,
551 ),
552 'notificationsEnabled' => array(
553 'type' => 'boolean',
554 'required' => false,
555 ),
556 ),
557 ),
558 )
559 );
560
561 // Future: register POST /pwa-push-subscription here when phase 4
562 // lands. The state route is intentionally orthogonal so the v1
563 // surface stays stable when push arrives.
564 }
565 add_action( 'rest_api_init', 'desktop_mode_pwa_register_rest_routes' );
566
567 /**
568 * REST permission gate — same shape as the session routes: logged in
569 * with desktop mode enabled. See
570 * {@see desktop_mode_rest_require_enabled()}.
571 *
572 * @since 0.8.0
573 * @since 0.8.10 Hardened to require desktop mode enabled (was `read`).
574 *
575 * @return true|WP_Error
576 */
577 function desktop_mode_pwa_rest_permission() {
578 return desktop_mode_rest_require_enabled();
579 }
580
581 /**
582 * GET handler — returns the current user's PWA state.
583 *
584 * @since 0.8.0
585 */
586 function desktop_mode_pwa_rest_get_state() {
587 return rest_ensure_response( desktop_mode_pwa_get_user_state() );
588 }
589
590 /**
591 * POST handler — merges the supplied keys into the user's state.
592 *
593 * @since 0.8.0
594 *
595 * @param WP_REST_Request $request REST request.
596 */
597 function desktop_mode_pwa_rest_post_state( $request ) {
598 $patch = array();
599 if ( null !== $request->get_param( 'installHintDismissed' ) ) {
600 $patch['installHintDismissed'] = (bool) $request->get_param( 'installHintDismissed' );
601 }
602 if ( null !== $request->get_param( 'notificationsEnabled' ) ) {
603 $patch['notificationsEnabled'] = (bool) $request->get_param( 'notificationsEnabled' );
604 }
605 if ( ! empty( $patch ) ) {
606 desktop_mode_pwa_update_user_state( $patch );
607 }
608 return rest_ensure_response( desktop_mode_pwa_get_user_state() );
609 }
610