Add README, plugin citations, and multi-platform release CI.

Ship screenshots and third-party notices for the desktop shell, and
package Windows, macOS, deb, rpm, and Flatpak from GitHub Releases.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Tommy
2026-08-15 22:02:13 +08:00
commit f8a3c2dc6f
72 changed files with 10372 additions and 0 deletions

27
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,27 @@
name: CI
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
jobs:
test:
name: Test
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: swatinem/rust-cache@v2
with:
workspaces: ". -> target"
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Test
run: make test

178
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,178 @@
name: Release
on:
push:
tags: ["v*"]
workflow_dispatch:
permissions:
contents: write
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false
jobs:
test:
name: Test
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: swatinem/rust-cache@v2
with:
workspaces: ". -> target"
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Test
run: make test
version:
name: Version
runs-on: ubuntu-22.04
outputs:
version: ${{ steps.meta.outputs.version }}
tag: ${{ steps.meta.outputs.tag }}
steps:
- uses: actions/checkout@v4
- id: meta
run: |
VERSION=$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -1)
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "tag=v$VERSION" >> "$GITHUB_OUTPUT"
if [ "${GITHUB_REF_TYPE}" = "tag" ] && [ "${GITHUB_REF_NAME}" != "v${VERSION}" ]; then
echo "Git tag ${GITHUB_REF_NAME} must match Cargo.toml version v${VERSION}" >&2
exit 1
fi
vendor:
name: Vendor Flatpak runtime
needs: test
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "24"
- name: Vendor dsh, ModLens, and presets
run: make vendor
- uses: actions/upload-artifact@v4
with:
name: vendor
path: vendor
include-hidden-files: true
retention-days: 1
package:
name: Package ${{ matrix.name }}
needs: [test, version]
permissions:
contents: write
strategy:
fail-fast: false
matrix:
include:
- name: macos-arm64
platform: macos-latest
rust_targets: aarch64-apple-darwin
args: --target aarch64-apple-darwin --bundles app,dmg
- name: macos-x64
platform: macos-latest
rust_targets: x86_64-apple-darwin
args: --target x86_64-apple-darwin --bundles app,dmg
- name: linux
platform: ubuntu-22.04
rust_targets: ""
args: --bundles deb,rpm
- name: windows
platform: windows-latest
rust_targets: ""
args: --bundles nsis,msi
runs-on: ${{ matrix.platform }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "24"
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.rust_targets }}
- uses: swatinem/rust-cache@v2
with:
workspaces: ". -> target"
- name: Install Linux packaging deps
if: matrix.platform == 'ubuntu-22.04'
run: |
sudo apt-get update
sudo apt-get install -y \
libwebkit2gtk-4.1-dev \
libgtk-3-dev \
librsvg2-dev \
patchelf \
xdg-utils \
rpm
- name: Vendor bundled plugins
shell: bash
run: bash scripts/vendor-native.sh
- uses: tauri-apps/tauri-action@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
projectPath: .
tauriScript: cargo tauri
tagName: v__VERSION__
releaseName: DeepSeek Harness Desktop v__VERSION__
releaseBody: |
跨平台安装包Windows NSIS/MSI、macOS DMGApple Silicon 与 Intel、Linux deb/rpm。
Flatpak 由同一次 Release 工作流另外上传。
- Windows / macOS / deb / rpm 需要本机已安装 `dsh``npm i -g @deepseek-ai/dsh`),或设置 `DSH_DESKTOP_DSH_BIN`。
- Flatpak 自带 Node.js 与 `dsh`。
- macOS 包未公证,需在「系统设置 → 隐私与安全性」中允许打开。
releaseDraft: false
prerelease: false
includeUpdaterJson: false
args: ${{ matrix.args }}
flatpak:
name: Flatpak
needs: [test, version, vendor]
runs-on: ubuntu-latest
container:
image: ghcr.io/flathub-infra/flatpak-github-actions:gnome-47
options: --privileged
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
name: vendor
path: vendor
- uses: flatpak/flatpak-github-actions/flatpak-builder@v6
with:
bundle: io.github.tommyfang.DshDesktop.flatpak
manifest-path: flatpak/io.github.tommyfang.DshDesktop.yml
cache-key: flatpak-gnome-47-${{ hashFiles('flatpak/io.github.tommyfang.DshDesktop.yml', 'Cargo.lock') }}
upload-artifact: false
- name: Attach Flatpak to GitHub Release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ needs.version.outputs.tag }}
name: DeepSeek Harness Desktop v${{ needs.version.outputs.version }}
files: io.github.tommyfang.DshDesktop.flatpak
fail_on_unmatched_files: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

27
.gitignore vendored Normal file
View File

