Skip to content

feat(pwa): installable web app, service worker, local notifications#87

Merged
epeicher merged 5 commits into
trunkfrom
add-pwa-installable
May 6, 2026
Merged

feat(pwa): installable web app, service worker, local notifications#87
epeicher merged 5 commits into
trunkfrom
add-pwa-installable

Conversation

@epeicher
Copy link
Copy Markdown
Collaborator

@epeicher epeicher commented May 5, 2026

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

Surface Behaviour
Web app manifest Served at /desktop-mode/manifest.webmanifest by a parse_request handler. Filterable via desktop_mode_pwa_manifest. Default icons pull from the WordPress Site Icon when set; otherwise from the bundled brand mark under assets/pwa/ (the same artwork as .wordpress-org/icon-*.png, copied in because that directory isn't shipped in the plugin zip).
Service worker At /desktop-mode/sw.js with Service-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.
Install affordance Persistent system 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"). Already-installed state detected via navigator.getInstalledRelatedApps() + a self-reference in manifest.related_applications.
Local notifications wp.desktop.notify({ title, body, icon, tag, onClick }). Uses the browser Notification API; falls back to a toast when permission is denied or unsupported. Filterable + observable via desktop-mode/notification-{requested,shown} activity-bus channels.
Programmatic API 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.).
REST routes GET/POST /wp-json/desktop-mode/v1/pwa-state for per-user dismissal + notification-permission record.

Web Push (v2) is intentionally not in this PR. The SW registers no-op push and notificationclick handlers 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

  • New Vite target pwa-sw builds src/pwa/sw.tsassets/js/sw[.min].js. Mirrors the existing per-target setup.
  • Both bundles gitignored alongside the other Vite outputs (consistent with the existing convention) and added to bin/package.sh's built[] splice list so the release zip includes them.

Docs

  • New docs/pwa.md — architecture, caching policy, scope rationale.
  • New docs/examples/pwa-install.md and docs/examples/notify.md.
  • docs/README.md, docs/examples/README.md, docs/hooks-reference.md, docs/javascript-reference.md, and the AGENTS.md doc-tree updated.

Version

Bumped to 0.8.0 in desktop-mode.php, package.json, readme.txt.

Test plan

  • npm run lint clean.
  • ./node_modules/.bin/tsc --noEmit clean.
  • npm run build produces the new assets/js/sw[.min].js.
  • npm run test:js — 768 / 768 vitest cases pass.
  • Live tested on localhost:8889 via Chrome DevTools MCP:
    • Manifest endpoint serves 200 with all expected fields and 4 icon sizes (128 / 192 / 256 / 512).
    • Service worker registered at scope /, state activated, response carries Service-Worker-Allowed: /.
    • Install tile renders on the side dock between OS Settings and Bug Report.
    • Click → browser install prompt → 'accepted' → app installs → display-mode: standalone flips true.
    • After install, click → "WordPress Develop is already installed. Open it from your apps menu or home screen." toast.
    • wp.desktop.notify({ ... }) renders OS notification when permitted; falls back to toast cleanly when Notification API is hidden.
    • REST /pwa-state round-trip persists the dismissal flag.
  • Verify on Firefox + Edge + Safari iOS (Safari skips beforeinstallprompt by spec; the apple-mobile-web-app-* meta tags cover the Add-to-Home-Screen path).
  • Verify Plugin Check passes against the new files (npm run check:plugin).

Known caveats

  • Pre-existing wp.hooks slash-rejection bug surfaced during testing — every desktop-mode/foo activity-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 in src/activity.ts hookName() (replace / with .) belongs in its own PR.
  • Already-installed PWA users keep the cached install icon (Chrome doesn't auto-refresh manifest icons). Uninstall via chrome://apps + reinstall to pick up the new brand artwork.

🤖 Generated with Claude Code

Open WordPress Playground Preview
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.
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 5, 2026

✅ WordPress Plugin Check Report

✅ Status: Passed

📊 Report

All checks passed! No errors or warnings found.


🤖 Generated by WordPress Plugin Check Action • Learn more about Plugin Check

epeicher added 4 commits May 5, 2026 18:48
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
@epeicher epeicher enabled auto-merge (squash) May 6, 2026 10:02
@epeicher epeicher merged commit f2bab1e into trunk May 6, 2026
5 checks passed
@epeicher epeicher deleted the add-pwa-installable branch May 6, 2026 10:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

1 participant