Skip to main content
D

deepseek-harness-desktop-app

desanv01/deepseek-harness-desktop-app

Updates and plugin management for the DeepSeek Harness desktop app: a sidebar entry beside Settings and a Settings section, driven by the app's native bridge.

Install

dsh plugin --profile web add github:desanv01/deepseek-harness-desktop-app

README

DeepSeek Harness Desktop App

A native Windows desktop app for DeepSeek Harness — it starts the harness server on launch, opens straight into your project, and shuts the server down when you close the window.

MIT Windows .NET C%23 WebView2 Status

DeepSeek Harness normally runs as dsh web in a terminal and is opened in a browser tab. This app removes that step: one executable boots the harness, renders its interface in an embedded WebView2 window, and owns the server lifecycle end to end. No terminal, no browser profiles, no manual start or stop.

Project status: this is a working baseline, built and verified against @deepseek-ai/dsh 0.1.5-rc.1 on Windows 11 (see Current status for the exact environment). The launch, attach, and shutdown paths are tested. A signed self-update is not implemented yet.


Screenshots

The DeepSeek Harness UI inside the native window, with the window icon and title bar themed to match the page.

DeepSeek Harness native window

Window chrome follows the rendered page — dark page, dark title bar.

DeepSeek Harness native window, dark chrome

Why this exists

Running the harness by hand means opening a terminal, changing into a project directory, typing a command, copying a tokenized URL, and remembering to stop the server afterwards. That is friction on every session, and it is easy to leave an orphaned server holding a port.

This app turns that sequence into a double-click, and makes the server's lifetime the same as the window's lifetime:

  • no terminal and no command to remember;
  • the window opens on the project you selected, not a generic dashboard;
  • the port is chosen by the operating system, so there is nothing to conflict with;
  • closing the window stops the server, and so does crashing the app.

The goal is a dependable desktop shell for a local harness — not a launcher script with a window bolted on.

