-
Notifications
You must be signed in to change notification settings - Fork 41
desktop host
OpenStation is a web application, and stays one. This document describes an optional layer on top: a small Electron app that loads the same site and adds the one thing a browser tab genuinely cannot provide — real operating system windows.
Don't rebuild OpenStation as a desktop app. Build a reusable OpenStation core, and use Electron as a native desktop shell around it.
OpenStation core
├── Window Manager ← untouched
├── App Registry ← untouched
│
└── extensions
└── Electron Adapter
├── IPC
├── Native windows
├── File system
└── OS integration
Everything Electron-specific lives in
extensions/openstation-electron-adapter/ —
a separate WordPress plugin plus the desktop app it talks to.
Core mentions Electron nowhere.
Two generic capabilities were added to core to make it possible, and both stand on their own:
| Core capability | What it is | Who else can use it |
|---|---|---|
wp.os.registerWindowAction() |
A registry for rows in every window's ⋯ menu | Any plugin with a per-window verb |
?openstation_solo=<id> |
Boot the shell painting exactly one window | Embeds, kiosks, PWA shortcuts |
Deactivate the adapter and OpenStation is exactly the browser experience it was.
With the desktop app running, every window's ⋯ menu grows one row:
- Send to your Mac
- Send to your Windows PC
- Send to your Linux desktop
Picking it takes the window out of the OpenStation desk and opens it as a real window on the user's actual desktop — its own entry in the dock or taskbar, its own Alt-Tab slot, its own place among their native apps. Closing that native window brings it back into OpenStation.
The row toggles rather than duplicating: a window is either here or there, never both, so a row that says what it will do right now describes the situation honestly where two competing rows would imply it could be both.
The OS name comes from the app, not from the user agent. A new platform only needs the app updated.
Two shapes, because OpenStation has two kinds of window:
| Window kind | What the native window loads |
|---|---|
| Iframe windows (any admin screen) | The exact chromeless URL the in-shell iframe was showing — same page, same session, same admin JS. |
| Native windows (Files, Games, plugin canvases) | The shell in solo mode, ?openstation_solo=<window-id>. |
A native window has no URL: it is a render callback painting into the shell's DOM. So solo mode boots the whole shell — every registry, every render callback, every plugin integration — and paints exactly one window and nothing else. It genuinely is the same window, in the same framework, with the desk removed around it.
The decision of which shape applies lives in the adapter
(extensions/openstation-electron-adapter/src/host.ts), never in the
desktop app. The app takes a URL.
?openstation_solo=<window-id> on any admin URL, for a user who has
OpenStation enabled. Adds the os-solo body class, loads
assets/css/solo.css, opens exactly that window, and suppresses
session restore.
It is a rendering mode, not an access grant: the flag is ignored for a user who has not turned OpenStation on, and every capability check on the underlying screen applies exactly as it would anywhere else.
The id is resolved against the native-window registry first, then —
for os-game-<id> — by launching that game, because a game's window is
minted at launch time rather than registered. An id nothing recognises
paints nothing and logs. There is deliberately no fallback to "some
other window": solo mode means this window, and quietly substituting
another is worse than an empty surface.
A second window opened in solo mode has nowhere to go. The surface
paints exactly one, and a window.open() or a plugin calling
openWindow() from inside it produces a window the user cannot see or
reach. Under the desktop host that is handled — the adapter forwards
the request to the app and it becomes a real OS window — but in a plain
embed or kiosk there is no forwarder, and the failure is silent. If you
are embedding solo mode, either keep the embedded screen to one window
or supply your own forwarder for the second.
Core's solo.css hides the desk — wallpaper, dock, both icon layouts
(the legacy grid and the files layer), widgets, Mio, sticky
notes — and fills the viewport with the one window. It
deliberately keeps the window's title bar, because a generic
embedder has no other chrome to offer: no frame, no close button, no
way to move the thing. An embedder that supplies its own chrome layers
a stylesheet on top and takes ours away there. That is exactly what the
Electron adapter does, gated on a body class it sets from JavaScript —
the server cannot know a solo request is being rendered inside the app,
only the page can see the injected global.
openstation_solo_window_id(); // '' unless this is a solo request
openstation_is_solo_request();| Hook | Kind | Signature |
|---|---|---|
openstation_solo_window_id |
filter |
( string $id, string $raw ) — return '' to refuse solo mode. |
Shell config gains one key: soloWindow, the window id or ''.
Everything below ships in the OpenStation — Electron Adapter plugin, not in core.
The desktop app injects, through an Electron contextBridge preload:
window.openStationDesktopHost // in the shell window
window.openStationDesktopFrame // inside a freed windowPresence of the object is the probe. It is synchronous, needs no network, and cannot go stale. Every host-dependent path is behind it, which is what keeps the browser build unchanged.
The server deliberately does not answer "is a host attached right now?" — the same user can have the site open in a browser tab at the same moment, so a server-rendered boolean could only ever be wrong somewhere.
A browser tab probes for the agent at load, and the answer can change underneath it — you start the app, or restart it. So the probe is retried when the tab regains focus and when a ⋯ menu opens: both are user actions, so nothing polls in the background, and both are moments the answer plausibly just changed.
Because an open menu repaints when the action registry changes
(HOOKS.WINDOW_MENU_OPENED), a probe that succeeds under the pointer
puts the row there without a second click. Starting the app never
requires a refresh.
The retry re-fetches the pairing from GET /host rather than reusing
the one baked into the page: the agent's port is ephemeral, so a
restarted app is listening somewhere the page has never heard of.
A chromeless page (?openstation_chromeless=1) is normally only meant
to exist inside an OpenStation window iframe. So the chromeless bridge
treats a top-level one as an accident — a stale bookmark, a bad
redirect — and rescues the user: it strips the flag and reloads as
classic admin, because without an admin bar there is no way to turn
OpenStation off.
A freed native window is the one legitimate exception. It is top-level on purpose, and closing the OS window is the way back.
Any embedder that hosts a chromeless page deliberately sets:
window.openStationChromelessHost = true; // before the page's scripts runThe bridge then leaves the page alone (and still skips the rest of itself, since there is no parent shell to talk to). The desktop app sets it from its freed-window preload, so it survives in-window navigation — which a query flag could not.
Get this wrong and the symptom is memorable: the freed window strips its own flag a few seconds after opening, bounces through the portal, and paints an entire second OpenStation desktop inside a window that was meant to hold one screen.
A freed window runs the shell in solo mode: one window, no dock, no
taskbar, no desk. That is right for the window the user set free and
wrong for a second one — solo's CSS stretches every .os-window to
fill the viewport, so a newcomer covers what the user was using with no
way back to either.
So under a desktop host, anything that opens another window there
opens a new native window instead. Launching a game from a freed
Games hub gives you the game as its own OS window; the hub keeps
showing the hub. The same routing applies to window.open() from any
page in the app, with two deliberate exceptions: off-site URLs and
anything carrying desktop_mode_classic=1 (the ⋯ menu's "Open in
browser tab", which asked for the browser by name) go to the browser.
Freed windows are peers, not a tree. Closing one says nothing about the others.
One consequence for core: solo mode must be able to reconstitute a
window from its id alone, and a window minted at runtime — a game — is
not in the native-window registry. ?openstation_solo=os-game-<id>
therefore launches the game. If an id resolves to nothing, solo mode
paints nothing and logs; substituting a different window looks
graceful and silently hands the user the wrong thing.
Published by the adapter when a host is present. Absent in a browser, so check before use:
if ( wp.os.electron?.isAvailable() ) {
console.log( wp.os.electron.getSendLabel() ); // "Send to your Mac"
await wp.os.electron.free( 'edit-php' );
}| Method | Returns | Notes |
|---|---|---|
isAvailable() |
boolean |
Always true when the namespace exists. |
getInfo() |
HostInfo | null |
Platform, app version, host id, currently-freed ids. |
getSendLabel() |
string |
Translated, OS-adapted. |
getDockLabel() |
string |
"Bring back into OpenStation". |
isFreedWindow() |
boolean |
Whether this page is itself a freed window. |
free( windowId ) |
Promise<boolean> |
Set a window free. Focuses it if already free. |
dock( windowId ) |
Promise<boolean> |
Bring a freed window back. |
listFreed() |
string[] |
Ids currently out on the desktop. |
isFreed( windowId ) |
boolean |
Whether one specific window is out there. |
getConnection() |
ConnectionState |
Last liveness-pulse snapshot. |
| Event | detail |
Fires when |
|---|---|---|
os-desktop-host-freed |
{ windowId } |
A window went out to the real desktop. |
os-desktop-host-docked |
{ windowId } |
A freed window came back. |
os-desktop-host-connection |
ConnectionState |
The connection changed phase. |
Both transitions fire regardless of who initiated them — the ⋯ menu,
a programmatic free(), or the user closing the native window. "Freed"
is one fact with two writers.
Anything that would surface a freed window inside the shell — a dock
click, the window switcher, a plugin calling openWindow() — raises
the native window instead. Without that, clicking Posts in the dock
while Posts is out on the desktop would restore a second copy inside
the shell, and the user would have two Posts windows that know nothing
about each other.
It is enforced on the framework's own window lifecycle hooks
(WINDOW_FOCUSED, WINDOW_RESTORED), so plugin authors get it for
free and never need to check isFreed() before opening a window.
The app registers itself over REST and beats a slow pulse. That record is what lets other requests — a plugin rendering an admin screen, a notification decision — know a desktop is attached, which one JavaScript page cannot answer for anyone but itself.
Every beat is a real PHP request, and OpenStation runs on cheap shared hosting. Four choices keep the cost near zero:
-
The server picks the interval. Every handshake and heartbeat
response carries
heartbeatInterval. A constrained host widens it with a PHP filter and the change takes effect within one beat — no app update. - One user-meta row. No custom table, no autoloaded option, no post type. A beat touches a row already in the object cache.
- Idle costs less. The app skips beats while no window has been focused and nothing is freed onto the desktop.
- Failure backs off. Consecutive errors widen the interval geometrically, so a site that went down is not hammered by every desktop that had it open.
Expiry is evaluated at read time. A stale record simply reads as disconnected; nothing is scheduled to clean it up.
All under openstation-electron/v1 — the adapter's own namespace, not
core's — and all gated on logged-in and OpenStation enabled.
| Route | Method | Purpose |
|---|---|---|
/host |
GET |
Current record + the interval a host should use. |
/host |
DELETE |
Detach. |
/host/handshake |
POST |
Register. Body: hostId, platform, appVersion, protocol. |
/host/heartbeat |
POST |
Liveness beat. Body: hostId. |
/host/disconnect |
POST |
Same as DELETE; used by the app's quit path. |
A handshake declaring a higher protocol than the server understands
is refused with 400 rather than half-accepted. The app degrades to
"no server record" and every client-side feature keeps working.
| Hook | Kind | Signature |
|---|---|---|
openstation_electron_enabled |
filter | ( bool $enabled, int $user_id ) |
openstation_electron_heartbeat_interval |
filter |
( int $seconds ) — default 120, floored at 30. |
openstation_electron_ttl |
filter |
( int $seconds ) — default 600, never below two intervals. |
openstation_electron_config |
filter | ( array $config, int $user_id ) |
openstation_electron_host_connected |
action | ( array $record, int $user_id ) |
openstation_electron_host_heartbeat |
action |
( array $record, int $user_id ) — fires on every beat; keep listeners cheap. |
openstation_electron_host_disconnected |
action | ( array $record, int $user_id ) |
Restrict the native host to editors and up:
add_filter(
'openstation_electron_enabled',
function ( $enabled, $user_id ) {
return user_can( $user_id, 'edit_others_posts' );
},
10,
2
);Halve the request cost on constrained hosting:
add_filter( 'openstation_electron_heartbeat_interval', fn() => 300 );openstation_electron_get_host( $user_id ); // `connected` already accounts for expiry
openstation_electron_set_host( $user_id, $args );
openstation_electron_clear_host( $user_id );
openstation_electron_enabled( $user_id );
openstation_electron_interval(); // seconds
openstation_electron_ttl(); // secondsThe adapter lives inside the OpenStation plugin directory, and
WordPress does not look for plugins in nested folders — so it needs a
symlink into wp-content/plugins/, exactly like every other extension
in this repo:
cd /path/to/wp-content/plugins
ln -sfn desktop-mode/extensions/openstation-electron-adapter openstation-electron-adapter
wp plugin activate openstation-electron-adapterSkip that and everything still works — the app connects, the desktop loads — but no ⋯ menu row appears, because the shell adapter that registers it was never enqueued. It is the first thing to check when "Send to your Mac" is missing.
Then:
cd extensions/openstation-electron-adapter
npm install
npm startFirst run asks for a site address; after that it opens straight into the desktop. See the adapter's README for the IPC contract, the build layout, and packaging.
A desktop host is a browser with a preload in it, and a preload is a capability that the page holds. Everything below follows from taking that seriously.
A window we own stays on the connected site. Both the shell window
and every freed window hold will-navigate and will-redirect to the
same rule popups already followed: on the site it stays, another
http(s) address opens in the user's browser, anything else is refused.
Checking only window.open() would not have been enough — a preload
survives navigation, so an un-targeted link off the site would leave
the new document holding window.openStationDesktopHost.
The one exception is the shell window's first navigation chain,
which runs unchecked so a site answering example.com with a redirect
to www.example.com still connects. What it settles on is not taken on
trust either: adoption is limited to the same registrable name (the
host, or a subdomain of it, in either direction — scheme and port may
move, because an HTTPS upgrade is the most ordinary redirect there is).
A chain that walked somewhere else entirely leaves the configured site
standing, because whatever settles here becomes the agent's single
allowed origin as well as the navigation allowlist.
A site that signs in through an external identity provider will not complete that flow in the app. Jetpack SSO, WordPress.com login and off-site 2FA steps all navigate away from the site after settling, so the guard sends that hop to the user's browser and the app is left on the login screen. Sign in there, or use Use my browser and let the app serve the browser tab as its agent — the whole feature works that way too. Bringing these flows into the app needs a narrow allowance for the provider's origin, which is a change to make deliberately rather than by widening the guard.
The REST root is not the page's to choose. The handshake body
carries the local agent's URL and bearer token, so the restUrl the
shell hands over is checked against the paired site before anything is
sent. A mismatch is refused outright and leaves the connection idle —
it does not fall back to a previously good root.
The local agent has four gates. Loopback binding, a
per-installation bearer token compared in constant time (requiring an
Authorization header also forces a CORS preflight, so a hostile page
cannot fire a blind request), exactly one allowed origin — the paired
site — and a re-check of every URL against that site before a window
opens.
A bad certificate is a question, not a refusal, only for the site the user typed — self-signed certificates are ordinary in local development. The comparison is host-to-host rather than a string prefix, so a lookalike domain does not inherit the prompt.
Experimental. The wp.os.electron shape, the REST routes, and the
solo-mode flag may still move. The capability-probe model — a global
whose absence means "browser, behave normally" — will not, and neither
will the rule that core stays free of Electron.
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
- Architecture
- Bridge protocol — wiring overview
- <os-*> component reference
- 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
- Mio
- Native Windows & Framework Interop
- Plugin compatibility layer
- Progressive Web App (PWA)
- Station Home
- Using openstation from your own plugin
Migration notes
- Migration: built-in activity channels move to the os/ namespace
- Migration: window, wallpaper and widget bundles load on demand
- Migration — the navigation model
- Migration: a native window's tabs move to the window chrome
All examples
- AI Agents — extend and invoke from a plugin
- wp.os.ai.ask() — programmatic AI Copilot
- Tune the AI model config
- Custom arrange-menu action
- 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
- 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
- Add an action that works on a whole selection
- WP Explorer — custom post types and their folder
- 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
- 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
- Programmatic folder sharing
- Share state across multi-bundle plugins — wp.os.createSharedStore()
- Example: loading spinner
- Add an opt-in card to Station Home
- 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