A small native Linux desktop window for DeepSeek Harness.
It boots a Harness profile (dsh web) as a child process, reads the authenticated
loopback URL the host prints on stdout, and loads that URL in a WebKitGTK window.
┌─ dsh-desktop (Go, ~2.8 MB) ──────────────────────────────┐
│ spawns: dsh web --port 0 --no-open │
│ reads: "dsh web: http://127.0.0.1:37907/?token=..." │
│ shows: that URL in a native WebKitGTK window │
│ close: hides to the tray; Quit there ends the app │
│ quits: kills the whole host process group │
└──────────────────────────────────────────────────────────┘
| dsh-desktop | Electron shell | |
|---|---|---|
| Runtime you install | WebKitGTK + GTK 3 (already on any GTK desktop) | bundled Chromium (~150 MB) + Node |
| Binary size | 2.8 MB | 120–330 MB |
| Extra Node runtime | none — reuses your installed dsh |
bundles a second Node |
| Memory | one WebKit render process | Chromium multi-process |
| Source | ~600 lines of Go | thousands, plus a build toolchain |
Because it does not fork or patch upstream, it shares ~/.dsh with your CLI:
the same sessions, settings, and agent presets.
- Linux x86_64 (any distro with GTK 3 and WebKitGTK 4.1)
- Go 1.21+ to build
dshon yourPATH(or pass--dsh)
Runtime packages, Debian/Ubuntu:
sudo apt install libgtk-3-0 libwebkit2gtk-4.1-0For the tray icon, add one of libayatana-appindicator3-1 (Debian/Ubuntu) or
libayatana-appindicator-gtk3 (Fedora). Both are optional: without either, the
window closes as it always did — see The tray icon.
Build packages:
sudo apt install golang libgtk-3-dev libwebkit2gtk-4.1-dev pkg-configFedora: sudo dnf install golang gtk3-devel webkit2gtk4.1-devel
Arch: sudo pacman -S go gtk3 webkit2gtk-4.1
./scripts/install.shInstalls per user, no sudo:
| Path | What |
|---|---|
~/.local/bin/dsh-desktop |
the binary |
~/.local/share/applications/dsh-desktop.desktop |
application-menu entry |
~/.local/share/icons/hicolor/*/apps/dsh-desktop.png |
icons, 16→512 px |
Then launch DeepSeek Harness from your application menu, or run dsh-desktop.
Uninstall with ./scripts/install.sh --uninstall. Your ~/.dsh data is never touched.
dsh-desktop [options]
-profile string Harness profile to boot (default "web")
-port int loopback port for the host (default 21777)
-width, -height initial window size (default: a share of the monitor;
setting either flag pins the size instead)
-dsh string path to the dsh executable
-no-dmabuf disable WebKit's DMA-BUF renderer (default true)
-safe-render additionally disable WebKit compositing mode
-opaque-menus make the client's translucent menus opaque (default true)
-tray tray icon, and close the window to it (default true)
-debug enable the WebKit inspector
-verbose echo the host's output to stderr
-check-update print the newest release and whether it is newer, then exit
-update download the newest release, replace this binary, and exit
-update-check check for a newer release after boot and show a banner (default true)
-selftest 25s load, inspect the rendered page, print JSON, exit
-version print the version
A fixed size is either cramped or overwhelming depending on the screen, and it lands wherever the window manager puts it. So the window opens at 78% x 88% of the primary monitor — two-thirds of the area — and is centred in the work area, the part of the screen the desktop is not using for panels:
1920x1080 screen 1498 x 950
2560x1440 screen 1997 x 1267
800x600 screen 720 x 528 minimum width wins
1920x1080, 200 px of panels 1498 x 880 capped by the work area
960x540 (a 1920x1080 screen at 200% scaling) 749 x 520 both minimums win
The minimum is applied before the work-area cap. The other order would let a tiny screen produce a window larger than the space it has to live in.
Both numbers come from GTK in application pixels, the same unit it sizes and moves windows in, so a HiDPI screen needs no arithmetic: GTK already reports a 1920x1080 screen at 200% scaling as 960x540.
-width and -height turn this off: an explicit size is an instruction, so
the window keeps it. Position is still centred, and on Wayland the compositor
places the window itself — moving a window by request is an X11 privilege.
If the display cannot report its geometry — a headless run — the flags' defaults of 1280x840 are used and nothing else changes.
Closing the window hides it in the tray instead of ending the app. That is the only order that makes sense once the window can be gone from the screen: hiding it without an icon would leave no way back to it.
The icon carries the menu:
仅重启前端 reload the page; the host is not touched
仅重启后端 respawn the Harness host, then load its new address
完全重启 replace this process with a fresh one
───────────
退出 stop the host and end the app
仅重启前端 runs on a single click: it loads the page again and nothing else, which is the cheap way back from a client that has wedged itself. The host keeps running, so the address, the token, and the window all stay as they were.
仅重启后端 and 完全重启 ask first, in a GTK dialog with Cancel as the default, because both lose something. The backend restart stops the host process group and boots a new host; the window keeps its page while that happens and follows the new address when the host publishes it. The full restart starts a replacement process — which waits for the profile lock — and then ends this one, so the app is never missing for longer than the handover. The replacement gets the same arguments and the same profile, and starts its own host.
Either way the window is surfaced before the dialog opens: the menu is reachable while the window is hidden, and a modal dialog over a hidden window would look like a hang.
A left click on the icon surfaces the window. Starting the app again while it is
open — hidden or not — does the same thing, over a per-profile unix socket in
$XDG_RUNTIME_DIR, instead of reporting that one is already running.
-tray=false turns the icon off, and then closing quits again. The icon needs
one of the two indicator libraries, at run time only:
sudo apt install libayatana-appindicator3-1 # Debian/Ubuntu
sudo dnf install libayatana-appindicator-gtk3 # FedoraNeither is a build dependency: the entry points are loaded when the window is created. Without either library the app starts exactly as before, says once on stderr why closing will quit, and leaves the window's old meaning in place.
The icon is a StatusNotifierItem, so it works on X11 and on Wayland. GNOME still needs its AppIndicator extension to show any tray icon, which is a GNOME decision, not this app's.
The host listens on 21777 by default — above 20000, below the Linux ephemeral range (32768–60999), so the OS is unlikely to hand the same port to something else while the window is open. A stable port means the URL survives restarts.
dsh itself does not fall back when its port is taken; it aborts with
EADDRINUSE. So the app picks the port before spawning:
- 21777 free → uses 21777.
- 21777 taken → walks upward and uses the next free port (21778, 21779, …), up to 64 candidates. Every candidate stays above 20000.
-port 0→ hands the decision to the OS, which may pick any port.
A probe cannot reserve the port for the child that will actually bind it, so a lost race is retried from the next free port rather than shown as an error.
The host runs in its own process group, so one signal reaches every process in
it: the Harness server, its MCP bridges, and whatever they started. That is what
makes quitting complete. Closing the window (-tray=false), quitting from the
tray, SIGINT, SIGTERM, and both restarts were checked process by process —
the host, the WebKit processes, and the plugin children all go, no zombie is
left, and the runtime socket is removed.
The price of that separate process group is that the kernel will not take the
host down when this process dies without running cleanup: a SIGKILL, an OOM
kill, a crash. That case is covered on the way back up instead. While it runs,
the host's pid and the kernel's start time are written next to the profile lock,
and the next launch — which holds the lock, so no instance is running — ends
whatever is still alive before starting its own:
dsh-desktop: ending a host left behind by a previous run (pid 57341)
The start time is the safety net. A pid on its own may have been reused between the crash and the next launch, and a process that is not ours is never signalled.
Your agent presets — including montir — live in a profile, not in the app.
The default web profile is the one this machine already uses, so the desktop
window sees exactly the same preset roster and the same default as the CLI.
If you keep presets in another profile, pass it:
dsh-desktop -profile workThe profile name is also the app name — dsh web boots the "web" profile — so
the flag is handed to the host as --profile <name>.
The client styles menu surfaces as --dsw-menu-surface-fill — 45% opaque in the
dark theme (#43454a73), 58% in the light one (#f8f9fa94) — and leans on
backdrop-filter: blur(40px) for contrast. That works on Chromium, where the
blur composites the page behind the menu. On this render path the blur does not
composite, so the menu draws as a see-through panel and its labels are hard to
read against the content underneath.
-opaque-menus (on by default) replaces the fill with the same colour made
opaque, which is what the menu looks like once the blur has done its work:
dark theme #43454a at 45% over the #151517 base -> #2a2b2e
light theme #f8f9fa over the #ffffff base -> #f8f9fa
The blur is switched off with it, since nothing depends on it any more. Pass
-opaque-menus=false to keep the upstream translucency.
The replacement follows the client's theme, because the labels inside the
menu are themed too. The client marks its dark theme with data-ds-dark-theme
on <body>, so the two rules track it live. A fixed dark fill does not: under
a light theme it paints a dark panel behind dark labels — a menu that opens
and looks like nothing at all.
The client follows the desktop's colour scheme — its theme preference is
system unless you change it in Settings → Appearance. The shell follows it
too, and keeps following it.
WebKitGTK resolves prefers-color-scheme once, when the view is created, so a
scheme change made while the app is open never reaches the page: the media
query does not change, and the client keeps painting the theme it started
with.
The shell therefore subscribes instead of polling. gdbus monitor watches the
portal's SettingChanged signal — the XDG appearance stream that Plasma and
GNOME both publish — and gsettings monitor is the fallback, so the window
learns about a change the moment it happens and costs nothing while nothing
does. The answer is handed to the page, which repaints the client from it.
That hand-off has to be defended: the client's own theme presenter repaints
from the stale media query whenever it publishes a snapshot, which would flip
the window back. A MutationObserver on <body> catches that and re-applies
the desktop's answer, so the correction is event-driven too.
A theme pinned in Settings → Appearance belongs to the user, not to the
desktop: the shell only speaks for the client while it is following the
system (data-ds-theme-source="system"), and stops as soon as it is pinned.
The menus follow the client wherever it ends up: the shell's override keys off
the client's own data-ds-dark-theme attribute, so the app in light mode gets
light menus and the app in dark mode gets dark ones, both readable.
Some drivers (notably NVIDIA under Wayland) cannot give WebKit a DMA-BUF buffer
and the engine aborts with Could not create GBM EGL display: EGL_SUCCESS.
-no-dmabuf is therefore on by default. If the window still renders blank or
garbled, add -safe-render. To opt out entirely:
dsh-desktop -no-dmabuf=falseBoth flags only set environment variables, and never override a value you set
yourself — so WEBKIT_DISABLE_DMABUF_RENDERER=0 dsh-desktop wins over the default.
The shell updates itself from this repository's GitHub releases:
dsh-desktop -check-update # dsh-desktop 0.1.3 (latest 0.1.4)
# Update available: 0.1.4 — install it with: dsh-desktop -update
dsh-desktop -update # download, verify the archive, replace the binaryBoth are ordinary commands: no window, no profile, no single-instance lock, so an
update runs while a window is open. The replacement is a rename of the new
binary over the old path — the only mutation — so a failed download or a
truncated archive leaves the working install untouched. The binary it replaced
is kept beside it as dsh-desktop.backup; restore it by hand if a release
misbehaves.
On launch the app also checks in the background (4 s timeout, once) and, when a
newer release exists, shows a dismissible banner in the window and a desktop
notification. It never installs anything by itself — a self-replacing binary
is not something to do behind the user's back. Disable even the check with
-update-check=false or DSH_DESKTOP_NO_UPDATE_CHECK=1.
This updates the shell. The dsh CLI and the plugins in your profile are a
separate stack with its own release cadence; the dsh-autoupdate plugin reports
and applies those.
make build # -> dist/dsh-desktop
make run # build and launch
make test # unit tests + full smoke test
make unit # unit tests only (no display, no dsh needed)
make vet # gofmt + go vet
make icons # re-render assets/icons from assets/icon.png
make release # package dist/dsh-desktop-<version>-linux-x86_64.tar.gzmake unit covers port selection, the URL pattern, and the default port range.
It needs no display and no dsh install, so it runs anywhere:
ok github.com/folkwayse/dsh-desktop-linux
make test adds the end-to-end check. It boots an actual host, loads the actual
UI in an actual WebKit window, then reaches into the rendered DOM and reports
what it finds:
1. boot, load, inspect
PASS process exited 0
PASS window loaded a page
PASS app root element rendered
PASS client UI is interactive
PASS stylesheet bundle applied
PASS no page-level errors
PASS reached the empty-session view
PASS agent presets on the roster
PASS agent preset menu opened
PASS menu surface is opaque
PASS workspace picker opened
PASS workspace menu is opaque
PASS menu fill follows the theme
PASS menu labels contrast with the surface
2. clean shutdown
PASS no orphan processes left behind
This matters because "the process started and a window exists" is compatible
with a completely blank page. Only inspecting the live document distinguishes
the two. The orphan check compares PID sets before and after, not command-line
patterns — pgrep -f matches the test script's own arguments and reports
phantom orphans.
| Path | Purpose |
|---|---|
main.go |
everything: flags, host supervision, WebKit window, self-test |
update.go |
the self-update engine: release feed, version compare, atomic install, banner |
main_test.go |
unit tests for port selection and the URL pattern |
update_test.go |
unit tests for version compare, asset pick, and a real download-and-replace |
pkgconfig/webkit2gtk-4.0.pc |
forwards the legacy 4.0 pkg-config name to 4.1 |
scripts/install.sh |
build + per-user install (and --uninstall) |
scripts/smoke.sh |
the end-to-end test |
scripts/render-icons.py |
rasterizes assets/icon.png into assets/icons/ |
assets/icon.png |
icon master, 512 px; the sizes in assets/icons/ are rendered from it |
The C library vendored inside webview_go still asks pkg-config for
webkit2gtk-4.0. Ubuntu 26.04 ships only webkit2gtk-4.1, which is
API-compatible for everything webview.h touches. Rather than patch the vendored
dependency, pkgconfig/webkit2gtk-4.0.pc forwards the old name to the new
library. make and install.sh put that directory on PKG_CONFIG_PATH.
- Linux only. The Go code is portable but the window layer (WebKitGTK) is not
wired for macOS/Windows here; those need the
webview_goCocoa/WebView2 paths. - No tray icon. Closing the window quits the app and stops the host. The Electron shells keep a tray instead.
- Updates only what it ships. The shell replaces its own binary; the
dshCLI and the profile's plugins are updated by thedsh-autoupdateplugin. dshmust resolve. If the CLI is not onPATH, pass--dsh <path>or setDSH_BIN.- Single instance per profile. A second launch does not open a second window: it surfaces the one that is already open, hidden in the tray or not.
MIT — see LICENSE. Third-party notices, including the MIT text for the
bundled webview_go and the C webview implementation it vendors, are in
THIRD_PARTY_NOTICES.md.