What it does

  • No-terminal launch — starts dsh web --no-open as a hidden child process. No console window is ever shown.
  • Fast launch — the CLI is probed cheaply (0.2 s) instead of being verified with a full dsh web --help (7–8 s) before every boot; the deep check runs only as diagnosis when something fails. The embedded browser is warmed while the server boots, the two update checks wait for the first paint, and — with keep-alive on — a later launch attaches to the running server in seconds instead of booting.
  • The harness keeps running — by default the server outlives the window, so closing and reopening the app is an attach rather than a boot. The tray's "Stop server and exit" and --stop end it, and --no-keep-alive restores "closing the window stops the server".
  • Remembers your project — the chosen folder, home, and port are saved to settings.json. Later launches open the same project with no flags; the first launch shows a picker with your recent projects.
  • Opens your project — --project <dir> becomes the server's working directory, which is what scopes the harness workspace. The window title shows the project name.
  • OS-assigned port — starts with --port 0 and reads the real address from the ready line the harness prints, so port conflicts cannot happen.
  • Verified endpoint — confirms the served root document contains the DSH bootstrap global before showing it, so an unrelated local service is never embedded.
  • Guaranteed shutdown — the child is assigned to a Windows job object with kill-on-close. When the app exits, crashes, or is force-killed, the server and its descendants are terminated by the OS.
  • One server per home — a named mutex keyed by DSH_HOME means a second launch hands focus to the running window instead of starting a second writer over the same session files.
  • Tray icon — present for the whole window lifetime: hide the window to the tray, bring it back, open the project folder or the log directory, see the installed harness version, check for a newer harness, and stop the server.
  • Ordinary window behavior — minimizing minimizes to the taskbar like any other window; hiding to the tray is an explicit tray-menu action, and closing the window still stops the server.
  • Harness version aware — on launch it reads the npm latest and alpha dist-tags once and shows the result in the tray. The check is read-only; installing is a deliberate click that verifies the CLI before the window restarts.
  • Finds the harness wherever npm put it — the CLI is located the way a shell locates it: the dsh shim on PATH is read for the entry point it runs, the package's own package.json bin is honoured, and a global prefix outside PATH is found through npm prefix -g. A custom npm prefix, a pnpm-style store, or a package layout change therefore still resolves.
  • Repairs a broken harness install — an interrupted npm install -g leaves the package directory behind without the files the CLI needs. The app says exactly that instead of claiming the harness is not installed, and offers to install it; --update does the same without asking, and --repair-harness does it from a script. Before npm touches a working install, the app moves it aside and puts it back if the result does not validate, so a failed update never costs you a working harness.
  • Update aware — on launch it also asks GitHub for a newer build of this app, caches the answer for six hours, and announces one when it exists: a tray notification, a line in the tray menu, a pill inside the harness page, and the version in the window title. Detection reads release metadata only.
  • In-app updates — the Updates UI lives inside the harness UI, where this harness expects extensions to live. A bundled DSH plugin contributes a sidebar entry beside Settings, a sidebar panel row, and a Settings section, and talks to the app through a versioned page bridge (window.__dshDesktop). Opening updates selects the plugin's own panel in this window — a layout action the page performs itself, so nothing appears beside the app and nothing depends on the bridge being up. The app keeps what a page must never hold: the download, the checksum verification, the staged swap, and the restart. The native updates window remains as the fallback surface: the tray menu, --updates, and any case where the page cannot show a panel (no page yet, plugin missing, bridge not answering).
  • The plugin ships two ways — the executable carries it and installs it into the home the app owns, with no package manager and no network; the same package is published to npm, so dsh plugin add dsh-plugin-desktop-updates installs it into any home like any other DSH plugin. A boot leaves an installed copy that is newer than the bundled one alone, so the npm path is not undone on the next launch; --install-plugin restores the bundled copy deliberately.
  • Plugin management — the same section installs, switches and removes harness plugins: ours and third-party ones, by npm name, git spec, or local folder. pnpm is carried by the build (extracted from the executable, or fetched with npm when the build shipped without it), so dsh plugin add works on a machine that has never installed pnpm. Enable/disable goes through the profile's patch layer, so a plugin can be taken out of the tree without uninstalling it.
  • Safe mode — a plugin the harness cannot load aborts the whole profile. The app reads the loader's own message, disables the offending plugin, and boots again once, so a bad plugin costs a notification instead of a window that never opens. --safe-mode boots with the base bundles only: it sets aside the plugins this home added, keeps the base bundles and this app's own plugin (the updates panel and settings section live in it, and an escape hatch that removes the way out is not one), and records what it removed so --exit-safe-mode can put the same list back in the same order.
  • Staged, never in place — the running executable is never overwritten while it runs. The download is verified before the swap, the previous build is kept as <exe>.old until the new one has stayed up, and a checksum mismatch discards the download outright.
  • Tells you when a plugin has moved on — the app installs, removes, enables and disables plugins, but never said that an installed one had a newer version published. --check-plugins compares each installed plugin against its registry latest and names the ones that are behind. A plugin installed from a local path or a git spec has no published version to compare against, so the recorded spec decides and it is reported as unchecked rather than as up to date — "cannot tell" is never shown as current. The check installs nothing; --add-plugin <name>@<version> is what acts on it.
  • Bounded logs — desktop.log rotates at 4 MB, and every other log the app writes (the per-boot server-*.out.log / .err.log pair, update runs, profile init and the rest) is pruned at startup to the newest 60 files or 7 days, whichever comes first.
  • The harness's own errors reach the log — the harness keeps runtime warnings and errors in an in-memory ring that nothing exports, its default level filters warnings out of that ring entirely, and session activation failures never reach its logger at all. --install-log-bridge adds a bundled plugin that records those to stderr, where this app already writes them to desktop.log, so a boot that fails after startup can say why instead of only that it failed. Every line carries [harness-log], which the app's loader-failure parser skips, so bridged chatter can never change which plugin a recovery pass blames. It is installed on request rather than on every launch, and its row can be switched off through the profile patch layer without uninstalling it.
  • Imports a web-version home — dsh web keeps everything in ~/.dsh, so someone who has been using the web UI would otherwise start here with an empty app. The first launch that finds such a home, with this one still unused, offers to copy it: settings, stored credentials, sessions, storages, agent presets, skills, attachments, the profile's own patch layer, and each community plugin's declaration — never the package tree, which is the package manager's output and tied to the tree that produced it. The original is only ever read, the copy is staged and promoted by a single rename so an interrupted import cannot leave a half-populated home, and the answer is recorded so the question is asked once. --import-web-home and --skip-web-home do either answer without being asked.
  • Opt-in updates — --update runs npm install -g @deepseek-ai/dsh@latest behind a lock and validates the CLI before booting. Without the flag, a launch never mutates a working install.
  • Theme-aware window — the title bar and border follow the rendered page background, and the window icon follows the Windows theme.

How it works

flowchart LR
    Start[Launch] --> Resolve{Project resolved?}
    Resolve -->|no| Picker[Project picker]
    Resolve -->|yes| Lock{Home lock}
    Picker --> Lock
    Lock -->|owner| Spawn[node dsh web --port 0]
    Lock -->|busy| Focus[Bring the running window forward]
    Spawn --> Job[Windows job object]
    Spawn --> Ready[Read ready line]
    Ready --> Verify[Verify DSH bootstrap marker]
    Verify --> Window[WebView2 window on the project]
    Window -->|window closed or app killed| Job
    Job --> Stop[Server terminated]

Launch sequence

  1. The app loads settings.json and resolves the project: --project, then the remembered folder, then an interactive picker with the recent list.
  2. It resolves node and the global @deepseek-ai/dsh entry, then validates that the CLI loads.
  3. It acquires the per-home mutex. If another instance owns the home, it signals that window to come forward and exits.
  4. It spawns dsh web --no-open --host 127.0.0.1 --port 0 with the project as the working directory and the chosen DSH_HOME.
  5. The child is assigned to a kill-on-close job object before any output is consumed.
  6. The app reads the child's stdout for the ready line — dsh web: http://127.0.0.1:<port>/?token=<token> — which carries both the port and the access token.
  7. It probes that authenticated URL and requires the DSH bootstrap marker in the root document.
  8. The WebView2 window opens on that URL. The lease (PID, start time, port, URL, project) is persisted for other instances, and the resolved choices are saved to settings.json.
  9. On close, the app kills the exact child it started, disposes the job handle, and removes the lease.

Requirements

RequirementNotes
Windows 10/11 x64Windows 11 22H2 or newer enables the full title-bar theming
WebView2 RuntimePreinstalled on Windows 11 and most Windows 10 machines; the window shows the official install link if missing
Node.jsRequired by the harness itself
@deepseek-ai/dshInstalled globally: npm install -g @deepseek-ai/dsh
.NET 8 SDKOnly to build from source; the published portable build bundles the runtime

Quick start

Run a published build

  1. Download DeepSeekHarness-win-x64-<version>.zip from Releases.
  2. Unzip anywhere and run DeepSeekHarness.exe.
  3. The first launch shows a project picker; choose the folder to open. The choice is remembered.
  4. A few seconds later the window opens on that project.

Close the window to stop the server.

Build from source

git clone https://github.com/desanv01/deepseek-harness-desktop-app.git
cd deepseek-harness-desktop-app

# framework-dependent dev build -> dist\ (needs the .NET 8 runtime)
.\build.ps1

# self-contained single-file exe -> release\ (no .NET needed on the target)
.\build_portable.ps1

build_portable.ps1 is a thin alias for build.ps1 -SingleFile. Both accept -RestoreSource to restore packages from a folder or feed instead of the machine's configured NuGet sources — useful for a disconnected or air-gapped machine:

.\build.ps1 -SingleFile -RestoreSource C:\offline-nuget   # folder of .nupkg files
.\build.ps1 -Portable  -RestoreSource https://my-feed/v3/index.json

A missing folder exits with code 2 and a clear message rather than a NuGet stack trace.

Open DeepSeekHarness.sln in Visual Studio and use the PortableFolder or PortableSingleFile publish profile if you prefer the Publish dialog.

Smoke test

dotnet build -c Release -r win-x64            # or .\build.ps1
.\tools\smoke-test.ps1                        # exercises .\dist\DeepSeekHarness.exe

tools\smoke-test.ps1 is a behavioural suite for the part of the app that depends on the machine: it builds throwaway fixtures — a fake npm global prefix with a shim and a package, and a fake npm that can succeed, fail, or leave a half-written package behind — then asserts what the app reports and what it does to the installation. It needs no network, no real harness install, and no real npm, and CI runs it on every push.

tools\client-plugin-smoke.mjs renders the updates plugin's browser half with a minimal React and a fake shell: it loads the real client.js, checks the plugin registers both slots, and asserts what the components produce — the sidebar entry, the settings section with live state, the plugin manager's controls, and the message shown when the desktop app is not attached. The fake shell enforces the rule the real one does — both slots are lists, and a list entry without an id is rejected — and declares the sidebar slot after the plugin applies, which is when the real declaration lands. That is how the UI is verified where WebView2 cannot start; the behavioural suite runs it as its last scenario.

tools\boot-graph-probe.mjs closes the gap between "the plugin is installed" and "the plugin runs in the page". It takes a running dsh web ready line's URL and token, mints the browser session, reads window.__DSH_BOOT__ out of the served page, fetches the plugin bundle the graph advertises, and checks that the source the browser would run carries the module id, the marker, and the slot ids it needs. The behavioural suite's last scenario boots a real server on a fixture home and runs it, so a plugin the loader never emits or a bundle route that answers 404 fails in CI rather than in the window.

Publishing the plugin

The plugin is distributed inside the executable and on npm. The repository copy is private: true — it carries the app's own files, and a stray npm publish from a checkout must not push it — so publishing stages a copy of it first:

node tools\stage-plugin-package.mjs plugins\dsh-plugin-desktop-updates plugin-out
cd plugin-out; npm publish --access public

