diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1cdce0d..084828f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -84,7 +84,7 @@ jobs: - name: linux platform: ubuntu-22.04 rust_targets: "" - args: --bundles deb,rpm + args: --bundles deb,rpm,appimage - name: windows platform: windows-latest rust_targets: "" @@ -128,6 +128,9 @@ jobs: - uses: tauri-apps/tauri-action@v1 env: 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 }} + DSH_DESKTOP_UPDATER_PUBKEY: ${{ secrets.DSH_DESKTOP_UPDATER_PUBKEY }} with: projectPath: . tagName: v__VERSION__ @@ -139,12 +142,32 @@ jobs: - Windows / macOS / deb / rpm 需要本机已安装 `dsh`(`npm i -g @deepseek-ai/dsh`),或设置 `DSH_DESKTOP_DSH_BIN`。 - Flatpak 自带 Node.js 与 `dsh`。 - macOS 包未公证,需在「系统设置 → 隐私与安全性」中允许打开。 + - 中国大陆用户可从 Gitea 镜像下载安装包;壳更新使用同一镜像并在安装前校验 Tauri 签名。 releaseDraft: false prerelease: false uploadUpdaterJson: false - uploadUpdaterSignatures: false + uploadUpdaterSignatures: true 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: name: Flatpak needs: [test, version, vendor] @@ -176,3 +199,44 @@ jobs: fail_on_unmatched_files: true env: 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 + + 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)" \ + --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 diff --git a/Cargo.lock b/Cargo.lock index a962542..b1969ee 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -97,6 +97,15 @@ version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "arboard" version = "3.6.1" @@ -799,6 +808,17 @@ dependencies = [ "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]] name = "derive_more" version = "2.1.1" @@ -935,6 +955,7 @@ dependencies = [ "dirs", "libc", "regex", + "semver", "serde", "serde_json", "shlex 1.3.0", @@ -961,7 +982,9 @@ dependencies = [ "tauri-build", "tauri-plugin-opener", "tauri-plugin-single-instance", + "tauri-plugin-updater", "url", + "webkit2gtk", ] [[package]] @@ -1160,6 +1183,16 @@ dependencies = [ "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]] name = "find-msvc-tools" version = "0.1.11" @@ -1746,6 +1779,21 @@ dependencies = [ "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]] name = "hyper-util" version = "0.1.20" @@ -2088,6 +2136,36 @@ dependencies = [ "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]] name = "jni-sys" version = "0.3.1" @@ -2277,6 +2355,12 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "minisign-verify" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -2529,6 +2613,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ "bitflags 2.13.1", "block2", + "libc", "objc2", "objc2-core-foundation", ] @@ -2544,6 +2629,18 @@ dependencies = [ "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]] name = "objc2-quartz-core" version = "0.3.2" @@ -2624,6 +2721,12 @@ dependencies = [ "libc", ] +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + [[package]] name = "option-ext" version = "0.2.0" @@ -2647,7 +2750,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.45.0", + "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]] @@ -3079,15 +3196,20 @@ dependencies = [ "http-body", "http-body-util", "hyper", + "hyper-rustls", "hyper-util", "js-sys", "log", "percent-encoding", "pin-project-lite", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", "serde", "serde_json", "sync_wrapper", "tokio", + "tokio-rustls", "tokio-util", "tower", "tower-http", @@ -3099,6 +3221,20 @@ dependencies = [ "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]] name = "rustc-hash" version = "2.1.3" @@ -3127,6 +3263,79 @@ dependencies = [ "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]] name = "rustversion" version = "1.0.23" @@ -3142,6 +3351,15 @@ dependencies = [ "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]] name = "schemars" version = "0.8.22" @@ -3199,6 +3417,29 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "selectors" version = "0.36.1" @@ -3426,6 +3667,22 @@ version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "siphasher" version = "1.0.3" @@ -3538,6 +3795,12 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "swift-rs" version = "1.0.7" @@ -3632,7 +3895,7 @@ dependencies = [ "gdkwayland-sys", "gdkx11-sys", "gtk", - "jni", + "jni 0.21.1", "libc", "log", "ndk", @@ -3665,6 +3928,17 @@ dependencies = [ "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]] name = "target-lexicon" version = "0.12.16" @@ -3688,7 +3962,7 @@ dependencies = [ "gtk", "heck 0.5.0", "http", - "jni", + "jni 0.21.1", "libc", "log", "mime", @@ -3838,6 +4112,39 @@ dependencies = [ "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]] name = "tauri-runtime" version = "2.11.3" @@ -3848,7 +4155,7 @@ dependencies = [ "dpi", "gtk", "http", - "jni", + "jni 0.21.1", "objc2", "objc2-ui-kit", "objc2-web-kit", @@ -3871,7 +4178,7 @@ checksum = "4e6fac707727b7a2f48e4ded90976324267371073edbb415ffb73bb0458d203f" dependencies = [ "gtk", "http", - "jni", + "jni 0.21.1", "log", "objc2", "objc2-app-kit", @@ -4083,6 +4390,16 @@ dependencies = [ "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]] name = "tokio-util" version = "0.7.19" @@ -4407,6 +4724,12 @@ version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "url" version = "2.5.8" @@ -4726,6 +5049,15 @@ dependencies = [ "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]] name = "webview2-com" version = "0.38.2" @@ -4971,6 +5303,15 @@ dependencies = [ "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]] name = "windows-sys" version = "0.59.0" @@ -5284,7 +5625,7 @@ dependencies = [ "gtk", "http", "javascriptcore-rs", - "jni", + "jni 0.21.1", "libc", "ndk", "objc2", @@ -5348,6 +5689,16 @@ version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "yoke" version = "0.8.3" @@ -5482,6 +5833,12 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + [[package]] name = "zerotrie" version = "0.2.5" @@ -5515,6 +5872,18 @@ dependencies = [ "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]] name = "zmij" version = "1.0.23" diff --git a/Makefile b/Makefile index 051d673..e67dbf1 100644 --- a/Makefile +++ b/Makefile @@ -4,10 +4,12 @@ PREFIX ?= $(HOME)/.local APP_ID := io.github.tommyfang.DshDesktop DSH_VERSION := 0.1.0-rc.6 MODLENS_VERSION := 3.16.6 +MARKET_VERSION := 1.9.0 ANCHORED_COMMIT := ffb845c5480adc953392a6db6f8a98ede621174b ANCHORED_REPO := https://github.com/xiaobright/dsh-anchored-standard.git VENDOR_DIR := vendor/dsh-prefix MODLENS_DIR := vendor/modlens +MARKET_DIR := vendor/dshmarket ANCHORED_DIR := vendor/anchored-standard ZERO_DIR := vendor/zero-anchored-standard FLATPAK ?= flatpak @@ -35,16 +37,19 @@ build: test: $(CARGO) test -p dsh-core $(PYTHON) -m unittest discover -s tests -v + node --test tests/*.test.mjs 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)/$(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 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: rm -rf vendor/.anchored-src $(ANCHORED_DIR) $(ZERO_DIR) @@ -79,11 +84,21 @@ install: build mkdir -p $(DESTDIR)$(PREFIX)/share/dsh-desktop; \ cp -R $(MODLENS_DIR) $(DESTDIR)$(PREFIX)/share/dsh-desktop/modlens; \ fi + if [ -d $(MARKET_DIR)/node_modules/dshmarket ]; then \ + rm -rf $(DESTDIR)$(PREFIX)/share/dsh-desktop/market; \ + mkdir -p $(DESTDIR)$(PREFIX)/share/dsh-desktop; \ + cp -R $(MARKET_DIR) $(DESTDIR)$(PREFIX)/share/dsh-desktop/market; \ + fi if [ -f plugins/dsh-desktop-vision/package.json ]; then \ rm -rf $(DESTDIR)$(PREFIX)/share/dsh-desktop/vision; \ mkdir -p $(DESTDIR)$(PREFIX)/share/dsh-desktop; \ cp -R plugins/dsh-desktop-vision $(DESTDIR)$(PREFIX)/share/dsh-desktop/vision; \ fi + if [ -f 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 if [ -f $(ANCHORED_DIR)/preset.yml ]; then \ rm -rf $(DESTDIR)$(PREFIX)/share/dsh-desktop/anchored-standard; \ mkdir -p $(DESTDIR)$(PREFIX)/share/dsh-desktop; \ @@ -105,7 +120,7 @@ uninstall: rm -f "$(DESTDIR)$(PREFIX)/share/icons/hicolor/$${size}x$${size}/apps/$(APP_ID).png"; \ done -flatpak-build: $(VENDOR_DIR)/bin/dsh $(MODLENS_DIR)/node_modules/@liustack/modlens $(ANCHORED_DIR)/preset.yml $(ZERO_DIR)/preset.yml +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) $(VENDOR_DIR)/bin/dsh: @@ -114,6 +129,9 @@ $(VENDOR_DIR)/bin/dsh: $(MODLENS_DIR)/node_modules/@liustack/modlens: $(MAKE) vendor + +$(MARKET_DIR)/node_modules/dshmarket: + $(MAKE) vendor $(ANCHORED_DIR)/preset.yml $(ZERO_DIR)/preset.yml: $(MAKE) vendor-anchored diff --git a/README.md b/README.md index 7dc4f01..4b973e0 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@

