| @@ -1,21 +1,21 @@ | ||
| 1 | 1 | <?php |
| 2 | 2 | /** |
| 3 | - * Desktop Mode — Progressive Web App support. | |
| 3 | + * OpenStation — Progressive Web App support. | |
| 4 | 4 | * |
| 5 | 5 | * Lets users install the WordPress site as a desktop / mobile app from |
| 6 | - * the desktop-mode shell. Three concerns live here: | |
| 6 | + * the openstation shell. Three concerns live here: | |
| 7 | 7 | * |
| 8 | - * 1. Web app manifest at `/desktop-mode/manifest.webmanifest` — | |
| 8 | + * 1. Web app manifest at `/openstation/manifest.webmanifest` — | |
| 9 | 9 | * served via `parse_request` like the portal URL, no rewrite-rule |
| 10 | 10 | * registration. Site name, theme color, and icons assembled from |
| 11 | 11 | * the WordPress Site Icon (when set) with a wp-logo fallback. The |
| 12 | - * `desktop_mode_pwa_manifest` filter lets plugins mutate any | |
| 12 | + * `openstation_pwa_manifest` filter lets plugins mutate any | |
| 13 | 13 | * field before encoding. |
| 14 | 14 | * |
| 15 | - * 2. Service worker at `/desktop-mode/sw.js`, served with the | |
| 15 | + * 2. Service worker at `/openstation/sw.js`, served with the | |
| 16 | 16 | * explicit `Service-Worker-Allowed: /` header so a single SW can |
| 17 | - * scope across `/desktop-mode/` AND `/wp-admin/` (their common | |
| 17 | + * scope across `/openstation/` AND `/wp-admin/` (their common | |
| 18 | 18 | * ancestor is `/`). The plugin lives at |
| 19 | 19 | * `/wp-content/plugins/desktop-mode/`, which is NOT a parent of |
| 20 | 20 | * `/wp-admin/`, so wp-content-served SWs cannot reach admin pages. |
| 21 | 21 | * PHP delivery sidesteps that constraint cleanly. |
| @@ -26,10 +26,9 @@ | ||
| 26 | 26 | * - (future) `POST /desktop-mode/v1/push-subscription` — Web |
| 27 | 27 | * Push subscription storage. Stub left here in a comment as |
| 28 | 28 | * a hint for the v2 push PR. |
| 29 | 29 | * |
| 30 | - * @package WPDesktopMode | |
| 31 | - * @since 0.8.0 | |
| 30 | + * @package OpenStation | |
| 32 | 31 | */ |
| 33 | 32 | |
| 34 | 33 | defined( 'ABSPATH' ) || exit; |
| 35 | 34 | |
| @@ -37,19 +36,31 @@ | ||
| 37 | 36 | * URL fragment for the manifest endpoint, joined onto the portal path. |
| 38 | 37 | * |
| 39 | 38 | * Kept as a constant so the JS-side script localisation and the |
| 40 | 39 | * `parse_request` matcher cannot drift apart. |
| 41 | - * | |
| 42 | - * @since 0.8.0 | |
| 43 | 40 | */ |
| 44 | -const DESKTOP_MODE_PWA_MANIFEST_FRAGMENT = 'manifest.webmanifest'; | |
| 41 | +const OPENSTATION_PWA_MANIFEST_FRAGMENT = 'manifest.webmanifest'; | |
| 45 | 42 | |
| 46 | 43 | /** |
| 47 | 44 | * URL fragment for the service worker. |
| 45 | + */ | |
| 46 | +const OPENSTATION_PWA_SW_FRAGMENT = 'sw.js'; | |
| 47 | + | |
| 48 | +/** | |
| 49 | + * Query var for the extensionless service-worker fallback endpoint. | |
| 48 | 50 | * |
| 49 | - * @since 0.8.0 | |
| 51 | + * Some hosts' nginx (WordPress.com among them) short-circuits paths | |
| 52 | + * with a static-file extension straight to the filesystem: a virtual | |
| 53 | + * route like `/openstation/sw.js` 404s at the web server and never | |
| 54 | + * reaches WordPress, so the pretty SW endpoint is unservable there — | |
| 55 | + * while the extensionless manifest route works fine. The fallback | |
| 56 | + * serves the same bytes at `/?openstation_sw=1`: no extension, so the | |
| 57 | + * request always reaches WordPress, and the script URL's *path* is | |
| 58 | + * `/`, which grants root scope without the `Service-Worker-Allowed` | |
| 59 | + * header even mattering. `src/pwa/sw-register.ts` retries with this | |
| 60 | + * URL when registering the pretty URL fails. | |
| 50 | 61 | */ |
| 51 | -const DESKTOP_MODE_PWA_SW_FRAGMENT = 'sw.js'; | |
| 62 | +const OPENSTATION_PWA_SW_QUERY = 'openstation_sw'; | |
| 52 | 63 | |
| 53 | 64 | /** |
| 54 | 65 | * User-meta key — JSON blob persisting per-user PWA UI state. |
| 55 | 66 | * |
| @@ -55,37 +66,69 @@ | ||
| 55 | 66 | * |
| 56 | 67 | * Today: `installHintDismissed` (bool), `notificationsEnabled` (bool). |
| 57 | 68 | * Future: `pushSubscription` (object) when phase 4 lands. |
| 58 | 69 | * |
| 59 | - * @since 0.8.0 | |
| 70 | + * The VALUE keeps its pre-rebrand spelling on purpose: it is a | |
| 71 | + * persisted or externally-visible identifier, so renaming it would | |
| 72 | + * orphan data already written by live installs (or break a live | |
| 73 | + * URL). The mismatch between this constant's name and its value is | |
| 74 | + * deliberate — it is NOT a half-finished rename. | |
| 60 | 75 | */ |
| 61 | -const DESKTOP_MODE_PWA_USER_META = 'desktop_mode_pwa_state'; | |
| 76 | +const OPENSTATION_PWA_USER_META = 'desktop_mode_pwa_state'; | |
| 62 | 77 | |
| 63 | 78 | /** |
| 64 | 79 | * Builds the absolute manifest URL. |
| 65 | 80 | * |
| 66 | - * @since 0.8.0 | |
| 81 | + * @return string | |
| 82 | + */ | |
| 83 | +function openstation_pwa_manifest_url() { | |
| 84 | + return openstation_portal_url() . OPENSTATION_PWA_MANIFEST_FRAGMENT; | |
| 85 | +} | |
| 86 | + | |
| 87 | +/** | |
| 88 | + * Builds the absolute service-worker URL. | |
| 67 | 89 | * |
| 68 | 90 | * @return string |
| 69 | 91 | */ |
| 70 | -function desktop_mode_pwa_manifest_url() { | |
| 71 | - return desktop_mode_portal_url() . DESKTOP_MODE_PWA_MANIFEST_FRAGMENT; | |
| 92 | +function openstation_pwa_sw_url() { | |
| 93 | + return openstation_portal_url() . OPENSTATION_PWA_SW_FRAGMENT; | |
| 72 | 94 | } |
| 73 | 95 | |
| 74 | 96 | /** |
| 75 | - * Builds the absolute service-worker URL. | |
| 97 | + * Builds the extensionless service-worker fallback URL. | |
| 76 | 98 | * |
| 77 | - * @since 0.8.0 | |
| 99 | + * See {@see OPENSTATION_PWA_SW_QUERY} for why this exists. Kept as a | |
| 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()}). | |
| 78 | 103 | * |
| 79 | 104 | * @return string |
| 80 | 105 | */ |
| 81 | -function desktop_mode_pwa_sw_url() { | |
| 82 | - return desktop_mode_portal_url() . DESKTOP_MODE_PWA_SW_FRAGMENT; | |
| 106 | +function openstation_pwa_sw_fallback_url() { | |
| 107 | + return add_query_arg( OPENSTATION_PWA_SW_QUERY, '1', home_url( '/' ) ); | |
| 83 | 108 | } |
| 84 | 109 | |
| 85 | 110 | /** |
| 86 | - * Resolves whether desktop-mode should usurp another root-scope SW. | |
| 111 | + * The service worker's registration scope: the SITE's home path. | |
| 87 | 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 | +/** | |
| 129 | + * Resolves whether openstation should usurp another root-scope SW. | |
| 130 | + * | |
| 88 | 131 | * When `false` (default), `src/pwa/sw-register.ts` bails on registration |
| 89 | 132 | * if another root-scope service worker is already on the origin — polite |
| 90 | 133 | * behaviour for sites that intentionally use a different PWA plugin. When |
| 91 | 134 | * `true`, our registration replaces the existing SW. |
| @@ -90,44 +133,224 @@ | ||
| 90 | 133 | * behaviour for sites that intentionally use a different PWA plugin. When |
| 91 | 134 | * `true`, our registration replaces the existing SW. |
| 92 | 135 | * |
| 93 | 136 | * Operators flip this to recover installability on sites where a foreign |
| 94 | - * SW (Super PWA, Jetpack Boost, etc.) is shadowing the desktop-mode SW | |
| 137 | + * SW (Super PWA, Jetpack Boost, etc.) is shadowing the openstation SW | |
| 95 | 138 | * and causing the "Install <site> as an app" tile to surface the |
| 96 | 139 | * "another app is handling installs" toast. |
| 97 | 140 | * |
| 98 | - * @since 0.8.6 | |
| 99 | - * | |
| 100 | 141 | * @return bool |
| 101 | 142 | */ |
| 102 | -function desktop_mode_pwa_force_replace_sw() { | |
| 143 | +function openstation_pwa_force_replace_sw() { | |
| 103 | 144 | /** |
| 104 | - * Filters whether desktop-mode replaces an existing root-scope SW. | |
| 145 | + * Filters whether openstation replaces an existing root-scope SW. | |
| 105 | 146 | * |
| 106 | 147 | * Return `true` to take over from a foreign PWA plugin's service |
| 107 | - * worker so desktop-mode's "Install as app" affordance works on | |
| 148 | + * worker so openstation's "Install as app" affordance works on | |
| 108 | 149 | * sites where another plugin's SW is already active. |
| 109 | 150 | * |
| 110 | - * @since 0.8.6 | |
| 151 | + * @param bool $force_replace Defaults to `false` (yield to existing SWs). | |
| 152 | + */ | |
| 153 | + return (bool) apply_filters( 'openstation_pwa_force_replace_sw', false ); | |
| 154 | +} | |
| 155 | + | |
| 156 | +/** | |
| 157 | + * Resolves whether the service worker's shared admin-asset cache is on. | |
| 158 | + * | |
| 159 | + * When enabled, the root-scope SW serves versioned admin static assets | |
| 160 | + * (Core CSS/JS, the `load-scripts.php` / `load-styles.php` concat | |
| 161 | + * blobs, plugin/theme assets carrying a `ver` query) from one | |
| 162 | + * origin-wide Cache Storage bucket — so an asset fetched by any window | |
| 163 | + * (shell or chromeless iframe) is answered locally for every later | |
| 164 | + * window, revalidation round-trips included. See `src/pwa/sw-policy.ts` | |
| 165 | + * for the exact classification rules. | |
| 166 | + * | |
| 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. | |
| 170 | + * | |
| 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. | |
| 174 | + * | |
| 175 | + * @return bool | |
| 176 | + */ | |
| 177 | +function openstation_pwa_admin_asset_cache_enabled() { | |
| 178 | + $settings = openstation_get_os_settings( get_current_user_id() ); | |
| 179 | + $enabled = ! empty( $settings['adminAssetCacheEnabled'] ); | |
| 180 | + | |
| 181 | + /** | |
| 182 | + * Filters whether the SW's shared admin-asset cache is enabled. | |
| 111 | 183 | * |
| 112 | - * @param bool $force_replace Defaults to `false` (yield to existing SWs). | |
| 184 | + * Return `true` to let the service worker cache versioned admin | |
| 185 | + * static assets in a shared, origin-wide bucket, or `false` to | |
| 186 | + * veto it regardless of the Extended option. The value | |
| 187 | + * reaches the worker as an `os-sw-config` message on the next shell | |
| 188 | + * boot, so a change takes effect without altering the served script | |
| 189 | + * — no SW update, no URL change, no re-registration. | |
| 190 | + * | |
| 191 | + * @param bool $enabled The site-wide `admin_asset_cache` Extended | |
| 192 | + * option, enabled by default. | |
| 113 | 193 | */ |
| 114 | - return (bool) apply_filters( 'desktop_mode_pwa_force_replace_sw', false ); | |
| 194 | + return (bool) apply_filters( 'openstation_pwa_admin_asset_cache', $enabled ); | |
| 115 | 195 | } |
| 116 | 196 | |
| 117 | 197 | /** |
| 198 | + * Builds the `self.__OS_SW_CONFIG` preamble line injected ahead of the | |
| 199 | + * service-worker bundle bytes by {@see openstation_pwa_serve_service_worker()}. | |
| 200 | + * | |
| 201 | + * The preamble is how per-site PHP state reaches the SW: the script is | |
| 202 | + * a static build artifact, but the *served response* is assembled per | |
| 203 | + * request, and the browser's byte-equality update check treats any | |
| 204 | + * change in these values as a new SW version (`updateViaCache: 'none'` | |
| 205 | + * at registration makes that check unconditional). The SW URL never | |
| 206 | + * changes, so the foreign-SW `scriptURL` comparison in | |
| 207 | + * `src/pwa/sw-register.ts` is unaffected. | |
| 208 | + * | |
| 209 | + * `pluginUrl` also lets the SW resolve its own asset paths on hosts | |
| 210 | + * with a non-default `wp-content` layout (Bedrock, moved | |
| 211 | + * `WP_CONTENT_DIR`) instead of hardcoding the conventional path. | |
| 212 | + * | |
| 213 | + * @return string One line of JavaScript, newline-terminated. | |
| 214 | + */ | |
| 215 | +function openstation_pwa_sw_config_preamble() { | |
| 216 | + /* | |
| 217 | + * Site-level values ONLY. Nothing here may depend on who is asking. | |
| 218 | + * | |
| 219 | + * `adminAssetCache` and `windowPrewarm` are per-user preferences, | |
| 220 | + * and a service worker is origin-wide. Putting them in the served | |
| 221 | + * bytes made the body differ between an anonymous and a logged-in | |
| 222 | + * request, so any in-scope logged-out navigation — the interim-login | |
| 223 | + * iframe, logging out — served a different script. The browser | |
| 224 | + * treats different bytes as an update, installs it, activates it, | |
| 225 | + * and the shell's `controllerchange` handler hard-reloads the | |
| 226 | + * desktop out from under the user. | |
| 227 | + * | |
| 228 | + * The shell pushes both flags to the running worker at boot instead | |
| 229 | + * (`os-sw-config`), and the toggle pushes changes as they happen. | |
| 230 | + * The worker starts with both off, so until that message lands it | |
| 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. | |
| 251 | + */ | |
| 252 | + $config = array( | |
| 253 | + 'pluginUrl' => OPENSTATION_URL, | |
| 254 | + 'version' => OPENSTATION_VERSION, | |
| 255 | + 'shellBuild' => openstation_shell_build_stamp(), | |
| 256 | + ); | |
| 257 | + return sprintf( "self.__OS_SW_CONFIG = %s;\n", wp_json_encode( $config ) ); | |
| 258 | +} | |
| 259 | + | |
| 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 | +/** | |
| 118 | 340 | * Detects which PWA endpoint the current request is targeting, if any. |
| 119 | 341 | * |
| 120 | - * Mirrors `desktop_mode_is_portal_request()`'s strategy: read the | |
| 342 | + * Mirrors `openstation_is_portal_request()`'s strategy: read the | |
| 121 | 343 | * unparsed REQUEST_URI rather than relying on rewrite-rule resolution. |
| 122 | 344 | * |
| 123 | - * @since 0.8.0 | |
| 124 | - * | |
| 125 | 345 | * @return string Empty string when not a PWA endpoint, otherwise one |
| 126 | 346 | * of `'manifest'` | `'sw'`. |
| 127 | 347 | */ |
| 128 | -function desktop_mode_pwa_endpoint_kind() { | |
| 129 | - $uri = isset( $_SERVER['REQUEST_URI'] ) ? wp_unslash( $_SERVER['REQUEST_URI'] ) : ''; | |
| 348 | +function openstation_pwa_endpoint_kind() { | |
| 349 | + // `esc_url_raw` rather than `sanitize_text_field`: the value is a URL | |
| 350 | + // and the latter strips percent-encoded octets, which would corrupt | |
| 351 | + // the path before it can be compared against the endpoint constants. | |
| 352 | + $uri = isset( $_SERVER['REQUEST_URI'] ) ? esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : ''; | |
| 130 | 353 | if ( ! is_string( $uri ) || '' === $uri ) { |
| 131 | 354 | return ''; |
| 132 | 355 | } |
| 133 | 356 | $path = (string) wp_parse_url( $uri, PHP_URL_PATH ); |
| @@ -135,15 +358,34 @@ | ||
| 135 | 358 | return ''; |
| 136 | 359 | } |
| 137 | 360 | $home_path = wp_parse_url( home_url( '/' ), PHP_URL_PATH ); |
| 138 | 361 | $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 ) { | |
| 362 | + $portal = $home_path . '/' . trim( OPENSTATION_PORTAL_PATH, '/' ) . '/'; | |
| 363 | + if ( $path === $portal . OPENSTATION_PWA_MANIFEST_FRAGMENT ) { | |
| 141 | 364 | return 'manifest'; |
| 142 | 365 | } |
| 143 | - if ( $path === $portal . DESKTOP_MODE_PWA_SW_FRAGMENT ) { | |
| 366 | + if ( $path === $portal . OPENSTATION_PWA_SW_FRAGMENT ) { | |
| 144 | 367 | return 'sw'; |
| 145 | 368 | } |
| 369 | + // Extensionless fallback (`/?openstation_sw=1`) for hosts whose web | |
| 370 | + // server 404s virtual `.js` paths before WordPress runs. | |
| 371 | + // | |
| 372 | + // Pinned to the site root — the one URL | |
| 373 | + // {@see openstation_pwa_sw_fallback_url()} builds and the only one | |
| 374 | + // the registration ever requests. Matching the query alone would | |
| 375 | + // have turned *any* path into a service-worker endpoint, which is | |
| 376 | + // harmless in practice (the handler streams a static file from | |
| 377 | + // disk and reflects nothing from the request) but wider than the | |
| 378 | + // contract this function documents, and a service worker's scope | |
| 379 | + // is decided by the path it is served from — so the path is not an | |
| 380 | + // incidental detail here. | |
| 381 | + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- public read-only endpoint selector, same trust level as the path match above. | |
| 382 | + if ( isset( $_GET[ OPENSTATION_PWA_SW_QUERY ] ) && '1' === $_GET[ OPENSTATION_PWA_SW_QUERY ] ) { | |
| 383 | + $home_root = '' === $home_path ? '/' : $home_path . '/'; | |
| 384 | + if ( $path === $home_root || $path === $home_path ) { | |
| 385 | + return 'sw'; | |
| 386 | + } | |
| 387 | + } | |
| 146 | 388 | return ''; |
| 147 | 389 | } |
| 148 | 390 | |
| 149 | 391 | /** |
| @@ -158,40 +400,36 @@ | ||
| 158 | 400 | * user revisits the install URL; the SW is fetched by the browser |
| 159 | 401 | * with no cookies on update checks. Both reveal only data already |
| 160 | 402 | * surfaced by the front-end (site name, blog icon, plugin version). |
| 161 | 403 | * |
| 162 | - * @since 0.8.0 | |
| 163 | - * | |
| 164 | 404 | * @param WP $wp Current WordPress environment instance (unused). |
| 165 | 405 | */ |
| 166 | -function desktop_mode_pwa_handle_request( $wp ) { | |
| 406 | +function openstation_pwa_handle_request( $wp ) { | |
| 167 | 407 | unset( $wp ); |
| 168 | 408 | |
| 169 | - $kind = desktop_mode_pwa_endpoint_kind(); | |
| 409 | + $kind = openstation_pwa_endpoint_kind(); | |
| 170 | 410 | if ( '' === $kind ) { |
| 171 | 411 | return; |
| 172 | 412 | } |
| 173 | 413 | |
| 174 | 414 | if ( 'manifest' === $kind ) { |
| 175 | - desktop_mode_pwa_serve_manifest(); | |
| 415 | + openstation_pwa_serve_manifest(); | |
| 176 | 416 | exit; |
| 177 | 417 | } |
| 178 | 418 | |
| 179 | 419 | if ( 'sw' === $kind ) { |
| 180 | - desktop_mode_pwa_serve_service_worker(); | |
| 420 | + openstation_pwa_serve_service_worker(); | |
| 181 | 421 | exit; |
| 182 | 422 | } |
| 183 | 423 | } |
| 184 | -add_action( 'parse_request', 'desktop_mode_pwa_handle_request' ); | |
| 424 | +add_action( 'parse_request', 'openstation_pwa_handle_request' ); | |
| 185 | 425 | |
| 186 | 426 | /** |
| 187 | - * Builds the manifest array, applies the `desktop_mode_pwa_manifest` | |
| 427 | + * Builds the manifest array, applies the `openstation_pwa_manifest` | |
| 188 | 428 | * filter, encodes as JSON and prints it. |
| 189 | - * | |
| 190 | - * @since 0.8.0 | |
| 191 | 429 | */ |
| 192 | -function desktop_mode_pwa_serve_manifest() { | |
| 193 | - $manifest = desktop_mode_pwa_build_manifest(); | |
| 430 | +function openstation_pwa_serve_manifest() { | |
| 431 | + $manifest = openstation_pwa_build_manifest(); | |
| 194 | 432 | |
| 195 | 433 | /** |
| 196 | 434 | * Filters the web-app manifest payload before encoding. |
| 197 | 435 | * |
| @@ -200,13 +438,11 @@ | ||
| 200 | 438 | * deep-link entries, change `display` to `'fullscreen'`. Returning |
| 201 | 439 | * a non-array silently disables the manifest — no PHP warning, but |
| 202 | 440 | * the browser will fail the install criterion. |
| 203 | 441 | * |
| 204 | - * @since 0.8.0 | |
| 205 | - * | |
| 206 | 442 | * @param array $manifest Manifest associative array. |
| 207 | 443 | */ |
| 208 | - $manifest = apply_filters( 'desktop_mode_pwa_manifest', $manifest ); | |
| 444 | + $manifest = apply_filters( 'openstation_pwa_manifest', $manifest ); | |
| 209 | 445 | |
| 210 | 446 | if ( ! is_array( $manifest ) ) { |
| 211 | 447 | status_header( 500 ); |
| 212 | 448 | return; |
| @@ -221,13 +457,11 @@ | ||
| 221 | 457 | |
| 222 | 458 | /** |
| 223 | 459 | * Assembles the default manifest fields. |
| 224 | 460 | * |
| 225 | - * @since 0.8.0 | |
| 226 | - * | |
| 227 | 461 | * @return array |
| 228 | 462 | */ |
| 229 | -function desktop_mode_pwa_build_manifest() { | |
| 463 | +function openstation_pwa_build_manifest() { | |
| 230 | 464 | $site_name = get_bloginfo( 'name' ); |
| 231 | 465 | if ( '' === $site_name ) { |
| 232 | 466 | $site_name = 'WordPress'; |
| 233 | 467 | } |
| @@ -235,60 +469,66 @@ | ||
| 235 | 469 | if ( '' === $short_name ) { |
| 236 | 470 | $short_name = $site_name; |
| 237 | 471 | } |
| 238 | 472 | |
| 239 | - // `start_url` is the actual landing URL after the `/desktop-mode/` | |
| 473 | + // `start_url` is the actual landing URL after the `/openstation/` | |
| 240 | 474 | // portal redirect — pointing the PWA directly at it lets us narrow |
| 241 | 475 | // `scope` to `/wp-admin/` without breaking the launch path. The |
| 242 | 476 | // portal redirect still exists for typed / bookmarked |
| 243 | - // `/desktop-mode/` visits in regular browser tabs. | |
| 477 | + // `/openstation/` visits in regular browser tabs. | |
| 244 | 478 | // |
| 245 | 479 | // `scope` is `/wp-admin/`, not `/`. The wider `/` scope had two |
| 246 | 480 | // failure modes that this fixes: |
| 247 | 481 | // |
| 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. | |
| 482 | + // - Front-end URLs (e.g. `/2026/05/post-123/`) were considered | |
| 483 | + // in-scope, so Chrome's "Open in app" link-capturing redirected | |
| 484 | + // external-link clicks (Comments "In response to" column, etc.) | |
| 485 | + // into the installed PWA window instead of opening a real | |
| 486 | + // browser tab. Excluding the front-end from scope makes those | |
| 487 | + // clicks open in a browser tab as users expect. | |
| 488 | + // - Every same-origin `<a target="_blank">` from inside the PWA | |
| 489 | + // opened a NEW standalone PWA window for the same reason. With | |
| 490 | + // scope narrowed, only `/wp-admin/*` links capture into the | |
| 491 | + // PWA; everything else escapes to the system browser. | |
| 258 | 492 | // |
| 259 | - // `id` is held at the previous `/desktop-mode/` value so existing | |
| 493 | + // `id` is held at the previous `/openstation/` value so existing | |
| 260 | 494 | // installs aren't treated as a different app and reset by Chrome |
| 261 | 495 | // after this change ships. |
| 262 | - $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(); | |
| 263 | 501 | $scope = admin_url( '/', 'relative' ); |
| 264 | 502 | if ( '' === $scope ) { |
| 265 | 503 | $scope = '/wp-admin/'; |
| 266 | 504 | } |
| 267 | 505 | |
| 268 | - $manifest_url = desktop_mode_pwa_manifest_url(); | |
| 506 | + $manifest_url = openstation_pwa_manifest_url(); | |
| 269 | 507 | |
| 270 | 508 | return array( |
| 271 | - 'name' => $site_name, | |
| 272 | - 'short_name' => $short_name, | |
| 273 | - 'description' => sprintf( | |
| 509 | + 'name' => $site_name, | |
| 510 | + 'short_name' => $short_name, | |
| 511 | + 'description' => sprintf( | |
| 274 | 512 | /* translators: %s: site name */ |
| 275 | 513 | __( '%s — installed as a desktop app.', 'desktop-mode' ), |
| 276 | 514 | $site_name |
| 277 | 515 | ), |
| 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(), | |
| 516 | + 'start_url' => $start_url, | |
| 517 | + 'scope' => $scope, | |
| 518 | + 'id' => openstation_portal_url(), | |
| 519 | + 'display' => 'standalone', | |
| 520 | + 'display_override' => array( 'standalone', 'minimal-ui' ), | |
| 521 | + 'orientation' => 'any', | |
| 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, | |
| 528 | + 'lang' => get_bloginfo( 'language' ), | |
| 529 | + 'dir' => is_rtl() ? 'rtl' : 'ltr', | |
| 530 | + 'icons' => openstation_pwa_default_icons(), | |
| 291 | 531 | // Self-reference under `related_applications` so |
| 292 | 532 | // `navigator.getInstalledRelatedApps()` (Chrome / Edge) returns |
| 293 | 533 | // a hit when this PWA is installed in the current profile. |
| 294 | 534 | // `prefer_related_applications: false` keeps the install prompt |
| @@ -297,13 +537,13 @@ | ||
| 297 | 537 | // has no way to detect "already installed in this profile" — |
| 298 | 538 | // `display-mode: standalone` is only true inside the PWA |
| 299 | 539 | // window. The detection is what powers the dock-tile click |
| 300 | 540 | // handler's "X is already installed" toast. |
| 301 | - 'related_applications' => array( | |
| 541 | + 'related_applications' => array( | |
| 302 | 542 | array( |
| 303 | 543 | 'platform' => 'webapp', |
| 304 | 544 | 'url' => $manifest_url, |
| 305 | - 'id' => desktop_mode_portal_url(), | |
| 545 | + 'id' => openstation_portal_url(), | |
| 306 | 546 | ), |
| 307 | 547 | ), |
| 308 | 548 | 'prefer_related_applications' => false, |
| 309 | 549 | ); |
| @@ -316,23 +556,38 @@ | ||
| 316 | 556 | * 1. WordPress Site Icon (`Settings → General → Site Icon`) — yields |
| 317 | 557 | * multiple PNG sizes via `get_site_icon_url()`. Authoritative |
| 318 | 558 | * when the operator has uploaded a brand mark for their site. |
| 319 | 559 | * 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. | |
| 560 | + * openstation brand mark (the same artwork shown on the | |
| 561 | + * WordPress.org plugin directory listing). | |
| 324 | 562 | * |
| 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`. | |
| 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. | |
| 329 | 569 | * |
| 330 | - * @since 0.8.0 | |
| 570 | + * Three purposes go out for the bundled set, because the platforms | |
| 571 | + * genuinely want three different pictures: | |
| 331 | 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 | + * | |
| 332 | 587 | * @return array<int, array<string, string>> |
| 333 | 588 | */ |
| 334 | -function desktop_mode_pwa_default_icons() { | |
| 589 | +function openstation_pwa_default_icons() { | |
| 335 | 590 | $icons = array(); |
| 336 | 591 | |
| 337 | 592 | $site_icon_id = (int) get_option( 'site_icon' ); |
| 338 | 593 | if ( $site_icon_id > 0 ) { |
| @@ -352,15 +607,25 @@ | ||
| 352 | 607 | } |
| 353 | 608 | } |
| 354 | 609 | } |
| 355 | 610 | |
| 356 | - if ( empty( $icons ) ) { | |
| 357 | - 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 ) { | |
| 358 | 623 | $icons[] = array( |
| 359 | - 'src' => DESKTOP_MODE_URL . "assets/pwa/icon-{$size}.png", | |
| 624 | + 'src' => openstation_pwa_bundled_icon_url( $size, $purpose ), | |
| 360 | 625 | 'sizes' => "{$size}x{$size}", |
| 361 | 626 | 'type' => 'image/png', |
| 362 | - 'purpose' => 'any', | |
| 627 | + 'purpose' => $purpose, | |
| 363 | 628 | ); |
| 364 | 629 | } |
| 365 | 630 | } |
| 366 | 631 | |
| @@ -367,8 +632,31 @@ | ||
| 367 | 632 | return $icons; |
| 368 | 633 | } |
| 369 | 634 | |
| 370 | 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 | +/** | |
| 371 | 659 | * Serves the service-worker bundle. |
| 372 | 660 | * |
| 373 | 661 | * Reads the built `assets/js/sw[.min].js` from disk and streams it back |
| 374 | 662 | * with the headers a SW needs to be valid: |
| @@ -373,13 +661,13 @@ | ||
| 373 | 661 | * Reads the built `assets/js/sw[.min].js` from disk and streams it back |
| 374 | 662 | * with the headers a SW needs to be valid: |
| 375 | 663 | * |
| 376 | 664 | * - `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`. | |
| 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`. | |
| 382 | 670 | * - `Cache-Control: no-cache, must-revalidate` — the browser already |
| 383 | 671 | * re-checks SW scripts on a 24h cycle, but caching the response |
| 384 | 672 | * defeats the immediate-update guarantee. |
| 385 | 673 | * |
| @@ -386,20 +674,18 @@ | ||
| 386 | 674 | * Falls back to a 503 + log entry when the file is missing (a deploy |
| 387 | 675 | * that didn't run `npm run build`). Logging gives the operator a |
| 388 | 676 | * concrete pointer; 503 (vs. 404) tells the browser the SW genuinely |
| 389 | 677 | * isn't available right now and it should retry later. |
| 390 | - * | |
| 391 | - * @since 0.8.0 | |
| 392 | 678 | */ |
| 393 | -function desktop_mode_pwa_serve_service_worker() { | |
| 394 | - $suffix = desktop_mode_asset_suffix(); | |
| 395 | - $path = DESKTOP_MODE_DIR . 'assets/js/sw' . $suffix . '.js'; | |
| 679 | +function openstation_pwa_serve_service_worker() { | |
| 680 | + $suffix = openstation_asset_suffix(); | |
| 681 | + $path = OPENSTATION_DIR . 'assets/js/sw' . $suffix . '.js'; | |
| 396 | 682 | |
| 397 | 683 | if ( ! file_exists( $path ) ) { |
| 398 | 684 | // Guard against hosts that disable error_log() via the |
| 399 | 685 | // `disable_functions` ini directive. |
| 400 | 686 | 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 | |
| 687 | + error_log( '[openstation] service worker bundle missing at ' . $path . ' — run `npm run build` to generate it.' ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log | |
| 402 | 688 | } |
| 403 | 689 | status_header( 503 ); |
| 404 | 690 | header( 'Cache-Control: no-cache, must-revalidate' ); |
| 405 | 691 | return; |
| @@ -411,9 +697,12 @@ | ||
| 411 | 697 | return; |
| 412 | 698 | } |
| 413 | 699 | |
| 414 | 700 | header( 'Content-Type: application/javascript; charset=utf-8' ); |
| 415 | - 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() ); | |
| 416 | 705 | header( 'Cache-Control: no-cache, must-revalidate' ); |
| 417 | 706 | header( 'X-Content-Type-Options: nosniff' ); |
| 418 | 707 | |
| 419 | 708 | // Stamp the SW with a CONTENT HASH so the browser's byte-equality |
| @@ -422,21 +711,29 @@ | ||
| 422 | 711 | // Earlier versions stamped with the file's `filemtime()`. Problem: |
| 423 | 712 | // `npm run build` rewrites `sw.min.js` on every run, bumping its |
| 424 | 713 | // mtime even when the SW source is byte-identical. Each rebuild |
| 425 | 714 | // 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. | |
| 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. | |
| 431 | 719 | // |
| 432 | 720 | // 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 | |
| 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 | |
| 435 | 724 | // stamp here (no security implications) and short enough that the |
| 436 | 725 | // inline comment stays under one line. |
| 437 | 726 | $stamp = substr( md5( $body ), 0, 16 ); |
| 438 | - printf( "/* desktop-mode SW build: %s */\n", esc_html( $stamp ) ); | |
| 727 | + printf( "/* openstation SW build: %s */\n", esc_html( $stamp ) ); | |
| 728 | + // Per-request config, injected ahead of the bundle. Deliberately | |
| 729 | + // NOT part of the stamp hash above: the stamp identifies the | |
| 730 | + // *bundle*, while a config change carries itself to the browser's | |
| 731 | + // update check through its own bytes. Don't "fix" the hash to | |
| 732 | + // cover the full response — identical bundles must keep identical | |
| 733 | + // stamps (see the phantom-reload note above). | |
| 734 | + // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- JS assembled via wp_json_encode; HTML escaping would corrupt the script. | |
| 735 | + echo openstation_pwa_sw_config_preamble(); | |
| 439 | 736 | // `$body` is the SW JavaScript bundle read off disk — escaping |
| 440 | 737 | // would corrupt the script. Suppress the sniff with the standard |
| 441 | 738 | // `--` separator (an em-dash silently fails to satisfy phpcs). |
| 442 | 739 | // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- JS bytes from disk. |
| @@ -443,10 +740,52 @@ | ||
| 443 | 740 | echo $body; |
| 444 | 741 | } |
| 445 | 742 | |
| 446 | 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 | +/** | |
| 447 | 786 | * Emits the `<link rel="manifest">` tag and the matching theme-color |
| 448 | - * meta into the admin `<head>` — only when desktop-mode is the active | |
| 787 | + * meta into the admin `<head>` — only when openstation is the active | |
| 449 | 788 | * surface for this request (no chromeless iframes, no classic admin). |
| 450 | 789 | * |
| 451 | 790 | * Without these tags the browser never discovers the manifest and the |
| 452 | 791 | * "install" criterion silently fails. Putting them in `<head>` (rather |
| @@ -451,27 +790,25 @@ | ||
| 451 | 790 | * Without these tags the browser never discovers the manifest and the |
| 452 | 791 | * "install" criterion silently fails. Putting them in `<head>` (rather |
| 453 | 792 | * than via `wp_localize_script`'s inline script tag) is what the |
| 454 | 793 | * spec requires. |
| 455 | - * | |
| 456 | - * @since 0.8.0 | |
| 457 | 794 | */ |
| 458 | -function desktop_mode_pwa_render_head_tags() { | |
| 795 | +function openstation_pwa_render_head_tags() { | |
| 459 | 796 | if ( ! is_admin() || ! is_user_logged_in() ) { |
| 460 | 797 | return; |
| 461 | 798 | } |
| 462 | - if ( desktop_mode_is_chromeless_request() ) { | |
| 799 | + if ( ! openstation_is_shell_request() ) { | |
| 463 | 800 | return; |
| 464 | 801 | } |
| 465 | - if ( ! desktop_mode_is_enabled() || desktop_mode_is_classic_request() ) { | |
| 466 | - return; | |
| 467 | - } | |
| 468 | 802 | |
| 469 | 803 | printf( |
| 470 | 804 | '<link rel="manifest" href="%s">' . "\n", |
| 471 | - esc_url( desktop_mode_pwa_manifest_url() ) | |
| 805 | + esc_url( openstation_pwa_manifest_url() ) | |
| 472 | 806 | ); |
| 473 | - 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 | + ); | |
| 474 | 811 | // `mobile-web-app-capable` is the cross-browser standard; |
| 475 | 812 | // `apple-mobile-web-app-capable` is the legacy iOS-only spelling |
| 476 | 813 | // (still required by older Safari versions). Chromium logs a |
| 477 | 814 | // deprecation warning if only the apple-prefixed form is present. |
| @@ -478,29 +815,61 @@ | ||
| 478 | 815 | // We emit both so iOS keeps treating the home-screen shortcut as |
| 479 | 816 | // a standalone app while Chromium stops the warning. |
| 480 | 817 | echo '<meta name="mobile-web-app-capable" content="yes">' . "\n"; |
| 481 | 818 | 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 | 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( | |
| 484 | 824 | '<meta name="apple-mobile-web-app-title" content="%s">' . "\n", |
| 485 | 825 | esc_attr( get_bloginfo( 'name' ) ) |
| 486 | 826 | ); |
| 827 | + printf( | |
| 828 | + '<link rel="apple-touch-icon" sizes="180x180" href="%s">' . "\n", | |
| 829 | + esc_url( openstation_pwa_apple_touch_icon_url() ) | |
| 830 | + ); | |
| 487 | 831 | } |
| 488 | -add_action( 'admin_head', 'desktop_mode_pwa_render_head_tags', 1 ); | |
| 832 | +add_action( 'admin_head', 'openstation_pwa_render_head_tags', 1 ); | |
| 489 | 833 | |
| 490 | 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 | +} | |
| 860 | + | |
| 861 | +/** | |
| 491 | 862 | * Reads the per-user PWA UI state. |
| 492 | 863 | * |
| 493 | - * @since 0.8.0 | |
| 494 | - * | |
| 495 | 864 | * @param int $user_id Defaults to current user. |
| 496 | 865 | * @return array{installHintDismissed: bool, notificationsEnabled: bool} |
| 497 | 866 | */ |
| 498 | -function desktop_mode_pwa_get_user_state( $user_id = 0 ) { | |
| 867 | +function openstation_pwa_get_user_state( $user_id = 0 ) { | |
| 499 | 868 | if ( 0 === $user_id ) { |
| 500 | 869 | $user_id = get_current_user_id(); |
| 501 | 870 | } |
| 502 | - $raw = get_user_meta( $user_id, DESKTOP_MODE_PWA_USER_META, true ); | |
| 871 | + $raw = get_user_meta( $user_id, OPENSTATION_PWA_USER_META, true ); | |
| 503 | 872 | if ( ! is_array( $raw ) ) { |
| 504 | 873 | $raw = array(); |
| 505 | 874 | } |
| 506 | 875 | return array( |
| @@ -512,28 +881,24 @@ | ||
| 512 | 881 | /** |
| 513 | 882 | * Writes the per-user PWA UI state, merging with the existing blob so |
| 514 | 883 | * partial updates from the JS side don't wipe other keys. |
| 515 | 884 | * |
| 516 | - * @since 0.8.0 | |
| 517 | - * | |
| 518 | 885 | * @param array $patch Partial state to merge. |
| 519 | 886 | * @param int $user_id Defaults to current user. |
| 520 | 887 | */ |
| 521 | -function desktop_mode_pwa_update_user_state( array $patch, $user_id = 0 ) { | |
| 888 | +function openstation_pwa_update_user_state( array $patch, $user_id = 0 ) { | |
| 522 | 889 | if ( 0 === $user_id ) { |
| 523 | 890 | $user_id = get_current_user_id(); |
| 524 | 891 | } |
| 525 | - $current = desktop_mode_pwa_get_user_state( $user_id ); | |
| 892 | + $current = openstation_pwa_get_user_state( $user_id ); | |
| 526 | 893 | $next = array_merge( $current, $patch ); |
| 527 | - update_user_meta( $user_id, DESKTOP_MODE_PWA_USER_META, $next ); | |
| 894 | + update_user_meta( $user_id, OPENSTATION_PWA_USER_META, $next ); | |
| 528 | 895 | } |
| 529 | 896 | |
| 530 | 897 | /** |
| 531 | 898 | * Registers the `/desktop-mode/v1/pwa-state` REST routes. |
| 532 | - * | |
| 533 | - * @since 0.8.0 | |
| 534 | 899 | */ |
| 535 | -function desktop_mode_pwa_register_rest_routes() { | |
| 900 | +function openstation_pwa_register_rest_routes() { | |
| 536 | 901 | register_rest_route( |
| 537 | 902 | 'desktop-mode/v1', |
| 538 | 903 | '/pwa-state', |
| 539 | 904 | array( |
| @@ -538,15 +903,15 @@ | ||
| 538 | 903 | '/pwa-state', |
| 539 | 904 | array( |
| 540 | 905 | array( |
| 541 | 906 | 'methods' => WP_REST_Server::READABLE, |
| 542 | - 'callback' => 'desktop_mode_pwa_rest_get_state', | |
| 543 | - 'permission_callback' => 'desktop_mode_pwa_rest_permission', | |
| 907 | + 'callback' => 'openstation_pwa_rest_get_state', | |
| 908 | + 'permission_callback' => 'openstation_pwa_rest_permission', | |
| 544 | 909 | ), |
| 545 | 910 | array( |
| 546 | 911 | 'methods' => WP_REST_Server::CREATABLE, |
| 547 | - 'callback' => 'desktop_mode_pwa_rest_post_state', | |
| 548 | - 'permission_callback' => 'desktop_mode_pwa_rest_permission', | |
| 912 | + 'callback' => 'openstation_pwa_rest_post_state', | |
| 913 | + 'permission_callback' => 'openstation_pwa_rest_permission', | |
| 549 | 914 | 'args' => array( |
| 550 | 915 | 'installHintDismissed' => array( |
| 551 | 916 | 'type' => 'boolean', |
| 552 | 917 | 'required' => false, |
| @@ -563,41 +928,34 @@ | ||
| 563 | 928 | // Future: register POST /pwa-push-subscription here when phase 4 |
| 564 | 929 | // lands. The state route is intentionally orthogonal so the v1 |
| 565 | 930 | // surface stays stable when push arrives. |
| 566 | 931 | } |
| 567 | -add_action( 'rest_api_init', 'desktop_mode_pwa_register_rest_routes' ); | |
| 932 | +add_action( 'rest_api_init', 'openstation_pwa_register_rest_routes' ); | |
| 568 | 933 | |
| 569 | 934 | /** |
| 570 | 935 | * REST permission gate — same shape as the session routes: logged in |
| 571 | - * with desktop mode enabled. See | |
| 572 | - * {@see desktop_mode_rest_require_enabled()}. | |
| 936 | + * with OpenStation enabled. See | |
| 937 | + * {@see openstation_rest_require_enabled()}. | |
| 573 | 938 | * |
| 574 | - * @since 0.8.0 | |
| 575 | - * @since 0.8.10 Hardened to require desktop mode enabled (was `read`). | |
| 576 | - * | |
| 577 | 939 | * @return true|WP_Error |
| 578 | 940 | */ |
| 579 | -function desktop_mode_pwa_rest_permission() { | |
| 580 | - return desktop_mode_rest_require_enabled(); | |
| 941 | +function openstation_pwa_rest_permission() { | |
| 942 | + return openstation_rest_require_enabled(); | |
| 581 | 943 | } |
| 582 | 944 | |
| 583 | 945 | /** |
| 584 | 946 | * GET handler — returns the current user's PWA state. |
| 585 | - * | |
| 586 | - * @since 0.8.0 | |
| 587 | 947 | */ |
| 588 | -function desktop_mode_pwa_rest_get_state() { | |
| 589 | - return rest_ensure_response( desktop_mode_pwa_get_user_state() ); | |
| 948 | +function openstation_pwa_rest_get_state() { | |
| 949 | + return rest_ensure_response( openstation_pwa_get_user_state() ); | |
| 590 | 950 | } |
| 591 | 951 | |
| 592 | 952 | /** |
| 593 | 953 | * POST handler — merges the supplied keys into the user's state. |
| 594 | 954 | * |
| 595 | - * @since 0.8.0 | |
| 596 | - * | |
| 597 | 955 | * @param WP_REST_Request $request REST request. |
| 598 | 956 | */ |
| 599 | -function desktop_mode_pwa_rest_post_state( $request ) { | |
| 957 | +function openstation_pwa_rest_post_state( $request ) { | |
| 600 | 958 | $patch = array(); |
| 601 | 959 | if ( null !== $request->get_param( 'installHintDismissed' ) ) { |
| 602 | 960 | $patch['installHintDismissed'] = (bool) $request->get_param( 'installHintDismissed' ); |
| 603 | 961 | } |
| @@ -604,8 +962,8 @@ | ||
| 604 | 962 | if ( null !== $request->get_param( 'notificationsEnabled' ) ) { |
| 605 | 963 | $patch['notificationsEnabled'] = (bool) $request->get_param( 'notificationsEnabled' ); |
| 606 | 964 | } |
| 607 | 965 | if ( ! empty( $patch ) ) { |
| 608 | - desktop_mode_pwa_update_user_state( $patch ); | |
| 966 | + openstation_pwa_update_user_state( $patch ); | |
| 609 | 967 | } |
| 610 | - return rest_ensure_response( desktop_mode_pwa_get_user_state() ); | |
| 968 | + return rest_ensure_response( openstation_pwa_get_user_state() ); | |
| 611 | 969 | } |