@@ -0,0 +1,27 @@
# Python helpers (scripts/, tests/)
__pycache__/
*.py[cod]
*.egg-info/
# Rust / Cargo
/target/
**/*.rs.bk
*.pdb
# Tauri generated schemas
src-tauri/gen/
# Vendored dsh, ModLens, presets (make vendor)
/vendor/
# Packaging
/dist/
/build/
*.flatpak
.flatpak-build/
.flatpak-builder/
.flatpak-repo/
# OS
.DS_Store
*~

5419
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

9
Cargo.toml Normal file
View File

@@ -0,0 +1,9 @@
[workspace]
members = ["crates/dsh-core", "src-tauri"]
resolver = "2"
[workspace.package]
version = "0.1.0"
edition = "2021"
license = "MIT"
authors = ["TommyFang2077"]

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 TommyFang2077
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN ANY ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

136
Makefile Normal file
View File

@@ -0,0 +1,136 @@
PYTHON ?= python3
CARGO ?= cargo
PREFIX ?= $(HOME)/.local
APP_ID := io.github.tommyfang.DshDesktop
DSH_VERSION := 0.1.0-rc.6
MODLENS_VERSION := 3.16.6
ANCHORED_COMMIT := ffb845c5480adc953392a6db6f8a98ede621174b
ANCHORED_REPO := https://github.com/xiaobright/dsh-anchored-standard.git
VENDOR_DIR := vendor/dsh-prefix
MODLENS_DIR := vendor/modlens
ANCHORED_DIR := vendor/anchored-standard
ZERO_DIR := vendor/zero-anchored-standard
FLATPAK ?= flatpak
BUILDER ?= flatpak run --user org.flatpak.Builder
BUILD_DIR ?= .flatpak-build
REPO_DIR ?= .flatpak-repo
MANIFEST := flatpak/$(APP_ID).yml
VERSION := $(shell sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -1)
BUNDLE := dist/$(APP_ID)-$(VERSION).flatpak
BIN := target/release/dsh-desktop
.PHONY: all run dev test vendor vendor-native vendor-anchored build install uninstall flatpak-build flatpak-export flatpak-install flatpak-bundle flatpak-run clean
all: test
run:
$(CARGO) run -p dsh-desktop -- --no-update
dev:
$(CARGO) run -p dsh-desktop -- --dev --verbose
build:
$(CARGO) build -p dsh-desktop --release
test:
$(CARGO) test -p dsh-core
$(PYTHON) -m unittest discover -s tests -v
vendor:
mkdir -p vendor
rm -rf $(VENDOR_DIR) $(MODLENS_DIR)
npm install --prefix=$(CURDIR)/$(VENDOR_DIR) --global --prefer-offline --no-audit --no-fund @deepseek-ai/dsh@$(DSH_VERSION)
npm install --prefix=$(CURDIR)/$(MODLENS_DIR) --prefer-offline --no-audit --no-fund @liustack/modlens@$(MODLENS_VERSION)
$(MAKE) vendor-anchored
vendor-native:
MODLENS_VERSION=$(MODLENS_VERSION) ANCHORED_COMMIT=$(ANCHORED_COMMIT) ANCHORED_REPO=$(ANCHORED_REPO) bash scripts/vendor-native.sh
vendor-anchored:
rm -rf vendor/.anchored-src $(ANCHORED_DIR) $(ZERO_DIR)
mkdir -p vendor/.anchored-src
git -C vendor/.anchored-src init --initial-branch=main
git -C vendor/.anchored-src remote add origin $(ANCHORED_REPO)
git -C vendor/.anchored-src fetch --depth 1 origin $(ANCHORED_COMMIT)
git -C vendor/.anchored-src checkout --detach FETCH_HEAD
mkdir -p $(ANCHORED_DIR) $(ZERO_DIR)
cp -R vendor/.anchored-src/preset/. $(ANCHORED_DIR)/
cp -R vendor/.anchored-src/zero-anchored-standard/. $(ZERO_DIR)/
cp vendor/.anchored-src/LICENSE vendor/.anchored-src/NOTICE $(ANCHORED_DIR)/
cp vendor/.anchored-src/LICENSE vendor/.anchored-src/NOTICE $(ZERO_DIR)/
printf '%s\n' $(ANCHORED_COMMIT) > $(ANCHORED_DIR)/.dsh-desktop-source
printf '%s\n' $(ANCHORED_COMMIT) > $(ZERO_DIR)/.dsh-desktop-source
$(PYTHON) scripts/localize_preset.py $(ANCHORED_DIR)/preset.yml
$(PYTHON) scripts/localize_preset.py $(ZERO_DIR)/preset.yml zero
rm -rf vendor/.anchored-src
install: build
install -Dm755 $(BIN) $(DESTDIR)$(PREFIX)/bin/dsh-desktop
install -Dm644 data/applications/$(APP_ID).desktop $(DESTDIR)$(PREFIX)/share/applications/$(APP_ID).desktop
install -Dm644 data/metainfo/$(APP_ID).metainfo.xml $(DESTDIR)$(PREFIX)/share/metainfo/$(APP_ID).metainfo.xml
for size in 16 24 32 48 64 128 256 512; do \
install -Dm644 "data/icons/hicolor/$${size}x$${size}/apps/$(APP_ID).png" "$(DESTDIR)$(PREFIX)/share/icons/hicolor/$${size}x$${size}/apps/$(APP_ID).png"; \
done
if [ -z "$(DESTDIR)" ]; then \
update-desktop-database "$(PREFIX)/share/applications" >/dev/null 2>&1 || true; \
fi
if [ -d $(MODLENS_DIR)/node_modules/@liustack/modlens ]; then \
rm -rf $(DESTDIR)$(PREFIX)/share/dsh-desktop/modlens; \
mkdir -p $(DESTDIR)$(PREFIX)/share/dsh-desktop; \
cp -R $(MODLENS_DIR) $(DESTDIR)$(PREFIX)/share/dsh-desktop/modlens; \
fi
if [ -f plugins/dsh-desktop-vision/package.json ]; then \
rm -rf $(DESTDIR)$(PREFIX)/share/dsh-desktop/vision; \
mkdir -p $(DESTDIR)$(PREFIX)/share/dsh-desktop; \
cp -R plugins/dsh-desktop-vision $(DESTDIR)$(PREFIX)/share/dsh-desktop/vision; \
fi
if [ -f $(ANCHORED_DIR)/preset.yml ]; then \
rm -rf $(DESTDIR)$(PREFIX)/share/dsh-desktop/anchored-standard; \
mkdir -p $(DESTDIR)$(PREFIX)/share/dsh-desktop; \
cp -R $(ANCHORED_DIR) $(DESTDIR)$(PREFIX)/share/dsh-desktop/anchored-standard; \
fi
if [ -f $(ZERO_DIR)/preset.yml ]; then \
rm -rf $(DESTDIR)$(PREFIX)/share/dsh-desktop/zero-anchored-standard; \
mkdir -p $(DESTDIR)$(PREFIX)/share/dsh-desktop; \
cp -R $(ZERO_DIR) $(DESTDIR)$(PREFIX)/share/dsh-desktop/zero-anchored-standard; \
fi
@echo "installed to $(PREFIX) — make sure $(PREFIX)/bin is on PATH"
uninstall:
rm -f $(DESTDIR)$(PREFIX)/bin/dsh-desktop
rm -rf $(DESTDIR)$(PREFIX)/share/dsh-desktop
rm -f $(DESTDIR)$(PREFIX)/share/applications/$(APP_ID).desktop
rm -f $(DESTDIR)$(PREFIX)/share/metainfo/$(APP_ID).metainfo.xml
for size in 16 24 32 48 64 128 256 512; do \
rm -f "$(DESTDIR)$(PREFIX)/share/icons/hicolor/$${size}x$${size}/apps/$(APP_ID).png"; \
done
flatpak-build: $(VENDOR_DIR)/bin/dsh $(MODLENS_DIR)/node_modules/@liustack/modlens $(ANCHORED_DIR)/preset.yml $(ZERO_DIR)/preset.yml
$(BUILDER) --user --force-clean --install-deps-from=flathub $(BUILD_DIR) $(MANIFEST)
$(VENDOR_DIR)/bin/dsh:
$(MAKE) vendor
$(MODLENS_DIR)/node_modules/@liustack/modlens:
$(MAKE) vendor
$(ANCHORED_DIR)/preset.yml $(ZERO_DIR)/preset.yml:
$(MAKE) vendor-anchored
flatpak-export:
$(FLATPAK) build-export $(REPO_DIR) $(BUILD_DIR) master
flatpak-install: flatpak-build flatpak-export
$(FLATPAK) --user install -y "$(CURDIR)/$(REPO_DIR)" $(APP_ID)
flatpak-bundle: flatpak-build flatpak-export
mkdir -p dist
$(FLATPAK) build-bundle $(REPO_DIR) "$(BUNDLE)" $(APP_ID) master
flatpak-run:
$(FLATPAK) run $(APP_ID)
clean:
$(CARGO) clean
rm -rf $(BUILD_DIR) $(REPO_DIR) build dist
find . -name __pycache__ -type d -prune -exec rm -rf {} +

215
README.md Normal file
View File

@@ -0,0 +1,215 @@
# DeepSeek Harness Desktop
<p align="center">
<img src="docs/screenshots/icon.png" width="96" alt="DeepSeek Harness" />
</p>
<p align="center">
<strong>把官方 <a href="https://github.com/deepseek-ai/deepseek-harness">DeepSeek Harness</a><code>dsh</code>WebUI 放进原生窗口。</strong><br />
Tauri 2 壳 · 系统 WebView · 苹果风薄标题栏 · Linux / Windows / macOS
</p>
<p align="center">
<a href="https://github.com/TommyFang2077/dsh-desktop/actions/workflows/ci.yml"><img src="https://github.com/TommyFang2077/dsh-desktop/actions/workflows/ci.yml/badge.svg" alt="CI" /></a>
<a href="https://github.com/TommyFang2077/dsh-desktop/actions/workflows/release.yml"><img src="https://github.com/TommyFang2077/dsh-desktop/actions/workflows/release.yml/badge.svg" alt="Release" /></a>
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="MIT" /></a>
<a href="https://github.com/TommyFang2077/dsh-desktop/releases/latest"><img src="https://img.shields.io/github/v/release/TommyFang2077/dsh-desktop" alt="release" /></a>
</p>
本仓库是第三方桌面壳,**不包含** DeepSeek Harness 源码。官方 WebUI 由本机或 Flatpak 内的 `dsh web` 提供;升级 `dsh` 后界面跟着升级,不必重打包前端。
![启动页:正在启动官方 WebUI](docs/screenshots/splash.png)
## 功能
| 能力 | 说明 |
| --- | --- |
| 原生窗口 | 启动 `dsh web --host 127.0.0.1 --port 0`,解析 stdout 里的随机端口,用系统 WebView 加载官方 WebUI |
| 薄标题栏 | 左侧 `•••` 菜单(重新启动 / 在浏览器中打开),右侧最小化 · 缩放 · 关闭;不再占用一排后退/前进/刷新 |
| 零重写 | 官方会话、工作区、插件、技能全部保留 |
| 内置 ModLens | 启动时写入 `~/.dsh/profiles/web`,纯文本模型自动套视觉桥 |
| 视觉设置页 | WebUI **设置 → 视觉模型** 配置引擎,写入 `~/.modlens/config.json` |
| 内置锚定预设 | **锚定式标准(实验)**、**零工具锚定式标准(实验)** 写入 `~/.dsh/.agent-presets/` |
| 生命周期 | 启动页显示状态;关窗口停掉 `dsh web`;崩溃可从标题栏重新启动 |
![主窗口:官方 WebUI 嵌在原生壳里](docs/screenshots/session.png)
*上图为桌面壳嵌套官方 WebUI 的界面示意(自定义标题栏为实际注入样式)。凭据、会话和插件仍在 `~/.dsh`,截图未使用真实对话记录。*
![标题栏菜单:重新启动 / 在浏览器中打开](docs/screenshots/menu.png)
## 内置插件与预设
应用启动时会把下面这些东西同步到用户目录。版本钉死在 [Makefile](Makefile);第三方原文许可证见 [docs/licenses/](docs/licenses/) 与 [THIRD_PARTY.md](THIRD_PARTY.md)。
### 1. DeepSeek Harness`dsh`
| | |
| --- | --- |
| 上游 | [@deepseek-ai/dsh](https://www.npmjs.com/package/@deepseek-ai/dsh) · [deepseek-ai/deepseek-harness](https://github.com/deepseek-ai/deepseek-harness) |
| 当前版本 | `0.1.0-rc.6` |
| 许可证 | MITCopyright (c) 2026 DeepSeek · [docs/licenses/deepseek-harness.LICENSE](docs/licenses/deepseek-harness.LICENSE) |
| 本仓库 | **不 vendoring 源码**。Flatpak 构建时 `make vendor` 打进 Node 24 + npm 包Windows / macOS / deb / rpm 运行时调用本机 `dsh` |
解析顺序:
1. 环境变量 `DSH_DESKTOP_DSH_BIN`
2. 命令行 `--dsh`
3. 应用自己的更新目录(`$XDG_DATA_HOME/dsh-desktop/dsh-prefix/bin/dsh`
4. **Flatpak**:内置 `/app/bin/dsh`
5. **宿主机**`~/.local/bin/dsh``~/.npm/_npx` 缓存 → `PATH``npx --yes @deepseek-ai/dsh`
`DSH_DESKTOP_NO_UPDATE=1` 或传 `--no-update` 可关掉启动时的 npm 更新检查。
### 2. ModLens`@liustack/modlens`
| | |
| --- | --- |
| 上游 | [liustack/modlens](https://github.com/liustack/modlens) · [npm @liustack/modlens](https://www.npmjs.com/package/@liustack/modlens) |
| 当前版本 | `3.16.6` |
| 作者 | Leon Liu / [liustack](https://github.com/liustack) |
| 许可证 | MIT · [docs/licenses/modlens.LICENSE](docs/licenses/modlens.LICENSE) |
| 作用 | 给纯文本对话模型补视觉能力(粘贴图片即可)。已声明视觉能力的模型(如 Qwen不会走这条桥 |
| 安装位置 | 启动时复制到 `~/.dsh/profiles/web/node_modules/@liustack/modlens` |
官方安装方式(本应用已内置,一般不必再跑):
```bash
npx -y @deepseek-ai/dsh plugin --profile web add @liustack/modlens@3.16.6
```
### 3. `dsh-desktop-vision`(本仓库)
| | |
| --- | --- |
| 路径 | [`plugins/dsh-desktop-vision/`](plugins/dsh-desktop-vision/) |
| 版本 | `0.1.4` |
| 许可证 | 与本仓库相同MIT |
| 作用 | 在官方 WebUI **设置 → 视觉模型** 增加表单,读写 `~/.modlens/config.json` |
支持的引擎:
| 引擎 | 默认接口 | 获取密钥 |
| --- | --- | --- |
| OpenAI 兼容 | `https://api.openai.com/v1` | [platform.openai.com/api-keys](https://platform.openai.com/api-keys) |
| Gemini API | `https://generativelanguage.googleapis.com` | [aistudio.google.com/apikey](https://aistudio.google.com/apikey) |
| Anthropic | `https://api.anthropic.com` | [console.anthropic.com/settings/keys](https://console.anthropic.com/settings/keys) |
| Antigravity CLI | 本机 CLI无需填 URL | [antigravity.google](https://antigravity.google/) |
| Claude CLI | 本机 CLI无需填 URL | [code.claude.com](https://code.claude.com) |
外链在系统浏览器中打开Tauri `on_navigation`),密钥只写在本机 ModLens 配置里。
![设置 → 视觉模型](docs/screenshots/vision.png)
### 4. Anchored Standard 预设
| | |
| --- | --- |
| 上游 | [xiaobright/dsh-anchored-standard](https://github.com/xiaobright/dsh-anchored-standard) |
| 钉选提交 | [`ffb845c5480adc953392a6db6f8a98ede621174b`](https://github.com/xiaobright/dsh-anchored-standard/commit/ffb845c5480adc953392a6db6f8a98ede621174b) |
| 作者 | [xiaobright](https://github.com/xiaobright) |
| 许可证 | MIT含 DeepSeek 部分版权)· [LICENSE](docs/licenses/dsh-anchored-standard.LICENSE) · [NOTICE](docs/licenses/dsh-anchored-standard.NOTICE) |
| 本仓库中的名称 | **锚定式标准(实验)**、**零工具锚定式标准(实验)**`scripts/localize_preset.py` 本地化) |
| 安装位置 | `~/.dsh/.agent-presets/anchored-standard``zero-anchored-standard` |
NOTICE 写明:预设改编自 DeepSeek Harness Standard agent preset[deepseek-harness@47f9438](https://github.com/deepseek-ai/deepseek-harness))。这是社区实验 preset**不是** DeepSeek 官方预设。若用户还没有默认 preset桌面会把默认设为锚定式标准。
## 安装包
`v*` 标签(例如 `git tag v0.1.0 && git push origin v0.1.0`)后,[Release 工作流](.github/workflows/release.yml)会测试、打包并发布:
| 平台 | 产物 | 运行时要求 |
| --- | --- | --- |
| Windows | NSIS `.exe`、MSI | [WebView2](https://developer.microsoft.com/microsoft-edge/webview2/)(安装器可引导下载);本机 `dsh` |
| macOS | Apple Silicon / Intel `.dmg` | 未公证,首次打开需在「系统设置 → 隐私与安全性」允许;本机 `dsh` |
| Linux | `.deb``.rpm` | WebKitGTK 4.1;本机 `dsh` |
| Linux | `.flatpak` | **自带** Node.js 24 与 `@deepseek-ai/dsh`,不需要本机安装 dsh |
从 [GitHub Releases](https://github.com/TommyFang2077/dsh-desktop/releases) 下载对应文件。
本机 `dsh`
```bash
npm install -g @deepseek-ai/dsh
# 或
npx --yes @deepseek-ai/dsh --version
```
## 从源码运行
开发依赖Rust stable、系统 WebView。
- LinuxGTK 3 + WebKitGTK 4.1Fedora`gtk3-devel webkit2gtk4.1-devel`Debian/Ubuntu`libgtk-3-dev libwebkit2gtk-4.1-dev`
- macOSWKWebViewXcode Command Line Tools
- WindowsWebView2
```bash
git clone https://github.com/TommyFang2077/dsh-desktop.git
cd dsh-desktop
make vendor-native # ModLens + 锚定预设Tauri 打包资源)
make run # 普通模式(跳过更新,便于开发)
make dev # 开 WebView 检查器和调试日志
cargo run -p dsh-desktop -- --cwd ~/your-project
```
```bash
make test
```
`make install` 把二进制装到 `~/.local/bin/dsh-desktop`,应用菜单里会出现 **DeepSeek Harness**
本地打原生包(先 `make vendor-native`
```bash
cargo tauri build --bundles deb,rpm # Linux
cargo tauri build --bundles nsis,msi # Windows
cargo tauri build --bundles app,dmg # macOS
```
## Flatpak
Flatpak 是唯一把 `dsh` 打进包内的渠道。
```bash
flatpak remote-add --user --if-not-exists flathub \
https://dl.flathub.org/repo/flathub.flatpakrepo
flatpak install --user -y flathub org.gnome.Sdk//47 org.flatpak.Builder \
org.freedesktop.Sdk.Extension.rust-stable//24.08 \
org.freedesktop.Sdk.Extension.node24//24.08
make vendor
make flatpak-build
make flatpak-install
make flatpak-run
make flatpak-bundle
```
清单:[flatpak/io.github.tommyfang.DshDesktop.yml](flatpak/io.github.tommyfang.DshDesktop.yml)。权限网络、宿主文件系统、Wayland/X11、下载目录。
## 项目结构
```text
dsh-desktop/
├── ui/ # 启动页 + 注入到 WebUI 的标题栏
├── src-tauri/ # Tauri 窗口、命令、deb/rpm/nsis/dmg
├── crates/dsh-core/ # 启动 / 更新 / ModLens / 预设 / 剪贴板
├── plugins/dsh-desktop-vision/ # 设置 → 视觉模型
├── data/ # .desktop、图标、AppStream
├── flatpak/
├── docs/screenshots/ # README 截图
├── docs/licenses/ # 第三方许可证副本
├── vendor/ # make vendor 生成git 忽略)
├── scripts/vendor-native.sh
├── scripts/localize_preset.py
└── .github/workflows/ # 测试 + 多平台发布
```
## 图标与商标
应用图标使用 [Icons8 上的 DeepSeek 图标](https://icons8.com/icon/YWOidjGxCpFW/deepseek)。DeepSeek 名称与鲸鱼标志归 DeepSeek 所有。本项目是独立第三方桌面壳,与 DeepSeek、ModLens、Anchored Standard 的作者均无从属关系。
## License
本仓库源码为 [MIT](LICENSE)Copyright © 2026 TommyFang2077。
运行时还会用到上游 MIT 组件,版权仍归原作者,详见 [THIRD_PARTY.md](THIRD_PARTY.md)。

34
THIRD_PARTY.md Normal file
View File

@@ -0,0 +1,34 @@
# Third-party notices
DeepSeek Harness Desktop (this repository) is MIT-licensed. It is a native
shell around other projects. Those projects keep their own copyright and
license. Copies of the relevant texts live in `docs/licenses/`.
This project is **not** affiliated with, endorsed by, or maintained by
DeepSeek, liustack, or xiaobright.
## Bundled or launched at runtime
| Component | Upstream | Version / pin | License | How this app uses it |
| --- | --- | --- | --- | --- |
| DeepSeek Harness (`@deepseek-ai/dsh`) | [deepseek-ai/deepseek-harness](https://github.com/deepseek-ai/deepseek-harness) | `0.1.0-rc.6` | MIT, © 2026 DeepSeek | Official WebUI. Not shipped as source. Flatpak vendors the npm package; other packages call a host `dsh`. See `docs/licenses/deepseek-harness.LICENSE`. |
| ModLens (`@liustack/modlens`) | [liustack/modlens](https://github.com/liustack/modlens) | `3.16.6` | MIT, © 2026 Leon Liu (liustack) | Copied into `~/.dsh/profiles/web` so text-only models can read images. See `docs/licenses/modlens.LICENSE`. |
| Anchored Standard | [xiaobright/dsh-anchored-standard](https://github.com/xiaobright/dsh-anchored-standard) | commit `ffb845c5480adc953392a6db6f8a98ede621174b` | MIT, © 2026 xiaobright; portions © 2026 DeepSeek | Localized as **锚定式标准(实验)** and **零工具锚定式标准(实验)**, written to `~/.dsh/.agent-presets/`. See `docs/licenses/dsh-anchored-standard.LICENSE` and `.NOTICE`. |
| `dsh-desktop-vision` | this repo `plugins/dsh-desktop-vision/` | `0.1.4` | MIT, © 2026 TommyFang2077 | Settings page **设置 → 视觉模型**; writes `~/.modlens/config.json`. |
The Anchored Standard NOTICE records that the presets adapt the DeepSeek
Harness Standard agent preset from
https://github.com/deepseek-ai/deepseek-harness
(commit `47f943859bef60e4160492346772ded9b24f765a`).
## App icon
The application icon is the [Icons8 DeepSeek icon](https://icons8.com/icon/YWOidjGxCpFW/deepseek).
DeepSeek and the whale mark belong to their owners.
## Tauri and system WebView
The window is built with [Tauri 2](https://tauri.app/) (MIT / Apache-2.0) and
the platform WebView (WebKitGTK 4.1, WKWebView, or WebView2). Those components
are linked or bundled by the respective platform toolchains, not copied into
this source tree.

13
bin/dsh-desktop Executable file
View File

@@ -0,0 +1,13 @@
#!/bin/sh
# Run a built binary from the checkout, or fall back to cargo.
set -e
ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
for candidate in \
"$ROOT/target/release/dsh-desktop" \
"$ROOT/target/debug/dsh-desktop"
do
if [ -x "$candidate" ]; then
exec "$candidate" "$@"
fi
done
exec cargo run --manifest-path "$ROOT/Cargo.toml" -p dsh-desktop -- "$@"

View File

@@ -0,0 +1,23 @@
[package]
name = "dsh-core"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
description = "Launcher, updater, and profile helpers for DeepSeek Harness Desktop"
[dependencies]
base64 = "0.22"
dirs = "6"
regex = "1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
shlex = "1"
thiserror = "2"
which = "8"
[target.'cfg(unix)'.dependencies]
libc = "0.2"
[dev-dependencies]
tempfile = "3"

View File

@@ -0,0 +1,227 @@
use std::path::Path;
use base64::Engine;
use serde::Serialize;
pub const IMAGE_MIMES: &[&str] = &[
"image/png",
"image/jpeg",
"image/jpg",
"image/webp",
"image/gif",
"image/bmp",
"image/tiff",
];
pub const INGEST_JS: &str = include_str!("../../../ui/inject/ingest.js");
#[derive(Debug, Clone, Serialize)]
pub struct ClipboardFile {
pub name: String,
#[serde(rename = "type")]
pub mime: String,
pub b64: String,
}
pub fn is_image_mime(mime: &str) -> bool {
let mime = mime.split(';').next().unwrap_or(mime).trim().to_ascii_lowercase();
let mime = if mime == "image/jpg" {
"image/jpeg"
} else {
mime.as_str()
};
IMAGE_MIMES.contains(&mime) || mime == "image/jpeg"
}
pub fn filename_for_mime(mime: &str, index: usize) -> String {
let mime = mime.split(';').next().unwrap_or(mime).trim().to_ascii_lowercase();
let ext = match mime.as_str() {
"image/png" => "png",
"image/jpeg" | "image/jpg" => "jpg",
"image/webp" => "webp",
"image/gif" => "gif",
"image/bmp" => "bmp",
"image/tiff" => "tiff",
_ => "png",
};
if index == 0 {
format!("clipboard.{ext}")
} else {
format!("clipboard-{}.{ext}", index + 1)
}
}
pub fn build_ingest_call(files: &[ClipboardFile]) -> String {
let payload = serde_json::to_string(files).unwrap_or_else(|_| "[]".into());
format!("window.__dshDesktopPasteFiles && window.__dshDesktopPasteFiles({payload});")
}
pub fn parse_uri_list(payload: &str) -> Vec<String> {
let mut paths = Vec::new();
for raw in payload.lines() {
let line = raw.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
if let Some(rest) = line.strip_prefix("file:") {
if let Ok(url) = url_parse_file(rest) {
paths.push(url);
}
} else if line.starts_with('/') {
paths.push(line.to_string());
}
}
paths
}
fn url_parse_file(rest: &str) -> Result<String, ()> {
// file:///home/me/Pictures/shot.png or file://localhost/home/...
let uri = if rest.starts_with("//") {
format!("file:{rest}")
} else {
format!("file:{rest}")
};
let decoded = percent_decode(&uri);
if let Some(idx) = decoded.find("://") {
let after = &decoded[idx + 3..];
let path = after
.strip_prefix("localhost")
.unwrap_or(after);
return Ok(path.to_string());
}
if let Some(path) = decoded.strip_prefix("file:") {
return Ok(path.to_string());
}
Ok(decoded)
}
fn percent_decode(input: &str) -> String {
let bytes = input.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' && i + 2 < bytes.len() {
if let Ok(v) = u8::from_str_radix(std::str::from_utf8(&bytes[i + 1..i + 3]).unwrap_or(""), 16)
{
out.push(v);
i += 3;
continue;
}
}
out.push(bytes[i]);
i += 1;
}
String::from_utf8_lossy(&out).into_owned()
}
pub fn files_from_paths<I, S>(paths: I) -> Vec<ClipboardFile>
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
let mut out = Vec::new();
for (index, raw) in paths.into_iter().enumerate() {
let path = Path::new(raw.as_ref());
if !path.is_file() {
continue;
}
let suffix = path
.extension()
.and_then(|s| s.to_str())
.unwrap_or("")
.to_ascii_lowercase();
let mime = match suffix.as_str() {
"png" => "image/png",
"jpg" | "jpeg" => "image/jpeg",
"webp" => "image/webp",
"gif" => "image/gif",
"bmp" => "image/bmp",
"tif" | "tiff" => "image/tiff",
_ => continue,
};
let Ok(data) = std::fs::read(path) else {
continue;
};
if data.is_empty() {
continue;
}
let name = path
.file_name()
.and_then(|s| s.to_str())
.map(|s| s.to_string())
.unwrap_or_else(|| filename_for_mime(mime, index));
out.push(ClipboardFile {
name,
mime: mime.to_string(),
b64: base64::engine::general_purpose::STANDARD.encode(data),
});
}
out
}
pub fn file_from_bytes(name: String, mime: String, data: Vec<u8>) -> Option<ClipboardFile> {
if data.is_empty() || !is_image_mime(&mime) {
return None;
}
Some(ClipboardFile {
name,
mime: mime.split(';').next().unwrap_or(&mime).trim().to_string(),
b64: base64::engine::general_purpose::STANDARD.encode(data),
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mime_and_filename() {
assert!(is_image_mime("image/png"));
assert!(is_image_mime("image/jpeg; charset=binary"));
assert!(is_image_mime("image/jpg"));
assert!(!is_image_mime("text/plain"));
assert_eq!(filename_for_mime("image/png", 0), "clipboard.png");
assert_eq!(filename_for_mime("image/jpeg", 1), "clipboard-2.jpg");
}
#[test]
fn ingest_js_defines_helper() {
assert!(INGEST_JS.contains("window.__dshDesktopPasteFiles"));
assert!(INGEST_JS.contains("DragEvent"));
assert!(INGEST_JS.contains("new File"));
assert!(INGEST_JS.contains("text/uri-list"));
}
#[test]
fn build_ingest_call_embeds_payload() {
let files = [ClipboardFile {
name: "shot.png".into(),
mime: "image/png".into(),
b64: base64::engine::general_purpose::STANDARD.encode(b"\x89PNG"),
}];
let script = build_ingest_call(&files);
assert!(script.contains("__dshDesktopPasteFiles"));
assert!(script.contains("shot.png"));
assert!(script.contains("image/png"));
assert!(!script.contains(", \"hi\""));
}
#[test]
fn parse_file_uri() {
let payload = "# comment\nfile:///home/me/Pictures/shot.png\n";
assert_eq!(
parse_uri_list(payload),
vec!["/home/me/Pictures/shot.png".to_string()]
);
}
#[test]
fn parse_plain_path() {
assert_eq!(parse_uri_list("/tmp/a.jpg"), vec!["/tmp/a.jpg".to_string()]);
}
#[test]
fn skips_non_images() {
assert!(files_from_paths(["/no/such/file.txt"]).is_empty());
}
}

View File

@@ -0,0 +1,437 @@
use std::collections::VecDeque;
use std::io::{BufRead, BufReader};
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::Instant;
use regex::Regex;
use thiserror::Error;
use crate::paths::{home_dir, is_flatpak};
use crate::updater::update_dsh_bin;
use crate::{ENV_BIN_OVERRIDE, ENV_CWD_OVERRIDE};
pub const DSH_DEFAULT_HOST: &str = "127.0.0.1";
pub const URL_TIMEOUT_SECONDS: u64 = 120;
#[derive(Debug, Error)]
pub enum DshNotFound {
#[error("{0}")]
Message(String),
}
impl DshNotFound {
fn new(msg: impl Into<String>) -> Self {
Self::Message(msg.into())
}
}
pub fn strip_ansi(text: &str) -> String {
let re = Regex::new(r"\x1b\[[0-9;?]*[ -/]*[@-~]").expect("ansi regex");
re.replace_all(text, "").into_owned()
}
pub fn parse_dsh_url(line: &str) -> Option<String> {
let re = Regex::new(r"https?://(?:\[[0-9a-fA-F:]+\]|[^/\s:]+)(?::\d+)?(?:/[^\s]*)?")
.expect("url regex");
re.find(&strip_ansi(line)).map(|m| m.as_str().to_string())
}
pub fn default_workspace() -> PathBuf {
if let Ok(raw) = std::env::var(ENV_CWD_OVERRIDE) {
if !raw.is_empty() {
return PathBuf::from(&raw)
.canonicalize()
.unwrap_or_else(|_| PathBuf::from(raw));
}
}
if is_flatpak() {
return home_dir();
}
std::env::current_dir().unwrap_or_else(|_| home_dir())
}
pub fn find_npx_dsh_bins() -> Vec<PathBuf> {
let pattern = home_dir().join(".npm/_npx/*/node_modules/.bin/dsh");
let mut bins = Vec::new();
if let Some(globbed) = pattern.to_str() {
if let Ok(paths) = glob_simple(globbed) {
for path in paths {
if is_executable(&path) {
bins.push(path);
}
}
}
}
bins.sort_by_key(|p| std::fs::metadata(p).and_then(|m| m.modified()).ok());
bins.reverse();
bins
}
fn glob_simple(pattern: &str) -> std::io::Result<Vec<PathBuf>> {
// Only the `_npx/*/` segment is a wildcard.
let Some((prefix, rest)) = pattern.split_once('*') else {
return Ok(vec![PathBuf::from(pattern)]);
};
let suffix = rest.trim_start_matches('/');
let parent = Path::new(prefix);
let mut out = Vec::new();
if parent.is_dir() {
for entry in std::fs::read_dir(parent)? {
let entry = entry?;
let candidate = entry.path().join(suffix);
if candidate.is_file() {
out.push(candidate);
}
}
}
Ok(out)
}
pub struct DshLauncher {
dsh_bin_override: Option<String>,
in_flatpak: bool,
pub workspace: PathBuf,
}
impl DshLauncher {
pub fn new(dsh_bin: Option<String>, workspace: Option<PathBuf>) -> Self {
Self {
dsh_bin_override: dsh_bin,
in_flatpak: is_flatpak(),
workspace: workspace.unwrap_or_else(default_workspace),
}
}
pub fn resolve(&self) -> Result<Vec<String>, DshNotFound> {
let mut candidates: Vec<Vec<String>> = Vec::new();
if let Ok(env_override) = std::env::var(ENV_BIN_OVERRIDE) {
if !env_override.is_empty() {
let parts = shlex::split(&env_override).ok_or_else(|| {
DshNotFound::new(format!("{ENV_BIN_OVERRIDE} 不是合法的命令"))
})?;
candidates.push(parts);
}
}
if let Some(cli) = &self.dsh_bin_override {
let parts = shlex::split(cli)
.ok_or_else(|| DshNotFound::new("dsh 命令不是合法的命令"))?;
candidates.push(parts);
}
let updated = update_dsh_bin();
if is_executable(&updated) {
candidates.push(vec![updated.to_string_lossy().into_owned()]);
}
if self.in_flatpak {
if let Some(bundled) = which::which("dsh").ok() {
candidates.push(vec![bundled.to_string_lossy().into_owned()]);
}
for path in crate::updater::BUNDLED_DSH {
let path = PathBuf::from(path);
if is_executable(&path)
&& candidates
.first()
.and_then(|c| c.first())
.map(|s| s.as_str())
!= Some(path.to_string_lossy().as_ref())
{
candidates.push(vec![path.to_string_lossy().into_owned()]);
}
}
} else {
let local_bin = dsh_bin_name(&home_dir().join(".local/bin"));
if is_executable(&local_bin) {
candidates.push(vec![local_bin.to_string_lossy().into_owned()]);
}
if let Some(cached) = find_npx_dsh_bins().into_iter().next() {
candidates.push(vec![cached.to_string_lossy().into_owned()]);
}
if let Ok(host) = which::which("dsh") {
candidates.push(vec![host.to_string_lossy().into_owned()]);
}
if which::which("npx").is_ok() {
candidates.push(vec![
"npx".into(),
"--yes".into(),
"@deepseek-ai/dsh".into(),
]);
}
}
let chosen = candidates.into_iter().next().ok_or_else(|| {
if self.in_flatpak {
DshNotFound::new(
"此 Flatpak 没有内置 dsh 可执行文件。\n请重新构建/安装 io.github.tommyfang.DshDesktop。",
)
} else {
DshNotFound::new(
"找不到 DeepSeek Harness (dsh),也没有可用的 npx。\n请先安装npm install -g @deepseek-ai/dsh\n或用 DSH_DESKTOP_DSH_BIN 指定 dsh 的完整路径。",
)
}
})?;
Ok(chosen)
}
pub fn web_argv(&self) -> Result<Vec<String>, DshNotFound> {
let mut argv = self.resolve()?;
argv.extend([
"web".into(),
"--host".into(),
DSH_DEFAULT_HOST.into(),
"--port".into(),
"0".into(),
]);
Ok(argv)
}
pub fn start(&self) -> Result<DshProcess, DshNotFound> {
let argv = self.web_argv()?;
let mut cmd = Command::new(&argv[0]);
cmd.args(&argv[1..])
.current_dir(&self.workspace)
.stdout(Stdio::piped())
.stderr(Stdio::inherit())
.stdin(Stdio::null());
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
cmd.process_group(0);
}
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200;
cmd.creation_flags(CREATE_NEW_PROCESS_GROUP);
}
let mut child = cmd.spawn().map_err(|exc| {
DshNotFound::new(format!("无法启动 dsh web: {exc}"))
})?;
let stdout = child.stdout.take();
Ok(DshProcess::new(child, stdout, self.in_flatpak))
}
}
fn dsh_bin_name(dir: &Path) -> PathBuf {
#[cfg(windows)]
{
let cmd = dir.join("dsh.cmd");
if cmd.is_file() {
return cmd;
}
}
dir.join("dsh")
}
pub fn is_executable(path: &Path) -> bool {
if !path.is_file() {
return false;
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::metadata(path)
.map(|m| m.permissions().mode() & 0o111 != 0)
.unwrap_or(false)
}
#[cfg(windows)]
{
true
}
}
pub struct DshProcess {
child: Arc<Mutex<Child>>,
pub in_flatpak: bool,
pub url: Arc<Mutex<Option<String>>>,
pub lines: Arc<Mutex<VecDeque<String>>>,
pub started_at: Instant,
}
impl DshProcess {
fn new(child: Child, stdout: Option<std::process::ChildStdout>, in_flatpak: bool) -> Self {
let proc = Self {
child: Arc::new(Mutex::new(child)),
in_flatpak,
url: Arc::new(Mutex::new(None)),
lines: Arc::new(Mutex::new(VecDeque::with_capacity(400))),
started_at: Instant::now(),
};
if let Some(stdout) = stdout {
let lines = Arc::clone(&proc.lines);
let url = Arc::clone(&proc.url);
thread::spawn(move || {
let reader = BufReader::new(stdout);
for line in reader.lines() {
let Ok(line) = line else { break };
let clean = strip_ansi(&line);
let trimmed = clean.trim_end();
if !trimmed.is_empty() {
if let Ok(mut buf) = lines.lock() {
if buf.len() == 400 {
buf.pop_front();
}
buf.push_back(trimmed.to_string());
}
}
if let Some(found) = parse_dsh_url(&line) {
if let Ok(mut slot) = url.lock() {
if slot.is_none() {
*slot = Some(found);
}
}
}
}
});
}
proc
}
pub fn poll(&self) -> Option<i32> {
self.child
.lock()
.ok()
.and_then(|mut child| child.try_wait().ok().flatten().and_then(|s| s.code()))
}
pub fn take_url(&self) -> Option<String> {
self.url.lock().ok().and_then(|g| g.clone())
}
pub fn snapshot_lines(&self) -> Vec<String> {
self.lines
.lock()
.map(|g| g.iter().cloned().collect())
.unwrap_or_default()
}
pub fn stop(&self) {
let Ok(mut child) = self.child.lock() else {
return;
};
if child.try_wait().ok().flatten().is_some() {
return;
}
let pid = child.id();
#[cfg(unix)]
unsafe {
libc::killpg(pid as i32, libc::SIGTERM);
}
#[cfg(windows)]
{
let _ = child.kill();
}
let start = Instant::now();
while start.elapsed() < std::time::Duration::from_secs(3) {
if child.try_wait().ok().flatten().is_some() {
return;
}
thread::sleep(std::time::Duration::from_millis(50));
}
#[cfg(unix)]
unsafe {
libc::killpg(pid as i32, libc::SIGKILL);
}
let _ = child.kill();
let _ = child.wait();
}
}
impl Drop for DshProcess {
fn drop(&mut self) {
self.stop();
}
}
#[cfg(test)]
mod tests {
use super::*;
static ENV_LOCK: Mutex<()> = Mutex::new(());
#[test]
fn parse_ipv4_url() {
assert_eq!(
parse_dsh_url("dsh web: http://127.0.0.1:39095"),
Some("http://127.0.0.1:39095".into())
);
}
#[test]
fn parse_ipv6_url() {
assert_eq!(
parse_dsh_url("dsh web: http://[::1]:39096"),
Some("http://[::1]:39096".into())
);
}
#[test]
fn parse_url_with_trailing_text() {
assert_eq!(
parse_dsh_url("booted profile web on http://127.0.0.1:39095 (ready)"),
Some("http://127.0.0.1:39095".into())
);
}
#[test]
fn parse_no_url() {
assert_eq!(parse_dsh_url("waiting for network…"), None);
}
#[test]
fn strip_ansi_codes() {
assert_eq!(strip_ansi("\x1b[36mhttp://x\x1b[0m"), "http://x");
}
#[test]
fn workspace_uses_env_override() {
let _guard = ENV_LOCK.lock().unwrap();
std::env::set_var(ENV_CWD_OVERRIDE, "/tmp/some-workspace");
let ws = default_workspace();
std::env::remove_var(ENV_CWD_OVERRIDE);
assert!(ws.ends_with("some-workspace") || ws == PathBuf::from("/tmp/some-workspace"));
}
#[test]
fn env_override_wins() {
let _guard = ENV_LOCK.lock().unwrap();
std::env::set_var(ENV_BIN_OVERRIDE, "echo");
let argv = DshLauncher::new(None, None).resolve().unwrap();
std::env::remove_var(ENV_BIN_OVERRIDE);
assert_eq!(argv, vec!["echo"]);
}
#[test]
fn invalid_env_override_raises() {
let _guard = ENV_LOCK.lock().unwrap();
std::env::set_var(ENV_BIN_OVERRIDE, "'unterminated");
let err = DshLauncher::new(None, None).resolve();
std::env::remove_var(ENV_BIN_OVERRIDE);
assert!(err.is_err());
}
#[test]
fn cli_override() {
let _guard = ENV_LOCK.lock().unwrap();
std::env::remove_var(ENV_BIN_OVERRIDE);
let argv = DshLauncher::new(Some("/opt/dsh/bin/dsh".into()), None)
.resolve()
.unwrap();
assert_eq!(argv, vec!["/opt/dsh/bin/dsh"]);
}
#[test]
fn web_argv_shape() {
let _guard = ENV_LOCK.lock().unwrap();
std::env::set_var(ENV_BIN_OVERRIDE, "dsh-test");
let argv = DshLauncher::new(None, None).web_argv().unwrap();
std::env::remove_var(ENV_BIN_OVERRIDE);
assert_eq!(&argv[0..4], ["dsh-test", "web", "--host", "127.0.0.1"]);
assert!(argv.contains(&"--port".into()));
assert_eq!(argv.last().map(String::as_str), Some("0"));
}
}

