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

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