npm.io
0.1.6 • Published 1h ago

@uued/dsh-desktop

Licence
MIT
Version
0.1.6
Deps
1
Size
76 kB
Vulns
0
Weekly
0

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:

  1. pnpm installs @uued/dsh-desktop and its optionalDependencies (@uued/dsh-desktop-shell-<platform>-<arch>, os/cpu-filtered) — the prebuilt Tauri shell binary comes with it, no Rust/compilation needed.
  2. dsh plugin reconcile sees dsh.bundle.patch → appends the package to the profile's dsh.profile.bundles.
  3. At boot, the bundle's cordis.patch.yml inserts the plugin row automatically. Do NOT also insert it into the profile's own cordis.patch.yml — that would mount it twice.
  4. resolveShellBinary finds 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 @uued scope. Swap it for your real npm scope in: package.json (name + optionalDependencies), cordis.patch.yml (insert name), lib/client.js (module id), the shell's npm/*/package.json, and dsh-desktop-shell/.github/workflows/desktop-shell.yml before 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 reports navBlocked.
  • Child lifecycle is managed by the harness subprocess service: it spawns a detached, process-grouped tree and terminate() 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: local shows this instance's own loopback URL; ssh spawns ssh -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 to 0.0.0.0), probes http://127.0.0.1:<LP>/ until the remote dsh answers, and points the window at it.
  • Switch live: change dsh-desktop.active to 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 via relaunch — 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 emits menuClick and 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 relaunch fails, 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 orphan ssh/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 logs relaunched …. The React browser module itself follows the established dsh.client pattern 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 → sends open with the exact loopback URL; UI serves HTTP 200 (verified via mock shell in a real dsh 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" and dsh.profile.bundles includes @uued/dsh-desktop (a bundle-declaring dep; dsh plugin adds 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-desktop insert in the profile's cordis.patch.yml — the bundle's own cordis.patch.yml mounts 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 a ctx.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 subprocess service (@deepseek-ai/dsh-subprocess-local) already provides detached + process-group lifecycle and tree-scoped terminate() — no need to hand-roll detached/process-group logic.
  • Plugin does not inject a settings/logger service: ctx.logger is 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, switchTo in-place relaunch, tunnel lifecycle tear-down, HTTP bridge. Remaining: RECONNECTING after tunnelLost, Linux WebKitGTK compat check.
  • M3: tray polish, code signing, auto-update, per-platform npm distribution + GitHub Actions matrix.