| @@ -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 | * |