View File

@@ -0,0 +1,21 @@
//! Shared desktop-shell logic for DeepSeek Harness Desktop.
//!
//! This crate has no GUI dependency. The Tauri (or any other) shell starts
//! `dsh web`, keeps the bundled plugins/presets current, and embeds the
//! official WebUI.
pub mod clipboard;
pub mod launcher;
pub mod modlens;
pub mod paths;
pub mod preset;
pub mod updater;
pub const APP_ID: &str = "io.github.tommyfang.DshDesktop";
pub const APP_NAME: &str = "DeepSeek Harness";
pub const APP_SUMMARY: &str = "Desktop shell for DeepSeek Harness";
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
pub const ENV_BIN_OVERRIDE: &str = "DSH_DESKTOP_DSH_BIN";
pub const ENV_CWD_OVERRIDE: &str = "DSH_DESKTOP_CWD";
pub const ENV_NO_UPDATE: &str = "DSH_DESKTOP_NO_UPDATE";

View File

@@ -0,0 +1,473 @@
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use regex::Regex;
use serde_json::{json, Value};
use crate::paths::{copy_tree, dsh_home, replace_symlink, BundledPaths};
pub const PACKAGE: &str = "@liustack/modlens";
pub const VISION_PACKAGE: &str = "dsh-desktop-vision";
pub const MODLENS_VERSION: &str = "3.16.6";
pub const HIDE_PLAIN_TWINS_JS: &str = include_str!("../../../ui/inject/hide-twins.js");
pub const MANAGED_OVERLAY: &str = "\
# dsh-desktop manages this modlens overlay (wrap every text-only model).
- id: modlens
config:
autoRead: true
families:
- \"\"
";
const OFFICIAL_TEXT_PROVIDERS: &[(&str, &str)] = &[
("deepseek-official", "deepseek-modlens"),
("deepseek", "deepseek-modlens"),
];
#[derive(Debug, Clone)]
pub struct ModlensEnsureResult {
pub status: &'static str,
pub version: Option<String>,
pub message: String,
}
pub fn web_profile_dir() -> PathBuf {
dsh_home().join("profiles/web")
}
fn package_dir(prefix: &Path) -> PathBuf {
prefix.join("node_modules/@liustack/modlens")
}
pub fn read_modlens_version(prefix: &Path) -> Option<String> {
let text = std::fs::read_to_string(package_dir(prefix).join("package.json")).ok()?;
let data: Value = serde_json::from_str(&text).ok()?;
data.get("version")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
}
pub fn bundled_vision_plugin(paths: &BundledPaths) -> Option<PathBuf> {
paths
.find_dir("vision", "package.json")
.or_else(|| paths.find_dir("dsh-desktop-vision", "package.json"))
.or_else(|| paths.find_dir("plugins/dsh-desktop-vision", "package.json"))
.filter(|p| p.join("client.js").is_file())
}
pub fn bundled_modlens_prefix(paths: &BundledPaths) -> Option<PathBuf> {
paths
.find_dir("modlens", "node_modules/@liustack/modlens")
.or_else(|| paths.find_dir("vendor/modlens", "node_modules/@liustack/modlens"))
}
fn install_into_profile(src_prefix: &Path, profile: &Path) -> std::io::Result<()> {
let dest_pkg = package_dir(profile);
copy_tree(&package_dir(src_prefix), &dest_pkg, true)?;
let dest_nm = profile.join("node_modules");
let src_nm = src_prefix.join("node_modules");
for dep in ["commander", "undici"] {
let src_dep = src_nm.join(dep);
if src_dep.is_dir() {
copy_tree(&src_dep, &dest_nm.join(dep), true)?;
}
}
let fallback = dsh_home().join("profiles/node_modules/@liustack/modlens");
let _ = replace_symlink(&fallback, &dest_pkg);
Ok(())
}
fn read_pkg_version(dir: &Path) -> Option<String> {
let text = std::fs::read_to_string(dir.join("package.json")).ok()?;
let data: Value = serde_json::from_str(&text).ok()?;
data.get("version")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
}
fn install_vision_plugin(paths: &BundledPaths, profile: &Path) -> bool {
let Some(src) = bundled_vision_plugin(paths) else {
return false;
};
let dest = profile.join("node_modules").join(VISION_PACKAGE);
let up_to_date = dest.join("client.js").is_file()
&& dest.join("index.js").is_file()
&& read_pkg_version(&dest) == read_pkg_version(&src);
if !up_to_date && copy_tree(&src, &dest, true).is_err() {
return false;
}
let fallback = dsh_home().join("profiles/node_modules").join(VISION_PACKAGE);
let _ = replace_symlink(&fallback, &dest);
true
}
fn ensure_manifest(profile: &Path, packages: &BTreeMap<String, String>) -> std::io::Result<()> {
let path = profile.join("package.json");
let mut data = if path.is_file() {
serde_json::from_str(&std::fs::read_to_string(&path)?).unwrap_or_else(|_| json!({}))
} else {
json!({
"name": "dsh-profile-web",
"private": true,
"dsh": {"profile": {"bundles": ["@deepseek-ai/dsh-base", "@deepseek-ai/dsh-web-app"]}}
})
};
{
let obj = data.as_object_mut().unwrap();
obj.entry("dependencies").or_insert_with(|| json!({}));
let dsh = obj.entry("dsh").or_insert_with(|| json!({}));
let profile_meta = dsh
.as_object_mut()
.unwrap()
.entry("profile")
.or_insert_with(|| json!({}));
let bundles = profile_meta
.as_object_mut()
.unwrap()
.entry("bundles")
.or_insert_with(|| json!(["@deepseek-ai/dsh-base", "@deepseek-ai/dsh-web-app"]));
if !bundles.is_array() {
*bundles = json!(["@deepseek-ai/dsh-base", "@deepseek-ai/dsh-web-app"]);
}
}
for (name, version) in packages {
data["dependencies"][name] = json!(version);
let arr = data["dsh"]["profile"]["bundles"].as_array_mut().unwrap();
if !arr.iter().any(|v| v.as_str() == Some(name)) {
arr.push(json!(name));
}
}
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(path, serde_json::to_string_pretty(&data)? + "\n")
}
pub fn ensure_modlens_overlay(text: &str) -> String {
let body = text.replace("\r\n", "\n");
let stripped = body.trim();
if stripped.is_empty() || stripped == "[]" || stripped == "#" {
return MANAGED_OVERLAY.to_string();
}
let re = Regex::new(r"(?m)^- id:\s*modlens\s*$").unwrap();
if re.is_match(&body) {
return patch_existing_modlens_entry(&body);
}
let mut body = body;
if !body.ends_with('\n') {
body.push('\n');
}
body.push('\n');
body.push_str(MANAGED_OVERLAY);
body
}
fn patch_existing_modlens_entry(body: &str) -> String {
let lines: Vec<&str> = body.split('\n').collect();
let mut start = None;
let start_re = Regex::new(r"^- id:\s*modlens\s*$").unwrap();
for (index, line) in lines.iter().enumerate() {
if start_re.is_match(line) {
start = Some(index);
break;
}
}
let Some(start) = start else {
let mut out = body.trim_end().to_string();
out.push_str("\n\n");
out.push_str(MANAGED_OVERLAY);
return out;
};
let mut end = lines.len();
for (index, line) in lines.iter().enumerate().skip(start + 1) {
if line.starts_with("- ") && !line.starts_with(' ') {
end = index;
break;
}
}
let block: Vec<String> = lines[start..end].iter().map(|s| (*s).to_string()).collect();
let new_block = force_wrap_all_config(block).join("\n");
let new_block = new_block.trim_end();
let mut out = String::new();
out.push_str(&lines[..start].join("\n"));
if start > 0 && !out.ends_with('\n') && !out.is_empty() {
out.push('\n');
}
// reconstruct like Python: lines[:start] + new_block.split + lines[end:]
let mut combined: Vec<String> = lines[..start].iter().map(|s| (*s).to_string()).collect();
combined.extend(new_block.split('\n').map(|s| s.to_string()));
combined.extend(lines[end..].iter().map(|s| (*s).to_string()));
let mut text = combined.join("\n");
text = text.trim_end().to_string();
text.push('\n');
text
}
fn force_wrap_all_config(block: Vec<String>) -> Vec<String> {
let config_re = Regex::new(r"^\s+config:\s*$").unwrap();
let mut block = block;
if !block.iter().any(|line| config_re.is_match(line)) {
block.push(" config:".into());
}
let mut stripped = Vec::new();
let mut skipping_families = false;
let families_re = Regex::new(r"^\s+families:\s*").unwrap();
let item_re = Regex::new(r"^\s+- ").unwrap();
for line in block {
if skipping_families {
if item_re.is_match(&line) || line.trim().is_empty() {
continue;
}
skipping_families = false;
}
if families_re.is_match(&line) {
skipping_families = true;
continue;
}
stripped.push(line);
}
let autoread_re = Regex::new(r"^\s+autoRead:\s*").unwrap();
let has_autoread = stripped.iter().any(|line| autoread_re.is_match(line));
let mut out = Vec::new();
let mut inserted = false;
for line in stripped {
if autoread_re.is_match(&line) {
let replaced = Regex::new(r"^(\s+autoRead:\s*).*$")
.unwrap()
.replace(&line, "${1}true");
out.push(replaced.into_owned());
out.push(" families:".into());
out.push(" - \"\"".into());
inserted = true;
continue;
}
let is_config = config_re.is_match(&line);
out.push(line);
if is_config && !has_autoread && !inserted {
out.push(" autoRead: true".into());
out.push(" families:".into());
out.push(" - \"\"".into());
inserted = true;
}
}
if !inserted {
out.push(" autoRead: true".into());
out.push(" families:".into());
out.push(" - \"\"".into());
}
out
}
pub fn remap_default_text_model(settings_text: &str) -> String {
let mut text = settings_text.to_string();
if !text.ends_with('\n') {
text.push('\n');
}
let re = Regex::new(r"(?m)^(agent-default-model:\n(?: .*\n)*)").unwrap();
let Some(caps) = re.captures(&text) else {
return settings_text.to_string();
};
let block = caps.get(1).unwrap().as_str().to_string();
let provider_re = Regex::new(r"(?m)^ provider:\s*(\S+)\s*$").unwrap();
let Some(provider) = provider_re.captures(&block) else {
return settings_text.to_string();
};
let current = provider[1].trim_matches(|c| c == '"' || c == '\'');
let Some((_, wrapped)) = OFFICIAL_TEXT_PROVIDERS.iter().find(|(k, _)| *k == current) else {
return settings_text.to_string();
};
let new_block = Regex::new(r"(?m)^( provider:\s*)\S+\s*$")
.unwrap()
.replacen(&block, 1, format!("${{1}}{wrapped}"));
let start = caps.get(1).unwrap().start();
let end = caps.get(1).unwrap().end();
let mut out = String::new();
out.push_str(&text[..start]);
out.push_str(&new_block);
out.push_str(&text[end..]);
if !settings_text.ends_with('\n') && out.ends_with('\n') {
// keep a trailing newline; Python operated on the padded copy
}
out
}
pub fn ensure_modlens(paths: &BundledPaths) -> ModlensEnsureResult {
let src = bundled_modlens_prefix(paths);
let profile = web_profile_dir();
let installed = read_modlens_version(&profile);
let version = src
.as_ref()
.and_then(|p| read_modlens_version(p))
.or_else(|| installed.clone())
.unwrap_or_else(|| MODLENS_VERSION.to_string());
match ensure_modlens_inner(paths, src.as_deref(), &profile, installed, &version) {
Ok(result) => result,
Err(exc) => ModlensEnsureResult {
status: "failed",
version: Some(version),
message: format!("内置 ModLens 安装失败:{exc}"),
},
}
}
fn ensure_modlens_inner(
paths: &BundledPaths,
src: Option<&Path>,
profile: &Path,
installed: Option<String>,
version: &str,
) -> std::io::Result<ModlensEnsureResult> {
std::fs::create_dir_all(profile)?;
let vision_ok = install_vision_plugin(paths, profile);
let mut packages = BTreeMap::new();
if vision_ok {
packages.insert(VISION_PACKAGE.to_string(), "0.1.0".into());
}
if src.is_none() && installed.is_none() {
if !packages.is_empty() {
ensure_manifest(profile, &packages)?;
}
return Ok(ModlensEnsureResult {
status: "skipped",
version: None,
message: "未找到内置 ModLens跳过插件安装".into(),
});
}
let installed_after = if let Some(src) = src {
if installed.as_deref() != Some(version) {
install_into_profile(src, profile)?;
read_modlens_version(profile).unwrap_or_else(|| version.to_string())
} else {
installed.clone().unwrap_or_else(|| version.to_string())
}
} else {
installed.clone().unwrap_or_else(|| version.to_string())
};
packages.insert(PACKAGE.to_string(), installed_after.clone());
ensure_manifest(profile, &packages)?;
let patch = profile.join("cordis.patch.yml");
let previous = if patch.is_file() {
std::fs::read_to_string(&patch)?
} else {
String::new()
};
let updated = ensure_modlens_overlay(&previous);
if updated != previous {
std::fs::write(&patch, updated)?;
}
let settings = dsh_home().join("settings.yaml");
if settings.is_file() {
let original = std::fs::read_to_string(&settings)?;
let remapped = remap_default_text_model(&original);
if remapped != original {
std::fs::write(&settings, remapped)?;
}
}
if src.is_none() {
return Ok(ModlensEnsureResult {
status: "current",
version: Some(installed_after.clone()),
message: format!("已配置已安装的 ModLens {installed_after}(纯文本自动套视觉桥)"),
});
}
if installed.as_deref() == Some(version) {
return Ok(ModlensEnsureResult {
status: "current",
version: Some(version.to_string()),
message: format!("内置 ModLens {version} 已就绪"),
});
}
if installed.is_some() {
return Ok(ModlensEnsureResult {
status: "updated",
version: Some(version.to_string()),
message: format!("已将内置 ModLens 更新到 {version}"),
});
}
Ok(ModlensEnsureResult {
status: "installed",
version: Some(version.to_string()),
message: format!("已启用内置 ModLens {version}"),
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_file_gets_managed_overlay() {
let text = ensure_modlens_overlay("");
assert!(text.contains("id: modlens"));
assert!(text.contains("autoRead: true"));
assert!(text.contains("families:"));
assert!(text.contains("- \"\""));
}
#[test]
fn existing_modlens_gains_wrap_all_families() {
let text = ensure_modlens_overlay("- id: modlens\n config:\n autoRead: true\n");
assert!(text.contains("autoRead: true"));
assert!(text.contains(" - \"\""));
}
#[test]
fn replaces_narrow_families() {
let original = "\
- id: modlens
config:
autoRead: true
families:
- deepseek
- glm
";
let text = ensure_modlens_overlay(original);
assert!(!text.contains("deepseek"));
assert!(!text.contains("glm"));
assert!(text.contains("- \"\""));
}
#[test]
fn keeps_other_entries() {
let original = "- id: other\n config:\n x: 1\n- id: modlens\n config:\n autoRead: false\n";
let text = ensure_modlens_overlay(original);
assert!(text.contains("id: other"));
assert!(text.contains("autoRead: true"));
assert!(text.contains(" - \"\""));
}
#[test]
fn official_deepseek_becomes_modlens() {
let original = "\
agent-default-model:
provider: deepseek-official
model: deepseek-v4-pro
ui-theme:
preference: dark
";
let updated = remap_default_text_model(original);
assert!(updated.contains("provider: deepseek-modlens"));
assert!(updated.contains("model: deepseek-v4-pro"));
assert!(updated.contains("preference: dark"));
}
#[test]
fn qwen_is_left_alone() {
let original = "agent-default-model:\n provider: qwen\n model: qwen-agent\n";
assert_eq!(remap_default_text_model(original), original);
}
#[test]
fn already_wrapped_is_left_alone() {
let original = "agent-default-model:\n provider: deepseek-modlens\n model: deepseek-v4-pro\n";
assert_eq!(remap_default_text_model(original), original);
}
#[test]
fn hide_twins_script_targets_suffix() {
assert!(HIDE_PLAIN_TWINS_JS.contains("(modlens vision)"));
assert!(HIDE_PLAIN_TWINS_JS.contains("MutationObserver"));
}
}

View File

@@ -0,0 +1,179 @@
use std::env;
use std::path::{Path, PathBuf};
/// XDG-style data directory (`~/.local/share` on Linux).
pub fn data_home() -> PathBuf {
if let Some(raw) = env::var_os("XDG_DATA_HOME") {
if !raw.is_empty() {
return PathBuf::from(raw);
}
}
dirs::data_dir().unwrap_or_else(|| home_dir().join(".local/share"))
}
/// XDG-style cache directory (`~/.cache` on Linux).
pub fn cache_home() -> PathBuf {
if let Some(raw) = env::var_os("XDG_CACHE_HOME") {
if !raw.is_empty() {
return PathBuf::from(raw);
}
}
dirs::cache_dir().unwrap_or_else(|| home_dir().join(".cache"))
}
pub fn home_dir() -> PathBuf {
dirs::home_dir().unwrap_or_else(|| PathBuf::from("."))
}
pub fn dsh_home() -> PathBuf {
if let Some(raw) = env::var_os("DSH_HOME") {
if !raw.is_empty() {
return PathBuf::from(raw);
}
}
home_dir().join(".dsh")
}
pub fn downloads_dir() -> PathBuf {
dirs::download_dir().unwrap_or_else(|| home_dir().join("Downloads"))
}
pub fn is_flatpak() -> bool {
Path::new("/.flatpak-info").exists()
}
/// Roots that may contain bundled ModLens / presets / vision plugin.
///
/// Search order: extra roots (Tauri resource dir), `$XDG_DATA_HOME/dsh-desktop`,
/// `/app/share/dsh-desktop`, `/usr/share/dsh-desktop`, `~/.local/share/dsh-desktop`,
/// then the source-tree `vendor/` / `plugins/` next to the executable or crate.
#[derive(Debug, Clone, Default)]
pub struct BundledPaths {
extra_roots: Vec<PathBuf>,
}
impl BundledPaths {
pub fn discover() -> Self {
Self::default()
}
pub fn with_resource_dir(mut self, dir: PathBuf) -> Self {
if dir.is_dir() {
self.extra_roots.insert(0, dir);
}
self
}
pub fn roots(&self) -> Vec<PathBuf> {
let mut roots = self.extra_roots.clone();
if let Some(xdg) = env::var_os("XDG_DATA_HOME") {
roots.push(PathBuf::from(xdg).join("dsh-desktop"));
}
roots.push(PathBuf::from("/app/share/dsh-desktop"));
roots.push(PathBuf::from("/usr/share/dsh-desktop"));
roots.push(home_dir().join(".local/share/dsh-desktop"));
for repo in repo_roots() {
roots.push(repo);
}
roots
}
pub fn find_dir(&self, rel: &str, marker: &str) -> Option<PathBuf> {
for root in self.roots() {
let candidate = root.join(rel);
if candidate.join(marker).is_file() || candidate.join(marker).is_dir() {
return Some(candidate);
}
}
None
}
}
fn repo_roots() -> Vec<PathBuf> {
let mut roots = Vec::new();
if let Ok(exe) = env::current_exe() {
if let Some(parent) = exe.parent() {
roots.push(parent.to_path_buf());
if let Some(grand) = parent.parent() {
roots.push(grand.to_path_buf());
}
}
}
// crates/dsh-core -> repo root; src-tauri -> repo root
let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
if let Some(parent) = manifest.parent() {
roots.push(parent.to_path_buf());
if let Some(grand) = parent.parent() {
roots.push(grand.to_path_buf());
}
}
roots
}
pub fn copy_tree(src: &Path, dest: &Path, keep_symlinks: bool) -> std::io::Result<()> {
if dest.exists() {
std::fs::remove_dir_all(dest)?;
}
if let Some(parent) = dest.parent() {
std::fs::create_dir_all(parent)?;
}
copy_tree_inner(src, dest, keep_symlinks)
}
fn copy_tree_inner(src: &Path, dest: &Path, keep_symlinks: bool) -> std::io::Result<()> {
if src
.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n == ".git")
{
return Ok(());
}
if src.is_symlink() && keep_symlinks {
let target = std::fs::read_link(src)?;
#[cfg(unix)]
std::os::unix::fs::symlink(target, dest)?;
#[cfg(windows)]
{
if src.is_dir() {
std::os::windows::fs::symlink_dir(target, dest)?;
} else {
std::os::windows::fs::symlink_file(target, dest)?;
}
}
return Ok(());
}
if src.is_dir() {
std::fs::create_dir_all(dest)?;
for entry in std::fs::read_dir(src)? {
let entry = entry?;
copy_tree_inner(&entry.path(), &dest.join(entry.file_name()), keep_symlinks)?;
}
return Ok(());
}
std::fs::copy(src, dest)?;
Ok(())
}
pub fn replace_symlink(link: &Path, target: &Path) -> std::io::Result<()> {
if let Some(parent) = link.parent() {
std::fs::create_dir_all(parent)?;
}
if link.exists() || link.is_symlink() {
if link.is_dir() && !link.is_symlink() {
std::fs::remove_dir_all(link)?;
} else {
std::fs::remove_file(link)?;
}
}
#[cfg(unix)]
std::os::unix::fs::symlink(target, link)?;
#[cfg(windows)]
{
if target.is_dir() {
std::os::windows::fs::symlink_dir(target, link)?;
} else {
std::os::windows::fs::symlink_file(target, link)?;
}
}
Ok(())
}

View File

@@ -0,0 +1,379 @@
use std::path::{Path, PathBuf};
use regex::Regex;
use crate::paths::{copy_tree, dsh_home, BundledPaths};
pub const PRESET_ID: &str = "anchored-standard";
pub const ZERO_PRESET_ID: &str = "zero-anchored-standard";
pub const SOURCE_MARKER: &str = ".dsh-desktop-source";
pub const DEFAULT_BLOCK: &str = "agent-presets:\n default: anchored-standard\n";
pub const PRESET_NAME_ZH: &str = "锚定式标准(实验)";
pub const PRESET_DESCRIPTION_ZH: &str =
"首轮使用 Minimal 的真实工具对(持久 bash + str_replace_editor不自动注入工作区或技能上下文首次工具调用或回复后开放完整 Standard 工具。";
pub const ZERO_PRESET_NAME_ZH: &str = "零工具锚定式标准(实验)";
pub const ZERO_PRESET_DESCRIPTION_ZH: &str =
"先插入一轮无工具的锚定对话(固定提示),从下一轮起开放完整 Standard 工具。";
#[derive(Debug, Clone)]
pub struct BundledPreset {
pub preset_id: &'static str,
pub name_zh: &'static str,
pub description_zh: &'static str,
}
pub const BUNDLED_PRESETS: &[BundledPreset] = &[
BundledPreset {
preset_id: PRESET_ID,
name_zh: PRESET_NAME_ZH,
description_zh: PRESET_DESCRIPTION_ZH,
},
BundledPreset {
preset_id: ZERO_PRESET_ID,
name_zh: ZERO_PRESET_NAME_ZH,
description_zh: ZERO_PRESET_DESCRIPTION_ZH,
},
];
#[derive(Debug, Clone)]
pub struct PresetEnsureResult {
pub status: &'static str,
pub version: Option<String>,
pub message: String,
}
pub fn preset_install_dir(preset_id: &str) -> PathBuf {
dsh_home().join(".agent-presets").join(preset_id)
}
fn is_preset_dir(path: &Path) -> bool {
path.is_dir() && path.join("preset.yml").is_file()
}
pub fn read_source_version(path: &Path) -> Option<String> {
let version = std::fs::read_to_string(path.join(SOURCE_MARKER)).ok()?;
let version = version.trim();
if version.is_empty() {
None
} else {
Some(version.to_string())
}
}
pub fn bundled_preset_dir(paths: &BundledPaths, preset_id: &str) -> Option<PathBuf> {
paths
.find_dir(preset_id, "preset.yml")
.or_else(|| paths.find_dir(&format!("vendor/{preset_id}"), "preset.yml"))
.filter(|p| is_preset_dir(p))
}
pub fn ensure_default_preset(text: &str) -> String {
let mut body = text.replace("\r\n", "\n");
if !body.ends_with('\n') {
body.push('\n');
}
if body.trim().is_empty() {
return DEFAULT_BLOCK.to_string();
}
let re = Regex::new(r"(?m)^agent-presets:\n((?:[ \t]+.*\n)*)").unwrap();
let Some(caps) = re.captures(&body) else {
body.push('\n');
body.push_str(DEFAULT_BLOCK);
return body;
};
let inner = caps.get(1).unwrap().as_str();
if Regex::new(r"(?m)^[ \t]+default:\s*\S+")
.unwrap()
.is_match(inner)
{
return body;
}
let insert = format!(" default: {PRESET_ID}\n");
let start = caps.get(1).unwrap().start();
let end = caps.get(1).unwrap().end();
format!("{}{}{}{}", &body[..start], insert, inner, &body[end..])
}
pub fn localize_preset_yml(text: &str, name: &str, description: &str) -> String {
let mut body = text.replace("\r\n", "\n");
if !body.ends_with('\n') {
body.push('\n');
}
let name_line = format!("name: {}\n", serde_json::to_string(name).unwrap());
let desc_line = format!(
"description: {}\n",
serde_json::to_string(description).unwrap()
);
let name_re = Regex::new(r"(?m)^name:.*\n").unwrap();
if let Some(m) = name_re.find(&body) {
body = format!("{}{}{}", &body[..m.start()], name_line, &body[m.end()..]);
} else {
body = format!("{name_line}{body}");
}
let desc_re = Regex::new(r"(?m)^description:(?:[ \t].*)?\n(?:[ \t].+\n)*").unwrap();
if let Some(m) = desc_re.find(&body) {
return format!("{}{}{}", &body[..m.start()], desc_line, &body[m.end()..]);
}
if let Some(m) = name_re.find(&body) {
return format!("{}{}{}", &body[..m.end()], desc_line, &body[m.end()..]);
}
format!("{desc_line}{body}")
}
fn write_localized_preset(dest: &Path, spec: &BundledPreset) -> std::io::Result<()> {
let path = dest.join("preset.yml");
let previous = std::fs::read_to_string(&path)?;
let updated = localize_preset_yml(&previous, spec.name_zh, spec.description_zh);
if updated != previous {
std::fs::write(path, updated)?;
}
Ok(())
}
fn short_version(version: Option<&str>) -> String {
match version {
None => "bundled".into(),
Some(v) if v.len() > 12 => v[..12].into(),
Some(v) => v.into(),
}
}
fn ensure_one(paths: &BundledPaths, spec: &BundledPreset) -> PresetEnsureResult {
let src = bundled_preset_dir(paths, spec.preset_id);
let dest = preset_install_dir(spec.preset_id);
let bundled = src.as_ref().and_then(|p| read_source_version(p));
let installed = if dest.is_dir() {
read_source_version(&dest)
} else {
None
};
let version = bundled.clone().or_else(|| installed.clone());
let label = spec.name_zh;
if src.is_none() {
if dest.is_dir() && dest.join("preset.yml").is_file() {
let _ = write_localized_preset(&dest, spec);
return PresetEnsureResult {
status: "current",
version: version.clone(),
message: format!("已配置已安装的{label}{}", short_version(version.as_deref())),
};
}
return PresetEnsureResult {
status: "skipped",
version: None,
message: format!("未找到内置{label},跳过安装"),
};
}
let src = src.unwrap();
if let Err(exc) = (|| -> std::io::Result<()> {
if installed != bundled || !dest.join("preset.yml").is_file() {
copy_tree(&src, &dest, false)?;
}
write_localized_preset(&dest, spec)?;
Ok(())
})() {
return PresetEnsureResult {
status: "failed",
version,
message: format!("内置{label}安装失败:{exc}"),
};
}
let sha = short_version(bundled.as_deref());
if installed == bundled && dest.join("preset.yml").is_file() {
return PresetEnsureResult {
status: "current",
version: bundled,
message: format!("内置{label} {sha} 已就绪"),
};
}
if installed.is_some() {
return PresetEnsureResult {
status: "updated",
version: bundled,
message: format!("已将内置{label}更新到 {sha}"),
};
}
PresetEnsureResult {
status: "installed",
version: bundled,
message: format!("已启用内置{label} {sha}"),
}
}
fn combine(results: &[PresetEnsureResult]) -> PresetEnsureResult {
if results.iter().any(|item| item.status == "failed") {
let failed: Vec<_> = results
.iter()
.filter(|item| item.status == "failed")
.map(|item| item.message.clone())
.collect();
return PresetEnsureResult {
status: "failed",
version: None,
message: failed.join(""),
};
}
let active: Vec<_> = results
.iter()
.filter(|item| item.status != "skipped")
.collect();
if active.is_empty() {
return PresetEnsureResult {
status: "skipped",
version: None,
message: "未找到内置锚定预设,跳过安装".into(),
};
}
let version = active.iter().find_map(|item| item.version.clone());
let (status, lead) = if active.iter().any(|item| item.status == "updated") {
("updated", "已更新内置锚定预设")
} else if active.iter().any(|item| item.status == "installed") {
("installed", "已启用内置锚定预设")
} else {
("current", "内置锚定预设已就绪")
};
let detail = active
.iter()
.map(|item| item.message.as_str())
.collect::<Vec<_>>()
.join("");
PresetEnsureResult {
status,
version,
message: format!("{lead}。{detail}"),
}
}
pub fn ensure_anchored_standard(paths: &BundledPaths) -> PresetEnsureResult {
let results: Vec<_> = BUNDLED_PRESETS
.iter()
.map(|spec| ensure_one(paths, spec))
.collect();
let combined = combine(&results);
if combined.status == "failed" {
return combined;
}
let settings = dsh_home().join("settings.yaml");
match (|| -> std::io::Result<()> {
let previous = if settings.is_file() {
std::fs::read_to_string(&settings)?
} else {
String::new()
};
let updated = ensure_default_preset(&previous);
if updated != previous {
if let Some(parent) = settings.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&settings, updated)?;
}
Ok(())
})() {
Ok(()) => combined,
Err(exc) => PresetEnsureResult {
status: "failed",
version: combined.version,
message: format!("写入默认 preset 失败:{exc}"),
},
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::paths::BundledPaths;
#[test]
fn empty_file_gets_default_block() {
assert_eq!(
ensure_default_preset(""),
"agent-presets:\n default: anchored-standard\n"
);
}
#[test]
fn appends_when_section_missing() {
let updated = ensure_default_preset("ui-theme:\n preference: dark\n");
assert!(updated.contains("preference: dark"));
assert!(updated.contains("agent-presets:\n default: anchored-standard\n"));
}
#[test]
fn inserts_default_into_empty_section() {
let updated = ensure_default_preset("agent-presets:\nui-theme:\n preference: dark\n");
assert!(updated.contains("agent-presets:\n default: anchored-standard\n"));
assert!(updated.contains("preference: dark"));
}
#[test]
fn keeps_existing_default() {
let original = "agent-presets:\n default: standard\n";
assert_eq!(ensure_default_preset(original), original);
}
#[test]
fn does_not_match_permission_default_preset() {
let original = "permission:\n defaultPreset: danger-full-access\n";
let updated = ensure_default_preset(original);
assert!(updated.contains("defaultPreset: danger-full-access"));
assert!(updated.contains("agent-presets:\n default: anchored-standard\n"));
}
#[test]
fn replaces_english_name_and_description() {
let original = "\
name: Anchored Standard (experimental)
description: Bootstrap with the Minimal preset's real tool pair.
order: 5
";
let updated = localize_preset_yml(original, PRESET_NAME_ZH, PRESET_DESCRIPTION_ZH);
assert!(updated.contains(PRESET_NAME_ZH));
assert!(updated.contains(PRESET_DESCRIPTION_ZH));
assert!(!updated.contains("Anchored Standard (experimental)"));
assert!(!updated.contains("Bootstrap"));
assert!(updated.contains("order: 5"));
}
#[test]
fn inserts_description_after_name() {
let updated = localize_preset_yml("name: Anchored Standard\norder: 5\n", PRESET_NAME_ZH, PRESET_DESCRIPTION_ZH);
assert!(updated.contains(PRESET_NAME_ZH));
assert!(!updated.contains("name: Anchored Standard\n"));
assert!(updated.contains(PRESET_DESCRIPTION_ZH));
assert!(updated.contains("order: 5"));
}
#[test]
fn installs_from_bundle_and_sets_default() {
let tmp = tempfile::TempDir::new().unwrap();
let src = tmp.path().join("anchored-standard");
std::fs::create_dir_all(&src).unwrap();
std::fs::write(src.join("preset.yml"), "name: Anchored Standard\n").unwrap();
std::fs::write(src.join(SOURCE_MARKER), "abc123def456\n").unwrap();
let home = tmp.path().join(".dsh");
std::env::set_var("DSH_HOME", &home);
let paths = BundledPaths::discover().with_resource_dir(tmp.path().to_path_buf());
let result = ensure_anchored_standard(&paths);
std::env::remove_var("DSH_HOME");
assert_eq!(result.status, "installed");
let dest = home.join(".agent-presets").join(PRESET_ID);
assert!(dest.join("preset.yml").is_file());
let yml = std::fs::read_to_string(dest.join("preset.yml")).unwrap();
assert!(yml.contains(PRESET_DESCRIPTION_ZH));
let settings = std::fs::read_to_string(home.join("settings.yaml")).unwrap();
assert!(settings.contains("default: anchored-standard"));
}
#[test]
fn localizes_zero_preset() {
let original = "\
name: Zero-Anchored Standard (experimental)
description: Inject one zero-tool anchor turn.
order: 6
";
let updated = localize_preset_yml(original, ZERO_PRESET_NAME_ZH, ZERO_PRESET_DESCRIPTION_ZH);
assert!(updated.contains(ZERO_PRESET_NAME_ZH));
assert!(updated.contains(ZERO_PRESET_DESCRIPTION_ZH));
assert!(!updated.contains("Zero-Anchored"));
}
}

View File

@@ -0,0 +1,348 @@
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::thread;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use crate::paths::{cache_home, data_home};
use crate::ENV_NO_UPDATE;
pub const DSH_PACKAGE: &str = "@deepseek-ai/dsh";
pub const VIEW_TIMEOUT_SECONDS: u64 = 20;
pub const INSTALL_TIMEOUT_SECONDS: u64 = 180;
pub const BUNDLED_PREFIX: &str = "/app";
pub const BUNDLED_DSH: &[&str] = &["/app/bin/dsh", "/app/node24/bin/dsh"];
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UpdateResult {
pub status: &'static str,
pub version: Option<String>,
pub previous: Option<String>,
pub message: String,
}
impl UpdateResult {
fn new(status: &'static str, version: Option<String>, message: impl Into<String>) -> Self {
Self {
status,
version,
previous: None,
message: message.into(),
}
}
}
pub fn update_prefix() -> PathBuf {
data_home().join("dsh-desktop/dsh-prefix")
}
pub fn update_dsh_bin() -> PathBuf {
let prefix = update_prefix();
#[cfg(windows)]
{
let cmd = prefix.join("dsh.cmd");
if cmd.is_file() {
return cmd;
}
}
prefix.join("bin/dsh")
}
pub fn package_json(prefix: &Path) -> PathBuf {
prefix.join("lib/node_modules/@deepseek-ai/dsh/package.json")
}
pub fn read_version(prefix: &Path) -> Option<String> {
let text = std::fs::read_to_string(package_json(prefix)).ok()?;
let data: serde_json::Value = serde_json::from_str(&text).ok()?;
data.get("version")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
}
pub fn find_npm() -> Option<PathBuf> {
for candidate in ["/app/bin/npm", "/app/node24/bin/npm"] {
let path = PathBuf::from(candidate);
if crate::launcher::is_executable(&path) {
return Some(path);
}
}
which::which("npm").ok()
}
fn find_node_dir() -> Option<PathBuf> {
for candidate in ["/app/bin/node", "/app/node24/bin/node"] {
let path = PathBuf::from(candidate);
if crate::launcher::is_executable(&path) {
return path.parent().map(|p| p.to_path_buf());
}
}
which::which("node")
.ok()
.and_then(|p| p.parent().map(|d| d.to_path_buf()))
}
fn npm_command(npm: &Path) -> Command {
let mut cmd = Command::new(npm);
let cache = cache_home().join("dsh-desktop/npm");
let _ = std::fs::create_dir_all(&cache);
cmd.env("npm_config_cache", &cache);
cmd.env("npm_config_update_notifier", "false");
cmd.env("npm_config_fund", "false");
cmd.env("npm_config_audit", "false");
if let Some(node_dir) = find_node_dir() {
let mut path = node_dir.into_os_string();
path.push(if cfg!(windows) { ";" } else { ":" });
if let Some(existing) = std::env::var_os("PATH") {
path.push(existing);
}
cmd.env("PATH", path);
}
cmd
}
fn run_npm(npm: &Path, args: &[&str], timeout: Duration) -> Result<std::process::Output, String> {
let mut cmd = npm_command(npm);
cmd.args(args);
cmd.stdout(Stdio::piped());
cmd.stderr(Stdio::piped());
let child = cmd.spawn().map_err(|e| e.to_string())?;
let pid = child.id();
let done = Arc::new(AtomicBool::new(false));
let flag = Arc::clone(&done);
thread::spawn(move || {
let start = Instant::now();
while start.elapsed() < timeout {
if flag.load(Ordering::Relaxed) {
return;
}
thread::sleep(Duration::from_millis(50));
}
if flag.load(Ordering::Relaxed) {
return;
}
#[cfg(unix)]
unsafe {
libc::kill(pid as i32, libc::SIGKILL);
}
#[cfg(windows)]
{
let _ = Command::new("taskkill")
.args(["/PID", &pid.to_string(), "/F"])
.status();
}
});
let out = child.wait_with_output().map_err(|e| e.to_string());
done.store(true, Ordering::Relaxed);
out
}
fn last_check_path() -> PathBuf {
cache_home().join("dsh-desktop/last-update-check")
}
/// True when a network update check has not run in the last 24 hours.
pub fn update_check_due() -> bool {
let path = last_check_path();
let Ok(raw) = std::fs::read_to_string(&path) else {
return true;
};
let ts: u64 = raw.trim().parse().unwrap_or(0);
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
now.saturating_sub(ts) >= 24 * 60 * 60
}
pub fn mark_update_checked() {
let path = last_check_path();
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let _ = std::fs::write(path, format!("{now}\n"));
}
pub fn fetch_latest_version(npm: &Path) -> Option<String> {
let out = run_npm(npm, &["view", DSH_PACKAGE, "version"], Duration::from_secs(VIEW_TIMEOUT_SECONDS))
.ok()?;
if !out.status.success() {
return None;
}
String::from_utf8_lossy(&out.stdout)
.lines()
.map(str::trim)
.filter(|l| !l.is_empty())
.last()
.map(|s| s.to_string())
}
fn seed_from_bundle(dest: &Path) {
let src_pkg = Path::new(BUNDLED_PREFIX).join("lib/node_modules/@deepseek-ai/dsh");
if !src_pkg.is_dir() {
return;
}
let dest_pkg = dest.join("lib/node_modules/@deepseek-ai/dsh");
if dest_pkg.exists() {
return;
}
if crate::paths::copy_tree(&src_pkg, &dest_pkg, true).is_err() {
return;
}
let dest_bin = dest.join("bin");
let _ = std::fs::create_dir_all(&dest_bin);
let link = dest_bin.join("dsh");
if !link.exists() {
let _ = crate::paths::replace_symlink(
&link,
Path::new("../lib/node_modules/@deepseek-ai/dsh/lib/bin.js"),
);
}
}
fn env_skips_update() -> bool {
std::env::var(ENV_NO_UPDATE)
.map(|v| matches!(v.to_ascii_lowercase().as_str(), "1" | "true" | "yes"))
.unwrap_or(false)
}
pub fn update_dsh(enabled: bool) -> UpdateResult {
if !enabled || env_skips_update() {
let version = read_version(&update_prefix()).or_else(|| read_version(Path::new(BUNDLED_PREFIX)));
return UpdateResult::new("skipped", version, "已跳过 dsh 更新");
}
let npm = find_npm();
let current = read_version(&update_prefix()).or_else(|| read_version(Path::new(BUNDLED_PREFIX)));
let Some(npm) = npm else {
let extra = current
.as_deref()
.map(|v| format!("{v}"))
.unwrap_or_default();
return UpdateResult::new(
"skipped",
current,
format!("未找到 npm使用已安装的 dsh{extra}"),
);
};
let latest = match fetch_latest_version(&npm) {
Some(v) => v,
None => {
let extra = current
.as_deref()
.map(|v| format!("{v}"))
.unwrap_or_default();
return UpdateResult::new(
"failed",
current,
format!("无法获取 dsh 最新版本,使用已安装版本{extra}"),
);
}
};
if current.as_deref() == Some(latest.as_str()) {
return UpdateResult::new(
"current",
current,
format!("内置 dsh 已是最新({latest}"),
);
}
let dest = update_prefix();
let _ = std::fs::create_dir_all(&dest);
if read_version(&dest).is_none() {
seed_from_bundle(&dest);
}
let prefix_arg = format!("--prefix={}", dest.display());
let pkg = format!("{DSH_PACKAGE}@latest");
let previous = current.clone();
match run_npm(
&npm,
&[
"install",
&prefix_arg,
"--global",
"--no-audit",
"--no-fund",
&pkg,
],
Duration::from_secs(INSTALL_TIMEOUT_SECONDS),
) {
Ok(out) if out.status.success() => {
let installed = read_version(&dest).or(current);
UpdateResult {
status: "updated",
version: installed.clone(),
previous,
message: format!(
"已将内置 dsh 更新到 {}",
installed.as_deref().unwrap_or("latest")
),
}
}
Ok(_) | Err(_) => {
let installed = read_version(&dest).or(current);
let extra = installed
.as_deref()
.map(|v| format!("{v}"))
.unwrap_or_default();
UpdateResult {
status: "failed",
version: installed,
previous,
message: format!("dsh 更新失败,使用已安装版本{extra}"),
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn read_version_ok() {
let tmp = TempDir::new().unwrap();
let pkg = package_json(tmp.path());
std::fs::create_dir_all(pkg.parent().unwrap()).unwrap();
std::fs::write(&pkg, r#"{"version":"0.1.0-rc.6"}"#).unwrap();
assert_eq!(read_version(tmp.path()).as_deref(), Some("0.1.0-rc.6"));
}
#[test]
fn read_version_missing() {
assert_eq!(read_version(Path::new("/no/such/prefix")), None);
}
#[test]
fn skip_when_disabled() {
let result = update_dsh(false);
assert_eq!(result.status, "skipped");
}
#[test]
fn skip_when_env_set() {
std::env::set_var(ENV_NO_UPDATE, "1");
let result = update_dsh(true);
std::env::remove_var(ENV_NO_UPDATE);
assert_eq!(result.status, "skipped");
}
#[test]
fn update_check_due_without_stamp() {
let tmp = TempDir::new().unwrap();
std::env::set_var("XDG_CACHE_HOME", tmp.path());
assert!(update_check_due());
mark_update_checked();
assert!(!update_check_due());
std::env::remove_var("XDG_CACHE_HOME");
}
}

View File

@@ -0,0 +1,16 @@
[Desktop Entry]
Name=DeepSeek Harness
Name[zh_CN]=DeepSeek Harness
GenericName=AI Agent Harness
GenericName[zh_CN]=AI 智能体工作台
Comment=Desktop shell for DeepSeek Harness
Comment[zh_CN]=在原生桌面窗口中运行 DeepSeek Harness
Exec=dsh-desktop
Icon=io.github.tommyfang.DshDesktop
Terminal=false
Type=Application
Categories=Development;
Keywords=AI;DeepSeek;agent;harness;dsh;assistant;
StartupNotify=true
StartupWMClass=io.github.tommyfang.DshDesktop
X-Flatpak-RenamedFrom=dsh-desktop.desktop;

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 728 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

View File

@@ -0,0 +1,53 @@
<?xml version="1.0" encoding="UTF-8"?>
<component type="desktop-application">
<id>io.github.tommyfang.DshDesktop</id>
<name>DeepSeek Harness Desktop</name>
<summary>Desktop shell for DeepSeek Harness</summary>
<summary xml:lang="zh_CN">DeepSeek Harness 的原生桌面壳</summary>
<metadata_license>MIT</metadata_license>
<project_license>MIT</project_license>
<developer id="io.github.tommyfang">
<name>TommyFang2077</name>
</developer>
<description>
<p>
DeepSeek Harness Desktop runs the official DeepSeek Harness (dsh) WebUI
in a native Tauri window. It starts dsh web, waits for its local URL and
embeds the UI with the system WebView. Credentials, plugins and agent
permissions stay in ~/.dsh.
</p>
<p>
The app bundles ModLens for text-only vision, a settings page at
设置 → 视觉模型, and the community Anchored Standard presets.
The Flatpak also bundles Node.js and @deepseek-ai/dsh.
</p>
</description>
<launchable type="desktop-id">io.github.tommyfang.DshDesktop.desktop</launchable>
<url type="homepage">https://github.com/TommyFang2077/dsh-desktop</url>
<url type="bugtracker">https://github.com/TommyFang2077/dsh-desktop/issues</url>
<provides>
<binary>dsh-desktop</binary>
</provides>
<categories>
<category>Development</category>
<category>Utility</category>
<category>Network</category>
</categories>
<content_rating type="oars-1.1" />
<releases>
<release version="0.1.0" date="2026-08-15">
<description>
<p>Tauri shell with an Apple-style title bar, bundled dsh, and on-launch updates.</p>
</description>
</release>
</releases>
</component>

View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 DeepSeek
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -0,0 +1,22 @@
MIT License
Copyright (c) 2026 xiaobright
Portions Copyright (c) 2026 DeepSeek
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -0,0 +1,14 @@
dsh-anchored-standard includes an adapted copy of the DeepSeek Harness
Standard agent preset from:
https://github.com/deepseek-ai/deepseek-harness
commit 47f943859bef60e4160492346772ded9b24f765a
DeepSeek Harness is distributed under the MIT License:
Copyright (c) 2026 DeepSeek
The full MIT permission notice is included in this repository's LICENSE file.
DeepSeek and DeepSeek Harness are names of their respective owner. This
community project is not affiliated with or endorsed by DeepSeek.

View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Leon Liu (liustack)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

BIN
docs/screenshots/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

BIN
docs/screenshots/menu.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

BIN
docs/screenshots/splash.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

View File

@@ -0,0 +1,61 @@
:root { --dsh-desktop-titlebar-h: 36px; }
* { box-sizing: border-box; }
html, body { margin: 0; }
html.dsh-desktop-offset {
box-sizing: border-box;
padding-top: var(--dsh-desktop-titlebar-h);
}
#dsh-desktop-titlebar {
position: fixed; top: 0; left: 0; right: 0; height: var(--dsh-desktop-titlebar-h);
z-index: 2147483646; display: flex; align-items: center;
padding: 0 12px; box-sizing: border-box;
background: rgba(246, 246, 248, 0.92);
-webkit-backdrop-filter: saturate(180%) blur(20px);
backdrop-filter: saturate(180%) blur(20px);
border-bottom: 0.5px solid rgba(0, 0, 0, 0.06);
user-select: none;
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", system-ui, sans-serif;
}
#dsh-desktop-titlebar .traffic {
display: flex; gap: 8px; align-items: center; height: 100%;
}
#dsh-desktop-titlebar .tl {
width: 12px; height: 12px; min-width: 12px; min-height: 12px;
max-width: 12px; max-height: 12px; flex: none; overflow: hidden;
box-sizing: border-box; border-radius: 50%; border: 0;
padding: 0; margin: 0; display: grid; place-items: center; cursor: default;
position: relative;
}
#dsh-desktop-titlebar .tl.close { background: #ff5f57; }
#dsh-desktop-titlebar .tl.min { background: #febc2e; }
#dsh-desktop-titlebar .tl.zoom { background: #28c840; }
#dsh-desktop-titlebar .tl::after {
content: ""; font-size: 9px; line-height: 1; font-weight: 700;
color: rgba(0,0,0,0.55); opacity: 0;
}
#dsh-desktop-titlebar .traffic:hover .tl.close::after { content: "×"; opacity: 1; }
#dsh-desktop-titlebar .traffic:hover .tl.min::after { content: ""; opacity: 1; }
#dsh-desktop-titlebar .traffic:hover .tl.zoom::after { content: "+"; opacity: 1; }
#dsh-desktop-titlebar .drag { flex: 1; height: 100%; }
#dsh-desktop-titlebar .more {
appearance: none; border: 0; background: transparent;
color: rgba(0,0,0,0.35); font-size: 15px; letter-spacing: 0.08em;
width: 28px; height: 20px; border-radius: 6px; cursor: default;
}
#dsh-desktop-titlebar .more:hover,
#dsh-desktop-titlebar .more.open { background: rgba(0,0,0,0.06); color: rgba(0,0,0,0.7); }
#dsh-desktop-titlebar .menu {
position: absolute; top: calc(var(--dsh-desktop-titlebar-h) + 2px); left: 8px; min-width: 168px;
padding: 4px; border-radius: 10px;
background: rgba(255,255,255,0.96);
box-shadow: 0 8px 28px rgba(0,0,0,0.16);
display: none; flex-direction: column;
}
#dsh-desktop-titlebar .menu.open { display: flex; }
#dsh-desktop-titlebar .menu button {
appearance: none; border: 0; background: transparent;
text-align: left; padding: 7px 10px; border-radius: 6px;
font: 13px/1.3 -apple-system, BlinkMacSystemFont, "SF Pro Text", system-ui, sans-serif;
color: #1d1d1f; cursor: default;
}
#dsh-desktop-titlebar .menu button:hover { background: rgba(0,0,0,0.05); }