.github/workflows/publish-plugin.yml does exactly that on demand. Bump the version in plugins\dsh-plugin-desktop-updates\package.json, merge it, then run the workflow with the same version: it refuses a version the manifest does not declare and one that is already published. It takes a method input:

  • trusted-publishing-oidc is what this package uses. No secret exists anywhere: the runner proves which workflow it is over OpenID Connect, npm exchanges that for a short-lived credential, and the registry attaches a provenance attestation to the release. It needs the workflow's id-token: write permission (already set), npm 11.5.1 or later (the step installs the current one), and a trusted publisher configured for the package on npmjs.com — desanv01 / deepseek-harness-desktop-app / publish-plugin.yml, with Allow npm publish ticked, since npm otherwise permits only npm stage publish.
  • token-with-bypass-2fa reads the NPM_TOKEN repository secret, and exists for one situation: the first release of a package that has no trusted publisher yet, because a trusted publisher is configured on the package's own settings page and cannot exist before the package does. Such a token must be account-wide, since a granular token can only be scoped to a package that already exists. It only works while the package's Publishing access setting allows bypass tokens; a package that requires two-factor authentication and disallows them can be published by a trusted publisher alone.

Two npm behaviours made that bootstrap awkward, and both are recorded here because they are easy to hit again. The registry refuses a publish that carries neither a one-time password nor a token allowed to bypass 2FA, answering 403 ... Two-factor authentication or granular access token with bypass 2fa enabled is required to publish packages.; and the npm CLI prefers the session token npm login leaves in .npmrc over a bypass token, which fails the same way — writing the bypass token into .npmrc by hand is the way around that, and a CI runner has no session token to conflict with. The npm log also warns that bypass tokens are being restricted for direct publishing, so a trusted publisher is the durable arrangement rather than the token.

Cutting a release

<Version> in DeepSeekHarness.csproj is the release date in yyyy.MM.dd form, and the release tag is v<Version>. The updater compares the two as dates, so they have to agree — and the workflow refuses to publish when they do not.

# 1. set <Version> to today's date, then
git commit -am "Release 2026.09.15"
git push origin main

# 2. tag it; the tag is what publishes
git tag v2026.09.15
git push origin v2026.09.15

.github/workflows/release.yml then builds the self-contained single-file exe from that tag, writes SHA256SUMS over the exe and the zip, and creates the GitHub release with all three assets. .github/workflows/ci.yml builds every push and pull request, checks that the version is a date, and smoke-tests the published executable's flags and exit codes. Both run on windows-latest and need no repository secrets.

The updater downloads the exe and verifies it against SHA256SUMS from the same release, so the checksum asset is not optional: a release published without it can still be installed, but only after the app has told you it could not be verified.

Command-line reference

DeepSeekHarness.exe                              normal launch (own server, OS-picked port)
DeepSeekHarness.exe --project C:\work\my-repo    open that workspace in the window
DeepSeekHarness.exe --dsh-home C:\dsh-home       use a specific harness home
DeepSeekHarness.exe --port 8080                  pin the port instead of letting the OS pick
DeepSeekHarness.exe --update                     update the global dsh first (opt-in, also repairs a broken one)
DeepSeekHarness.exe --repair-harness             install or repair the global @deepseek-ai/dsh, then exit
DeepSeekHarness.exe --dsh-cli <path\bin.js>      use this harness entry point instead of searching for one
DeepSeekHarness.exe --self-test                  environment report and exit
DeepSeekHarness.exe --check-harness              report installed vs published harness versions
DeepSeekHarness.exe --check-updates              report app + harness update state (exit 10 = update available)
DeepSeekHarness.exe --check-plugins              report each installed plugin vs its published version
DeepSeekHarness.exe --updates                    open the updates view on launch (the page panel, else the window)
DeepSeekHarness.exe --keep-alive                 let the harness outlive the window (default)
DeepSeekHarness.exe --no-keep-alive              closing the window stops the server
DeepSeekHarness.exe --safe-mode                  boot with the base bundles only
DeepSeekHarness.exe --exit-safe-mode             put back what safe mode set aside
DeepSeekHarness.exe --install-plugin             install the bundled updates plugin into the home, then exit
DeepSeekHarness.exe --install-log-bridge         install the bundled log bridge into the home, then exit
DeepSeekHarness.exe --import-web-home            copy a `dsh web` home into this app's home, then exit
DeepSeekHarness.exe --skip-web-home              record that the web home should be left alone
DeepSeekHarness.exe --plugin-list                list the plugins this home runs
DeepSeekHarness.exe --add-plugin <spec>          install a plugin (npm name, git spec, or local folder)
DeepSeekHarness.exe --remove-plugin <name>       remove one
DeepSeekHarness.exe --enable-plugin <name>       switch one on (and --disable-plugin for off)
DeepSeekHarness.exe --bridge-selftest            exercise the page bridge protocol without a browser
DeepSeekHarness.exe --install-update             download + verify + stage the newest release, then exit
DeepSeekHarness.exe --install-update --apply-now  ... and hand over to the update helper (restarts the app)
DeepSeekHarness.exe --no-update-check            launch without asking the release feed anything
DeepSeekHarness.exe --update-feed <url|file>     read the release feed from here instead of the GitHub API
DeepSeekHarness.exe --stop                       stop the server this app started
DeepSeekHarness.exe --no-window                  headless boot test: start, verify, stop
FlagDefaultMeaning
--project <dir>remembered project, else pickerWorking directory of the managed server: the workspace the window opens
--dsh-home <dir>remembered home, else %LOCALAPPDATA%\DeepSeekHarness\homeDSH_HOME for the managed server; one server owns one home
--port <n>0 (OS picks a free port)Pin the listen port; the real port is read from the ready line either way
--keep-aliveonThe managed server outlives the window; the next launch attaches to it
--no-keep-aliveoffClosing the window stops the server, as it did before keep-alive
--safe-modeoffBoot with the base bundles only, leaving added plugins aside
--exit-safe-modeoffPut back the bundles a safe-mode boot set aside, then exit
--install-pluginoffInstall the bundled updates plugin into the selected home and exit
--install-log-bridgeoffInstall the bundled log bridge into the selected home and exit
--import-web-homeoffCopy a dsh web home into this app's home, then exit
--skip-web-homeoffRecord that the web home should be left alone, then exit
--web-home <dir>~/.dshThe web home to read; for a home kept somewhere else
--plugin-listoffPrint the bundles this home runs, with version and state
--add-plugin <spec>—dsh plugin add through the app: registry name, git spec, or local folder
--remove-plugin <name>—Remove a plugin from the profile
--enable-plugin <name> / --disable-plugin <name>—Switch one through the profile's patch layer, keeping it installed
--bridge-selftestoffCheck the page bridge protocol (parsing, dispatch, replies, events) and exit
--address <host>127.0.0.1Bind address for the managed server
--ready-timeout <sec>240How long to wait for the server's ready line
--updateoffRun npm install -g @deepseek-ai/dsh@latest before boot; also installs or repairs a missing, half-installed CLI
--no-updateonExplicit alias that keeps updates off
--repair-harnessoffInstall or repair the global @deepseek-ai/dsh, then exit (0 usable, 1 still broken, 10 node or npm missing)
--dsh-cli <path>auto-detectedHarness entry point to use; for an install the search cannot see
--no-windowoffOwned-mode boot test with no UI
--self-testoffPrint an environment report and exit
--check-harnessoffPrint installed vs published harness versions and exit (0 current, 10 update available)
--check-updatesoffPrint the desktop-app and harness update state and exit (0 current, 10 update available, 1 nothing checkable)
--check-pluginsoffPrint each installed plugin's standing against its published version and exit (0 current, 10 update available, 1 nothing checkable)
--updatesoffOpen the updates view as soon as the app window is up: the page's panel when it can show one, otherwise the native window
--install-updateoffDownload, verify and stage the newest release without a window, then exit (0 staged, 1 failed)
--apply-nowoffWith --install-update: hand over to the update helper instead of stopping at staging
--no-update-checkoffThis launch never asks the release feed, and the tray still checks on demand
--update-feed <url|file>GitHub APIRead the release feed from this URL or JSON file; a local path drives the update flow without a network
--stopoffStop the managed server for the selected home (exit 1 when none was found)

Command-line flags always win over settings.json; anything not named on the command line falls back to the remembered value.

Exit codes: 0 success, 1 runtime error or nothing to stop, 2 invalid arguments, 10–14 toolchain problems, 21/22 server start or verification failure, 23 unexpected error while booting the server, 30 a pinned port is occupied by another service, 40 home owned by another instance.

Where things live

WhatWhere
Sessions, settings, storagesDSH_HOME — default %LOCALAPPDATA%\DeepSeekHarness\home
App settings%LOCALAPPDATA%\DeepSeekHarness\settings.json — last project, home, port, recent projects
App logs%LOCALAPPDATA%\DeepSeekHarness\logs\ (desktop.log plus desktop.log.1 after rotation, unique server-*.out/err.log, unique npm-update-*.log)
Server lease%LOCALAPPDATA%\DeepSeekHarness\instance-<home-key>.json — PID, start time, port, URL; removed on clean stop
Staged updates%LOCALAPPDATA%\DeepSeekHarness\updates\<tag>\ — the verified download, pending.json, the apply helper, and apply-*.log
Cached update check%LOCALAPPDATA%\DeepSeekHarness\update-check.json — the last release answer, reused for six hours
WebView2 local state%LOCALAPPDATA%\DeepSeekHarness\webview2\ — safe to delete
Build artifactsdist\ and release\ (git-ignored)

