# dsh-auto-open-web

A persistent plugin for the `dsh web` profile that automatically opens the DSH Web GUI in an app-style
window (or browser tab) on profile start, with a configuration form (manually maintained
browser path, etc.). On dsh ≥ 0.1.6 (including 0.2.x) that entry point is the bundle's detail page
on the **Plugins page**; on the 0.1.5-rc.x line it is the **Settings → Plugin configuration** card.

**dsh 0.2.1-rc.3 compatibility (since 0.1.30)**: the manifest now declares `engines.dsh`
(`>=0.1.5-rc.1 <0.3.0`; 0.2.1-alpha.3+ reads it before peers), the bundle patch mirrors the
0.2.1 `web-runtime` config keys (`publicUrl` included), and the host unwraps the **Volatile
references** that dsh ≥ 0.1.7 produces for `.volatile()` Config fields — previously every
configured value read as its default. The GUI URL comes from `webApp.getAdvertisedUrl()`
(`--public-url`, TLS and non-loopback binds included).

## Behavior

On startup (after the HTTP service binds and the actual listening port is known), the window type is
selected by `windowKind`:

1. **WebView2 host** (`windowKind: webview2`, default, Windows only): launches the bundled
   `DshAppWindow.exe` (WinForms + WebView2, own process, no tab/address bar),
   **loading the GUI root address directly** (no iframe, no wrapper page, no injected scripts).
   **Taskbar/window icon = DSH icon** (the window is owned by the host process, which sets
   `Form.Icon` directly, independent of browser taskbar identity rules).
   **Exits with DSH** (the host watches the parent process PID).
   **Remembers window size/position/maximized state**
   (`%LOCALAPPDATA%\DeepSeekHarness\window-state.json`, saved on close and restored on start;
   falls back to centering when the display layout changes).
2. **Browser app window** (`windowKind: browser`): a **dedicated Edge/Chrome instance** via `--app`
   (`--user-data-dir=~/.dsh/<browser>-app-profile`, isolated process tree and storage,
   **shares no processes/Cookies/cache with the normal browser**; `--no-first-run` skips the
   first-run welcome page).
   **Exits with DSH (including force-kill)**: the browser instance is placed into a **Job Object**
   (`JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE`, koffi-driven `JOBOBJECT_EXTENDED_LIMIT_INFORMATION`
   structure, 144 bytes verified); whether DSH exits normally or is force-killed
   (`taskkill /F`, crash, shutdown, etc.), the Windows kernel terminates every process in the job
   when its last handle closes — the whole dedicated instance process tree dies with it, with no
   reliance on any exit event. Two extra safety nets: on normal DSH exit a `process 'exit'` handler
   kills the dedicated instance process tree (matching only our own user-data-dir, never the normal
   browser); instances left behind by a force-kill are cleaned up before the next launch.
3. If the selected type is unavailable (host missing / browser not found / non-Windows, etc.) →
   **falls back to the official default-browser handoff** — the URL is given to the OS default
   browser (plain tab), so the user can always reach the GUI. The implementation mirrors exactly
   how the DSH core (web-app bundle) opens the page at startup: it prefers the `open` package
   shipped with the DSH deployment (win32 = PowerShell Start, darwin = `open`, linux = `xdg-open`);
   when the package is unavailable it falls back to a native platform launch
   (win32 = `cmd /c start`, darwin = `open`, linux = `xdg-open`).
   `appWindow: false` behaves **exactly like the official core**: the GUI is opened in the
   system default browser (plain tab, official `open` method).
4. **`--no-open` / SSH sessions (official identical suppression)**: `dsh web --no-open`
   (`webStartup.openBrowser === false`) or an SSH session (`SSH_CONNECTION`/`SSH_TTY` in the
   inherited process environment, read through the launcher's `launchEnvironment` snapshot on
   dsh ≥ 0.1.7 — the same source as the official `launchedThroughSsh`, so `.env` values cannot
   fake an SSH session) opens **nothing** — the plugin reads the **same sources** as the
   official core and suppresses opening the same way.

The listening origin comes from the real webServer value (`--port` overrides and `--port 0` both
work). The URL handed to the window/browser is the **advertised** one: `webApp.getAdvertisedUrl()`
on dsh ≥ 0.2.1 (honouring `--public-url`, TLS and non-loopback binds), then
`webServer.getOrigin()`, then the plain `http://127.0.0.1:<port>` fallback on older releases;
the connection service appends this process's launch token. **No waiting needed**: the plugin
declares webServer as a hard dependency (`inject`), so Cordis only activates it after the
webServer plugin's `Service.init()` completes (HTTP socket bound, port written) — the port is
directly available in `apply`.
Both modes close with DSH: the webview2 host watches the parent process; the
browser dedicated instance is ended by the Job Object (also effective on force-kill) plus exit
cleanup.

> **Relationship with DSH's core browser handoff**: the DSH core (web-app bundle) opens the GUI
> in the system default browser at startup by default (`openBrowser: true` — a plain tab/window,
> not an app window). Installing this plugin flips `web-runtime.openBrowser` to `false` via the
> plugin's bundle patch, so the opening behavior is fully owned by the plugin: with
> `appWindow: true` the app-style window opens and no ordinary browser page pops up; with
> `appWindow: false` the plugin performs **the same default-browser handoff as the official core**
> (open package → native platform launch), matching the no-plugin behavior. With
> `dsh web --no-open` (or in an SSH session) the plugin, like the official core, opens
> nothing. Uninstalling the plugin restores the official default.

### WebView2 host requirements (webview2 mode only)

- Windows 10 1803+ / Windows 11 / Windows Server 2016+
  (Win7/8.1 reached end of support in 2023-01, see Microsoft announcements)
- WebView2 Runtime (evergreen, usually preinstalled with Edge; verified with 151.x locally)
- **No .NET 10 runtime required**: since 0.1.15 the host targets **.NET Framework 4.7.2**,
  built into Windows 10 1803+ / Windows 11 (4.8.x on Win11) — no .NET Core/10 runtime or
  self-contained publish is needed. The build machine needs a .NET SDK and the .NET
  Framework 4.x targeting packs (installed with VS/SDK; without them add the
  `Microsoft.NETFramework.ReferenceAssemblies` NuGet package to the csproj).
- **DPI awareness (since 0.1.20)**: the host declares **PerMonitorV2** (`host/app.manifest`
  plus the WinForms `DpiAwareness` section in `App.config`). .NET Framework WinForms is
  DPI-unaware by default, so on 125%/150% scaled displays Windows renders the window at
  96 DPI and bitmap-stretches it (DPI virtualization) → blurry text and web content.
  Declaring awareness renders natively at the monitor's real DPI, keeping text crisp.

## Configuration

Two equivalent ways:

1. **Settings form** (recommended) — two supported version lines; the plugin registers for
   both and whichever exists wins:
   - **Newest version (dsh ≥ 0.1.6, incl. 0.1.7-alpha.1 and 0.2.x)**: **Plugins page →
     `dsh-auto-open-web`** — the form is rendered on the bundle's detail page between the
     description and its rows (slot `plugins.bundle.config`, key = **bundle package name**
     `dsh-auto-open-web`, the same seat as `dsh-harness-tags`). The page only passes
     `{ view: 'page' }`, so the plugin binds the namespace itself (entry id `auto-open-web`)
     through the client service `configForms` (snapshot + `set`/`unset`/`subscribe`); if a page
     ever supplies `form = { state, mutate }` that is preferred. Only **Save** is offered —
     leaving the page drops staged edits. The namespace exists only because every `Config`
     field is marked `.volatile()` (the host projects nothing else).
   - **Latest rc line (0.1.5-rc.x)**: Settings → Plugin configuration → the "自动打开网页"
     collapsible card (slot `settings.plugin.item`, keyed by settings namespace). Data goes
     through the host's `settings.register` plus the client `settingsScope`.

   The two lines' client services **do not coexist** (new `configForms` ↔ rc-line
   `settingsScope`), so the plugin reads both **lazily** and declares only the shared
   services in `inject` (`slots`/`locale`/`connection`/`remote`) — a hard inject would leave
   the plugin pending forever on the other line and the settings UI would never appear.
   On dsh ≥ 0.1.7 the `.volatile()` fields arrive as **Volatile references**; the host
   unwraps them with `.get()` before normalizing, and an older deployment whose schemastery
   has no `.volatile()` simply gets the plain schema. Earlier versions (0.1.0-rc.*,
   0.1.1-rc.*, 0.1.2-*, 0.1.3-alpha, 0.1.5-alpha, 0.1.6-alpha.1) are **no longer guaranteed**.

   Both entry points edit
   `appWindow` (independent app window), `windowKind` (WebView2 host / browser app window),
   `browserPath` (browser executable, with a native "Browse" file dialog; located below the
   window-type field and enabled only when "Browser app window" is selected),
   `exitOnWindowClose` (exit DSH when the window closes, off by default).
   After saving, values take precedence over row configuration (on the newest version they land
   in that row's user layer; on the rc line in the `auto-open-web` settings namespace); once saved,
   settings take precedence over row configuration. The host keeps only the "Browse" / "Test"
   helper routes (capabilities the official channel cannot cover).
2. **Row configuration** (`cordis.patch.yml`): acts as the startup seed, effective until the
   settings card is saved.

| Field | Default | Description |
| --- | --- | --- |
| `appWindow` | `true` | Automatically open the independent app window on start; `false` matches the official core — the GUI opens in the system default browser (plain tab, official `open` method) |
| `windowKind` | `webview2` | `webview2` = WebView2 host (own process, DSH taskbar icon, exits with DSH); `browser` = dedicated `--app` browser instance. If the selected type is unavailable, it falls back to the default-browser handoff (official open method, plain tab) |
| `exitOnWindowClose` | `false` | **（Experimental）** Exit DSH when the auto-opened window closes (off by default; only effective while `appWindow` is on). Triggered only when the window process exits **normally** (user closes the window) → `process.exit(0)`; startup failures/crashes/force-kills (non-zero exit code) do not trigger, preventing accidental exits. **Takes effect immediately in the current session after saving** (the exit listener is always registered; behavior is driven by a live flag), no restart needed |
| `browserPath` | `''` | Manual browser executable path (single entry, e.g. `C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe`; used only in browser mode), preferred over the built-in candidates Edge → Chrome; a non-existent path is skipped with a warning. The "Browse" button on the card opens a **native file dialog**: same mechanism as the official workspace directory picker (child process + koffi-driven `IFileOpenDialog`; the dialog is the child's first window and is automatically brought to front; no PowerShell). The "Test" button **actually launches** a dedicated `--app` test instance (separate user-data-dir `~/.dsh/<browser>-test-profile`, never pollutes the real instance): after confirming the browser main process stays alive it reports success, then automatically ends that test process tree after a few seconds of display (exact pid, never touches the real instance; the test instance is also placed in the Job Object when available as an exit safety net); the test uses the currently typed path (works even when unsaved), and failures show the reason |

### `browserPath` row configuration example

Override the row's config by id in `~/.dsh/profiles/web/cordis.patch.yml` (the override replaces the
whole config; fields not listed fall back to defaults):

```yaml
- id: auto-open-web
  config:
    browserPath: 'C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe'
```

## Packaging and installation

This package is a **bundle**: an npm package carrying a configuration layer — `dsh.bundle` in
`package.json` declares the patch file (`cordis.patch.yml`), and a profile activates the plugin row
by package name when installed. Published to the **npm registry** (`dsh-auto-open-web`'s `latest`
is the current compatibility release, including the dsh 0.2.1-rc.3 adaptation) and **GitHub**
(https://github.com/jinsiyu/dsh-auto-open-web, `main` branch).

### Packaging

```bash
cd dsh-auto-open-web
pnpm pack          # the prepack hook compiles the WebView2 host first (dotnet publish), producing dsh-auto-open-web-0.1.31.tgz
```

### Installation (pick one)

**Option 1: source checkout link (development; changes take effect immediately)**

```bash
# absolute path to avoid pnpm self-linking
dsh plugin --profile web add C:\path\to\dsh-auto-open-web
```

**Option 2: tarball (published artifact, recommended for delivery; no build permission needed)**

```bash
dsh plugin --profile web add ./dsh-auto-open-web-0.1.31.tgz
```

**Option 3: npm registry (recommended delivery; `latest` is the current compatibility release)**

```bash
dsh plugin --profile web add dsh-auto-open-web
```

**Option 4: GitHub source**

```bash
dsh plugin --profile web add github:jinsiyu/dsh-auto-open-web#main
```

### Uninstall

```bash
dsh plugin --profile web remove dsh-auto-open-web   # removes the dependency and its configuration layer together
```

### Effect and layer order

After installation: pnpm adds the package to `profiles/web/node_modules`, and `dsh` appends
`dsh-auto-open-web` to `dsh.profile.bundles`; at startup the bundle's `cordis.patch.yml` inserts the
plugin row (`name: auto-open-web`, resolved by package name). After restarting `dsh web`, the
configuration form shows up at the matching entry point (dsh ≥ 0.1.6, 0.2.x included: the
`dsh-auto-open-web` bundle's detail page on the Plugins page; earlier versions: the Settings →
Plugin configuration card). The client bundle is scanned into the browser manifest at startup via
the modules line, per the `dsh.client` declaration; on the client the plugin registers into
**both** `plugins.bundle.config` (new) and `settings.plugin.item` (old), and the slot machinery
decides which one takes effect (a slot that does not exist stays pending — no error, no
interference).

The effective configuration is composed layer by layer in this order (later layers win per row,
replacing the whole row's config rather than deep-merging): each bundle's patch (in bundles list
order) → the profile's own `cordis.patch.yml` → the global `$DSH_HOME/cordis.patch.yml` → the
`--patch` overlay. Users can override this package's row in their own profile's `cordis.patch.yml`
without touching the package.

### Notes

- **Zero dependencies**: every runtime dependency is provided by the **DSH deployment** and
  declared as an optional peer / engine (no install warnings):
  - `@deepseek-ai/dsh` (the host): the manifest declares **both** `engines.dsh` and the
    `@deepseek-ai/dsh` peer as **`>=0.1.5-rc.1 <0.3.0`**. dsh 0.2.1-alpha.3+ reads
    `engines.dsh` alone and ignores the peers; older releases ignore `engines.dsh` and read
    the peer. This range covers the 0.1.5-rc.x line, 0.1.7 and **0.2.x**
    (0.2.1-rc.3 verified); older 0.1.0-rc.*/0.1.1-rc.*/0.1.2-*/0.1.3-alpha/0.1.5-alpha
    releases are no longer guaranteed. The snapshot store uses
    `@deepseek-ai/dsh-client-store` only — the rc.8-era `dsh-client-runtime` fallback was
    removed; the client registers the newest version's bundle slot
    (`plugins.bundle.config`) and the rc line's settings slot (`settings.plugin.item`);
    no runtime version check is performed.
  - `@deepseek-ai/schemastery` (config schema validator; resolved at runtime: normal import
    first, then the DSH deployment copy — `%APPDATA%\npm` global layout or the shared
    `<DSH_HOME>/profiles/node_modules` layer)
  - `koffi` (Windows only: Job Object / process verification / native file dialog; same runtime
    deployment-copy resolution, failure only degrades)
  So no platform ever hits build-script blocking — HarmonyOS and other environments without
  koffi prebuilds install out of the box; posix platforms use the posix fallback adapter (browser
  mode works; webview2 / native dialog unavailable), and the platform adapter is loaded
  conditionally so win32.js is never evaluated on posix.
- This package does not depend on `@deepseek-ai/cordis` (zero code references; the plugin identity
  comes from the `dsh.bundle` / `dsh.client` fields, and Cordis is provided by the DSH deployment
  at runtime), so installation produces no peer warnings.
- **The npm package already contains the compiled WebView2 host** (built by the prepack hook before
  publishing); the **GitHub `main` branch and source-checkout installs do not** include
  `host-publish/` (build artifacts are .gitignore'd): for webview2 mode, run
  `pnpm run build:host` inside `node_modules/dsh-auto-open-web` first (requires the .NET SDK);
  browser mode needs no build.
- If installing by editing `package.json` manually (not via the `dsh plugin` command), you must add
  both the `dependencies` entry and `dsh.profile.bundles`; when using a local `file:` dependency,
  `dsh web` normalizes `file:` to `^0.1.31` at startup, which does not affect runtime.
- **Development smoke tests** (run from the repo; they need `@deepseek-ai/schemastery` to resolve —
  a `node_modules` copy or the DSH deployment both work):
  `node smoke-config.mjs && node smoke-client-slots.mjs && node smoke-fallback.mjs`.

## Icons

- GUI page icon: the GUI ships its own `/favicon.svg` (same as index.html).
- **Taskbar/window icon (WebView2 host)**: the host process sets `Form.Icon` directly to the
  plugin-generated DSH .ico (`~/.dsh/auto-open-web-icon.ico`), independent of browser taskbar
  identity rules. The .ico is built by fetching the local `favicon.svg` and rasterizing it with
  **sharp** (bundled with the deployment, resolved upward at runtime, not declared as a dependency)
  into 16/32/48/64/128/256 PNGs.
- **Fixed-icon policy (since 0.1.14)**: once written, the .ico is cached and reused — later
  starts return the existing non-empty cache directly (no favicon fetch / rasterization, so the
  icon cannot vanish on a transient favicon/sharp failure, and starts are faster); it is
  generated only when the cache is missing. **Extra safety net**: `host/icon.ico` (same source as
  the cache) is embedded into the host exe via `<ApplicationIcon>`, so even with a missing cache
  the window/taskbar shows the DSH icon instead of the default one.

## Platform support

- Windows: `windowKind: webview2` (default, DSH taskbar icon) or `windowKind: browser` (dedicated
  `--app` instance); falls back to the default-browser handoff (official open method, plain tab) if the selected type is unavailable
- macOS/Linux: `webview2` mode is unavailable (falls back to the default-browser handoff); `browser` mode is untested
  (dedicated `--app` instance)

## dsh 0.2.1-rc.3 adaptation notes (0.1.30)

**Symptom**: after upgrading to dsh 0.2.1 the plugin did not load at all (no window, no
configuration entry on the Plugins page).

**Root cause**: 0.2.x app-boot evaluates bundle compatibility before mounting a profile layer
(`packages/boot/app-boot/src/plugin-compatibility.ts`): an `engines.dsh` alone decides when
present, otherwise every `@deepseek-ai/dsh` / `@deepseek-ai/dsh-*` peer must satisfy the runtime
version (semver with `includePrerelease: true`). The old peer capped at `<0.2.0`: 0.2.0-rc.x
slipped through because of includePrerelease, 0.2.1-rc.3 no longer did → the bundle landed in
`skippedBundles` and startup stderr printed

```
dsh: skipping profile bundle "dsh-auto-open-web": Error: Plugin dsh-auto-open-web@0.1.29 is incompatible with dsh 0.2.1-rc.3: requirements {"@deepseek-ai/dsh":">=0.1.5-rc.1 <0.2.0"} ...
```

Note `dsh --dump-config` still exits 0 for a skipped bundle — stderr is the only signal.

**Fixes** (0.1.30):
1. `engines.dsh` and the `@deepseek-ai/dsh` peer both declare `>=0.1.5-rc.1 <0.3.0`
   (0.2.1-alpha.3+ reads engines, older releases read the peer).
2. The bundle patch's `web-runtime` config now restates `publicUrl` (added in 0.2.1-alpha.1;
   the patch replaces the whole config, so omitting it would break `--public-url`).
3. The host unwraps **Volatile references** for the `.volatile()` Config fields — a debt from
   0.1.7: the settings card could display and save values, but apply/exit checks always read
   the defaults.
4. The GUI URL comes from `webApp.getAdvertisedUrl()` (so `--public-url`, TLS and non-loopback
   binds work).
5. The client subscribes to the `configForms.describe()` mirror while the namespace is loading,
   so the page cannot get stuck on "this deployment does not serve these settings".
6. sharp / open / koffi resolution gained the `<DSH_HOME>/profiles/node_modules` shared-layer
   fallback (the desktop build carries the core inside app.asar and has no `%APPDATA%\npm`
   global layout).

**Verification** (local dsh 0.2.1-rc.3, throwaway `DSH_HOME` + minimal profile, real profile
untouched):
- 0.1.29 baseline: `--dump-config` stderr carries the skipping line above;
- 0.1.30: `--dump-config` shows no skip and the composed tree contains `- id: auto-open-web`
  plus `openBrowser: false`; `dsh web --no-open --port 3199` reaches readiness
  (`dsh web: http://127.0.0.1:3199/?token=...`) with empty stderr and the plugin log
  `[auto-open-web] --no-open given; not opening anything (official behavior)`;
  `GET /` → 200, `GET /auto-open-web/pick-browser|test-browser` → 405 (POST-only routes
  registered);
- `node smoke-config.mjs && node smoke-client-slots.mjs && node smoke-fallback.mjs` all green.

> Release notes: 0.1.30 is the dsh 0.2.1-rc.3 compatibility release; 0.1.31 is a **documentation-only**
> release (identical code) fixing the pre-publication wording on the npm page and the packaging
> example version numbers.

## Edge cases

- Normal restart: in webview2 mode the old host window exits with the old DSH process; the new DSH
  opens a new host window
- `browser` mode: after a restart the old window stays as-is (needs a manual refresh; may briefly
  coexist with the new window); when DSH is force-killed (`taskkill /F`, crash), the dedicated
  instance is ended by the Job Object without leftovers
- Plugin removed: no injected code, no leftover routes, zero residual impact
