Skip to content

About

Lightweight native Linux desktop window for DeepSeek Harness — Go + WebKitGTK, no Electron

Resources

Stars

0 stars

Watchers

0 watching

Forks

 
 

Repository files navigation

dsh-desktop

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             │
└──────────────────────────────────────────────────────────┘

Why not Electron

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.

Requirements

  • Linux x86_64 (any distro with GTK 3 and WebKitGTK 4.1)
  • Go 1.21+ to build
  • dsh on your PATH (or pass --dsh)

Runtime packages, Debian/Ubuntu:

sudo apt install libgtk-3-0 libwebkit2gtk-4.1-0

For 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-config

Fedora: sudo dnf install golang gtk3-devel webkit2gtk4.1-devel Arch: sudo pacman -S go gtk3 webkit2gtk-4.1

Install

./scripts/install.sh

Installs 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.

Usage

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

The window size

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.

The tray icon

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   # Fedora

Neither 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 port

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.

When the app is killed

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.

The -profile flag is the important one

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 work

The profile name is also the app name — dsh web boots the "web" profile — so the flag is handed to the host as --profile <name>.

Unreadable menus

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 theme

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.

Blank window?

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=false

Both 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.

Updates

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 binary

Both 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.

Develop

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.gz

Tests

make 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.

Layout

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

Why the pkg-config shim

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.

Known limits

  • Linux only. The Go code is portable but the window layer (WebKitGTK) is not wired for macOS/Windows here; those need the webview_go Cocoa/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 dsh CLI and the profile's plugins are updated by the dsh-autoupdate plugin.
  • dsh must resolve. If the CLI is not on PATH, pass --dsh <path> or set DSH_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.

Licence

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.

About

Lightweight native Linux desktop window for DeepSeek Harness — Go + WebKitGTK, no Electron

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages