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 +301 -54 1.1.41.1.10 View file →
@@ -96,10 +96,11 @@
96 96 /**
97 97 * Builds the extensionless service-worker fallback URL.
98 98 *
99 99 * See {@see OPENSTATION_PWA_SW_QUERY} for why this exists. Kept as a
100 - * root-path URL on purpose: the SW script URL's path determines the
101 - * default maximum scope, and `/` is exactly the scope we register.
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()}).
102 103 *
103 104 * @return string
104 105 */
105 106 function openstation_pwa_sw_fallback_url() {
@@ -106,8 +107,26 @@
106 107 return add_query_arg( OPENSTATION_PWA_SW_QUERY, '1', home_url( '/' ) );
107 108 }
108 109
109 110 /**
111 + * The service worker's registration scope: the SITE's home path.
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 +/**
110 129 * Resolves whether openstation should usurp another root-scope SW.
111 130 *
112 131 * When `false` (default), `src/pwa/sw-register.ts` bails on registration
113 132 * if another root-scope service worker is already on the origin — polite
@@ -144,21 +163,15 @@
144 163 * (shell or chromeless iframe) is answered locally for every later
145 164 * window, revalidation round-trips included. See `src/pwa/sw-policy.ts`
146 165 * for the exact classification rules.
147 166 *
148 - * Off by default while the feature proves itself: the failure mode of
149 - * cache-first (an asset edited without a `ver` bump staying pinned) is
150 - * silent, so users opt in deliberately — via **OpenStation Preferences →
151 - * Features → Beta features** (`adminAssetCacheEnabled`, per user), or
152 - * site-wide via the filter below.
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.
153 170 *
154 - * Per-user works even though a service worker is origin-wide because
155 - * the answer never travels in the worker's own bytes. It is resolved
156 - * per request here and pushed to the running worker as an `os-sw-config`
157 - * message when the shell boots, and again whenever the preference
158 - * changes — see {@see openstation_pwa_sw_config_preamble()} for why
159 - * baking it into the script was abandoned. The worker starts with the
160 - * cache off, so until that message lands it does less, never more.
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.
161 174 *
162 175 * @return bool
163 176 */
164 177 function openstation_pwa_admin_asset_cache_enabled() {
@@ -169,16 +182,15 @@
169 182 * Filters whether the SW's shared admin-asset cache is enabled.
170 183 *
171 184 * Return `true` to let the service worker cache versioned admin
172 185 * static assets in a shared, origin-wide bucket, or `false` to
173 - * veto it site-wide regardless of per-user opt-ins. The value
186 + * veto it regardless of the Extended option. The value
174 187 * reaches the worker as an `os-sw-config` message on the next shell
175 188 * boot, so a change takes effect without altering the served script
176 189 * — no SW update, no URL change, no re-registration.
177 190 *
178 - * @param bool $enabled Defaults to the requesting user's
179 - * `adminAssetCacheEnabled` OpenStation
180 - * preference (`false` until they opt in).
191 + * @param bool $enabled The site-wide `admin_asset_cache` Extended
192 + * option, enabled by default.
181 193 */
182 194 return (bool) apply_filters( 'openstation_pwa_admin_asset_cache', $enabled );
183 195 }
184 196
@@ -216,16 +228,116 @@
216 228 * The shell pushes both flags to the running worker at boot instead
217 229 * (`os-sw-config`), and the toggle pushes changes as they happen.
218 230 * The worker starts with both off, so until that message lands it
219 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.
220 251 */
221 252 $config = array(
222 - 'pluginUrl' => OPENSTATION_URL,
253 + 'pluginUrl' => OPENSTATION_URL,
254 + 'version' => OPENSTATION_VERSION,
255 + 'shellBuild' => openstation_shell_build_stamp(),
223 256 );
224 257 return sprintf( "self.__OS_SW_CONFIG = %s;\n", wp_json_encode( $config ) );
225 258 }
226 259
227 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 +/**
228 340 * Detects which PWA endpoint the current request is targeting, if any.
229 341 *
230 342 * Mirrors `openstation_is_portal_request()`'s strategy: read the
231 343 * unparsed REQUEST_URI rather than relying on rewrite-rule resolution.
@@ -380,9 +492,13 @@
380 492 //
381 493 // `id` is held at the previous `/openstation/` value so existing
382 494 // installs aren't treated as a different app and reset by Chrome
383 495 // after this change ships.
384 - $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();
385 501 $scope = admin_url( '/', 'relative' );
386 502 if ( '' === $scope ) {
387 503 $scope = '/wp-admin/';
388 504 }
@@ -402,12 +518,14 @@
402 518 'id' => openstation_portal_url(),
403 519 'display' => 'standalone',
404 520 'display_override' => array( 'standalone', 'minimal-ui' ),
405 521 'orientation' => 'any',
406 - // Match the shell's default surface colour. Filter to override
407 - // per-site without redefining the whole manifest.
408 - 'theme_color' => '#1d2327',
409 - 'background_color' => '#1d2327',
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,
410 528 'lang' => get_bloginfo( 'language' ),
411 529 'dir' => is_rtl() ? 'rtl' : 'ltr',
412 530 'icons' => openstation_pwa_default_icons(),
413 531 // Self-reference under `related_applications` so
@@ -439,17 +557,34 @@
439 557 * multiple PNG sizes via `get_site_icon_url()`. Authoritative
440 558 * when the operator has uploaded a brand mark for their site.
441 559 * 2. Plugin-bundled icons under `assets/pwa/` — the official
442 560 * openstation brand mark (the same artwork shown on the
443 - * WordPress.org plugin directory listing). Sizes 128 / 192 /
444 - * 256 / 512 cover everything from notification badges to splash
445 - * screens.
561 + * WordPress.org plugin directory listing).
446 562 *
447 - * Purpose is `'any'` rather than `'any maskable'` — the brand icon
448 - * has rounded corners + transparent padding that Android's adaptive
449 - * mask would crop into. Plugins shipping a full-bleed maskable
450 - * variant should replace the array via `openstation_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.
451 569 *
570 + * Three purposes go out for the bundled set, because the platforms
571 + * genuinely want three different pictures:
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 + *
452 587 * @return array<int, array<string, string>>
453 588 */
454 589 function openstation_pwa_default_icons() {
455 590 $icons = array();
@@ -472,15 +607,25 @@
472 607 }
473 608 }
474 609 }
475 610
476 - if ( empty( $icons ) ) {
477 - 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 ) {
478 623 $icons[] = array(
479 - 'src' => OPENSTATION_URL . "assets/pwa/icon-{$size}.png",
624 + 'src' => openstation_pwa_bundled_icon_url( $size, $purpose ),
480 625 'sizes' => "{$size}x{$size}",
481 626 'type' => 'image/png',
482 - 'purpose' => 'any',
627 + 'purpose' => $purpose,
483 628 );
484 629 }
485 630 }
486 631
@@ -487,8 +632,31 @@
487 632 return $icons;
488 633 }
489 634
490 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 +/**
491 659 * Serves the service-worker bundle.
492 660 *
493 661 * Reads the built `assets/js/sw[.min].js` from disk and streams it back
494 662 * with the headers a SW needs to be valid:
@@ -493,13 +661,13 @@
493 661 * Reads the built `assets/js/sw[.min].js` from disk and streams it back
494 662 * with the headers a SW needs to be valid:
495 663 *
496 664 * - `Content-Type: application/javascript`
497 - * - `Service-Worker-Allowed: /` — required for `/`-scoped registration
498 - * when the script itself is served from `/openstation/`. Without
499 - * this header the browser rejects the `register()` call with
500 - * `SecurityError: The path of the provided scope ('/') is not
501 - * 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`.
502 670 * - `Cache-Control: no-cache, must-revalidate` — the browser already
503 671 * re-checks SW scripts on a 24h cycle, but caching the response
504 672 * defeats the immediate-update guarantee.
505 673 *
@@ -529,9 +697,12 @@
529 697 return;
530 698 }
531 699
532 700 header( 'Content-Type: application/javascript; charset=utf-8' );
533 - 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() );
534 705 header( 'Cache-Control: no-cache, must-revalidate' );
535 706 header( 'X-Content-Type-Options: nosniff' );
536 707
537 708 // Stamp the SW with a CONTENT HASH so the browser's byte-equality
@@ -540,17 +711,17 @@
540 711 // Earlier versions stamped with the file's `filemtime()`. Problem:
541 712 // `npm run build` rewrites `sw.min.js` on every run, bumping its
542 713 // mtime even when the SW source is byte-identical. Each rebuild
543 714 // produced a different stamp → different SW response → browser
544 - // installed a "new" SW → `controllerchange` fired → the
545 - // `bindControllerChangeReload` hook in `src/pwa/sw-register.ts`
546 - // auto-reloaded the page. The user observed a "phantom reload"
547 - // 2–3s after every `npm run build`, even when only an unrelated
548 - // 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.
549 719 //
550 720 // A content hash collapses identical bodies onto identical stamps
551 - // — only a *real* change in `src/pwa/sw.ts` triggers the SW
552 - // 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
553 724 // stamp here (no security implications) and short enough that the
554 725 // inline comment stays under one line.
555 726 $stamp = substr( md5( $body ), 0, 16 );
556 727 printf( "/* openstation SW build: %s */\n", esc_html( $stamp ) );
@@ -569,8 +740,50 @@
569 740 echo $body;
570 741 }
571 742
572 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 +/**
573 786 * Emits the `<link rel="manifest">` tag and the matching theme-color
574 787 * meta into the admin `<head>` — only when openstation is the active
575 788 * surface for this request (no chromeless iframes, no classic admin).
576 789 *
@@ -582,20 +795,20 @@
582 795 function openstation_pwa_render_head_tags() {
583 796 if ( ! is_admin() || ! is_user_logged_in() ) {
584 797 return;
585 798 }
586 - if ( openstation_is_chromeless_request() ) {
799 + if ( ! openstation_is_shell_request() ) {
587 800 return;
588 801 }
589 - if ( ! openstation_is_enabled() || openstation_is_classic_request() ) {
590 - return;
591 - }
592 802
593 803 printf(
594 804 '<link rel="manifest" href="%s">' . "\n",
595 805 esc_url( openstation_pwa_manifest_url() )
596 806 );
597 - 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 + );
598 811 // `mobile-web-app-capable` is the cross-browser standard;
599 812 // `apple-mobile-web-app-capable` is the legacy iOS-only spelling
600 813 // (still required by older Safari versions). Chromium logs a
601 814 // deprecation warning if only the apple-prefixed form is present.
@@ -602,15 +815,49 @@
602 815 // We emit both so iOS keeps treating the home-screen shortcut as
603 816 // a standalone app while Chromium stops the warning.
604 817 echo '<meta name="mobile-web-app-capable" content="yes">' . "\n";
605 818 echo '<meta name="apple-mobile-web-app-capable" content="yes">' . "\n";
606 - echo '<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">' . "\n";
607 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(
608 824 '<meta name="apple-mobile-web-app-title" content="%s">' . "\n",
609 825 esc_attr( get_bloginfo( 'name' ) )
610 826 );
827 + printf(
828 + '<link rel="apple-touch-icon" sizes="180x180" href="%s">' . "\n",
829 + esc_url( openstation_pwa_apple_touch_icon_url() )
830 + );
611 831 }
612 832 add_action( 'admin_head', 'openstation_pwa_render_head_tags', 1 );
833 +
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 +}
613 860
614 861 /**
615 862 * Reads the per-user PWA UI state.
616 863 *