- DeepSeek Harness Desktop:官方 dsh 原生壳,给 DeepSeek 带上眼睛 + DeepSeek Harness Desktop:内置离线语音、插件市场与视觉模型配置

DeepSeek Harness Desktop

@@ -9,13 +9,14 @@

- 👁️ 给 DeepSeek 带上眼睛 —— 粘贴图片直接识别  ·  ⚓ 锚定模式实测比官方 Standard 约 +8% + 离线语音输入  ·  内置插件市场  ·  可视化配置视觉模型

- ⬇ 下载 · + 下载 · + 大陆镜像 · + 核心体验 · 三十秒上手 · - 内置插件 · 从源码运行

@@ -27,140 +28,91 @@ dsh-plugin

-dsh 只能开在浏览器标签页里?这个仓库把它变成一个真正的桌面应用:Tauri 2 原生窗口 + 系统 WebView,Linux / Windows / macOS 通用。打开就是官方 WebUI——会话、工作区、插件、技能一个不少,`dsh` 升级后界面跟着升级,永远不用重打包前端。 +dsh 原本运行在浏览器标签页中;本项目用 Tauri 2 和系统 WebView 把官方 WebUI 变成原生桌面窗口。会话、工作区、插件和技能全部保留,`dsh` 更新后界面也会随之更新,不需要重新打包前端。 -这是作者自用的第三方壳,**不包含** DeepSeek Harness 源码,会持续跟着 dsh 和内置组件更新;踩到坑欢迎 [提 issue](https://github.com/TommyFang2077/dsh-desktop/issues)。仓库按官方 [贡献指南](https://github.com/deepseek-ai/deepseek-harness/blob/master/CONTRIBUTING.md) 挂了 [`dsh-plugin`](https://github.com/topics/dsh-plugin) topic。 +这是作者维护的第三方壳,**不包含** DeepSeek Harness 源码。重点补齐官方 WebUI 在桌面端缺少的输入和扩展体验:直接说话、直接安装插件、直接配置视觉模型。 -## 为什么值得一试 +## 核心体验 -### 👁️ 给 DeepSeek 带上眼睛 +### 内置语音:SenseVoice 本机离线听写 -DeepSeek 主力对话模型是纯文本的——你贴一张截图,它两眼一抹黑。本应用内置 [ModLens](https://github.com/liustack/modlens)(全网第一个 dsh 视觉插件):外挂视觉引擎后,**图片直接粘贴进对话框就能识别**,不用先存盘再填路径。配套的 **设置 → 视觉模型** 页面把 OpenAI / Gemini / Anthropic / 本机 CLI 引擎全配齐,免费的 Gemini key 就能跑。 +对话框旁直接提供麦克风按钮;按 `Ctrl+E`(macOS 为 `⌘E`)即可开始或结束听写,也可切换为按住说话。默认引擎是本机离线 **SenseVoiceSmall**,支持中文、粤语、英语、日语和韩语,识别结果直接写入当前输入框。 -![设置 → 视觉模型](docs/screenshots/vision.png) +模型和 sherpa-onnx WASM 运行时**不塞进安装包**。首次点击麦克风时会明确提示下载约 245 MB,底部状态条持续显示下载与校验进度;安装完成后保存在系统缓存目录,录音不离开本机。需要云端识别时,也可切换到 OpenAI 兼容的 `/v1/audio/transcriptions` 接口。 -### ⚓ 锚定模式:比官方 Standard 约 +8% +![设置 → 语音输入:SenseVoice 本机离线听写](docs/screenshots/voice.webp) -DeepSeek V4 Pro 会按「第一眼看到的工具表」选执行轨迹:Project2 评测里官方 Minimal 拿 **99**,Standard 只有 **91**——但常驻 Minimal 又缺工具。内置的 [锚定式标准](https://github.com/xiaobright/dsh-anchored-standard) 两头都要:**首轮用 Minimal 真工具对钉住高分轨迹,从第二轮起解锁完整 Standard 工具目录**,同配置实测 Ability **98 / 99**,相对 Standard 的 91 约 **+8% / +9%**。另附零工具锚定变体。新会话默认就是它,开箱即用。 +### 内置插件市场:发现、安装和更新社区插件 -![模式菜单:锚定式标准(实验)已选中](docs/screenshots/anchored.png) +无需记包名或离开应用。在 **设置 → Plugin Market** 中可以浏览目录、搜索分类、查看已安装插件,并直接安装、更新、备份或恢复社区插件。ModLens 等默认组件也能从这里正常更新;桌面启动不会再把用户更新的版本降回内置基线。 -*百分比按预设作者在 Project2 / DeepSeek V4 Pro 上的 Ability 计算:`(98−91)/91 ≈ 8%`。同配置可复现;社区实验预设,不是官方出品,不代表所有任务都涨。* +![设置 → Plugin Market:浏览并安装社区插件](docs/screenshots/market.webp) -### 🪟 像个真正的 Mac / Linux / Windows 应用 +### 视觉模型配置:给 DeepSeek 带上眼睛 -36px 苹果风薄标题栏,不占一排后退/前进/刷新;左侧 `•••` 菜单可重启 dsh 或跳回浏览器。关窗口自动停掉 `dsh web`,崩溃一键拉起。凭据、权限、会话全部还在 `~/.dsh`,卸载壳不丢任何东西。 +纯文本 DeepSeek 配合视觉桥后,可以直接粘贴截图识别内容。内置的 **设置 → 视觉模型** 页面集中配置 OpenAI 兼容接口、Gemini API、Anthropic API、Antigravity CLI 和 Claude Code 登录;只展示当前引擎需要的字段,密钥保存在本机配置中,相关外链由系统浏览器打开。 -![主窗口:官方 WebUI 嵌在原生壳里](docs/screenshots/session.png) +![设置 → 视觉模型:配置 OpenAI 兼容视觉引擎](docs/screenshots/vision.webp) -![标题栏菜单:重新启动 / 在浏览器中打开](docs/screenshots/menu.png) +### 锚定模式与原生窗口 + +内置的锚定式标准预设首轮使用 Minimal 工具表固定执行轨迹,从第二轮起恢复完整 Standard 工具目录。Project2 / DeepSeek V4 Pro 同配置 Ability 为 **98 / 99**,相对官方 Standard 的 91 约 **+8% / +9%**。这是社区实验预设,不代表所有任务都会提升。 + +36px 薄标题栏保留更多对话空间;左侧 `•••` 菜单可重新启动 dsh 或在浏览器中打开。关闭窗口会停止对应的 `dsh web` 进程,凭据、权限和会话仍保存在 `~/.dsh`。 + +![主窗口:官方 WebUI 嵌在原生壳中](docs/screenshots/session.png) ## 三十秒上手 -去 [GitHub Releases](https://github.com/TommyFang2077/dsh-desktop/releases/latest) 下载对应平台的安装包: +从 [GitHub Releases](https://github.com/TommyFang2077/dsh-desktop/releases/latest) 下载对应平台的安装包;中国大陆网络可改用 [Gitea 发行版镜像](https://git.fangsiyuan.top/TomHanck4/dsh-easy-desktop/releases/latest)。壳会在启动时从该镜像检查自身更新,下载完成后先校验 Tauri 签名再安装。 | 平台 | 产物 | 运行时要求 | | --- | --- | --- | -| 🪟 Windows | NSIS `.exe` / `.msi` | [WebView2](https://developer.microsoft.com/microsoft-edge/webview2/)(安装器可引导下载)+ 本机 `dsh` | -| 🍎 macOS | Apple Silicon / Intel `.dmg` | 未公证,首次打开需在「隐私与安全性」允许 + 本机 `dsh` | -| 🐧 Linux | `.deb` / `.rpm` | WebKitGTK 4.1 + 本机 `dsh` | -| 📦 Linux Flatpak | `.flatpak` | **零依赖**:自带 Node.js 24 与 `@deepseek-ai/dsh` | +| Windows | NSIS `.exe` / `.msi` | [WebView2](https://developer.microsoft.com/microsoft-edge/webview2/)(安装器可引导下载)+ 本机 `dsh` | +| macOS | Apple Silicon / Intel `.dmg` | 未公证,首次打开需在「隐私与安全性」允许 + 本机 `dsh` | +| Linux | `.deb` / `.rpm` | WebKitGTK 4.1 + 本机 `dsh` | +| Linux Flatpak | `.flatpak` | **零依赖**:自带 Node.js 24 与 `@deepseek-ai/dsh` | -除 Flatpak 外需要本机有 `dsh`: +除 Flatpak 外,需要先安装 `dsh`: ```bash npm install -g @deepseek-ai/dsh ``` -装好后启动,等启动页转完就是官方 WebUI。 - -![启动页:正在启动官方 WebUI](docs/screenshots/splash.png) +安装后直接启动,等待启动页完成即可进入官方 WebUI。 ## 功能一览 | 能力 | 说明 | | --- | --- | -| 原生窗口 | 启动 `dsh web --host 127.0.0.1 --port 0`,解析随机端口后用系统 WebView 加载 | -| 零重写 | 官方会话、工作区、插件、技能全部保留;dsh 升级即界面升级 | -| 内置视图 | 👁️ 纯文本 DeepSeek 也能粘贴识图(ModLens + 设置页) | -| 内置锚定 | ⚓ 相对官方 Standard 约 +8%(Project2 Ability 91 → 98/99) | -| 薄标题栏 | `•••` 菜单(重新启动 / 在浏览器中打开)+ 右侧最小化 · 缩放 · 关闭 | -| 生命周期 | 关窗口停掉 `dsh web`;崩溃可从标题栏一键重启 | +| 离线语音 | 对话框麦克风、`Ctrl+E` / `⌘E` 快捷键、SenseVoice 模型按需下载、本机识别 | +| 插件市场 | 在设置内浏览、搜索、安装、更新、备份和恢复社区插件 | +| 视觉模型 | 粘贴图片直接识别;用表单配置五类视觉引擎,不必手改 JSON | +| 官方 WebUI | 会话、工作区、插件和技能原样保留;dsh 更新后界面同步更新 | +| 锚定预设 | Project2 / DeepSeek V4 Pro 相对官方 Standard 约 +8% | +| 原生生命周期 | 随机本地端口启动 `dsh web`;关闭窗口停止服务;崩溃可一键重启 | -## 内置插件与预设 +## 内置能力与数据位置 -应用启动时把下面这些同步到用户目录。版本钉死在 [Makefile](Makefile);第三方原文许可证见 [docs/licenses/](docs/licenses/) 与 [THIRD_PARTY.md](THIRD_PARTY.md)。 +| 组件 | 当前基线 | 用途 | 本地位置 | +| --- | --- | --- | --- | +| DeepSeek Harness | `0.1.0-rc.6` | 官方 WebUI;Flatpak 内置,其他安装包调用本机 `dsh` | `~/.dsh` | +| 离线语音 | `dsh-desktop-voice 0.4.0` | 麦克风、快捷键、SenseVoice / OpenAI 兼容听写 | 配置 `~/.config/dsh-desktop/voice.json`;模型在系统缓存目录 | +| 插件市场 | `dshmarket 1.9.0` | 社区插件的发现、安装和更新 | `~/.dsh/profiles/web` | +| 视觉配置 | `dsh-desktop-vision 0.1.4` | 配置视觉桥所使用的引擎、接口和模型 | `~/.modlens/config.json` | +| 视觉桥 | `ModLens 3.16.6` | 让纯文本模型读取粘贴的图片;可从市场更新 | `~/.dsh/profiles/web` | +| 锚定预设 | `ffb845c5480a` | 锚定式标准与零工具锚定式标准 | `~/.dsh/.agent-presets/` | -### 1. DeepSeek Harness(`dsh`) +应用启动时会同步桌面自带组件,但会保留用户从市场更新到更新版本的 ModLens。捆绑版本固定在 [Makefile](Makefile) 中。 -| | | -| --- | --- | -| 上游 | [@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` | -| 许可证 | MIT,Copyright (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` | +`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 更新检查。 -解析顺序: +## 安全与边界 -1. 环境变量 `DSH_DESKTOP_DSH_BIN` -2. 命令行 `--dsh` -3. 应用自己的更新目录(`$XDG_DATA_HOME/dsh-desktop/dsh-prefix/bin/dsh`) -4. **Flatpak**:内置 `/app/bin/dsh` -5. **宿主机**:`~/.local/bin/dsh` → `~/.npm/_npx` 缓存 → `PATH` → `npx --yes @deepseek-ai/dsh` - -设 `DSH_DESKTOP_NO_UPDATE=1` 或传 `--no-update` 可关掉启动时的 npm 更新检查。 - -### 2. ModLens(`@liustack/modlens`) - -| | | -| --- | --- | -| 上游 | [liustack/modlens](https://github.com/liustack/modlens) · [npm @liustack/modlens](https://www.npmjs.com/package/@liustack/modlens) | -| 当前版本 | `3.16.6` | -| 作者 | Leon Liu / [liustack](https://github.com/liustack) | -| 许可证 | MIT · [docs/licenses/modlens.LICENSE](docs/licenses/modlens.LICENSE) | -| 亮点 | 给 DeepSeek 带上眼睛:纯文本模型粘贴即可读图 | -| 安装位置 | 启动时复制到 `~/.dsh/profiles/web/node_modules/@liustack/modlens` | - -官方安装方式(本应用已内置,一般不必再跑): - -```bash -npx -y @deepseek-ai/dsh plugin --profile web add @liustack/modlens@3.16.6 -``` - -### 3. `dsh-desktop-vision`(本仓库) - -| | | -| --- | --- | -| 路径 | [`plugins/dsh-desktop-vision/`](plugins/dsh-desktop-vision/) | -| 版本 | `0.1.4` | -| 许可证 | 与本仓库相同(MIT) | -| 作用 | 在官方 WebUI **设置 → 视觉模型** 增加表单,读写 `~/.modlens/config.json` | - -支持的引擎: - -| 引擎 | 默认接口 | 获取密钥 | -| --- | --- | --- | -| OpenAI 兼容 | `https://api.openai.com/v1` | [platform.openai.com/api-keys](https://platform.openai.com/api-keys) | -| Gemini API | `https://generativelanguage.googleapis.com` | [aistudio.google.com/apikey](https://aistudio.google.com/apikey) | -| Anthropic | `https://api.anthropic.com` | [console.anthropic.com/settings/keys](https://console.anthropic.com/settings/keys) | -| Antigravity CLI | 本机 CLI,无需填 URL | [antigravity.google](https://antigravity.google/) | -| Claude CLI | 本机 CLI,无需填 URL | [code.claude.com](https://code.claude.com) | - -外链在系统浏览器中打开(Tauri `on_navigation`),密钥只写在本机 ModLens 配置里。 - -### 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` 本地化) | -| 亮点 | 相对官方 Standard 约 **+8%**(Project2 Ability 91 → 98/99),同时拿回完整工具目录 | -| 安装位置 | `~/.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,桌面会把默认设为锚定式标准。 +- WebUI 只监听随机的 `127.0.0.1` 端口。 +- 语音模型按需下载;SenseVoice 识别在本机执行。 +- API 密钥只写入本机配置,不进入仓库或远端服务。 +- 卸载桌面壳不会删除 `~/.dsh` 中的会话、权限和工作区设置。 ## 从源码运行 @@ -223,6 +175,7 @@ dsh-desktop/ ├── src-tauri/ # Tauri 窗口、命令、deb/rpm/nsis/dmg ├── crates/dsh-core/ # 启动 / 更新 / ModLens / 预设 / 剪贴板 ├── plugins/dsh-desktop-vision/ # 设置 → 视觉模型 +├── plugins/dsh-desktop-voice/ # 设置 → 语音输入 + 对话框麦克风 ├── data/ # .desktop、图标、AppStream ├── flatpak/ ├── docs/screenshots/ # README 截图 @@ -237,6 +190,19 @@ dsh-desktop/ 自用项目,会持续更新。bug、想法、打包问题都欢迎开 [issue](https://github.com/TommyFang2077/dsh-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 的作者均无从属关系。 diff --git a/THIRD_PARTY.md b/THIRD_PARTY.md index a1de674..5367eac 100644 --- a/THIRD_PARTY.md +++ b/THIRD_PARTY.md @@ -15,6 +15,10 @@ DeepSeek, liustack, or xiaobright. | ModLens (`@liustack/modlens`) | [liustack/modlens](https://github.com/liustack/modlens) | `3.16.6` | MIT, © 2026 Leon Liu (liustack) | Copied into `~/.dsh/profiles/web` so text-only models can read images. See `docs/licenses/modlens.LICENSE`. | | Anchored Standard | [xiaobright/dsh-anchored-standard](https://github.com/xiaobright/dsh-anchored-standard) | commit `ffb845c5480adc953392a6db6f8a98ede621174b` | MIT, © 2026 xiaobright; portions © 2026 DeepSeek | Localized as **锚定式标准(实验)** and **零工具锚定式标准(实验)**, written to `~/.dsh/.agent-presets/`. See `docs/licenses/dsh-anchored-standard.LICENSE` and `.NOTICE`. | | `dsh-desktop-vision` | this repo `plugins/dsh-desktop-vision/` | `0.1.4` | MIT, © 2026 TommyFang2077 | Settings page **设置 → 视觉模型**; writes `~/.modlens/config.json`. | +| `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.9.0` | 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 Harness Standard agent preset from diff --git a/crates/dsh-core/Cargo.toml b/crates/dsh-core/Cargo.toml index 544822a..5166bc3 100644 --- a/crates/dsh-core/Cargo.toml +++ b/crates/dsh-core/Cargo.toml @@ -11,6 +11,7 @@ base64 = "0.22" dirs = "6" regex = "1" serde = { version = "1", features = ["derive"] } +semver = "1" serde_json = "1" shlex = "1" thiserror = "2" diff --git a/crates/dsh-core/src/clipboard.rs b/crates/dsh-core/src/clipboard.rs index 10c0231..157a93b 100644 --- a/crates/dsh-core/src/clipboard.rs +++ b/crates/dsh-core/src/clipboard.rs @@ -24,7 +24,12 @@ pub struct ClipboardFile { } 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" { "image/jpeg" } else { @@ -34,7 +39,12 @@ pub fn is_image_mime(mime: &str) -> bool { } 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() { "image/png" => "png", "image/jpeg" | "image/jpg" => "jpg", @@ -84,9 +94,7 @@ fn url_parse_file(rest: &str) -> Result { let decoded = percent_decode(&uri); if let Some(idx) = decoded.find("://") { let after = &decoded[idx + 3..]; - let path = after - .strip_prefix("localhost") - .unwrap_or(after); + let path = after.strip_prefix("localhost").unwrap_or(after); return Ok(path.to_string()); } if let Some(path) = decoded.strip_prefix("file:") { @@ -101,7 +109,8 @@ fn percent_decode(input: &str) -> String { let mut i = 0; while i < bytes.len() { if bytes[i] == b'%' && i + 2 < bytes.len() { - if let Ok(v) = u8::from_str_radix(std::str::from_utf8(&bytes[i + 1..i + 3]).unwrap_or(""), 16) + if let Ok(v) = + u8::from_str_radix(std::str::from_utf8(&bytes[i + 1..i + 3]).unwrap_or(""), 16) { out.push(v); i += 3; @@ -220,7 +229,10 @@ mod tests { 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(&[0xFF, 0xD8, 0xFF, 0xE0]), + Some("image/jpeg") + ); assert_eq!(detect_image_mime(b"not-an-image"), None); } diff --git a/crates/dsh-core/src/launcher.rs b/crates/dsh-core/src/launcher.rs index 1d0eaca..08e0674 100644 --- a/crates/dsh-core/src/launcher.rs +++ b/crates/dsh-core/src/launcher.rs @@ -10,7 +10,7 @@ use regex::Regex; use thiserror::Error; 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}; pub const DSH_DEFAULT_HOST: &str = "127.0.0.1"; @@ -117,8 +117,8 @@ impl DshLauncher { } } if let Some(cli) = &self.dsh_bin_override { - let parts = shlex::split(cli) - .ok_or_else(|| DshNotFound::new("dsh 命令不是合法的命令"))?; + let parts = + shlex::split(cli).ok_or_else(|| DshNotFound::new("dsh 命令不是合法的命令"))?; candidates.push(parts); } @@ -197,6 +197,7 @@ impl DshLauncher { .stdout(Stdio::piped()) .stderr(Stdio::inherit()) .stdin(Stdio::null()); + configure_npm_registry(&mut cmd); #[cfg(unix)] { use std::os::unix::process::CommandExt; @@ -208,9 +209,9 @@ impl DshLauncher { const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200; cmd.creation_flags(CREATE_NEW_PROCESS_GROUP); } - let mut child = cmd.spawn().map_err(|exc| { - DshNotFound::new(format!("无法启动 dsh web: {exc}")) - })?; + let mut child = cmd + .spawn() + .map_err(|exc| DshNotFound::new(format!("无法启动 dsh web: {exc}")))?; let stdout = child.stdout.take(); Ok(DshProcess::new(child, stdout, self.in_flatpak)) } diff --git a/crates/dsh-core/src/lib.rs b/crates/dsh-core/src/lib.rs index f020855..1c92040 100644 --- a/crates/dsh-core/src/lib.rs +++ b/crates/dsh-core/src/lib.rs @@ -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_CWD_OVERRIDE: &str = "DSH_DESKTOP_CWD"; pub const ENV_NO_UPDATE: &str = "DSH_DESKTOP_NO_UPDATE"; +pub const ENV_NPM_REGISTRY: &str = "DSH_DESKTOP_NPM_REGISTRY"; diff --git a/crates/dsh-core/src/modlens.rs b/crates/dsh-core/src/modlens.rs index a4735c1..f5e11d2 100644 --- a/crates/dsh-core/src/modlens.rs +++ b/crates/dsh-core/src/modlens.rs @@ -8,7 +8,13 @@ use crate::paths::{copy_tree, dsh_home, replace_symlink, BundledPaths}; pub const PACKAGE: &str = "@liustack/modlens"; pub const VISION_PACKAGE: &str = "dsh-desktop-vision"; +pub const VOICE_PACKAGE: &str = "dsh-desktop-voice"; +pub const MARKET_PACKAGE: &str = "dshmarket"; pub const MODLENS_VERSION: &str = "3.16.6"; +const VISION_PLUGIN_VERSION: &str = "0.1.4"; +const VOICE_PLUGIN_VERSION: &str = "0.4.0"; +const MARKET_PLUGIN_VERSION: &str = "1.9.0"; +const MANAGED_BUNDLES_DIR: &str = ".dsh-desktop/bundles"; pub const HIDE_PLAIN_TWINS_JS: &str = include_str!("../../../ui/inject/hide-twins.js"); pub const MANAGED_OVERLAY: &str = "\ @@ -48,12 +54,42 @@ pub fn read_modlens_version(prefix: &Path) -> Option { .map(|s| s.to_string()) } +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 { + dirs.iter().find_map(|rel| { + paths + .find_dir(rel, "package.json") + .filter(|p| p.join("client.js").is_file() && p.join("index.js").is_file()) + }) +} + pub fn bundled_vision_plugin(paths: &BundledPaths) -> Option { - paths - .find_dir("vision", "package.json") - .or_else(|| paths.find_dir("dsh-desktop-vision", "package.json")) - .or_else(|| paths.find_dir("plugins/dsh-desktop-vision", "package.json")) - .filter(|p| p.join("client.js").is_file()) + bundled_plugin( + paths, + &["vision", "dsh-desktop-vision", "plugins/dsh-desktop-vision"], + ) +} + +fn bundled_voice_plugin(paths: &BundledPaths) -> Option { + bundled_plugin( + paths, + &["voice", "dsh-desktop-voice", "plugins/dsh-desktop-voice"], + ) } pub fn bundled_modlens_prefix(paths: &BundledPaths) -> Option { @@ -62,6 +98,13 @@ pub fn bundled_modlens_prefix(paths: &BundledPaths) -> Option { .or_else(|| paths.find_dir("vendor/modlens", "node_modules/@liustack/modlens")) } +pub fn bundled_market_prefix(paths: &BundledPaths) -> Option { + 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<()> { let dest_pkg = package_dir(profile); copy_tree(&package_dir(src_prefix), &dest_pkg, true)?; @@ -86,22 +129,92 @@ fn read_pkg_version(dir: &Path) -> Option { .map(|s| s.to_string()) } -fn install_vision_plugin(paths: &BundledPaths, profile: &Path) -> bool { - let Some(src) = bundled_vision_plugin(paths) else { +fn local_plugin_spec(pkg: &str) -> String { + format!("file:{MANAGED_BUNDLES_DIR}/{pkg}") +} + +fn install_profile_plugin( + profile: &Path, + pkg: &str, + expected_version: &str, + src: Option, +) -> bool { + let Some(src) = src else { return false; }; - let dest = profile.join("node_modules").join(VISION_PACKAGE); - let up_to_date = dest.join("client.js").is_file() - && dest.join("index.js").is_file() - && read_pkg_version(&dest) == read_pkg_version(&src); - if !up_to_date && copy_tree(&src, &dest, true).is_err() { + if read_pkg_version(&src).as_deref() != Some(expected_version) { return false; } + 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); + true +} + +fn install_vision_plugin(paths: &BundledPaths, profile: &Path) -> bool { + install_profile_plugin( + profile, + VISION_PACKAGE, + VISION_PLUGIN_VERSION, + bundled_vision_plugin(paths), + ) +} + +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> { + 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(VISION_PACKAGE); + .join(MARKET_PACKAGE); let _ = replace_symlink(&fallback, &dest); - true + Ok(Some(version)) } fn ensure_manifest(profile: &Path, packages: &BTreeMap) -> std::io::Result<()> { @@ -323,9 +436,19 @@ fn ensure_modlens_inner( ) -> std::io::Result { 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(); if vision_ok { - packages.insert(VISION_PACKAGE.to_string(), "0.1.0".into()); + packages.insert( + VISION_PACKAGE.to_string(), + local_plugin_spec(VISION_PACKAGE), + ); + } + if voice_ok { + 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 !packages.is_empty() { @@ -337,8 +460,9 @@ fn ensure_modlens_inner( message: "未找到内置 ModLens,跳过插件安装".into(), }); } + let replace_bundle = src.is_some() && bundle_should_replace(installed.as_deref(), version); let installed_after = if let Some(src) = src { - if installed.as_deref() != Some(version) { + if replace_bundle { install_into_profile(src, profile)?; read_modlens_version(profile).unwrap_or_else(|| version.to_string()) } else { @@ -374,11 +498,18 @@ fn ensure_modlens_inner( message: format!("已配置已安装的 ModLens {installed_after}(纯文本自动套视觉桥)"), }); } - if installed.as_deref() == Some(version) { + if !replace_bundle { + if installed.as_deref() == Some(version) { + return Ok(ModlensEnsureResult { + status: "current", + version: Some(version.to_string()), + message: format!("内置 ModLens {version} 已就绪"), + }); + } return Ok(ModlensEnsureResult { status: "current", - version: Some(version.to_string()), - message: format!("内置 ModLens {version} 已就绪"), + version: Some(installed_after.clone()), + message: format!("已保留用户更新的 ModLens {installed_after}(内置版本 {version})"), }); } if installed.is_some() { @@ -474,4 +605,80 @@ ui-theme: assert!(HIDE_PLAIN_TWINS_JS.contains("(modlens vision)")); 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"), + r#"{"name":"dshmarket","version":"1.9.0"}"#, + ) + .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"], "1.9.0"); + 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 [ + ("vision", VISION_PACKAGE, VISION_PLUGIN_VERSION), + ("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 [VISION_PACKAGE, 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")); + } } diff --git a/crates/dsh-core/src/paths.rs b/crates/dsh-core/src/paths.rs index e191b46..3e75abe 100644 --- a/crates/dsh-core/src/paths.rs +++ b/crates/dsh-core/src/paths.rs @@ -42,7 +42,7 @@ pub fn is_flatpak() -> bool { 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`, /// `/app/share/dsh-desktop`, `/usr/share/dsh-desktop`, `~/.local/share/dsh-desktop`, diff --git a/crates/dsh-core/src/preset.rs b/crates/dsh-core/src/preset.rs index bd5f0e0..b4e90c0 100644 --- a/crates/dsh-core/src/preset.rs +++ b/crates/dsh-core/src/preset.rs @@ -155,7 +155,10 @@ fn ensure_one(paths: &BundledPaths, spec: &BundledPreset) -> PresetEnsureResult return PresetEnsureResult { status: "current", version: version.clone(), - message: format!("已配置已安装的{label}({})", short_version(version.as_deref())), + message: format!( + "已配置已安装的{label}({})", + short_version(version.as_deref()) + ), }; } return PresetEnsureResult { @@ -336,7 +339,11 @@ order: 5 #[test] fn inserts_description_after_name() { - let updated = localize_preset_yml("name: Anchored Standard\norder: 5\n", PRESET_NAME_ZH, PRESET_DESCRIPTION_ZH); + let updated = localize_preset_yml( + "name: Anchored Standard\norder: 5\n", + PRESET_NAME_ZH, + PRESET_DESCRIPTION_ZH, + ); assert!(updated.contains(PRESET_NAME_ZH)); assert!(!updated.contains("name: Anchored Standard\n")); assert!(updated.contains(PRESET_DESCRIPTION_ZH)); @@ -371,7 +378,8 @@ name: Zero-Anchored Standard (experimental) description: Inject one zero-tool anchor turn. order: 6 "; - let updated = localize_preset_yml(original, ZERO_PRESET_NAME_ZH, ZERO_PRESET_DESCRIPTION_ZH); + let updated = + localize_preset_yml(original, ZERO_PRESET_NAME_ZH, ZERO_PRESET_DESCRIPTION_ZH); assert!(updated.contains(ZERO_PRESET_NAME_ZH)); assert!(updated.contains(ZERO_PRESET_DESCRIPTION_ZH)); assert!(!updated.contains("Zero-Anchored")); diff --git a/crates/dsh-core/src/updater.rs b/crates/dsh-core/src/updater.rs index bdb659c..5dfacb0 100644 --- a/crates/dsh-core/src/updater.rs +++ b/crates/dsh-core/src/updater.rs @@ -1,3 +1,4 @@ +use std::ffi::OsStr; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; use std::sync::atomic::{AtomicBool, Ordering}; @@ -6,13 +7,14 @@ use std::thread; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; 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 VIEW_TIMEOUT_SECONDS: u64 = 20; pub const INSTALL_TIMEOUT_SECONDS: u64 = 180; pub const BUNDLED_PREFIX: &str = "/app"; pub const BUNDLED_DSH: &[&str] = &["/app/bin/dsh", "/app/node24/bin/dsh"]; +pub const DEFAULT_NPM_REGISTRY: &str = "https://registry.npmmirror.com"; #[derive(Debug, Clone, PartialEq, Eq)] pub struct UpdateResult { @@ -83,9 +85,30 @@ fn find_node_dir() -> Option { .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 { let mut cmd = Command::new(npm); let cache = cache_home().join("dsh-desktop/npm"); + configure_npm_registry(&mut cmd); let _ = std::fs::create_dir_all(&cache); cmd.env("npm_config_cache", &cache); cmd.env("npm_config_update_notifier", "false"); @@ -169,8 +192,12 @@ pub fn mark_update_checked() { } pub fn fetch_latest_version(npm: &Path) -> Option { - let out = run_npm(npm, &["view", DSH_PACKAGE, "version"], Duration::from_secs(VIEW_TIMEOUT_SECONDS)) - .ok()?; + let out = run_npm( + npm, + &["view", DSH_PACKAGE, "version"], + Duration::from_secs(VIEW_TIMEOUT_SECONDS), + ) + .ok()?; if !out.status.success() { return None; } @@ -213,12 +240,14 @@ fn env_skips_update() -> bool { pub fn update_dsh(enabled: bool) -> UpdateResult { if !enabled || env_skips_update() { - let version = read_version(&update_prefix()).or_else(|| read_version(Path::new(BUNDLED_PREFIX))); + let version = + read_version(&update_prefix()).or_else(|| read_version(Path::new(BUNDLED_PREFIX))); return UpdateResult::new("skipped", version, "已跳过 dsh 更新"); } let npm = find_npm(); - let current = read_version(&update_prefix()).or_else(|| read_version(Path::new(BUNDLED_PREFIX))); + let current = + read_version(&update_prefix()).or_else(|| read_version(Path::new(BUNDLED_PREFIX))); let Some(npm) = npm else { let extra = current .as_deref() @@ -247,11 +276,7 @@ pub fn update_dsh(enabled: bool) -> UpdateResult { }; if current.as_deref() == Some(latest.as_str()) { - return UpdateResult::new( - "current", - current, - format!("内置 dsh 已是最新({latest})"), - ); + return UpdateResult::new("current", current, format!("内置 dsh 已是最新({latest})")); } let dest = update_prefix(); @@ -321,6 +346,32 @@ mod tests { fn read_version_missing() { 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] fn skip_when_disabled() { diff --git a/docs/licenses/dshmarket.LICENSE b/docs/licenses/dshmarket.LICENSE new file mode 100644 index 0000000..6d41392 --- /dev/null +++ b/docs/licenses/dshmarket.LICENSE @@ -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. diff --git a/docs/licenses/sensevoice.LICENSE b/docs/licenses/sensevoice.LICENSE new file mode 100644 index 0000000..9ea3374 --- /dev/null +++ b/docs/licenses/sensevoice.LICENSE @@ -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. diff --git a/docs/screenshots/market.webp b/docs/screenshots/market.webp new file mode 100644 index 0000000..f8dd4cc Binary files /dev/null and b/docs/screenshots/market.webp differ diff --git a/docs/screenshots/src/session.html b/docs/screenshots/src/session.html index 3fa349d..7b6f2a7 100644 --- a/docs/screenshots/src/session.html +++ b/docs/screenshots/src/session.html @@ -82,6 +82,11 @@ .chip { 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 { width: 28px; height: 28px; border-radius: 50%; background: var(--accent); color: #fff; display: grid; place-items: center; font-size: 14px; @@ -126,7 +131,10 @@ 锚定式标准(实验) 工作区 -
+
+
🎤
+
+
diff --git a/docs/screenshots/vision.webp b/docs/screenshots/vision.webp new file mode 100644 index 0000000..9012c50 Binary files /dev/null and b/docs/screenshots/vision.webp differ diff --git a/docs/screenshots/voice.webp b/docs/screenshots/voice.webp new file mode 100644 index 0000000..3ecb5b8 Binary files /dev/null and b/docs/screenshots/voice.webp differ diff --git a/flatpak/io.github.tommyfang.DshDesktop.yml b/flatpak/io.github.tommyfang.DshDesktop.yml index e0b9a1a..3e05831 100644 --- a/flatpak/io.github.tommyfang.DshDesktop.yml +++ b/flatpak/io.github.tommyfang.DshDesktop.yml @@ -15,6 +15,8 @@ finish-args: - --share=network - --filesystem=host - --filesystem=xdg-download + - --socket=pulseaudio + - --filesystem=xdg-run/pipewire-0:ro - --talk-name=org.freedesktop.Notifications - --talk-name=org.freedesktop.portal.Desktop - --talk-name=org.a11y.Bus @@ -50,11 +52,13 @@ modules: build-args: - --share=network build-commands: - - mkdir -p vendor/modlens vendor/anchored-standard vendor/zero-anchored-standard + - mkdir -p vendor/modlens vendor/dshmarket vendor/anchored-standard vendor/zero-anchored-standard - cargo build --release --locked --offline || cargo build --release - install -Dm755 target/release/dsh-desktop ${FLATPAK_DEST}/bin/dsh-desktop - mkdir -p ${FLATPAK_DEST}/share/dsh-desktop/vision - cp -a plugins/dsh-desktop-vision/. ${FLATPAK_DEST}/share/dsh-desktop/vision + - mkdir -p ${FLATPAK_DEST}/share/dsh-desktop/voice + - 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/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 @@ -79,6 +83,15 @@ modules: - type: dir 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 buildsystem: simple build-commands: diff --git a/plugins/dsh-desktop-voice/client.js b/plugins/dsh-desktop-voice/client.js new file mode 100644 index 0000000..9969b45 --- /dev/null +++ b/plugins/dsh-desktop-voice/client.js @@ -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 = + '' + + '' + + '' + + '' + + '' + + '' + 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 + }, +}) diff --git a/plugins/dsh-desktop-voice/cordis.patch.yml b/plugins/dsh-desktop-voice/cordis.patch.yml new file mode 100644 index 0000000..4b4d577 --- /dev/null +++ b/plugins/dsh-desktop-voice/cordis.patch.yml @@ -0,0 +1,4 @@ +# Settings page + composer mic + host route for desktop dictation. +- insert: + - id: dsh-desktop-voice + name: dsh-desktop-voice diff --git a/plugins/dsh-desktop-voice/index.js b/plugins/dsh-desktop-voice/index.js new file mode 100644 index 0000000..f8f08d8 --- /dev/null +++ b/plugins/dsh-desktop-voice/index.js @@ -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}`) + } + }) +} diff --git a/plugins/dsh-desktop-voice/package.json b/plugins/dsh-desktop-voice/package.json new file mode 100644 index 0000000..cc86181 --- /dev/null +++ b/plugins/dsh-desktop-voice/package.json @@ -0,0 +1,24 @@ +{ + "name": "dsh-desktop-voice", + "version": "0.4.0", + "private": true, + "type": "module", + "exports": { + ".": "./index.js", + "./client": "./client.js", + "./package.json": "./package.json" + }, + "dsh": { + "bundle": { + "patch": "./cordis.patch.yml" + }, + "client": { + "inject": [ + "@deepseek-ai/dsh-client-ui-settings", + "@deepseek-ai/dsh-client-ui-conversation" + ], + "platform": "web", + "immediately": true + } + } +} diff --git a/scripts/build-gitea-update.py b/scripts/build-gitea-update.py new file mode 100755 index 0000000..9c5ccd0 --- /dev/null +++ b/scripts/build-gitea-update.py @@ -0,0 +1,135 @@ +#!/usr/bin/env python3 +"""Normalize Tauri artifacts and build the static updater manifest.""" + +from __future__ import annotations + +import argparse +import json +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", +} + + +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 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", required=True, type=Path) + 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() + + staged = stage_artifacts(args.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() diff --git a/scripts/patch-dshmarket-mainland.py b/scripts/patch-dshmarket-mainland.py new file mode 100755 index 0000000..c0a5a82 --- /dev/null +++ b/scripts/patch-dshmarket-mainland.py @@ -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 = """

