Repository navigation
pwa
Stable.
OpenStation ships a web app manifest, a service worker, and a local notifications API so users can install their WordPress site as a real OS app and plugins can surface alerts the same way native apps do.
This page is the architectural ground-truth. For the plugin-author's
copy-paste recipes see
docs/examples/pwa-install.md and
docs/examples/notify.md.
| Surface | Behaviour |
|---|---|
| App icons | Full-bleed, opaque, square PNGs under assets/pwa/. The bundled set declares three purposes: any (128/180/192/256/512), maskable (192/512, the tile at 80% so Android's adaptive masks crop into margin), and monochrome (192/512, the silhouette Android 13+ recolours). A WordPress Site Icon takes priority and goes out as any only. The shell also emits <link rel="apple-touch-icon" sizes="180x180"> into the admin head, because core hooks wp_site_icon() to wp_head and login_head but not admin_head — without it an iPhone installing from wp-admin has no tile. See the icon rule. |
| Web app manifest | Served at /openstation/manifest.webmanifest. Site name + short name, theme color, icons (Site Icon when set, plugin logo otherwise), start_url=/wp-admin/admin.php?page=openstation (the shell screen, which resolves the entry from the saved session; installs made with the older index.php?desktop_mode_portal=1 start URL still land there through the alias redirect), scope=/wp-admin/ (narrowed from / so front-end links escape the PWA window; the manifest id stays at /openstation/ so existing installs aren't reset). Filterable via openstation_pwa_manifest. |
| Service worker | Served at <home>/openstation/sw.js with Service-Worker-Allowed: <home path>. Registered at the site's home-path scope (/ on most installs, the site path on a subdirectory network's subsites) with a deliberately narrow fetch handler — it only intercepts paths under its scope's openstation/ and wp-admin/, plus the plugin's own static assets. wp-admin HTML is always network-first (nonces would otherwise drift). |
| Install hint | A system tile on the dock (id: 'os-pwa-install') registered on shell boot — except when the shell is already running standalone. It is removed live when display-mode flips to standalone or when getInstalledRelatedApps() reports the app installed (Chromium); on Safari / Firefox it persists as a fallback. Clicking it dispatches the browser install prompt when the site is currently installable, otherwise shows a contextual toast ("already installed", "not yet"). |
| Local notifications |
wp.os.notify({ title, body, icon, tag, onClick }) — uses the browser Notification API, falls back to a toast when permission is denied or the browser doesn't support it. |
| Push notifications |
Not in v1. The SW registers a no-op push handler (claimed so future v2 push payloads aren't silently dropped); the notificationclick handler is live — it closes the notification, focuses an existing /openstation/ window client, or opens notification.data.url (default /openstation/) when none exists. The same wp.os.notify shape will route through the SW's showNotification once push is wired. |
Every platform masks a home-screen tile itself, and it fills any transparency first. iOS fills with white and then applies its squircle. Android crops to whatever shape the launcher prefers: a circle, a squircle, a rounded square, a teardrop.
So artwork that rounds its own corners installs as a mark floating on a white
square. That is not a theoretical failure, it is how the pre-full-bleed set
installed on an iPhone. The bundled files in assets/pwa/ are therefore square,
edge to edge, with no alpha anywhere. Do not re-round them and do not
reintroduce transparency.
| Purpose | Files | What the platform does with it |
|---|---|---|
any |
icon-{128,180,192,256,512}.png |
Used as-is, masked by the platform. 192 and 512 are named explicitly because Chrome's installability heuristic looks for those exact sizes strings. |
maskable |
icon-maskable-{192,512}.png |
The same tile at 80% on the same ground. Android crops into the margin instead of into the mark. |
monochrome |
icon-mono-{192,512}.png |
Android 13+ themed icons. The launcher recolours the silhouette to the wallpaper palette, so this one is alpha by design. |
maskable and monochrome are claims about how a specific piece of artwork is
composed. We know that about ours and not about someone else's: declaring an
operator's logo maskable when it is not gets it cropped, and pairing their any
with our monochrome would put the OpenStation mark on their app. So
openstation_pwa_default_icons() resolves a Site Icon to any only, and the
maskable and monochrome entries appear exactly when the bundled set is in use.
A corollary worth knowing when someone reports a white tile: a site with a Site Icon never sees the bundled files at all. If the Site Icon has transparent corners, the fix is that upload, not this plugin.
The PNGs are generated from the brand logomark, not drawn per size. The tool and its proof page live in the design repo alongside the brand assets; it renders one 1024px master through headless Chrome (the tile's halo and drop shadows are real SVG filters) and resamples every size from it.
A service worker has exactly one scope path. The only common ancestor of
<home>/openstation/ and <home>/wp-admin/ is the site's home path
itself — / on most installs. Registering at openstation/ 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 the home-path scope (PwaConfig.swScope,
computed server-side by openstation_pwa_sw_scope()), but the fetch
handler returns early (no event.respondWith call) for any URL outside
its scope's openstation/, wp-admin/, or the plugin's own assets
directory. The worker derives those prefixes from
self.registration.scope rather than assuming the origin root.
Behaviorally this is "narrow scope" without inheriting the technical
limitation.
On a subdirectory multisite every site registers its own worker at
its own path scope (/site2/). The browser routes each navigation to
the registration with the longest matching scope, so the main site's
root worker and every subsite worker coexist, and a sibling site's
OpenStation worker is never treated as foreign by the guard below.
Subdomain and domain-mapped networks are separate origins and were
never in each other's way.
Some hosts' web servers short-circuit any path with a static-file
extension straight to the filesystem: /openstation/sw.js 404s at
nginx and never reaches WordPress, while the extensionless manifest
route works fine. For those hosts the same SW bytes are also served at
the extensionless fallback /?openstation_sw=1 — registration
tries the pretty URL first and retries once with
PwaConfig.swFallbackUrl on failure. The fallback URL's path is the
home path, which is also the registered scope, so the fallback needs no
Service-Worker-Allowed header at all, and a
SW registered through it is still recognized as OpenStation's own by
the foreign-SW guard.
If any other service worker (any scope) is already registered on the
origin, the registration bails with a console warning rather than
usurping it. OpenStation's own registrations are never treated as
foreign — that includes the current pretty URL, the extensionless
fallback, and legacy endpoints from before a portal-path move (e.g.
/desktop-mode/sw.js). A browser stuck on such a stale worker (its
old endpoint no longer serves JavaScript, so it can never self-update)
is recovered automatically: registering the current URL at the same
scope replaces it on the next shell load. The "Install <site> as an app" tile then surfaces a
focused toast pointing at the opt-in filter (rather than the generic
"not available" fallback), so users on affected sites see the
actionable message instead of silently broken behaviour.
To opt this install in, return true from the
openstation_pwa_force_replace_sw filter:
add_filter( 'openstation_pwa_force_replace_sw', '__return_true' );The filter resolves at shell-config build time; effective on the next page load. Use this when another PWA plugin's SW is shadowing OpenStation and you want OpenStation to take over the install path.
The shell's own bundles are fetched network-first (see the table below), so any navigation picks up a new build. The gap is that an installed app on a phone rarely navigates: iOS keeps the page alive in the background for days and brings the same document back every time the icon is tapped, and there is no reload button to ask for a newer one. Three things together close that gap:
-
Every release is a new worker. The served
sw.jscarries the plugin version and the shell-build stamp in itsself.__OS_SW_CONFIGpreamble. The bundle itself is content-hashed, so without these a release that touched nothing undersrc/pwa/would serve byte-identical script and the browser — which only installs a worker whose bytes differ — would have nothing to install. -
The shell checks on every return.
registerServiceWorker()callsregistration.update()when the page comes back to the foreground (visibilitychangeto visible, andpageshowfrom the back-forward cache), at most once every five minutes and never while hidden. The script is servedno-cacheand registered withupdateViaCache: 'none', so the check always compares real bytes. -
The new worker waits; the shell asks, and at most offers. The
worker never
skipWaiting()s on its own — it installs and sits inwaiting(the standard prompt-for-update shape). The shell finds it there (registration.waitingat boot, orupdatefound→installedmid-session) and asks which shell build it was served with (os-sw-get-build→os-sw-build). Same stamp as the one the page booted with (PwaConfig.shellBuild), or unknown on either side: the shell tells it to take over (os-sw-skip-waiting), silently — caches refresh, nothing is shown. A different stamp — the shell's own files really changed on the server — shows a persistent toast with a Reload action, and the worker keeps waiting until the user takes it. A release that changed nothing underassets/, a worker-only change, or a preamble that moved for another reason is a new worker and not a word to the user.
The shell never reloads itself. A desktop is somebody's work in
progress; the reload is the user's to take, from the toast, when they
are ready. Taking it does three things in order: the waiting worker is
told to take over and the shell waits for controllerchange (bounded
by a short timeout, so a worker that will not swap cannot hold the
reload hostage); the session is written to the server and the
navigation waits for the answer — an unload beacon racing the request
that reads the session back is how two open windows once came back as
none; then the page reloads, served by the worker that matches it.
Why waiting rather than taking over on install: the activate handler
drops the previous version's caches, so a worker that claimed a page
still running the previous bundle left that page able to ask for a
lazy bundle from a bucket that no longer existed. A page and its worker
now change together. The one case where they cannot is another tab
consenting first — the new worker claims every tab; a page that sees a
controllerchange it did not ask for runs the same comparison against
the new controller and, when the shell changed, makes the same offer.
A first install (no controller yet) activates on its own and is never a
takeover: the bundle came straight from the network and there is
nothing to compare.
The stamp is openstation_shell_build_stamp(): a content hash over
every stylesheet in assets/css/ and every bundle in assets/js/,
memoised behind the files' (path, size, mtime) signature. Bytes, not
clocks — a deploy rewrites every mtime whether or not the contents
moved, and the plugin version moves on releases that never touched the
shell. Between releases a rebuild that changed only desktop.min.js
does not produce a new worker (that is the point of the worker's own
content hash: no phantom activity after npm run build); a cold launch
of the app, or a navigation, is what picks those up.
| Pattern | Strategy | Why |
|---|---|---|
/wp-content/plugins/desktop-mode/assets/**.{css,png,jpg,svg,…} |
Stale-while-revalidate (runtime cache) | Returning users open the shell instantly; the SW updates the cache in the background. |
/wp-content/plugins/desktop-mode/assets/**.js |
Network-first with cache: 'reload' + cache fallback |
JS bundles change per deploy — a fresh deploy reaches online users on the next load, with no stale-revalidate window where a freshly-pushed fix is invisible. The cache still serves offline users. |
Opt-out — versioned Core statics (/wp-admin/**, /wp-includes/** with ?ver=) and load-scripts.php / load-styles.php
|
Exact-URL cache-first (os-admin bucket) |
The ver query embeds the WordPress version, so bytes behind a URL only change when the URL changes — the same contract Core expresses by serving the loader endpoints with a one-year Cache-Control. A warm window-open costs zero HTTP requests for these. Enabled by the admin_asset_cache Extended option (default on), subject to the openstation_pwa_admin_asset_cache filter. |
| Opt-out — versioned plugin/theme statics (`/wp-content/plugins | themes/**with?ver=`) |
Stale-while-revalidate (os-admin bucket) |
| Opt-out — an iframe navigation to a document the shell asked for early | Served from the held response (never re-fetched) | The document is the one thing that can never be cached — admin HTML carries nonces — and it is the majority of a window open. Speculation does not make it cacheable; it moves the wait to before the click. See Speculative documents. |
| Navigation requests under our scope | Network-first with offline fallback | wp-admin HTML carries nonces and per-request screen state; caching it would desynchronise the user. The fallback is a tiny inline placeholder so an offline user sees something coherent. |
| REST / AJAX / non-asset GETs / unversioned asset URLs | Pass-through (no SW handling) | Same reason as navigation — auth-bound dynamic content must hit the network; an asset URL without a ver cache-buster carries no immutability contract. |
install-time precache |
A handful of CSS files, the three critical-path JS bundles (desktop.min.js, window-system.min.js, shell-overlays.min.js), and the plugin logo |
Just enough to render the offline shell skeleton. Anything else is picked up at runtime by the caching paths above. |
The two opt-out asset rows above are the shared admin-asset cache: because the SW is root-scoped, it sees asset requests from the shell and from every window's chromeless iframe, and Cache Storage is origin-wide — so a stylesheet fetched by one window is served locally to every later window, revalidation round-trips included.
Both performance features are enabled site-wide by default. Administrators
can disable them in OpenStation Preferences → Features → Extended
options using Preload windows on hover (window_prewarm) and
Shared asset cache (admin_asset_cache). Changes apply when each
OpenStation shell reloads. Explicit site-wide opt-outs survive partial
saves. The old per-user flags no longer decide these values; see
Performance settings migration.
The openstation_pwa_admin_asset_cache filter can force or veto the
shared-cache option:
add_filter( 'openstation_pwa_admin_asset_cache', '__return_true' ); // force on
add_filter( 'openstation_pwa_admin_asset_cache', '__return_false' ); // kill switchMechanics and caveats:
- The shell posts both flags in an
os-sw-configmessage at boot. The shared-cache flag includes the PHP filter's result. The worker starts with both flags off until configured. Its served script bytes are identical for authenticated and anonymous requests; changing an option requires a shell reload, with no worker update or re-registration. - Classification logic lives in
src/pwa/sw-policy.ts(pure, unit-tested). Only200, non-redirected, same-origin responses withoutno-store/privateare cached;Rangerequests bypass the cache entirely. - Because
/wp-includes/assets are also referenced by front-end pages, a versioned wp-includes asset requested by the front end is cached and served under the same policy — same URL contract, shared benefit. - The bucket is capped (~500 entries, FIFO pruning) and dropped wholesale on every SW version bump, so it cannot grow without bound.
- Known trade-off: a core-path asset edited in place without a
verchange (rare outside development) stays pinned until the URL or the SW version changes. Development setups should keep the filter off or run withSCRIPT_DEBUG.
The cache is keyed by version (os-static-<v>,
os-runtime-<v>). The activate step deletes any cache whose
key doesn't carry the current version, so a deploy doesn't accumulate
stale buckets.
The shared asset cache removes the network from a window's assets. It can never touch the document: admin HTML carries nonces and per-request screen state, so it is uncacheable by construction — and it is the majority of a window open (measured at ~2.1 s of a ~3.8 s tab click on production hosting).
That wait does not have to happen after the click. The shell knows every URL a window can reach; the worker sees every iframe navigation. Connecting them lets a document be fetched while the user is still deciding, and the navigation that follows is answered from those bytes.
This is not "keep the window alive": nothing rendered is retained. No DOM, no live iframe, no memory beyond a response body dropped after 30 seconds. The page is still built fresh — just early.
Two triggers, one mechanism:
| Trigger | What happens |
|---|---|
| Hovering a submenu tab | After a 120 ms dwell, the shell posts os-speculate-doc; the worker fetches that screen and holds it. The tab already on screen is skipped. A tab a native window claims (for example Comments with the native Comments window on) never loads in the iframe, so it warms that window instead, through wp.os.prewarmWindow( id, { params } ), with the params the click will open it with. |
| Booting the shell | The worker replays the previous session's restore list the moment the shell's own navigation arrives — before the server has finished building the shell document, so the two renders overlap instead of running back to back. The list is persisted from os-remember-session, which the session saver posts from both of its paths: the debounced save and the pagehide beacon. The unload path matters most — it carries the state at the moment the tab closed, which is exactly the "close it and come straight back" case this is for. |
Measured on production hosting: window-document TTFB 1,353 ms → ~1 ms, whole shell boot 6,492 ms → ~4,700 ms, a hovered tab click ~3,600 ms → ~1,000 ms.
-
Speculation never acts. A URL carrying
action,action2,_wpnonce,nonceordelete_allis refused, so a hover can never activate a plugin or empty a trash. Same-origin,/wp-admin/only, and theopenstation_chromelessflag must be present. - Held documents are single-use and expire after 30 s, capped at 6 with oldest-first eviction. A document is a moment-in-time view carrying nonces; replaying one twice would show a superseded page.
- The store holds the in-flight promise, not the settled response, so a navigation landing mid-fetch joins the request already running instead of starting a second one for the same screen.
-
Answering an iframe navigation is safe only because the response is never re-fetched. The worker otherwise refuses iframe navigations: re-fetching one makes Chrome send
Sec-Fetch-Dest: empty, the server's chromeless detection falls through, and the whole desktop renders inside a window. A speculative document is fetched once, ahead of time, from a URL carrying the chromeless flag — which the server reads before it consults Sec-Fetch. - The speculative fetch is a plain same-origin GET and does not forward
RefererorAccept-Languagefrom the navigation it stands in for. Admin screens do not branch on either (locale comes from the user's profile, server-side). -
No controlling worker, no speculation.
speculateDocument()is a silent no-op whennavigator.serviceWorker.controlleris null: plain HTTP, a foreign worker on the origin (seeopenstation_pwa_force_replace_sw), or a browser that refuses registration. To check, open DevTools → Application → Service workers on the shell and look for/openstation/sw.jsas activated and running. Native-window warms (wp.os.prewarmWindow) don't need the worker. - Gated on the site-wide Preload windows on hover option (
window_prewarm, default on), exposed to the shell aswindowPrewarmEnabledand posted to the worker aswindowPrewarminos-sw-config. Both sides check it: after an opt-out and reload, the shell skips speculative messages and the worker ignores them.
Both messages are posted to navigator.serviceWorker.controller and are ignored unless preloading is enabled.
// Fetch this screen now; hold it for the navigation that follows.
{ type: 'os-speculate-doc', url: '<absolute same-origin chromeless URL>' }
// Remember these screens for the NEXT boot's replay.
{ type: 'os-remember-session', urls: [ '<absolute>', … ] }The shell-side helpers are speculateDocument( url ) and rememberRestoreTargets( urls ) in src/pwa/speculate.ts. Policy lives in src/pwa/sw-policy.ts (isSpeculatableDocument) and the store in src/pwa/speculative-store.ts; both are pure and unit-tested.
Three more are not gated on any opt-in — they are how a new worker and the shell agree on a takeover (see How a release reaches an installed app). The first two are posted to the WAITING worker (registration.waiting), not the controller:
// Shell → worker: which shell build were you served with?
{ type: 'os-sw-get-build' }
// Worker → the asking client. `shellBuild` is the preamble's stamp, or
// '' for a body served without one — which the shell reads as
// "unknown", never as "changed".
{ type: 'os-sw-build', shellBuild: '<16 hex chars>' }
// Shell → waiting worker: take over now. The only thing that makes the
// worker skipWaiting(); sent silently when the shell did not change,
// and on the user's Reload when it did.
{ type: 'os-sw-skip-waiting' }| Symbol | Role |
|---|---|
openstation_pwa_manifest_url() |
Absolute URL of the manifest endpoint. |
openstation_pwa_sw_url() |
Absolute URL of the service worker. |
openstation_pwa_sw_fallback_url() |
Extensionless fallback URL for the same SW script (/?openstation_sw=1) — for hosts whose web server 404s virtual .js paths. |
openstation_pwa_get_user_state( $user_id = 0 ) |
Read the per-user PWA UI state. |
openstation_pwa_update_user_state( array $patch, $user_id = 0 ) |
Merge a partial update into the state. |
openstation_pwa_admin_asset_cache_enabled() |
Whether the shared admin-asset cache is on (resolves the filter below). |
openstation_shell_build_stamp() |
Content hash of the shell's built files (assets/css/*.css, assets/js/*.js); 16 hex characters, '' when nothing is built. Carried by openStationConfig.pwa.shellBuild and by the worker preamble. |
openstation_pwa_manifest (filter) |
Mutate manifest fields before encoding. |
openstation_pwa_admin_asset_cache (filter) |
Force or veto the shared admin-asset cache site-wide. Default: the site-wide admin_asset_cache Extended option (on unless an administrator opts out). |
REST routes:
-
GET /wp-json/desktop-mode/v1/pwa-state→{ installHintDismissed, notificationsEnabled } -
POST /wp-json/desktop-mode/v1/pwa-state→ merge partial state. Body:{ installHintDismissed?: bool, notificationsEnabled?: bool }.
Both routes require a logged-in user with OpenStation enabled for
their account (openstation_rest_require_enabled() — 401 when logged
out, 403 when OpenStation is off) and a valid X-WP-Nonce.
// All exposed on `window.wp.os`:
wp.os.notify( {
title: 'Build complete',
body: '12 files updated.',
icon: '/favicon.png',
tag: 'my-plugin/build',
onClick: ( n ) => { window.focus(); n.close(); },
} );
const choice = await wp.os.pwa.promptInstall();
// 'accepted' | 'dismissed' | 'unavailable'
await wp.os.pwa.requestNotificationPermission();
// 'granted' | 'denied' | 'default' | 'unsupported'
const state = wp.os.pwa.getState();
// { installHintDismissed: boolean, notificationsEnabled: boolean }
const off = wp.os.pwa.subscribe( ( s ) => {
console.log( 'PWA state changed:', s );
} );
off();Activity-bus channels for plugins that want to mute / amplify / audit:
-
os/notification-requested— filterable; setcancel: trueto suppress the underlying notification. -
os/notification-shown— fire-and-forget; carriesfallback: 'toast' | nullso analytics can distinguish the permission-denied path from the real-notification path.
For Chromium / Edge to fire beforeinstallprompt, the site needs:
- HTTPS (or
localhost). - A valid manifest with a
name(orshort_name), an icon ≥192×192, and astart_url. - A registered service worker that responds to a
fetchforstart_url(we do — the SW's network-first handler covers it). - The user to have engaged with the page for a few seconds (browser heuristic).
Safari (macOS / iOS) doesn't fire beforeinstallprompt. Users add the
app via the "Share → Add to Home Screen" gesture, which picks up our
apple-mobile-web-app-* meta tags emitted from admin_head.
Three things the shell does so that an installed app fits a phone, and where each lives:
It knows it is installed. The head stamp
(openstation_print_mode_stamp(), includes/mobile.php) writes
data-os-display="standalone" on <html> when the display-mode: standalone media query matches or Safari's navigator.standalone
says the page was launched from the home screen, and browser
otherwise; the mode controller keeps it current and exposes it as
wp.os.mode.getDisplay() / isStandalone(). A stylesheet keys on the
attribute the way it keys on data-os-mode. The two are orthogonal:
a phone in Safari is mobile + browser.
It does not zoom. The viewport meta on a shell request
(openstation_mode_viewport_meta()) carries viewport-fit=cover,
interactive-widget=resizes-content, maximum-scale=1 and
user-scalable=no; src/mode/zoom-guard.ts cancels the pinch
gestures the browser would otherwise honour while the document is
stamped mobile or standalone (never on a desktop in a tab, where
zoom is an accessibility affordance); the phone layer sets
touch-action: pan-x pan-y on the root and --os-ui-field-font-size
to 16px, the size under which iOS zooms the page into a focused
control. A plugin's own <input> inside a native window should be
16px on a phone for the same reason; the kit's fields already are.
It paints one surface with the system. The manifest's
theme_color / background_color, the theme-color meta and the
iOS status bar all use the shell's backstop
(OPENSTATION_PWA_THEME_COLOR, #0c0b0f). The status bar is opaque
(apple-mobile-web-app-status-bar-style = black,
openstation_pwa_status_bar_style
to change it): the page is laid out below it and
env( safe-area-inset-top ) is 0. The bottom edge still runs under
the home indicator (viewport-fit=cover), and the phone layer's tab
bar is fixed to that edge with env( safe-area-inset-bottom ) as its
bottom padding — see mobile.md.
-
Phase 4 — Web Push. VAPID keypair, REST routes for subscribe /
unsubscribe, server-side
openstation_push( $user_id, $payload )PHP helper, SWpushpayload renderer wired to the existingnotify()intent shape. The v1wp.os.notifyAPI is the same call site — only the transport changes. - Per-site icon override hint. A small OpenStation Preferences tab entry that lets administrators upload a custom PWA icon without writing a filter.
This wiki is generated from the docs/ directory — edits made here are overwritten by the next sync.
To change a page, open a pull request against docs/.
Guides
- Development guide
- Releasing openstation
- Agents security model
- API Index
- The App Framework — a window in one PHP file
- Architecture
- Bridge protocol — wiring overview
- <os-*> component reference
- Data model — where OpenStation keeps its data
- Native Desktop Host — Experimental
- Desktop themes
- Dock customization — two registries, one mental model
- The event-driven framework
- Files on the Desktop
- Folder sharing
- Getting Started
- Hooks Reference
- Icons
- JavaScript Reference
- The Living Tree — algorithm definition
- Window-scoped MIO
- Mio
- Mobile — the phone layer
- Multisite
- Native Windows & Framework Interop
- OpenStation Network
- Plugin compatibility layer
- Pulse Monitor
- Progressive Web App (PWA)
- Station Home
- Using openstation from your own plugin
- Workspaces
Migration notes
- Migration: built-in activity channels move to the os/ namespace
- Migration — Code Blue becomes an App Framework app
- Migration — AI comment scoring leaves core
- Migration: window, wallpaper and widget bundles load on demand
- Migration — Posts, Pages, Users, User Edit, Plugins and Comments become App Framework apps
- Migration — the navigation model
- Migration — OpenStation Preferences becomes an App Framework app
- Performance settings move to Extended options
- Presence storage migration
- Migration — the Recycle Bin becomes an App Framework app
- Migration — the shell boots from its own screen
- Migration — Station Home becomes an App Framework app
- Migration: a native window's tabs move to the window chrome
- Migration — WP Explorer becomes the my-wordpress app
- Migration — WordPress package globals are no longer ambient
More
All examples
- AI Agents — extend and invoke from a plugin
- wp.os.ai.ask() — programmatic AI Copilot
- Tune the AI model config
- App layout recipes
- Open a child window its owner can't cover
- Style a specific admin page inside the iframe
- Code Blue — register your plugin's log file
- Open a file in the Code editor (deep-link from any window)
- Connect to a window — title-bar button + iframe pub/sub
- Content changes — live-refresh every window listing your type
- Custom window chrome (Experimental)
- Register a custom unfocused-window effect
- Example: render a data table
- Real file storage — react to uploads, gate policy, share from PHP
- React to a window being set free onto the real desktop
- Cross-window devtools — instrumentation primitives
- Add a dock item with a badge
- Decorate the dock without forking the renderer
- Replace the dock rail entirely
- Retune the Drafts widget's AI writing assistant
- Edit a record with one form values map
- Gate OpenStation by role
- Iframe-initiated window opens
- Build a feed reader without the bookkeeping
- Inject data into openStationConfig
- Render a list without losing clicks — renderKeyedList()
- Example: layout primitives (body → panel → row → col)
- Use <os-*> components from a plugin that ships as a zip
- Restyle and drive Mio
- Repairable form edits with MIO
- Register a window companion
- Pin your app to the phone tab bar, and react to the mode
- Add an action that works on a whole selection
- WP Explorer — custom post types and their folder
- WP Explorer — add a column to the list view
- Add an action button to a WP Explorer preview pane
- Example: native Posts window
- Example: native window with tabs
- Native windows
- Customize note → post conversion
- Send a notification
- OAuth relay — connect to an external service
- Ship a window as an .os.php app
- OS-file drop
- <os-flyout> — window-scoped sliding card
- Plugins window — extras
- Track who's around — wp.os.presence
- Example: progress bar
- PWA install — surface your own button
- React to window events
- Example: extend the Trash
- Register a slash-command
- Register a desktop theme from a plugin
- Register a game
- Example: register a desktop icon (Jorvy)
- Register a wallpaper
- Register a widget
- Related entities — extend the title bar's "Related" menu
- The native-window render ctx
- Revisions in their own window — extend or redirect "View revisions"
- Programmatic folder sharing
- Share state across multi-bundle plugins — wp.os.createSharedStore()
- Example: loading spinner
- Add an opt-in card to Station Home
- Observe stored-file cleanup failures
- Accept drops on your desktop icon
- Give a tile two icons, one per state
- Add a row to a window's ⋯ menu
- Example: window activity & the status ring
- Window controls
- Subscribe to window lifecycle events
- Window links — relate windows and restyle the ties (Experimental)
- Window loading state — spinner overlay & ready signal
- Show a banner at the top of a window
- Pulse a window's icon — Window.requestAttention()
- Register a custom window reveal
- Window slots
- Window themes
- Native window with bundle-bound config
- Place something where the user can reach it — wp.os.workArea
- Ship a workspace template