View File

@@ -0,0 +1,42 @@
<!doctype html>
<html lang="zh-CN" class="dsh-desktop-offset">
<head>
<meta charset="utf-8" />
<title>标题栏菜单</title>
<link rel="stylesheet" href="chrome.css" />
<link rel="stylesheet" href="../../../ui/styles.css" />
<style>
:root { color-scheme: light; --bg: #f5f5f7; --text: #1d1d1f; --muted: #86868b; }
html, body { width: 1280px; height: 800px; overflow: hidden; background: #f5f5f7; color: #1d1d1f; }
.stage { min-height: calc(800px - 36px); }
#dsh-desktop-titlebar .traffic:hover .tl.close::after,
#dsh-desktop-titlebar .traffic:hover .tl.min::after,
#dsh-desktop-titlebar .traffic:hover .tl.zoom::after { opacity: 1; }
#dsh-desktop-titlebar .tl.close::after { content: "×"; opacity: 1; }
#dsh-desktop-titlebar .tl.min::after { content: ""; opacity: 1; }
#dsh-desktop-titlebar .tl.zoom::after { content: "+"; opacity: 1; }
</style>
</head>
<body>
<header id="dsh-desktop-titlebar">
<button class="more open" type="button">•••</button>
<div class="menu open">
<button type="button">重新启动</button>
<button type="button">在浏览器中打开</button>
</div>
<div class="drag"></div>
<div class="traffic">
<button class="tl min"></button>
<button class="tl zoom"></button>
<button class="tl close"></button>
</div>
</header>
<main class="stage">
<section class="hero">
<img class="mark" src="../../../ui/icon.png" alt="" />
<h1>DeepSeek Harness</h1>
<p id="status">已连接到本地 dsh web</p>
</section>
</main>
</body>
</html>

View File

@@ -0,0 +1,135 @@
<!doctype html>
<html lang="zh-CN" class="dsh-desktop-offset">
<head>
<meta charset="utf-8" />
<title>新会话</title>
<link rel="stylesheet" href="chrome.css" />
<style>
:root {
--bg: #f6f6f8;
--sidebar: #efeff2;
--text: #1d1d1f;
--muted: #86868b;
--line: rgba(0,0,0,0.06);
--accent: #0071e3;
--card: #ffffff;
}
html, body {
margin: 0; width: 1280px; height: 800px; overflow: hidden;
background: var(--bg); color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "PingFang SC", "Noto Sans SC", system-ui, sans-serif;
-webkit-font-smoothing: antialiased;
}
.shell {
display: grid;
grid-template-columns: 248px 1fr;
height: calc(800px - 36px);
}
.sidebar {
background: var(--sidebar);
border-right: 0.5px solid var(--line);
padding: 16px 12px 18px;
display: flex; flex-direction: column; gap: 14px;
}
.brand {
display: flex; align-items: center; gap: 10px;
padding: 4px 8px 8px;
font-weight: 600; font-size: 13px;
}
.brand img { width: 22px; height: 22px; border-radius: 6px; }
.new-session {
height: 34px; border: 0; border-radius: 10px;
background: var(--card); color: var(--text);
font: 600 13px/1 inherit; box-shadow: 0 1px 2px rgba(0,0,0,0.04);
display: flex; align-items: center; justify-content: center; gap: 6px;
}
.section-label {
margin: 8px 8px 4px; font-size: 11px; color: var(--muted); letter-spacing: 0.04em;
}
.session {
padding: 8px 10px; border-radius: 8px; font-size: 13px;
background: rgba(255,255,255,0.7);
}
.session small { display: block; color: var(--muted); font-size: 11px; margin-top: 2px; }
.main {
display: flex; flex-direction: column; min-width: 0;
background: var(--bg);
}
.hero {
flex: 1; display: flex; flex-direction: column;
align-items: center; justify-content: center; gap: 10px;
padding-bottom: 40px;
}
.hero .mark {
width: 56px; height: 56px; border-radius: 14px; margin-bottom: 8px;
}
.hero h1 { margin: 0; font-size: 28px; font-weight: 600; letter-spacing: -0.03em; }
.badge {
font-size: 11px; color: var(--muted); background: rgba(0,0,0,0.04);
padding: 2px 8px; border-radius: 999px;
}
.hint { color: var(--muted); font-size: 13px; margin: 0; }
.composer {
margin: 0 28px 22px; background: var(--card);
border: 0.5px solid var(--line); border-radius: 16px;
min-height: 92px; padding: 14px 16px 12px;
display: flex; flex-direction: column; justify-content: space-between;
box-shadow: 0 8px 28px rgba(0,0,0,0.04);
}
.placeholder { color: #aeaeb2; font-size: 14px; }
.tools { display: flex; justify-content: space-between; align-items: center; }
.chips { display: flex; gap: 8px; color: var(--muted); font-size: 12px; }
.chip {
border: 0.5px solid var(--line); border-radius: 999px; padding: 4px 10px; background: #fff;
}
.send {
width: 28px; height: 28px; border-radius: 50%; background: var(--accent); color: #fff;
display: grid; place-items: center; font-size: 14px;
}
</style>
</head>
<body>
<header id="dsh-desktop-titlebar">
<button class="more" type="button">•••</button>
<div class="menu"></div>
<div class="drag"></div>
<div class="traffic">
<button class="tl min"></button>
<button class="tl zoom"></button>
<button class="tl close"></button>
</div>
</header>
<div class="shell">
<aside class="sidebar">
<div class="brand">
<img src="../../../ui/icon.png" alt="" />
DeepSeek Harness
</div>
<div class="new-session"> 新会话</div>
<div class="section-label">工作区</div>
<div class="session">
新会话
<small>锚定式标准(实验)</small>
</div>
</aside>
<section class="main">
<div class="hero">
<img class="mark" src="../../../ui/icon.png" alt="" />
<h1>探索未至之境</h1>
<span class="badge">预览版</span>
<p class="hint">官方 WebUI 运行在原生窗口中,凭据仍保存在 ~/.dsh</p>
</div>
<div class="composer">
<div class="placeholder">给 Harness 发送消息</div>
<div class="tools">
<div class="chips">
<span class="chip">锚定式标准(实验)</span>
<span class="chip">工作区</span>
</div>
<div class="send"></div>
</div>
</div>
</section>
</div>
</body>
</html>

View File

@@ -0,0 +1,34 @@
<!doctype html>
<html lang="zh-CN" class="dsh-desktop-offset">
<head>
<meta charset="utf-8" />
<title>启动页</title>
<link rel="stylesheet" href="../../../ui/styles.css" />
<link rel="stylesheet" href="chrome.css" />
<style>
:root { color-scheme: light; --bg: #f5f5f7; --text: #1d1d1f; --muted: #86868b; }
html, body { width: 1280px; height: 800px; overflow: hidden; background: #f5f5f7; color: #1d1d1f; }
.stage { min-height: calc(800px - 36px); padding-top: 48px; }
</style>
</head>
<body>
<header id="dsh-desktop-titlebar">
<button class="more" type="button">•••</button>
<div class="menu"></div>
<div class="drag"></div>
<div class="traffic">
<button class="tl min"></button>
<button class="tl zoom"></button>
<button class="tl close"></button>
</div>
</header>
<main class="stage">
<section class="hero">
<img class="mark" src="../../../ui/icon.png" alt="" />
<h1>DeepSeek Harness</h1>
<p id="status">正在启动官方 WebUI…</p>
<div class="ring" aria-hidden="true"></div>
</section>
</main>
</body>
</html>

View File

@@ -0,0 +1,115 @@
<!doctype html>
<html lang="zh-CN" class="dsh-desktop-offset">
<head>
<meta charset="utf-8" />
<title>视觉模型</title>
<link rel="stylesheet" href="chrome.css" />
<style>
:root {
--bg: #f6f6f8;
--sidebar: #efeff2;
--text: #1d1d1f;
--muted: #86868b;
--line: rgba(0,0,0,0.08);
--accent: #0071e3;
--card: #ffffff;
--ok: #248a3d;
}
html, body {
margin: 0; width: 1280px; height: 800px; overflow: hidden;
background: var(--bg); color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "PingFang SC", "Noto Sans SC", system-ui, sans-serif;
-webkit-font-smoothing: antialiased;
}
.shell {
display: grid;
grid-template-columns: 220px 1fr;
height: calc(800px - 36px);
}
.nav {
background: var(--sidebar);
border-right: 0.5px solid var(--line);
padding: 22px 12px;
}
.nav h2 { margin: 0 10px 16px; font-size: 18px; font-weight: 600; }
.nav a {
display: block; padding: 8px 12px; border-radius: 8px;
color: var(--text); text-decoration: none; font-size: 13px;
}
.nav a.active { background: #fff; font-weight: 600; }
.nav a.muted { color: var(--muted); }
.content { padding: 28px 40px; overflow: hidden; }
.content h1 { margin: 0 0 6px; font-size: 22px; font-weight: 600; }
.lead { margin: 0 0 22px; color: var(--muted); font-size: 13px; }
.dshdv { width: 100%; max-width: 640px; display: flex; flex-direction: column; gap: 14px; }
.dshdv h3 { margin: 0; font-size: 15px; font-weight: 600; line-height: 22px; }
.dshdv p { margin: 0; color: var(--muted); font-size: 13px; line-height: 20px; }
.dshdv label { display: flex; flex-direction: column; gap: 6px; font-size: 12px; color: #6e6e73; }
.dshdv .head { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.dshdv a { color: var(--accent); text-decoration: none; font-size: 12px; }
.dshdv input, .dshdv select {
height: 36px; border: 1px solid var(--line); background: var(--card);
color: var(--text); border-radius: 8px; padding: 0 12px; font: inherit;
}
.dshdv button {
align-self: flex-start; height: 32px; padding: 0 14px; border: 0;
border-radius: 8px; background: var(--accent); color: #fff; font: inherit;
}
.dshdv .ok { color: var(--ok); }
</style>
</head>
<body>
<header id="dsh-desktop-titlebar">
<button class="more" type="button">•••</button>
<div class="menu"></div>
<div class="drag"></div>
<div class="traffic">
<button class="tl min"></button>
<button class="tl zoom"></button>
<button class="tl close"></button>
</div>
</header>
<div class="shell">
<nav class="nav">
<h2>设置</h2>
<a class="muted" href="#">通用</a>
<a class="muted" href="#">模型</a>
<a class="active" href="#">视觉模型</a>
<a class="muted" href="#">插件</a>
<a class="muted" href="#">技能</a>
</nav>
<section class="content">
<h1>视觉模型</h1>
<p class="lead">内置插件 dsh-desktop-vision · 写入 ~/.modlens/config.json</p>
<div class="dshdv">
<h3>ModLens 视觉模型</h3>
<p>纯文本对话模型读图时使用这里的引擎。已声明视觉能力的模型(如 Qwen不会走这条桥。</p>
<label>
引擎
<select>
<option selected>OpenAI 兼容</option>
<option>Gemini API</option>
<option>Anthropic</option>
<option>Antigravity CLI</option>
<option>Claude CLI</option>
</select>
</label>
<label>
接口地址
<input value="https://api.openai.com/v1" />
</label>
<label>
<span class="head">API 密钥 <a href="#">获取 API</a></span>
<input type="password" value="sk-••••••••••••••••" />
</label>
<label>
视觉模型
<input value="" placeholder="例如 gpt-4o" />
</label>
<button type="button">保存</button>
<p class="ok">已保存</p>
</div>
</section>
</div>
</body>
</html>

BIN
docs/screenshots/vision.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

View File

@@ -0,0 +1,97 @@
app-id: io.github.tommyfang.DshDesktop
runtime: org.gnome.Platform
runtime-version: '47'
sdk: org.gnome.Sdk
sdk-extensions:
- org.freedesktop.Sdk.Extension.node24
- org.freedesktop.Sdk.Extension.rust-stable
command: dsh-desktop
finish-args:
- --device=dri
- --share=ipc
- --socket=fallback-x11
- --socket=wayland
- --share=network
- --filesystem=host
- --filesystem=xdg-download
- --talk-name=org.freedesktop.Notifications
- --talk-name=org.freedesktop.portal.Desktop
- --talk-name=org.a11y.Bus
- --talk-name=org.freedesktop.Flatpak
cleanup:
- /include
- /lib/pkgconfig
- /share/man
- '*.a'
- '*.la'
modules:
- name: dsh-runtime
buildsystem: simple
build-commands:
- mkdir -p ${FLATPAK_DEST}/bin
- cp -a /usr/lib/sdk/node24 ${FLATPAK_DEST}/node24
- ln -sf ../node24/bin/node ${FLATPAK_DEST}/bin/node
- ln -sf ../node24/bin/npm ${FLATPAK_DEST}/bin/npm
- ln -sf ../node24/bin/npx ${FLATPAK_DEST}/bin/npx
- cp -a . ${FLATPAK_DEST}/
sources:
- type: dir
path: ../vendor/dsh-prefix
- name: dsh-desktop
buildsystem: simple
build-options:
append-path: /usr/lib/sdk/rust-stable/bin
env:
CARGO_HOME: /run/build/dsh-desktop/cargo
build-args:
- --share=network
build-commands:
- cargo build --release --locked --offline || cargo build --release
- install -Dm755 target/release/dsh-desktop ${FLATPAK_DEST}/bin/dsh-desktop
- mkdir -p ${FLATPAK_DEST}/share/dsh-desktop/vision
- cp -a plugins/dsh-desktop-vision/. ${FLATPAK_DEST}/share/dsh-desktop/vision
- install -Dm644 data/applications/io.github.tommyfang.DshDesktop.desktop ${FLATPAK_DEST}/share/applications/io.github.tommyfang.DshDesktop.desktop
- install -Dm644 data/metainfo/io.github.tommyfang.DshDesktop.metainfo.xml ${FLATPAK_DEST}/share/metainfo/io.github.tommyfang.DshDesktop.metainfo.xml
- for size in 16 24 32 48 64 128 256 512; do install -Dm644 data/icons/hicolor/${size}x${size}/apps/io.github.tommyfang.DshDesktop.png ${FLATPAK_DEST}/share/icons/hicolor/${size}x${size}/apps/io.github.tommyfang.DshDesktop.png; done
sources:
- type: dir
path: ..
skip:
- .flatpak-build
- .flatpak-builder
- .flatpak-repo
- dist
- vendor
- .git
- target
- name: modlens-plugin
buildsystem: simple
build-commands:
- mkdir -p ${FLATPAK_DEST}/share/dsh-desktop/modlens
- cp -a . ${FLATPAK_DEST}/share/dsh-desktop/modlens
sources:
- type: dir
path: ../vendor/modlens
- name: anchored-standard-preset
buildsystem: simple
build-commands:
- mkdir -p ${FLATPAK_DEST}/share/dsh-desktop/anchored-standard
- cp -a . ${FLATPAK_DEST}/share/dsh-desktop/anchored-standard
sources:
- type: dir
path: ../vendor/anchored-standard
- name: zero-anchored-standard-preset
buildsystem: simple
build-commands:
- mkdir -p ${FLATPAK_DEST}/share/dsh-desktop/zero-anchored-standard
- cp -a . ${FLATPAK_DEST}/share/dsh-desktop/zero-anchored-standard
sources:
- type: dir
path: ../vendor/zero-anchored-standard

View File

@@ -0,0 +1,209 @@
window.__ModuleLoader__.load({
id: 'dsh-desktop-vision',
factory: (require) => {
var module = { exports: {} }
var exports = module.exports
var React = require('react')
var jsx = require('react/jsx-runtime')
var css = [
'.dshdv{width:100%;max-width:640px;display:flex;flex-direction:column;gap:14px;color:var(--dsw-alias-label-primary)}',
'.dshdv h3{margin:0;font-size:15px;font-weight:600;line-height:22px}',
'.dshdv p{margin:0;color:var(--dsw-alias-label-tertiary);font-size:13px;line-height:20px}',
'.dshdv label{display:flex;flex-direction:column;gap:6px;font-size:12px;color:var(--dsw-alias-label-secondary)}',
'.dshdv .head{display:flex;align-items:center;justify-content:space-between;gap:12px}',
'.dshdv a{color:var(--dsw-alias-state-business-primary);text-decoration:none;font-size:12px;line-height:18px}',
'.dshdv a:hover{text-decoration:underline}',
'.dshdv input,.dshdv select{height:36px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);border-radius:8px;padding:0 12px;font:inherit}',
'.dshdv input:focus,.dshdv select:focus{outline:none;border-color:var(--dsw-alias-state-business-primary)}',
'.dshdv button{align-self:flex-start;height:32px;padding:0 14px;border:0;border-radius:8px;background:var(--dsw-alias-state-business-primary);color:#fff;font:inherit;cursor:pointer}',
'.dshdv button:disabled{opacity:.55;cursor:default}',
'.dshdv .ok{color:var(--dsw-alias-state-success-primary)}',
'.dshdv .err{color:var(--dsw-alias-state-error-primary)}',
].join('')
function ensureCss() {
if (typeof document === 'undefined') return
if (document.querySelector('style[data-plugin-css="dsh-desktop-vision"]')) return
var tag = document.createElement('style')
tag.dataset.pluginCss = 'dsh-desktop-vision'
tag.textContent = css
document.head.appendChild(tag)
}
function remote(provider) {
var q = provider ? '?provider=' + encodeURIComponent(provider) : ''
return fetch('/dsh-desktop/modlens' + q).then(function (res) {
if (!res.ok) throw new Error('load failed ' + res.status)
return res.json()
})
}
function VisionSettings() {
ensureCss()
var state = React.useState({ status: 'loading' })
var snap = state[0]
var setSnap = state[1]
React.useEffect(function () {
var live = true
remote()
.then(function (form) {
if (live) setSnap({ status: 'ready', form: form, message: '' })
})
.catch(function (error) {
if (live) setSnap({ status: 'error', message: String(error.message || error) })
})
return function () {
live = false
}
}, [])
function patch(field, value) {
setSnap(function (cur) {
if (cur.status !== 'ready') return cur
return { status: 'ready', form: Object.assign({}, cur.form, { [field]: value }), message: '' }
})
}
function onProvider(id) {
remote(id)
.then(function (form) {
setSnap({ status: 'ready', form: form, message: '' })
})
.catch(function () {
patch('provider', id)
})
}
function save() {
if (snap.status !== 'ready' || snap.saving) return
setSnap(Object.assign({}, snap, { saving: true, message: '' }))
fetch('/dsh-desktop/modlens', {
method: 'PUT',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(snap.form),
})
.then(function (res) {
return res.json().then(function (body) {
if (!res.ok) throw new Error(body.error || 'save failed')
return body
})
})
.then(function (form) {
setSnap({ status: 'ready', form: form, message: '已保存', saving: false })
})
.catch(function (error) {
setSnap(Object.assign({}, snap, { saving: false, message: String(error.message || error) }))
})
}
if (snap.status === 'loading') {
return jsx.jsx('div', { className: 'dshdv', children: jsx.jsx('p', { children: '正在读取 ModLens 配置…' }) })
}
if (snap.status === 'error') {
return jsx.jsx('div', { className: 'dshdv', children: jsx.jsx('p', { className: 'err', children: snap.message }) })
}
var form = snap.form
var remoteEngine = form.provider === 'openai' || form.provider === 'gemini-api' || form.provider === 'anthropic'
var options = form.options || []
var current = options.filter(function (item) { return item.id === form.provider })[0]
var apiUrl = (current && current.apiUrl) || form.apiUrl || ''
var example = (current && current.example) || form.example || ''
var getApi = apiUrl
? jsx.jsx('a', { href: apiUrl, children: '获取 API' })
: null
return jsx.jsxs('div', {
className: 'dshdv',
children: [
jsx.jsx('h3', { children: 'ModLens 视觉模型' }),
jsx.jsx('p', {
children:
'纯文本对话模型读图时使用这里的引擎。已声明视觉能力的模型(如 Qwen不会走这条桥。',
}),
jsx.jsxs('label', {
children: [
'引擎',
jsx.jsx('select', {
value: form.provider,
onChange: function (event) {
onProvider(event.target.value)
},
children: options.map(function (item) {
return jsx.jsx('option', { value: item.id, children: item.label }, item.id)
}),
}),
],
}),
jsx.jsxs('label', {
children: [
'接口地址',
jsx.jsx('input', {
value: form.baseUrl || '',
disabled: !remoteEngine,
placeholder: form.officialBaseUrl || '',
onChange: function (event) {
patch('baseUrl', event.target.value)
},
}),
],
}),
jsx.jsxs('label', {
children: [
jsx.jsxs('span', { className: 'head', children: ['API 密钥', getApi] }),
jsx.jsx('input', {
type: 'password',
value: form.apiKey || '',
disabled: !remoteEngine,
onChange: function (event) {
patch('apiKey', event.target.value)
},
}),
],
}),
jsx.jsxs('label', {
children: [
'视觉模型',
jsx.jsx('input', {
value: form.model || '',
placeholder: example ? '例如 ' + example : '',
onChange: function (event) {
patch('model', event.target.value)
},
}),
],
}),
jsx.jsx('button', {
type: 'button',
disabled: !!snap.saving,
onClick: save,
children: snap.saving ? '保存中…' : '保存',
}),
snap.message
? jsx.jsx('p', {
className: /失败|failed|error/i.test(snap.message) ? 'err' : 'ok',
children: snap.message,
})
: null,
],
})
}
function apply(ctx) {
ctx.slots.inject('settings.section', function () {
return ctx.slots.register(
{
name: 'settings.section',
id: 'modlens-vision',
order: 12,
label: '视觉模型',
},
VisionSettings,
)
})
}
exports.apply = apply
exports.inject = ['slots']
return module.exports
},
})

View File

@@ -0,0 +1,4 @@
# Settings page + host route for the ModLens vision engine.
- insert:
- id: dsh-desktop-vision
name: dsh-desktop-vision

View File

@@ -0,0 +1,181 @@
import { chmodSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { homedir } from 'node:os'
import { dirname, join } from 'node:path'
export const name = 'dsh-desktop-vision'
export const inject = []
const PROVIDERS = [
{
id: 'openai',
label: 'OpenAI 兼容',
baseUrl: 'https://api.openai.com/v1',
apiUrl: 'https://platform.openai.com/api-keys',
example: 'gpt-4o',
},
{
id: 'gemini-api',
label: 'Gemini API',
baseUrl: 'https://generativelanguage.googleapis.com',
apiUrl: 'https://aistudio.google.com/apikey',
example: 'gemini-3.6-flash',
},
{
id: 'anthropic',
label: 'Anthropic API',
baseUrl: 'https://api.anthropic.com',
apiUrl: 'https://console.anthropic.com/settings/keys',
example: 'claude-haiku-4-5-20251001',
},
{
id: 'antigravity-cli',
label: 'Antigravity CLI免费',
baseUrl: '',
apiUrl: 'https://antigravity.google/',
example: 'gemini-3.6-flash-low',
},
{
id: 'claude-cli',
label: 'Claude Code 登录',
baseUrl: '',
apiUrl: 'https://code.claude.com',
example: 'haiku',
},
]
const PROVIDER_IDS = new Set(PROVIDERS.map((item) => item.id))
function providerOf(id) {
return PROVIDERS.find((entry) => entry.id === id)
}
function officialBaseUrl(provider) {
const item = providerOf(provider)
return item ? item.baseUrl : ''
}
function officialApiUrl(provider) {
const item = providerOf(provider)
return item ? item.apiUrl : ''
}
function officialExample(provider) {
const item = providerOf(provider)
return item ? item.example : ''
}
const FORM_FIELDS = ['apiKey', 'baseUrl', 'model']
function configPath() {
const home = process.env.MODLENS_HOME || join(homedir(), '.modlens')
return join(home, 'config.json')
}
function loadConfig() {
try {
const data = JSON.parse(readFileSync(configPath(), 'utf8'))
return data && typeof data === 'object' ? data : {}
} catch {
return {}
}
}
function formOf(cfg, provider) {
const id = PROVIDER_IDS.has(provider) ? provider : 'openai'
const entry =
cfg.providers && typeof cfg.providers === 'object' && typeof cfg.providers[id] === 'object'
? cfg.providers[id]
: {}
return {
provider: id,
apiKey: String(entry.apiKey || ''),
baseUrl: String(entry.baseUrl || officialBaseUrl(id)),
officialBaseUrl: officialBaseUrl(id),
apiUrl: officialApiUrl(id),
example: officialExample(id),
model: String(entry.model || ''),
}
}
function saveForm(values) {
const provider = String(values.provider || 'openai').trim()
if (!PROVIDER_IDS.has(provider)) {
const error = new Error(`unknown provider: ${provider}`)
error.status = 400
throw error
}
const cfg = loadConfig()
cfg.provider = provider
if (!cfg.providers || typeof cfg.providers !== 'object') cfg.providers = {}
if (!cfg.providers[provider] || typeof cfg.providers[provider] !== 'object') {
cfg.providers[provider] = {}
}
const entry = cfg.providers[provider]
for (const field of FORM_FIELDS) {
const raw = String(values[field] || '').trim()
if (raw) entry[field] = raw
else delete entry[field]
}
const path = configPath()
mkdirSync(dirname(path), { recursive: true })
writeFileSync(path, `${JSON.stringify(cfg, null, 2)}\n`, { encoding: 'utf8' })
try {
chmodSync(path, 0o600)
} catch {
// best-effort; some filesystems ignore mode
}
return formOf(cfg, provider)
}
async function readJsonBody(req) {
const chunks = []
for await (const chunk of req) chunks.push(chunk)
const raw = Buffer.concat(chunks).toString('utf8').trim()
if (!raw) return {}
return JSON.parse(raw)
}
function json(res, status, body) {
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' })
res.end(JSON.stringify(body))
}
export function apply(ctx) {
if (typeof ctx.inject !== 'function') return
ctx.inject(['webServer'], (scope) => {
try {
scope.webServer.register({
name: 'dsh-desktop-modlens',
kind: 'exact',
path: '/dsh-desktop/modlens',
handler: async (req, res) => {
try {
if (req.method === 'GET') {
const cfg = loadConfig()
const provider = new URL(req.url, 'http://localhost').searchParams.get('provider')
json(res, 200, {
...formOf(cfg, provider || cfg.provider || 'openai'),
options: PROVIDERS.map((item) => ({
id: item.id,
label: item.label,
officialBaseUrl: item.baseUrl,
apiUrl: item.apiUrl,
example: item.example,
})),
})
return
}
if (req.method === 'PUT' || req.method === 'POST') {
const body = await readJsonBody(req)
json(res, 200, { ...saveForm(body), saved: true })
return
}
res.writeHead(405).end()
} catch (error) {
json(res, error.status || 500, { error: String(error?.message || error) })
}
},
})
} catch (error) {
console.error(`[dsh-desktop-vision] config route skipped: ${error}`)
}
})
}

View File

@@ -0,0 +1,23 @@
{
"name": "dsh-desktop-vision",
"version": "0.1.4",
"private": true,
"type": "module",
"exports": {
".": "./index.js",
"./client": "./client.js",
"./package.json": "./package.json"
},
"dsh": {
"bundle": {
"patch": "./cordis.patch.yml"
},
"client": {
"inject": [
"@deepseek-ai/dsh-client-ui-settings"
],
"platform": "web",
"immediately": true
}
}
}

29
scripts/capture-screenshots.sh Executable file
View File

@@ -0,0 +1,29 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
OUT="$ROOT/docs/screenshots"
SRC="$OUT/src"
CHROME="${CHROME:-google-chrome}"
capture() {
local name="$1"
local html="$2"
"$CHROME" \
--headless=new \
--disable-gpu \
--no-sandbox \
--hide-scrollbars \
--force-device-scale-factor=2 \
--window-size=1280,800 \
--default-background-color=00000000 \
--screenshot="$OUT/$name.png" \
"file://$html"
echo "wrote $OUT/$name.png"
}
mkdir -p "$OUT"
capture splash "$SRC/splash.html"
capture session "$SRC/session.html"
capture vision "$SRC/vision.html"
capture menu "$SRC/menu.html"
cp -f "$ROOT/ui/icon.png" "$OUT/icon.png"

58
scripts/localize_preset.py Executable file
View File

@@ -0,0 +1,58 @@
#!/usr/bin/env python3
"""Localize vendored anchored-standard preset.yml files (used by make vendor)."""
from __future__ import annotations
import json
import re
import sys
from pathlib import Path
PRESET_NAME_ZH = "锚定式标准(实验)"
PRESET_DESCRIPTION_ZH = (
"首轮使用 Minimal 的真实工具对(持久 bash + str_replace_editor"
"不自动注入工作区或技能上下文;首次工具调用或回复后开放完整 Standard 工具。"
)
ZERO_PRESET_NAME_ZH = "零工具锚定式标准(实验)"
ZERO_PRESET_DESCRIPTION_ZH = (
"先插入一轮无工具的锚定对话(固定提示),从下一轮起开放完整 Standard 工具。"
)
def localize_preset_yml(text: str, name: str, description: str) -> str:
body = text.replace("\r\n", "\n")
if not body.endswith("\n"):
body += "\n"
name_line = f"name: {json.dumps(name, ensure_ascii=False)}\n"
desc_line = f"description: {json.dumps(description, ensure_ascii=False)}\n"
name_match = re.search(r"(?m)^name:.*\n", body)
if name_match:
body = body[: name_match.start()] + name_line + body[name_match.end() :]
else:
body = name_line + body
desc_match = re.search(r"(?m)^description:(?:[ \t].*)?\n(?:[ \t].+\n)*", body)
if desc_match:
return body[: desc_match.start()] + desc_line + body[desc_match.end() :]
name_written = re.search(r"(?m)^name:.*\n", body)
if name_written:
return body[: name_written.end()] + desc_line + body[name_written.end() :]
return desc_line + body
def main(argv: list[str]) -> int:
if len(argv) < 2:
print("usage: localize_preset.py <preset.yml> [zero]", file=sys.stderr)
return 2
path = Path(argv[1])
zero = len(argv) > 2 and argv[2] == "zero"
name = ZERO_PRESET_NAME_ZH if zero else PRESET_NAME_ZH
description = ZERO_PRESET_DESCRIPTION_ZH if zero else PRESET_DESCRIPTION_ZH
path.write_text(
localize_preset_yml(path.read_text(encoding="utf-8"), name, description),
encoding="utf-8",
)
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv))

48
scripts/vendor-native.sh Executable file
View File

@@ -0,0 +1,48 @@
#!/usr/bin/env bash
# Fetch ModLens + Anchored Standard into vendor/ for Tauri resource bundling.
# Does not vendor @deepseek-ai/dsh (that is Flatpak-only; see `make vendor`).
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"
MODLENS_VERSION="${MODLENS_VERSION:-3.16.6}"
ANCHORED_COMMIT="${ANCHORED_COMMIT:-ffb845c5480adc953392a6db6f8a98ede621174b}"
ANCHORED_REPO="${ANCHORED_REPO:-https://github.com/xiaobright/dsh-anchored-standard.git}"
if [ -z "${PYTHON:-}" ]; then
if command -v python3 >/dev/null 2>&1; then
PYTHON=python3
else
PYTHON=python
fi
fi
ANCHORED_DIR="vendor/anchored-standard"
ZERO_DIR="vendor/zero-anchored-standard"
MODLENS_DIR="vendor/modlens"
rm -rf vendor/.anchored-src "$ANCHORED_DIR" "$ZERO_DIR"
mkdir -p vendor/.anchored-src
git -C vendor/.anchored-src init --initial-branch=main
git -C vendor/.anchored-src remote add origin "$ANCHORED_REPO"
git -C vendor/.anchored-src fetch --depth 1 origin "$ANCHORED_COMMIT"
git -C vendor/.anchored-src checkout --detach FETCH_HEAD
mkdir -p "$ANCHORED_DIR" "$ZERO_DIR"
cp -R vendor/.anchored-src/preset/. "$ANCHORED_DIR/"
cp -R vendor/.anchored-src/zero-anchored-standard/. "$ZERO_DIR/"
cp vendor/.anchored-src/LICENSE vendor/.anchored-src/NOTICE "$ANCHORED_DIR/"
cp vendor/.anchored-src/LICENSE vendor/.anchored-src/NOTICE "$ZERO_DIR/"
printf '%s\n' "$ANCHORED_COMMIT" > "$ANCHORED_DIR/.dsh-desktop-source"
printf '%s\n' "$ANCHORED_COMMIT" > "$ZERO_DIR/.dsh-desktop-source"
"$PYTHON" scripts/localize_preset.py "$ANCHORED_DIR/preset.yml"
"$PYTHON" scripts/localize_preset.py "$ZERO_DIR/preset.yml" zero
rm -rf vendor/.anchored-src
rm -rf "$MODLENS_DIR"
mkdir -p "$MODLENS_DIR"
npm install --prefix "$MODLENS_DIR" --prefer-offline --no-audit --no-fund "@liustack/modlens@${MODLENS_VERSION}"
test -f "$ANCHORED_DIR/preset.yml"
test -f "$ZERO_DIR/preset.yml"
test -d "$MODLENS_DIR/node_modules/@liustack/modlens"
echo "vendored ModLens ${MODLENS_VERSION} and Anchored Standard ${ANCHORED_COMMIT}"

32
src-tauri/Cargo.toml Normal file
View File

@@ -0,0 +1,32 @@
[package]
name = "dsh-desktop"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
description = "Tauri desktop shell for DeepSeek Harness"
[lib]
name = "dsh_desktop_lib"
crate-type = ["lib", "cdylib", "staticlib"]
[[bin]]
name = "dsh-desktop"
path = "src/main.rs"
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
dsh-core = { path = "../crates/dsh-core" }
tauri = { version = "2", features = ["devtools"] }
tauri-plugin-opener = "2"
tauri-plugin-single-instance = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
arboard = "3"
image = { version = "0.25", default-features = false, features = ["png"] }
open = "5"
url = "2"
log = "0.4"
env_logger = "0.11"

3
src-tauri/build.rs Normal file
View File

@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}

View File

@@ -0,0 +1,18 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Main window, including the embedded dsh WebUI",
"windows": ["main"],
"remote": {
"urls": ["http://127.0.0.1:*", "http://localhost:*"]
},
"permissions": [
"core:default",
"core:window:allow-close",
"core:window:allow-minimize",
"core:window:allow-toggle-maximize",
"core:window:allow-start-dragging",
"core:webview:allow-internal-toggle-devtools",
"opener:default"
]
}

BIN
src-tauri/icons/128x128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

BIN
src-tauri/icons/32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

BIN
src-tauri/icons/64x64.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

BIN
src-tauri/icons/icon.icns Normal file

Binary file not shown.

BIN
src-tauri/icons/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

BIN
src-tauri/icons/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

View File

@@ -0,0 +1,15 @@
[Desktop Entry]
Categories={{categories}}
Comment={{comment}}
Comment[zh_CN]=在原生桌面窗口中运行 DeepSeek Harness
Exec={{exec}}
GenericName=AI Agent Harness
GenericName[zh_CN]=AI 智能体工作台
Icon={{icon}}
Keywords=AI;DeepSeek;agent;harness;dsh;assistant;
Name={{name}}
Name[zh_CN]=DeepSeek Harness
StartupNotify=true
StartupWMClass=io.github.tommyfang.DshDesktop
Terminal=false
Type=Application

383
src-tauri/src/lib.rs Normal file
View File

@@ -0,0 +1,383 @@
use std::io::Cursor;
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::{Duration, Instant};
use dsh_core::clipboard::{
file_from_bytes, filename_for_mime, files_from_paths, parse_uri_list, ClipboardFile,
};
use dsh_core::launcher::{DshLauncher, DshProcess, URL_TIMEOUT_SECONDS};
use dsh_core::modlens::ensure_modlens;
use dsh_core::paths::BundledPaths;
use dsh_core::preset::ensure_anchored_standard;
use dsh_core::updater::{mark_update_checked, update_check_due, update_dsh};
use dsh_core::{APP_NAME, ENV_NO_UPDATE};
use serde::Serialize;
use tauri::{AppHandle, Emitter, Manager, WebviewUrl, WebviewWindowBuilder};
use url::Url;
const INJECT: &str = concat!(
include_str!("../../ui/inject/ingest.js"),
include_str!("../../ui/inject/hide-twins.js"),
include_str!("../../ui/inject/chrome.js"),
);
#[derive(Clone, Debug)]
pub struct Args {
pub dsh: Option<String>,
pub cwd: Option<PathBuf>,
pub no_update: bool,
pub force_update: bool,
pub dev: bool,
pub verbose: bool,
}
impl Args {
pub fn parse() -> Self {
let mut args = Args {
dsh: std::env::var(dsh_core::ENV_BIN_OVERRIDE).ok().filter(|s| !s.is_empty()),
cwd: std::env::var(dsh_core::ENV_CWD_OVERRIDE)
.ok()
.filter(|s| !s.is_empty())
.map(PathBuf::from),
no_update: std::env::var(ENV_NO_UPDATE)
.map(|v| matches!(v.to_ascii_lowercase().as_str(), "1" | "true" | "yes"))
.unwrap_or(false),
force_update: false,
dev: false,
verbose: false,
};
let mut argv = std::env::args().skip(1);
while let Some(arg) = argv.next() {
match arg.as_str() {
"--dsh" => args.dsh = argv.next(),
"--cwd" => args.cwd = argv.next().map(PathBuf::from),
"--no-update" => args.no_update = true,
"--update" => args.force_update = true,
"--dev" => args.dev = true,
"--verbose" => args.verbose = true,
"--help" | "-h" => {
print_help();
std::process::exit(0);
}
"--version" | "-V" => {
println!("{APP_NAME} {}", dsh_core::VERSION);
std::process::exit(0);
}
_ => {}
}
}
args
}
}
fn print_help() {
println!(
"{APP_NAME} — native window around the official dsh WebUI.\n\n\
--dsh PATH dsh 可执行文件路径或命令\n\
--cwd DIR 传给 dsh 的工作目录\n\
--no-update 不检查 dsh 更新\n\
--update 启动前强制检查/安装 dsh 更新\n\
--dev 打开 WebView 开发者工具\n\
--verbose 输出调试日志"
);
}
struct AppState {
args: Args,
paths: BundledPaths,
process: Mutex<Option<Arc<DshProcess>>>,
url: Mutex<Option<String>>,
}
impl AppState {
fn stop(&self) {
if let Ok(mut slot) = self.process.lock() {
if let Some(proc) = slot.take() {
proc.stop();
}
}
}
}
#[derive(Clone, Serialize)]
struct StatusPayload {
message: String,
}
#[derive(Clone, Serialize)]
struct ErrorPayload {
message: String,
detail: String,
}
#[derive(Clone, Serialize)]
struct ReadyPayload {
url: String,
}
#[tauri::command]
fn restart(app: AppHandle) {
thread::spawn(move || boot(app));
}
#[tauri::command]
fn open_in_browser(state: tauri::State<AppState>) -> Result<(), String> {
let url = state
.url
.lock()
.ok()
.and_then(|g| g.clone())
.ok_or_else(|| "服务尚未就绪".to_string())?;
open::that(&url).map_err(|e| e.to_string())
}
#[tauri::command]
fn read_clipboard_images() -> Result<Vec<ClipboardFile>, String> {
read_images().map_err(|e| e.to_string())
}
fn read_images() -> Result<Vec<ClipboardFile>, String> {
let mut clipboard = arboard::Clipboard::new().map_err(|e| e.to_string())?;
if let Ok(img) = clipboard.get_image() {
let width = img.width as u32;
let height = img.height as u32;
let bytes = img.bytes.into_owned();
if let Some(buffer) = image::RgbaImage::from_raw(width, height, bytes) {
let mut png = Vec::new();
if buffer
.write_to(&mut Cursor::new(&mut png), image::ImageFormat::Png)
.is_ok()
{
if let Some(file) = file_from_bytes(
filename_for_mime("image/png", 0),
"image/png".into(),
png,
) {
return Ok(vec![file]);
}
}
}
}
if let Ok(text) = clipboard.get_text() {
let loaded = files_from_paths(parse_uri_list(&text));
if !loaded.is_empty() {
return Ok(loaded);
}
}
Ok(Vec::new())
}
fn is_internal(url: &Url) -> bool {
matches!(url.scheme(), "tauri" | "asset" | "about" | "data" | "blob")
|| matches!(
url.host_str(),
Some("127.0.0.1" | "localhost" | "::1" | "tauri.localhost")
)
}
fn boot(app: AppHandle) {
let Some(state) = app.try_state::<AppState>() else {
return;
};
state.stop();
let started = Instant::now();
let _ = app.emit(
"status",
StatusPayload {
message: "正在启动…".into(),
},
);
if state.args.force_update && !state.args.no_update {
let _ = app.emit(
"status",
StatusPayload {
message: "正在检查 dsh 更新…".into(),
},
);
let result = update_dsh(true);
mark_update_checked();
log::info!("dsh update: {} ({})", result.status, result.message);
}
let plugin = ensure_modlens(&state.paths);
log::info!(
"modlens: {} ({}) in {:?}",
plugin.status,
plugin.message,
started.elapsed()
);
let preset = ensure_anchored_standard(&state.paths);
log::info!(
"anchored-standard: {} ({}) in {:?}",
preset.status,
preset.message,
started.elapsed()
);
let _ = app.emit(
"status",
StatusPayload {
message: "正在启动 dsh web 服务…".into(),
},
);
let launcher = DshLauncher::new(state.args.dsh.clone(), state.args.cwd.clone());
let process = match launcher.start() {
Ok(p) => Arc::new(p),
Err(err) => {
let _ = app.emit(
"error",
ErrorPayload {
message: err.to_string(),
detail: String::new(),
},
);
return;
}
};
if let Ok(mut slot) = state.process.lock() {
*slot = Some(Arc::clone(&process));
}
log::info!("dsh web spawned in {:?}", started.elapsed());
if !state.args.no_update && !state.args.force_update && update_check_due() {
thread::spawn(|| {
log::info!("background dsh update check");
let result = update_dsh(true);
mark_update_checked();
log::info!(
"background dsh update: {} ({})",
result.status,
result.message
);
});
}
let deadline = Instant::now() + Duration::from_secs(URL_TIMEOUT_SECONDS);
loop {
if let Some(url) = process.take_url() {
if let Ok(mut slot) = state.url.lock() {
*slot = Some(url.clone());
}
let _ = app.emit("ready", ReadyPayload { url });
log::info!("dsh web ready in {:?}", started.elapsed());
return;
}
if let Some(code) = process.poll() {
let detail = process.snapshot_lines().join("\n");
let _ = app.emit(
"error",
ErrorPayload {
message: format!("服务已退出exit {code})。"),
detail,
},
);
return;
}
if Instant::now() >= deadline {
let detail = process
.snapshot_lines()
.into_iter()
.rev()
.take(80)
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect::<Vec<_>>()
.join("\n");
process.stop();
let _ = app.emit(
"error",
ErrorPayload {
message: format!("等待 dsh web 输出 URL 超时({URL_TIMEOUT_SECONDS} 秒)。"),
detail,
},
);
return;
}
thread::sleep(Duration::from_millis(80));
}
}
pub fn run() {
let args = Args::parse();
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or(if args.verbose {
"debug"
} else {
"info"
}))
.init();
let mut paths = BundledPaths::discover();
let dev = args.dev;
tauri::Builder::default()
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_single_instance::init(|app, _argv, _cwd| {
if let Some(window) = app.get_webview_window("main") {
let _ = window.unminimize();
let _ = window.set_focus();
}
}))
.setup(move |app| {
if let Ok(dir) = app.path().resource_dir() {
paths = paths.with_resource_dir(dir);
}
app.manage(AppState {
args: args.clone(),
paths,
process: Mutex::new(None),
url: Mutex::new(None),
});
let mut builder = WebviewWindowBuilder::new(app, "main", WebviewUrl::App("index.html".into()))
.title(APP_NAME)
.inner_size(1320.0, 860.0)
.min_inner_size(800.0, 560.0)
.decorations(false)
.resizable(true)
.initialization_script(INJECT)
.on_navigation(|url| {
if is_internal(&url) {
true
} else {
let _ = open::that(url.as_str());
false
}
});
if let Some(icon) = app.default_window_icon().cloned() {
builder = builder.icon(icon)?;
}
if dev {
builder = builder.devtools(true);
}
let window = builder.build()?;
if dev {
window.open_devtools();
}
let app_handle = app.handle().clone();
let _ = window;
thread::spawn(move || boot(app_handle));
Ok(())
})
.invoke_handler(tauri::generate_handler![
restart,
open_in_browser,
read_clipboard_images
])
.on_window_event(|window, event| {
if let tauri::WindowEvent::CloseRequested { .. } = event {
if let Some(state) = window.try_state::<AppState>() {
state.stop();
}
}
})
.run(tauri::generate_context!())
.expect("error while running DeepSeek Harness Desktop");
}

5
src-tauri/src/main.rs Normal file
View File

@@ -0,0 +1,5 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
dsh_desktop_lib::run();
}

71
src-tauri/tauri.conf.json Normal file
View File

@@ -0,0 +1,71 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "DeepSeek Harness",
"version": "0.1.0",
"identifier": "io.github.tommyfang.DshDesktop",
"build": {
"frontendDist": "../ui"
},
"app": {
"withGlobalTauri": true,
"enableGTKAppId": true,
"macOSPrivateApi": false,
"windows": [],
"security": {
"csp": null
}
},
"bundle": {
"active": true,
"targets": ["deb", "rpm", "nsis", "msi", "app", "dmg"],
"publisher": "TommyFang2077",
"homepage": "https://github.com/TommyFang2077/dsh-desktop",
"copyright": "Copyright © 2026 TommyFang2077",
"category": "DeveloperTool",
"shortDescription": "Desktop shell for DeepSeek Harness",
"longDescription": "Runs the official DeepSeek Harness (dsh) WebUI in a native window. Starts dsh web, embeds the UI with the system WebView, and ships ModLens plus Anchored Standard presets. Credentials stay in ~/.dsh.",
"licenseFile": "../LICENSE",
"icon": [
"icons/32x32.png",
"icons/64x64.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.png",
"icons/icon.icns",
"icons/icon.ico"
],
"resources": [
"../plugins/dsh-desktop-vision/",
"../vendor/modlens/",
"../vendor/anchored-standard/",
"../vendor/zero-anchored-standard/"
],
"linux": {
"deb": {
"section": "devel",
"desktopTemplate": "linux/dsh-desktop.desktop"
},
"rpm": {
"release": "1",
"desktopTemplate": "linux/dsh-desktop.desktop"
}
},
"windows": {
"nsis": {
"installMode": "currentUser",
"displayLanguageSelector": true,
"languages": ["SimpChinese", "English"]
},
"webviewInstallMode": {
"type": "downloadBootstrapper"
}
},
"macOS": {
"minimumSystemVersion": "10.15",
"dmg": {
"appPosition": { "x": 180, "y": 170 },
"applicationFolderPosition": { "x": 480, "y": 170 }
}
}
}
}

60
tests/test_plugins.py Normal file
View File

@@ -0,0 +1,60 @@
"""Sanity checks for the bundled dsh WebUI plugin (no desktop runtime)."""
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
class VisionPluginFilesTests(unittest.TestCase):
def test_client_registers_system_settings_slots(self):
client = (ROOT / "plugins" / "dsh-desktop-vision" / "client.js").read_text(
encoding="utf-8"
)
self.assertIn("settings.section", client)
self.assertNotIn("settings.plugins.tab", client)
self.assertIn("modlens-vision", client)
host = (ROOT / "plugins" / "dsh-desktop-vision" / "index.js").read_text(
encoding="utf-8"
)
self.assertIn("/dsh-desktop/modlens", host)
self.assertIn("'OpenAI 兼容'", host)
self.assertNotIn("Qwen / 自建网关", host)
self.assertIn("https://api.openai.com/v1", host)
self.assertIn("https://generativelanguage.googleapis.com", host)
self.assertIn("https://api.anthropic.com", host)
self.assertIn("https://platform.openai.com/api-keys", host)
self.assertIn("https://aistudio.google.com/apikey", host)
self.assertIn("https://console.anthropic.com/settings/keys", host)
self.assertIn("获取 API", client)
self.assertNotIn("qwen-agent", client)
self.assertIn("example: 'gpt-4o'", host)
self.assertIn("example: 'gemini-3.6-flash'", host)
self.assertIn("example: 'claude-haiku-4-5-20251001'", host)
self.assertIn("example: 'gemini-3.6-flash-low'", host)
self.assertIn("example: 'haiku'", host)
class BundledAttributionTests(unittest.TestCase):
def test_readme_cites_upstream_plugins(self):
readme = (ROOT / "README.md").read_text(encoding="utf-8")
third = (ROOT / "THIRD_PARTY.md").read_text(encoding="utf-8")
for text in (readme, third):
self.assertIn("https://github.com/deepseek-ai/deepseek-harness", text)
self.assertIn("https://github.com/liustack/modlens", text)
self.assertIn("https://github.com/xiaobright/dsh-anchored-standard", text)
self.assertIn("0.1.0-rc.6", text)
self.assertIn("3.16.6", text)
self.assertIn("ffb845c5480adc953392a6db6f8a98ede621174b", text)
self.assertIn("dsh-desktop-vision", text)
self.assertTrue((ROOT / "docs" / "licenses" / "modlens.LICENSE").is_file())
self.assertTrue(
(ROOT / "docs" / "licenses" / "dsh-anchored-standard.NOTICE").is_file()
)
for name in ("splash.png", "session.png", "vision.png", "menu.png"):
self.assertTrue((ROOT / "docs" / "screenshots" / name).is_file())
if __name__ == "__main__":
unittest.main()

52
ui/app.js Normal file
View File

@@ -0,0 +1,52 @@
const statusEl = document.getElementById("status");
const spinner = document.getElementById("spinner");
const retry = document.getElementById("retry");
const detail = document.getElementById("detail");
function tauri() {
return window.__TAURI__;
}
function setStatus(message, kind) {
statusEl.textContent = message;
const failed = kind === "error";
spinner.hidden = failed;
retry.hidden = !failed;
if (!failed) {
detail.hidden = true;
detail.textContent = "";
}
}
async function boot() {
const api = tauri();
if (!api) {
setStatus("桌面接口未就绪", "error");
return;
}
await api.event.listen("status", function (event) {
setStatus(event.payload.message || "正在启动…", "ok");
});
await api.event.listen("error", function (event) {
setStatus(event.payload.message || "启动失败", "error");
if (event.payload.detail) {
detail.hidden = false;
detail.textContent = event.payload.detail;
}
});
await api.event.listen("ready", function (event) {
if (event.payload && event.payload.url) {
window.location.replace(event.payload.url);
}
});
retry.addEventListener("click", function () {
setStatus("正在重新启动…", "ok");
api.core.invoke("restart");
});
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", boot);
} else {
boot();
}

BIN
ui/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

22
ui/index.html Normal file
View File

@@ -0,0 +1,22 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>DeepSeek Harness</title>
<link rel="stylesheet" href="styles.css" />
</head>
<body>
<main class="stage">
<section class="hero">
<img class="mark" src="icon.png" alt="" />
<h1>DeepSeek Harness</h1>
<p id="status">正在启动…</p>
<div class="ring" id="spinner" aria-hidden="true"></div>
<button type="button" class="pill" id="retry" hidden>重试</button>
<pre id="detail" hidden></pre>
</section>
</main>
<script src="app.js"></script>
</body>
</html>

168
ui/inject/chrome.js Normal file
View File

@@ -0,0 +1,168 @@
(function () {
if (window.__dshDesktopChrome) return;
window.__dshDesktopChrome = true;
const css = `
:root { --dsh-desktop-titlebar-h: 36px; }
html.dsh-desktop-offset {
box-sizing: border-box !important;
padding-top: var(--dsh-desktop-titlebar-h);
}
#dsh-desktop-titlebar {
position: fixed; top: 0; left: 0; right: 0; height: var(--dsh-desktop-titlebar-h);
z-index: 2147483646; display: flex; align-items: center;
padding: 0 12px; box-sizing: border-box;
background: rgba(246, 246, 248, 0.72);
-webkit-backdrop-filter: saturate(180%) blur(20px);
backdrop-filter: saturate(180%) blur(20px);
border-bottom: 0.5px solid rgba(0, 0, 0, 0.06);
user-select: none; -webkit-user-select: none;
}
@media (prefers-color-scheme: dark) {
#dsh-desktop-titlebar {
background: rgba(28, 28, 30, 0.72);
border-bottom-color: rgba(255, 255, 255, 0.08);
}
#dsh-desktop-titlebar .more { color: rgba(255,255,255,0.45); }
#dsh-desktop-titlebar .menu {
background: rgba(44, 44, 46, 0.96);
box-shadow: 0 8px 28px rgba(0,0,0,0.45);
}
#dsh-desktop-titlebar .menu button { color: #f5f5f7; }
#dsh-desktop-titlebar .menu button:hover { background: rgba(255,255,255,0.08); }
}
#dsh-desktop-titlebar .traffic {
display: flex; gap: 8px; align-items: center; height: 100%;
}
#dsh-desktop-titlebar .tl {
width: 12px; height: 12px; min-width: 12px; min-height: 12px;
max-width: 12px; max-height: 12px; flex: none; overflow: hidden;
box-sizing: border-box; border-radius: 50%; border: 0;
padding: 0; margin: 0; display: grid; place-items: center; cursor: default;
position: relative;
}
#dsh-desktop-titlebar .tl.close { background: #ff5f57; }
#dsh-desktop-titlebar .tl.min { background: #febc2e; }
#dsh-desktop-titlebar .tl.zoom { background: #28c840; }
#dsh-desktop-titlebar .tl::after {
content: ""; font-size: 9px; line-height: 1; font-weight: 700;
color: rgba(0,0,0,0.55); opacity: 0;
}
#dsh-desktop-titlebar .traffic:hover .tl.close::after { content: "×"; opacity: 1; }
#dsh-desktop-titlebar .traffic:hover .tl.min::after { content: ""; opacity: 1; }
#dsh-desktop-titlebar .traffic:hover .tl.zoom::after { content: "+"; opacity: 1; }
#dsh-desktop-titlebar .drag { flex: 1; height: 100%; }
#dsh-desktop-titlebar .more {
appearance: none; border: 0; background: transparent;
color: rgba(0,0,0,0.35); font-size: 15px; letter-spacing: 0.08em;
width: 28px; height: 20px; border-radius: 6px; cursor: default;
}
#dsh-desktop-titlebar .more:hover { background: rgba(0,0,0,0.06); color: rgba(0,0,0,0.7); }
#dsh-desktop-titlebar .menu {
position: absolute; top: calc(var(--dsh-desktop-titlebar-h) + 2px); left: 8px; min-width: 168px;
padding: 4px; border-radius: 10px;
background: rgba(255,255,255,0.96);
box-shadow: 0 8px 28px rgba(0,0,0,0.16);
display: none; flex-direction: column;
}
#dsh-desktop-titlebar .menu.open { display: flex; }
#dsh-desktop-titlebar .menu button {
appearance: none; border: 0; background: transparent;
text-align: left; padding: 7px 10px; border-radius: 6px;
font: 13px/1.3 -apple-system, BlinkMacSystemFont, "SF Pro Text", system-ui, sans-serif;
color: #1d1d1f; cursor: default;
}
#dsh-desktop-titlebar .menu button:hover { background: rgba(0,0,0,0.05); }
#dsh-desktop-titlebar .menu button:disabled { opacity: 0.35; }
`;
function api() {
return window.__TAURI__ || null;
}
function inject() {
if (document.getElementById("dsh-desktop-titlebar")) return;
const style = document.createElement("style");
style.textContent = css;
document.documentElement.appendChild(style);
const bar = document.createElement("header");
bar.id = "dsh-desktop-titlebar";
bar.innerHTML =
'<button class="more" type="button" aria-label="更多">•••</button>' +
'<div class="menu">' +
'<button type="button" data-cmd="restart">重新启动</button>' +
'<button type="button" data-cmd="open_in_browser">在浏览器中打开</button>' +
"</div>" +
'<div class="drag" data-tauri-drag-region></div>' +
'<div class="traffic">' +
'<button class="tl min" data-win="minimize" aria-label="最小化"></button>' +
'<button class="tl zoom" data-win="zoom" aria-label="缩放"></button>' +
'<button class="tl close" data-win="close" aria-label="关闭"></button>' +
"</div>";
document.documentElement.appendChild(bar);
if (/^(127\.0\.0\.1|localhost|\[::1\])$/.test(location.hostname)) {
document.documentElement.classList.add("dsh-desktop-offset");
}
const menu = bar.querySelector(".menu");
const more = bar.querySelector(".more");
more.addEventListener("click", function (e) {
e.stopPropagation();
menu.classList.toggle("open");
});
document.addEventListener("click", function () {
menu.classList.remove("open");
});
bar.addEventListener("dblclick", function (e) {
if (e.target.closest(".tl, .more, .menu")) return;
const t = api();
if (t && t.window) t.window.getCurrentWindow().toggleMaximize();
});
bar.addEventListener("click", function (e) {
const t = api();
const winBtn = e.target.closest("[data-win]");
if (winBtn && t && t.window) {
const w = t.window.getCurrentWindow();
const act = winBtn.getAttribute("data-win");
if (act === "close") w.close();
else if (act === "minimize") w.minimize();
else if (act === "zoom") w.toggleMaximize();
return;
}
const cmd = e.target.closest("[data-cmd]");
if (cmd && t && t.core) {
menu.classList.remove("open");
t.core.invoke(cmd.getAttribute("data-cmd"));
}
});
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", inject);
} else {
inject();
}
const harness = /^(127\.0\.0\.1|localhost|\[::1\])$/.test(location.hostname);
if (harness && location.protocol.indexOf("http") === 0) {
document.addEventListener("paste", async function (e) {
const t = api();
if (!t || !t.core) return;
try {
const items = e.clipboardData ? Array.from(e.clipboardData.items) : [];
if (items.some(function (item) { return item.kind === "file" && item.getAsFile(); })) {
return;
}
const files = await t.core.invoke("read_clipboard_images");
if (files && files.length) {
e.preventDefault();
e.stopImmediatePropagation();
if (window.__dshDesktopPasteFiles) window.__dshDesktopPasteFiles(files);
}
} catch (err) {}
}, true);
}
})();

35
ui/inject/hide-twins.js Normal file
View File

@@ -0,0 +1,35 @@
(function () {
const SUFFIX = " (modlens vision)";
function textOf(el) {
return (el.textContent || "").replace(/\s+/g, " ").trim();
}
function hideTwins(root) {
const nodes = root.querySelectorAll("button, [role='menuitem'], [role='option'], [role='group'], h1, h2, h3, h4, span, div, p");
const visionBases = new Set();
for (const el of nodes) {
if (el.childElementCount > 3) continue;
const t = textOf(el);
if (t.endsWith(SUFFIX) && t.length > SUFFIX.length) {
visionBases.add(t.slice(0, -SUFFIX.length));
}
}
if (visionBases.size === 0) return;
for (const el of nodes) {
if (el.childElementCount > 3) continue;
const t = textOf(el);
if (!visionBases.has(t)) continue;
const row = el.closest("[role='group'], [role='menuitem'], li, section") || el;
if (row && row.style.display !== "none") row.style.display = "none";
}
}
const run = function () {
try { hideTwins(document.body); } catch (e) {}
};
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", run);
} else {
run();
}
const obs = new MutationObserver(run);
obs.observe(document.documentElement, { childList: true, subtree: true });
})();

45
ui/inject/ingest.js Normal file
View File

@@ -0,0 +1,45 @@
(function () {
const IMAGE_PATH = /^(?:file:\/\/|(?:\/|\.{1,2}\/)).+\.(?:png|jpe?g|gif|webp|bmp|tiff?)$/i;
function looksLikeImagePath(text) {
const first = (text || "").trim().split(/\s+/, 1)[0] || "";
return IMAGE_PATH.test(first);
}
document.addEventListener("paste", function (e) {
try {
const items = Array.from(e.clipboardData ? e.clipboardData.items : []);
const files = items.filter(function (item) { return item.kind === "file"; })
.map(function (item) { return item.getAsFile(); })
.filter(Boolean);
if (files.length > 0) return;
const text = e.clipboardData ? (e.clipboardData.getData("text/plain") || "") : "";
const uris = e.clipboardData ? (e.clipboardData.getData("text/uri-list") || "") : "";
if (looksLikeImagePath(text) || looksLikeImagePath(uris)) {
e.preventDefault();
e.stopImmediatePropagation();
}
} catch (err) {}
}, true);
window.__dshDesktopPasteFiles = function (items) {
try {
const dt = new DataTransfer();
for (const item of items) {
const bin = atob(item.b64);
const bytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
dt.items.add(new File([bytes], item.name, { type: item.type }));
}
const drop = new DragEvent("drop", {
bubbles: true,
cancelable: true,
dataTransfer: dt
});
try {
Object.defineProperty(drop, "dataTransfer", { value: dt, configurable: true });
} catch (e) {}
document.dispatchEvent(drop);
} catch (err) {
console.error("dsh-desktop paste image failed", err);
}
};
})();

114
ui/styles.css Normal file
View File

@@ -0,0 +1,114 @@
:root {
color-scheme: light dark;
--bg: #f5f5f7;
--text: #1d1d1f;
--muted: #86868b;
--accent: #0071e3;
--card: rgba(255, 255, 255, 0.64);
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #000000;
--text: #f5f5f7;
--muted: #86868b;
--accent: #0a84ff;
--card: rgba(28, 28, 30, 0.72);
}
}
* { box-sizing: border-box; }
html, body {
margin: 0;
min-height: 100%;
background: var(--bg);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "SF Pro Display",
"Helvetica Neue", system-ui, sans-serif;
-webkit-font-smoothing: antialiased;
}
body {
min-height: 100vh;
}
.stage {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 72px 32px 48px;
}
.hero {
width: min(420px, 100%);
text-align: center;
}
.mark {
display: block;
width: 72px;
height: 72px;
margin: 0 auto 22px;
border-radius: 18px;
object-fit: contain;
}
h1 {
margin: 0;
font-size: 28px;
font-weight: 600;
letter-spacing: -0.03em;
}
#status {
margin: 10px 0 0;
color: var(--muted);
font-size: 13px;
line-height: 1.5;
min-height: 1.5em;
}
.ring {
width: 22px;
height: 22px;
margin: 28px auto 0;
border-radius: 50%;
border: 1.5px solid rgba(134, 134, 139, 0.28);
border-top-color: var(--text);
animation: spin 0.8s linear infinite;
}
.ring[hidden] { display: none; }
@keyframes spin { to { transform: rotate(360deg); } }
.pill {
margin-top: 28px;
appearance: none;
border: 0;
border-radius: 980px;
background: var(--accent);
color: #fff;
font: 600 13px/1 -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
padding: 9px 18px;
cursor: default;
}
.pill[hidden] { display: none; }
#detail {
margin: 22px 0 0;
text-align: left;
max-height: 180px;
overflow: auto;
padding: 12px 14px;
border-radius: 12px;
background: var(--card);
color: var(--muted);
font: 11px/1.45 ui-monospace, SFMono-Regular, Menlo, monospace;
white-space: pre-wrap;
}
#detail[hidden] { display: none; }