Skip to content

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

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

feat(pwa): installable web app, service worker, local notifications#87
epeicher merged 5 commits intotrunkfrom
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

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant