feat(pwa): installable web app, service worker, local notifications#87
Merged
feat(pwa): installable web app, service worker, local notifications#87
Conversation
Adds full PWA support to desktop-mode so users can install the
WordPress site as a desktop / mobile app and plugins can surface
local notifications through one unified API.
Surface:
- Web app manifest at /desktop-mode/manifest.webmanifest (filterable
via desktop_mode_pwa_manifest), served by parse_request alongside
the existing portal handler. Default icons pull from the WordPress
Site Icon when set, falling back to the bundled brand mark under
assets/pwa/ — no more blank-tile install on the macOS dock.
- Root-scoped service worker at /desktop-mode/sw.js with
Service-Worker-Allowed: / so a single SW covers /desktop-mode/ and
/wp-admin/ (their common ancestor). Fetch handler stays narrow:
stale-while-revalidate for plugin static assets, network-first for
navigations with an offline fallback, pass-through for everything
else. wp-admin HTML is never cached.
- Persistent install tile on the side dock next to OS Settings.
Click triggers Chrome's install prompt when available, otherwise
shows a contextual toast ("already installed", "not yet"). Detects
installed state via navigator.getInstalledRelatedApps() backed by
a self-reference in manifest.related_applications.
- wp.desktop.notify({ title, body, icon, tag, onClick }) — uses the
browser Notification API directly with a toast fallback when
permission is denied or unsupported. Filterable + observable via
the activity bus channels desktop-mode/notification-{requested,shown}.
- wp.desktop.pwa.* — programmatic install + permission control:
promptInstall(), requestNotificationPermission(),
getNotificationPermission(), getState(), subscribe(),
undismissInstallHint().
Web Push (v2) is wired as no-op `push` and `notificationclick`
handlers in the SW so a future PR can drop in a real payload
renderer without breaking the v1 call surface.
Build:
- New Vite target `pwa-sw` builds src/pwa/sw.ts to assets/js/sw.js
and sw.min.js; both gitignored alongside the other Vite outputs
and added to bin/package.sh's built[] splice list so the release
zip ships them.
Docs:
- New docs/pwa.md (architecture, caching policy, scope rationale).
- New docs/examples/{pwa-install,notify}.md.
- Hooks reference, JavaScript reference, AGENTS.md doc-tree updated.
Version bumped to 0.8.0 across desktop-mode.php, package.json,
readme.txt.
✅ WordPress Plugin Check Report
📊 ReportAll checks passed! No errors or warnings found. 🤖 Generated by WordPress Plugin Check Action • Learn more about Plugin Check |
Two issues the WordPress Plugin Check Action flagged on PR #87: 1. `echo "/* desktop-mode SW build: {$mtime} */\n"` had no escape directive even though `$mtime` is already `(int)`-cast. Switched to `printf( "...%d...\n", $mtime )` — phpcs trusts the `%d` format specifier, no ignore comment needed. 2. `echo $body;` carried a `phpcs:ignore` directive that used an em-dash (`—`) as the separator, which phpcs silently fails to parse. Switched to the standard `--` so the suppression actually takes effect. Also moved the comment to the line above the echo per phpcs conventions. `$body` is the SW JavaScript bundle read off disk — escaping it would corrupt the script.
WordPress.Security.EscapeOutput.OutputNotEscaped doesn't follow the (int) cast at the assignment site — only the immediate printf argument matters. absint() is on the auto-recognised escape list, so the sniff sees the value as already sanitized.
# Conflicts: # .gitignore # bin/package.sh # desktop-mode.php # package.json # readme.txt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds full PWA support to desktop-mode so users can install the WordPress site as a desktop / mobile app, and plugins can surface local notifications through one unified API.
What ships
/desktop-mode/manifest.webmanifestby aparse_requesthandler. Filterable viadesktop_mode_pwa_manifest. Default icons pull from the WordPress Site Icon when set; otherwise from the bundled brand mark underassets/pwa/(the same artwork as.wordpress-org/icon-*.png, copied in because that directory isn't shipped in the plugin zip)./desktop-mode/sw.jswithService-Worker-Allowed: /so a single SW scopes across/desktop-mode/and/wp-admin/(root is their only common ancestor). Fetch handler is deliberately narrow: stale-while-revalidate for plugin static assets, network-first for navigations with an offline fallback, pass-through for everything else. wp-admin HTML is never cached.navigator.getInstalledRelatedApps()+ a self-reference inmanifest.related_applications.wp.desktop.notify({ title, body, icon, tag, onClick }). Uses the browserNotificationAPI; falls back to a toast when permission is denied or unsupported. Filterable + observable viadesktop-mode/notification-{requested,shown}activity-bus channels.wp.desktop.pwa.{ promptInstall, requestNotificationPermission, getNotificationPermission, getState, subscribe, undismissInstallHint }— for plugin authors who want a redundant entry point in their own UI (settings tab, etc.).GET/POST /wp-json/desktop-mode/v1/pwa-statefor per-user dismissal + notification-permission record.Web Push (v2) is intentionally not in this PR. The SW registers no-op
pushandnotificationclickhandlers so a follow-up can drop in a real payload renderer without breaking the v1 call surface.Why root scope (with a narrow fetch handler)
A service worker has exactly one scope path. Registering at
/desktop-mode/would cut the SW off from admin-page navigations — defeating the purpose for the typical install target (a dashboard URL inside/wp-admin/). So the SW registers at root scope and the fetch handler returns early for any URL outside our paths. Behaviorally "narrow scope" without inheriting the technical limitation. Foreign root-scoped SWs (Jetpack Boost, Super PWA, …) are detected and the registration bails with a console warning rather than silently usurping them.Build / packaging
pwa-swbuildssrc/pwa/sw.ts→assets/js/sw[.min].js. Mirrors the existing per-target setup.bin/package.sh'sbuilt[]splice list so the release zip includes them.Docs
docs/pwa.md— architecture, caching policy, scope rationale.docs/examples/pwa-install.mdanddocs/examples/notify.md.docs/README.md,docs/examples/README.md,docs/hooks-reference.md,docs/javascript-reference.md, and theAGENTS.mddoc-tree updated.Version
Bumped to 0.8.0 in
desktop-mode.php,package.json,readme.txt.Test plan
npm run lintclean../node_modules/.bin/tsc --noEmitclean.npm run buildproduces the newassets/js/sw[.min].js.npm run test:js— 768 / 768 vitest cases pass.localhost:8889via Chrome DevTools MCP:/, stateactivated, response carriesService-Worker-Allowed: /.'accepted'→ app installs →display-mode: standaloneflips true.wp.desktop.notify({ ... })renders OS notification when permitted; falls back to toast cleanly whenNotificationAPI is hidden./pwa-stateround-trip persists the dismissal flag.beforeinstallpromptby spec; theapple-mobile-web-app-*meta tags cover the Add-to-Home-Screen path).npm run check:plugin).Known caveats
wp.hooksslash-rejection bug surfaced during testing — everydesktop-mode/fooactivity-bus channel (toast, badge-changed, presence, my new notification-*) is silently a no-op because hook names with/fail validation. Not introduced by this PR, but worth flagging — the activity-bus filter / observe surface advertised in the docs has never actually fired. One-line fix insrc/activity.tshookName()(replace/with.) belongs in its own PR.chrome://apps+ reinstall to pick up the new brand artwork.🤖 Generated with Claude Code