Deleting settings.json simply restores the first-run picker; it holds no credentials.

Set DSH_DESKTOP_HOME to relocate everything the app owns — settings, logs, leases, and the WebView2 profile — under one directory instead of %LOCALAPPDATA%. That makes the app portable (keep it on a USB stick next to the exe) and gives tests a scratch root to run against:

$env:DSH_DESKTOP_HOME = "D:\portable\DeepSeekHarness"
.\DeepSeekHarness.exe

--self-test reports which root is in use. The variable affects only the app's own files: the harness home is still chosen by --dsh-home / the remembered setting.

Project structure

src/DeepSeekHarness/
├── Program.cs             # entry point, DPI awareness, log pruning, error handling
├── Options.cs             # CLI parsing, validation, settings precedence
├── Settings.cs            # settings.json: last project, home, port, recent projects
├── Orchestrator.cs        # project resolution, boot, focus handoff, window lifetime
├── ProjectPickerForm.cs   # first-run / recent-project chooser
├── ServerManager.cs       # spawn dsh, parse the ready line, lease, stop
├── JobObject.cs           # Win32 job object (kill-on-close)
├── SingleInstance.cs      # focus event that brings the owning window forward
├── Proc.cs                # child process run/spawn/kill, output draining
├── ManagedLock.cs         # per-home named mutex
├── NetProbe.cs            # endpoint identity probe (DSH bootstrap marker)
├── Tools.cs               # node/npm/dsh discovery and CLI validation
├── HarnessUpdate.cs       # npm dist-tag check: installed vs published harness
├── AppInfo.cs             # this build's version, release repo, and asset naming
├── AppUpdate.cs           # GitHub release check, version comparison, cached answer
├── UpdateHttp.cs          # update transport: in-process TLS, Node fallback, file:// feeds
├── UpdateInstaller.cs     # download, SHA-256 verification, staged apply, and the helper
├── UpdatesForm.cs         # the updates window: desktop app, harness, about
├── Updater.cs             # opt-in serialized npm update
├── AppPaths.cs            # %LOCALAPPDATA% layout, home keys, log pruning
├── MainForm.cs            # WebView2 window, theme measurement, tray handoff, bridge host
├── DesktopBridge.cs       # window.__dshDesktop: the versioned page bridge and its protocol
├── DesktopPlugin.cs       # the bundled harness plugin: extract, install, keep current
├── DesktopPluginCli.cs    # --install-plugin
├── HarnessProfile.cs      # the profile a home runs: bundle stack and patch layer
├── PluginManager.cs       # add/remove/enable/disable, with the pnpm runtime it carries
├── PluginCli.cs           # --plugin-list / --add-plugin / --remove-plugin / --enable-plugin
├── SafeMode.cs            # recovery from a plugin the harness cannot load
├── MarkdownView.cs        # release notes rendered into the updates window
├── TrayIcon.cs            # tray icon and its menu
├── SplashForm.cs          # startup splash with live status
├── Theme.cs               # shared palette and embedded artwork
├── NativeTheme.cs         # DWM dark mode and caption colours
├── SelfTest.cs            # --self-test report
├── Log.cs                 # rotating, never-throwing file + console logger
└── Ui.cs                  # message-box error surface
assets/                    # official DeepSeek artwork (regenerated by tools\update-icons.ps1)
assets/pnpm/               # pnpm runtime, fetched by build.ps1 and embedded (git-ignored)
plugins/                   # the harness plugins this app ships
  dsh-plugin-desktop-updates/   # the Updates UI: sidebar entry + Settings section
screenshots/               # README images
build.ps1                  # dev / portable-folder / single-file builds
build_portable.ps1         # one self-contained exe + zip
tools/smoke-test.ps1       # behavioural suite: CLI discovery, repair, install safety
tools/client-plugin-smoke.mjs  # renders the updates plugin's browser half headlessly
tools/boot-graph-probe.mjs # reads a live server's boot graph and the plugin bundle it serves
tools/update-icons.ps1     # regenerates the embedded DeepSeek artwork

