24 Commits

Author SHA1 Message Date
97e5ae50b5 release: v0.1.2
- bundle dshmarket 1.10.1; drop the duplicate vision settings section (modlens card is the single config surface)
- embed the updater signing public key in every build (Makefile / CI / Flatpak); in-app updates now actually run
- structured release notes per version: docs/release-notes/v<version>.md feeds GitHub, Gitea and the in-app update panel
- gate releases on the notes file; Gitea publish reads/patches the same body
2026-08-17 00:48:38 +08:00
c71a87c92a fix: gate Gitea publish on package feed presence 2026-08-17 00:45:55 +08:00
303e64790b fix: use PUBLISH_TOKEN secret name (GITEA_ prefix forbidden) 2026-08-17 00:44:35 +08:00
c94374b62a feat: publish Gitea releases via Gitea Actions runner 2026-08-17 00:39:52 +08:00
f1b41e989a fix: retry Gitea publish through slow mainland link 2026-08-17 00:00:01 +08:00
33186b2ef9 fix: embed updater pubkey in tauri config 2026-08-16 23:43:30 +08:00
6a3cd55f88 docs: replace README banner with real-product showcase 2026-08-16 23:38:10 +08:00
862382daf6 release: v0.1.1 2026-08-16 23:25:03 +08:00
16c80dfa45 chore: rename repository to dsh-easy-desktop 2026-08-16 23:03:52 +08:00
a24fbe0037 fix: tolerate stale proxy metadata during setup 2026-08-16 23:01:52 +08:00
a3e1f11e85 feat: add mainland-reachable desktop distribution 2026-08-16 23:00:54 +08:00
e8eeb4ac72 Point agents at GitHub issues and the domain glossary.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-16 16:15:54 +08:00
Tommy
629a36c0af Merge pull request #3 from TommyFang2077/TommyFang2077/plugin-agentrq
Fix Linux clipboard image paste ingest
2026-08-16 13:26:35 +08:00
0d762a1c5e revert: drop the AgentRQ plugin from the desktop shell
It is a remote human-in-the-loop task queue with per-workspace tokens, not a chat-native dsh plugin.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-16 13:11:26 +08:00
d6a7a3ab08 fix: do not cancel HTML or text paste when no image is in the event
Empty text/plain used to preventDefault before native clipboard read, which discarded HTML-only paste if no image was recovered.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-16 12:46:10 +08:00
00209d55e6 fix: read Linux clipboard images natively and ingest them as paste
GTK/wl-paste/xclip fallbacks plus a paste-event path so ModLens can take over image paste instead of only synthesizing drop.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-16 12:39:08 +08:00
d6f5359873 fix: ship a loadable AgentRQ bundle that stays idle without a workspace URL
The first integration copied sources without lib/, skipped packaging paths, and could fail the default web profile when url was unset.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-16 12:36:04 +08:00
73cb4cec60 feat: integrate AgentRQ task manager plugin
- Clone agentrq/agentrq plugin to plugins/agentrq/
- Update package.json name to 'agentrq'
- Add agentrq plugin to tauri.conf.json resources
- Add AGENTRQ_PACKAGE constant and bundled_agentrq_plugin() discovery
- Add install_agentrq_plugin() function to modlens.rs
- Update README with plugin listing
2026-08-16 12:13:11 +08:00
366bee00d6 Add Anchored Standard screenshot to README
Show the mode menu with the experimental preset selected so readers can recognize the default setup.
2026-08-15 23:11:42 +08:00
Tommy
079d5de378 Put a full-width product banner at the top of the README.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-15 22:54:09 +08:00
Tommy
27af857d4b Fix Flatpak CI: stub vendor dirs for tauri-build and move to GNOME 50.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-15 22:43:33 +08:00
Tommy
edab39cae2 Restructure the README around the two selling points.
Lead with the hero screenshot, "eyes for DeepSeek" and the ~8%
anchored lift, then a 30-second install table before the details.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-15 22:40:10 +08:00
Tommy
cb63aaa28e Tone down the intro: this is a personal shell that keeps getting updates.
Invite issues instead of claiming the bundled plugins need no setup.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-15 22:37:36 +08:00
Tommy
844c6338cf Pitch built-in vision and the ~8% Anchored Standard lift.
Lead with ModLens giving DeepSeek eyes, and cite Project2 Ability
91 to 98/99 as about +8% over official Standard.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-15 22:36:36 +08:00
69 changed files with 4961 additions and 718 deletions

View File

@@ -0,0 +1,25 @@
name: publish-gitea
on:
workflow_dispatch:
inputs:
tag:
description: "GitHub release tag to publish (default: latest)"
required: false
default: latest
jobs:
publish:
runs-on: [self-hosted, linux, x64]
steps:
- name: Download publish scripts
run: |
BASE=https://raw.githubusercontent.com/TommyFang2077/dsh-easy-desktop/main
curl -fsSL -o build-gitea-update.py "$BASE/scripts/build-gitea-update.py"
curl -fsSL -o publish-gitea-release.sh "$BASE/scripts/publish-gitea-release.sh"
curl -fsSL -o publish-gitea-actions.sh "$BASE/scripts/publish-gitea-actions.sh"
- name: Publish to Gitea
run: bash publish-gitea-actions.sh
env:
GITEA_TOKEN: ${{ secrets.PUBLISH_TOKEN }}
GITEA_BASE_URL: http://192.168.30.33:3000
RELEASE_TAG: ${{ github.event.inputs.tag }}

View File

@@ -41,6 +41,10 @@ jobs:
VERSION=$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -1) VERSION=$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -1)
echo "version=$VERSION" >> "$GITHUB_OUTPUT" echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "tag=v$VERSION" >> "$GITHUB_OUTPUT" echo "tag=v$VERSION" >> "$GITHUB_OUTPUT"
test -f "docs/release-notes/v${VERSION}.md" || {
echo "docs/release-notes/v${VERSION}.md is required for a release (see docs/release-notes/v0.1.1.md)" >&2
exit 1
}
if [ "${GITHUB_REF_TYPE}" = "tag" ] && [ "${GITHUB_REF_NAME}" != "v${VERSION}" ]; then 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 echo "Git tag ${GITHUB_REF_NAME} must match Cargo.toml version v${VERSION}" >&2
exit 1 exit 1
@@ -84,7 +88,7 @@ jobs:
- name: linux - name: linux
platform: ubuntu-22.04 platform: ubuntu-22.04
rust_targets: "" rust_targets: ""
args: --bundles deb,rpm args: --bundles deb,rpm,appimage
- name: windows - name: windows
platform: windows-latest platform: windows-latest
rust_targets: "" rust_targets: ""
@@ -125,9 +129,14 @@ jobs:
shell: bash shell: bash
run: bash scripts/vendor-native.sh run: bash scripts/vendor-native.sh
- name: Export embedded updater public key
run: echo "DSH_DESKTOP_UPDATER_PUBKEY=$(jq -r '.plugins.updater.pubkey' src-tauri/tauri.conf.json)" >> "$GITHUB_ENV"
- uses: tauri-apps/tauri-action@v1 - uses: tauri-apps/tauri-action@v1
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
with: with:
projectPath: . projectPath: .
tagName: v__VERSION__ tagName: v__VERSION__
@@ -139,18 +148,38 @@ jobs:
- Windows / macOS / deb / rpm 需要本机已安装 `dsh``npm i -g @deepseek-ai/dsh`),或设置 `DSH_DESKTOP_DSH_BIN`。 - Windows / macOS / deb / rpm 需要本机已安装 `dsh``npm i -g @deepseek-ai/dsh`),或设置 `DSH_DESKTOP_DSH_BIN`。
- Flatpak 自带 Node.js 与 `dsh`。 - Flatpak 自带 Node.js 与 `dsh`。
- macOS 包未公证,需在「系统设置 → 隐私与安全性」中允许打开。 - macOS 包未公证,需在「系统设置 → 隐私与安全性」中允许打开。
- 中国大陆用户可从 Gitea 镜像下载安装包;壳更新使用同一镜像并在安装前校验 Tauri 签名。
releaseDraft: false releaseDraft: false
prerelease: false prerelease: false
uploadUpdaterJson: false uploadUpdaterJson: false
uploadUpdaterSignatures: false uploadUpdaterSignatures: true
args: ${{ matrix.args }} args: ${{ matrix.args }}
- name: Collect Gitea release artifacts
uses: actions/upload-artifact@v4
with:
name: gitea-${{ matrix.name }}
path: |
target/**/release/bundle/**/*.dmg
target/**/release/bundle/**/*.app.tar.gz
target/**/release/bundle/**/*.app.tar.gz.sig
target/**/release/bundle/**/*.deb
target/**/release/bundle/**/*.rpm
target/**/release/bundle/**/*.AppImage
target/**/release/bundle/**/*.AppImage.sig
target/**/release/bundle/**/*.exe
target/**/release/bundle/**/*.exe.sig
target/**/release/bundle/**/*.msi
target/**/release/bundle/**/*.msi.sig
if-no-files-found: error
retention-days: 1
flatpak: flatpak:
name: Flatpak name: Flatpak
needs: [test, version, vendor] needs: [test, version, vendor]
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: container:
image: ghcr.io/flathub-infra/flatpak-github-actions:gnome-47 image: ghcr.io/flathub-infra/flatpak-github-actions:gnome-50
options: --privileged options: --privileged
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@@ -164,7 +193,7 @@ jobs:
with: with:
bundle: io.github.tommyfang.DshDesktop.flatpak bundle: io.github.tommyfang.DshDesktop.flatpak
manifest-path: flatpak/io.github.tommyfang.DshDesktop.yml manifest-path: flatpak/io.github.tommyfang.DshDesktop.yml
cache-key: flatpak-gnome-47-${{ hashFiles('flatpak/io.github.tommyfang.DshDesktop.yml', 'Cargo.lock') }} cache-key: flatpak-gnome-50-${{ hashFiles('flatpak/io.github.tommyfang.DshDesktop.yml', 'Cargo.lock') }}
upload-artifact: false upload-artifact: false
- name: Attach Flatpak to GitHub Release - name: Attach Flatpak to GitHub Release
@@ -176,3 +205,61 @@ jobs:
fail_on_unmatched_files: true fail_on_unmatched_files: true
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Collect Gitea Flatpak artifact
uses: actions/upload-artifact@v4
with:
name: gitea-flatpak
path: io.github.tommyfang.DshDesktop.flatpak
if-no-files-found: error
retention-days: 1
release-notes:
name: Release notes
needs: [version, package, flatpak]
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- name: Set structured release body
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ needs.version.outputs.tag }}
name: DeepSeek Harness Desktop v${{ needs.version.outputs.version }}
body_path: docs/release-notes/v${{ needs.version.outputs.version }}.md
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
publish-gitea:
name: Publish Gitea mirror
needs: [version, package, flatpak]
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
pattern: gitea-*
path: artifacts
- name: Normalize artifacts and build latest.json
env:
VERSION: ${{ needs.version.outputs.version }}
run: |
python3 scripts/build-gitea-update.py \
--artifacts artifacts \
--output staged \
--version "$VERSION" \
--pub-date "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
--notes "$(python3 scripts/release-notes.py notes "docs/release-notes/v${VERSION}.md")" \
--package-base-url "https://git.fangsiyuan.top/api/packages/TomHanck4/generic/dsh-easy-desktop-updater"
- name: Publish Gitea release and updater feed
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
GITEA_BASE_URL: https://git.fangsiyuan.top
GITEA_OWNER: TomHanck4
GITEA_REPO: dsh-easy-desktop
RELEASE_TAG: ${{ needs.version.outputs.tag }}
RELEASE_VERSION: ${{ needs.version.outputs.version }}
run: bash scripts/publish-gitea-release.sh staged

3
.gitignore vendored
View File

@@ -22,6 +22,9 @@ src-tauri/gen/
.flatpak-builder/ .flatpak-builder/
.flatpak-repo/ .flatpak-repo/
# Local agent scratch (wayfinder snapshots, research notes)
.scratch/
# OS # OS
.DS_Store .DS_Store
*~ *~

15
AGENTS.md Normal file
View File

@@ -0,0 +1,15 @@
# DeepSeek Harness Desktop
## Agent skills
### Issue tracker
Issues live in GitHub Issues; use the `gh` CLI. See `docs/agents/issue-tracker.md`.
### Triage labels
Five canonical roles, each label string equal to its name: `needs-triage`, `needs-info`, `ready-for-agent`, `ready-for-human`, `wontfix`. See `docs/agents/triage-labels.md`.
### Domain docs
Single-context: root `CONTEXT.md` plus `docs/adr/`. See `docs/agents/domain.md`.

37
CONTEXT.md Normal file
View File

@@ -0,0 +1,37 @@
# DeepSeek Harness Desktop
Official dsh WebUI in a native window. This glossary is the language for the shell, what it ships, and how users get more plugins.
## Language
**壳**:
The Tauri desktop application that launches `dsh web` and embeds the official WebUI.
_Avoid_: 应用, 客户端, wrapper, desktop shell对用户说话时
**捆绑件**:
A piece shipped inside the installer and synced into the user's dsh profile or preset directory on launch.
_Avoid_: 内置插件, 三件套
**市场**:
The in-WebUI storefront plugin `dshmarket`. It is itself a Bundled component.
_Avoid_: 插件商店, awesome-dsh-plugin, 目录
**目录**:
The curated awesome-dsh-plugin list that names installable Community plugins (`plugins.json`).
_Avoid_: 市场, registry, awesome 列表(对内请用「目录」)
**社区插件**:
A plugin acquired through the Market, not shipped as a Bundled component.
_Avoid_: 内置插件, 第三方插件(捆绑件也可以是上游项目)
**默认安装**:
A Community plugin the Shell installs through the Market on first launch. ModLens is one.
_Avoid_: 捆绑件(默认安装不进安装包)
**降级**:
Bundled UI stays available when a default-installed Community plugin is missing. Vision settings stay; image reading does not.
_Avoid_: fallback本项目里 fallback 指把引擎再打进安装包,已否决)
**在线更新**:
Fetching a newer Shell, dsh, Bundled component, Market, or default-installed Community plugin over the network.
_Avoid_: 升级(和 dsh 自己的版本号口语混淆)

544
Cargo.lock generated
View File

@@ -97,6 +97,15 @@ version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
dependencies = [
"derive_arbitrary",
]
[[package]] [[package]]
name = "arboard" name = "arboard"
version = "3.6.1" version = "3.6.1"
@@ -114,6 +123,7 @@ dependencies = [
"parking_lot", "parking_lot",
"percent-encoding", "percent-encoding",
"windows-sys 0.60.2", "windows-sys 0.60.2",
"wl-clipboard-rs",
"x11rb", "x11rb",
] ]
@@ -798,6 +808,17 @@ dependencies = [
"serde_core", "serde_core",
] ]
[[package]]
name = "derive_arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]] [[package]]
name = "derive_more" name = "derive_more"
version = "2.1.1" version = "2.1.1"
@@ -904,13 +925,19 @@ checksum = "521e380c0c8afb8d9a1e83a1822ee03556fc3e3e7dbc1fd30be14e37f9cb3f89"
dependencies = [ dependencies = [
"bit-set", "bit-set",
"cssparser", "cssparser",
"foldhash", "foldhash 0.2.0",
"html5ever", "html5ever",
"precomputed-hash", "precomputed-hash",
"selectors", "selectors",
"tendril", "tendril",
] ]
[[package]]
name = "downcast-rs"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
[[package]] [[package]]
name = "dpi" name = "dpi"
version = "0.1.2" version = "0.1.2"
@@ -922,12 +949,13 @@ dependencies = [
[[package]] [[package]]
name = "dsh-core" name = "dsh-core"
version = "0.1.0" version = "0.1.2"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"dirs", "dirs",
"libc", "libc",
"regex", "regex",
"semver",
"serde", "serde",
"serde_json", "serde_json",
"shlex 1.3.0", "shlex 1.3.0",
@@ -938,11 +966,13 @@ dependencies = [
[[package]] [[package]]
name = "dsh-desktop" name = "dsh-desktop"
version = "0.1.0" version = "0.1.2"
dependencies = [ dependencies = [
"arboard", "arboard",
"dsh-core", "dsh-core",
"env_logger", "env_logger",
"gdk",
"gtk",
"image", "image",
"log", "log",
"open", "open",
@@ -952,7 +982,9 @@ dependencies = [
"tauri-build", "tauri-build",
"tauri-plugin-opener", "tauri-plugin-opener",
"tauri-plugin-single-instance", "tauri-plugin-single-instance",
"tauri-plugin-updater",
"url", "url",
"webkit2gtk",
] ]
[[package]] [[package]]
@@ -1151,12 +1183,28 @@ dependencies = [
"rustc_version", "rustc_version",
] ]
[[package]]
name = "filetime"
version = "0.2.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759"
dependencies = [
"cfg-if",
"libc",
]
[[package]] [[package]]
name = "find-msvc-tools" name = "find-msvc-tools"
version = "0.1.11" version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890"
[[package]]
name = "fixedbitset"
version = "0.5.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99"
[[package]] [[package]]
name = "flate2" name = "flate2"
version = "1.1.9" version = "1.1.9"
@@ -1173,6 +1221,12 @@ version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
[[package]]
name = "foldhash"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]] [[package]]
name = "foldhash" name = "foldhash"
version = "0.2.0" version = "0.2.0"
@@ -1617,6 +1671,15 @@ version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
[[package]]
name = "hashbrown"
version = "0.15.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
dependencies = [
"foldhash 0.1.5",
]
[[package]] [[package]]
name = "hashbrown" name = "hashbrown"
version = "0.17.1" version = "0.17.1"
@@ -1716,6 +1779,21 @@ dependencies = [
"want", "want",
] ]
[[package]]
name = "hyper-rustls"
version = "0.27.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f"
dependencies = [
"http",
"hyper",
"hyper-util",
"rustls",
"tokio",
"tokio-rustls",
"tower-service",
]
[[package]] [[package]]
name = "hyper-util" name = "hyper-util"
version = "0.1.20" version = "0.1.20"
@@ -2058,6 +2136,36 @@ dependencies = [
"windows-sys 0.45.0", "windows-sys 0.45.0",
] ]
[[package]]
name = "jni"
version = "0.22.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498"
dependencies = [
"cfg-if",
"combine",
"jni-macros",
"jni-sys 0.4.1",
"log",
"simd_cesu8",
"thiserror 2.0.20",
"walkdir",
"windows-link 0.2.1",
]
[[package]]
name = "jni-macros"
version = "0.22.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3"
dependencies = [
"proc-macro2",
"quote",
"rustc_version",
"simd_cesu8",
"syn 2.0.119",
]
[[package]] [[package]]
name = "jni-sys" name = "jni-sys"
version = "0.3.1" version = "0.3.1"
@@ -2247,6 +2355,12 @@ version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "minisign-verify"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e"
[[package]] [[package]]
name = "miniz_oxide" name = "miniz_oxide"
version = "0.8.9" version = "0.8.9"
@@ -2329,6 +2443,15 @@ version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
[[package]]
name = "nom"
version = "8.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405"
dependencies = [
"memchr",
]
[[package]] [[package]]
name = "num-conv" name = "num-conv"
version = "0.2.2" version = "0.2.2"
@@ -2490,6 +2613,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
dependencies = [ dependencies = [
"bitflags 2.13.1", "bitflags 2.13.1",
"block2", "block2",
"libc",
"objc2", "objc2",
"objc2-core-foundation", "objc2-core-foundation",
] ]
@@ -2505,6 +2629,18 @@ dependencies = [
"objc2-core-foundation", "objc2-core-foundation",
] ]
[[package]]
name = "objc2-osa-kit"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0"
dependencies = [
"bitflags 2.13.1",
"objc2",
"objc2-app-kit",
"objc2-foundation",
]
[[package]] [[package]]
name = "objc2-quartz-core" name = "objc2-quartz-core"
version = "0.3.2" version = "0.3.2"
@@ -2585,6 +2721,12 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "openssl-probe"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
[[package]] [[package]]
name = "option-ext" name = "option-ext"
version = "0.2.0" version = "0.2.0"
@@ -2601,6 +2743,30 @@ dependencies = [
"pin-project-lite", "pin-project-lite",
] ]
[[package]]
name = "os_pipe"
version = "1.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
dependencies = [
"libc",
"windows-sys 0.61.2",
]
[[package]]
name = "osakit"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b"
dependencies = [
"objc2",
"objc2-foundation",
"objc2-osa-kit",
"serde",
"serde_json",
"thiserror 2.0.20",
]
[[package]] [[package]]
name = "pango" name = "pango"
version = "0.18.3" version = "0.18.3"
@@ -2661,6 +2827,17 @@ version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "petgraph"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455"
dependencies = [
"fixedbitset",
"hashbrown 0.15.5",
"indexmap 2.14.0",
]
[[package]] [[package]]
name = "phf" name = "phf"
version = "0.13.1" version = "0.13.1"
@@ -3019,15 +3196,20 @@ dependencies = [
"http-body", "http-body",
"http-body-util", "http-body-util",
"hyper", "hyper",
"hyper-rustls",
"hyper-util", "hyper-util",
"js-sys", "js-sys",
"log", "log",
"percent-encoding", "percent-encoding",
"pin-project-lite", "pin-project-lite",
"rustls",
"rustls-pki-types",
"rustls-platform-verifier",
"serde", "serde",
"serde_json", "serde_json",
"sync_wrapper", "sync_wrapper",
"tokio", "tokio",
"tokio-rustls",
"tokio-util", "tokio-util",
"tower", "tower",
"tower-http", "tower-http",
@@ -3039,6 +3221,20 @@ dependencies = [
"web-sys", "web-sys",
] ]
[[package]]
name = "ring"
version = "0.17.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
dependencies = [
"cc",
"cfg-if",
"getrandom 0.2.17",
"libc",
"untrusted",
"windows-sys 0.52.0",
]
[[package]] [[package]]
name = "rustc-hash" name = "rustc-hash"
version = "2.1.3" version = "2.1.3"
@@ -3067,6 +3263,79 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "rustls"
version = "0.23.43"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06"
dependencies = [
"once_cell",
"ring",
"rustls-pki-types",
"rustls-webpki",
"subtle",
"zeroize",
]
[[package]]
name = "rustls-native-certs"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d"
dependencies = [
"openssl-probe",
"rustls-pki-types",
"schannel",
"security-framework",
]
[[package]]
name = "rustls-pki-types"
version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
dependencies = [
"zeroize",
]
[[package]]
name = "rustls-platform-verifier"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0"
dependencies = [
"core-foundation",
"core-foundation-sys",
"jni 0.22.4",
"log",
"once_cell",
"rustls",
"rustls-native-certs",
"rustls-platform-verifier-android",
"rustls-webpki",
"security-framework",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.61.2",
]
[[package]]
name = "rustls-platform-verifier-android"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
[[package]]
name = "rustls-webpki"
version = "0.103.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0527518605e68109d875e248ea259b6758801cf165e4b2c2733ae3b51f12535a"
dependencies = [
"ring",
"rustls-pki-types",
"untrusted",
]
[[package]] [[package]]
name = "rustversion" name = "rustversion"
version = "1.0.23" version = "1.0.23"
@@ -3082,6 +3351,15 @@ dependencies = [
"winapi-util", "winapi-util",
] ]
[[package]]
name = "schannel"
version = "0.1.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939"
dependencies = [
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "schemars" name = "schemars"
version = "0.8.22" version = "0.8.22"
@@ -3139,6 +3417,29 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "security-framework"
version = "3.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
dependencies = [
"bitflags 2.13.1",
"core-foundation",
"core-foundation-sys",
"libc",
"security-framework-sys",
]
[[package]]
name = "security-framework-sys"
version = "2.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]] [[package]]
name = "selectors" name = "selectors"
version = "0.36.1" version = "0.36.1"
@@ -3366,6 +3667,22 @@ version = "0.3.10"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
[[package]]
name = "simd_cesu8"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520"
dependencies = [
"rustc_version",
"simdutf8",
]
[[package]]
name = "simdutf8"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
[[package]] [[package]]
name = "siphasher" name = "siphasher"
version = "1.0.3" version = "1.0.3"
@@ -3478,6 +3795,12 @@ version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]] [[package]]
name = "swift-rs" name = "swift-rs"
version = "1.0.7" version = "1.0.7"
@@ -3572,7 +3895,7 @@ dependencies = [
"gdkwayland-sys", "gdkwayland-sys",
"gdkx11-sys", "gdkx11-sys",
"gtk", "gtk",
"jni", "jni 0.21.1",
"libc", "libc",
"log", "log",
"ndk", "ndk",
@@ -3605,6 +3928,17 @@ dependencies = [
"syn 2.0.119", "syn 2.0.119",
] ]
[[package]]
name = "tar"
version = "0.4.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
dependencies = [
"filetime",
"libc",
"xattr",
]
[[package]] [[package]]
name = "target-lexicon" name = "target-lexicon"
version = "0.12.16" version = "0.12.16"
@@ -3628,7 +3962,7 @@ dependencies = [
"gtk", "gtk",
"heck 0.5.0", "heck 0.5.0",
"http", "http",
"jni", "jni 0.21.1",
"libc", "libc",
"log", "log",
"mime", "mime",
@@ -3778,6 +4112,39 @@ dependencies = [
"zbus", "zbus",
] ]
[[package]]
name = "tauri-plugin-updater"
version = "2.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "806d9dac662c2e4594ff03c647a552f2c9bd544e7d0f683ec58f872f952ce4af"
dependencies = [
"base64 0.22.1",
"dirs",
"flate2",
"futures-util",
"http",
"infer",
"log",
"minisign-verify",
"osakit",
"percent-encoding",
"reqwest",
"rustls",
"semver",
"serde",
"serde_json",
"tar",
"tauri",
"tauri-plugin",
"tempfile",
"thiserror 2.0.20",
"time",
"tokio",
"url",
"windows-sys 0.60.2",
"zip",
]
[[package]] [[package]]
name = "tauri-runtime" name = "tauri-runtime"
version = "2.11.3" version = "2.11.3"
@@ -3788,7 +4155,7 @@ dependencies = [
"dpi", "dpi",
"gtk", "gtk",
"http", "http",
"jni", "jni 0.21.1",
"objc2", "objc2",
"objc2-ui-kit", "objc2-ui-kit",
"objc2-web-kit", "objc2-web-kit",
@@ -3811,7 +4178,7 @@ checksum = "4e6fac707727b7a2f48e4ded90976324267371073edbb415ffb73bb0458d203f"
dependencies = [ dependencies = [
"gtk", "gtk",
"http", "http",
"jni", "jni 0.21.1",
"log", "log",
"objc2", "objc2",
"objc2-app-kit", "objc2-app-kit",
@@ -4023,6 +4390,16 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "tokio-rustls"
version = "0.26.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
dependencies = [
"rustls",
"tokio",
]
[[package]] [[package]]
name = "tokio-util" name = "tokio-util"
version = "0.7.19" version = "0.7.19"
@@ -4254,6 +4631,17 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "tree_magic_mini"
version = "3.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6"
dependencies = [
"memchr",
"nom",
"petgraph",
]
[[package]] [[package]]
name = "try-lock" name = "try-lock"
version = "0.2.5" version = "0.2.5"
@@ -4336,6 +4724,12 @@ version = "1.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
[[package]]
name = "untrusted"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]] [[package]]
name = "url" name = "url"
version = "2.5.8" version = "2.5.8"
@@ -4519,6 +4913,76 @@ dependencies = [
"web-sys", "web-sys",
] ]
[[package]]
name = "wayland-backend"
version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078"
dependencies = [
"cc",
"downcast-rs",
"rustix",
"smallvec",
"wayland-sys",
]
[[package]]
name = "wayland-client"
version = "0.31.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073"
dependencies = [
"bitflags 2.13.1",
"rustix",
"wayland-backend",
"wayland-scanner",
]
[[package]]
name = "wayland-protocols"
version = "0.32.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6"
dependencies = [
"bitflags 2.13.1",
"wayland-backend",
"wayland-client",
"wayland-scanner",
]
[[package]]
name = "wayland-protocols-wlr"
version = "0.3.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234"
dependencies = [
"bitflags 2.13.1",
"wayland-backend",
"wayland-client",
"wayland-protocols",
"wayland-scanner",
]
[[package]]
name = "wayland-scanner"
version = "0.31.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0"
dependencies = [
"proc-macro2",
"quick-xml",
"quote",
]
[[package]]
name = "wayland-sys"
version = "0.31.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be"
dependencies = [
"pkg-config",
]
[[package]] [[package]]
name = "web-sys" name = "web-sys"
version = "0.3.104" version = "0.3.104"
@@ -4585,6 +5049,15 @@ dependencies = [
"system-deps", "system-deps",
] ]
[[package]]
name = "webpki-root-certs"
version = "1.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b"
dependencies = [
"rustls-pki-types",
]
[[package]] [[package]]
name = "webview2-com" name = "webview2-com"
version = "0.38.2" version = "0.38.2"
@@ -4830,6 +5303,15 @@ dependencies = [
"windows-targets 0.42.2", "windows-targets 0.42.2",
] ]
[[package]]
name = "windows-sys"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
dependencies = [
"windows-targets 0.52.6",
]
[[package]] [[package]]
name = "windows-sys" name = "windows-sys"
version = "0.59.0" version = "0.59.0"
@@ -5101,6 +5583,24 @@ version = "0.57.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
[[package]]
name = "wl-clipboard-rs"
version = "0.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e9651471a32e87d96ef3a127715382b2d11cc7c8bb9822ded8a7cc94072eb0a3"
dependencies = [
"libc",
"log",
"os_pipe",
"rustix",
"thiserror 2.0.20",
"tree_magic_mini",
"wayland-backend",
"wayland-client",
"wayland-protocols",
"wayland-protocols-wlr",
]
[[package]] [[package]]
name = "writeable" name = "writeable"
version = "0.6.4" version = "0.6.4"
@@ -5125,7 +5625,7 @@ dependencies = [
"gtk", "gtk",
"http", "http",
"javascriptcore-rs", "javascriptcore-rs",
"jni", "jni 0.21.1",
"libc", "libc",
"ndk", "ndk",
"objc2", "objc2",
@@ -5189,6 +5689,16 @@ version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd"
[[package]]
name = "xattr"
version = "1.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
dependencies = [
"libc",
"rustix",
]
[[package]] [[package]]
name = "yoke" name = "yoke"
version = "0.8.3" version = "0.8.3"
@@ -5323,6 +5833,12 @@ dependencies = [
"synstructure", "synstructure",
] ]
[[package]]
name = "zeroize"
version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
[[package]] [[package]]
name = "zerotrie" name = "zerotrie"
version = "0.2.5" version = "0.2.5"
@@ -5356,6 +5872,18 @@ dependencies = [
"syn 3.0.3", "syn 3.0.3",
] ]
[[package]]
name = "zip"
version = "4.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1"
dependencies = [
"arbitrary",
"crc32fast",
"indexmap 2.14.0",
"memchr",
]
[[package]] [[package]]
name = "zmij" name = "zmij"
version = "1.0.23" version = "1.0.23"

