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

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