Troubleshooting

  • Window shows "Starting …" for a long time — run DeepSeekHarness.exe --self-test, raise --ready-timeout, and read the newest server-*.out.log.
  • WebView2 failed to initialize — the WebView2 Runtime is missing; the window shows the official install link.
  • "Could not start the embedded browser" — the message carries the runtime's HRESULT and the data folder it was given, and desktop.log has the same line with the inner exception chain. 0x8000FFFF is the runtime refusing to start, usually a missing runtime or a temporary folder the account cannot write. 0x80080005 ("Server execution failed") means the browser process could not be launched at all — a restricted environment that denies process access produces it, and the runtime's own crash handler names why (crashpad: OpenProcess: Access is denied); no Chromium flag gets past that, so the app has to run somewhere the browser may start. --self-test prints the runtime version this machine reports.
  • The window goes blank — a browser process died after starting. The app logs the failure kind and reason to desktop.log and reloads the page once, which recovers the usual case (the browser process being killed under memory pressure is replaced on the next navigation). If it fails again, or if it was the whole browser process rather than a renderer, the window says so and asks for a restart instead of retrying — a browser that keeps dying is not something a reload fixes, and an unbounded reload loop would only hide it.
  • @deepseek-ai/dsh is not installed globally — nothing that looks like the harness was found. The app offers to install it; from a script, DeepSeekHarness.exe --repair-harness or --update does the same, and both print where they looked.
  • @deepseek-ai/dsh is installed but incomplete — the package directory is there without the files the CLI needs, which is what an interrupted npm install -g leaves behind. The dialog names the missing file; accepting the install (or --repair-harness) fixes it. Installing over a working CLI keeps a copy until the replacement validates, so this cannot strand you.
  • dsh works in a terminal but the app cannot find it — the app reads the dsh shim on PATH, the package's package.json bin, %APPDATA%\npm, and npm prefix -g. If your install is somewhere none of those see, --dsh-cli <path\to\bin.js> names it directly; --self-test prints the search.
  • "Another instance owns this home" — a second window is already serving the same DSH_HOME. A normal launch would just bring that window forward; this message means the owner is alive but its server did not answer, so close it (or run --stop) and retry.
  • The project picker appears every launch — settings.json could not be written (check permissions on %LOCALAPPDATA%\DeepSeekHarness), or the remembered folder was moved or deleted. Pick the project again to re-record it.
  • The "Check for updates" entry is missing from the sidebar — the harness shell refuses a slot registration silently: it drops the entry and the page looks normal. The plugin records what it registered and what was refused, the app logs that marker after every page load, and the Updates section repeats it. Grep desktop.log for updates plugin in the page: — a non-empty failed list names the slot and the shell's reason. The entry carries id: 'desktop-updates' because sidebar.footer.action is a list slot, where the shell requires an entry id.
  • The Updates section renders but its buttons do nothing, and every value says unknown — the page's messages are not reaching the app. The app logs what it cannot read (ignoring an unrecognized page message: …) and what the page reports (page info|warn|error: …), so an empty log across a click means the message never arrived, and a logged preview shows what did. The wire is JSON: a page posts JSON.stringify(request), and WebView2 hands the app a string containing it — both shapes are accepted, and --bridge-selftest asserts both.
  • Port already in use — only possible when you pin one with --port; the default --port 0 cannot conflict.
  • A server survived a crash — the job object normally prevents this; if it ever happens, DeepSeekHarness.exe --stop stops only the lease whose PID and process start time still match.

Current status

Verified on Windows 11 x64 with .NET 8, WebView2 153.0.4234.32, and @deepseek-ai/dsh 0.1.5-rc.1:

  • dotnet build -c Release — clean build;
  • headless boot (--no-window) — OS-assigned port, ready line parsed, endpoint verified, server stopped;
  • GUI launch with --project — settings written with the project, home, port, and recent list;
  • second launch with no flags — resolves the project from settings, brings the running window forward, and exits without starting a second server;
  • force-kill of the app — the managed server is gone within seconds;
  • --stop, invalid --port, unknown flag, missing --project — correct exit codes;
  • update check against the live repository — the redirect endpoint reports the newest tag even while the anonymous API limit is exhausted, and --check-updates exits 10 when a newer build exists and 0 when the running one is newest;
  • update install against a local release fixture — download, SHA256SUMS verification, staged apply, restart; a tampered checksum is refused and the download discarded; a build that exits immediately is rolled back to the previous one;
  • the injected update pill — exercised against a fake DOM (one element, posts on click, hides, survives a missing document.body);
  • harness CLI handling — tools\smoke-test.ps1 covers discovery through a shim and through package.json, a half-installed package reported as incomplete, --repair-harness installing a missing CLI, a failed install restoring the previous one, and a successful install replacing it;
  • the plugin pipeline — the same suite installs the bundled plugin into a fresh home, adds a local plugin, switches it off and on through the patch layer, refuses to switch off a base bundle, removes it, and boots a home whose plugin throws on import, recovering with that plugin disabled; it also holds a newer plugin in place at boot and restores the bundled copy on --install-plugin (63 assertions, no network);
  • the page bridge — --bridge-selftest, 38 checks over the message shapes the page really sends, dispatch, replies, parameter decoding, event shapes, and the plugin marker — run by the suite, where WebView2 cannot start. A page posts JSON.stringify(...), so the app is handed a JSON string containing the request rather than the request: both that shape and the raw object are asserted, because a bridge that understands only one of them drops every real message while its own tests stay green;
  • the updates UI — tools\client-plugin-smoke.mjs renders it against a fake shell that enforces the real slot rules (a list entry needs an id, the panel slot needs a key): the sidebar entry, the panel row and body under one id, the panel opening by layout selection with no bridge call, the fallback to the app's window when the shell has no layout service, the Settings section and its hand-off, and the plugin manager (53 assertions, part of the same suite);
  • the served plugin — a real dsh web is booted on a fixture home and asked what it serves: the boot graph names the plugin row, the bundle route answers 200, and the served source carries the module id, the panel row and body, the layout call that opens it, and the marker (13 checks, part of the same suite); the npm path was walked the same way, from a packed tarball through --add-plugin to the graph the server then served;
  • startup — measured on one machine: 25.5 s to a ready window before, 14.6 s after (the pre-flight phase alone went from 10 s to 1 s);
  • keep-alive — the server survives the window, the next launch adopts it (same pid), and --stop ends it;
  • --stop, invalid --port, unknown flag, missing --update-feed file — correct exit codes (2), --check-updates exits 10/0 as documented.

