@uued/dsh-desktop
dsh-desktop
Host-plane Cordis plugin for the dsh desktop wrapper — no-compile, cross-platform, installable via the official pnpm flow.
Inside the dsh web process it spawns a minimal native shell and drives it over
stdio JSON-RPC, pointing it at the loopback URL of the already-running Web
server, so the dsh GUI renders in a native window instead of a browser. It
can also open a loopback-only SSH tunnel and switch the SAME window to a remote
dsh instance.
Install (official, one command — no compilation, no manual wiring)
The package is an npm bundle (dsh.bundle.patch ships cordis.patch.yml),
so a single dsh plugin call installs the plugin, its prebuilt platform shell
binary, AND activates it:
# from a registry build (published):
dsh plugin --profile web add @uued/dsh-desktop
# or from a local source checkout (same official command, local path):
dsh plugin --profile web add file:/data/dsh_plugins/dsh-desktop
What happens automatically:
- pnpm installs
@uued/dsh-desktopand itsoptionalDependencies(@uued/dsh-desktop-shell-<platform>-<arch>, os/cpu-filtered) — the prebuilt Tauri shell binary comes with it, no Rust/compilation needed. dsh pluginreconcile seesdsh.bundle.patch→ appends the package to the profile'sdsh.profile.bundles.- At boot, the bundle's
cordis.patch.ymlinserts the plugin row automatically. Do NOT also insert it into the profile's own cordis.patch.yml — that would mount it twice. resolveShellBinaryfinds the binary automatically (config.shell.binary >$DSH_DESKTOP_SHELL>@uued/dsh-desktop-shell-<platform>-<arch>); absent/mismatched → graceful warning, webServer keeps serving.
Scope placeholder: published names use the
@uuedscope. Swap it for your real npm scope in:package.json(name + optionalDependencies),cordis.patch.yml(insertname),lib/client.js(moduleid), the shell'snpm/*/package.json, anddsh-desktop-shell/.github/workflows/desktop-shell.ymlbefore first publish.
The real window lives in ../dsh-desktop-shell (Tauri v2
source; binaries are built once per platform in CI and published as npm
packages). For local development and verification the plugin can be pointed at
the included Node mock shell (config.shell.binary), no binary required.
How it works
dsh web process (this plugin) minimal shell (child)
┌──────────────────────────────────┐ ┌──────────────────────┐
│ inject webServer → real port │ spawn │ WebView window │
│ IpcClient (stdio JSON-RPC) ──────┼────────▶ │ open {url} → navigate │
│ ConnectionManager state machine │ │ setTitle / quit │
└──────────────────────────────────┘ ◀────────┘ ready / closed / navBlocked
- URL always comes from IPC, never baked into the shell (contrast static Pake).
- Only
http://127.0.0.1:<port>URLs are ever handed to the shell; the shell additionally refuses any non-loopback navigation and reportsnavBlocked. - Child lifecycle is managed by the harness
subprocessservice: it spawns a detached, process-grouped tree andterminate()escalates SIGTERM→SIGKILL (Windows → taskkill). Closing the window →closed→ the plugin tears the child down and (by default) asks the whole dsh process to exit gracefully via SIGTERM — no orphan shell or server.
Config (dsh-desktop: namespace in settings.yaml)
dsh-desktop:
local:
autoStart: true # spawn the shell as soon as webServer binds
active: local # which connection the live window uses (switchable)
shell:
binary: /abs/path/to/shell # or set $DSH_DESKTOP_SHELL
cwd: ~
graceMs: 5000
tunnelTimeoutMs: 20000 # seconds/millis to wait for an ssh tunnel to answer
# env: # extra shell-child env; overrides the Linux defaults
# WEBKIT_DISABLE_COMPOSITING_MODE: "1"
# WEBKIT_DISABLE_DMABUF_RENDERER: null # null removes the default
connections:
- id: local # implicit; always available
mode: local
- id: home-server # SSH tunnel to a REMOTE dsh (e.g. a headless server)
mode: ssh
ssh:
host: my.box
port: 22
user: me
key: ~/.ssh/id_ed25519
remotePort: 3080 # the remote dsh `--port`
# extraArgs: ["-o", "ServerAliveInterval=30"]
- id: direct # best-effort remote direct (non-loopback; low trust)
mode: remote
url: http://some-host:3080
security:
allowExternalNav: false
quitOnWindowClose: true # close window → ask dsh to shut down gracefully
If no shell.binary and no $DSH_DESKTOP_SHELL are present, the plugin logs a
warning and stays inactive; the webServer keeps serving normally (so the
browser workflow is unaffected).
On Linux the plugin layers WebKitGTK mitigations onto the shell child env by
default (WEBKIT_DISABLE_COMPOSITING_MODE=1, WEBKIT_DISABLE_DMABUF_RENDERER=1)
— fixes input-method popup / repaint flicker and occasional webview crashes on
several drivers and Wayland compositors. When the dsh theme preference is
explicit (ui-theme.preference = dark/light) it also mirrors
GTK_APPLICATION_PREFER_DARK_THEME so IME popups (fcitx5 etc.) match the page
theme; system/unknown leaves the OS default. The shell itself sets the same
two WebKit vars as a fallback for direct launches. Note: GTK theme is read at
shell spawn, so a mid-session theme change affects the IME popup only after a
dsh restart. Override any value per key via shell.env (a null value removes
the default so the OS/parent value is kept).
In-instance local ⇄ remote (SSH) switching
- At boot the plugin opens the active connection:
localshows this instance's own loopback URL;sshspawnsssh -N -T -o ExitOnForwardFailure=yes -L 127.0.0.1:<LP>:127.0.0.1:<remotePort> user@host(loopback-only — the tunnel is a privileged path to a remote dsh and is never bound to0.0.0.0), probeshttp://127.0.0.1:<LP>/until the remote dsh answers, and points the window at it. - Switch live: change
dsh-desktop.activeto another connection id (via the Settings UI or settings.yaml) while the window is open. The plugin tears down the old tunnel, (re)establishes the new target, and navigates the SAME window viarelaunch— no new process, no flicker of a second shell.ConnectionManager.switchTo(id, config, webServerPort)is the programmatic API the browser-plane UI (M1) will call. - Tray switcher (always available): the shell's tray menu mirrors the
connection list (
setMenu) with the active one checked; clicking a tray item emitsmenuClickand the plugin switches the same window. Because the tray belongs to the LOCAL shell process, you can always switch back to the local instance even while the window displays a REMOTE instance's page (that page cannot reach this plugin's routes — the "can't switch back" failure). - Failure rollback (last-known-good): if a switch target is unreachable or
relaunchfails, the plugin keeps the previous connection identity, restores the previous tunnel best-effort, and reports the failure — the window never silently lands in a half-switched state. - Tunnel lifecycle is owned by the plugin:
teardown()terminates both the tunnel and the shell, so closing the window or exiting dsh leaves no orphanssh/shell process.
stdio JSON-RPC protocol (one JSON object per line)
plugin → shell: {"id":1,"method":"open","params":{"url":"http://127.0.0.1:51777"}}
{"id":2,"method":"setTitle","params":{"title":"dsh — 127.0.0.1:51777"}}
{"id":3,"method":"relaunch","params":{"url":"http://127.0.0.1:51778"}}
{"id":4,"method":"setMenu","params":{"connections":[{"id":"local","title":"local"},{"id":"home-server","title":"home-server"}],"active":"local"}}
{"id":5,"method":"quit"}
shell → plugin: {"event":"ready","params":{"state":"ready"}} (on startup)
{"id":1,"result":{"state":"opened"}} (reply)
{"event":"closed","params":{"state":"closed"}} (window closed)
{"event":"navBlocked","params":{"url":"..."}} (blocked nav)
{"event":"menuClick","params":{"id":"local"}} (tray item clicked)
setMenu rebuilds the shell tray menu (checkable per-connection items + the
active one checked, then Show/Quit); menuClick asks the plugin to switch to
that connection. Older shells answer setMenu with an error which the plugin
ignores — the protocol is backward compatible.
The switch path reuses relaunch so only one WebView window ever exists.
Browser-plane UI (M1) & in-instance switch bridge
A browser module (lib/client.js, wired via package.json dsh.client) adds a
"桌面" capsule to the sidebar footer showing the active connection and a
list to switch local ⇄ remote in place. It talks to the host over same-origin
HTTP routes (registered by the host half once webServer is bound):
GET /plugins/dsh-desktop/state → { active, state, shellOpen, connections: [{id,mode,url,host?,remotePort?}] }
POST /plugins/dsh-desktop/switch → { id } → { ok, switched, from, to, url }
POST …/switch calls the host ConnectionManager.switchTo, which tears down
any old tunnel and navigates the SAME desktop window via relaunch. These
routes are independent of autoStart, so the status/switch control is always
available in the Web UI (the switch simply lands on the live window or spawns
one if none is open).
Verification note: the host routes + switch flow are verified headlessly (see "Testing"), e.g. against the mock shell:
POST /plugins/dsh-desktop/switch {"id":"remote1"}→{ok:true,switched:true, from:"local",to:"remote1",url:"…"}and the mock logsrelaunched …. The React browser module itself follows the establisheddsh.clientpattern but renders in a real browser — give it a visual check in your client instance.
Testing
Unit tests (protocol + state machine, no shell needed):
node --test dsh-desktop/test/unit.mjs # 15 checks
End-to-end with the mock shell against a real dsh web (does not touch the
live instance — uses the workspace DSH_HOME and its own port):
cd /home/hp400g5/plugin_dev
DSH_HOME=$PWD/.dsh-home node \
<dsh>/lib/bin.js --profile web \
--patch dsh-desktop/test/e2e-patch.yml --port 38217
# mock logs to dsh-desktop/test/mock.log:
# ready / opened http://127.0.0.1:38217 / titled dsh — 127.0.0.1:38217
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:38217/ # 200
Close/quit path (graceful shutdown, no orphans):
DSH_HOME=$PWD/.dsh-home MOCK_CLOSE_AFTER_MS=2000 node \
<dsh>/lib/bin.js --profile web \
--patch dsh-desktop/test/e2e-close-patch.yml --port 38219
# process exits 0 shortly after the mock emits "closed"; no mock/dsh leftover
M0 acceptance status
- Plugin resolves
webServer.port(non-zero) and composes the loopback URL. - Spawns the shell →
ready→ sendsopenwith the exact loopback URL; UI serves HTTP 200 (verified via mock shell in a realdsh web). - Close → child torn down + graceful process exit (exit 0), no orphans (verified end-to-end).
- Single-instance guard host-side (refuses a second shell while one is
open) — unit tested; shell-side enforced by
tauri-plugin-single-instance(verify on a real window). - Real native window (needs a Rust/WebKit toolchain + the Tauri shell built):
see
../dsh-desktop-shell/README.md.
Wiring
This workspace's web profile uses the official bundle model (equivalent to
what dsh plugin add produces):
profiles/web/package.json: dependency"@uued/dsh-desktop": "link:…/dsh-desktop"anddsh.profile.bundlesincludes@uued/dsh-desktop(a bundle-declaring dep;dsh pluginadds it automatically).profiles/web/node_modules/@uued/dsh-desktop→ symlink to this directory (pnpm creates this when installing from a registry/package).- No manual
dsh-desktopinsert in the profile'scordis.patch.yml— the bundle's owncordis.patch.ymlmounts the row. - Versioned copies are kept in
../dsh-config/profiles/web.
To install on the live home (~/.dsh / $DSH_HOME) run the official command in
the profile directory (dsh plugin --profile web add …), or mirror the
package.json and node_modules layout above, then refresh.
Corrections to the original design
ctx.scope.dispose()does not exist on this Cordis version. Equivalent cleanup is achieved with actx.effect(...)teardown (runs whenever the plugin scope is disposed) plus, for "close window → exit dsh",requestGracefulShutdown()which sends SIGTERM so the boot layer's bounded teardown runs.- The
subprocessservice (@deepseek-ai/dsh-subprocess-local) already provides detached + process-group lifecycle and tree-scopedterminate()— no need to hand-rolldetached/process-group logic. - Plugin does not inject a
settings/loggerservice:ctx.loggeris a built-in context property, and the settings namespace registration is guarded so it can never abort activation.
Roadmap
- M1 (done): browser-plane client module — sidebar-footer "桌面" capsule with
a connection status + local/remote switcher (
lib/client.js), bridged to the host over/plugins/dsh-desktop/{state,switch}. Remaining: visual QA of the React module in a real browser. - M2 (host side: done): multi-connection, loopback-only SSH tunnel,
switchToin-placerelaunch, tunnel lifecycle tear-down, HTTP bridge. Remaining: RECONNECTING aftertunnelLost, Linux WebKitGTK compat check. - M3: tray polish, code signing, auto-update, per-platform npm distribution + GitHub Actions matrix.