{' ' + t('confirmWarn')}

\n""" + source_addition = """ {typeof confirming.npm !== 'string' && (\n

\n \n {' ' + t('githubInstallWarn')}\n

\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() diff --git a/scripts/publish-gitea-release.sh b/scripts/publish-gitea-release.sh new file mode 100755 index 0000000..1cb03e4 --- /dev/null +++ b/scripts/publish-gitea-release.sh @@ -0,0 +1,72 @@ +#!/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}" + +# The Gitea repository is a pull mirror. Sync the GitHub tag before creating +# the matching Gitea release, then wait until the tag is queryable. +curl --fail --silent --show-error -X POST -H "$AUTH" "$API/mirror-sync" >/dev/null +for _ in $(seq 1 30); do + if curl --fail --silent --show-error -H "$AUTH" "$API/tags/$RELEASE_TAG" >/dev/null 2>&1; then + break + fi + sleep 10 +done +curl --fail --silent --show-error -H "$AUTH" "$API/tags/$RELEASE_TAG" >/dev/null + +release_json=$(curl --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 "大陆镜像安装包;文件与 GitHub Release 同源。应用内更新包由 Tauri 签名校验。" \ + '{tag_name:$tag,target_commitish:$tag,name:$name,body:$body,draft:false,prerelease:false}') + release_json=$(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') +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 --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 --fail --silent --show-error -H "$AUTH" --upload-file "$STAGING_DIR/latest.json" \ + "$PACKAGE_BASE/latest/latest.json" >/dev/null + +assets=$(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 --fail --silent --show-error -X DELETE -H "$AUTH" \ + "$API/releases/$release_id/assets/$old_id" >/dev/null + fi + curl --fail --silent --show-error -H "$AUTH" \ + -F "attachment=@$file" "$API/releases/$release_id/assets?name=$name" >/dev/null +done diff --git a/scripts/setup-gitea-release.sh b/scripts/setup-gitea-release.sh new file mode 100755 index 0000000..a7a27c9 --- /dev/null +++ b/scripts/setup-gitea-release.sh @@ -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)" +case "$(printf '%s' "$repo" | jq -r .html_url)" in + https://*) ;; + *) + printf '%bGitea API 仍生成 HTTP 链接。请先把 app.ini 的 [server] ROOT_URL 改为 https://git.fangsiyuan.top/ 并重启 Gitea。%b\n' "$RED" "$RESET" >&2 + exit 1 + ;; +esac +[[ "$(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 +cat "$PUBLIC_KEY_PATH" | gh secret set DSH_DESKTOP_UPDATER_PUBKEY +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" diff --git a/scripts/vendor-native.sh b/scripts/vendor-native.sh index 340063e..ae3c423 100755 --- a/scripts/vendor-native.sh +++ b/scripts/vendor-native.sh @@ -1,5 +1,5 @@ #!/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`). set -euo pipefail @@ -7,6 +7,7 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd)" cd "$ROOT" MODLENS_VERSION="${MODLENS_VERSION:-3.16.6}" +MARKET_VERSION="${MARKET_VERSION:-1.9.0}" ANCHORED_COMMIT="${ANCHORED_COMMIT:-ffb845c5480adc953392a6db6f8a98ede621174b}" ANCHORED_REPO="${ANCHORED_REPO:-https://github.com/xiaobright/dsh-anchored-standard.git}" if [ -z "${PYTHON:-}" ]; then @@ -20,6 +21,7 @@ fi ANCHORED_DIR="vendor/anchored-standard" ZERO_DIR="vendor/zero-anchored-standard" MODLENS_DIR="vendor/modlens" +MARKET_DIR="vendor/dshmarket" rm -rf vendor/.anchored-src "$ANCHORED_DIR" "$ZERO_DIR" mkdir -p vendor/.anchored-src @@ -42,7 +44,13 @@ rm -rf "$MODLENS_DIR" mkdir -p "$MODLENS_DIR" 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 "$ZERO_DIR/preset.yml" 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}" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 6d7b395..f04e638 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -22,6 +22,7 @@ dsh-core = { path = "../crates/dsh-core" } tauri = { version = "2", features = ["devtools"] } tauri-plugin-opener = "2" tauri-plugin-single-instance = "2" +tauri-plugin-updater = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" arboard = { version = "3", features = ["wayland-data-control"] } @@ -34,3 +35,4 @@ env_logger = "0.11" [target.'cfg(target_os = "linux")'.dependencies] gdk = "0.18" gtk = "0.18" +webkit2gtk = { version = "=2.0.2", features = ["v2_38"] } diff --git a/src-tauri/Info.plist b/src-tauri/Info.plist new file mode 100644 index 0000000..7b15818 --- /dev/null +++ b/src-tauri/Info.plist @@ -0,0 +1,8 @@ + + + + + NSMicrophoneUsageDescription + 语音输入需要使用麦克风,以便把说话内容写进对话框。 + + diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 1e9dc6a..09736e9 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,6 +1,9 @@ +mod shell_updater; + use std::io::Cursor; use std::path::PathBuf; use std::process::Command; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; use std::thread; use std::time::{Duration, Instant}; @@ -38,7 +41,9 @@ pub struct Args { impl Args { pub fn parse() -> Self { let mut args = Args { - dsh: std::env::var(dsh_core::ENV_BIN_OVERRIDE).ok().filter(|s| !s.is_empty()), + dsh: std::env::var(dsh_core::ENV_BIN_OVERRIDE) + .ok() + .filter(|s| !s.is_empty()), cwd: std::env::var(dsh_core::ENV_CWD_OVERRIDE) .ok() .filter(|s| !s.is_empty()) @@ -91,6 +96,7 @@ struct AppState { paths: BundledPaths, process: Mutex>>, url: Mutex>, + initial_boot_started: AtomicBool, } impl AppState { @@ -123,6 +129,41 @@ struct ReadyPayload { fn restart(app: AppHandle) { thread::spawn(move || boot(app)); } +fn start_initial_boot(app: AppHandle) { + let Some(state) = app.try_state::() 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 { + 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] fn open_in_browser(state: tauri::State) -> Result<(), String> { @@ -204,9 +245,18 @@ fn read_cli_image() -> Option { ("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"]), + ( + "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() { @@ -225,6 +275,33 @@ fn read_cli_image() -> Option { 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 { matches!(url.scheme(), "tauri" | "asset" | "about" | "data" | "blob") || matches!( @@ -359,17 +436,21 @@ fn boot(app: AppHandle) { pub fn run() { let args = Args::parse(); - env_logger::Builder::from_env(env_logger::Env::default().default_filter_or(if args.verbose { - "debug" - } else { - "info" - })) + env_logger::Builder::from_env( + env_logger::Env::default().default_filter_or(if args.verbose { "debug" } else { "info" }), + ) .init(); let mut paths = BundledPaths::discover(); let dev = args.dev; + 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() + .plugin(updater) .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_single_instance::init(|app, _argv, _cwd| { if let Some(window) = app.get_webview_window("main") { @@ -386,23 +467,25 @@ pub fn run() { paths, process: 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())) - .title(APP_NAME) - .inner_size(1320.0, 860.0) - .min_inner_size(800.0, 560.0) - .decorations(false) - .resizable(true) - .initialization_script(INJECT) - .on_navigation(|url| { - if is_internal(&url) { - true - } else { - let _ = open::that(url.as_str()); - false - } - }); + let mut builder = + WebviewWindowBuilder::new(app, "main", WebviewUrl::App("index.html".into())) + .title(APP_NAME) + .inner_size(1320.0, 860.0) + .min_inner_size(800.0, 560.0) + .decorations(false) + .resizable(true) + .initialization_script(INJECT) + .on_navigation(|url| { + if is_internal(&url) { + true + } else { + let _ = open::that(url.as_str()); + false + } + }); if let Some(icon) = app.default_window_icon().cloned() { builder = builder.icon(icon)?; @@ -416,16 +499,18 @@ pub fn run() { if dev { window.open_devtools(); } + enable_microphone(&window); - let app_handle = app.handle().clone(); let _ = window; - thread::spawn(move || boot(app_handle)); Ok(()) }) .invoke_handler(tauri::generate_handler![ restart, open_in_browser, - read_clipboard_images + read_clipboard_images, + check_shell_update, + install_shell_update, + skip_shell_update, ]) .on_window_event(|window, event| { if let tauri::WindowEvent::CloseRequested { .. } = event { diff --git a/src-tauri/src/shell_updater.rs b/src-tauri/src/shell_updater.rs new file mode 100644 index 0000000..048b166 --- /dev/null +++ b/src-tauri/src/shell_updater.rs @@ -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, +} + +#[derive(Clone, Serialize)] +struct UpdateProgress { + downloaded: u64, + total: Option, + percent: Option, +} + +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, 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, 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")); + } +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 63e1062..7bc21ea 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -15,7 +15,13 @@ "csp": null } }, + "plugins": { + "updater": { + "pubkey": "" + } + }, "bundle": { + "createUpdaterArtifacts": true, "active": true, "targets": ["deb", "rpm", "nsis", "msi", "app", "dmg"], "publisher": "TommyFang2077", @@ -23,7 +29,7 @@ "copyright": "Copyright © 2026 TommyFang2077", "category": "DeveloperTool", "shortDescription": "Give DeepSeek eyes; Anchored Standard ~+8%", - "longDescription": "Official dsh WebUI in a native window. Built-in ModLens vision gives text-only DeepSeek eyes (paste to read images). Built-in Anchored Standard raises DeepSeek about 8% over official Standard on Project2 (Ability 91 to 98/99). 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", "icon": [ "icons/32x32.png", @@ -36,7 +42,9 @@ ], "resources": [ "../plugins/dsh-desktop-vision/", + "../plugins/dsh-desktop-voice/", "../vendor/modlens/", + "../vendor/dshmarket/", "../vendor/anchored-standard/", "../vendor/zero-anchored-standard/" ], @@ -62,6 +70,7 @@ }, "macOS": { "minimumSystemVersion": "10.15", + "infoPlist": "Info.plist", "dmg": { "appPosition": { "x": 180, "y": 170 }, "applicationFolderPosition": { "x": 480, "y": 170 } diff --git a/tests/shell_update.test.mjs b/tests/shell_update.test.mjs new file mode 100644 index 0000000..c871335 --- /dev/null +++ b/tests/shell_update.test.mjs @@ -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) +}) diff --git a/tests/test_plugins.py b/tests/test_plugins.py index 7b9edbb..639c90c 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -9,6 +9,12 @@ ROOT = Path(__file__).resolve().parents[1] class VisionPluginFilesTests(unittest.TestCase): def test_client_registers_system_settings_slots(self): + client = (ROOT / "plugins" / "dsh-desktop-vision" / "client.js").read_text( + encoding="utf-8" + ) + self.assertIn("settings.section", client) + self.assertNotIn("settings.plugins.tab", client) + self.assertIn("modlens-vision", client) client = (ROOT / "plugins" / "dsh-desktop-vision" / "client.js").read_text( encoding="utf-8" ) @@ -36,6 +42,40 @@ class VisionPluginFilesTests(unittest.TestCase): self.assertIn("example: 'haiku'", host) +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" + ) + self.assertIn("settings.section", client) + self.assertIn("conversation.input.right", client) + self.assertIn("dsh-desktop-voice", client) + self.assertIn("Ctrl+E", client) + 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("https://api.openai.com/v1", host) + self.assertIn("@deepseek-ai/dsh-client-ui-conversation", pkg) + self.assertIn("@deepseek-ai/dsh-client-ui-settings", pkg) + + class ClipboardIngestTests(unittest.TestCase): def test_inject_delivers_images_as_paste_not_only_drop(self): ingest = (ROOT / "ui" / "inject" / "ingest.js").read_text(encoding="utf-8") @@ -67,6 +107,9 @@ class BundledAttributionTests(unittest.TestCase): self.assertIn("3.16.6", 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.9.0", text) self.assertIn("dsh-plugin", readme) self.assertIn("带上眼睛", readme) self.assertIn("+8%", readme) @@ -74,6 +117,7 @@ class BundledAttributionTests(unittest.TestCase): self.assertTrue( (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"): self.assertTrue((ROOT / "docs" / "screenshots" / name).is_file()) diff --git a/tests/test_release.py b/tests/test_release.py new file mode 100644 index 0000000..f31a40e --- /dev/null +++ b/tests/test_release.py @@ -0,0 +1,72 @@ +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")) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/voice_client.test.mjs b/tests/voice_client.test.mjs new file mode 100644 index 0000000..674aafb --- /dev/null +++ b/tests/voice_client.test.mjs @@ -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%') +}) diff --git a/tests/voice_host.test.mjs b/tests/voice_host.test.mjs new file mode 100644 index 0000000..877b46a --- /dev/null +++ b/tests/voice_host.test.mjs @@ -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 }) + } +}) diff --git a/ui/app.js b/ui/app.js index 9468722..e43e171 100644 --- a/ui/app.js +++ b/ui/app.js @@ -2,6 +2,10 @@ const statusEl = document.getElementById("status"); const spinner = document.getElementById("spinner"); const retry = document.getElementById("retry"); 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() { 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() { const api = tauri(); if (!api) { @@ -39,10 +50,42 @@ async function boot() { 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 () { setStatus("正在重新启动…", "ok"); 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") { diff --git a/ui/index.html b/ui/index.html index 806056b..9130a18 100644 --- a/ui/index.html +++ b/ui/index.html @@ -13,6 +13,13 @@

DeepSeek Harness

正在启动…

+ diff --git a/ui/styles.css b/ui/styles.css index 9750cfc..acdf6b7 100644 --- a/ui/styles.css +++ b/ui/styles.css @@ -98,6 +98,40 @@ h1 { .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 { margin: 22px 0 0; text-align: left;