View File

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

View File

@@ -4,10 +4,12 @@ PREFIX ?= $(HOME)/.local
APP_ID := io.github.tommyfang.DshDesktop APP_ID := io.github.tommyfang.DshDesktop
DSH_VERSION := 0.1.0-rc.6 DSH_VERSION := 0.1.0-rc.6
MODLENS_VERSION := 3.16.6 MODLENS_VERSION := 3.16.6
MARKET_VERSION := 1.10.1
ANCHORED_COMMIT := ffb845c5480adc953392a6db6f8a98ede621174b ANCHORED_COMMIT := ffb845c5480adc953392a6db6f8a98ede621174b
ANCHORED_REPO := https://github.com/xiaobright/dsh-anchored-standard.git ANCHORED_REPO := https://github.com/xiaobright/dsh-anchored-standard.git
VENDOR_DIR := vendor/dsh-prefix VENDOR_DIR := vendor/dsh-prefix
MODLENS_DIR := vendor/modlens MODLENS_DIR := vendor/modlens
MARKET_DIR := vendor/dshmarket
ANCHORED_DIR := vendor/anchored-standard ANCHORED_DIR := vendor/anchored-standard
ZERO_DIR := vendor/zero-anchored-standard ZERO_DIR := vendor/zero-anchored-standard
FLATPAK ?= flatpak FLATPAK ?= flatpak
@@ -19,6 +21,11 @@ VERSION := $(shell sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -1)
BUNDLE := dist/$(APP_ID)-$(VERSION).flatpak BUNDLE := dist/$(APP_ID)-$(VERSION).flatpak
BIN := target/release/dsh-desktop BIN := target/release/dsh-desktop
# The shell updater gates the update check on an embedded public key
# (option_env in shell_updater.rs). Single source of truth is the tauri
# config; without it every build silently disables in-app updates.
export DSH_DESKTOP_UPDATER_PUBKEY := $(shell jq -r '.plugins.updater.pubkey' src-tauri/tauri.conf.json)
.PHONY: all run dev test vendor vendor-native vendor-anchored build install uninstall flatpak-build flatpak-export flatpak-install flatpak-bundle flatpak-run clean .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 all: test
@@ -35,16 +42,19 @@ build:
test: test:
$(CARGO) test -p dsh-core $(CARGO) test -p dsh-core
$(PYTHON) -m unittest discover -s tests -v $(PYTHON) -m unittest discover -s tests -v
node --test tests/*.test.mjs
vendor: vendor:
mkdir -p vendor mkdir -p vendor
rm -rf $(VENDOR_DIR) $(MODLENS_DIR) rm -rf $(VENDOR_DIR) $(MODLENS_DIR) $(MARKET_DIR)
npm install --prefix=$(CURDIR)/$(VENDOR_DIR) --global --prefer-offline --no-audit --no-fund @deepseek-ai/dsh@$(DSH_VERSION) 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) npm install --prefix=$(CURDIR)/$(MODLENS_DIR) --prefer-offline --no-audit --no-fund @liustack/modlens@$(MODLENS_VERSION)
npm install --prefix=$(CURDIR)/$(MARKET_DIR) --prefer-offline --no-audit --no-fund dshmarket@$(MARKET_VERSION)
$(PYTHON) scripts/patch-dshmarket-mainland.py
$(MAKE) vendor-anchored $(MAKE) vendor-anchored
vendor-native: vendor-native:
MODLENS_VERSION=$(MODLENS_VERSION) ANCHORED_COMMIT=$(ANCHORED_COMMIT) ANCHORED_REPO=$(ANCHORED_REPO) bash scripts/vendor-native.sh MODLENS_VERSION=$(MODLENS_VERSION) MARKET_VERSION=$(MARKET_VERSION) ANCHORED_COMMIT=$(ANCHORED_COMMIT) ANCHORED_REPO=$(ANCHORED_REPO) bash scripts/vendor-native.sh
vendor-anchored: vendor-anchored:
rm -rf vendor/.anchored-src $(ANCHORED_DIR) $(ZERO_DIR) rm -rf vendor/.anchored-src $(ANCHORED_DIR) $(ZERO_DIR)
@@ -79,10 +89,15 @@ install: build
mkdir -p $(DESTDIR)$(PREFIX)/share/dsh-desktop; \ mkdir -p $(DESTDIR)$(PREFIX)/share/dsh-desktop; \
cp -R $(MODLENS_DIR) $(DESTDIR)$(PREFIX)/share/dsh-desktop/modlens; \ cp -R $(MODLENS_DIR) $(DESTDIR)$(PREFIX)/share/dsh-desktop/modlens; \
fi fi
if [ -f plugins/dsh-desktop-vision/package.json ]; then \ if [ -d $(MARKET_DIR)/node_modules/dshmarket ]; then \
rm -rf $(DESTDIR)$(PREFIX)/share/dsh-desktop/vision; \ rm -rf $(DESTDIR)$(PREFIX)/share/dsh-desktop/market; \
mkdir -p $(DESTDIR)$(PREFIX)/share/dsh-desktop; \ mkdir -p $(DESTDIR)$(PREFIX)/share/dsh-desktop; \
cp -R plugins/dsh-desktop-vision $(DESTDIR)$(PREFIX)/share/dsh-desktop/vision; \ cp -R $(MARKET_DIR) $(DESTDIR)$(PREFIX)/share/dsh-desktop/market; \
fi
if [ -f plugins/dsh-desktop-voice/package.json ]; then \
rm -rf $(DESTDIR)$(PREFIX)/share/dsh-desktop/voice; \
mkdir -p $(DESTDIR)$(PREFIX)/share/dsh-desktop; \
cp -R plugins/dsh-desktop-voice $(DESTDIR)$(PREFIX)/share/dsh-desktop/voice; \
fi fi
if [ -f $(ANCHORED_DIR)/preset.yml ]; then \ if [ -f $(ANCHORED_DIR)/preset.yml ]; then \
rm -rf $(DESTDIR)$(PREFIX)/share/dsh-desktop/anchored-standard; \ rm -rf $(DESTDIR)$(PREFIX)/share/dsh-desktop/anchored-standard; \
@@ -105,7 +120,7 @@ uninstall:
rm -f "$(DESTDIR)$(PREFIX)/share/icons/hicolor/$${size}x$${size}/apps/$(APP_ID).png"; \ rm -f "$(DESTDIR)$(PREFIX)/share/icons/hicolor/$${size}x$${size}/apps/$(APP_ID).png"; \
done done
flatpak-build: $(VENDOR_DIR)/bin/dsh $(MODLENS_DIR)/node_modules/@liustack/modlens $(ANCHORED_DIR)/preset.yml $(ZERO_DIR)/preset.yml flatpak-build: $(VENDOR_DIR)/bin/dsh $(MODLENS_DIR)/node_modules/@liustack/modlens $(MARKET_DIR)/node_modules/dshmarket $(ANCHORED_DIR)/preset.yml $(ZERO_DIR)/preset.yml
$(BUILDER) --user --force-clean --install-deps-from=flathub $(BUILD_DIR) $(MANIFEST) $(BUILDER) --user --force-clean --install-deps-from=flathub $(BUILD_DIR) $(MANIFEST)
$(VENDOR_DIR)/bin/dsh: $(VENDOR_DIR)/bin/dsh:
@@ -114,6 +129,9 @@ $(VENDOR_DIR)/bin/dsh:
$(MODLENS_DIR)/node_modules/@liustack/modlens: $(MODLENS_DIR)/node_modules/@liustack/modlens:
$(MAKE) vendor $(MAKE) vendor
$(MARKET_DIR)/node_modules/dshmarket:
$(MAKE) vendor
$(ANCHORED_DIR)/preset.yml $(ZERO_DIR)/preset.yml: $(ANCHORED_DIR)/preset.yml $(ZERO_DIR)/preset.yml:
$(MAKE) vendor-anchored $(MAKE) vendor-anchored

206
README.md
View File

@@ -1,141 +1,118 @@
# DeepSeek Harness Desktop <p align="center">
<img src="docs/screenshots/banner.png" alt="DeepSeek Harness Desktop内置离线语音、插件市场与视觉模型配置" />
</p>
<h1 align="center">DeepSeek Harness Desktop</h1>
<p align="center"> <p align="center">
<img src="docs/screenshots/icon.png" width="96" alt="DeepSeek Harness" /> <strong>官方 <a href="https://github.com/deepseek-ai/deepseek-harness">DeepSeek Harness</a>dsh的原生桌面壳</strong>
</p> </p>
<p align="center"> <p align="center">
<strong>把官方 <a href="https://github.com/deepseek-ai/deepseek-harness">DeepSeek Harness</a><code>dsh</code>WebUI 放进原生窗口。</strong><br /> <strong>离线语音输入</strong>&nbsp;&nbsp;·&nbsp;&nbsp;<strong>内置插件市场</strong>&nbsp;&nbsp;·&nbsp;&nbsp;<strong>可视化配置视觉模型</strong>
Tauri 2 壳 · 系统 WebView · 苹果风薄标题栏 · Linux / Windows / macOS
</p> </p>
<p align="center"> <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-easy-desktop/releases/latest"><b>下载</b></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="https://git.fangsiyuan.top/TomHanck4/dsh-easy-desktop/releases/latest"><b>大陆镜像</b></a> ·
<a href="#核心体验">核心体验</a> ·
<a href="#三十秒上手">三十秒上手</a> ·
<a href="#从源码运行">从源码运行</a>
</p>
<p align="center">
<a href="https://github.com/TommyFang2077/dsh-easy-desktop/actions/workflows/ci.yml"><img src="https://github.com/TommyFang2077/dsh-easy-desktop/actions/workflows/ci.yml/badge.svg" alt="CI" /></a>
<a href="https://github.com/TommyFang2077/dsh-easy-desktop/actions/workflows/release.yml"><img src="https://github.com/TommyFang2077/dsh-easy-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="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> <a href="https://github.com/TommyFang2077/dsh-easy-desktop/releases/latest"><img src="https://img.shields.io/github/v/release/TommyFang2077/dsh-easy-desktop" alt="release" /></a>
<a href="https://github.com/topics/dsh-plugin"><img src="https://img.shields.io/badge/topic-dsh--plugin-1f6feb" alt="dsh-plugin" /></a> <a href="https://github.com/topics/dsh-plugin"><img src="https://img.shields.io/badge/topic-dsh--plugin-1f6feb" alt="dsh-plugin" /></a>
</p> </p>
本仓库是第三方桌面壳,**不包含** DeepSeek Harness 源码。官方 WebUI 由本机或 Flatpak 内的 `dsh web` 提供;升级 `dsh` 后界面跟着升级,不必重打包前端。仓库已按 [DeepSeek Harness 贡献指南](https://github.com/deepseek-ai/deepseek-harness/blob/master/CONTRIBUTING.md) 添加 GitHub topic [`dsh-plugin`](https://github.com/topics/dsh-plugin),方便在生态里被发现 dsh 原本运行在浏览器标签页中;本项目用 Tauri 2 和系统 WebView 把官方 WebUI 变成原生桌面窗口。会话、工作区、插件和技能全部保留,`dsh` 更新后界面也会随之更新,不需要重新打包前端
![启动页:正在启动官方 WebUI](docs/screenshots/splash.png) 这是作者维护的第三方壳,**不包含** DeepSeek Harness 源码。重点补齐官方 WebUI 在桌面端缺少的输入和扩展体验:直接说话、直接安装插件、直接配置视觉模型。
## 功能 ## 核心体验
| 能力 | 说明 | ### 内置语音SenseVoice 本机离线听写
| --- | --- |
| 原生窗口 | 启动 `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) 对话框旁直接提供麦克风按钮;按 `Ctrl+E`macOS 为 `⌘E`)即可开始或结束听写,也可切换为按住说话。默认引擎是本机离线 **SenseVoiceSmall**,支持中文、粤语、英语、日语和韩语,识别结果直接写入当前输入框。
*上图为桌面壳嵌套官方 WebUI 的界面示意(自定义标题栏为实际注入样式)。凭据、会话和插件仍在 `~/.dsh`,截图未使用真实对话记录。* 模型和 sherpa-onnx WASM 运行时**不塞进安装包**。首次点击麦克风时会明确提示下载约 245 MB底部状态条持续显示下载与校验进度安装完成后保存在系统缓存目录录音不离开本机。需要云端识别时也可切换到 OpenAI 兼容的 `/v1/audio/transcriptions` 接口。
![标题栏菜单:重新启动 / 在浏览器中打开](docs/screenshots/menu.png) ![设置 → 语音输入SenseVoice 本机离线听写](docs/screenshots/voice.webp)
## 内置插件与预设 ### 内置插件市场:发现、安装和更新社区插件
应用启动时会把下面这些东西同步到用户目录。版本钉死在 [Makefile](Makefile);第三方原文许可证见 [docs/licenses/](docs/licenses/) 与 [THIRD_PARTY.md](THIRD_PARTY.md) 无需记包名或离开应用。在 **设置 → Plugin Market** 中可以浏览目录、搜索分类、查看已安装插件并直接安装、更新、备份或恢复社区插件。ModLens 等默认组件也能从这里正常更新;桌面启动不会再把用户更新的版本降回内置基线
### 1. DeepSeek Harness`dsh` ![设置 → Plugin Market浏览并安装社区插件](docs/screenshots/market.webp)
| | | ### 视觉模型配置:给 DeepSeek 带上眼睛
| --- | --- |
| 上游 | [@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` |
解析顺序: 纯文本 DeepSeek 配合视觉桥后,可以直接粘贴截图识别内容。引擎配置在 **设置 → 插件 → 插件配置 → 视觉引擎ModLens**:支持 OpenAI 兼容接口、Gemini API、Anthropic API、Antigravity CLI 和 Claude Code 登录,只展示当前引擎需要的字段,密钥保存在本机 `~/.modlens/config.json` 中,相关外链由系统浏览器打开。
1. 环境变量 `DSH_DESKTOP_DSH_BIN` ![设置 → 插件 → 插件配置视觉引擎ModLens](docs/screenshots/vision.webp)
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` 内置的锚定式标准预设首轮使用 Minimal 工具表固定执行轨迹,从第二轮起恢复完整 Standard 工具目录。Project2 / DeepSeek V4 Pro 同配置 Ability 为 **98 / 99**,相对官方 Standard 的 91 约 **+8% / +9%**。这是社区实验预设,不代表所有任务都会提升。
| | | 36px 薄标题栏保留更多对话空间;左侧 `•••` 菜单可重新启动 dsh 或在浏览器中打开。关闭窗口会停止对应的 `dsh web` 进程,凭据、权限和会话仍保存在 `~/.dsh`
| --- | --- |
| 上游 | [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` |
官方安装方式(本应用已内置,一般不必再跑): ![主窗口:官方 WebUI 嵌在原生壳中](docs/screenshots/session.png)
```bash ## 三十秒上手
npx -y @deepseek-ai/dsh plugin --profile web add @liustack/modlens@3.16.6
```
### 3. `dsh-desktop-vision`(本仓库) 从 [GitHub Releases](https://github.com/TommyFang2077/dsh-easy-desktop/releases/latest) 下载对应平台的安装包;中国大陆网络可改用 [Gitea 发行版镜像](https://git.fangsiyuan.top/TomHanck4/dsh-easy-desktop/releases/latest)。壳会在启动时从该镜像检查自身更新,下载完成后先校验 Tauri 签名再安装。
| | |
| --- | --- |
| 路径 | [`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` | | Windows | NSIS `.exe` / `.msi` | [WebView2](https://developer.microsoft.com/microsoft-edge/webview2/)(安装器可引导下载)+ 本机 `dsh` |
| macOS | Apple Silicon / Intel `.dmg` | 未公证,首次打开需在「系统设置 → 隐私与安全性」允许本机 `dsh` | | macOS | Apple Silicon / Intel `.dmg` | 未公证,首次打开需在「隐私与安全性」允许 + 本机 `dsh` |
| Linux | `.deb``.rpm` | WebKitGTK 4.1本机 `dsh` | | Linux | `.deb` / `.rpm` | WebKitGTK 4.1 + 本机 `dsh` |
| Linux | `.flatpak` | **自带** Node.js 24 与 `@deepseek-ai/dsh`,不需要本机安装 dsh | | Linux Flatpak | `.flatpak` | **零依赖**:自带 Node.js 24 与 `@deepseek-ai/dsh` |
从 [GitHub Releases](https://github.com/TommyFang2077/dsh-desktop/releases) 下载对应文件。 除 Flatpak 外,需要先安装 `dsh`
本机 `dsh`
```bash ```bash
npm install -g @deepseek-ai/dsh npm install -g @deepseek-ai/dsh
# 或
npx --yes @deepseek-ai/dsh --version
``` ```
安装后直接启动,等待启动页完成即可进入官方 WebUI。
## 功能一览
| 能力 | 说明 |
| --- | --- |
| 离线语音 | 对话框麦克风、`Ctrl+E` / `⌘E` 快捷键、SenseVoice 模型按需下载、本机识别 |
| 插件市场 | 在设置内浏览、搜索、安装、更新、备份和恢复社区插件 |
| 视觉模型 | 粘贴图片直接识别;用表单配置五类视觉引擎,不必手改 JSON |
| 官方 WebUI | 会话、工作区、插件和技能原样保留dsh 更新后界面同步更新 |
| 锚定预设 | Project2 / DeepSeek V4 Pro 相对官方 Standard 约 +8% |
| 原生生命周期 | 随机本地端口启动 `dsh web`;关闭窗口停止服务;崩溃可一键重启 |
## 内置能力与数据位置
| 组件 | 当前基线 | 用途 | 本地位置 |
| --- | --- | --- | --- |
| DeepSeek Harness | `0.1.0-rc.6` | 官方 WebUIFlatpak 内置,其他安装包调用本机 `dsh` | `~/.dsh` |
| 离线语音 | `dsh-desktop-voice 0.4.0` | 麦克风、快捷键、SenseVoice / OpenAI 兼容听写 | 配置 `~/.config/dsh-desktop/voice.json`;模型在系统缓存目录 |
| 插件市场 | `dshmarket 1.10.1` | 社区插件的发现、安装和更新 | `~/.dsh/profiles/web` |
| 视觉桥 | `ModLens 3.16.6` | 让纯文本模型读取粘贴的图片;可从市场更新;引擎配置在「设置 → 插件」 | `~/.dsh/profiles/web`;配置 `~/.modlens/config.json` |
| 锚定预设 | `ffb845c5480a` | 锚定式标准与零工具锚定式标准 | `~/.dsh/.agent-presets/` |
应用启动时会同步桌面自带组件,但会保留用户从市场更新到更新版本的 ModLens。捆绑版本固定在 [Makefile](Makefile) 中。
`dsh` 的查找顺序:`DSH_DESKTOP_DSH_BIN``--dsh` → 桌面更新目录 → Flatpak 内置路径 → 宿主机常见路径 → `PATH``npx`。壳启动的 dsh、市场和语音运行时默认通过 `https://registry.npmmirror.com` 获取 npm 包;已有 `npm_config_registry` / `NPM_CONFIG_REGISTRY` 会保留,也可用 `DSH_DESKTOP_NPM_REGISTRY` 显式覆盖。设置 `DSH_DESKTOP_NO_UPDATE=1` 或传入 `--no-update` 可关闭启动时的 dsh 更新检查。
## 安全与边界
- WebUI 只监听随机的 `127.0.0.1` 端口。
- 语音模型按需下载SenseVoice 识别在本机执行。
- API 密钥只写入本机配置,不进入仓库或远端服务。
- 卸载桌面壳不会删除 `~/.dsh` 中的会话、权限和工作区设置。
## 从源码运行 ## 从源码运行
开发依赖Rust stable、系统 WebView。 开发依赖Rust stable、系统 WebView。
@@ -145,8 +122,8 @@ npx --yes @deepseek-ai/dsh --version
- WindowsWebView2 - WindowsWebView2
```bash ```bash
git clone https://github.com/TommyFang2077/dsh-desktop.git git clone https://github.com/TommyFang2077/dsh-easy-desktop.git
cd dsh-desktop cd dsh-easy-desktop
make vendor-native # ModLens + 锚定预设Tauri 打包资源) make vendor-native # ModLens + 锚定预设Tauri 打包资源)
make run # 普通模式(跳过更新,便于开发) make run # 普通模式(跳过更新,便于开发)
make dev # 开 WebView 检查器和调试日志 make dev # 开 WebView 检查器和调试日志
@@ -167,6 +144,8 @@ cargo tauri build --bundles nsis,msi # Windows
cargo tauri build --bundles app,dmg # macOS cargo tauri build --bundles app,dmg # macOS
``` ```
发布:打 `v*` 标签(如 `git tag v0.1.0 && git push origin v0.1.0`[Release 工作流](.github/workflows/release.yml)自动测试、打包 Windows / macOS / deb / rpm / Flatpak 并挂到 GitHub Releases。
## Flatpak ## Flatpak
Flatpak 是唯一把 `dsh` 打进包内的渠道。 Flatpak 是唯一把 `dsh` 打进包内的渠道。
@@ -174,9 +153,9 @@ Flatpak 是唯一把 `dsh` 打进包内的渠道。
```bash ```bash
flatpak remote-add --user --if-not-exists flathub \ flatpak remote-add --user --if-not-exists flathub \
https://dl.flathub.org/repo/flathub.flatpakrepo https://dl.flathub.org/repo/flathub.flatpakrepo
flatpak install --user -y flathub org.gnome.Sdk//47 org.flatpak.Builder \ flatpak install --user -y flathub org.gnome.Sdk//50 org.flatpak.Builder \
org.freedesktop.Sdk.Extension.rust-stable//24.08 \ org.freedesktop.Sdk.Extension.rust-stable//25.08 \
org.freedesktop.Sdk.Extension.node24//24.08 org.freedesktop.Sdk.Extension.node24//25.08
make vendor make vendor
make flatpak-build make flatpak-build
@@ -194,7 +173,7 @@ dsh-desktop/
├── ui/ # 启动页 + 注入到 WebUI 的标题栏 ├── ui/ # 启动页 + 注入到 WebUI 的标题栏
├── src-tauri/ # Tauri 窗口、命令、deb/rpm/nsis/dmg ├── src-tauri/ # Tauri 窗口、命令、deb/rpm/nsis/dmg
├── crates/dsh-core/ # 启动 / 更新 / ModLens / 预设 / 剪贴板 ├── crates/dsh-core/ # 启动 / 更新 / ModLens / 预设 / 剪贴板
├── plugins/dsh-desktop-vision/ # 设置 → 视觉模型 ├── plugins/dsh-desktop-voice/ # 设置 → 语音输入 + 对话框麦克风
├── data/ # .desktop、图标、AppStream ├── data/ # .desktop、图标、AppStream
├── flatpak/ ├── flatpak/
├── docs/screenshots/ # README 截图 ├── docs/screenshots/ # README 截图
@@ -205,6 +184,23 @@ dsh-desktop/
└── .github/workflows/ # 测试 + 多平台发布 └── .github/workflows/ # 测试 + 多平台发布
``` ```
## 反馈
自用项目会持续更新。bug、想法、打包问题都欢迎开 [issue](https://github.com/TommyFang2077/dsh-easy-desktop/issues)。
## 上游项目与许可证
引用与许可证集中列在这里,正文只介绍用户能直接使用的能力。完整版权说明见 [THIRD_PARTY.md](THIRD_PARTY.md),许可证副本见 [docs/licenses/](docs/licenses/)。
| 组件 | 上游 / 固定版本 | 许可证 |
| --- | --- | --- |
| DeepSeek Harness | [deepseek-ai/deepseek-harness](https://github.com/deepseek-ai/deepseek-harness) · `0.1.0-rc.6` | [MIT](docs/licenses/deepseek-harness.LICENSE) |
| ModLens | [liustack/modlens](https://github.com/liustack/modlens) · `3.16.6` | [MIT](docs/licenses/modlens.LICENSE) |
| dshmarket | [dsh-market/dsh-market](https://github.com/dsh-market/dsh-market) · `1.9.0` | [MIT](docs/licenses/dshmarket.LICENSE) |
| SenseVoiceSmall ONNX | [FunAudioLLM/SenseVoice](https://github.com/FunAudioLLM/SenseVoice) · 按需下载 | [MIT](docs/licenses/sensevoice.LICENSE) |
| sherpa-onnx WASM | [k2-fsa/sherpa-onnx](https://github.com/k2-fsa/sherpa-onnx) · `1.13.5` · 按需下载 | Apache-2.0(许可证随运行时包提供) |
| Anchored Standard | [xiaobright/dsh-anchored-standard](https://github.com/xiaobright/dsh-anchored-standard) · [`ffb845c5480a`](https://github.com/xiaobright/dsh-anchored-standard/commit/ffb845c5480adc953392a6db6f8a98ede621174b) | [MIT](docs/licenses/dsh-anchored-standard.LICENSE) · [NOTICE](docs/licenses/dsh-anchored-standard.NOTICE) |
## 图标与商标 ## 图标与商标
应用图标使用 [Icons8 上的 DeepSeek 图标](https://icons8.com/icon/YWOidjGxCpFW/deepseek)。DeepSeek 名称与鲸鱼标志归 DeepSeek 所有。本项目是独立第三方桌面壳,与 DeepSeek、ModLens、Anchored Standard 的作者均无从属关系。 应用图标使用 [Icons8 上的 DeepSeek 图标](https://icons8.com/icon/YWOidjGxCpFW/deepseek)。DeepSeek 名称与鲸鱼标志归 DeepSeek 所有。本项目是独立第三方桌面壳,与 DeepSeek、ModLens、Anchored Standard 的作者均无从属关系。

View File

@@ -14,7 +14,10 @@ DeepSeek, liustack, or xiaobright.
| 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`. | | 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`. | | 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`. | | 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`. | | `dsh-desktop-voice` | this repo `plugins/dsh-desktop-voice/` | `0.4.0` | MIT, © 2026 TommyFang2077 | Settings page **设置 → 语音输入** and composer mic; writes `~/.config/dsh-desktop/voice.json`. |
| dshmarket | [dsh-market/dsh-market](https://github.com/dsh-market/dsh-market) | `1.10.1` | MIT, © 2026 fkysly and dsh-market contributors | Bundled WebUI market for browsing and installing Community plugins. See `docs/licenses/dshmarket.LICENSE`. |
| SenseVoiceSmall ONNX | [FunAudioLLM/SenseVoice](https://github.com/FunAudioLLM/SenseVoice) via [k2-fsa conversion](https://huggingface.co/csukuangfj/sherpa-onnx-sense-voice-zh-en-ja-ko-yue-2024-07-17) | commit `2365baeacb507f821a0c8120fcee3d484dba7a07` | MIT, © 2025 FunASR | Not bundled. Downloaded after the user confirms, then stored in the platform cache. See `docs/licenses/sensevoice.LICENSE`. |
| sherpa-onnx WASM | [k2-fsa/sherpa-onnx](https://github.com/k2-fsa/sherpa-onnx) | `1.13.5` | Apache-2.0 | Not bundled. Installed with the model to run SenseVoice locally; the npm package carries its upstream license. |
The Anchored Standard NOTICE records that the presets adapt the DeepSeek The Anchored Standard NOTICE records that the presets adapt the DeepSeek
Harness Standard agent preset from Harness Standard agent preset from

View File

@@ -11,6 +11,7 @@ base64 = "0.22"
dirs = "6" dirs = "6"
regex = "1" regex = "1"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
semver = "1"
serde_json = "1" serde_json = "1"
shlex = "1" shlex = "1"
thiserror = "2" thiserror = "2"

View File

@@ -24,7 +24,12 @@ pub struct ClipboardFile {
} }
pub fn is_image_mime(mime: &str) -> bool { pub fn is_image_mime(mime: &str) -> bool {
let mime = mime.split(';').next().unwrap_or(mime).trim().to_ascii_lowercase(); let mime = mime
.split(';')
.next()
.unwrap_or(mime)
.trim()
.to_ascii_lowercase();
let mime = if mime == "image/jpg" { let mime = if mime == "image/jpg" {
"image/jpeg" "image/jpeg"
} else { } else {
@@ -34,7 +39,12 @@ pub fn is_image_mime(mime: &str) -> bool {
} }
pub fn filename_for_mime(mime: &str, index: usize) -> String { pub fn filename_for_mime(mime: &str, index: usize) -> String {
let mime = mime.split(';').next().unwrap_or(mime).trim().to_ascii_lowercase(); let mime = mime
.split(';')
.next()
.unwrap_or(mime)
.trim()
.to_ascii_lowercase();
let ext = match mime.as_str() { let ext = match mime.as_str() {
"image/png" => "png", "image/png" => "png",
"image/jpeg" | "image/jpg" => "jpg", "image/jpeg" | "image/jpg" => "jpg",
@@ -84,9 +94,7 @@ fn url_parse_file(rest: &str) -> Result<String, ()> {
let decoded = percent_decode(&uri); let decoded = percent_decode(&uri);
if let Some(idx) = decoded.find("://") { if let Some(idx) = decoded.find("://") {
let after = &decoded[idx + 3..]; let after = &decoded[idx + 3..];
let path = after let path = after.strip_prefix("localhost").unwrap_or(after);
.strip_prefix("localhost")
.unwrap_or(after);
return Ok(path.to_string()); return Ok(path.to_string());
} }
if let Some(path) = decoded.strip_prefix("file:") { if let Some(path) = decoded.strip_prefix("file:") {
@@ -101,7 +109,8 @@ fn percent_decode(input: &str) -> String {
let mut i = 0; let mut i = 0;
while i < bytes.len() { while i < bytes.len() {
if bytes[i] == b'%' && i + 2 < 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) if let Ok(v) =
u8::from_str_radix(std::str::from_utf8(&bytes[i + 1..i + 3]).unwrap_or(""), 16)
{ {
out.push(v); out.push(v);
i += 3; i += 3;
@@ -170,6 +179,25 @@ pub fn file_from_bytes(name: String, mime: String, data: Vec<u8>) -> Option<Clip
}) })
} }
pub fn detect_image_mime(data: &[u8]) -> Option<&'static str> {
if data.len() >= 8 && data.starts_with(&[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A]) {
return Some("image/png");
}
if data.len() >= 3 && data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF {
return Some("image/jpeg");
}
if data.len() >= 12 && data.starts_with(b"RIFF") && &data[8..12] == b"WEBP" {
return Some("image/webp");
}
if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
return Some("image/gif");
}
if data.len() >= 2 && data.starts_with(b"BM") {
return Some("image/bmp");
}
None
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -187,9 +215,25 @@ mod tests {
#[test] #[test]
fn ingest_js_defines_helper() { fn ingest_js_defines_helper() {
assert!(INGEST_JS.contains("window.__dshDesktopPasteFiles")); assert!(INGEST_JS.contains("window.__dshDesktopPasteFiles"));
assert!(INGEST_JS.contains("window.__dshDesktopIngestFiles"));
assert!(INGEST_JS.contains("/modlens/paste"));
assert!(INGEST_JS.contains("ClipboardEvent"));
assert!(INGEST_JS.contains("DragEvent")); assert!(INGEST_JS.contains("DragEvent"));
assert!(INGEST_JS.contains("new File")); assert!(INGEST_JS.contains("new File"));
assert!(INGEST_JS.contains("text/uri-list")); assert!(INGEST_JS.contains("__dshDesktopLooksLikeImagePath"));
}
#[test]
fn sniffs_image_magic() {
assert_eq!(
detect_image_mime(&[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A, 0, 1]),
Some("image/png")
);
assert_eq!(
detect_image_mime(&[0xFF, 0xD8, 0xFF, 0xE0]),
Some("image/jpeg")
);
assert_eq!(detect_image_mime(b"not-an-image"), None);
} }
#[test] #[test]

View File

@@ -10,7 +10,7 @@ use regex::Regex;
use thiserror::Error; use thiserror::Error;
use crate::paths::{home_dir, is_flatpak}; use crate::paths::{home_dir, is_flatpak};
use crate::updater::update_dsh_bin; use crate::updater::{configure_npm_registry, update_dsh_bin};
use crate::{ENV_BIN_OVERRIDE, ENV_CWD_OVERRIDE}; use crate::{ENV_BIN_OVERRIDE, ENV_CWD_OVERRIDE};
pub const DSH_DEFAULT_HOST: &str = "127.0.0.1"; pub const DSH_DEFAULT_HOST: &str = "127.0.0.1";
@@ -117,8 +117,8 @@ impl DshLauncher {
} }
} }
if let Some(cli) = &self.dsh_bin_override { if let Some(cli) = &self.dsh_bin_override {
let parts = shlex::split(cli) let parts =
.ok_or_else(|| DshNotFound::new("dsh 命令不是合法的命令"))?; shlex::split(cli).ok_or_else(|| DshNotFound::new("dsh 命令不是合法的命令"))?;
candidates.push(parts); candidates.push(parts);
} }
@@ -197,6 +197,7 @@ impl DshLauncher {
.stdout(Stdio::piped()) .stdout(Stdio::piped())
.stderr(Stdio::inherit()) .stderr(Stdio::inherit())
.stdin(Stdio::null()); .stdin(Stdio::null());
configure_npm_registry(&mut cmd);
#[cfg(unix)] #[cfg(unix)]
{ {
use std::os::unix::process::CommandExt; use std::os::unix::process::CommandExt;
@@ -208,9 +209,9 @@ impl DshLauncher {
const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200; const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200;
cmd.creation_flags(CREATE_NEW_PROCESS_GROUP); cmd.creation_flags(CREATE_NEW_PROCESS_GROUP);
} }
let mut child = cmd.spawn().map_err(|exc| { let mut child = cmd
DshNotFound::new(format!("无法启动 dsh web: {exc}")) .spawn()
})?; .map_err(|exc| DshNotFound::new(format!("无法启动 dsh web: {exc}")))?;
let stdout = child.stdout.take(); let stdout = child.stdout.take();
Ok(DshProcess::new(child, stdout, self.in_flatpak)) Ok(DshProcess::new(child, stdout, self.in_flatpak))
} }

View File

@@ -19,3 +19,4 @@ pub const VERSION: &str = env!("CARGO_PKG_VERSION");
pub const ENV_BIN_OVERRIDE: &str = "DSH_DESKTOP_DSH_BIN"; pub const ENV_BIN_OVERRIDE: &str = "DSH_DESKTOP_DSH_BIN";
pub const ENV_CWD_OVERRIDE: &str = "DSH_DESKTOP_CWD"; pub const ENV_CWD_OVERRIDE: &str = "DSH_DESKTOP_CWD";
pub const ENV_NO_UPDATE: &str = "DSH_DESKTOP_NO_UPDATE"; pub const ENV_NO_UPDATE: &str = "DSH_DESKTOP_NO_UPDATE";
pub const ENV_NPM_REGISTRY: &str = "DSH_DESKTOP_NPM_REGISTRY";

View File

@@ -7,8 +7,12 @@ use serde_json::{json, Value};
use crate::paths::{copy_tree, dsh_home, replace_symlink, BundledPaths}; use crate::paths::{copy_tree, dsh_home, replace_symlink, BundledPaths};
pub const PACKAGE: &str = "@liustack/modlens"; pub const PACKAGE: &str = "@liustack/modlens";
pub const VISION_PACKAGE: &str = "dsh-desktop-vision"; pub const VOICE_PACKAGE: &str = "dsh-desktop-voice";
pub const MARKET_PACKAGE: &str = "dshmarket";
pub const MODLENS_VERSION: &str = "3.16.6"; pub const MODLENS_VERSION: &str = "3.16.6";
const VOICE_PLUGIN_VERSION: &str = "0.4.0";
const MARKET_PLUGIN_VERSION: &str = "1.10.1";
const MANAGED_BUNDLES_DIR: &str = ".dsh-desktop/bundles";
pub const HIDE_PLAIN_TWINS_JS: &str = include_str!("../../../ui/inject/hide-twins.js"); pub const HIDE_PLAIN_TWINS_JS: &str = include_str!("../../../ui/inject/hide-twins.js");
pub const MANAGED_OVERLAY: &str = "\ pub const MANAGED_OVERLAY: &str = "\
@@ -48,12 +52,35 @@ pub fn read_modlens_version(prefix: &Path) -> Option<String> {
.map(|s| s.to_string()) .map(|s| s.to_string())
} }
pub fn bundled_vision_plugin(paths: &BundledPaths) -> Option<PathBuf> { fn bundle_should_replace(installed: Option<&str>, bundled: &str) -> bool {
let Some(installed) = installed else {
return true;
};
if installed == bundled {
return false;
}
match (
semver::Version::parse(installed),
semver::Version::parse(bundled),
) {
(Ok(installed), Ok(bundled)) => installed < bundled,
_ => true,
}
}
fn bundled_plugin(paths: &BundledPaths, dirs: &[&str]) -> Option<PathBuf> {
dirs.iter().find_map(|rel| {
paths paths
.find_dir("vision", "package.json") .find_dir(rel, "package.json")
.or_else(|| paths.find_dir("dsh-desktop-vision", "package.json")) .filter(|p| p.join("client.js").is_file() && p.join("index.js").is_file())
.or_else(|| paths.find_dir("plugins/dsh-desktop-vision", "package.json")) })
.filter(|p| p.join("client.js").is_file()) }
fn bundled_voice_plugin(paths: &BundledPaths) -> Option<PathBuf> {
bundled_plugin(
paths,
&["voice", "dsh-desktop-voice", "plugins/dsh-desktop-voice"],
)
} }
pub fn bundled_modlens_prefix(paths: &BundledPaths) -> Option<PathBuf> { pub fn bundled_modlens_prefix(paths: &BundledPaths) -> Option<PathBuf> {
@@ -62,6 +89,13 @@ pub fn bundled_modlens_prefix(paths: &BundledPaths) -> Option<PathBuf> {
.or_else(|| paths.find_dir("vendor/modlens", "node_modules/@liustack/modlens")) .or_else(|| paths.find_dir("vendor/modlens", "node_modules/@liustack/modlens"))
} }
pub fn bundled_market_prefix(paths: &BundledPaths) -> Option<PathBuf> {
paths
.find_dir("market", "node_modules/dshmarket/package.json")
.or_else(|| paths.find_dir("dshmarket", "node_modules/dshmarket/package.json"))
.or_else(|| paths.find_dir("vendor/dshmarket", "node_modules/dshmarket/package.json"))
}
fn install_into_profile(src_prefix: &Path, profile: &Path) -> std::io::Result<()> { fn install_into_profile(src_prefix: &Path, profile: &Path) -> std::io::Result<()> {
let dest_pkg = package_dir(profile); let dest_pkg = package_dir(profile);
copy_tree(&package_dir(src_prefix), &dest_pkg, true)?; copy_tree(&package_dir(src_prefix), &dest_pkg, true)?;
@@ -86,22 +120,85 @@ fn read_pkg_version(dir: &Path) -> Option<String> {
.map(|s| s.to_string()) .map(|s| s.to_string())
} }
fn install_vision_plugin(paths: &BundledPaths, profile: &Path) -> bool { fn local_plugin_spec(pkg: &str) -> String {
let Some(src) = bundled_vision_plugin(paths) else { format!("file:{MANAGED_BUNDLES_DIR}/{pkg}")
}
fn install_profile_plugin(
profile: &Path,
pkg: &str,
expected_version: &str,
src: Option<PathBuf>,
) -> bool {
let Some(src) = src else {
return false; return false;
}; };
let dest = profile.join("node_modules").join(VISION_PACKAGE); if read_pkg_version(&src).as_deref() != Some(expected_version) {
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; return false;
} }
let fallback = dsh_home().join("profiles/node_modules").join(VISION_PACKAGE); let managed = profile.join(MANAGED_BUNDLES_DIR).join(pkg);
let managed_current = managed.join("client.js").is_file()
&& managed.join("index.js").is_file()
&& read_pkg_version(&managed) == read_pkg_version(&src);
if !managed_current && copy_tree(&src, &managed, true).is_err() {
return false;
}
let dest = profile.join("node_modules").join(pkg);
let installed_current = dest.join("client.js").is_file()
&& dest.join("index.js").is_file()
&& read_pkg_version(&dest) == read_pkg_version(&managed);
if !installed_current && copy_tree(&managed, &dest, true).is_err() {
return false;
}
let fallback = dsh_home().join("profiles/node_modules").join(pkg);
let _ = replace_symlink(&fallback, &dest); let _ = replace_symlink(&fallback, &dest);
true true
} }
fn install_voice_plugin(paths: &BundledPaths, profile: &Path) -> bool {
install_profile_plugin(
profile,
VOICE_PACKAGE,
VOICE_PLUGIN_VERSION,
bundled_voice_plugin(paths),
)
}
fn install_market_plugin(paths: &BundledPaths, profile: &Path) -> std::io::Result<Option<String>> {
let Some(prefix) = bundled_market_prefix(paths) else {
return Ok(None);
};
let src = prefix.join("node_modules").join(MARKET_PACKAGE);
let Some(version) = read_pkg_version(&src) else {
return Ok(None);
};
if version != MARKET_PLUGIN_VERSION {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("bundled dshmarket version {version} does not match {MARKET_PLUGIN_VERSION}"),
));
}
let dest = profile.join("node_modules").join(MARKET_PACKAGE);
if read_pkg_version(&dest).as_deref() != Some(version.as_str()) {
copy_tree(&src, &dest, true)?;
}
for dep in [
"@deepseek-ai/cordis",
"@deepseek-ai/cosmokit",
"@standard-schema/spec",
] {
let src_dep = prefix.join("node_modules").join(dep);
if src_dep.is_dir() {
copy_tree(&src_dep, &profile.join("node_modules").join(dep), true)?;
}
}
let fallback = dsh_home()
.join("profiles/node_modules")
.join(MARKET_PACKAGE);
let _ = replace_symlink(&fallback, &dest);
Ok(Some(version))
}
fn ensure_manifest(profile: &Path, packages: &BTreeMap<String, String>) -> std::io::Result<()> { fn ensure_manifest(profile: &Path, packages: &BTreeMap<String, String>) -> std::io::Result<()> {
let path = profile.join("package.json"); let path = profile.join("package.json");
let mut data = if path.is_file() { let mut data = if path.is_file() {
@@ -320,10 +417,13 @@ fn ensure_modlens_inner(
version: &str, version: &str,
) -> std::io::Result<ModlensEnsureResult> { ) -> std::io::Result<ModlensEnsureResult> {
std::fs::create_dir_all(profile)?; std::fs::create_dir_all(profile)?;
let vision_ok = install_vision_plugin(paths, profile); let voice_ok = install_voice_plugin(paths, profile);
let mut packages = BTreeMap::new(); let mut packages = BTreeMap::new();
if vision_ok { if voice_ok {
packages.insert(VISION_PACKAGE.to_string(), "0.1.0".into()); packages.insert(VOICE_PACKAGE.to_string(), local_plugin_spec(VOICE_PACKAGE));
}
if let Some(version) = install_market_plugin(paths, profile)? {
packages.insert(MARKET_PACKAGE.to_string(), version);
} }
if src.is_none() && installed.is_none() { if src.is_none() && installed.is_none() {
if !packages.is_empty() { if !packages.is_empty() {
@@ -335,8 +435,9 @@ fn ensure_modlens_inner(
message: "未找到内置 ModLens跳过插件安装".into(), message: "未找到内置 ModLens跳过插件安装".into(),
}); });
} }
let replace_bundle = src.is_some() && bundle_should_replace(installed.as_deref(), version);
let installed_after = if let Some(src) = src { let installed_after = if let Some(src) = src {
if installed.as_deref() != Some(version) { if replace_bundle {
install_into_profile(src, profile)?; install_into_profile(src, profile)?;
read_modlens_version(profile).unwrap_or_else(|| version.to_string()) read_modlens_version(profile).unwrap_or_else(|| version.to_string())
} else { } else {
@@ -372,6 +473,7 @@ fn ensure_modlens_inner(
message: format!("已配置已安装的 ModLens {installed_after}(纯文本自动套视觉桥)"), message: format!("已配置已安装的 ModLens {installed_after}(纯文本自动套视觉桥)"),
}); });
} }
if !replace_bundle {
if installed.as_deref() == Some(version) { if installed.as_deref() == Some(version) {
return Ok(ModlensEnsureResult { return Ok(ModlensEnsureResult {
status: "current", status: "current",
@@ -379,6 +481,12 @@ fn ensure_modlens_inner(
message: format!("内置 ModLens {version} 已就绪"), message: format!("内置 ModLens {version} 已就绪"),
}); });
} }
return Ok(ModlensEnsureResult {
status: "current",
version: Some(installed_after.clone()),
message: format!("已保留用户更新的 ModLens {installed_after}(内置版本 {version}"),
});
}
if installed.is_some() { if installed.is_some() {
return Ok(ModlensEnsureResult { return Ok(ModlensEnsureResult {
status: "updated", status: "updated",
@@ -431,7 +539,8 @@ mod tests {
#[test] #[test]
fn keeps_other_entries() { fn keeps_other_entries() {
let original = "- id: other\n config:\n x: 1\n- id: modlens\n config:\n autoRead: false\n"; let original =
"- id: other\n config:\n x: 1\n- id: modlens\n config:\n autoRead: false\n";
let text = ensure_modlens_overlay(original); let text = ensure_modlens_overlay(original);
assert!(text.contains("id: other")); assert!(text.contains("id: other"));
assert!(text.contains("autoRead: true")); assert!(text.contains("autoRead: true"));
@@ -461,7 +570,8 @@ ui-theme:
#[test] #[test]
fn already_wrapped_is_left_alone() { fn already_wrapped_is_left_alone() {
let original = "agent-default-model:\n provider: deepseek-modlens\n model: deepseek-v4-pro\n"; let original =
"agent-default-model:\n provider: deepseek-modlens\n model: deepseek-v4-pro\n";
assert_eq!(remap_default_text_model(original), original); assert_eq!(remap_default_text_model(original), original);
} }
@@ -470,4 +580,80 @@ ui-theme:
assert!(HIDE_PLAIN_TWINS_JS.contains("(modlens vision)")); assert!(HIDE_PLAIN_TWINS_JS.contains("(modlens vision)"));
assert!(HIDE_PLAIN_TWINS_JS.contains("MutationObserver")); assert!(HIDE_PLAIN_TWINS_JS.contains("MutationObserver"));
} }
#[test]
fn bundled_market_is_added_to_profile() {
let tmp = tempfile::TempDir::new().unwrap();
let market = tmp.path().join("market/node_modules/dshmarket");
std::fs::create_dir_all(&market).unwrap();
std::fs::write(
market.join("package.json"),
format!(r#"{{"name":"dshmarket","version":"{MARKET_PLUGIN_VERSION}"}}"#),
)
.unwrap();
std::fs::write(market.join("index.js"), "export {}\n").unwrap();
let paths = BundledPaths::default().with_resource_dir(tmp.path().to_path_buf());
let profile = tmp.path().join("profile");
ensure_modlens_inner(&paths, None, &profile, None, MODLENS_VERSION).unwrap();
let manifest: Value =
serde_json::from_str(&std::fs::read_to_string(profile.join("package.json")).unwrap())
.unwrap();
assert_eq!(
manifest["dependencies"]["dshmarket"],
MARKET_PLUGIN_VERSION
);
assert!(manifest["dsh"]["profile"]["bundles"]
.as_array()
.unwrap()
.iter()
.any(|item| item == "dshmarket"));
assert!(profile
.join("node_modules/dshmarket/package.json")
.is_file());
}
#[test]
fn bundled_local_plugins_use_file_dependencies() {
let tmp = tempfile::TempDir::new().unwrap();
for (dir, name, version) in [("voice", VOICE_PACKAGE, VOICE_PLUGIN_VERSION)] {
let plugin = tmp.path().join(dir);
std::fs::create_dir_all(&plugin).unwrap();
std::fs::write(
plugin.join("package.json"),
format!(r#"{{"name":"{name}","version":"{version}"}}"#),
)
.unwrap();
std::fs::write(plugin.join("client.js"), "export {}\n").unwrap();
std::fs::write(plugin.join("index.js"), "export {}\n").unwrap();
}
let paths = BundledPaths::default().with_resource_dir(tmp.path().to_path_buf());
let profile = tmp.path().join("profile");
ensure_modlens_inner(&paths, None, &profile, None, MODLENS_VERSION).unwrap();
let manifest: Value =
serde_json::from_str(&std::fs::read_to_string(profile.join("package.json")).unwrap())
.unwrap();
for name in [VOICE_PACKAGE] {
assert_eq!(
manifest["dependencies"][name],
format!("file:.dsh-desktop/bundles/{name}")
);
assert!(profile
.join(".dsh-desktop/bundles")
.join(name)
.join("package.json")
.is_file());
}
}
#[test]
fn newer_marketplace_modlens_is_not_replaced_by_bundle() {
assert!(!bundle_should_replace(Some("3.17.3"), "3.16.6"));
assert!(!bundle_should_replace(Some("3.16.6"), "3.16.6"));
assert!(bundle_should_replace(Some("3.16.5"), "3.16.6"));
assert!(bundle_should_replace(None, "3.16.6"));
}
} }

View File

@@ -42,7 +42,7 @@ pub fn is_flatpak() -> bool {
Path::new("/.flatpak-info").exists() Path::new("/.flatpak-info").exists()
} }
/// Roots that may contain bundled ModLens / presets / vision plugin. /// Roots that may contain bundled ModLens / market / preset / vision / voice components.
/// ///
/// Search order: extra roots (Tauri resource dir), `$XDG_DATA_HOME/dsh-desktop`, /// Search order: extra roots (Tauri resource dir), `$XDG_DATA_HOME/dsh-desktop`,
/// `/app/share/dsh-desktop`, `/usr/share/dsh-desktop`, `~/.local/share/dsh-desktop`, /// `/app/share/dsh-desktop`, `/usr/share/dsh-desktop`, `~/.local/share/dsh-desktop`,

View File

@@ -155,7 +155,10 @@ fn ensure_one(paths: &BundledPaths, spec: &BundledPreset) -> PresetEnsureResult
return PresetEnsureResult { return PresetEnsureResult {
status: "current", status: "current",
version: version.clone(), version: version.clone(),
message: format!("已配置已安装的{label}{}", short_version(version.as_deref())), message: format!(
"已配置已安装的{label}{}",
short_version(version.as_deref())
),
}; };
} }
return PresetEnsureResult { return PresetEnsureResult {
@@ -336,7 +339,11 @@ order: 5
#[test] #[test]
fn inserts_description_after_name() { fn inserts_description_after_name() {
let updated = localize_preset_yml("name: Anchored Standard\norder: 5\n", PRESET_NAME_ZH, PRESET_DESCRIPTION_ZH); 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(PRESET_NAME_ZH));
assert!(!updated.contains("name: Anchored Standard\n")); assert!(!updated.contains("name: Anchored Standard\n"));
assert!(updated.contains(PRESET_DESCRIPTION_ZH)); assert!(updated.contains(PRESET_DESCRIPTION_ZH));
@@ -371,7 +378,8 @@ name: Zero-Anchored Standard (experimental)
description: Inject one zero-tool anchor turn. description: Inject one zero-tool anchor turn.
order: 6 order: 6
"; ";
let updated = localize_preset_yml(original, ZERO_PRESET_NAME_ZH, ZERO_PRESET_DESCRIPTION_ZH); 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_NAME_ZH));
assert!(updated.contains(ZERO_PRESET_DESCRIPTION_ZH)); assert!(updated.contains(ZERO_PRESET_DESCRIPTION_ZH));
assert!(!updated.contains("Zero-Anchored")); assert!(!updated.contains("Zero-Anchored"));

View File

@@ -1,3 +1,4 @@
use std::ffi::OsStr;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::process::{Command, Stdio}; use std::process::{Command, Stdio};
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
@@ -6,13 +7,14 @@ use std::thread;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use crate::paths::{cache_home, data_home}; use crate::paths::{cache_home, data_home};
use crate::ENV_NO_UPDATE; use crate::{ENV_NO_UPDATE, ENV_NPM_REGISTRY};
pub const DSH_PACKAGE: &str = "@deepseek-ai/dsh"; pub const DSH_PACKAGE: &str = "@deepseek-ai/dsh";
pub const VIEW_TIMEOUT_SECONDS: u64 = 20; pub const VIEW_TIMEOUT_SECONDS: u64 = 20;
pub const INSTALL_TIMEOUT_SECONDS: u64 = 180; pub const INSTALL_TIMEOUT_SECONDS: u64 = 180;
pub const BUNDLED_PREFIX: &str = "/app"; pub const BUNDLED_PREFIX: &str = "/app";
pub const BUNDLED_DSH: &[&str] = &["/app/bin/dsh", "/app/node24/bin/dsh"]; pub const BUNDLED_DSH: &[&str] = &["/app/bin/dsh", "/app/node24/bin/dsh"];
pub const DEFAULT_NPM_REGISTRY: &str = "https://registry.npmmirror.com";
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct UpdateResult { pub struct UpdateResult {
@@ -83,9 +85,30 @@ fn find_node_dir() -> Option<PathBuf> {
.and_then(|p| p.parent().map(|d| d.to_path_buf())) .and_then(|p| p.parent().map(|d| d.to_path_buf()))
} }
fn selected_npm_registry<'a>(
desktop_override: Option<&'a OsStr>,
npm_override: Option<&'a OsStr>,
) -> &'a OsStr {
desktop_override
.filter(|value| !value.is_empty())
.or_else(|| npm_override.filter(|value| !value.is_empty()))
.unwrap_or_else(|| OsStr::new(DEFAULT_NPM_REGISTRY))
}
pub(crate) fn configure_npm_registry(command: &mut Command) {
let desktop_override = std::env::var_os(ENV_NPM_REGISTRY);
let npm_override =
std::env::var_os("npm_config_registry").or_else(|| std::env::var_os("NPM_CONFIG_REGISTRY"));
command.env(
"npm_config_registry",
selected_npm_registry(desktop_override.as_deref(), npm_override.as_deref()),
);
}
fn npm_command(npm: &Path) -> Command { fn npm_command(npm: &Path) -> Command {
let mut cmd = Command::new(npm); let mut cmd = Command::new(npm);
let cache = cache_home().join("dsh-desktop/npm"); let cache = cache_home().join("dsh-desktop/npm");
configure_npm_registry(&mut cmd);
let _ = std::fs::create_dir_all(&cache); let _ = std::fs::create_dir_all(&cache);
cmd.env("npm_config_cache", &cache); cmd.env("npm_config_cache", &cache);
cmd.env("npm_config_update_notifier", "false"); cmd.env("npm_config_update_notifier", "false");
@@ -169,7 +192,11 @@ pub fn mark_update_checked() {
} }
pub fn fetch_latest_version(npm: &Path) -> Option<String> { pub fn fetch_latest_version(npm: &Path) -> Option<String> {
let out = run_npm(npm, &["view", DSH_PACKAGE, "version"], Duration::from_secs(VIEW_TIMEOUT_SECONDS)) let out = run_npm(
npm,
&["view", DSH_PACKAGE, "version"],
Duration::from_secs(VIEW_TIMEOUT_SECONDS),
)
.ok()?; .ok()?;
if !out.status.success() { if !out.status.success() {
return None; return None;
@@ -213,12 +240,14 @@ fn env_skips_update() -> bool {
pub fn update_dsh(enabled: bool) -> UpdateResult { pub fn update_dsh(enabled: bool) -> UpdateResult {
if !enabled || env_skips_update() { if !enabled || env_skips_update() {
let version = read_version(&update_prefix()).or_else(|| read_version(Path::new(BUNDLED_PREFIX))); let version =
read_version(&update_prefix()).or_else(|| read_version(Path::new(BUNDLED_PREFIX)));
return UpdateResult::new("skipped", version, "已跳过 dsh 更新"); return UpdateResult::new("skipped", version, "已跳过 dsh 更新");
} }
let npm = find_npm(); let npm = find_npm();
let current = read_version(&update_prefix()).or_else(|| read_version(Path::new(BUNDLED_PREFIX))); let current =
read_version(&update_prefix()).or_else(|| read_version(Path::new(BUNDLED_PREFIX)));
let Some(npm) = npm else { let Some(npm) = npm else {
let extra = current let extra = current
.as_deref() .as_deref()
@@ -247,11 +276,7 @@ pub fn update_dsh(enabled: bool) -> UpdateResult {
}; };
if current.as_deref() == Some(latest.as_str()) { if current.as_deref() == Some(latest.as_str()) {
return UpdateResult::new( return UpdateResult::new("current", current, format!("内置 dsh 已是最新({latest}"));
"current",
current,
format!("内置 dsh 已是最新({latest}"),
);
} }
let dest = update_prefix(); let dest = update_prefix();
@@ -321,6 +346,32 @@ mod tests {
fn read_version_missing() { fn read_version_missing() {
assert_eq!(read_version(Path::new("/no/such/prefix")), None); assert_eq!(read_version(Path::new("/no/such/prefix")), None);
} }
#[test]
fn npm_commands_default_to_mainland_reachable_registry() {
assert_eq!(
selected_npm_registry(None, None),
OsStr::new(DEFAULT_NPM_REGISTRY)
);
}
#[test]
fn desktop_registry_override_wins() {
assert_eq!(
selected_npm_registry(
Some(OsStr::new("https://registry.example.cn")),
Some(OsStr::new("https://registry.npmjs.org")),
),
OsStr::new("https://registry.example.cn")
);
}
#[test]
fn npm_registry_override_is_preserved() {
assert_eq!(
selected_npm_registry(None, Some(OsStr::new("https://packages.example.com/npm")),),
OsStr::new("https://packages.example.com/npm")
);
}
#[test] #[test]
fn skip_when_disabled() { fn skip_when_disabled() {

View File

@@ -3,8 +3,8 @@ Name=DeepSeek Harness
Name[zh_CN]=DeepSeek Harness Name[zh_CN]=DeepSeek Harness
GenericName=AI Agent Harness GenericName=AI Agent Harness
GenericName[zh_CN]=AI 智能体工作台 GenericName[zh_CN]=AI 智能体工作台
Comment=Desktop shell for DeepSeek Harness Comment=Give DeepSeek eyes (ModLens) and ~+8% via Anchored Standard
Comment[zh_CN]=在原生桌面窗口中运行 DeepSeek Harness Comment[zh_CN]=给 DeepSeek 带上眼睛;内置锚定模式约 +8%
Exec=dsh-desktop Exec=dsh-desktop
Icon=io.github.tommyfang.DshDesktop Icon=io.github.tommyfang.DshDesktop
Terminal=false Terminal=false

View File

@@ -2,8 +2,8 @@
<component type="desktop-application"> <component type="desktop-application">
<id>io.github.tommyfang.DshDesktop</id> <id>io.github.tommyfang.DshDesktop</id>
<name>DeepSeek Harness Desktop</name> <name>DeepSeek Harness Desktop</name>
<summary>Desktop shell for DeepSeek Harness</summary> <summary>Give DeepSeek eyes and ~+8% with Anchored Standard</summary>
<summary xml:lang="zh_CN">DeepSeek Harness 的原生桌面壳</summary> <summary xml:lang="zh_CN">DeepSeek 带上眼睛;内置锚定模式约 +8%</summary>
<metadata_license>MIT</metadata_license> <metadata_license>MIT</metadata_license>
<project_license>MIT</project_license> <project_license>MIT</project_license>
@@ -14,22 +14,19 @@
<description> <description>
<p> <p>
DeepSeek Harness Desktop runs the official DeepSeek Harness (dsh) WebUI Official DeepSeek Harness (dsh) WebUI in a native window. Built-in
in a native Tauri window. It starts dsh web, waits for its local URL and ModLens vision gives text-only DeepSeek eyes: paste an image and it
embeds the UI with the system WebView. Credentials, plugins and agent can read it. Built-in Anchored Standard raises DeepSeek V4 Pro about
permissions stay in ~/.dsh. 8% over official Standard on Project2 (Ability 91 to 98/99) by
</p> anchoring the first turn on Minimal tools, then unlocking the full
<p> catalog. Credentials stay in ~/.dsh.
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> </p>
</description> </description>
<launchable type="desktop-id">io.github.tommyfang.DshDesktop.desktop</launchable> <launchable type="desktop-id">io.github.tommyfang.DshDesktop.desktop</launchable>
<url type="homepage">https://github.com/TommyFang2077/dsh-desktop</url> <url type="homepage">https://github.com/TommyFang2077/dsh-easy-desktop</url>
<url type="bugtracker">https://github.com/TommyFang2077/dsh-desktop/issues</url> <url type="bugtracker">https://github.com/TommyFang2077/dsh-easy-desktop/issues</url>
<provides> <provides>
<binary>dsh-desktop</binary> <binary>dsh-desktop</binary>
@@ -44,6 +41,21 @@
<content_rating type="oars-1.1" /> <content_rating type="oars-1.1" />
<releases> <releases>
<release version="0.1.2" date="2026-08-17">
<description>
<ul>
<li>Bundled plugin market upgraded to dshmarket 1.10.1 (more resilient downloads, safer installs)</li>
<li>In-app updater enabled: the signing public key is now embedded in every build</li>
<li>Removed the duplicate 视觉模型 settings section — the modlens card under Settings → Plugins is the single config surface</li>
<li>Structured release notes for every version</li>
</ul>
</description>
</release>
<release version="0.1.1" date="2026-08-16">
<description>
<p>Signed in-app updater from the mainland mirror; HTTPS Gitea distribution; Market install warnings for GitHub-only sources.</p>
</description>
</release>
<release version="0.1.0" date="2026-08-15"> <release version="0.1.0" date="2026-08-15">
<description> <description>
<p>Tauri shell with an Apple-style title bar, bundled dsh, and on-launch updates.</p> <p>Tauri shell with an Apple-style title bar, bundled dsh, and on-launch updates.</p>

51
docs/agents/domain.md Normal file
View File

@@ -0,0 +1,51 @@
# Domain Docs
How the engineering skills should consume this repo's domain documentation when exploring the codebase.
## Before exploring, read these
- **`CONTEXT.md`** at the repo root, or
- **`CONTEXT-MAP.md`** at the repo root if it exists — it points at one `CONTEXT.md` per context. Read each one relevant to the topic.
- **`docs/adr/`** — read ADRs that touch the area you're about to work in. In multi-context repos, also check `src/<context>/docs/adr/` for context-scoped decisions.
If any of these files don't exist, **proceed silently**. Don't flag their absence; don't suggest creating them upfront. The `/domain-modeling` skill (reached via `/grill-with-docs` and `/improve-codebase-architecture`) creates them lazily when terms or decisions actually get resolved.
## File structure
Single-context repo (most repos):
```
/
├── CONTEXT.md
├── docs/adr/
│ ├── 0001-event-sourced-orders.md
│ └── 0002-postgres-for-write-model.md
└── src/
```
Multi-context repo (presence of `CONTEXT-MAP.md` at the root):
```
/
├── CONTEXT-MAP.md
├── docs/adr/ ← system-wide decisions
└── src/
├── ordering/
│ ├── CONTEXT.md
│ └── docs/adr/ ← context-specific decisions
└── billing/
├── CONTEXT.md
└── docs/adr/
```
## Use the glossary's vocabulary
When your output names a domain concept (in an issue title, a refactor proposal, a hypothesis, a test name), use the term as defined in `CONTEXT.md`. Don't drift to synonyms the glossary explicitly avoids.
If the concept you need isn't in the glossary yet, that's a signal — either you're inventing language the project doesn't use (reconsider) or there's a real gap (note it for `/domain-modeling`).
## Flag ADR conflicts
If your output contradicts an existing ADR, surface it explicitly rather than silently overriding:
> _Contradicts ADR-0007 (event-sourced orders) — but worth reopening because…_

View File

@@ -0,0 +1,45 @@
# Issue tracker: GitHub
Issues and specs for this repo live as GitHub issues. Use the `gh` CLI for all operations.
## Conventions
- **Create an issue**: `gh issue create --title "..." --body "..."`. Use a heredoc for multi-line bodies.
- **Read an issue**: `gh issue view <number> --comments`, filtering comments by `jq` and also fetching labels.
- **List issues**: `gh issue list --state open --json number,title,body,labels,comments --jq '[.[] | {number, title, body, labels: [.labels[].name], comments: [.comments[].body]}]'` with appropriate `--label` and `--state` filters.
- **Comment on an issue**: `gh issue comment <number> --body "..."`
- **Apply / remove labels**: `gh issue edit <number> --add-label "..."` / `--remove-label "..."`
- **Close**: `gh issue close <number> --comment "..."`
Infer the repo from `git remote -v``gh` does this automatically when run inside a clone.
## Pull requests as a triage surface
**PRs as a request surface: no.** _(Set to `yes` if this repo treats external PRs as feature requests; `/triage` reads this flag.)_
When set to `yes`, PRs run through the same labels and states as issues, using the `gh pr` equivalents:
- **Read a PR**: `gh pr view <number> --comments` and `gh pr diff <number>` for the diff.
- **List external PRs for triage**: `gh pr list --state open --json number,title,body,labels,author,authorAssociation,comments` then keep only `authorAssociation` of `CONTRIBUTOR`, `FIRST_TIME_CONTRIBUTOR`, or `NONE` (drop `OWNER`/`MEMBER`/`COLLABORATOR`).
- **Comment / label / close**: `gh pr comment`, `gh pr edit --add-label`/`--remove-label`, `gh pr close`.
GitHub shares one number space across issues and PRs, so a bare `#42` may be either — resolve with `gh pr view 42` and fall back to `gh issue view 42`.
## When a skill says "publish to the issue tracker"
Create a GitHub issue.
## When a skill says "fetch the relevant ticket"
Run `gh issue view <number> --comments`.
## Wayfinding operations
Used by `/wayfinder`. The **map** is a single issue with **child** issues as tickets.
- **Map**: a single issue labelled `wayfinder:map`, holding the Notes / Decisions-so-far / Fog body. `gh issue create --label wayfinder:map`.
- **Child ticket**: an issue linked to the map as a GitHub sub-issue (`gh api` on the sub-issues endpoint). Where sub-issues aren't enabled, add the child to a task list in the map body and put `Part of #<map>` at the top of the child body. Labels: `wayfinder:<type>` (`research`/`prototype`/`grilling`/`task`). Once claimed, the ticket is assigned to the driving dev.
- **Blocking**: GitHub's **native issue dependencies** — the canonical, UI-visible representation. Add an edge with `gh api --method POST repos/<owner>/<repo>/issues/<child>/dependencies/blocked_by -F issue_id=<blocker-db-id>`, where `<blocker-db-id>` is the blocker's numeric **database id** (`gh api repos/<owner>/<repo>/issues/<n> --jq .id`, _not_ the `#number` or `node_id`). GitHub reports `issue_dependencies_summary.blocked_by` (open blockers only — the live gate). Where dependencies aren't available, fall back to a `Blocked by: #<n>, #<n>` line at the top of the child body. A ticket is unblocked when every blocker is closed.
- **Frontier query**: list the map's open children (`gh issue list --state open`, scoped to the map's sub-issues / task list), drop any with an open blocker (`issue_dependencies_summary.blocked_by > 0`, or an open issue in the `Blocked by` line) or an assignee; first in map order wins.
- **Claim**: `gh issue edit <n> --add-assignee @me` — the session's first write.
- **Resolve**: `gh issue comment <n> --body "<answer>"`, then `gh issue close <n>`, then append a context pointer (gist + link) to the map's Decisions-so-far.

View File

@@ -0,0 +1,15 @@
# Triage Labels
The skills speak in terms of five canonical triage roles. This file maps those roles to the actual label strings used in this repo's issue tracker.
| Label in mattpocock/skills | Label in our tracker | Meaning |
| -------------------------- | -------------------- | ---------------------------------------- |
| `needs-triage` | `needs-triage` | Maintainer needs to evaluate this issue |
| `needs-info` | `needs-info` | Waiting on reporter for more information |
| `ready-for-agent` | `ready-for-agent` | Fully specified, ready for an AFK agent |
| `ready-for-human` | `ready-for-human` | Requires human implementation |
| `wontfix` | `wontfix` | Will not be actioned |
When a skill mentions a role (e.g. "apply the AFK-ready triage label"), use the corresponding label string from this table.
Edit the right-hand column to match whatever vocabulary you actually use.

View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 fkysly and dsh-market contributors
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,21 @@
MIT License
Copyright (c) 2025 FunASR
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,19 @@
# DeepSeek Harness Desktop v0.1.1
**摘要 / Summary:** 大陆可直达的发行链路上线Gitea 镜像安装包 + 应用内签名更新HTTPS 分发、安装前校验 Tauri 签名)。
## 新功能 / Features
- 大陆发行镜像:安装包与本应用更新走 Gitea HTTPS无需额外网络工具即可下载
- 应用内更新:发现新版本后一键「下载并安装」,安装包下载完成先校验 Tauri 签名再安装
- Linux 剪贴板图片原生读取:直接粘贴截图即作为图片进输入框(此前部分桌面环境取不到)
## 修复 / Fixes
- Gitea 发布经过慢速链路时自动重试,不再因网关超时半途失败
- 壳更新公钥内嵌进打包配置,签名校验始终可用
- 仓库改名后dsh-easy-desktop的镜像与文档地址保持一致
## 安装与更新 / Install & Update
- 安装包Windows NSIS/MSI、macOS DMGApple Silicon 与 Intel、Linux deb/rpm、Flatpak
- 除 Flatpak 外需本机已安装 `dsh``npm i -g @deepseek-ai/dsh`),或设置 `DSH_DESKTOP_DSH_BIN`
- macOS 包未公证:首次打开需在「系统设置 → 隐私与安全性」中允许
- 更新提示出现在启动页:点击「下载并安装」即可(走 Gitea 镜像,签名校验后生效)

View File

@@ -0,0 +1,26 @@
# DeepSeek Harness Desktop v0.1.2
**摘要 / Summary:** 内置插件市场升级至 dshmarket 1.10.1(下载更稳、安装更安全);应用内更新真正启用(签名公钥内嵌);移除重复的视觉模型设置区;发布说明改为结构化更新日志。
## 新功能 / Features
- 内置插件市场 dshmarket 1.9.0 → 1.10.1
- pnpm 拉取超时自动加重试,弱网下安装/更新不再一次失败就放弃
- 失败的安装会回收遗留的本地缓存目录,不会越积越多
- 重复安装防护:同一插件(含别名条目)只保留一个加载项,杜绝下次启动冲突
- 每个版本提供结构化更新日志(新功能 / 修复 / 变更 / 安装GitHub 与 Gitea 的发布文案、应用内更新面板自动取同一份
## 修复 / Fixes
- **应用内更新此前在所有构建中都静默关闭**:壳更新器要求编译期内嵌签名公钥,而构建流程从未注入(`public_key() → None → 不检查更新`。现在本地、CI、Flatpak 构建都会从 `tauri.conf.json` 读取公钥注入,「发现壳更新 → 下载并安装」真正可用
- 消除市场「有新版本」横幅与桌面内嵌市场版本不一致的死结:内嵌市场已同步到上游最新,横幅不再出现;此前该更新提示在桌面版中无法真正落地(每次启动会被内嵌版本还原)
- 插件安装遇到 pnpm 默认拦截构建脚本(如 node-pty放行与重试路径按上游最新逻辑工作不再被 allowBuilds 占位符卡死
- Gitea 镜像发布链路重构:由自托管 Actions runner 经局域网直连发布,慢速公网链路超时影响正式发布流程
## 变更 / Changes
- 移除独立的「视觉模型」设置区:视觉引擎配置统一由 modlens 自带的「设置 → 插件 → 插件配置 → 视觉引擎ModLens」提供同样读写 `~/.modlens/config.json`,不再有两处重复入口)
- 构建产物不再附带 `plugins/dsh-desktop-vision`license 列表与 README 同步更新)
## 安装与更新 / Install & Update
- 安装包Windows NSIS/MSI、macOS DMGApple Silicon 与 Intel、Linux deb/rpm、Flatpak
- 除 Flatpak 外需本机已安装 `dsh``npm i -g @deepseek-ai/dsh`),或设置 `DSH_DESKTOP_DSH_BIN`
- macOS 包未公证:首次打开需在「系统设置 → 隐私与安全性」中允许
- 更新提示出现在启动页:点击「下载并安装」即可(走 Gitea 镜像,签名校验后生效)

Binary file not shown.

After

Width:  |  Height:  |  Size: 253 KiB

BIN
docs/screenshots/banner.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 41 KiB

View File

@@ -0,0 +1,188 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<title>Banner</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: 1920px; height: 960px; overflow: hidden;
background: #07080c; color: #f5f5f7;
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "PingFang SC", "Noto Sans SC", system-ui, sans-serif;
-webkit-font-smoothing: antialiased;
}
body {
background:
radial-gradient(ellipse 70% 55% at 50% 18%, rgba(0, 113, 227, 0.28), transparent 62%),
radial-gradient(ellipse 50% 40% at 78% 88%, rgba(29, 78, 216, 0.16), transparent 70%),
#07080c;
}
.wordmark {
position: absolute; top: 32px; left: 0; right: 0;
display: flex; flex-direction: column; align-items: center; gap: 10px;
z-index: 2;
}
.wordmark .row {
display: flex; align-items: center; gap: 14px;
}
.wordmark img {
width: 44px; height: 44px; border-radius: 11px;
box-shadow: 0 0 0 1px rgba(255,255,255,0.08);
}
.wordmark h1 {
margin: 0; font-size: 34px; font-weight: 650; letter-spacing: -0.04em;
}
.wordmark p {
margin: 0; color: rgba(245,245,247,0.72); font-size: 16px; letter-spacing: -0.01em;
}
.stage {
position: absolute; left: 0; right: 0; top: 124px;
display: flex; justify-content: center;
}
.app {
width: 1280px; height: 800px;
transform: scale(1.06);
transform-origin: top center;
border-radius: 14px;
overflow: hidden;
background: var(--bg);
color: var(--text);
box-shadow:
0 0 0 1px rgba(255,255,255,0.08),
0 28px 80px rgba(0,0,0,0.55);
}
.app #dsh-desktop-titlebar { position: absolute; }
.shell {
display: grid;
grid-template-columns: 248px 1fr;
height: calc(800px - 36px);
margin-top: 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; color: var(--text); }
.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>
<div class="wordmark">
<div class="row">
<img src="../../../ui/icon.png" alt="" />
<h1>DeepSeek Harness Desktop</h1>
</div>
<p>官方 dsh 原生壳 · 给 DeepSeek 带上眼睛 · 锚定模式约 +8%</p>
</div>
<div class="stage">
<div class="app">
<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>
</div>
</div>
</body>
</html>

View File

@@ -82,6 +82,11 @@
.chip { .chip {
border: 0.5px solid var(--line); border-radius: 999px; padding: 4px 10px; background: #fff; border: 0.5px solid var(--line); border-radius: 999px; padding: 4px 10px; background: #fff;
} }
.send-cluster { display: flex; align-items: center; gap: 8px; }
.mic {
width: 28px; height: 28px; border-radius: 8px;
display: grid; place-items: center; font-size: 14px; color: var(--muted);
}
.send { .send {
width: 28px; height: 28px; border-radius: 50%; background: var(--accent); color: #fff; width: 28px; height: 28px; border-radius: 50%; background: var(--accent); color: #fff;
display: grid; place-items: center; font-size: 14px; display: grid; place-items: center; font-size: 14px;
@@ -126,9 +131,12 @@
<span class="chip">锚定式标准(实验)</span> <span class="chip">锚定式标准(实验)</span>
<span class="chip">工作区</span> <span class="chip">工作区</span>
</div> </div>
<div class="send-cluster">
<div class="mic" title="语音输入">🎤</div>
<div class="send"></div> <div class="send"></div>
</div> </div>
</div> </div>
</div>
</section> </section>
</div> </div>
</body> </body>

View File

@@ -2,7 +2,7 @@
<html lang="zh-CN" class="dsh-desktop-offset"> <html lang="zh-CN" class="dsh-desktop-offset">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<title>视觉模型</title> <title>视觉引擎ModLens</title>
<link rel="stylesheet" href="chrome.css" /> <link rel="stylesheet" href="chrome.css" />
<style> <style>
:root { :root {
@@ -74,15 +74,15 @@
<h2>设置</h2> <h2>设置</h2>
<a class="muted" href="#">通用</a> <a class="muted" href="#">通用</a>
<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> <a class="active" href="#">插件</a>
<a class="muted" href="#">技能</a> <a class="muted" href="#">技能</a>
</nav> </nav>
<section class="content"> <section class="content">
<h1>视觉模型</h1> <h1>插件 · 插件配置</h1>
<p class="lead">内置插件 dsh-desktop-vision · 写入 ~/.modlens/config.json</p> <p class="lead">视觉引擎ModLens由 modlens 插件提供 · 写入 ~/.modlens/config.json</p>
<div class="dshdv"> <div class="dshdv">
<h3>ModLens 视觉模型</h3> <h3>视觉引擎(ModLens</h3>
<p>纯文本对话模型读图时使用这里的引擎。已声明视觉能力的模型(如 Qwen不会走这条桥。</p> <p>纯文本对话模型读图时使用这里的引擎。已声明视觉能力的模型(如 Qwen不会走这条桥。</p>
<label> <label>
引擎 引擎

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

BIN
docs/screenshots/voice.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -1,6 +1,6 @@
app-id: io.github.tommyfang.DshDesktop app-id: io.github.tommyfang.DshDesktop
runtime: org.gnome.Platform runtime: org.gnome.Platform
runtime-version: '47' runtime-version: '50'
sdk: org.gnome.Sdk sdk: org.gnome.Sdk
sdk-extensions: sdk-extensions:
- org.freedesktop.Sdk.Extension.node24 - org.freedesktop.Sdk.Extension.node24
@@ -15,6 +15,8 @@ finish-args:
- --share=network - --share=network
- --filesystem=host - --filesystem=host
- --filesystem=xdg-download - --filesystem=xdg-download
- --socket=pulseaudio
- --filesystem=xdg-run/pipewire-0:ro
- --talk-name=org.freedesktop.Notifications - --talk-name=org.freedesktop.Notifications
- --talk-name=org.freedesktop.portal.Desktop - --talk-name=org.freedesktop.portal.Desktop
- --talk-name=org.a11y.Bus - --talk-name=org.a11y.Bus
@@ -50,10 +52,12 @@ modules:
build-args: build-args:
- --share=network - --share=network
build-commands: build-commands:
- mkdir -p vendor/modlens vendor/dshmarket vendor/anchored-standard vendor/zero-anchored-standard
- export DSH_DESKTOP_UPDATER_PUBKEY=$(python3 -c "import json;print(json.load(open('src-tauri/tauri.conf.json'))['plugins']['updater']['pubkey'])")
- cargo build --release --locked --offline || cargo build --release - cargo build --release --locked --offline || cargo build --release
- install -Dm755 target/release/dsh-desktop ${FLATPAK_DEST}/bin/dsh-desktop - install -Dm755 target/release/dsh-desktop ${FLATPAK_DEST}/bin/dsh-desktop
- mkdir -p ${FLATPAK_DEST}/share/dsh-desktop/vision - mkdir -p ${FLATPAK_DEST}/share/dsh-desktop/voice
- cp -a plugins/dsh-desktop-vision/. ${FLATPAK_DEST}/share/dsh-desktop/vision - cp -a plugins/dsh-desktop-voice/. ${FLATPAK_DEST}/share/dsh-desktop/voice
- install -Dm644 data/applications/io.github.tommyfang.DshDesktop.desktop ${FLATPAK_DEST}/share/applications/io.github.tommyfang.DshDesktop.desktop - 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 - 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 - 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
@@ -78,6 +82,15 @@ modules:
- type: dir - type: dir
path: ../vendor/modlens path: ../vendor/modlens
- name: plugin-market
buildsystem: simple
build-commands:
- mkdir -p ${FLATPAK_DEST}/share/dsh-desktop/market
- cp -a . ${FLATPAK_DEST}/share/dsh-desktop/market
sources:
- type: dir
path: ../vendor/dshmarket
- name: anchored-standard-preset - name: anchored-standard-preset
buildsystem: simple buildsystem: simple
build-commands: build-commands:

View File

@@ -1,209 +0,0 @@
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

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

View File

@@ -1,181 +0,0 @@
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,892 @@
window.__ModuleLoader__.load({
id: 'dsh-desktop-voice',
factory: (require) => {
var module = { exports: {} }
var exports = module.exports
var React = require('react')
var jsx = require('react/jsx-runtime')
var css = [
'.dshdvoice{width:100%;max-width:640px;display:flex;flex-direction:column;gap:14px;color:var(--dsw-alias-label-primary)}',
'.dshdvoice h3{margin:0;font-size:15px;font-weight:600;line-height:22px}',
'.dshdvoice p{margin:0;color:var(--dsw-alias-label-tertiary);font-size:13px;line-height:20px}',
'.dshdvoice label{display:flex;flex-direction:column;gap:6px;font-size:12px;color:var(--dsw-alias-label-secondary)}',
'.dshdvoice .head{display:flex;align-items:center;justify-content:space-between;gap:12px}',
'.dshdvoice .row{flex-direction:row;align-items:center;gap:10px}',
'.dshdvoice .openai{display:flex;flex-direction:column;gap:14px}',
'.dshdvoice a{color:var(--dsw-alias-state-business-primary);text-decoration:none;font-size:12px;line-height:18px}',
'.dshdvoice a:hover{text-decoration:underline}',
'.dshdvoice input,.dshdvoice 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}',
'.dshdvoice input:focus,.dshdvoice select:focus{outline:none;border-color:var(--dsw-alias-state-business-primary)}',
'.dshdvoice input[type=checkbox]{width:16px;height:16px;padding:0}',
'.dshdvoice 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}',
'.dshdvoice button:disabled{opacity:.55;cursor:default}',
'.dshdvoice .ok{color:var(--dsw-alias-state-success-primary)}',
'.dshdvoice .err{color:var(--dsw-alias-state-error-primary)}',
'.dshdvoice-mic{appearance:none;border:0;background:transparent;width:28px;height:28px;border-radius:8px;color:var(--dsw-alias-label-secondary);display:grid;place-items:center;cursor:pointer;padding:0}',
'.dshdvoice-mic:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}',
'.dshdvoice-mic:disabled{opacity:.4;cursor:default}',
'.dshdvoice-mic.is-live{color:var(--dsw-alias-state-error-primary)}',
'.dshdvoice-mic.is-live svg{animation:dshdvoice-pulse 1.1s ease-in-out infinite}',
'@keyframes dshdvoice-pulse{0%,100%{opacity:1}50%{opacity:.45}}',
'#dsh-desktop-dictation{position:fixed;left:50%;bottom:48px;z-index:2147483645;transform:translateX(-50%);max-width:min(36rem,calc(100vw - 3rem));display:none;align-items:center;gap:8px;padding:6px 12px;border-radius:10px;background:rgba(28,28,30,.92);color:#f5f5f7;font:13px/1.3 -apple-system,BlinkMacSystemFont,"SF Pro Text","PingFang SC",system-ui,sans-serif;box-shadow:0 8px 28px rgba(0,0,0,.28)}',
'#dsh-desktop-dictation.open{display:flex}',
'#dsh-desktop-dictation .label{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
'#dsh-desktop-dictation .stop{appearance:none;border:0;background:rgba(255,255,255,.12);color:#f5f5f7;width:22px;height:22px;border-radius:6px;display:grid;place-items:center;cursor:pointer;padding:0}',
'#dsh-desktop-dictation .kbd{opacity:.55;font-size:11px;letter-spacing:.02em;flex:none}',
'#dsh-desktop-dictation .progress{width:120px;height:6px;accent-color:var(--dsw-alias-state-business-primary)}',
'#dsh-desktop-dictation .percent{min-width:30px;text-align:right;font-variant-numeric:tabular-nums;font-size:11px;opacity:.75}',
].join('')
function ensureCss() {
if (typeof document === 'undefined') return
if (document.querySelector('style[data-plugin-css="dsh-desktop-voice"]')) return
var tag = document.createElement('style')
tag.dataset.pluginCss = 'dsh-desktop-voice'
tag.textContent = css
document.head.appendChild(tag)
}
function isMac() {
return /Mac|iPhone|iPad/.test(navigator.platform || '')
}
function shortcutLabel() {
return isMac() ? '⌘E' : 'Ctrl+E'
}
function isDictationHotkey(event) {
if (event.repeat || event.altKey || event.shiftKey) return false
var mod = isMac() ? event.metaKey && !event.ctrlKey : event.ctrlKey && !event.metaKey
if (!mod) return false
return event.key === 'e' || event.key === 'E' || event.code === 'KeyE'
}
var WORD_BOUNDARY = /^[\p{L}\p{N}]$/u
var CJK_BOUNDARY = /^[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]$/u
var NO_SPACE_BEFORE = /^[,.;:!?%。,、!?;:))\]}]$/u
var NO_SPACE_AFTER = /^[([{(《「『]$/u
var SPACE_AFTER = /^[,.;:!?%]$/u
function firstChar(text) {
return Array.from(String(text || '').trimStart())[0] || ''
}
function lastChar(text) {
var chars = Array.from(String(text || '').trimEnd())
return chars.length ? chars[chars.length - 1] : ''
}
function formatSegment(previous, next) {
var text = String(next || '').trim()
if (!text) return ''
if (!previous || /\s$/.test(previous) || /^\s/.test(text)) return text
var prev = lastChar(previous)
var cur = firstChar(text)
if (!prev || !cur) return text
if (CJK_BOUNDARY.test(prev) || CJK_BOUNDARY.test(cur) || NO_SPACE_BEFORE.test(cur) || NO_SPACE_AFTER.test(prev)) {
return text
}
if ((WORD_BOUNDARY.test(prev) || SPACE_AFTER.test(prev)) && WORD_BOUNDARY.test(cur)) return ' ' + text
return text
}
function remote() {
return fetch('/dsh-desktop/voice').then(function (res) {
if (!res.ok) throw new Error('load failed ' + res.status)
return res.json()
})
}
function installSenseVoice(form) {
if (form && form.modelInstalled) return Promise.resolve(form)
var size = (form && form.modelDownloadSizeMb) || 245
if (!window.confirm('SenseVoice 离线语音模型尚未安装(约 ' + size + ' MB。现在安装吗')) {
return Promise.reject(new Error('需要先安装 SenseVoice 离线语音模型'))
}
setState({ state: 'installing', partial: '', error: '', installPercent: 0, installStage: '正在准备安装…' })
var stopped = false
function pollProgress() {
return fetch('/dsh-desktop/voice/model')
.then(function (res) {
if (!res.ok) return null
return res.json()
})
.then(function (progress) {
if (!progress || stopped || session.state !== 'installing') return
setState({
installPercent: Math.max(0, Math.min(100, Number(progress.percent) || 0)),
installStage: progress.stage || '正在安装 SenseVoice…',
})
})
.catch(function () {})
}
pollProgress()
var timer = setInterval(pollProgress, 250)
if (timer && typeof timer.unref === 'function') timer.unref()
return fetch('/dsh-desktop/voice/model', { method: 'POST' }).then(function (res) {
return res.json().then(function (body) {
if (!res.ok) throw new Error(body.error || 'SenseVoice 安装失败')
setState({ installPercent: 100, installStage: 'SenseVoice 已安装' })
return body
})
})
.finally(function () {
stopped = true
clearInterval(timer)
})
}
function transcribeBlob(blob, language) {
var mime = blob.type || 'application/octet-stream'
return fetch(
'/dsh-desktop/voice/transcribe?language=' + encodeURIComponent(language || '') + '&mime=' + encodeURIComponent(mime),
{ method: 'POST', headers: { 'content-type': mime }, body: blob },
).then(function (res) {
return res.json().then(function (body) {
if (!res.ok) throw new Error(body.error || 'transcribe failed ' + res.status)
return String(body.text || '').trim()
})
})
}
function pickRecorderMime() {
if (!window.MediaRecorder) return ''
var types = ['audio/webm;codecs=opus', 'audio/webm', 'audio/mp4', 'audio/ogg;codecs=opus']
for (var i = 0; i < types.length; i++) {
try {
if (MediaRecorder.isTypeSupported(types[i])) return types[i]
} catch (err) {}
}
return ''
}
function encodeWav(chunks, sampleRate) {
var length = 0
for (var i = 0; i < chunks.length; i++) length += chunks[i].length
var samples = new Float32Array(length)
var offset = 0
for (var j = 0; j < chunks.length; j++) {
samples.set(chunks[j], offset)
offset += chunks[j].length
}
var buffer = new ArrayBuffer(44 + samples.length * 2)
var view = new DataView(buffer)
function str(at, value) {
for (var n = 0; n < value.length; n++) view.setUint8(at + n, value.charCodeAt(n))
}
str(0, 'RIFF')
view.setUint32(4, 36 + samples.length * 2, true)
str(8, 'WAVE')
str(12, 'fmt ')
view.setUint32(16, 16, true)
view.setUint16(20, 1, true)
view.setUint16(22, 1, true)
view.setUint32(24, sampleRate, true)
view.setUint32(28, sampleRate * 2, true)
view.setUint16(32, 2, true)
view.setUint16(34, 16, true)
str(36, 'data')
view.setUint32(40, samples.length * 2, true)
var idx = 44
for (var s = 0; s < samples.length; s++, idx += 2) {
var v = Math.max(-1, Math.min(1, samples[s]))
view.setInt16(idx, v < 0 ? v * 0x8000 : v * 0x7fff, true)
}
return new Blob([buffer], { type: 'audio/wav' })
}
function openMic(deviceId) {
var audio = {
channelCount: 1,
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
}
if (deviceId) audio.deviceId = { exact: deviceId }
return navigator.mediaDevices.getUserMedia({ audio: audio }).catch(function (error) {
if (!deviceId) throw error
return navigator.mediaDevices.getUserMedia({ audio: { channelCount: 1, echoCancellation: true, noiseSuppression: true, autoGainControl: true } })
})
}
var session = {
state: 'idle',
partial: '',
error: '',
stream: null,
recorder: null,
parts: [],
wav: null,
inserted: '',
target: null,
installPercent: 0,
installStage: '',
run: 0,
}
var listeners = []
var cachedForm = null
function currentForm() {
return session.liveForm || cachedForm || (session.target && session.target.form && session.target.form()) || null
}
function emit() {
for (var i = 0; i < listeners.length; i++) listeners[i]()
}
function setState(next) {
for (var key in next) session[key] = next[key]
renderIndicator()
emit()
}
function indicatorEl() {
var el = document.getElementById('dsh-desktop-dictation')
if (el) return el
el = document.createElement('div')
el.id = 'dsh-desktop-dictation'
el.innerHTML =
'<svg class="mic" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 3a3 3 0 0 0-3 3v6a3 3 0 0 0 6 0V6a3 3 0 0 0-3-3z"/><path d="M19 10v1a7 7 0 0 1-14 0v-1"/><line x1="12" y1="19" x2="12" y2="22"/></svg>' +
'<span class="label"></span>' +
'<progress class="progress" max="100" value="0" aria-label="SenseVoice 安装进度"></progress>' +
'<span class="percent"></span>' +
'<span class="kbd"></span>' +
'<button class="stop" type="button" aria-label="停止听写"><svg width="10" height="10" viewBox="0 0 10 10"><rect x="1" y="1" width="8" height="8" rx="1.5" fill="currentColor"/></svg></button>'
document.documentElement.appendChild(el)
el.querySelector('.stop').addEventListener('mousedown', function (event) {
event.preventDefault()
})
el.querySelector('.stop').addEventListener('click', function () {
stopDictation()
})
return el
}
function renderIndicator() {
ensureCss()
var el = indicatorEl()
var open =
session.state === 'installing' || session.state === 'starting' || session.state === 'listening' || session.state === 'stopping'
el.classList.toggle('open', open)
var label = el.querySelector('.label')
var progress = el.querySelector('.progress')
var percent = el.querySelector('.percent')
var kbd = el.querySelector('.kbd')
if (session.state === 'installing') label.textContent = session.installStage || '正在安装 SenseVoice…'
else if (session.state === 'starting') label.textContent = '正在启动…'
else if (session.state === 'stopping') label.textContent = '正在识别…'
else label.textContent = session.partial || '正在听…'
var installing = session.state === 'installing'
progress.hidden = !installing
progress.value = session.installPercent || 0
percent.hidden = !installing
percent.textContent = installing ? Math.round(session.installPercent || 0) + '%' : ''
kbd.textContent = shortcutLabel()
el.querySelector('.stop').hidden = session.state === 'stopping' || installing
}
function cleanupCapture() {
if (session.recorder) {
try {
if (session.recorder.state !== 'inactive') session.recorder.stop()
} catch (err) {}
session.recorder = null
}
if (session.wav) {
try {
session.wav.processor.disconnect()
session.wav.source.disconnect()
session.wav.context.close()
} catch (err) {}
session.wav = null
}
if (session.stream) {
session.stream.getTracks().forEach(function (track) {
track.stop()
})
session.stream = null
}
session.parts = []
}
function appendToComposer(text) {
var target = session.target
if (!target || !text) return
var chunk = formatSegment(session.inserted || (target.draft ? target.draft() : ''), text)
if (!chunk) return
if (target.setDraft && target.draft) {
target.setDraft((target.draft() || '') + chunk)
}
session.inserted += chunk
}
function finishError(message) {
cleanupCapture()
setState({ state: 'idle', partial: '', error: message || '' })
}
function useSenseVoice(form) {
return form.engine === 'sensevoice'
}
function startRecorder(form, runId) {
return openMic(form.microphoneDeviceId).then(function (stream) {
if (session.run !== runId) {
stream.getTracks().forEach(function (track) {
track.stop()
})
return
}
session.stream = stream
var local = useSenseVoice(form)
var mime = local ? '' : pickRecorderMime()
if (!local && (mime || window.MediaRecorder)) {
var recorder = mime ? new MediaRecorder(stream, { mimeType: mime }) : new MediaRecorder(stream)
session.parts = []
recorder.ondataavailable = function (event) {
if (event.data && event.data.size) session.parts.push(event.data)
}
session.recorder = recorder
recorder.start(250)
} else {
var context = new AudioContext()
var source = context.createMediaStreamSource(stream)
var processor = context.createScriptProcessor(4096, 1, 1)
var chunks = []
processor.onaudioprocess = function (event) {
chunks.push(new Float32Array(event.inputBuffer.getChannelData(0)))
}
source.connect(processor)
processor.connect(context.destination)
session.wav = { context: context, source: source, processor: processor, chunks: chunks, sampleRate: context.sampleRate }
}
setState({ state: 'listening', partial: '', error: '' })
})
}
function recordedBlob() {
if (session.wav) return encodeWav(session.wav.chunks, session.wav.sampleRate)
if (!session.parts.length) return null
return new Blob(session.parts, { type: session.parts[0].type || 'audio/webm' })
}
function startDictation() {
if (session.state !== 'idle') return
var target = session.target
if (!target) {
setState({ error: '对话框还没就绪' })
return
}
var runId = session.run + 1
session.run = runId
session.inserted = target.draft ? target.draft() : ''
setState({ state: 'starting', partial: '', error: '' })
remote()
.catch(function () {
return target.form ? target.form() : null
})
.then(function (form) {
if (session.run !== runId) return
if (!form || form.enabled === false) throw new Error('语音输入已关闭,请到设置 → 语音输入开启')
session.liveForm = form
cachedForm = form
if (useSenseVoice(form)) {
return installSenseVoice(form).then(function (installed) {
if (session.run !== runId) return
session.liveForm = installed
cachedForm = installed
return startRecorder(installed, runId)
})
}
if (!form.apiKey) throw new Error('请先在设置 → 语音输入里填写 OpenAI API 密钥')
return startRecorder(form, runId)
})
.catch(function (error) {
if (session.run !== runId) return
var message = String(error && error.message ? error.message : error)
if (/NotAllowed|Permission/i.test(message)) message = '麦克风权限被拒绝,请在系统设置中允许后重试'
finishError(message)
})
}
function stopDictation() {
if (session.state === 'idle' || session.state === 'stopping' || session.state === 'installing') return
var runId = session.run
var form = currentForm()
setState({ state: 'stopping' })
var recorder = session.recorder
function afterBlob(blob) {
cleanupCapture()
if (session.run !== runId) return
if (!blob || !blob.size) {
setState({ state: 'idle', partial: '', error: '没有识别到语音' })
return
}
transcribeBlob(blob, form && form.language)
.then(function (text) {
if (session.run !== runId) return
if (text) appendToComposer(text)
else setState({ error: '没有识别到语音' })
setState({ state: 'idle', partial: '' })
})
.catch(function (error) {
if (session.run !== runId) return
finishError(String(error && error.message ? error.message : error))
})
}
if (recorder && recorder.state !== 'inactive') {
recorder.onstop = function () {
afterBlob(recordedBlob())
}
try {
recorder.stop()
} catch (err) {
afterBlob(recordedBlob())
}
return
}
afterBlob(recordedBlob())
}
function toggleDictation() {
if (session.state === 'installing') return
if (session.state === 'listening' || session.state === 'starting') stopDictation()
else startDictation()
}
if (typeof window !== 'undefined' && !window.__dshDesktopVoiceKeys) {
window.__dshDesktopVoiceKeys = true
window.addEventListener(
'keydown',
function (event) {
if (!isDictationHotkey(event)) return
var form = currentForm()
var mode = form && form.dictationMode === 'hold' ? 'hold' : 'toggle'
event.preventDefault()
event.stopPropagation()
if (mode === 'hold') {
if (session.state === 'idle') startDictation()
return
}
toggleDictation()
},
true,
)
window.addEventListener(
'keyup',
function (event) {
var form = currentForm()
if (!form || form.dictationMode !== 'hold') return
var key = event.key === 'e' || event.key === 'E' || event.code === 'KeyE'
var mod = event.key === 'Control' || event.key === 'Meta' || event.code.indexOf('Control') === 0 || event.code.indexOf('Meta') === 0
if (!key && !mod) return
event.preventDefault()
stopDictation()
},
true,
)
}
function MicIcon() {
return jsx.jsxs('svg', {
width: '16',
height: '16',
viewBox: '0 0 24 24',
fill: 'none',
stroke: 'currentColor',
strokeWidth: '2',
strokeLinecap: 'round',
strokeLinejoin: 'round',
'aria-hidden': true,
children: [
jsx.jsx('path', { d: 'M12 3a3 3 0 0 0-3 3v6a3 3 0 0 0 6 0V6a3 3 0 0 0-3-3z' }),
jsx.jsx('path', { d: 'M19 10v1a7 7 0 0 1-14 0v-1' }),
jsx.jsx('line', { x1: '12', y1: '19', x2: '12', y2: '22' }),
],
})
}
function SquareIcon() {
return jsx.jsx('svg', {
width: '12',
height: '12',
viewBox: '0 0 12 12',
'aria-hidden': true,
children: jsx.jsx('rect', { x: '2', y: '2', width: '8', height: '8', rx: '1.5', fill: 'currentColor' }),
})
}
function MicButton(props) {
ensureCss()
var formState = React.useState(null)
var form = formState[0]
var setForm = formState[1]
var tickState = React.useState(0)
var setTick = tickState[1]
var draft = props.useInput ? props.useInput(function (s) { return s.draft }) : ''
var draftRef = React.useRef(draft)
draftRef.current = draft
var formRef = React.useRef(form)
formRef.current = form
React.useEffect(function () {
var live = true
remote()
.then(function (next) {
if (live) {
cachedForm = next
setForm(next)
}
})
.catch(function () {
if (live) setForm({ enabled: true, engine: 'sensevoice', dictationMode: 'toggle', language: 'zh', modelInstalled: false })
})
return function () {
live = false
}
}, [])
React.useEffect(function () {
function onChange() {
setTick(function (n) { return n + 1 })
}
listeners.push(onChange)
return function () {
listeners = listeners.filter(function (fn) { return fn !== onChange })
}
}, [])
React.useEffect(function () {
session.target = {
setDraft: props.inputActions && props.inputActions.setDraft,
draft: function () {
return draftRef.current || ''
},
form: function () {
return formRef.current
},
}
})
var installing = session.state === 'installing'
var live = installing || session.state === 'starting' || session.state === 'listening' || session.state === 'stopping'
var title = installing
? '正在安装 SenseVoice…'
: live
? '停止听写(' + shortcutLabel() + ''
: '语音输入(' + shortcutLabel() + ''
return jsx.jsx('button', {
type: 'button',
className: 'dshdvoice-mic' + (session.state === 'listening' ? ' is-live' : ''),
'aria-label': title,
title: session.error ? session.error : title,
disabled: session.state === 'stopping' || installing,
onMouseDown: function (event) {
event.preventDefault()
},
onClick: function () {
toggleDictation()
},
children: live && !installing && session.state !== 'starting' ? jsx.jsx(SquareIcon, {}) : jsx.jsx(MicIcon, {}),
})
}
function VoiceSettings() {
ensureCss()
var state = React.useState({ status: 'loading' })
var snap = state[0]
var setSnap = state[1]
var devicesState = React.useState([])
var devices = devicesState[0]
var setDevices = devicesState[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 save() {
if (snap.status !== 'ready' || snap.saving) return
setSnap(Object.assign({}, snap, { saving: true, message: '' }))
fetch('/dsh-desktop/voice', {
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) {
cachedForm = form
setSnap({ status: 'ready', form: form, message: '已保存', saving: false })
})
.catch(function (error) {
setSnap(Object.assign({}, snap, { saving: false, message: String(error.message || error) }))
})
}
function listMics() {
if (!navigator.mediaDevices || !navigator.mediaDevices.enumerateDevices) return
navigator.mediaDevices
.getUserMedia({ audio: true })
.then(function (stream) {
stream.getTracks().forEach(function (track) { track.stop() })
return navigator.mediaDevices.enumerateDevices()
})
.then(function (list) {
setDevices(
list
.filter(function (item) { return item.kind === 'audioinput' })
.map(function (item, index) {
return { id: item.deviceId, label: item.label || '麦克风 ' + (index + 1) }
}),
)
})
.catch(function () {})
}
if (snap.status === 'loading') {
return jsx.jsx('div', { className: 'dshdvoice', children: jsx.jsx('p', { children: '正在读取语音输入配置…' }) })
}
if (snap.status === 'error') {
return jsx.jsx('div', { className: 'dshdvoice', children: jsx.jsx('p', { className: 'err', children: snap.message }) })
}
var form = snap.form
var options = form.options || {}
var engines = options.engines || []
var modes = options.modes || []
var languages = options.languages || []
var openai = form.engine === 'openai'
var local = form.engine === 'sensevoice'
return jsx.jsxs('div', {
className: 'dshdvoice',
children: [
jsx.jsx('h3', { children: '语音输入' }),
jsx.jsx('p', {
children:
'对着麦克风说话,文字写进对话框。快捷键 ' +
shortcutLabel() +
',和 Orca 一样点按开关或按住说话。默认使用本机 SenseVoice模型首次点击麦克风时按需安装不包含在安装包内。',
}),
jsx.jsxs('label', {
className: 'row',
children: [
jsx.jsx('input', {
type: 'checkbox',
checked: form.enabled !== false,
onChange: function (event) {
patch('enabled', event.target.checked)
},
}),
'启用语音输入',
],
}),
jsx.jsxs('label', {
children: [
'听写方式',
jsx.jsx('select', {
value: form.dictationMode || 'toggle',
onChange: function (event) {
patch('dictationMode', event.target.value)
},
children: modes.map(function (item) {
return jsx.jsx('option', { value: item.id, children: item.label }, item.id)
}),
}),
],
}),
jsx.jsxs('label', {
children: [
'引擎',
jsx.jsx('select', {
value: form.engine || 'sensevoice',
onChange: function (event) {
patch('engine', event.target.value)
},
children: engines.map(function (item) {
return jsx.jsx('option', { value: item.id, children: item.label }, item.id)
}),
}),
],
}),
local
? jsx.jsx('p', {
className: form.modelInstalled ? 'ok' : '',
children: form.modelInstalled
? 'SenseVoice 离线模型已安装。'
: 'SenseVoice 离线模型未安装;点击对话框麦克风后会提示安装(约 ' +
(form.modelDownloadSizeMb || 245) +
' MB。',
})
: null,
jsx.jsxs('label', {
children: [
'语言',
jsx.jsx('select', {
value: form.language || '',
onChange: function (event) {
patch('language', event.target.value)
},
children: languages.map(function (item) {
return jsx.jsx('option', { value: item.id, children: item.label }, item.id)
}),
}),
],
}),
openai
? jsx.jsxs('div', {
className: 'openai',
children: [
jsx.jsxs('label', {
children: [
jsx.jsxs('span', {
className: 'head',
children: [
'接口地址',
jsx.jsx('a', { href: 'https://console.groq.com/keys', children: 'Groq 密钥' }),
],
}),
jsx.jsx('input', {
value: form.baseUrl || '',
placeholder: 'https://api.openai.com/v1',
onChange: function (event) {
patch('baseUrl', event.target.value)
},
}),
],
}),
jsx.jsxs('label', {
children: [
jsx.jsxs('span', {
className: 'head',
children: [
'API 密钥',
jsx.jsx('a', {
href: 'https://platform.openai.com/api-keys',
children: '获取 API',
}),
],
}),
jsx.jsx('input', {
type: 'password',
value: form.apiKey || '',
onChange: function (event) {
patch('apiKey', event.target.value)
},
}),
],
}),
jsx.jsxs('label', {
children: [
'Whisper 模型',
jsx.jsx('input', {
value: form.model || '',
placeholder: '例如 whisper-1 或 whisper-large-v3',
onChange: function (event) {
patch('model', event.target.value)
},
}),
],
}),
],
})
: null,
jsx.jsxs('label', {
children: [
jsx.jsxs('span', {
className: 'head',
children: [
'麦克风',
jsx.jsx('a', {
href: '#',
onClick: function (event) {
event.preventDefault()
listMics()
},
children: '列出设备',
}),
],
}),
jsx.jsxs('select', {
value: form.microphoneDeviceId || '',
onChange: function (event) {
var id = event.target.value
var match = devices.filter(function (item) { return item.id === id })[0]
patch('microphoneDeviceId', id)
patch('microphoneDeviceLabel', match ? match.label : '')
},
children: [jsx.jsx('option', { value: '', children: '系统默认' })].concat(
devices.map(function (item) {
return jsx.jsx('option', { value: item.id, children: item.label }, item.id)
}),
),
}),
],
}),
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: 'dsh-desktop-voice',
order: 13,
label: '语音输入',
},
VoiceSettings,
)
})
ctx.slots.inject('conversation.input.right', function () {
return ctx.slots.register(
{
name: 'conversation.input.right',
id: 'dsh-desktop-voice',
order: 20,
label: '语音输入',
},
MicButton,
)
})
}
exports.apply = apply
exports.inject = ['slots']
return module.exports
},
})

View File

@@ -0,0 +1,4 @@
# Settings page + composer mic + host route for desktop dictation.
- insert:
- id: dsh-desktop-voice
name: dsh-desktop-voice

View File

@@ -0,0 +1,576 @@
import { createHash, randomUUID } from 'node:crypto'
import { execFile } from 'node:child_process'
import {
chmodSync,
createReadStream,
createWriteStream,
existsSync,
mkdirSync,
readFileSync,
renameSync,
rmSync,
statSync,
writeFileSync,
} from 'node:fs'
import { createRequire } from 'node:module'
import { homedir, tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { Transform, Readable } from 'node:stream'
import { pipeline } from 'node:stream/promises'
import { promisify } from 'node:util'
export const name = 'dsh-desktop-voice'
export const inject = []
const execFileAsync = promisify(execFile)
const SHERPA_VERSION = '1.13.5'
const SENSEVOICE_VERSION = '2365baeacb507f821a0c8120fcee3d484dba7a07'
const SENSEVOICE_MODEL_BYTES = 239233841
const SENSEVOICE_TOKENS_BYTES = 315894
const SENSEVOICE_FILES = [
{
name: 'model.int8.onnx',
bytes: SENSEVOICE_MODEL_BYTES,
sha256: 'c71f0ce00bec95b07744e116345e33d8cbbe08cef896382cf907bf4b51a2cd51',
},
{
name: 'tokens.txt',
bytes: SENSEVOICE_TOKENS_BYTES,
sha256: 'f449eb28dc567533d7fa59be34e2abca8784f771850c78a47fb731a31429a1dc',
},
]
const SENSEVOICE_BASE_URL =
'https://huggingface.co/csukuangfj/sherpa-onnx-sense-voice-zh-en-ja-ko-yue-2024-07-17/resolve/' +
SENSEVOICE_VERSION
const SENSEVOICE_DOWNLOAD_BYTES = SENSEVOICE_FILES.reduce((sum, file) => sum + file.bytes, 0)
const DOWNLOAD_PROGRESS_LIMIT = 95
let installPromise = null
let installState = { status: 'missing', percent: 0, stage: '' }
let sherpa = null
const recognizers = new Map()
let recognitionQueue = Promise.resolve()
const ENGINES = [
{ id: 'sensevoice', label: 'SenseVoice本机离线推荐' },
{ id: 'openai', label: 'OpenAI 兼容接口' },
]
const ENGINE_IDS = new Set(ENGINES.map((item) => item.id))
const MODES = [
{ id: 'toggle', label: '点按开关(再按一次快捷键结束)' },
{ id: 'hold', label: '按住说话(松开快捷键结束)' },
]
const MODE_IDS = new Set(MODES.map((item) => item.id))
const LANGUAGES = [
{ id: '', label: '自动检测' },
{ id: 'zh', label: '中文' },
{ id: 'yue', label: '粤语' },
{ id: 'en', label: 'English' },
{ id: 'ja', label: '日本語' },
{ id: 'ko', label: '한국어' },
]
const LANGUAGE_IDS = new Set(LANGUAGES.map((item) => item.id))
function configDir() {
if (process.env.XDG_CONFIG_HOME) return join(process.env.XDG_CONFIG_HOME, 'dsh-desktop')
return join(homedir(), '.config', 'dsh-desktop')
}
function configPath() {
return join(configDir(), 'voice.json')
}
function voiceDataDir() {
if (process.env.DSH_DESKTOP_VOICE_HOME) return process.env.DSH_DESKTOP_VOICE_HOME
if (process.env.XDG_CACHE_HOME) return join(process.env.XDG_CACHE_HOME, 'dsh-desktop', 'voice')
if (process.platform === 'win32' && process.env.LOCALAPPDATA) {
return join(process.env.LOCALAPPDATA, 'dsh-desktop', 'Cache', 'voice')
}
if (process.platform === 'darwin') return join(homedir(), 'Library', 'Caches', 'dsh-desktop', 'voice')
return join(homedir(), '.cache', 'dsh-desktop', 'voice')
}
function senseVoiceDir() {
return join(voiceDataDir(), 'sensevoice')
}
function runtimeDir() {
return join(voiceDataDir(), 'runtime')
}
function markerPath() {
return join(senseVoiceDir(), 'installed.json')
}
function senseVoiceInstalled() {
try {
const marker = JSON.parse(readFileSync(markerPath(), 'utf8'))
const runtime = JSON.parse(readFileSync(join(runtimeDir(), 'node_modules', 'sherpa-onnx', 'package.json'), 'utf8'))
return (
marker.modelVersion === SENSEVOICE_VERSION &&
marker.runtimeVersion === SHERPA_VERSION &&
runtime.version === SHERPA_VERSION &&
SENSEVOICE_FILES.every((file) => statSync(join(senseVoiceDir(), file.name)).size === file.bytes)
)
} catch {
return false
}
}
function senseVoiceProgress() {
if (senseVoiceInstalled()) return { status: 'installed', percent: 100, stage: 'SenseVoice 已安装' }
if (installPromise) return { ...installState }
if (installState.status === 'failed') return { ...installState }
return { status: 'missing', percent: 0, stage: '' }
}
function senseVoiceStatus() {
return senseVoiceProgress().status
}
function defaults() {
return {
enabled: true,
engine: 'sensevoice',
dictationMode: 'toggle',
language: 'zh',
apiKey: '',
baseUrl: 'https://api.openai.com/v1',
model: 'whisper-1',
microphoneDeviceId: '',
microphoneDeviceLabel: '',
}
}
function loadConfig() {
try {
const data = JSON.parse(readFileSync(configPath(), 'utf8'))
return data && typeof data === 'object' ? data : {}
} catch {
return {}
}
}
function formOf(cfg) {
const base = defaults()
const engine = ENGINE_IDS.has(cfg.engine) ? cfg.engine : base.engine
const dictationMode = MODE_IDS.has(cfg.dictationMode) ? cfg.dictationMode : base.dictationMode
const language = LANGUAGE_IDS.has(String(cfg.language ?? base.language))
? String(cfg.language ?? base.language)
: base.language
return {
enabled: cfg.enabled !== false,
engine,
dictationMode,
language,
apiKey: String(cfg.apiKey || ''),
baseUrl: String(cfg.baseUrl || base.baseUrl),
model: String(cfg.model || base.model),
microphoneDeviceId: String(cfg.microphoneDeviceId || ''),
microphoneDeviceLabel: String(cfg.microphoneDeviceLabel || ''),
modelInstalled: senseVoiceInstalled(),
modelStatus: senseVoiceStatus(),
modelDownloadSizeMb: 245,
modelProgress: senseVoiceProgress(),
options: {
engines: ENGINES,
modes: MODES,
languages: LANGUAGES,
},
}
}
function saveForm(values) {
const next = formOf({ ...loadConfig(), ...values })
const path = configPath()
mkdirSync(dirname(path), { recursive: true })
writeFileSync(
path,
`${JSON.stringify(
{
enabled: next.enabled,
engine: next.engine,
dictationMode: next.dictationMode,
language: next.language,
apiKey: next.apiKey,
baseUrl: next.baseUrl,
model: next.model,
microphoneDeviceId: next.microphoneDeviceId,
microphoneDeviceLabel: next.microphoneDeviceLabel,
},
null,
2,
)}\n`,
{ encoding: 'utf8' },
)
try {
chmodSync(path, 0o600)
} catch {
// best-effort; some filesystems ignore mode
}
return formOf(loadConfig())
}
function whisperLanguage(code) {
const raw = String(code || '').trim().toLowerCase()
if (!raw || raw === 'auto') return ''
return raw.split(/[-_]/, 1)[0]
}
function transcriptionUrl(baseUrl) {
const trimmed = String(baseUrl || '').trim().replace(/\/+$/, '')
if (!trimmed) return 'https://api.openai.com/v1/audio/transcriptions'
if (/\/audio\/transcriptions$/i.test(trimmed)) return trimmed
return `${trimmed}/audio/transcriptions`
}
function filenameFor(mime) {
const type = String(mime || '').toLowerCase()
if (type.includes('wav')) return 'audio.wav'
if (type.includes('mpeg') || type.includes('mp3')) return 'audio.mp3'
if (type.includes('mp4') || type.includes('m4a')) return 'audio.m4a'
if (type.includes('ogg')) return 'audio.ogg'
return 'audio.webm'
}
async function readBody(req) {
const chunks = []
for await (const chunk of req) chunks.push(chunk)
return Buffer.concat(chunks)
}
async function readJsonBody(req) {
const raw = (await readBody(req)).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))
}
async function sha256File(path) {
const hash = createHash('sha256')
for await (const chunk of createReadStream(path)) hash.update(chunk)
return hash.digest('hex')
}
async function ensureModelFile(file, reportBytes) {
const dest = join(senseVoiceDir(), file.name)
try {
if (statSync(dest).size === file.bytes && (await sha256File(dest)) === file.sha256) {
reportBytes(file.bytes)
return
}
} catch {}
mkdirSync(dirname(dest), { recursive: true })
const partial = `${dest}.part`
rmSync(partial, { force: true })
const response = await fetch(`${SENSEVOICE_BASE_URL}/${file.name}?download=true`, {
signal: AbortSignal.timeout(900000),
})
if (!response.ok || !response.body) {
throw new Error(`下载 ${file.name} 失败HTTP ${response.status}`)
}
const hash = createHash('sha256')
let bytes = 0
const verify = new Transform({
transform(chunk, _encoding, callback) {
bytes += chunk.length
reportBytes(chunk.length)
hash.update(chunk)
callback(null, chunk)
},
})
try {
await pipeline(Readable.fromWeb(response.body), verify, createWriteStream(partial, { mode: 0o600 }))
const digest = hash.digest('hex')
if (bytes !== file.bytes || digest !== file.sha256) {
throw new Error(`下载的 ${file.name} 校验失败`)
}
renameSync(partial, dest)
} catch (error) {
rmSync(partial, { force: true })
throw error
}
}
function npmInvocation() {
if (process.env.npm_execpath && existsSync(process.env.npm_execpath)) {
return { file: process.execPath, prefix: [process.env.npm_execpath] }
}
const configured = process.env.DSH_DESKTOP_NPM
if (configured) return { file: configured, prefix: [] }
for (const candidate of ['/app/bin/npm', '/app/node24/bin/npm']) {
if (existsSync(candidate)) return { file: candidate, prefix: [] }
}
if (process.platform === 'win32') return { file: 'cmd.exe', prefix: ['/d', '/s', '/c', 'npm.cmd'] }
return { file: 'npm', prefix: [] }
}
async function installSherpaRuntime() {
try {
const pkg = JSON.parse(readFileSync(join(runtimeDir(), 'node_modules', 'sherpa-onnx', 'package.json'), 'utf8'))
if (pkg.version === SHERPA_VERSION) return
} catch {}
mkdirSync(runtimeDir(), { recursive: true })
const npm = npmInvocation()
try {
await execFileAsync(
npm.file,
[
...npm.prefix,
'install',
'--prefix',
runtimeDir(),
'--no-audit',
'--no-fund',
'--omit=dev',
'--save-exact',
`sherpa-onnx@${SHERPA_VERSION}`,
],
{ timeout: 300000, maxBuffer: 1024 * 1024 },
)
} catch (error) {
const detail = String(error?.stderr || error?.message || error).trim()
throw new Error(`安装离线识别运行时失败:${detail}`)
}
}
async function installSenseVoice() {
if (senseVoiceInstalled()) return { ...formOf(loadConfig()), installed: true }
if (!installPromise) {
installState = { status: 'installing', percent: 0, stage: '正在准备离线识别运行时…' }
installPromise = (async () => {
mkdirSync(senseVoiceDir(), { recursive: true })
rmSync(markerPath(), { force: true })
let downloadedBytes = 0
const reportBytes = (bytes) => {
downloadedBytes += bytes
installState = {
status: 'installing',
percent: Math.min(
DOWNLOAD_PROGRESS_LIMIT,
Math.floor((downloadedBytes / SENSEVOICE_DOWNLOAD_BYTES) * DOWNLOAD_PROGRESS_LIMIT),
),
stage: '正在下载 SenseVoice 模型…',
}
}
const runtime = installSherpaRuntime()
await Promise.all(SENSEVOICE_FILES.map((file) => ensureModelFile(file, reportBytes)))
installState = {
status: 'installing',
percent: DOWNLOAD_PROGRESS_LIMIT,
stage: '正在完成离线识别运行时…',
}
await runtime
const partial = `${markerPath()}.part`
writeFileSync(
partial,
`${JSON.stringify({ modelVersion: SENSEVOICE_VERSION, runtimeVersion: SHERPA_VERSION }, null, 2)}\n`,
{ encoding: 'utf8', mode: 0o600 },
)
renameSync(partial, markerPath())
installState = { status: 'installed', percent: 100, stage: 'SenseVoice 已安装' }
})()
.catch((error) => {
installState = {
status: 'failed',
percent: installState.percent,
stage: String(error?.message || error),
}
throw error
})
.finally(() => {
installPromise = null
})
}
await installPromise
return { ...formOf(loadConfig()), installed: true }
}
function sherpaModule() {
if (!sherpa) {
const require = createRequire(join(runtimeDir(), 'package.json'))
sherpa = require('sherpa-onnx')
}
return sherpa
}
function senseVoiceRecognizer(language) {
const code = whisperLanguage(language)
if (recognizers.has(code)) return recognizers.get(code)
const runtime = sherpaModule()
const recognizer = runtime.createOfflineRecognizer({
modelConfig: {
senseVoice: {
model: join(senseVoiceDir(), 'model.int8.onnx'),
language: code,
useInverseTextNormalization: 1,
},
tokens: join(senseVoiceDir(), 'tokens.txt'),
},
})
recognizers.set(code, recognizer)
return recognizer
}
function enqueueRecognition(task) {
const next = recognitionQueue.then(task, task)
recognitionQueue = next.catch(() => {})
return next
}
async function transcribeSenseVoice(audio, mime, language) {
if (!senseVoiceInstalled()) {
const error = new Error('SenseVoice 离线模型尚未安装')
error.status = 409
throw error
}
if (!String(mime).toLowerCase().includes('wav')) {
const error = new Error('SenseVoice 离线识别需要 WAV 音频')
error.status = 415
throw error
}
const path = join(tmpdir(), `dsh-desktop-voice-${randomUUID()}.wav`)
writeFileSync(path, audio, { mode: 0o600 })
try {
return await enqueueRecognition(() => {
const runtime = sherpaModule()
const recognizer = senseVoiceRecognizer(language)
const wave = runtime.readWave(path)
const stream = recognizer.createStream()
try {
stream.acceptWaveform(wave.sampleRate, wave.samples)
recognizer.decode(stream)
return String(recognizer.getResult(stream).text || '').trim()
} finally {
stream.free()
}
})
} finally {
rmSync(path, { force: true })
}
}
async function transcribeOpenAI(cfg, audio, mime, language) {
if (!cfg.apiKey) {
const error = new Error('未配置 OpenAI API 密钥')
error.status = 400
throw error
}
const form = new FormData()
form.append('file', new Blob([audio], { type: mime }), filenameFor(mime))
form.append('model', cfg.model || 'whisper-1')
form.append('response_format', 'json')
if (language) form.append('language', language)
const response = await fetch(transcriptionUrl(cfg.baseUrl), {
method: 'POST',
headers: { authorization: `Bearer ${cfg.apiKey}` },
body: form,
signal: AbortSignal.timeout(60000),
})
const text = await response.text()
let body = {}
try {
body = text ? JSON.parse(text) : {}
} catch {
body = { error: text.slice(0, 400) }
}
if (!response.ok) {
const detail = body.error?.message || body.error || body.message || text.slice(0, 400)
const error = new Error(String(detail || `OpenAI 接口返回 ${response.status}`))
error.status = response.status
throw error
}
return String(body.text || body.transcript || '').trim()
}
async function transcribe(req) {
const cfg = formOf(loadConfig())
const url = new URL(req.url, 'http://localhost')
const mime = String(req.headers['content-type'] || url.searchParams.get('mime') || 'application/octet-stream')
const language = whisperLanguage(url.searchParams.get('language') || cfg.language)
const audio = await readBody(req)
if (!audio.length) {
const error = new Error('没有收到音频')
error.status = 400
throw error
}
const transcript =
cfg.engine === 'openai'
? await transcribeOpenAI(cfg, audio, mime, language)
: await transcribeSenseVoice(audio, mime, language)
if (!transcript) {
const error = new Error('没有识别到语音')
error.status = 422
throw error
}
return { text: transcript }
}
export function apply(ctx) {
if (typeof ctx.inject !== 'function') return
ctx.inject(['webServer'], (scope) => {
try {
scope.webServer.register({
name: 'dsh-desktop-voice',
kind: 'exact',
path: '/dsh-desktop/voice',
handler: async (req, res) => {
try {
if (req.method === 'GET') {
json(res, 200, formOf(loadConfig()))
return
}
if (req.method === 'PUT' || req.method === 'POST') {
json(res, 200, { ...saveForm(await readJsonBody(req)), saved: true })
return
}
res.writeHead(405).end()
} catch (error) {
json(res, error.status || 500, { error: String(error?.message || error) })
}
},
})
scope.webServer.register({
name: 'dsh-desktop-voice-model',
kind: 'exact',
path: '/dsh-desktop/voice/model',
handler: async (req, res) => {
try {
if (req.method === 'GET') {
json(res, 200, senseVoiceProgress())
return
}
if (req.method === 'POST') {
json(res, 200, await installSenseVoice())
return
}
res.writeHead(405).end()
} catch (error) {
json(res, error.status || 500, { error: String(error?.message || error) })
}
},
})
scope.webServer.register({
name: 'dsh-desktop-voice-transcribe',
kind: 'exact',
path: '/dsh-desktop/voice/transcribe',
handler: async (req, res) => {
try {
if (req.method !== 'POST') {
res.writeHead(405).end()
return
}
json(res, 200, await transcribe(req))
} catch (error) {
json(res, error.status || 500, { error: String(error?.message || error) })
}
},
})
} catch (error) {
console.error(`[dsh-desktop-voice] routes skipped: ${error}`)
}
})
}

View File

@@ -1,6 +1,6 @@
{ {
"name": "dsh-desktop-vision", "name": "dsh-desktop-voice",
"version": "0.1.4", "version": "0.4.0",
"private": true, "private": true,
"type": "module", "type": "module",
"exports": { "exports": {
@@ -14,7 +14,8 @@
}, },
"client": { "client": {
"inject": [ "inject": [
"@deepseek-ai/dsh-client-ui-settings" "@deepseek-ai/dsh-client-ui-settings",
"@deepseek-ai/dsh-client-ui-conversation"
], ],
"platform": "web", "platform": "web",
"immediately": true "immediately": true

185
scripts/build-gitea-update.py Executable file
View File

@@ -0,0 +1,185 @@
#!/usr/bin/env python3
"""Normalize Tauri artifacts and build the static updater manifest."""
from __future__ import annotations
import argparse
import json
import re
import shutil
from pathlib import Path
MATRIX_TARGETS = {
"gitea-macos-arm64": ("darwin", "aarch64"),
"gitea-macos-x64": ("darwin", "x86_64"),
"gitea-linux": ("linux", "x86_64"),
"gitea-windows": ("windows", "x86_64"),
"gitea-flatpak": ("linux", "x86_64"),
}
DELIVERABLE_SUFFIXES = (
".app.tar.gz.sig",
".app.tar.gz",
".AppImage.sig",
".AppImage",
".flatpak",
".dmg",
".deb",
".rpm",
".exe.sig",
".exe",
".msi.sig",
".msi",
)
UPDATER_SUFFIX = {
"darwin": ".app.tar.gz",
"linux": ".AppImage",
"windows": ".exe",
}
INSTALLER_KIND = {
"darwin": "app",
"linux": "appimage",
"windows": "nsis",
}
# Asset-name patterns for GitHub Release downloads (flat directory): maps a
# fileName to its canonical (os, arch, deliverable suffix).
FLAT_PATTERNS: list[tuple[re.Pattern, str, str, str]] = [
(re.compile(r"^.*_aarch64\.app\.tar\.gz\.sig$"), "darwin", "aarch64", ".app.tar.gz.sig"),
(re.compile(r"^.*_aarch64\.app\.tar\.gz$"), "darwin", "aarch64", ".app.tar.gz"),
(re.compile(r"^.*_aarch64\.dmg$"), "darwin", "aarch64", ".dmg"),
(re.compile(r"^.*_x64\.app\.tar\.gz\.sig$"), "darwin", "x86_64", ".app.tar.gz.sig"),
(re.compile(r"^.*_x64\.app\.tar\.gz$"), "darwin", "x86_64", ".app.tar.gz"),
(re.compile(r"^.*_x64\.dmg$"), "darwin", "x86_64", ".dmg"),
(re.compile(r"^.*_amd64\.deb\.sig$"), "linux", "x86_64", ".deb.sig"),
(re.compile(r"^.*_amd64\.deb$"), "linux", "x86_64", ".deb"),
(re.compile(r"^.*_amd64\.AppImage\.sig$"), "linux", "x86_64", ".AppImage.sig"),
(re.compile(r"^.*_amd64\.AppImage$"), "linux", "x86_64", ".AppImage"),
(re.compile(r"^.*?\.x86_64\.rpm\.sig$"), "linux", "x86_64", ".rpm.sig"),
(re.compile(r"^.*?\.x86_64\.rpm$"), "linux", "x86_64", ".rpm"),
(re.compile(r"^.*_x64-setup\.exe\.sig$"), "windows", "x86_64", ".exe.sig"),
(re.compile(r"^.*_x64-setup\.exe$"), "windows", "x86_64", ".exe"),
(re.compile(r"^.*_x64_en-US\.msi\.sig$"), "windows", "x86_64", ".msi.sig"),
(re.compile(r"^.*_x64_en-US\.msi$"), "windows", "x86_64", ".msi"),
(re.compile(r"^.*\.flatpak$"), "linux", "x86_64", ".flatpak"),
]
def artifact_target(path: Path, root: Path) -> tuple[str, str] | None:
relative = path.relative_to(root)
for part in relative.parts:
if part in MATRIX_TARGETS:
return MATRIX_TARGETS[part]
return None
def deliverable_suffix(name: str) -> str | None:
return next((suffix for suffix in DELIVERABLE_SUFFIXES if name.endswith(suffix)), None)
def stage_artifacts(source: Path, output: Path, version: str) -> dict[tuple[str, str, str], Path]:
output.mkdir(parents=True, exist_ok=True)
staged: dict[tuple[str, str, str], Path] = {}
for path in sorted(source.rglob("*")):
if not path.is_file():
continue
target = artifact_target(path, source)
suffix = deliverable_suffix(path.name)
if target is None or suffix is None:
continue
os_name, arch = target
key = (os_name, arch, suffix)
if key in staged:
raise ValueError(f"duplicate {os_name}-{arch}{suffix}: {staged[key]} and {path}")
destination = output / f"dsh-easy-desktop_{version}_{os_name}_{arch}{suffix}"
shutil.copy2(path, destination)
staged[key] = destination
return staged
def stage_flat_artifacts(source: Path, output: Path, version: str) -> dict[tuple[str, str, str], Path]:
output.mkdir(parents=True, exist_ok=True)
staged: dict[tuple[str, str, str], Path] = {}
for path in sorted(source.iterdir()):
if not path.is_file():
continue
match = next(
((pattern, os_name, arch, suffix) for pattern, os_name, arch, suffix in FLAT_PATTERNS if pattern.match(path.name)),
None,
)
if match is None:
continue
_, os_name, arch, suffix = match
key = (os_name, arch, suffix)
if key in staged:
raise ValueError(f"duplicate {os_name}-{arch}{suffix}: {staged[key]} and {path}")
destination = output / f"dsh-easy-desktop_{version}_{os_name}_{arch}{suffix}"
shutil.copy2(path, destination)
staged[key] = destination
return staged
def build_manifest(
staged: dict[tuple[str, str, str], Path],
version: str,
package_base_url: str,
notes: str,
pub_date: str | None,
) -> dict[str, object]:
platforms: dict[str, dict[str, str]] = {}
for (os_name, arch, suffix), artifact in staged.items():
if suffix != UPDATER_SUFFIX.get(os_name):
continue
signature = staged.get((os_name, arch, suffix + ".sig"))
if signature is None:
raise ValueError(f"missing signature for {artifact.name}")
entry = {
"url": f"{package_base_url.rstrip('/')}/{version}/{artifact.name}",
"signature": signature.read_text(encoding="utf-8").strip(),
}
platforms[f"{os_name}-{arch}-{INSTALLER_KIND[os_name]}"] = entry
platforms[f"{os_name}-{arch}"] = entry
required = {"darwin-aarch64", "darwin-x86_64", "linux-x86_64", "windows-x86_64"}
missing = required.difference(platforms)
if missing:
raise ValueError(f"missing updater targets: {', '.join(sorted(missing))}")
manifest: dict[str, object] = {
"version": version,
"notes": notes,
"platforms": platforms,
}
if pub_date:
manifest["pub_date"] = pub_date
return manifest
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--artifacts", type=Path, help="matrix-layout artifact directory")
parser.add_argument("--flat-artifacts", type=Path, help="flat GitHub Release asset directory")
parser.add_argument("--output", required=True, type=Path)
parser.add_argument("--version", required=True)
parser.add_argument("--package-base-url", required=True)
parser.add_argument("--notes", default="DeepSeek Harness Desktop update")
parser.add_argument("--pub-date")
args = parser.parse_args()
if (args.artifacts is None) == (args.flat_artifacts is None):
raise SystemExit("exactly one of --artifacts or --flat-artifacts is required")
if args.artifacts is not None:
staged = stage_artifacts(args.artifacts, args.output, args.version)
else:
staged = stage_flat_artifacts(args.flat_artifacts, args.output, args.version)
manifest = build_manifest(
staged,
args.version,
args.package_base_url,
args.notes,
args.pub_date,
)
(args.output / "latest.json").write_text(
json.dumps(manifest, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
if __name__ == "__main__":
main()

View File

@@ -21,9 +21,24 @@ capture() {
echo "wrote $OUT/$name.png" echo "wrote $OUT/$name.png"
} }
capture_banner() {
"$CHROME" \
--headless=new \
--disable-gpu \
--no-sandbox \
--hide-scrollbars \
--force-device-scale-factor=2 \
--window-size=1920,960 \
--default-background-color=00000000 \
--screenshot="$OUT/banner.png" \
"file://$SRC/banner.html"
echo "wrote $OUT/banner.png"
}
mkdir -p "$OUT" mkdir -p "$OUT"
capture splash "$SRC/splash.html" capture splash "$SRC/splash.html"
capture session "$SRC/session.html" capture session "$SRC/session.html"
capture vision "$SRC/vision.html" capture vision "$SRC/vision.html"
capture menu "$SRC/menu.html" capture menu "$SRC/menu.html"
capture_banner
cp -f "$ROOT/ui/icon.png" "$OUT/icon.png" cp -f "$ROOT/ui/icon.png" "$OUT/icon.png"

View File

@@ -0,0 +1,63 @@
#!/usr/bin/env python3
"""Apply the downstream mainland-connectivity warning to vendored dshmarket."""
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
MARKET = ROOT / "vendor" / "dshmarket" / "node_modules" / "dshmarket"
def insert_after(path: Path, anchor: str, addition: str) -> None:
text = path.read_text(encoding="utf-8")
if addition.strip() in text:
return
if text.count(anchor) != 1:
raise RuntimeError(f"expected one patch anchor in {path}: {anchor!r}")
path.write_text(text.replace(anchor, anchor + addition), encoding="utf-8")
def insert_before(path: Path, anchor: str, addition: str) -> None:
text = path.read_text(encoding="utf-8")
if addition.strip() in text:
return
if text.count(anchor) != 1:
raise RuntimeError(f"expected one patch anchor in {path}: {anchor!r}")
path.write_text(text.replace(anchor, addition + anchor), encoding="utf-8")
def main() -> None:
locales = MARKET / "src" / "client" / "locales.ts"
insert_after(
locales,
" terminalWarn: '这看起来是终端/命令行插件:装进网页版可能无效,甚至导致 DeepSeek Harness 无法启动。建议先看它的使用说明,按说明装进对应的 profile。',\n",
" githubInstallWarn: '此插件没有 npm 安装包,安装时必须从 github.com 下载源码;中国大陆网络通常无法直连。请仅在当前网络能访问 GitHub 时继续。',\n",
)
insert_after(
locales,
" terminalWarn: 'This looks like a terminal/CLI plugin: installing it into the web profile may do nothing, or even break DeepSeek Harness startup. Read its README and install it into the profile it targets.',\n",
" githubInstallWarn: 'This plugin has no npm package. Installation must download its source from github.com and will fail where GitHub is unreachable. Continue only if this network can access GitHub.',\n",
)
section = MARKET / "src" / "client" / "MarketSection.tsx"
source_anchor = """ <p className={css.modalNote}><IconWarningOutline16 size={14} className={css.bannerIcon} />{' ' + t('confirmWarn')}</p>\n"""
source_addition = """ {typeof confirming.npm !== 'string' && (\n <p className={css.warnLine}>\n <IconWarningOutline16 size={14} className={css.bannerIcon} />\n {' ' + t('githubInstallWarn')}\n </p>\n )}\n"""
insert_before(section, source_anchor, source_addition)
client = MARKET / "client" / "client.js"
insert_after(
client,
'\t\t\tterminalWarn: "这看起来是终端/命令行插件:装进网页版可能无效,甚至导致 DeepSeek Harness 无法启动。建议先看它的使用说明,按说明装进对应的 profile。",\n',
'\t\t\tgithubInstallWarn: "此插件没有 npm 安装包,安装时必须从 github.com 下载源码;中国大陆网络通常无法直连。请仅在当前网络能访问 GitHub 时继续。",\n',
)
insert_after(
client,
'\t\t\tterminalWarn: "This looks like a terminal/CLI plugin: installing it into the web profile may do nothing, or even break DeepSeek Harness startup. Read its README and install it into the profile it targets.",\n',
'\t\t\tgithubInstallWarn: "This plugin has no npm package. Installation must download its source from github.com and will fail where GitHub is unreachable. Continue only if this network can access GitHub.",\n',
)
built_anchor = """\t\t\t\t\t\t\t/* @__PURE__ */ (0, react_jsx_runtime.jsxs)(\"p\", {\n\t\t\t\t\t\t\t\tclassName: Market_module_css_default.modalNote,\n"""
built_addition = """\t\t\t\t\t\t\ttypeof confirming.npm !== \"string\" && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(\"p\", {\n\t\t\t\t\t\t\t\tclassName: Market_module_css_default.warnLine,\n\t\t\t\t\t\t\t\tchildren: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconWarningOutline16, {\n\t\t\t\t\t\t\t\t\tsize: 14,\n\t\t\t\t\t\t\t\t\tclassName: Market_module_css_default.bannerIcon\n\t\t\t\t\t\t\t\t}), \" \" + t(\"githubInstallWarn\")]\n\t\t\t\t\t\t\t}),\n"""
insert_before(client, built_anchor, built_addition)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,50 @@
#!/usr/bin/env bash
# Gitea Actions publisher: download a GitHub release's assets and publish
# them to the local Gitea instance over the LAN interface (bypassing the
# public reverse proxy, which times out on large uploads).
set -euo pipefail
GITHUB_REPO="${GITHUB_REPO:-TommyFang2077/dsh-easy-desktop}"
REPO_OWNER="${GITEA_OWNER:-TomHanck4}"
REPO_NAME="${GITEA_REPO:-dsh-easy-desktop}"
GITEA_BASE_URL="${GITEA_BASE_URL:-http://192.168.30.33:3000}"
PACKAGE_BASE_URL="${PACKAGE_BASE_URL:-https://git.fangsiyuan.top/api/packages/TomHanck4/generic/dsh-easy-desktop-updater}"
RELEASE_TAG="${RELEASE_TAG:-latest}"
: "${GITEA_TOKEN:?GITEA_TOKEN is required}"
if [[ "$RELEASE_TAG" == "latest" ]]; then
RELEASE_TAG=$(curl -fsS "https://api.github.com/repos/$GITHUB_REPO/releases?per_page=1" | jq -r '.[0].tag_name')
fi
[[ -n "$RELEASE_TAG" && "$RELEASE_TAG" != "null" ]] || {
echo "no GitHub release found" >&2
exit 1
}
VERSION="${RELEASE_TAG#v}"
echo "target: $RELEASE_TAG ($VERSION)"
echo "gitea: $GITEA_BASE_URL"
if curl -fsS -H "Authorization: token $GITEA_TOKEN" \
"$GITEA_BASE_URL/api/packages/$REPO_OWNER/generic/dsh-easy-desktop-updater/$VERSION/dsh-easy-desktop_${VERSION}_darwin_aarch64.app.tar.gz" >/dev/null 2>&1; then
echo "version $VERSION already published on the Gitea package feed; nothing to do"
exit 0
fi
rm -rf artifacts staged
mkdir -p artifacts
curl -fsS "https://api.github.com/repos/$GITHUB_REPO/releases/tags/$RELEASE_TAG" -o /tmp/release.json
jq -r '.assets[].browser_download_url' /tmp/release.json | while read -r url; do
curl -fsSL --retry 3 --retry-delay 5 -o "artifacts/$(basename "$url")" "$url" &
done
wait
echo "downloaded $(find artifacts -type f | wc -l) assets"
python3 build-gitea-update.py \
--flat-artifacts artifacts \
--output staged \
--version "$VERSION" \
--pub-date "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
--package-base-url "$PACKAGE_BASE_URL"
bash publish-gitea-release.sh staged
echo "published $RELEASE_TAG to Gitea"

111
scripts/publish-gitea-release.sh Executable file
View File

@@ -0,0 +1,111 @@
#!/usr/bin/env bash
# Publish normalized installers and signed updater artifacts to Gitea.
set -euo pipefail
STAGING_DIR="${1:?usage: publish-gitea-release.sh STAGING_DIR}"
: "${GITEA_TOKEN:?GITEA_TOKEN is required}"
: "${GITEA_BASE_URL:?GITEA_BASE_URL is required}"
: "${GITEA_OWNER:?GITEA_OWNER is required}"
: "${GITEA_REPO:?GITEA_REPO is required}"
: "${RELEASE_TAG:?RELEASE_TAG is required}"
: "${RELEASE_VERSION:?RELEASE_VERSION is required}"
API="${GITEA_BASE_URL%/}/api/v1/repos/${GITEA_OWNER}/${GITEA_REPO}"
PACKAGE_BASE="${GITEA_BASE_URL%/}/api/packages/${GITEA_OWNER}/generic/dsh-easy-desktop-updater"
AUTH="Authorization: token ${GITEA_TOKEN}"
# Retry transient gateway errors (504) coming from the front proxy while the
# NAS reaches github.com over a mainland link: requests can stall past the
# proxy read timeout. 404s are NOT retried here (release/package state).
curl_retry() {
local attempts=0
while ! "$@"; do
attempts=$((attempts + 1))
if (( attempts >= 4 )); then
echo "curl failed after 4 attempts: $*" >&2
return 1
fi
echo "curl transient failure (attempt ${attempts}/3), retrying: $*" >&2
sleep 10
done
}
# The Gitea repository is a pull mirror. Kick a background sync; the trigger
# response itself is irrelevant (it can 504 while the sync runs on Gitea).
# The tag poll below is the actual gate.
curl --silent --show-error -X POST -H "$AUTH" "$API/mirror-sync" >/dev/null || true
# Wait (up to 10 min) until the tag has synced; then fail loudly if it never did.
for _ in $(seq 1 60); do
if curl --fail --silent --show-error -H "$AUTH" "$API/tags/$RELEASE_TAG" >/dev/null 2>&1; then
synced=1
break
fi
sleep 10
done
[[ "${synced:-0}" == 1 ]] || {
echo "tag $RELEASE_TAG did not appear on the Gitea mirror after 10 minutes" >&2
exit 1
}
# Structured release body from docs/release-notes/v<version>.md; legacy
# fallback only when the file is missing (the version job normally gates it).
RELEASE_NOTES="docs/release-notes/v${RELEASE_VERSION}.md"
if [[ -f "$RELEASE_NOTES" ]]; then
release_body=$(cat "$RELEASE_NOTES")
else
release_body="大陆镜像安装包;文件与 GitHub Release 同源。应用内更新包由 Tauri 签名校验。"
fi
release_json=$(curl_retry curl --fail --silent --show-error -H "$AUTH" "$API/releases/tags/$RELEASE_TAG")
release_id=$(printf '%s' "$release_json" | jq -r '.id // empty')
if [[ -z "$release_id" ]]; then
release_payload=$(jq -n \
--arg tag "$RELEASE_TAG" \
--arg name "DeepSeek Harness Desktop $RELEASE_TAG" \
--arg body "$release_body" \
'{tag_name:$tag,target_commitish:$tag,name:$name,body:$body,draft:false,prerelease:false}')
release_json=$(curl_retry curl --fail --silent --show-error \
-X POST -H "$AUTH" -H 'Content-Type: application/json' \
--data "$release_payload" "$API/releases")
release_id=$(printf '%s' "$release_json" | jq -r '.id')
else
# The mirror may have synced an older, terse GitHub body; keep the Gitea
# copy identical to the structured notes file on every publish.
curl_retry curl --fail --silent --show-error -X PATCH -H "$AUTH" \
-H 'Content-Type: application/json' \
--data "$(jq -n --arg body "$release_body" '{body:$body}')" \
"$API/releases/$release_id" >/dev/null
fi
# Versioned generic package: immutable URLs consumed by latest.json.
curl --silent --show-error -X DELETE -H "$AUTH" \
"$PACKAGE_BASE/$RELEASE_VERSION" >/dev/null || true
for file in "$STAGING_DIR"/*; do
[[ -f "$file" && "$(basename "$file")" != "latest.json" ]] || continue
curl_retry curl --fail --silent --show-error -H "$AUTH" --upload-file "$file" \
"$PACKAGE_BASE/$RELEASE_VERSION/$(basename "$file")" >/dev/null
done
# Stable updater endpoint. Gitea generic packages are immutable, so replace
# the synthetic "latest" version on each completed release.
curl --silent --show-error -X DELETE -H "$AUTH" "$PACKAGE_BASE/latest" >/dev/null || true
curl_retry curl --fail --silent --show-error -H "$AUTH" --upload-file "$STAGING_DIR/latest.json" \
"$PACKAGE_BASE/latest/latest.json" >/dev/null
assets=$(curl_retry curl --fail --silent --show-error -H "$AUTH" "$API/releases/$release_id/assets")
for file in "$STAGING_DIR"/*; do
[[ -f "$file" ]] || continue
name=$(basename "$file")
case "$name" in
*.dmg|*.deb|*.rpm|*.exe|*.msi|*.AppImage|*.flatpak) ;;
*) continue ;;
esac
old_id=$(printf '%s' "$assets" | jq -r --arg name "$name" '[.[] | select(.name == $name) | .id][0] // empty')
if [[ -n "$old_id" ]]; then
curl_retry curl --fail --silent --show-error -X DELETE -H "$AUTH" \
"$API/releases/$release_id/assets/$old_id" >/dev/null
fi
curl_retry curl --fail --silent --show-error -H "$AUTH" \
-F "attachment=@$file" "$API/releases/$release_id/assets?name=$name" >/dev/null
done

67
scripts/release-notes.py Executable file
View File

@@ -0,0 +1,67 @@
#!/usr/bin/env python3
"""Extract release-copy fragments from a structured per-version notes file.
Notes live under docs/release-notes/v<version>.md in this layout:
# DeepSeek Harness Desktop v0.1.2
**摘要 / Summary:** one short bilingual paragraph …
## 新功能 / Features
- …
## 修复 / Fixes
- …
## 安装与更新 / Install & Update
- …
Subcommands:
body FILE the full file (GitHub / Gitea release body)
notes FILE the 摘要 / Summary paragraph, one line (in-app update notes)
"""
from __future__ import annotations
import argparse
import re
import sys
from pathlib import Path
TITLE_RE = re.compile(r"^# .+$", re.M)
SUMMARY_RE = re.compile(r"^\*\*摘要\s*/\s*Summary:\*\*\s*(.+)$", re.M)
def load(path: Path) -> str:
text = path.read_text(encoding="utf-8")
if TITLE_RE.search(text) is None:
raise SystemExit(f"{path}: expected a '# ' title line")
return text
def read_body(path: Path) -> str:
text = load(path)
if not text.endswith("\n"):
text += "\n"
return text
def read_notes(path: Path) -> str:
text = load(path)
match = SUMMARY_RE.search(text)
if match is None:
raise SystemExit(f"{path}: missing '**摘要 / Summary:**' line")
return match.group(1).strip()
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument("subcommand", choices=["body", "notes"])
parser.add_argument("file")
args = parser.parse_args()
out = read_body(Path(args.file)) if args.subcommand == "body" else read_notes(Path(args.file))
sys.stdout.write(out if out.endswith("\n") else out + "\n")
if __name__ == "__main__":
main()

93
scripts/setup-gitea-release.sh Executable file
View File

@@ -0,0 +1,93 @@
#!/usr/bin/env bash
# Interactive one-time setup for Gitea publishing and Tauri update signing.
set -euo pipefail
BOLD='\033[1m'
DIM='\033[2m'
RED='\033[31m'
GREEN='\033[32m'
RESET='\033[0m'
GITEA_BASE_URL='https://git.fangsiyuan.top'
GITEA_OWNER='TomHanck4'
GITEA_REPO='dsh-easy-desktop'
step() {
printf '\n%b%s%b\n' "$BOLD" "$1" "$RESET"
printf '%b%s%b\n\n' "$DIM" "$2" "$RESET"
}
confirm() {
local answer
read -r -p "$1 [y/N] " answer
[[ "$answer" =~ ^[Yy]$ ]]
}
need() {
command -v "$1" >/dev/null 2>&1 || {
printf '%b缺少命令%s%b\n' "$RED" "$1" "$RESET" >&2
exit 1
}
}
need curl
need gh
need jq
printf '%bGitea 大陆发行通道设置%b\n' "$BOLD" "$RESET"
printf '共 3 步:验证 Gitea → 配置签名 → 写入 GitHub Secrets\n'
step '1/3 验证 Gitea' '需要一个可写入 TomHanck4/dsh-easy-desktop 发行版与 Generic Package 的 Gitea token。'
printf '%bHTTPS 已启用:%btoken 与发行包传输均受 TLS 保护。\n' "$GREEN" "$RESET"
read -r -s -p '粘贴 Gitea token: ' GITEA_TOKEN
printf '\n'
[[ -n "$GITEA_TOKEN" ]] || { printf '%btoken 不能为空%b\n' "$RED" "$RESET" >&2; exit 1; }
AUTH="Authorization: token $GITEA_TOKEN"
user=$(curl --fail --silent --show-error -H "$AUTH" "$GITEA_BASE_URL/api/v1/user")
repo=$(curl --fail --silent --show-error -H "$AUTH" "$GITEA_BASE_URL/api/v1/repos/$GITEA_OWNER/$GITEA_REPO")
printf '已认证:%s仓库%s\n' "$(printf '%s' "$user" | jq -r .login)" "$(printf '%s' "$repo" | jq -r .full_name)"
[[ "$(printf '%s' "$repo" | jq -r .mirror)" == 'true' ]] || {
printf '%b仓库不是 pull mirror发布脚本无法同步 GitHub tag。%b\n' "$RED" "$RESET" >&2
exit 1
}
step '2/3 配置 Tauri 签名' '已有密钥可复用;否则向导通过 Tauri CLI 在 ~/.tauri 生成。私钥不会写入仓库。'
if confirm '已有 Tauri updater 私钥吗?'; then
read -r -e -p '私钥路径: ' PRIVATE_KEY_PATH
read -r -e -p '公钥路径: ' PUBLIC_KEY_PATH
read -r -s -p '私钥密码(没有则留空): ' SIGNING_PASSWORD
printf '\n'
else
need npx
PRIVATE_KEY_PATH="$HOME/.tauri/dsh-easy-desktop.key"
PUBLIC_KEY_PATH="$PRIVATE_KEY_PATH.pub"
mkdir -p "$(dirname "$PRIVATE_KEY_PATH")"
read -r -s -p '设置私钥密码(可留空): ' SIGNING_PASSWORD
printf '\n正在生成签名密钥…\n'
npm_config_registry='https://registry.npmmirror.com' \
npx --yes @tauri-apps/cli@2 signer generate --ci --password "$SIGNING_PASSWORD" -w "$PRIVATE_KEY_PATH"
fi
[[ -f "$PRIVATE_KEY_PATH" ]] || { printf '%b找不到私钥%s%b\n' "$RED" "$PRIVATE_KEY_PATH" "$RESET" >&2; exit 1; }
[[ -f "$PUBLIC_KEY_PATH" ]] || { printf '%b找不到公钥%s%b\n' "$RED" "$PUBLIC_KEY_PATH" "$RESET" >&2; exit 1; }
chmod 600 "$PRIVATE_KEY_PATH"
printf '私钥:%s\n公钥%s\n' "$PRIVATE_KEY_PATH" "$PUBLIC_KEY_PATH"
step '3/3 写入 GitHub Secrets' 'gh 会把四项机密直接写到当前仓库;终端不会打印 secret 内容。'
gh auth status >/dev/null
confirm '现在写入 GITEA_TOKEN 与 Tauri 签名 secrets 吗?' || exit 1
printf '%s' "$GITEA_TOKEN" | gh secret set GITEA_TOKEN
cat "$PRIVATE_KEY_PATH" | gh secret set TAURI_SIGNING_PRIVATE_KEY
python3 - "$PUBLIC_KEY_PATH" <<'PY'
import json, pathlib, sys
conf = pathlib.Path("src-tauri/tauri.conf.json")
data = json.loads(conf.read_text(encoding="utf-8"))
data["plugins"]["updater"]["pubkey"] = pathlib.Path(sys.argv[1]).read_text(encoding="utf-8").strip()
conf.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
PY
echo "公钥已写入 src-tauri/tauri.conf.json随仓库提交"
if [[ -n "$SIGNING_PASSWORD" ]]; then
printf '%s' "$SIGNING_PASSWORD" | gh secret set TAURI_SIGNING_PRIVATE_KEY_PASSWORD
fi
unset GITEA_TOKEN SIGNING_PASSWORD AUTH
printf '\n%b设置完成。%b 下次推送 v* tag 时Release 工作流会发布 GitHub 与 Gitea 两套安装包。\n' "$GREEN" "$RESET"
printf '首个版本发布后检查:%s/%s/%s/releases/latest\n' "$GITEA_BASE_URL" "$GITEA_OWNER" "$GITEA_REPO"

View File

@@ -1,5 +1,5 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Fetch ModLens + Anchored Standard into vendor/ for Tauri resource bundling. # Fetch ModLens, dshmarket, and Anchored Standard into vendor/ for Tauri resources.
# Does not vendor @deepseek-ai/dsh (that is Flatpak-only; see `make vendor`). # Does not vendor @deepseek-ai/dsh (that is Flatpak-only; see `make vendor`).
set -euo pipefail set -euo pipefail
@@ -7,6 +7,7 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT" cd "$ROOT"
MODLENS_VERSION="${MODLENS_VERSION:-3.16.6}" MODLENS_VERSION="${MODLENS_VERSION:-3.16.6}"
MARKET_VERSION="${MARKET_VERSION:-1.9.0}"
ANCHORED_COMMIT="${ANCHORED_COMMIT:-ffb845c5480adc953392a6db6f8a98ede621174b}" ANCHORED_COMMIT="${ANCHORED_COMMIT:-ffb845c5480adc953392a6db6f8a98ede621174b}"
ANCHORED_REPO="${ANCHORED_REPO:-https://github.com/xiaobright/dsh-anchored-standard.git}" ANCHORED_REPO="${ANCHORED_REPO:-https://github.com/xiaobright/dsh-anchored-standard.git}"
if [ -z "${PYTHON:-}" ]; then if [ -z "${PYTHON:-}" ]; then
@@ -20,6 +21,7 @@ fi
ANCHORED_DIR="vendor/anchored-standard" ANCHORED_DIR="vendor/anchored-standard"
ZERO_DIR="vendor/zero-anchored-standard" ZERO_DIR="vendor/zero-anchored-standard"
MODLENS_DIR="vendor/modlens" MODLENS_DIR="vendor/modlens"
MARKET_DIR="vendor/dshmarket"
rm -rf vendor/.anchored-src "$ANCHORED_DIR" "$ZERO_DIR" rm -rf vendor/.anchored-src "$ANCHORED_DIR" "$ZERO_DIR"
mkdir -p vendor/.anchored-src mkdir -p vendor/.anchored-src
@@ -42,7 +44,13 @@ rm -rf "$MODLENS_DIR"
mkdir -p "$MODLENS_DIR" mkdir -p "$MODLENS_DIR"
npm install --prefix "$MODLENS_DIR" --prefer-offline --no-audit --no-fund "@liustack/modlens@${MODLENS_VERSION}" npm install --prefix "$MODLENS_DIR" --prefer-offline --no-audit --no-fund "@liustack/modlens@${MODLENS_VERSION}"
rm -rf "$MARKET_DIR"
mkdir -p "$MARKET_DIR"
npm install --prefix "$MARKET_DIR" --prefer-offline --no-audit --no-fund "dshmarket@${MARKET_VERSION}"
"$PYTHON" scripts/patch-dshmarket-mainland.py
test -f "$ANCHORED_DIR/preset.yml" test -f "$ANCHORED_DIR/preset.yml"
test -f "$ZERO_DIR/preset.yml" test -f "$ZERO_DIR/preset.yml"
test -d "$MODLENS_DIR/node_modules/@liustack/modlens" test -d "$MODLENS_DIR/node_modules/@liustack/modlens"
echo "vendored ModLens ${MODLENS_VERSION} and Anchored Standard ${ANCHORED_COMMIT}" test -d "$MARKET_DIR/node_modules/dshmarket"
echo "vendored ModLens ${MODLENS_VERSION}, dshmarket ${MARKET_VERSION}, and Anchored Standard ${ANCHORED_COMMIT}"

View File

@@ -22,11 +22,17 @@ dsh-core = { path = "../crates/dsh-core" }
tauri = { version = "2", features = ["devtools"] } tauri = { version = "2", features = ["devtools"] }
tauri-plugin-opener = "2" tauri-plugin-opener = "2"
tauri-plugin-single-instance = "2" tauri-plugin-single-instance = "2"
tauri-plugin-updater = "2"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
arboard = "3" arboard = { version = "3", features = ["wayland-data-control"] }
image = { version = "0.25", default-features = false, features = ["png"] } image = { version = "0.25", default-features = false, features = ["png"] }
open = "5" open = "5"
url = "2" url = "2"
log = "0.4" log = "0.4"
env_logger = "0.11" env_logger = "0.11"
[target.'cfg(target_os = "linux")'.dependencies]
gdk = "0.18"
gtk = "0.18"
webkit2gtk = { version = "=2.0.2", features = ["v2_38"] }

8
src-tauri/Info.plist Normal file
View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSMicrophoneUsageDescription</key>
<string>语音输入需要使用麦克风,以便把说话内容写进对话框。</string>
</dict>
</plist>

View File

@@ -1,7 +1,7 @@
[Desktop Entry] [Desktop Entry]
Categories={{categories}} Categories={{categories}}
Comment={{comment}} Comment={{comment}}
Comment[zh_CN]=在原生桌面窗口中运行 DeepSeek Harness Comment[zh_CN]=给 DeepSeek 带上眼睛;锚定模式约 +8%
Exec={{exec}} Exec={{exec}}
GenericName=AI Agent Harness GenericName=AI Agent Harness
GenericName[zh_CN]=AI 智能体工作台 GenericName[zh_CN]=AI 智能体工作台

View File

@@ -1,11 +1,16 @@
mod shell_updater;
use std::io::Cursor; use std::io::Cursor;
use std::path::PathBuf; use std::path::PathBuf;
use std::process::Command;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use std::thread; use std::thread;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use dsh_core::clipboard::{ use dsh_core::clipboard::{
file_from_bytes, filename_for_mime, files_from_paths, parse_uri_list, ClipboardFile, detect_image_mime, 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::launcher::{DshLauncher, DshProcess, URL_TIMEOUT_SECONDS};
use dsh_core::modlens::ensure_modlens; use dsh_core::modlens::ensure_modlens;
@@ -36,7 +41,9 @@ pub struct Args {
impl Args { impl Args {
pub fn parse() -> Self { pub fn parse() -> Self {
let mut args = Args { let mut args = Args {
dsh: std::env::var(dsh_core::ENV_BIN_OVERRIDE).ok().filter(|s| !s.is_empty()), dsh: std::env::var(dsh_core::ENV_BIN_OVERRIDE)
.ok()
.filter(|s| !s.is_empty()),
cwd: std::env::var(dsh_core::ENV_CWD_OVERRIDE) cwd: std::env::var(dsh_core::ENV_CWD_OVERRIDE)
.ok() .ok()
.filter(|s| !s.is_empty()) .filter(|s| !s.is_empty())
@@ -89,6 +96,7 @@ struct AppState {
paths: BundledPaths, paths: BundledPaths,
process: Mutex<Option<Arc<DshProcess>>>, process: Mutex<Option<Arc<DshProcess>>>,
url: Mutex<Option<String>>, url: Mutex<Option<String>>,
initial_boot_started: AtomicBool,
} }
impl AppState { impl AppState {
@@ -121,6 +129,41 @@ struct ReadyPayload {
fn restart(app: AppHandle) { fn restart(app: AppHandle) {
thread::spawn(move || boot(app)); thread::spawn(move || boot(app));
} }
fn start_initial_boot(app: AppHandle) {
let Some(state) = app.try_state::<AppState>() else {
return;
};
if state.initial_boot_started.swap(true, Ordering::AcqRel) {
return;
}
thread::spawn(move || boot(app));
}
#[tauri::command]
async fn check_shell_update(app: AppHandle) -> Option<shell_updater::UpdateInfo> {
match shell_updater::check(&app).await {
Ok(Some(update)) => Some(update),
Ok(None) => {
start_initial_boot(app);
None
}
Err(error) => {
log::warn!("shell update check failed: {error}");
start_initial_boot(app);
None
}
}
}
#[tauri::command]
async fn install_shell_update(app: AppHandle) -> Result<(), String> {
shell_updater::install(&app).await
}
#[tauri::command]
fn skip_shell_update(app: AppHandle) {
start_initial_boot(app);
}
#[tauri::command] #[tauri::command]
fn open_in_browser(state: tauri::State<AppState>) -> Result<(), String> { fn open_in_browser(state: tauri::State<AppState>) -> Result<(), String> {
@@ -134,39 +177,129 @@ fn open_in_browser(state: tauri::State<AppState>) -> Result<(), String> {
} }
#[tauri::command] #[tauri::command]
fn read_clipboard_images() -> Result<Vec<ClipboardFile>, String> { fn read_clipboard_images(app: AppHandle) -> Result<Vec<ClipboardFile>, String> {
read_images().map_err(|e| e.to_string()) let (tx, rx) = std::sync::mpsc::sync_channel(1);
app.run_on_main_thread(move || {
let _ = tx.send(read_images());
})
.map_err(|e| e.to_string())?;
rx.recv().map_err(|e| e.to_string())?
} }
fn read_images() -> Result<Vec<ClipboardFile>, String> { fn read_images() -> Result<Vec<ClipboardFile>, String> {
let mut clipboard = arboard::Clipboard::new().map_err(|e| e.to_string())?; if let Some(file) = read_native_image() {
if let Ok(img) = clipboard.get_image() { return Ok(vec![file]);
}
if let Some(file) = read_arboard_image() {
return Ok(vec![file]);
}
if let Some(file) = read_cli_image() {
return Ok(vec![file]);
}
if let Some(files) = read_arboard_image_paths() {
return Ok(files);
}
Ok(Vec::new())
}
#[cfg(target_os = "linux")]
fn read_native_image() -> Option<ClipboardFile> {
let clipboard = gtk::Clipboard::get(&gdk::SELECTION_CLIPBOARD);
let pixbuf = clipboard.wait_for_image()?;
let png = pixbuf.save_to_bufferv("png", &[]).ok()?;
file_from_bytes(filename_for_mime("image/png", 0), "image/png".into(), png)
}
#[cfg(not(target_os = "linux"))]
fn read_native_image() -> Option<ClipboardFile> {
None
}
fn read_arboard_image() -> Option<ClipboardFile> {
let mut clipboard = arboard::Clipboard::new().ok()?;
let img = clipboard.get_image().ok()?;
let width = img.width as u32; let width = img.width as u32;
let height = img.height as u32; let height = img.height as u32;
let bytes = img.bytes.into_owned(); let bytes = img.bytes.into_owned();
if let Some(buffer) = image::RgbaImage::from_raw(width, height, bytes) { let buffer = image::RgbaImage::from_raw(width, height, bytes)?;
let mut png = Vec::new(); let mut png = Vec::new();
if buffer buffer
.write_to(&mut Cursor::new(&mut png), image::ImageFormat::Png) .write_to(&mut Cursor::new(&mut png), image::ImageFormat::Png)
.is_ok() .ok()?;
{ file_from_bytes(filename_for_mime("image/png", 0), "image/png".into(), png)
if let Some(file) = file_from_bytes( }
filename_for_mime("image/png", 0),
"image/png".into(), fn read_arboard_image_paths() -> Option<Vec<ClipboardFile>> {
png, let mut clipboard = arboard::Clipboard::new().ok()?;
) { let text = clipboard.get_text().ok()?;
return Ok(vec![file]);
}
}
}
}
if let Ok(text) = clipboard.get_text() {
let loaded = files_from_paths(parse_uri_list(&text)); let loaded = files_from_paths(parse_uri_list(&text));
if !loaded.is_empty() { if loaded.is_empty() {
return Ok(loaded); None
} else {
Some(loaded)
}
}
fn read_cli_image() -> Option<ClipboardFile> {
const ATTEMPTS: &[(&str, &[&str])] = &[
("wl-paste", &["--type", "image/png"]),
("wl-paste", &["--type", "image/jpeg"]),
("wl-paste", &["--type", "image/webp"]),
(
"xclip",
&["-selection", "clipboard", "-t", "image/png", "-o"],
),
(
"xclip",
&["-selection", "clipboard", "-t", "image/jpeg", "-o"],
),
(
"xclip",
&["-selection", "clipboard", "-t", "image/webp", "-o"],
),
];
for (bin, args) in ATTEMPTS {
let output = match Command::new(bin).args(*args).output() {
Ok(output) => output,
Err(_) => continue,
};
if !output.status.success() || output.stdout.is_empty() {
continue;
}
let mime = detect_image_mime(&output.stdout).unwrap_or("image/png");
if let Some(file) = file_from_bytes(filename_for_mime(mime, 0), mime.into(), output.stdout)
{
return Some(file);
} }
} }
Ok(Vec::new()) None
}
fn enable_microphone(window: &tauri::WebviewWindow) {
#[cfg(target_os = "linux")]
{
let _ = window.with_webview(|platform| {
use gtk::glib::{object::ObjectExt, StaticType};
use webkit2gtk::{PermissionRequestExt, SettingsExt, WebViewExt};
let webview = platform.inner();
if let Some(settings) = webview.settings() {
settings.set_enable_media_stream(true);
settings.set_enable_mediasource(true);
}
webview.connect_permission_request(|_, request| {
if request
.type_()
.is_a(webkit2gtk::UserMediaPermissionRequest::static_type())
{
request.allow();
true
} else {
false
}
});
});
}
let _ = window;
} }
fn is_internal(url: &Url) -> bool { fn is_internal(url: &Url) -> bool {
@@ -303,17 +436,21 @@ fn boot(app: AppHandle) {
pub fn run() { pub fn run() {
let args = Args::parse(); let args = Args::parse();
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or(if args.verbose { env_logger::Builder::from_env(
"debug" env_logger::Env::default().default_filter_or(if args.verbose { "debug" } else { "info" }),
} else { )
"info"
}))
.init(); .init();
let mut paths = BundledPaths::discover(); let mut paths = BundledPaths::discover();
let dev = args.dev; let dev = args.dev;
let updater = match shell_updater::public_key() {
Some(public_key) => tauri_plugin_updater::Builder::new().pubkey(public_key),
None => tauri_plugin_updater::Builder::new(),
}
.build();
tauri::Builder::default() tauri::Builder::default()
.plugin(updater)
.plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_single_instance::init(|app, _argv, _cwd| { .plugin(tauri_plugin_single_instance::init(|app, _argv, _cwd| {
if let Some(window) = app.get_webview_window("main") { if let Some(window) = app.get_webview_window("main") {
@@ -330,9 +467,11 @@ pub fn run() {
paths, paths,
process: Mutex::new(None), process: Mutex::new(None),
url: Mutex::new(None), url: Mutex::new(None),
initial_boot_started: AtomicBool::new(false),
}); });
let mut builder = WebviewWindowBuilder::new(app, "main", WebviewUrl::App("index.html".into())) let mut builder =
WebviewWindowBuilder::new(app, "main", WebviewUrl::App("index.html".into()))
.title(APP_NAME) .title(APP_NAME)
.inner_size(1320.0, 860.0) .inner_size(1320.0, 860.0)
.min_inner_size(800.0, 560.0) .min_inner_size(800.0, 560.0)
@@ -360,16 +499,18 @@ pub fn run() {
if dev { if dev {
window.open_devtools(); window.open_devtools();
} }
enable_microphone(&window);
let app_handle = app.handle().clone();
let _ = window; let _ = window;
thread::spawn(move || boot(app_handle));
Ok(()) Ok(())
}) })
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
restart, restart,
open_in_browser, open_in_browser,
read_clipboard_images read_clipboard_images,
check_shell_update,
install_shell_update,
skip_shell_update,
]) ])
.on_window_event(|window, event| { .on_window_event(|window, event| {
if let tauri::WindowEvent::CloseRequested { .. } = event { if let tauri::WindowEvent::CloseRequested { .. } = event {

View File

@@ -0,0 +1,126 @@
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use std::time::Duration;
use serde::Serialize;
use tauri::{AppHandle, Emitter};
use tauri_plugin_updater::{Update, UpdaterExt};
use url::Url;
const UPDATE_ENDPOINT: &str = "https://git.fangsiyuan.top/api/packages/TomHanck4/generic/dsh-easy-desktop-updater/latest/latest.json";
const UPDATE_TIMEOUT_SECONDS: u64 = 8;
pub fn public_key() -> Option<&'static str> {
option_env!("DSH_DESKTOP_UPDATER_PUBKEY").filter(|key| !key.trim().is_empty())
}
#[derive(Clone, Serialize)]
pub struct UpdateInfo {
pub version: String,
pub notes: Option<String>,
}
#[derive(Clone, Serialize)]
struct UpdateProgress {
downloaded: u64,
total: Option<u64>,
percent: Option<u8>,
}
fn supported_install() -> bool {
#[cfg(target_os = "linux")]
{
// Tauri's Linux updater replaces the running AppImage. A deb, rpm, or
// Flatpak install must be updated by its package manager instead.
std::env::var_os("APPIMAGE").is_some()
}
#[cfg(not(target_os = "linux"))]
{
true
}
}
async fn find_update(app: &AppHandle) -> Result<Option<Update>, String> {
let Some(public_key) = public_key() else {
return Ok(None);
};
if !supported_install() {
return Ok(None);
}
let endpoint = Url::parse(UPDATE_ENDPOINT).map_err(|error| error.to_string())?;
app.updater_builder()
.endpoints(vec![endpoint])
.map_err(|error| error.to_string())?
.pubkey(public_key)
.timeout(Duration::from_secs(UPDATE_TIMEOUT_SECONDS))
.build()
.map_err(|error| error.to_string())?
.check()
.await
.map_err(|error| error.to_string())
}
pub async fn check(app: &AppHandle) -> Result<Option<UpdateInfo>, String> {
Ok(find_update(app).await?.map(|update| UpdateInfo {
version: update.version,
notes: update.body,
}))
}
pub async fn install(app: &AppHandle) -> Result<(), String> {
let update = find_update(app)
.await?
.ok_or_else(|| "没有可安装的壳更新".to_string())?;
let downloaded = Arc::new(AtomicU64::new(0));
let progress_app = app.clone();
let progress_downloaded = Arc::clone(&downloaded);
update
.download_and_install(
move |chunk, total| {
let downloaded =
progress_downloaded.fetch_add(chunk as u64, Ordering::Relaxed) + chunk as u64;
let percent = total
.filter(|total| *total > 0)
.map(|total| ((downloaded.saturating_mul(100) / total).min(100)) as u8);
let _ = progress_app.emit(
"shell-update-progress",
UpdateProgress {
downloaded,
total,
percent,
},
);
},
{
let app = app.clone();
move || {
let total = downloaded.load(Ordering::Relaxed);
let _ = app.emit(
"shell-update-progress",
UpdateProgress {
downloaded: total,
total: Some(total),
percent: Some(100),
},
);
}
},
)
.await
.map_err(|error| error.to_string())?;
app.restart();
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn endpoint_is_the_public_gitea_package() {
let endpoint = Url::parse(UPDATE_ENDPOINT).unwrap();
assert_eq!(endpoint.scheme(), "https");
assert_eq!(endpoint.host_str(), Some("git.fangsiyuan.top"));
assert!(endpoint.path().ends_with("/latest/latest.json"));
}
}

View File

@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "DeepSeek Harness", "productName": "DeepSeek Harness",
"version": "0.1.0", "version": "0.1.2",
"identifier": "io.github.tommyfang.DshDesktop", "identifier": "io.github.tommyfang.DshDesktop",
"build": { "build": {
"frontendDist": "../ui" "frontendDist": "../ui"
@@ -15,15 +15,21 @@
"csp": null "csp": null
} }
}, },
"plugins": {
"updater": {
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDM1OEI5NkQwMjJCNkFDODMKUldTRHJMWWkwSmFMTlcza3Vkd1UxclQydm0xOW9MTVNxU1JIckkreWRwc1dMTm1HOVVaMHI1SysK"
}
},
"bundle": { "bundle": {
"createUpdaterArtifacts": true,
"active": true, "active": true,
"targets": ["deb", "rpm", "nsis", "msi", "app", "dmg"], "targets": ["deb", "rpm", "nsis", "msi", "app", "dmg"],
"publisher": "TommyFang2077", "publisher": "TommyFang2077",
"homepage": "https://github.com/TommyFang2077/dsh-desktop", "homepage": "https://github.com/TommyFang2077/dsh-easy-desktop",
"copyright": "Copyright © 2026 TommyFang2077", "copyright": "Copyright © 2026 TommyFang2077",
"category": "DeveloperTool", "category": "DeveloperTool",
"shortDescription": "Desktop shell for DeepSeek Harness", "shortDescription": "Give DeepSeek eyes; Anchored Standard ~+8%",
"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.", "longDescription": "Official dsh WebUI in a native window. Built-in ModLens vision gives text-only DeepSeek eyes (paste to read images). On-demand offline SenseVoice dictation writes speech into the composer (Ctrl+E); its model is not bundled. Built-in Anchored Standard raises DeepSeek about 8% over official Standard on Project2 (Ability 91 to 98/99). Credentials stay in ~/.dsh.",
"licenseFile": "../LICENSE", "licenseFile": "../LICENSE",
"icon": [ "icon": [
"icons/32x32.png", "icons/32x32.png",
@@ -35,8 +41,9 @@
"icons/icon.ico" "icons/icon.ico"
], ],
"resources": [ "resources": [
"../plugins/dsh-desktop-vision/", "../plugins/dsh-desktop-voice/",
"../vendor/modlens/", "../vendor/modlens/",
"../vendor/dshmarket/",
"../vendor/anchored-standard/", "../vendor/anchored-standard/",
"../vendor/zero-anchored-standard/" "../vendor/zero-anchored-standard/"
], ],
@@ -62,6 +69,7 @@
}, },
"macOS": { "macOS": {
"minimumSystemVersion": "10.15", "minimumSystemVersion": "10.15",
"infoPlist": "Info.plist",
"dmg": { "dmg": {
"appPosition": { "x": 180, "y": 170 }, "appPosition": { "x": 180, "y": 170 },
"applicationFolderPosition": { "x": 480, "y": 170 } "applicationFolderPosition": { "x": 480, "y": 170 }

View File

@@ -0,0 +1,63 @@
import assert from 'node:assert/strict'
import test from 'node:test'
const nextTurn = () => new Promise(resolve => setImmediate(resolve))
function element(hidden = false) {
return {
disabled: false,
hidden,
textContent: '',
listeners: new Map(),
addEventListener(name, listener) {
this.listeners.set(name, listener)
},
}
}
test('startup waits for an available signed shell update decision', async () => {
const elements = new Map([
['status', element()],
['spinner', element()],
['retry', element(true)],
['detail', element(true)],
['update-panel', element(true)],
['update-notes', element()],
['install-update', element()],
['skip-update', element()],
])
const invokes = []
globalThis.document = {
readyState: 'complete',
getElementById(id) {
return elements.get(id)
},
}
globalThis.window = {
__TAURI__: {
event: { async listen() {} },
core: {
async invoke(command) {
invokes.push(command)
if (command === 'check_shell_update') {
return { version: '1.2.3', notes: 'signed release' }
}
return null
},
},
},
}
await import(new URL('../ui/app.js?update-test', import.meta.url))
await nextTurn()
assert.deepEqual(invokes, ['check_shell_update'])
assert.equal(elements.get('status').textContent, '发现壳更新 1.2.3')
assert.equal(elements.get('update-notes').textContent, 'signed release')
assert.equal(elements.get('update-panel').hidden, false)
await elements.get('skip-update').listeners.get('click')()
await nextTurn()
assert.deepEqual(invokes, ['check_shell_update', 'skip_shell_update'])
assert.equal(elements.get('update-panel').hidden, true)
})

View File

@@ -7,33 +7,69 @@ from pathlib import Path
ROOT = Path(__file__).resolve().parents[1] ROOT = Path(__file__).resolve().parents[1]
class VisionPluginFilesTests(unittest.TestCase): class VisionSettingsDocsTests(unittest.TestCase):
def test_client_registers_system_settings_slots(self): """The vision engine has exactly one documented settings surface: the
client = (ROOT / "plugins" / "dsh-desktop-vision" / "client.js").read_text( modlens card under 设置 → 插件 → 插件配置 (regression guard for the
removed duplicate dsh-desktop-vision section)."""
def test_readme_points_at_the_modlens_config_card(self):
readme = (ROOT / "README.md").read_text(encoding="utf-8")
self.assertIn("设置 → 插件 → 插件配置", readme)
self.assertIn("视觉引擎ModLens", readme)
self.assertNotIn("设置 → 视觉模型", readme)
class VoicePluginFilesTests(unittest.TestCase):
def test_client_registers_composer_and_settings(self):
client = (ROOT / "plugins" / "dsh-desktop-voice" / "client.js").read_text(
encoding="utf-8"
)
host = (ROOT / "plugins" / "dsh-desktop-voice" / "index.js").read_text(
encoding="utf-8"
)
pkg = (ROOT / "plugins" / "dsh-desktop-voice" / "package.json").read_text(
encoding="utf-8" encoding="utf-8"
) )
self.assertIn("settings.section", client) self.assertIn("settings.section", client)
self.assertNotIn("settings.plugins.tab", client) self.assertIn("conversation.input.right", client)
self.assertIn("modlens-vision", client) self.assertIn("dsh-desktop-voice", client)
host = (ROOT / "plugins" / "dsh-desktop-vision" / "index.js").read_text( self.assertIn("Ctrl+E", client)
encoding="utf-8" self.assertIn("dictationMode", client)
self.assertIn("whisper-1", host)
self.assertIn("/dsh-desktop/voice", host)
self.assertIn("/dsh-desktop/voice/transcribe", host)
self.assertIn("/dsh-desktop/voice/model", host)
self.assertIn("SenseVoice", client)
self.assertIn("sherpa-onnx@", host)
self.assertNotIn("SpeechRecognition", client)
self.assertNotIn("engine === 'browser'", client)
self.assertIn("engine === 'openai'", client)
self.assertIn("cfg.engine === 'openai'", host)
self.assertFalse(
(ROOT / "plugins" / "dsh-desktop-voice" / "model.int8.onnx").exists()
) )
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://api.openai.com/v1", host)
self.assertIn("https://generativelanguage.googleapis.com", host) self.assertIn("@deepseek-ai/dsh-client-ui-conversation", pkg)
self.assertIn("https://api.anthropic.com", host) self.assertIn("@deepseek-ai/dsh-client-ui-settings", pkg)
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) class ClipboardIngestTests(unittest.TestCase):
self.assertIn("获取 API", client) def test_inject_delivers_images_as_paste_not_only_drop(self):
self.assertNotIn("qwen-agent", client) ingest = (ROOT / "ui" / "inject" / "ingest.js").read_text(encoding="utf-8")
self.assertIn("example: 'gpt-4o'", host) chrome = (ROOT / "ui" / "inject" / "chrome.js").read_text(encoding="utf-8")
self.assertIn("example: 'gemini-3.6-flash'", host) self.assertIn("window.__dshDesktopIngestFiles", ingest)
self.assertIn("example: 'claude-haiku-4-5-20251001'", host) self.assertIn("/modlens/paste", ingest)
self.assertIn("example: 'gemini-3.6-flash-low'", host) self.assertIn("ClipboardEvent", ingest)
self.assertIn("example: 'haiku'", host) self.assertIn("navigator.clipboard.read", chrome)
self.assertIn("read_clipboard_images", chrome)
self.assertIn("__dshDesktopIngesting", chrome)
self.assertIn('clipboardText(e, "text/html")', chrome)
self.assertNotIn(
'item.kind === "file" && item.getAsFile()',
chrome,
)
self.assertNotIn("steal", chrome)
self.assertNotIn("|| !String(text).trim()", chrome)
class BundledAttributionTests(unittest.TestCase): class BundledAttributionTests(unittest.TestCase):
@@ -47,12 +83,17 @@ class BundledAttributionTests(unittest.TestCase):
self.assertIn("0.1.0-rc.6", text) self.assertIn("0.1.0-rc.6", text)
self.assertIn("3.16.6", text) self.assertIn("3.16.6", text)
self.assertIn("ffb845c5480adc953392a6db6f8a98ede621174b", text) self.assertIn("ffb845c5480adc953392a6db6f8a98ede621174b", text)
self.assertIn("dsh-desktop-vision", text) self.assertIn("dsh-desktop-voice", text)
self.assertIn("https://github.com/dsh-market/dsh-market", text)
self.assertIn("1.10.1", text)
self.assertIn("dsh-plugin", readme) self.assertIn("dsh-plugin", readme)
self.assertIn("带上眼睛", readme)
self.assertIn("+8%", readme)
self.assertTrue((ROOT / "docs" / "licenses" / "modlens.LICENSE").is_file()) self.assertTrue((ROOT / "docs" / "licenses" / "modlens.LICENSE").is_file())
self.assertTrue( self.assertTrue(
(ROOT / "docs" / "licenses" / "dsh-anchored-standard.NOTICE").is_file() (ROOT / "docs" / "licenses" / "dsh-anchored-standard.NOTICE").is_file()
) )
self.assertTrue((ROOT / "docs" / "licenses" / "dshmarket.LICENSE").is_file())
for name in ("splash.png", "session.png", "vision.png", "menu.png"): for name in ("splash.png", "session.png", "vision.png", "menu.png"):
self.assertTrue((ROOT / "docs" / "screenshots" / name).is_file()) self.assertTrue((ROOT / "docs" / "screenshots" / name).is_file())

135
tests/test_release.py Normal file
View File

@@ -0,0 +1,135 @@
import json
import subprocess
import tempfile
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
SCRIPT = ROOT / "scripts" / "build-gitea-update.py"
class GiteaUpdateManifestTests(unittest.TestCase):
def test_normalizes_matrix_artifacts_and_embeds_signatures(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
artifacts = root / "artifacts"
output = root / "staged"
fixtures = {
"gitea-macos-arm64": ".app.tar.gz",
"gitea-macos-x64": ".app.tar.gz",
"gitea-linux": ".AppImage",
"gitea-windows": ".exe",
}
for matrix, suffix in fixtures.items():
bundle = artifacts / matrix / "target" / "release" / "bundle"
bundle.mkdir(parents=True)
artifact = bundle / f"DeepSeek Harness{suffix}"
artifact.write_bytes(matrix.encode())
artifact.with_name(artifact.name + ".sig").write_text(
f"signature-{matrix}\n", encoding="utf-8"
)
subprocess.run(
[
"python3",
str(SCRIPT),
"--artifacts",
str(artifacts),
"--output",
str(output),
"--version",
"1.2.3",
"--package-base-url",
"http://gitea.example/api/packages/u/generic/app",
"--pub-date",
"2026-08-16T00:00:00Z",
],
check=True,
)
manifest = json.loads((output / "latest.json").read_text(encoding="utf-8"))
self.assertEqual(manifest["version"], "1.2.3")
self.assertEqual(
set(manifest["platforms"]),
{
"darwin-aarch64-app",
"darwin-aarch64",
"darwin-x86_64-app",
"darwin-x86_64",
"linux-x86_64-appimage",
"linux-x86_64",
"windows-x86_64-nsis",
"windows-x86_64",
},
)
windows = manifest["platforms"]["windows-x86_64"]
self.assertEqual(windows["signature"], "signature-gitea-windows")
self.assertTrue(windows["url"].endswith("/1.2.3/dsh-easy-desktop_1.2.3_windows_x86_64.exe"))
def test_normalizes_flat_github_assets(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
artifacts = root / "artifacts"
output = root / "staged"
artifacts.mkdir()
fixtures = {
"DeepSeek.Harness_0.1.1_aarch64.app.tar.gz": "darwin-aarch64",
"DeepSeek.Harness_0.1.1_aarch64.app.tar.gz.sig": "darwin-aarch64",
"DeepSeek.Harness_0.1.1_aarch64.dmg": "darwin-aarch64",
"DeepSeek.Harness_0.1.1_x64.app.tar.gz": "darwin-x86_64",
"DeepSeek.Harness_0.1.1_x64.app.tar.gz.sig": "darwin-x86_64",
"DeepSeek.Harness_0.1.1_x64.dmg": "darwin-x86_64",
"DeepSeek.Harness_0.1.1_amd64.deb": "linux-x86_64",
"DeepSeek.Harness_0.1.1_amd64.AppImage": "linux-x86_64",
"DeepSeek.Harness_0.1.1_amd64.AppImage.sig": "linux-x86_64",
"DeepSeek.Harness-0.1.1-1.x86_64.rpm": "linux-x86_64",
"DeepSeek.Harness_0.1.1_x64-setup.exe": "windows-x86_64",
"DeepSeek.Harness_0.1.1_x64-setup.exe.sig": "windows-x86_64",
"DeepSeek.Harness_0.1.1_x64_en-US.msi": "windows-x86_64",
"io.github.tommyfang.DshDesktop.flatpak": "linux-x86_64",
}
for name, marker in fixtures.items():
(artifacts / name).write_bytes(marker.encode())
subprocess.run(
[
"python3",
str(SCRIPT),
"--flat-artifacts",
str(artifacts),
"--output",
str(output),
"--version",
"1.2.3",
"--package-base-url",
"http://gitea.example/api/packages/u/generic/app",
],
check=True,
)
manifest = json.loads((output / "latest.json").read_text(encoding="utf-8"))
self.assertEqual(
set(manifest["platforms"]),
{
"darwin-aarch64-app",
"darwin-aarch64",
"darwin-x86_64-app",
"darwin-x86_64",
"linux-x86_64-appimage",
"linux-x86_64",
"windows-x86_64-nsis",
"windows-x86_64",
},
)
# Each updater target embeds its own .sig content; fixed-name
# assets (dmg/rpm/deb/flatpak) are deliverables only.
self.assertEqual(
manifest["platforms"]["linux-x86_64"]["signature"],
"linux-x86_64",
)
self.assertNotIn("linux-x86_64-app", manifest["platforms"])
if __name__ == "__main__":
unittest.main()

233
tests/voice_client.test.mjs Normal file
View File

@@ -0,0 +1,233 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import vm from 'node:vm'
const CLIENT = new URL('../plugins/dsh-desktop-voice/client.js', import.meta.url)
function loadVoiceClient({ confirmInstall = () => false, engine = 'sensevoice', holdInstall = false } = {}) {
let plugin
const registered = new Map()
const indicatorParts = {
'.label': { textContent: '' },
'.kbd': { textContent: '' },
'.stop': { hidden: false },
'.progress': { hidden: true, value: 0 },
'.percent': { hidden: true, textContent: '' },
}
const indicator = {
classList: { toggle() {} },
querySelector(selector) {
return indicatorParts[selector]
},
}
const window = {
__ModuleLoader__: {
load(definition) {
plugin = definition.factory((id) => {
if (id === 'react') return React
if (id === 'react/jsx-runtime') return jsx
throw new Error(`unexpected module: ${id}`)
})
},
},
addEventListener() {},
confirm: confirmInstall,
}
const document = {
querySelector() {
return {}
},
getElementById() {
return indicator
},
}
const jsx = {
jsx(type, props, key) {
return { type, props: props || {}, key }
},
jsxs(type, props, key) {
return { type, props: props || {}, key }
},
}
let hooks = []
let hookIndex = 0
const React = {
useState(initial) {
const index = hookIndex++
if (!(index in hooks)) hooks[index] = typeof initial === 'function' ? initial() : initial
return [hooks[index], (next) => {
hooks[index] = typeof next === 'function' ? next(hooks[index]) : next
}]
},
useRef(initial) {
const index = hookIndex++
if (!(index in hooks)) hooks[index] = { current: initial }
return hooks[index]
},
useEffect(effect, dependencies) {
const index = hookIndex++
const previous = hooks[index]
const changed = !dependencies || !previous || dependencies.some((value, i) => value !== previous[i])
hooks[index] = dependencies || null
if (changed) effect()
},
}
const requests = []
let micRequests = 0
const context = {
AudioContext: function AudioContext() {},
Blob,
DataView,
Float32Array,
MediaRecorder: undefined,
URL,
clearTimeout,
clearInterval,
console,
document,
encodeURIComponent,
fetch: async (url, options = {}) => {
requests.push({ url, method: options.method || 'GET' })
if (url === '/dsh-desktop/voice') {
return {
ok: true,
async json() {
return {
enabled: true,
engine,
dictationMode: 'toggle',
language: 'zh',
modelInstalled: false,
}
},
}
}
if (url === '/dsh-desktop/voice/model' && (!options.method || options.method === 'GET')) {
return {
ok: true,
async json() {
return { status: 'installing', percent: 42, stage: '正在下载 SenseVoice 模型…' }
},
}
}
if (url === '/dsh-desktop/voice/model' && options.method === 'POST') {
if (holdInstall) return new Promise(() => {})
return {
ok: true,
async json() {
return { installed: true }
},
}
}
throw new Error(`unexpected request: ${options.method || 'GET'} ${url}`)
},
navigator: {
language: 'zh-CN',
platform: 'Linux x86_64',
mediaDevices: {
getUserMedia() {
micRequests += 1
return new Promise(() => {})
},
},
},
setInterval,
setTimeout,
window,
}
vm.runInNewContext(readFileSync(CLIENT, 'utf8'), context, { filename: CLIENT.pathname })
plugin.apply({
slots: {
inject(_name, register) {
register()
},
register(meta, component) {
registered.set(meta.name, component)
},
},
})
const MicButton = registered.get('conversation.input.right')
assert.equal(typeof MicButton, 'function')
const VoiceSettings = registered.get('settings.section')
assert.equal(typeof VoiceSettings, 'function')
const props = {
inputActions: { setDraft() {} },
useInput() {
return ''
},
}
return {
indicatorParts,
requests,
get micRequests() {
return micRequests
},
async renderMicReady() {
hooks = []
hookIndex = 0
MicButton(props)
await new Promise((resolve) => setTimeout(resolve, 0))
hookIndex = 0
return MicButton(props)
},
async renderSettingsReady() {
hooks = []
hookIndex = 0
VoiceSettings({})
await new Promise((resolve) => setTimeout(resolve, 0))
hookIndex = 0
return VoiceSettings({})
},
}
}
test('microphone click offers to install a missing SenseVoice model', async () => {
let prompts = 0
const harness = loadVoiceClient({
confirmInstall(message) {
prompts += 1
assert.match(message, /SenseVoice/)
assert.match(message, /安装/)
return true
},
})
const button = await harness.renderMicReady()
button.props.onClick()
await new Promise((resolve) => setTimeout(resolve, 0))
assert.equal(prompts, 1)
assert.deepEqual(
harness.requests.filter((request) => request.method === 'POST'),
[{ url: '/dsh-desktop/voice/model', method: 'POST' }],
)
assert.equal(harness.micRequests, 1)
})
function textOf(node) {
if (node == null || typeof node === 'boolean') return ''
if (typeof node === 'string' || typeof node === 'number') return String(node)
if (Array.isArray(node)) return node.map(textOf).join(' ')
return textOf(node.props && node.props.children)
}
test('OpenAI fields only render for the OpenAI engine', async () => {
const localSettings = textOf(await loadVoiceClient({ engine: 'sensevoice' }).renderSettingsReady())
assert.doesNotMatch(localSettings, /接口地址|API 密钥|Whisper 模型/)
const openAISettings = textOf(await loadVoiceClient({ engine: 'openai' }).renderSettingsReady())
assert.match(openAISettings, /接口地址/)
assert.match(openAISettings, /API 密钥/)
assert.match(openAISettings, /Whisper 模型/)
})
test('model installation renders live progress', async () => {
const harness = loadVoiceClient({ confirmInstall: () => true, holdInstall: true })
const button = await harness.renderMicReady()
button.props.onClick()
await new Promise((resolve) => setTimeout(resolve, 0))
assert.equal(harness.indicatorParts['.progress'].hidden, false)
assert.equal(harness.indicatorParts['.progress'].value, 42)
assert.equal(harness.indicatorParts['.percent'].textContent, '42%')
})

112
tests/voice_host.test.mjs Normal file
View File

@@ -0,0 +1,112 @@
import assert from 'node:assert/strict'
import { mkdirSync, mkdtempSync, rmSync, truncateSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import test from 'node:test'
function response() {
return {
status: 0,
body: '',
writeHead(status) {
this.status = status
return this
},
end(body = '') {
this.body = body
},
}
}
function request(method, url) {
return {
method,
url,
headers: {},
async *[Symbol.asyncIterator]() {},
}
}
test('voice host reports and accepts an on-demand SenseVoice installation', async () => {
const home = mkdtempSync(join(tmpdir(), 'dsh-desktop-voice-test-'))
process.env.DSH_DESKTOP_VOICE_HOME = home
process.env.XDG_CONFIG_HOME = join(home, 'config')
const configDir = join(home, 'config', 'dsh-desktop')
mkdirSync(configDir, { recursive: true })
writeFileSync(join(configDir, 'voice.json'), JSON.stringify({ engine: 'auto' }))
try {
const routes = new Map()
const plugin = await import(`../plugins/dsh-desktop-voice/index.js?test=${Date.now()}`)
plugin.apply({
inject(_dependencies, load) {
load({
webServer: {
register(route) {
routes.set(route.path, route.handler)
},
},
})
},
})
const configResponse = response()
await routes.get('/dsh-desktop/voice')(request('GET', '/dsh-desktop/voice'), configResponse)
assert.equal(configResponse.status, 200)
const missing = JSON.parse(configResponse.body)
assert.equal(missing.engine, 'sensevoice')
assert.equal(missing.modelInstalled, false)
assert.equal(missing.modelStatus, 'missing')
assert.deepEqual(missing.options.engines.map((engine) => engine.id), ['sensevoice', 'openai'])
const missingProgressResponse = response()
await routes.get('/dsh-desktop/voice/model')(
request('GET', '/dsh-desktop/voice/model'),
missingProgressResponse,
)
assert.deepEqual(JSON.parse(missingProgressResponse.body), {
status: 'missing',
percent: 0,
stage: '',
})
const modelDir = join(home, 'sensevoice')
const packageDir = join(home, 'runtime', 'node_modules', 'sherpa-onnx')
mkdirSync(modelDir, { recursive: true })
mkdirSync(packageDir, { recursive: true })
writeFileSync(join(modelDir, 'model.int8.onnx'), '')
truncateSync(join(modelDir, 'model.int8.onnx'), 239233841)
writeFileSync(join(modelDir, 'tokens.txt'), '')
truncateSync(join(modelDir, 'tokens.txt'), 315894)
writeFileSync(
join(modelDir, 'installed.json'),
JSON.stringify({
modelVersion: '2365baeacb507f821a0c8120fcee3d484dba7a07',
runtimeVersion: '1.13.5',
}),
)
writeFileSync(join(packageDir, 'package.json'), JSON.stringify({ version: '1.13.5' }))
const installResponse = response()
await routes.get('/dsh-desktop/voice/model')(request('POST', '/dsh-desktop/voice/model'), installResponse)
assert.equal(installResponse.status, 200)
const installed = JSON.parse(installResponse.body)
assert.equal(installed.installed, true)
assert.equal(installed.modelInstalled, true)
assert.equal(installed.modelStatus, 'installed')
const installedProgressResponse = response()
await routes.get('/dsh-desktop/voice/model')(
request('GET', '/dsh-desktop/voice/model'),
installedProgressResponse,
)
assert.deepEqual(JSON.parse(installedProgressResponse.body), {
status: 'installed',
percent: 100,
stage: 'SenseVoice 已安装',
})
} finally {
delete process.env.XDG_CONFIG_HOME
delete process.env.DSH_DESKTOP_VOICE_HOME
rmSync(home, { recursive: true, force: true })
}
})

View File

@@ -2,6 +2,10 @@ const statusEl = document.getElementById("status");
const spinner = document.getElementById("spinner"); const spinner = document.getElementById("spinner");
const retry = document.getElementById("retry"); const retry = document.getElementById("retry");
const detail = document.getElementById("detail"); const detail = document.getElementById("detail");
const updatePanel = document.getElementById("update-panel");
const updateNotes = document.getElementById("update-notes");
const installUpdate = document.getElementById("install-update");
const skipUpdate = document.getElementById("skip-update");
function tauri() { function tauri() {
return window.__TAURI__; return window.__TAURI__;
@@ -18,6 +22,13 @@ function setStatus(message, kind) {
} }
} }
function continueBoot(api) {
updatePanel.hidden = true;
spinner.hidden = false;
setStatus("正在启动…", "ok");
return api.core.invoke("skip_shell_update");
}
async function boot() { async function boot() {
const api = tauri(); const api = tauri();
if (!api) { if (!api) {
@@ -39,10 +50,42 @@ async function boot() {
window.location.replace(event.payload.url); window.location.replace(event.payload.url);
} }
}); });
await api.event.listen("shell-update-progress", function (event) {
const progress = event.payload || {};
const suffix = typeof progress.percent === "number" ? ` ${progress.percent}%` : "";
setStatus(`正在下载更新…${suffix}`, "ok");
});
retry.addEventListener("click", function () { retry.addEventListener("click", function () {
setStatus("正在重新启动…", "ok"); setStatus("正在重新启动…", "ok");
api.core.invoke("restart"); api.core.invoke("restart");
}); });
skipUpdate.addEventListener("click", function () {
continueBoot(api);
});
installUpdate.addEventListener("click", async function () {
installUpdate.disabled = true;
skipUpdate.disabled = true;
spinner.hidden = false;
setStatus("正在准备签名更新…", "ok");
try {
await api.core.invoke("install_shell_update");
} catch (error) {
installUpdate.disabled = false;
skipUpdate.disabled = false;
spinner.hidden = true;
statusEl.textContent = `更新失败:${String(error)}`;
}
});
const update = await api.core.invoke("check_shell_update");
if (update) {
spinner.hidden = true;
retry.hidden = true;
statusEl.textContent = `发现壳更新 ${update.version}`;
updateNotes.textContent = update.notes || "更新包已经签名,安装前会在本机完成校验。";
updatePanel.hidden = false;
}
} }
if (document.readyState === "loading") { if (document.readyState === "loading") {

View File

@@ -13,6 +13,13 @@
<h1>DeepSeek Harness</h1> <h1>DeepSeek Harness</h1>
<p id="status">正在启动…</p> <p id="status">正在启动…</p>
<div class="ring" id="spinner" aria-hidden="true"></div> <div class="ring" id="spinner" aria-hidden="true"></div>
<section id="update-panel" class="update-panel" hidden>
<p id="update-notes"></p>
<div class="update-actions">
<button type="button" class="pill" id="install-update">下载并安装</button>
<button type="button" class="pill secondary" id="skip-update">稍后</button>
</div>
</section>
<button type="button" class="pill" id="retry" hidden>重试</button> <button type="button" class="pill" id="retry" hidden>重试</button>
<pre id="detail" hidden></pre> <pre id="detail" hidden></pre>
</section> </section>

View File

@@ -146,23 +146,81 @@
inject(); inject();
} }
function clipboardText(e, type) {
try {
return e.clipboardData ? (e.clipboardData.getData(type) || "") : "";
} catch (err) {
return "";
}
}
async function readAsyncClipboardImages() {
if (!navigator.clipboard || !navigator.clipboard.read) return [];
try {
const items = await navigator.clipboard.read();
const files = [];
for (const item of items) {
for (const type of item.types) {
if (type.indexOf("image/") !== 0) continue;
const blob = await item.getType(type);
if (!blob || !blob.size) continue;
const ext = (type.split("/")[1] || "png").replace("jpeg", "jpg");
files.push(new File([blob], "clipboard." + ext, { type: blob.type || type }));
}
}
return files;
} catch (err) {
return [];
}
}
const harness = /^(127\.0\.0\.1|localhost|\[::1\])$/.test(location.hostname); const harness = /^(127\.0\.0\.1|localhost|\[::1\])$/.test(location.hostname);
if (harness && location.protocol.indexOf("http") === 0) { if (harness && location.protocol.indexOf("http") === 0) {
document.addEventListener("paste", async function (e) { document.addEventListener("paste", async function (e) {
const t = api(); if (window.__dshDesktopIngesting) return;
if (!t || !t.core) return; const ingest = window.__dshDesktopIngestFiles;
const fromEvent = window.__dshDesktopImageFilesFromEvent;
const pathLike = window.__dshDesktopLooksLikeImagePath;
if (!ingest) return;
try { try {
const items = e.clipboardData ? Array.from(e.clipboardData.items) : []; const local = fromEvent ? fromEvent(e) : [];
if (items.some(function (item) { return item.kind === "file" && item.getAsFile(); })) { const text = clipboardText(e, "text/plain");
return; const uris = clipboardText(e, "text/uri-list");
} const html = clipboardText(e, "text/html");
const files = await t.core.invoke("read_clipboard_images"); const imagePath = Boolean(pathLike && (pathLike(text) || pathLike(uris)));
if (files && files.length) { const hasText = Boolean(String(text).trim() || String(html).trim());
// Image files in the event, or a pasted image path, are ours. Empty
// payload is the Linux case where the image is only on the native
// clipboard. Non-empty text/html must reach the page — do not cancel
// first and then fail to recover an image.
if (!local.length && !imagePath && hasText) return;
e.preventDefault(); e.preventDefault();
e.stopImmediatePropagation(); e.stopImmediatePropagation();
if (window.__dshDesktopPasteFiles) window.__dshDesktopPasteFiles(files); window.__dshDesktopIngesting = true;
try {
if (local.length) {
await ingest(local);
return;
}
let files = await readAsyncClipboardImages();
if (!files.length) {
const t = api();
if (t && t.core) {
const payload = await t.core.invoke("read_clipboard_images");
if (payload && payload.length && window.__dshDesktopPasteFiles) {
await window.__dshDesktopPasteFiles(payload);
return;
}
}
}
if (files.length) await ingest(files);
} finally {
window.__dshDesktopIngesting = false;
}
} catch (err) {
window.__dshDesktopIngesting = false;
} }
} catch (err) {}
}, true); }, true);
} }
})(); })();

View File

@@ -1,45 +1,179 @@
(function () { (function () {
const IMAGE_PATH = /^(?:file:\/\/|(?:\/|\.{1,2}\/)).+\.(?:png|jpe?g|gif|webp|bmp|tiff?)$/i; const IMAGE_PATH = /^(?:file:\/\/|(?:\/|\.{1,2}\/)).+\.(?:png|jpe?g|gif|webp|bmp|tiff?)$/i;
const IMAGE_MIME = /^image\/(png|jpe?g|gif|webp|bmp|tiff?)$/i;
function looksLikeImagePath(text) { function looksLikeImagePath(text) {
const first = (text || "").trim().split(/\s+/, 1)[0] || ""; const first = (text || "").trim().split(/\s+/, 1)[0] || "";
return IMAGE_PATH.test(first); return IMAGE_PATH.test(first);
} }
document.addEventListener("paste", function (e) {
try { function addImageFile(out, seen, file) {
const items = Array.from(e.clipboardData ? e.clipboardData.items : []); if (!file || !file.size) return;
const files = items.filter(function (item) { return item.kind === "file"; }) const type = file.type || "";
.map(function (item) { return item.getAsFile(); }) if (type && !IMAGE_MIME.test(type)) return;
.filter(Boolean); if (!type && !IMAGE_PATH.test(file.name || "")) return;
if (files.length > 0) return; const key = (file.name || "") + ":" + file.size + ":" + type;
const text = e.clipboardData ? (e.clipboardData.getData("text/plain") || "") : ""; if (seen.has(key)) return;
const uris = e.clipboardData ? (e.clipboardData.getData("text/uri-list") || "") : ""; seen.add(key);
if (looksLikeImagePath(text) || looksLikeImagePath(uris)) { out.push(file);
e.preventDefault();
e.stopImmediatePropagation();
} }
function imageFilesFromEvent(e) {
const out = [];
const seen = new Set();
const data = e.clipboardData || e.dataTransfer;
if (!data) return out;
const items = data.items ? Array.from(data.items) : [];
for (const item of items) {
if (item.kind === "file" || (item.type && item.type.indexOf("image/") === 0)) {
try {
addImageFile(out, seen, item.getAsFile());
} catch (err) {} } catch (err) {}
}, true); }
}
if (data.files) {
for (const file of data.files) addImageFile(out, seen, file);
}
return out;
}
function composer() {
const el = document.activeElement;
if (el && (el.tagName === "TEXTAREA" || el.tagName === "INPUT" || el.isContentEditable)) {
return el;
}
return document.querySelector("form textarea, textarea, [contenteditable='true']");
}
function insertText(target, text) {
const el = target && (target.tagName === "TEXTAREA" || target.tagName === "INPUT")
? target
: composer();
if (!el || (el.tagName !== "TEXTAREA" && el.tagName !== "INPUT")) return;
el.focus();
let inserted = false;
try {
inserted = document.execCommand("insertText", false, text);
} catch (err) {
inserted = false;
}
if (!inserted) {
const proto = el.tagName === "TEXTAREA"
? window.HTMLTextAreaElement.prototype
: window.HTMLInputElement.prototype;
const setter = Object.getOwnPropertyDescriptor(proto, "value").set;
const start = el.selectionStart || el.value.length;
const end = el.selectionEnd || start;
setter.call(el, el.value.slice(0, start) + text + el.value.slice(end));
el.dispatchEvent(new Event("input", { bubbles: true }));
}
}
function dataTransferOf(files) {
const dt = new DataTransfer();
for (const file of files) dt.items.add(file);
return dt;
}
function dispatchWithData(type, target, dt, key) {
let ev;
try {
if (type === "paste") {
ev = new ClipboardEvent("paste", { bubbles: true, cancelable: true, clipboardData: dt });
} else {
ev = new DragEvent(type, { bubbles: true, cancelable: true, dataTransfer: dt });
}
} catch (err) {
ev = new Event(type, { bubbles: true, cancelable: true });
}
try {
Object.defineProperty(ev, key, { value: dt, configurable: true });
} catch (err) {}
target.dispatchEvent(ev);
return ev;
}
function currentModelLabel() {
const buttons = document.querySelectorAll("button[aria-label]");
for (const button of buttons) {
const label = button.getAttribute("aria-label") || "";
if (/选择模型|select model|current model/i.test(label)) return label;
}
return "";
}
function shouldTakeoverPaste() {
const label = currentModelLabel();
return fetch("/modlens/paste?model=" + encodeURIComponent(label))
.then(function (res) {
if (!res.ok) return false;
return res.json().then(function (body) {
return body && body.takeover === true;
});
})
.catch(function () {
return false;
});
}
function uploadModlens(files) {
return Promise.all(
files.map(function (file) {
return file.arrayBuffer().then(function (buffer) {
return fetch("/modlens/paste", { method: "POST", body: buffer }).then(function (res) {
if (!res.ok) throw new Error("modlens paste failed " + res.status);
return res.json();
});
});
})
).then(function (results) {
const text = results.map(function (r) { return r.path; }).filter(Boolean).join(" ");
if (text) insertText(composer(), text + " ");
});
}
function deliverNative(files) {
const prev = window.__dshDesktopIngesting;
window.__dshDesktopIngesting = true;
try {
const dt = dataTransferOf(files);
const target = composer() || document;
const paste = dispatchWithData("paste", target, dt, "clipboardData");
if (paste.defaultPrevented) return;
dispatchWithData("drop", document, dt, "dataTransfer");
} finally {
window.__dshDesktopIngesting = prev;
}
}
window.__dshDesktopLooksLikeImagePath = looksLikeImagePath;
window.__dshDesktopImageFilesFromEvent = imageFilesFromEvent;
window.__dshDesktopIngestFiles = function (files) {
if (!files || !files.length) return Promise.resolve();
return shouldTakeoverPaste()
.then(function (takeover) {
if (takeover) return uploadModlens(files);
deliverNative(files);
})
.catch(function (err) {
console.error("dsh-desktop paste image failed", err);
});
};
window.__dshDesktopPasteFiles = function (items) { window.__dshDesktopPasteFiles = function (items) {
try { try {
const dt = new DataTransfer(); const files = [];
for (const item of items) { for (const item of items) {
const bin = atob(item.b64); const bin = atob(item.b64);
const bytes = new Uint8Array(bin.length); const bytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
dt.items.add(new File([bytes], item.name, { type: item.type })); files.push(new File([bytes], item.name, { type: item.type }));
} }
const drop = new DragEvent("drop", { return window.__dshDesktopIngestFiles(files);
bubbles: true,
cancelable: true,
dataTransfer: dt
});
try {
Object.defineProperty(drop, "dataTransfer", { value: dt, configurable: true });
} catch (e) {}
document.dispatchEvent(drop);
} catch (err) { } catch (err) {
console.error("dsh-desktop paste image failed", err); console.error("dsh-desktop paste image failed", err);
return Promise.resolve();
} }
}; };
})(); })();

View File

@@ -98,6 +98,40 @@ h1 {
.pill[hidden] { display: none; } .pill[hidden] { display: none; }
.pill.secondary {
background: var(--card);
color: var(--text);
}
.pill:disabled { opacity: 0.55; }
.update-panel {
margin-top: 20px;
padding: 16px;
border-radius: 14px;
background: var(--card);
}
.update-panel[hidden] { display: none; }
#update-notes {
max-height: 120px;
margin: 0;
overflow: auto;
color: var(--muted);
font-size: 12px;
line-height: 1.5;
white-space: pre-wrap;
}
.update-actions {
display: flex;
justify-content: center;
gap: 8px;
}
.update-actions .pill { margin-top: 16px; }
#detail { #detail {
margin: 22px 0 0; margin: 22px 0 0;
text-align: left; text-align: left;