Known limitations:

  • The app cannot attach to a dsh web server it did not start, because a foreign server never publishes its token. It reports the conflict instead of guessing.
  • One window per home: two projects need two homes (--dsh-home) rather than two windows over one server.
  • Update downloads are verified by checksum, not by signature. SHA256SUMS comes from the same release as the binary, so it catches a corrupted or altered download, not a compromised release. Authenticode signing and WinVerifyTrust at apply time are the remaining step.
  • There is no automated unit-test suite yet; CI builds, checks the version scheme, and smoke-tests the command line, and the manual checks above were run by hand.
  • The UI itself is verified without a browser: the plugin's components are rendered headlessly and the served bundle is read from a live server, but no check drives a real click in WebView2. A machine where the runtime cannot start runs everything except the window.

Roadmap

  • No-terminal launch with a hidden child process
  • Project-scoped window
  • OS-assigned port and ready-line discovery
  • Endpoint verification before display
  • Job-object shutdown guarantee
  • One server per home with attach
  • Persisted settings and a project picker
  • Log rotation and retention limits
  • Single-instance window with focus-on-relaunch
  • Tray icon and "open logs" menu
  • Harness version display, with an opt-in in-app update
  • Update check for the desktop app itself, with in-app alerts and a cached answer
  • Verified self-update: download, SHA-256 check, staged apply, rollback, restart
  • Authenticode signing of releases, verified before the swap
  • Unit tests for options, lease validation, and the endpoint probe
  • GitHub Actions build and release workflow

The detailed gameplan for the remaining items — design, acceptance criteria, risks, and milestones — is in ROADMAP.md.

Security

  • The app talks only to a loopback harness server; harness data stays in local files under DSH_HOME.
  • The harness access token is passed to the embedded browser in the URL and is never logged by the app.
  • The app performs no telemetry. Its outbound requests are the optional npm update, the npm dist-tag read, and the GitHub release check — all of which can be disabled with --no-update-check (the npm update stays opt-in through --update).
  • Update detection reads public release metadata. Nothing is downloaded or replaced without an explicit action, and the tray menu, the update window, and --no-update-check all let you keep the app quiet.
  • Downloads are verified. The desktop-app update is refused unless its SHA-256 matches the release's SHA256SUMS; a mismatch deletes the download, and installing anything without a published checksum requires an explicit confirmation that says so. SHA256SUMS comes from the same place as the binary, so it detects corruption and tampering in transit, not a compromised release — Authenticode signing is the remaining step (see ROADMAP.md).
  • The running executable is never replaced in place. A separate helper waits for the app to exit, keeps the previous build until the new one has started, and restores it if the new build dies.
  • --update mutates the global @deepseek-ai/dsh installation; use it deliberately.
  • Report sensitive issues through GitHub's private security advisory flow rather than a public issue.

Contributing

Keep changes focused and explain the user-visible impact. Bug reports are most useful with reproduction steps, the exact flags used, the relevant server-*.out.log excerpt, and the --self-test output.

License and attribution

Licensed under the MIT License.

This project is a derivative of ZeroHackz/deepseek-harness-windows-native; the original MIT notice is preserved. The lifecycle model here was rebuilt around an OS-assigned port, a verified ready line, per-home ownership, and a kill-on-close job object.

DeepSeek Harness itself is developed by DeepSeek. This app is an independent wrapper: it is not affiliated with or endorsed by DeepSeek, and DeepSeek logos are used only to identify the wrapped application.


A dependable desktop shell for a local DeepSeek Harness: double-click, work, close.

Related plugins