diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..860d4a5 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,3 @@ +[env] +"CMAKE_PROJECT_llama.cpp_INCLUDE" = { value = "scripts/cmake/windows-llama.cmake", relative = true, force = true } +"CMAKE_GENERATOR_x86_64-pc-windows-msvc" = { value = "Ninja", force = true } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5f47307..b7750e8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,9 @@ on: branches: [master, main] pull_request: +permissions: + contents: read + env: CARGO_TERM_COLOR: always @@ -12,9 +15,11 @@ jobs: rustfmt: runs-on: macos-14 steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable (2026-06-30) with: components: rustfmt @@ -24,13 +29,15 @@ jobs: clippy: runs-on: macos-14 steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable (2026-06-30) with: components: clippy - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 with: workspaces: src-tauri @@ -40,11 +47,13 @@ jobs: cargo-test: runs-on: macos-14 steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable (2026-06-30) - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 with: workspaces: src-tauri @@ -54,20 +63,20 @@ jobs: frontend-build: runs-on: macos-14 steps: - - uses: actions/checkout@v7 - - - uses: pnpm/action-setup@v6.0.9 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: - version: 10 + persist-credentials: false + + - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 - - uses: actions/setup-node@v6 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: 24 cache: pnpm - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable (2026-06-30) - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 with: workspaces: src-tauri @@ -90,20 +99,20 @@ jobs: frontend-test: runs-on: macos-14 steps: - - uses: actions/checkout@v7 - - - uses: pnpm/action-setup@v6.0.9 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: - version: 10 + persist-credentials: false - - uses: actions/setup-node@v6 + - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: 24 cache: pnpm - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable (2026-06-30) - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 with: workspaces: src-tauri @@ -115,3 +124,137 @@ jobs: - name: vitest run: pnpm test + + windows: + name: Windows x86_64 gates and NSIS smoke + runs-on: windows-latest + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: 24 + cache: pnpm + + - uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable (2026-06-30) + with: + components: clippy,rustfmt + targets: x86_64-pc-windows-msvc + + - uses: ilammy/msvc-dev-cmd@0b201ec74fa43914dc39ae48a89fd1d8cb592756 # v1.13.0 + + - name: Install Vulkan SDK + uses: humbletim/install-vulkan-sdk@30ba978f977e81b72d091fc8888feb1fb26f9aff # v1.2 + with: + version: 1.3.296.0 + cache: true + + - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 + with: + workspaces: src-tauri + + - name: Verify native build tools + shell: pwsh + run: | + ninja --version + if ([string]::IsNullOrWhiteSpace($env:VULKAN_SDK)) { + throw "VULKAN_SDK was not configured" + } + $vulkanCmakePrefix = Join-Path $env:VULKAN_SDK "Lib\cmake" + $spirvHeadersConfig = Join-Path $vulkanCmakePrefix "SPIRV-HeadersConfig.cmake" + if (-not (Test-Path -LiteralPath $spirvHeadersConfig -PathType Leaf)) { + throw "Vulkan SDK is missing SPIRV-HeadersConfig.cmake: $spirvHeadersConfig" + } + $cmakePrefixPath = if ([string]::IsNullOrWhiteSpace($env:CMAKE_PREFIX_PATH)) { + $vulkanCmakePrefix + } else { + "$vulkanCmakePrefix;$env:CMAKE_PREFIX_PATH" + } + Add-Content -LiteralPath $env:GITHUB_ENV -Encoding utf8 ` + -Value "CMAKE_PREFIX_PATH=$cmakePrefixPath" + & (Join-Path $env:VULKAN_SDK "Bin\glslc.exe") --version + $sevenZip = Join-Path $env:ProgramFiles "7-Zip\7z.exe" + if (-not (Test-Path -LiteralPath $sevenZip -PathType Leaf)) { + throw "Full 7-Zip is required for NSIS payload verification" + } + $sevenZipInfo = & $sevenZip i + if ($LASTEXITCODE -ne 0 -or -not ($sevenZipInfo | Select-String -SimpleMatch "Nsis")) { + throw "7-Zip does not support NSIS extraction" + } + $sevenZipInfo | Select-String -Pattern "7-Zip|Nsis" + + - name: Install frontend deps + run: pnpm install --frozen-lockfile + + - name: rustfmt + run: cargo fmt --check --manifest-path src-tauri/Cargo.toml + + - name: Rust no-default-features check + run: cargo check --manifest-path src-tauri/Cargo.toml --no-default-features + + - name: Rust no-default-features clippy + run: cargo clippy --manifest-path src-tauri/Cargo.toml --no-default-features -- -D warnings + + - name: Rust no-default-features test + run: cargo test --manifest-path src-tauri/Cargo.toml --no-default-features + + - name: Rust default check (CPU and Vulkan) + run: cargo check --manifest-path src-tauri/Cargo.toml + + - name: Rust default clippy (CPU and Vulkan) + run: cargo clippy --manifest-path src-tauri/Cargo.toml -- -D warnings + + - name: Rust default test (CPU and Vulkan) + run: cargo test --manifest-path src-tauri/Cargo.toml + + - name: Generate IPC bindings + run: pnpm gen:ipc + + - name: Frontend build + run: pnpm build + + - name: Frontend test + run: pnpm test + + - name: Release metadata tests + run: pnpm test:release-metadata + + - name: Build unsigned non-release NSIS smoke installer + shell: pwsh + run: | + pnpm tauri build --no-bundle --ci + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + + $config = @{ bundle = @{ createUpdaterArtifacts = $false } } | + ConvertTo-Json -Depth 8 -Compress + $configPath = Join-Path $env:RUNNER_TEMP "tauri-ci.json" + [IO.File]::WriteAllText( + $configPath, + $config, + [Text.UTF8Encoding]::new($false) + ) + pnpm tauri bundle --bundles nsis --ci ` + --config src-tauri/tauri.windows.bundle.conf.json ` + --config $configPath + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + + $bundleDir = [IO.Path]::GetFullPath("src-tauri/target/release/bundle/nsis") + $installer = Get-ChildItem -LiteralPath $bundleDir -Filter "*.exe" -File | + Select-Object -First 1 + if (-not $installer) { throw "NSIS smoke installer was not produced" } + $unexpectedUpdaterArtifacts = @( + Get-ChildItem -LiteralPath $bundleDir -Filter "*.nsis.zip" -File + Get-ChildItem -LiteralPath $bundleDir -Filter "*.exe.sig" -File + ) + if ($unexpectedUpdaterArtifacts.Count -gt 0) { + throw "Non-release smoke build unexpectedly produced updater artifacts" + } + Write-Host "NSIS smoke installer: $($installer.Name) ($($installer.Length) bytes)" + + - name: Verify NSIS payload and PE dependency closure + shell: pwsh + run: ./scripts/verify-windows-bundle.ps1 -RequireInstallerExtraction diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index a9200ce..2f1024e 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -6,7 +6,7 @@ on: workflow_dispatch: permissions: - contents: write + contents: read concurrency: group: nightly @@ -16,31 +16,30 @@ env: CARGO_TERM_COLOR: always jobs: - # Add build-windows/build-linux jobs next to this one as platform support lands. - # Each platform job should upload a nightly-assets- artifact; publish - # consumes those artifacts without caring which platform produced them. + # Each platform job uploads a nightly-assets- artifact. publish + # aggregates updater fragments and nightly metadata without rebuilding them. build-macos: name: Build macOS universal runs-on: macos-14 env: PLATFORM_ID: macos-universal steps: - - uses: actions/checkout@v7 - - - uses: pnpm/action-setup@v6.0.9 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: - version: 10 + persist-credentials: false + + - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 - - uses: actions/setup-node@v6 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: 24 cache: pnpm - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable (2026-06-30) with: targets: aarch64-apple-darwin,x86_64-apple-darwin - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 with: workspaces: src-tauri @@ -89,7 +88,136 @@ jobs: scripts/prepare-macos-assets.sh - name: Upload macOS nightly assets - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: nightly-assets-${{ env.PLATFORM_ID }} + path: nightly-assets/${{ env.PLATFORM_ID }}/* + if-no-files-found: error + + build-windows: + name: Build Windows x86_64 NSIS + runs-on: windows-latest + env: + PLATFORM_ID: windows-x86_64 + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: 24 + cache: pnpm + + - uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable (2026-06-30) + with: + targets: x86_64-pc-windows-msvc + + - uses: ilammy/msvc-dev-cmd@0b201ec74fa43914dc39ae48a89fd1d8cb592756 # v1.13.0 + + - name: Install Vulkan SDK + uses: humbletim/install-vulkan-sdk@30ba978f977e81b72d091fc8888feb1fb26f9aff # v1.2 + with: + version: 1.3.296.0 + cache: true + + - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 + with: + workspaces: src-tauri + + - name: Verify native build tools + shell: pwsh + run: | + ninja --version + if ([string]::IsNullOrWhiteSpace($env:VULKAN_SDK)) { + throw "VULKAN_SDK was not configured" + } + $vulkanCmakePrefix = Join-Path $env:VULKAN_SDK "Lib\cmake" + $spirvHeadersConfig = Join-Path $vulkanCmakePrefix "SPIRV-HeadersConfig.cmake" + if (-not (Test-Path -LiteralPath $spirvHeadersConfig -PathType Leaf)) { + throw "Vulkan SDK is missing SPIRV-HeadersConfig.cmake: $spirvHeadersConfig" + } + $cmakePrefixPath = if ([string]::IsNullOrWhiteSpace($env:CMAKE_PREFIX_PATH)) { + $vulkanCmakePrefix + } else { + "$vulkanCmakePrefix;$env:CMAKE_PREFIX_PATH" + } + Add-Content -LiteralPath $env:GITHUB_ENV -Encoding utf8 ` + -Value "CMAKE_PREFIX_PATH=$cmakePrefixPath" + & (Join-Path $env:VULKAN_SDK "Bin\glslc.exe") --version + $sevenZip = Join-Path $env:ProgramFiles "7-Zip\7z.exe" + if (-not (Test-Path -LiteralPath $sevenZip -PathType Leaf)) { + throw "Full 7-Zip is required for NSIS payload verification" + } + $sevenZipInfo = & $sevenZip i + if ($LASTEXITCODE -ne 0 -or -not ($sevenZipInfo | Select-String -SimpleMatch "Nsis")) { + throw "7-Zip does not support NSIS extraction" + } + $sevenZipInfo | Select-String -Pattern "7-Zip|Nsis" + + - name: Install frontend deps + run: pnpm install --frozen-lockfile + + - name: Generate IPC bindings + run: pnpm gen:ipc + + - name: Build Tauri-signed updater and unsigned NSIS installer + shell: pwsh + env: + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + TAURI_UPDATER_PUBKEY: ${{ secrets.TAURI_UPDATER_PUBKEY }} + run: | + foreach ($name in @( + "TAURI_SIGNING_PRIVATE_KEY", + "TAURI_UPDATER_PUBKEY" + )) { + if ([string]::IsNullOrWhiteSpace([Environment]::GetEnvironmentVariable($name))) { + throw "$name is required" + } + } + + $config = @{ + bundle = @{ createUpdaterArtifacts = $true } + plugins = @{ updater = @{ pubkey = $env:TAURI_UPDATER_PUBKEY.Trim() } } + } | ConvertTo-Json -Depth 8 -Compress + $configPath = Join-Path $env:RUNNER_TEMP "tauri-nightly.json" + [IO.File]::WriteAllText($configPath, $config, [Text.UTF8Encoding]::new($false)) + + pnpm tauri build --no-bundle --ci --config $configPath + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + + pnpm tauri bundle --bundles nsis --ci ` + --config src-tauri/tauri.windows.bundle.conf.json ` + --config $configPath + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + + - name: Verify Windows installer and updater signature + shell: pwsh + env: + TAURI_UPDATER_PUBKEY: ${{ secrets.TAURI_UPDATER_PUBKEY }} + run: ./scripts/verify-windows-bundle.ps1 -RequireInstallerExtraction -RequireUpdaterSignature + + - name: Prepare Windows nightly assets + shell: pwsh + env: + REQUIRE_AUTHENTICODE: "false" + TAURI_UPDATER_PUBKEY: ${{ secrets.TAURI_UPDATER_PUBKEY }} + run: | + $version = node -p "require('./package.json').version" + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + $env:BUILD_TIME = [DateTime]::UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ") + $env:ASSET_DIR = "nightly-assets/$($env:PLATFORM_ID)" + $env:INSTALLER_NAME = "typex-$($env:PLATFORM_ID)-nightly-unsigned-setup.exe" + $env:RELEASE_TAG = "nightly" + $env:UPDATER_VERSION = $version + $env:UPDATE_NOTES = "Nightly $env:GITHUB_SHA" + ./scripts/prepare-windows-assets.ps1 + + - name: Upload Windows nightly assets + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: nightly-assets-${{ env.PLATFORM_ID }} path: nightly-assets/${{ env.PLATFORM_ID }}/* @@ -99,21 +227,53 @@ jobs: name: Publish nightly release needs: - build-macos + - build-windows runs-on: ubuntu-22.04 + permissions: + contents: write steps: - - uses: actions/checkout@v7 + # This job force-moves the nightly tag, so checkout must retain its scoped write token. + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Download platform nightly assets - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: pattern: nightly-assets-* - path: nightly-assets - merge-multiple: true + path: nightly-artifacts + + - name: Collect nightly assets without name collisions + run: | + mkdir nightly-assets + found=0 + while IFS= read -r -d '' file; do + found=1 + name="$(basename "$file")" + if [[ -e "nightly-assets/$name" ]]; then + echo "Duplicate nightly asset name across platform artifacts: $name" + exit 1 + fi + cp "$file" "nightly-assets/$name" + done < <(find nightly-artifacts -type f -print0 | sort -z) + if [[ "$found" -ne 1 ]]; then + echo "No platform nightly assets were downloaded" + exit 1 + fi - - uses: actions/setup-node@v6 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: 24 + - uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable (2026-06-30) + + - name: Aggregate updater manifests + env: + EXPECTED_RELEASE_TAG: nightly + EXPECTED_CHANNEL: nightly + TAURI_UPDATER_PUBKEY: ${{ secrets.TAURI_UPDATER_PUBKEY }} + run: | + node scripts/aggregate-updater-manifests.mjs nightly-assets + find nightly-assets -maxdepth 1 -type f -name 'updater-*.json' -delete + - name: Prepare aggregate nightly metadata run: | export BUILD_TIME="$(date -u +'%Y-%m-%dT%H:%M:%SZ')" @@ -130,7 +290,7 @@ jobs: const dir = "nightly-assets"; const files = fs.readdirSync(dir).filter((file) => fs.statSync(`${dir}/${file}`).isFile()); const platformFiles = files - .filter((file) => file.endsWith(".json") && file !== "nightly.json" && file !== "latest.json") + .filter((file) => /^platform-[a-z0-9_-]+\.json$/.test(file)) .sort(); const platformAssets = platformFiles.map((file) => JSON.parse(fs.readFileSync(`${dir}/${file}`, "utf8"))); const assetFiles = files @@ -143,14 +303,12 @@ jobs: workflow_run: `${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}`, built_at: process.env.BUILD_TIME, platforms: platformAssets.map((item) => item.platform), - assets: [...assetFiles, "nightly.json"], + assets: [...assetFiles, ...platformFiles, "nightly.json"], platform_assets: platformAssets, }; fs.writeFileSync(`${dir}/nightly.json`, `${JSON.stringify(metadata, null, 2)}\n`); NODE - find nightly-assets -maxdepth 1 -type f -name '*.json' ! -name 'nightly.json' ! -name 'latest.json' -delete - PLATFORMS="$(node -e "const fs = require('fs'); const m = JSON.parse(fs.readFileSync('nightly-assets/nightly.json', 'utf8')); console.log(m.platforms.join(', '));")" cat > nightly-notes.md <> "$GITHUB_OUTPUT" - # Add build-windows/build-linux jobs next to this one as platform support lands. - # Each platform job should upload a release-assets- artifact; publish - # consumes those artifacts without caring which platform produced them. + # Each platform job uploads a release-assets- artifact. publish + # aggregates updater fragments and release metadata without rebuilding them. build-macos: name: Build macOS universal needs: validate @@ -71,22 +72,22 @@ jobs: env: PLATFORM_ID: macos-universal steps: - - uses: actions/checkout@v7 - - - uses: pnpm/action-setup@v6.0.9 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: - version: 10 + persist-credentials: false + + - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 - - uses: actions/setup-node@v6 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: 24 cache: pnpm - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable (2026-06-30) with: targets: aarch64-apple-darwin,x86_64-apple-darwin - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 with: workspaces: src-tauri @@ -136,7 +137,136 @@ jobs: scripts/prepare-macos-assets.sh - name: Upload macOS release assets - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: release-assets-${{ env.PLATFORM_ID }} + path: release-assets/${{ env.PLATFORM_ID }}/* + if-no-files-found: error + + build-windows: + name: Build Windows x86_64 NSIS + needs: validate + runs-on: windows-latest + env: + PLATFORM_ID: windows-x86_64 + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: 24 + cache: pnpm + + - uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable (2026-06-30) + with: + targets: x86_64-pc-windows-msvc + + - uses: ilammy/msvc-dev-cmd@0b201ec74fa43914dc39ae48a89fd1d8cb592756 # v1.13.0 + + - name: Install Vulkan SDK + uses: humbletim/install-vulkan-sdk@30ba978f977e81b72d091fc8888feb1fb26f9aff # v1.2 + with: + version: 1.3.296.0 + cache: true + + - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 + with: + workspaces: src-tauri + + - name: Verify native build tools + shell: pwsh + run: | + ninja --version + if ([string]::IsNullOrWhiteSpace($env:VULKAN_SDK)) { + throw "VULKAN_SDK was not configured" + } + $vulkanCmakePrefix = Join-Path $env:VULKAN_SDK "Lib\cmake" + $spirvHeadersConfig = Join-Path $vulkanCmakePrefix "SPIRV-HeadersConfig.cmake" + if (-not (Test-Path -LiteralPath $spirvHeadersConfig -PathType Leaf)) { + throw "Vulkan SDK is missing SPIRV-HeadersConfig.cmake: $spirvHeadersConfig" + } + $cmakePrefixPath = if ([string]::IsNullOrWhiteSpace($env:CMAKE_PREFIX_PATH)) { + $vulkanCmakePrefix + } else { + "$vulkanCmakePrefix;$env:CMAKE_PREFIX_PATH" + } + Add-Content -LiteralPath $env:GITHUB_ENV -Encoding utf8 ` + -Value "CMAKE_PREFIX_PATH=$cmakePrefixPath" + & (Join-Path $env:VULKAN_SDK "Bin\glslc.exe") --version + $sevenZip = Join-Path $env:ProgramFiles "7-Zip\7z.exe" + if (-not (Test-Path -LiteralPath $sevenZip -PathType Leaf)) { + throw "Full 7-Zip is required for NSIS payload verification" + } + $sevenZipInfo = & $sevenZip i + if ($LASTEXITCODE -ne 0 -or -not ($sevenZipInfo | Select-String -SimpleMatch "Nsis")) { + throw "7-Zip does not support NSIS extraction" + } + $sevenZipInfo | Select-String -Pattern "7-Zip|Nsis" + + - name: Install frontend deps + run: pnpm install --frozen-lockfile + + - name: Generate IPC bindings + run: pnpm gen:ipc + + - name: Build Tauri-signed updater and unsigned NSIS installer + shell: pwsh + env: + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + TAURI_UPDATER_PUBKEY: ${{ secrets.TAURI_UPDATER_PUBKEY }} + run: | + foreach ($name in @( + "TAURI_SIGNING_PRIVATE_KEY", + "TAURI_UPDATER_PUBKEY" + )) { + if ([string]::IsNullOrWhiteSpace([Environment]::GetEnvironmentVariable($name))) { + throw "$name is required" + } + } + + $config = @{ + bundle = @{ createUpdaterArtifacts = $true } + plugins = @{ updater = @{ pubkey = $env:TAURI_UPDATER_PUBKEY.Trim() } } + } | ConvertTo-Json -Depth 8 -Compress + $configPath = Join-Path $env:RUNNER_TEMP "tauri-release.json" + [IO.File]::WriteAllText($configPath, $config, [Text.UTF8Encoding]::new($false)) + + pnpm tauri build --no-bundle --ci --config $configPath + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + + pnpm tauri bundle --bundles nsis --ci ` + --config src-tauri/tauri.windows.bundle.conf.json ` + --config $configPath + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + + - name: Verify Windows installer and updater signature + shell: pwsh + env: + TAURI_UPDATER_PUBKEY: ${{ secrets.TAURI_UPDATER_PUBKEY }} + run: ./scripts/verify-windows-bundle.ps1 -RequireInstallerExtraction -RequireUpdaterSignature + + - name: Prepare Windows release assets + shell: pwsh + env: + VERSION: ${{ needs.validate.outputs.version }} + REQUIRE_AUTHENTICODE: "false" + TAURI_UPDATER_PUBKEY: ${{ secrets.TAURI_UPDATER_PUBKEY }} + run: | + $env:BUILD_TIME = [DateTime]::UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ") + $env:ASSET_DIR = "release-assets/$($env:PLATFORM_ID)" + $env:INSTALLER_NAME = "typex-$($env:PLATFORM_ID)-$($env:VERSION)-unsigned-setup.exe" + $env:RELEASE_TAG = $env:GITHUB_REF_NAME + $env:UPDATER_VERSION = $env:VERSION + $env:UPDATE_NOTES = "Typex $env:GITHUB_REF_NAME`nCommit: $env:GITHUB_SHA" + ./scripts/prepare-windows-assets.ps1 + + - name: Upload Windows release assets + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: release-assets-${{ env.PLATFORM_ID }} path: release-assets/${{ env.PLATFORM_ID }}/* @@ -147,19 +277,54 @@ jobs: needs: - validate - build-macos + - build-windows runs-on: ubuntu-22.04 + permissions: + contents: write steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + - name: Download platform release assets - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: pattern: release-assets-* - path: release-assets - merge-multiple: true + path: release-artifacts + + - name: Collect release assets without name collisions + run: | + mkdir release-assets + found=0 + while IFS= read -r -d '' file; do + found=1 + name="$(basename "$file")" + if [[ -e "release-assets/$name" ]]; then + echo "Duplicate release asset name across platform artifacts: $name" + exit 1 + fi + cp "$file" "release-assets/$name" + done < <(find release-artifacts -type f -print0 | sort -z) + if [[ "$found" -ne 1 ]]; then + echo "No platform release assets were downloaded" + exit 1 + fi - - uses: actions/setup-node@v6 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: 24 + - uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable (2026-06-30) + + - name: Aggregate updater manifests + env: + EXPECTED_RELEASE_TAG: ${{ github.ref_name }} + EXPECTED_CHANNEL: stable + TAURI_UPDATER_PUBKEY: ${{ secrets.TAURI_UPDATER_PUBKEY }} + run: | + node scripts/aggregate-updater-manifests.mjs release-assets + find release-assets -maxdepth 1 -type f -name 'updater-*.json' -delete + - name: Prepare aggregate release metadata env: VERSION: ${{ needs.validate.outputs.version }} @@ -178,7 +343,7 @@ jobs: const dir = "release-assets"; const files = fs.readdirSync(dir).filter((file) => fs.statSync(`${dir}/${file}`).isFile()); const platformFiles = files - .filter((file) => file.endsWith(".json") && file !== "release.json" && file !== "latest.json") + .filter((file) => /^platform-[a-z0-9_-]+\.json$/.test(file)) .sort(); const platformAssets = platformFiles.map((file) => JSON.parse(fs.readFileSync(`${dir}/${file}`, "utf8"))); const assetFiles = files @@ -191,14 +356,12 @@ jobs: workflow_run: `${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}`, built_at: process.env.BUILD_TIME, platforms: platformAssets.map((item) => item.platform), - assets: [...assetFiles, "release.json"], + assets: [...assetFiles, ...platformFiles, "release.json"], platform_assets: platformAssets, }; fs.writeFileSync(`${dir}/release.json`, `${JSON.stringify(metadata, null, 2)}\n`); NODE - find release-assets -maxdepth 1 -type f -name '*.json' ! -name 'release.json' ! -name 'latest.json' -delete - PLATFORMS="$(node -e "const fs = require('fs'); const m = JSON.parse(fs.readFileSync('release-assets/release.json', 'utf8')); console.log(m.platforms.join(', '));")" cat > release-notes.md </dev/null 2>&1; then + if RELEASE_STATE="$(gh release view "$GITHUB_REF_NAME" --json isDraft,isImmutable --jq '[.isDraft, .isImmutable] | @tsv' 2>/dev/null)"; then + IFS=$'\t' read -r IS_DRAFT IS_IMMUTABLE <<< "$RELEASE_STATE" + if [[ "$IS_DRAFT" != "true" ]]; then + echo "Release $GITHUB_REF_NAME is already published; refusing to change it back to draft." + exit 1 + fi + if [[ "$IS_IMMUTABLE" == "true" ]]; then + echo "Release $GITHUB_REF_NAME is immutable; refusing to edit its assets." + exit 1 + fi + gh release edit "$GITHUB_REF_NAME" \ --title "Typex $GITHUB_REF_NAME" \ --notes-file release-notes.md \ diff --git a/.gitignore b/.gitignore index c912c0e..cd8eb21 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ # Rust /src-tauri/target /target +/tools/updater-signature-verifier/target/ # Node node_modules @@ -9,6 +10,7 @@ dist # 生成物 /src/ipc/bindings.ts /src-tauri/gen/schemas +/src-tauri/windows-runtime/ # 系统 .DS_Store diff --git a/AGENTS.md b/AGENTS.md index 5480fb2..0a1e55c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -40,6 +40,7 @@ docs/ # 长期维护文档集,结构见 docs/README.md - 分层依赖单向:前端 → app → orchestrator → service → platform。service 之间不横向依赖;只有 orchestrator 能同时调用多个 service。 - `app/` 只做 Tauri 胶水;业务流程归 `orchestrator/`。 - `src/ipc/bindings.ts` 是生成物,禁止手改。 +- 新增、升级或修改 `src-tauri/vendor/` 包时,必须同步更新 [`src-tauri/vendor/README.md`](src-tauri/vendor/README.md),记录 vendor 原因、上游基线和所有 Typex 修改文件;移除 vendor 时同步删除对应条目。 - 不记录转写文本内容、音频内容、密钥或凭据;日志只记长度、耗时、错误分类等非敏感信息。 - 改 IPC 契约、配置 schema、状态机行为、Provider wire shape、UI token 或错误码时,必须同步更新对应章节。 - 不要删除、回滚或覆盖用户已有改动,除非用户明确要求。 @@ -136,9 +137,10 @@ cargo build --manifest-path src-tauri/Cargo.toml --no-default-features ## 平台验证 -- 当前可用平台是 macOS;Windows / Linux 适配即将展开,代码保持三平台 trait 可扩展。 -- HUD 必须是 nonactivating NSPanel。 -- rdev 未获辅助功能权限时会静默无事件,排查热键问题先看权限。 +- 当前支持 macOS 与 Windows x64;Linux 仍待适配,代码保持三平台 trait 可扩展。 +- HUD 在 macOS 必须是 nonactivating NSPanel;Windows 必须带 `WS_EX_NOACTIVATE`,显示前后前台 HWND 不得变化。 +- macOS 的 rdev 未获辅助功能权限时会静默无事件;Windows 快捷键先检查低级键盘钩子诊断状态。 +- Windows 构建保持默认 `src-tauri/target`;不要为规避路径长度把 `CARGO_TARGET_DIR` 重定向到盘符根目录,也不要占用应用运行时 cwd。 - cpal Stream 非 Send;录音相关问题优先检查专属线程与 callback 是否只做轻量拷贝。 - macOS 实机 UI 截图可用 `screencapture -x -l `;窗口 ID 用 `CGWindowListCopyWindowInfo` 查。 - 不要用全局鼠标点击自动化验证 UI,会干扰正在使用电脑的用户。 diff --git a/README.md b/README.md index f961db6..12f4d69 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,10 @@ Typex is an open-source, desktop AI voice input tool. Hold a shortcut and speak Get the latest release from the [GitHub Releases page](https://github.com/typex-ink/Typex/releases). +Windows builds support 64-bit Windows 10 22H2 or later and Windows 11. The NSIS installer uses the WebView2 Evergreen Bootstrapper and may need an internet connection when WebView2 is not already installed. Native AI runtimes, the required VC++ runtime files, and the Vulkan loader are deployed app-locally; no separate developer tools or system-wide VC++/Vulkan runtime installation is required. + +Windows release candidates are currently not Authenticode-signed. Windows may therefore show an **Unknown Publisher** or Microsoft Defender SmartScreen warning. Verify the SHA-256 checksum and Tauri updater signature published with the release before choosing **More info > Run anyway**. Managed devices using WDAC, AppLocker, or Smart App Control may block unsigned builds entirely. + **On macOS**, if you see a warning that the app "cannot be verified to be free of malware" on launch, remove the quarantine attribute after installing: ```bash @@ -43,4 +47,4 @@ sudo xattr -dr com.apple.quarantine /Applications/Typex.app ## License -Typex is licensed under GPL-3.0. See [LICENSE](LICENSE) for details. \ No newline at end of file +Typex is licensed under GPL-3.0. See [LICENSE](LICENSE) for details. diff --git a/README_ZH.md b/README_ZH.md index 89d16f4..dbb654d 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -31,6 +31,10 @@ Typex 是一款开源的桌面 AI 语音输入工具。按住快捷键说话, 前往 [GitHub Releases 页面](https://github.com/typex-ink/Typex/releases) 下载最新版本。 +Windows 版本支持 64 位 Windows 10 22H2 及以上版本和 Windows 11。NSIS 安装器使用 WebView2 Evergreen Bootstrapper;如果系统尚未安装 WebView2,安装时需要联网。本地 AI 原生运行库、所需 VC++ runtime 与 Vulkan loader 均随应用目录分发,无需另装开发工具或系统级 VC++/Vulkan runtime。 + +当前 Windows 候选构建尚未进行 Authenticode 发布者签名,因此 Windows 可能显示“未知发布者”或 Microsoft Defender SmartScreen 提示。选择“更多信息 > 仍要运行”前,请先核对随版本提供的 SHA-256 校验值和 Tauri updater 签名。启用 WDAC、AppLocker 或 Smart App Control 的受管设备可能会直接阻止未签名构建。 + **macOS 用户注意**:如果启动时提示"无法验证是否包含恶意软件",安装后运行以下命令移除隔离属性即可: ```bash @@ -43,4 +47,4 @@ sudo xattr -dr com.apple.quarantine /Applications/Typex.app ## 许可证 -Typex 基于 GPL-3.0 许可证开源,详见 [LICENSE](LICENSE)。 \ No newline at end of file +Typex 基于 GPL-3.0 许可证开源,详见 [LICENSE](LICENSE)。 diff --git a/docs/01-product-overview.md b/docs/01-product-overview.md index d36224d..5d93413 100644 --- a/docs/01-product-overview.md +++ b/docs/01-product-overview.md @@ -9,7 +9,7 @@ **Typex 是一款开源、跨平台(Windows / macOS / Linux)的 AI 语音输入软件:按住一个键说话,松开后被整理干净的文字出现在任何应用的光标处——并且能翻译你说的话、能听懂你对选中文本的指令、能随时语音提问得到回答。** -当前可用平台是 macOS;Windows 与 Linux 是接下来要适配的目标平台。产品定位仍是桌面三平台。 +当前支持 macOS 12+ 与 Windows x64(Windows 10 22H2+ / Windows 11);Linux 是下一步适配目标。产品定位仍是桌面三平台。 口号(候选): diff --git a/docs/02-features.md b/docs/02-features.md index dc52d6e..18fc6ab 100644 --- a/docs/02-features.md +++ b/docs/02-features.md @@ -36,7 +36,7 @@ ``` 按键按下 ──▶ 开始录音(HUD 出现,波形反馈) -按键松开 ──▶ 停止录音 ──▶ [VAD 裁剪静音] ──▶ STT Provider 转写 +按键松开 ──▶ 停止录音 ──▶ [所选 VAD 裁剪静音] ──▶ STT Provider 转写 ──▶ 文本整理(F-9,默认开启,可一键切「原样模式」) ──▶ 文本注入(光标处)──▶ HUD 成功反馈后隐藏 ``` @@ -48,23 +48,28 @@ - **短按切换(toggle)**:按一下开始,再按一下结束。两种模式同一个键自动区分,无需配置。 - `Esc` 或点击 HUD 取消按钮 = 放弃本次录音,不产生任何输出。 - **录音**:16-bit PCM,按设备原生采样率采集后重采样为 16 kHz 单声道(见 [06 代码架构 §7.4](06-code-architecture.md))。录音期间 HUD 显示实时波形(麦克风是否在工作必须一眼可见)。 +- **语音活动检测(VAD)**:设置中可选「神经网络」(默认)或「能量门限」。神经网络路径使用内嵌权重的 Silero VAD,每 512 个 16 kHz 样本推理;进入门限默认 `0.50`,退出门限为 `max(进入门限 - 0.15, 0.01)`,最短语音 90 ms、结束静音 100 ms。能量路径以 30 ms RMS 帧判断,默认门限 `0.010`。两种模式都从首个语音区间向前保留 300 ms、从最后语音区间向后保留 150 ms;神经网络初始化或推理失败时自动降级到用户设置的能量门限。 +- **弱声保底**:VAD 未检出语音但录音中存在连续至少 90 ms 的弱信号时,提交未裁剪音频给 STT,避免轻声起音被误删;真正静音仍返回 `NoSpeech`。录音开始时快照整组 VAD 配置,失败重试与长录音切片沿用同一快照。 - **无时长硬上限**:这是对 Typeless「6 分钟上限」的直接回应。实现上: - 录音超过 Provider 单次上限(如 OpenAI 25 MB)前自动切片,切片边界选在 VAD 静音处,分段转写后拼接,用户无感。 - HUD 显示已录制时长;超过 10 分钟给出温和提示(非中断)。 - **语言**:默认「自动检测」;可在设置中固定语言(传给 STT 的 `language` 参数,能提升准确率与速度)。 - **注入**:默认「剪贴板粘贴 + 恢复剪贴板」策略;按平台自动选择注入后端,允许高级用户切换(见 [06 代码架构 §7.5](06-code-architecture.md))。焦点应用不可写(无输入焦点)时,结果自动进入剪贴板并由 HUD 提示「已复制,Ctrl+V 粘贴」。 - **失败处理**(铁律:不丢话): + - 麦克风固定设备缺失、启动失败或录音中被拔出:录音态立即结束并进入可见失败态,释放当前音频流;修复设备后可直接重试录音,不等待用户再次按下或松开快捷键。 - 网络/服务错误:指数退避自动重试 2 次;仍失败则 HUD 显示错误与「重试」按钮,**本次录音音频保留在内存/临时目录**直至用户放弃。 - 转写结果为空(纯静音):HUD 提示「没有听到声音」,附麦克风选择快捷入口。 ### 验收标准 -1. 在微信 / Slack / VS Code / 浏览器地址栏 / 终端 中均能正确注入中英文(含标点、emoji、换行)。 +1. 受控文本输入 harness 必须正确接收中英文、标点、emoji 与换行;平台代表应用至少覆盖 macOS 原生文本控件,及 Windows 的记事本、Edge、VS Code、Windows Terminal。微信 / Slack / Word 等只作兼容抽测,不是支持声明的前置条件。 2. 5 秒中文短句:松开按键到文字上屏 P50 < 2 s(含整理环节;「原样模式」下 < 1.5 s。以 Groq/豆包极速版级别的 Provider 测量)。 3. 含语气词、重复与中途改口的口语样句,上屏结果为整理后的干净文本(测试样例集见 F-9)。 4. 断网时给出明确错误且音频不丢;恢复网络后点重试成功。 5. 15 分钟连续口述不中断、不丢内容。 6. 非美式键盘布局(如德语 QWERTZ、中文输入法激活状态)下注入内容无乱码。 +7. Windows 目标窗口无焦点或因 UIPI 处于更高完整性级别时,不尝试自动提权;结果完整复制到剪贴板并显示可操作提示。 +8. Windows 默认快捷键按下后立即发音时,75 ms 语义确认窗口不得截掉起音;轻声起音可通过弱声保底送达 STT,纯静音仍不得发起 Provider 请求。 --- @@ -145,7 +150,7 @@ ### 验收标准 -1. 在 Chrome / VS Code / Word / 备忘录中选中文本 → 口述「翻译成英文」→ 原地替换成功,**全程无弹窗**,HUD 显示完整流水线并以 ✓ 结束。 +1. 在受控 AX/UIA harness 与平台代表应用中选中文本 → 口述「翻译成英文」→ 原地替换成功,**全程无弹窗**,HUD 显示完整流水线并以 ✓ 结束;不暴露文本选区 API 的应用必须可靠降级为剪贴板读取或普通提问。 2. 选中报错日志口述「这是什么原因」→ 选区下方弹窗展示回答且**不**替换选区。 3. 无选区按住助手键提问 → 屏幕上 1/3 居中弹窗展示回答。 4. 弹窗出现后点击其他应用(焦点切走)→ 弹窗自动关闭。 @@ -189,18 +194,23 @@ ## F-5 快捷键系统 - 三个全局快捷键:**听写**、**助手**、**翻译**(默认为全修饰键三角方案:右⌘/右Ctrl、右⌥/右Alt、两键同按;详见 [05 UX 规格 §7](05-ux-spec.md))。 +- 听写与助手配置中的每个按键数组表示一个**完整 chord**,不是多个候选键:只有数组内全部物理键同时按住才触发。翻译 chord 是听写 chord 与助手 chord 的有序去重并集,因此两组自定义多键组合也必须完整按下后才进入翻译。 +- 听写与助手 chord 必须非空且互不包含(相同也属于互相包含);否则其中至少一个功能会被派生翻译 chord 遮蔽,设置界面必须阻止保存,后端也必须拒绝非法更新。读取到历史非法配置时仅把热键恢复为当前平台默认值,其他设置保持不变。 - 每个键支持「长按 push-to-talk / 短按 toggle」双模(同键自动区分,阈值 350 ms,可调)。 - 支持**单个修饰键**作为触发键(如仅「右 ⌘」「右 Alt」)——这是品类标准体验,需要底层键盘监听而非普通热键 API(实现见 [06 代码架构 §7.3](06-code-architecture.md));**组合键让路规则**保证触发键的日常组合用法(⌘C 等)零干扰。 +- Windows 在单修饰键 75 ms 语义确认窗口开始时立即启动仅驻内存的候选录音;确认 Typex 手势后以同一 token 提升为正式录音,不重开设备。普通键、AltGr、暂停、配置更新、hook 失败或退出会静默取消匹配候选,不显示 HUD、不播放提示音、不写历史或磁盘,也不向 Provider/电平事件发送任何候选数据。 +- 自定义普通键按物理位置记录,与当前键盘布局及输入法输出字符无关;历史版本保存的后端别名在读取时迁移到稳定 `KeyId`,界面标签仍按 macOS/Windows/Linux 惯例显示。 - 冲突检测:录制快捷键时检测与系统/常见软件的已知冲突并警告。 - Wayland 降级:无法全局监听时自动引导用户走 Portal 授权或 compositor 绑定方案(见 [06 代码架构 §8](06-code-architecture.md))。 ## F-6 托盘与常驻行为 - Typex 无主窗口,启动后驻留系统托盘/菜单栏。托盘图标反映状态:空闲 / 录音中 / 处理中 / 错误(图标规格见 [04 设计系统 §3.4](04-design-system.md))。 -- 托盘菜单:`复制上次结果`、`文本整理开关`、`翻译目标`、**`模型 ▸`(按 听写/文本整理/翻译/问答 分组列出兼容服务配置,点选即切,F-4 配置池的快速切换入口)**、`暂停 Typex`(临时禁用全局监听)、`设置…`、`主页…`、`检查更新`、`退出`。 +- 托盘菜单:`复制上次结果`、`文本整理开关`、`翻译目标`、**`模型 ▸`(按 听写/文本整理/翻译/问答 分组列出兼容服务配置,点选即切,F-4 配置池的快速切换入口)**、`暂停 Typex`(临时禁用全局监听;进入暂停时静默取消当前录音,含短按 toggle,不中断已经进入转写/处理的会话)、`设置…`、`主页…`、`检查更新`、`退出`。 - 单实例:二次启动唤起设置窗口而非新进程。 -- 开机自启:默认询问(onboarding 最后一步),可随时在设置中改。 +- 开机自启:默认询问(onboarding 最后一步),可随时在设置中改;应用启动与设置变更只在系统注册状态和配置不一致时写入,已关闭且不存在注册项是正常状态,不得产生警告。 - 自动更新:内置 updater(GitHub Releases 渠道),默认开启检查、手动确认安装([ADR-11](08-decisions.md))。 +- Windows 分发为 x64 NSIS 用户安装器,WebView2 使用 Evergreen Bootstrapper;卸载默认保留设置、历史和已下载模型。Tauri updater 完整性签名与 Windows Authenticode 发布者签名相互独立。 ## F-7 历史记录与主页统计 @@ -265,5 +275,6 @@ - **问答槽不提供本地兜底**——未配置时助手指令走 HUD 失败态显示配置引导;但设置中允许用户手动选择本地 LLM 档案(性能档设备可选本地大档模型)。 - **模型管理**:不随安装包分发;应用内按需下载(HuggingFace + ModelScope 双源自动择优,校验和 + 断点续传),设置-模型服务页可删除已下载模型,并在模型管理页底部固定下载源(自动 / HuggingFace / ModelScope)。 - **运行时**:模型常驻内存或「录音开始时预热」可选;本地推理零网络请求。 +- **Windows 加速**:CPU 路径始终可用;GPU 路径统一使用 Vulkan,不维护 CUDA 专用分发矩阵。缓存必须记录模型实际加载模式,同一模型实例的推理通过独占租约串行执行,避免并发请求在 fallback 时继续持有失败的 GPU 模型。仅当模型确实以 GPU offload 加载后,运行时上下文初始化或 decode 失败时自动改用严格 CPU 参数重载并从头重试一次:模型设置 `n_gpu_layers=0` 且设备列表为空,context 禁用 K/Q/V 与算子 offload,Qwen3-ASR 的 mtmd 同时禁用 GPU;释放失败 GPU 模型后才可在缓存锁外加载 CPU。本地 LLM 只允许在 ThinkingFilter 后尚未发出首个可见 delta 时重试,已发出 delta 后明确失败且不得重放;Qwen3-ASR 为非流式,可在整次转写返回前用 CPU 完整重试一次。GGUF/音频/提示词校验、上下文过长等输入错误、无 GPU、已经 CPU 加载或 CPU 重试失败均不得继续重试。 - **零配置路径**:onboarding 第 3 步提供「使用本地模型」一键选项(显示检测到的推荐档位与体积,下载后 STT/整理/翻译功能指向本地服务配置),见 F-8。 - Provider 抽象与模型下载管理器统一承载本地 STT 与本地 LLM;本地能力与云端能力可按功能槽位混搭。 diff --git a/docs/03-model-providers.md b/docs/03-model-providers.md index 4d983be..70f5764 100644 --- a/docs/03-model-providers.md +++ b/docs/03-model-providers.md @@ -98,6 +98,7 @@ Content-Type: application/json - **Qwen3-ASR(标准/性能档)**:llama.cpp(qwen3vl 音频架构,官方 ggml-org GGUF)跑 `Qwen3-ASR-0.6B`(Q8_0 主模型 + mmproj 约 1.0 GB)/ `Qwen3-ASR-1.7B`(Q8_0 主模型 + mmproj 约 2.5 GB,仅 GPU 加速可用时提供——纯 CPU 低于实时)。52 语言 + 22 中文方言,1.7B 为开源 ASR SOTA。**注意**:llama.cpp 音频支持仍标 experimental、长音频有已知 bug——所有音频先过 VAD 切片成短分段再转写(本来就是 F-1 的路径),规避该问题。 - Qwen3-ASR 的 llama.cpp / OpenAI-compatible 网关输出可能带 `language Chinese...` 包装;Provider 层必须剥离 `language ...` 前缀、把语言填入 `Transcript.detected_language`,不得把包装文本传给 orchestrator / 前端 / 注入层。 + - 缓存条目记录 GPU/CPU load mode,同一缓存的转写通过独占 inference lease 串行执行。仅 GPU-loaded 条目的不透明 mtmd 初始化错误或 context/eval/decode 运行错误触发一次完整 CPU 重载,并用同一音频与词典从头重试;CPU 模型设备列表为空,context offload 与 mtmd GPU 均关闭。fallback 先释放失败 GPU 代际,再在缓存锁外加载 CPU;WAV/GGUF/mmproj 预校验、明确的 `support_audio=false`、prompt/tokenize 等输入或模型契约错误不重试,显式 unload 后不得把重试模型重新写回缓存。 - **SenseVoice(轻量档)**:sherpa-onnx(官方 Rust crate,静态链接)+ `SenseVoice-Small int8`(约 230 MB)。非自回归,CPU 实时数倍速——弱机器上唯一保证实时的选项;自带 VAD 可复用。F-10 词典经 sherpa hotwords 接口传入。 - **Whisper large-v3(高配精度档)**:sherpa-onnx Whisper 导出(encoder/decoder int8 + tokens,约 1.8 GB)。质量和语言覆盖优先,CPU 推理通常低于实时,只作为设置页手动下载/选择的高精度 STT,不参与零配置推荐档。 - whisper.cpp 降为可选扩展(当前 Whisper large-v3 先走 sherpa-onnx;large-v3-turbo / Parakeet / Moonshine 等需新增运行时或稳定导出后再进入清单)。 @@ -157,7 +158,7 @@ SSE 事件流:处理 `response.output_text.delta`(增量文本)、`respons - **引擎**:llama.cpp(`llama-cpp-2` 绑定——GGUF 生态最全,Apple Silicon Metal 加速成熟;与本地 STT 的 Qwen3-ASR 共用同一引擎,[ADR-22](08-decisions.md))。 - **模型**:Qwen3.5 小模型系列 instruct GGUF(0.8B / 2B / 4B / 9B,Q4_K_M)按硬件档位/设置页下载;高配用户可手动下载 Qwen3 14B / 30B-A3B / 32B Q4_K_M。Apache 2.0,多语言,中文分词效率高。 - **槽位策略**:本地 LLM 可绑定到「文本整理」「翻译模型」「问答模型」槽;零配置路径只自动指向整理/翻译,问答槽默认仍为空并显示配置引导。性能档设备可在设置中手动把问答槽指向本地 4B–32B 级模型([ADR-22](08-decisions.md))。 -- **运行时策略**:模型常驻内存或「录音开始时预热」(设置可选);冷加载约 1–3 s。上下文窗口按需 4 K 即可(整理/翻译都是短输入)。 +- **运行时策略**:模型常驻内存或「录音开始时预热」(设置可选);冷加载约 1–3 s。上下文窗口按需 4 K 即可(整理/翻译都是短输入)。缓存记录 GPU/CPU load mode;同一缓存的推理由独占租约串行执行。仅 GPU-loaded 模型在 context 初始化或 decode 失败、且 ThinkingFilter 后尚未向调用方发出首个可见 delta 时,允许严格关闭模型设备、K/Q/V 与算子 offload 后从头 CPU 重试一次。fallback 必须先从缓存移除并释放失败 GPU 代际,再在缓存锁外加载 CPU;显式 unload 与 CPU 加载竞态时本次请求使用 detached CPU 模型,不得回填过时代际。首个可见 delta 发出后发生错误时不得重放,直接返回明确错误;prompt/tokenize/上下文长度等输入错误、无 GPU、CPU-loaded 模型和 CPU 重试错误不再重试。`UnloadAfterUse` 只清理自己持有的缓存代际。 - **思考模式**:本地 Qwen LLM 仅支持开关语义。`profiles[].options.reasoning_effort=none` 或缺省时视为关闭,其他 effort 等级视为开启;旧配置 `profiles[].options.enable_thinking=true` 继续等价于开启。Provider 在最后一条用户消息末尾注入 `/think` 或 `/no_think` 控制词。即便模型仍输出 `...`,Provider 层也会在流式 delta 进入 orchestrator 前过滤。 - `capabilities()`:流式 = 是;错误分类只剩 `InvalidRequest`/模型未下载/内存不足。 @@ -330,7 +331,7 @@ F-3 不引入新的 Provider 类型: ```jsonc { - "schema_version": 4, + "schema_version": 7, "dictionary": { "terms": ["Typex", "OpenAI", "Qwen3-ASR"] }, @@ -339,6 +340,19 @@ F-3 不引入新的 Provider 类型: "check_updates": true, "update_channel": "stable" // stable = 正式版 release;nightly = 最新 nightly build }, + "dictation": { + "vad": { + "mode": "neural", // neural | energy;v7 默认 neural + "energy_threshold": 0.010, // 0.001..0.050,步长 0.001 + "neural_threshold": 0.50 // 0.10..0.90,步长 0.05 + } + }, + "hotkeys": { + "dictation": ["ControlRight"], // 一个完整 chord,稳定物理 KeyId + "assistant": ["AltRight"], + "translation": ["ControlRight", "AltRight"], // 前两组的派生有序并集 + "hold_threshold_ms": 350 + }, "slots": { "stt": { "active_profile": "groq-fast" }, "polish": { "active_profile": "deepseek" }, // 文本整理 @@ -398,6 +412,7 @@ F-3 不引入新的 Provider 类型: 要点: - `capability` 决定服务配置可被哪些功能槽位选择:`stt` 只能用于语音转文字,`llm` 可用于文本整理 / 翻译 / 问答;`kind` 决定 adapter;`credentials` 是 **map 结构**(为火山双凭据这类情况设计),值随 profile 存在 `settings.json`,与其他配置项一致。诊断包、导出配置与日志必须剔除或脱敏 credentials;旧版 `keyring://` 引用会在迁移时清除,运行时也视为未配置,用户需重新保存密钥。 +- schema v7 为 `dictation.vad` 增加双路径配置。所有旧版本统一迁移为 `mode: neural`,两个门限独立保存;后端拒绝非有限值或越界值。磁盘中只有 VAD 子配置无效时,仅恢复 `dictation.vad` 默认值,其他设置必须保留。 - LLM `options.reasoning_effort` 控制思考等级,允许 `none` / `minimal` / `low` / `medium` / `high` / `xhigh`;设置 UI 默认保存 `none`,缺省仅表示旧配置或手写配置“不指定”。Responses 发送 `reasoning.effort`,普通 OpenAI 兼容 Chat Completions 发送顶层 `reasoning_effort`。Qwen 兼容端点与本地模型只支持开关语义,使用兼容字段 `options.enable_thinking` / `/think` / `/no_think`,其中 `none` 视为关闭,其他等级视为开启。 - **预设模板**(前端内置数据,非后端逻辑):OpenAI / Groq / SiliconFlow / 火山·豆包 / DeepSeek / OpenRouter / Ollama —— 选中即预填 `kind/base_url/model` 与凭据字段表单,用户只贴密钥。 - 「测试连接」:STT 槽发内置 2 秒样音(assets 内置,中文「你好,Typex」),LLM 槽发 `ping` 单词请求;展示延迟与分类后的错误。 diff --git a/docs/04-design-system.md b/docs/04-design-system.md index 5129470..38fb900 100644 --- a/docs/04-design-system.md +++ b/docs/04-design-system.md @@ -45,11 +45,16 @@ | 平台 | 形状与背景 | 交付尺寸 | |---|---|---| | macOS | 系统 squircle 蒙版;默认交付纸版(Dock 图标为静态资源,不随主题自动切换,优先展示白底高对比图标);墨版仅作为界面内联变体 | `.icns`(16–1024 全套,@1x/@2x) | -| Windows | 同一设计直接用圆角矩形(半径 27%),默认交付纸版 | `.ico`(16/24/32/48/64/256) | +| Windows | 同一设计直接用圆角矩形(半径 27%);安装包 / 任务栏默认交付纸版,常规窗口标题栏随主题在墨版与纸版间切换 | `.ico`(16/24/32/48/64/256) | | Linux | 同 Windows 设计 | hicolor PNG 16–512 + SVG | 小尺寸(≤32px)仍保留五柱 + 竖笔,与应用内 mini 图标一致。 +Windows 常规窗口必须按每窗口 DPI 同时设置原生 small / big HICON,并在跨屏 DPI +变化后刷新;例如 144 DPI 分别使用 24px / 48px 图层。禁止把 `.ico` 第一帧的 +16px 位图放大后同时用于标题栏和任务栏。标题栏在浅色主题使用墨版、深色主题使用纸版; +`system` 跟随 Windows 应用主题,并在运行时主题切换后立即更新。 + 源文件:`assets/icon/typex.svg`(单一 SVG 源,脚本导出全平台,禁止手改位图)。 ### 2.3 Logo 与文字标 @@ -59,7 +64,7 @@ ### 2.4 托盘 / 菜单栏图标(状态系统) -托盘图标 = 去掉竖笔的**五柱波形 glyph**(小尺寸下比完整 T 清晰)。macOS 用 template image(系统自动适配深浅/高亮),Windows/Linux 提供深浅两套。 +托盘图标 = 去掉竖笔的**五柱波形 glyph**(小尺寸下比完整 T 清晰)。macOS 用 template image(系统自动适配深浅/高亮);Windows/Linux 提供深浅两套,Windows 启动时及系统主题变化后重新选择高对比资源,不依赖 macOS template 语义。 | 状态 | 表现 | |---|---| @@ -120,7 +125,7 @@ ### 3.4 主题切换 -默认**跟随系统**(`prefers-color-scheme`),可手动固定亮/暗。所有颜色一律经 CSS 变量引用,组件内禁止硬编码色值(lint 规则保障)。HUD 与托盘同样双主题。常规窗口的原生标题栏 / 系统控件外观必须同步 `general.theme`:`light` / `dark` 固定时调用 Tauri native theme,`system` 时交回系统外观。主页 / 设置窗口打开时必须在首个 paint 前应用初始主题底色,禁止先露出浏览器默认白底。macOS 主页 / 设置窗口使用透明 titlebar,并将 NSWindow 背景固定为 `--surface-2` 对应颜色,避免 AppKit 深色标题栏材质与页面 token 不一致。 +默认**跟随系统**(`prefers-color-scheme`),可手动固定亮/暗。所有颜色一律经 CSS 变量引用,组件内禁止硬编码色值(lint 规则保障)。HUD 与托盘同样双主题。常规窗口的原生标题栏 / 系统控件外观必须同步 `general.theme`:`light` / `dark` 固定时调用 Tauri native theme,`system` 时交回系统外观。Windows 在更新 native theme 后必须立即重绘非客户区,禁止依赖窗口失焦再激活才刷新标题栏。主页 / 设置窗口打开时必须在首个 paint 前应用初始主题底色,禁止先露出浏览器默认白底。macOS 主页 / 设置窗口使用透明 titlebar,并将 NSWindow 背景固定为 `--surface-2` 对应颜色,避免 AppKit 深色标题栏材质与页面 token 不一致。 ## 4. 字体 @@ -145,11 +150,11 @@ | HUD 出现/消失 | 220 ms,spring(stiffness 380 / damping 30):自屏幕底边缘上滑 12px + 淡入;消失 160 ms 淡出下滑 | | 波形 | Canvas 60 fps,柱高对电平做 80 ms 平滑;**只在录音时运行**(空闲 CPU≈0 铁律) | | 状态切换(录音→处理→成功) | 200 ms 交叉淡入;成功时胶囊短促「回弹」1 次(scale 1→1.04→1) | -| 处理中 | 五柱依次呼吸 + 高光微光(前景色 12% 不透明度)从左到右 1.6 s 循环 | +| 处理中 | 五柱依次呼吸 + 高光微光(前景色 12% 不透明度)从左到右 1.6 s 循环;相位按单调时钟计算,不得按渲染帧累加,所有平台与刷新率节奏一致 | | 按钮/hover | 120 ms ease-out | | 全局 | 尊重 `prefers-reduced-motion`:动画替换为 80 ms 淡入淡出;波形降为静态电平条 | -提示音(`assets/sounds/`,皆 < 150 ms、-14 LUFS、可关闭):开始 = 上行双音「叮·咚」(清爽、不刺耳);成功 = 单音短「嗒」;错误 = 低频闷「噗」。三枚音色同一乐器族(木琴系),构成品牌听觉签名。 +提示音(`assets/sounds/`,皆 < 150 ms、-14 LUFS、可关闭、全局音量 0–100% 且默认 60%):开始 = 上行双音「叮·咚」(清爽、不刺耳);成功 = 单音短「嗒」;错误 = 低频闷「噗」。三枚音色同一乐器族(木琴系),构成品牌听觉签名。 ## 7. 组件规范(前端 `src/components/`) diff --git a/docs/05-ux-spec.md b/docs/05-ux-spec.md index 44af113..43b5a55 100644 --- a/docs/05-ux-spec.md +++ b/docs/05-ux-spec.md @@ -37,6 +37,8 @@ ``` - 左键点击托盘 = 打开主页;右键 = 菜单(macOS 均为菜单,符合平台惯例)。 +- 选择「暂停 Typex」立即禁用全局触发,并静默取消当前录音(含短按 toggle);已进入转写/处理的会话继续完成。恢复后只接受新的完整按键手势,不沿用暂停前的 held 状态。 +- Windows 低级键盘钩子在运行期失效时,立即静默取消当前录音并把托盘状态改为 `⚠ 快捷键不可用`;同一终态不得重复取消或反复提示。应用主动退出导致的正常 hook shutdown 不显示错误。 ## 3. HUD(核心界面) @@ -61,7 +63,7 @@ - 翻译模式:徽标显示 `中 → EN`(点击弹出目标语言快切);助手模式:徽标 `助手`。 - 波形必须真实反映电平——用户要能一眼确认「麦克风在收音」。收不到声音 3 秒 → 波形区显示「没有听到声音」微提示。 -**处理中**:波形区渐变为五柱依次呼吸动画 + 文案(`正在转写…` → `正在整理…`/`正在翻译…`);超过 5 s 显示已耗时。 +**处理中**:波形区渐变为五柱依次呼吸动画 + 文案(`正在转写…` → `正在整理…`/`正在翻译…`);呼吸动画按真实经过时间保持 1.6 s 固定周期,不随平台或显示器刷新率变化;超过 5 s 显示已耗时。 **成功**:胶囊短促回弹,显示 `✓ 已输入`(`--success`),600 ms 后下滑淡出。 @@ -98,7 +100,7 @@ hidden ─按键按下─▶ recording ─松开─▶ processing ─▶ success ### 4.1 形态 -优先显示在选中文本下方(放不下时上方),选区位置不可得或无选区时回退到屏幕上 1/3 居中;宽 560px,高度随内容收缩/展开(透明区域不得拦截点击),大小不可手动调整,圆角 16,毛玻璃 + 边框;禁用系统原生窗口阴影与外扩 CSS 阴影;显示时获得焦点;**失焦(焦点切到其他应用)自动关闭**,无固定(📌)选项。 +优先显示在选中文本下方(放不下时上方),选区位置不可得或无选区时回退到屏幕上 1/3 居中;宽 560px,高度随内容收缩/展开(透明区域不得拦截点击),大小不可手动调整,圆角 16,毛玻璃 + 边框;禁用系统原生窗口阴影与外扩 CSS 阴影;显示时获得焦点;**失焦(焦点切到其他应用)自动关闭**,无固定(📌)选项。Windows 使用前台窗口所在显示器的 work area 和每显示器 DPI 计算位置,必须支持负坐标副屏。 ### 4.2 布局 @@ -117,7 +119,7 @@ hidden ─按键按下─▶ recording ─松开─▶ processing ─▶ success ### 4.3 行为 - **单轮语义**:每次提问独立(不携带上一问);新提问重置弹窗内容;无会话列表、无历史线程、无动作按钮。 -- **上下文读取**:松开助手键(录音结束、转写开始)时读取当前选中文本——不能在按住期间读,剪贴板降级的模拟 Cmd+C 会触发组合键让路误取消会话([06 §7.6](06-code-architecture.md));macOS 剪贴板降级使用固定 CGEvent 键码,不经输入法布局查询。 +- **上下文读取**:松开助手键(录音结束、转写开始)时读取当前选中文本——不能在按住期间读,剪贴板降级的模拟 Cmd+C 会触发组合键让路误取消会话([06 §7.6](06-code-architecture.md));macOS 剪贴板降级使用固定 CGEvent 键码,不经输入法布局查询。Windows 优先使用 UIA 的明确空/非空结果;仅在 Ctrl+C 降级时读取已知编辑器的空选区元数据,VS Code/Monaco 的 `vscode-editor-data` 必须是 `version=1` 且 `isFromEmptySelection=true` 才按无选区处理。缺失、损坏或未知元数据不得用文本长度/换行启发式丢弃内容;没有可靠 marker 的第三方编辑器若把无选区 Ctrl+C 定义为复制整行,仍可能被当作选区,这是明确的兼容性残余。 - **弹出时机**:无选区 → 转写完成、LLM 请求发出即弹窗并流式渲染;有选区 → 流首部确认为回答型(`ANSWER:` 信号,见 [03 §3.4](03-model-providers.md))的那一刻才弹窗——改写型结果全程不弹窗(直接替换选区,反馈走 HUD)。 - **关闭**:右上角 ✕ / `Esc` / 焦点切换到其他应用。 - 流式渲染节流 30 fps;渲染 Markdown(标题/列表/代码块),链接点击用系统浏览器打开;回答文本可直接选中复制(`⌘/Ctrl+C`)。 @@ -152,20 +154,22 @@ hidden ─按键按下─▶ recording ─松开─▶ processing ─▶ success - 页面分两段:上方「功能分配」每个功能槽位一张 `ProviderCard`,只负责显示/切换当前服务;下方「配置池」列出全部 STT / LLM 服务配置,负责新建、编辑、测试、删除。配置池放在页面底部,避免遮挡最常用的功能分配。 - 「编辑」进入**编辑子页**(同一内容区内切换,顶部「← 模型服务」返回链接;保存/删除档案后自动返回列表;有未保存修改时离开需确认):预设下拉(OpenAI/Groq/SiliconFlow/火山·豆包/DeepSeek/OpenRouter/Ollama/自定义,仅作表单填充、无推荐标注)→ 按 `capability/kind` 动态渲染字段(STT:openai_compat Base URL/模型/密钥 或 volcengine AppKey/AccessToken/ResourceId;LLM:Chat Completions / Responses;所有 LLM 档案额外显示「思考等级」下拉,默认“关闭”,选项为关闭/最小/低/中/高/极高;本地与 Qwen 兼容端点按开关语义处理)。「已下载模型」管理页同为子页,同样以「← 模型服务」返回。 - **「切换 ▾」= 从服务池选择兼容配置([ADR-21](08-decisions.md))**:下拉列出该功能可用的服务配置(✓ 标当前)+「新建配置…」;新建不覆盖旧档案,切换即时生效。同一组配置也出现在托盘「模型 ▸」子菜单中(快速切换,不用打开设置)。 +- 新建配置在同一次编辑流程内执行「测试连接」和「保存」时必须复用同一个档案 ID;测试后保存只更新该档案并完成槽位分配,不得向配置池追加内容相同的副本。 - 密钥输入用 `SecretInput`(默认掩码、可见性切换、粘贴按钮);保存后随 profile 写入 `settings.json` 的 `credentials` 字段,诊断包与日志必须脱敏。 - 「测试」按钮内联显示结果:成功显示 `✓ 320ms` 延迟徽标;失败时测试按钮自身变为红色「测试失败」,完整分类错误与上游报错只在悬停/键盘聚焦时显示,避免长错误文案挤压卡片布局。 - **本地 Provider 卡片**:预设列表含「本地 · 离线」;卡片副标题显示引擎与模型(如 `local · Qwen3.5-2B`)及模型状态(`已下载 · 1.3 GB` / `未下载 [下载]`,下载中显示进度条);无密钥字段,编辑态字段 = 模型(下拉,来自模型库)+ 加载策略(常驻 / 录音时预热);「测试」= 本地跑一次内置样音/ping。问答槽允许手动选择本地 LLM,但不参与零配置自动兜底([ADR-20](08-decisions.md))。 -- **已下载模型管理**(页面底部「管理…」进入):已下载列表(名称 · 用途 · 体积 · 来源 · 许可证 · 被哪些槽使用 · 删除,删除使用中模型先警告)+「导入模型」入口(选择本地 GGUF/ONNX/tokens 文件,托管导入到 Typex 模型目录)+ 模型库可下载列表(行内显示硬件要求与本机检测结果,如「需 GPU 加速(本机 ✓ Metal)」,[ADR-22](08-decisions.md))+ 底部占用合计与下载源切换(自动 / HuggingFace / ModelScope)。下载中进度条旁显示已下载 / 总量。 +- **已下载模型管理**(页面底部「管理…」进入):已下载列表(名称 · 用途 · 体积 · 来源 · 许可证 · 被哪些槽使用 · 删除,删除使用中模型先警告)+「导入模型」入口(选择本地 GGUF/ONNX/tokens 文件,托管导入到 Typex 模型目录)+ 模型库可下载列表(行内显示硬件要求与本机检测结果,如「需 GPU 加速(本机 ✓ Metal/Vulkan)」,[ADR-22](08-decisions.md))+ 底部占用合计与下载源切换(自动 / HuggingFace / ModelScope)。下载中进度条旁显示已下载 / 总量。 - 共用连接不再需要单独开关:多个 LLM 功能选择同一个池中服务配置,即表示共用连接。 ### 5.2 其他页要点 -- **通用**:语言(跟随系统/中/英)、主题(跟随系统/亮/暗)、开机自启、提示音开关与音量、网络代理(跟随系统/手动/直连)、更新通道(稳定版 = 正式 release;开发版/Nightly = 最新 nightly build)。 -- **听写**:文本整理开关、整理提示词(高级,折叠;展开为模板编辑器——占位符 `{transcript}`(原始转写,必需)/`{dictionary}`(个人词典,可选)/`{target_app}`(目标应用,可选)高亮显示,缺必需占位符时禁用保存并行内报错,「恢复默认」回内置模板;占位符规格见 [03 §3.4](03-model-providers.md))、注入方式(自动/剪贴板粘贴/逐字输入)、粘贴延迟滑杆、麦克风设备选择 + 电平实时预览条。文本整理默认开启;关闭后听写、翻译、助手都直通原始 STT 文本,不经过整理层。 +- **通用**:语言(跟随系统/中/英)、主题(跟随系统/亮/暗)、开机自启、提示音开关与音量(0–100%,默认 60%;关闭时滑杆禁用但保留上次音量)、网络代理(跟随系统/手动/直连)、更新通道(稳定版 = 正式 release;开发版/Nightly = 最新 nightly build)。 +- **听写**:文本整理开关、整理提示词(高级,折叠;展开为模板编辑器——占位符 `{transcript}`(原始转写,必需)/`{dictionary}`(个人词典,可选)/`{target_app}`(目标应用,可选)高亮显示,缺必需占位符时禁用保存并行内报错,「恢复默认」回内置模板;占位符规格见 [03 §3.4](03-model-providers.md))、VAD 模式分段控件(「神经网络 / 能量门限」,全键盘可操作)及当前模式唯一可见的门限滑杆(神经网络 `0.10–0.90`、步长 `0.05`、默认 `0.50`;能量 `0.001–0.050`、步长 `0.001`、默认 `0.010`)。模式行须说明神经网络适合多数环境、能量门限仅按输入音量判断;门限行须说明神经模式调节的是语音置信度、能量模式调节的是输入音量,并明确两者均为数值越低越灵敏、数值越高越能过滤噪声,通常保持默认即可。其后依次为注入方式(自动/剪贴板粘贴/逐字输入)、粘贴延迟滑杆和麦克风设备选择。设备下拉显示用户可读名称,但保存稳定设备 ID;已固定设备缺失或旧名称无法唯一迁移时保留失效项并明确标为不可用,不得伪装成系统默认。文本整理默认开启;关闭后听写、翻译、助手都直通原始 STT 文本,不经过整理层。 - **翻译**:源语言(你说话使用的语言)、目标语言(要翻译成的语言)、双向翻译开关(检测到说的是目标语言时自动译回源语言)、翻译提示词(高级,折叠,置底;模板编辑器同听写,占位符 `{transcript}`/`{source_language}`/`{target_language}`/`{target_app}`)。 - **助手**:选区处理提示词与问答提示词(高级,均折叠;可单独展开,未编辑的另一项仍显示入口;模板编辑器同听写)。选区处理模板占位符 `{selection}`(选中文本,必需)/`{instruction}`(语音指令,必需)/`{target_app}`(目标应用,可选),问答模板占位符 `{instruction}`(语音指令,必需)/`{selection}`(选中文本,可选)/`{target_app}`(目标应用,可选)。 - **历史**:开关(默认开,附一句说明:仅存本机、不含音频)、保留期限(默认 90 天)、清空按钮。 - **诊断**:环境自检(平台/compositor/可用注入后端/权限状态,每项 ✓/✗ + 修复引导按钮)、日志目录、导出诊断包(自动脱敏)。 +- **Windows 诊断**:额外显示低级键盘钩子、SendInput、UI Automation、WebView2、当前进程完整性级别与本地模型 CPU/Vulkan 状态;只显示能力与错误分类,不暴露 HWND、窗口标题全文或选区内容。 - **调试**:开发/测试入口;提供「重新进入引导页」按钮,用于手动打开首次启动引导流程,不修改 `onboarding_done` 状态。 - **关于**:图标、Typex logotype、版本号、许可证、官网、隐私口号、检查更新、开源许可入口。点击「开源许可」用系统浏览器打开 GitHub 仓库 `LICENSE`;点击官网链接打开 `typex.ink`。检查更新发现新版本后只展示确认卡片,下载安装必须由用户主动点击确认。 @@ -195,16 +199,21 @@ macOS 权限卡片:麦克风、辅助功能(注入+读选区)、输入监 设计逻辑:**只用修饰键,不碰任何普通键**。修饰键「单独按住」在几乎所有软件里是空操作,右侧修饰键又极少参与日常组合键——这是冲突概率最低的一类键位;三键有记忆结构(A=输入,B=问答,A+B=输入的变体:翻译)。 -**乐观启动与模式判定**:任一触发键按下即开始录音;350 ms 判定窗口内组合出另一个触发键 → 无缝切换为翻译模式(音频保留、HUD 徽标即时变化);录音中追加按下另一键同样即时升级为翻译。 +**乐观启动与模式判定**:任一已确认触发手势开始可见会话;350 ms 判定窗口内组合出另一个触发键 → 无缝切换为翻译模式(音频保留、HUD 徽标即时变化);录音中追加按下另一键同样即时升级为翻译。Windows 默认右 Ctrl / 右 Alt 单键进入最多 75 ms 的静默确认窗口时,立即以唯一 token 启动内存候选录音;出现普通键/AltGr 则静默取消并释放,出现第二触发键则立即确认并升级,单键超时确认则将同一流提升为正式录音。候选阶段无 HUD、提示音、电平、历史、磁盘或 Provider 副作用;单键确认后的 HUD 仍须满足按下到可见 ≤ 100 ms。 + +**自定义 chord 语义**:`HotkeyRecorder` 为听写和助手各保存一个完整 chord;数组内全部键按住后才算手势开始,partial chord 不启动录音。翻译键位由两组 chord 的有序去重并集自动派生,不单独编辑。两组 chord 必须非空且互不包含;相同或任一为另一子集会让功能被翻译手势遮蔽,界面就地显示校验提示并阻止保存。手势一旦开始,追加另一功能的完整 chord 可升级为翻译;随后沿用既有 release 语义,等本次 tracked 触发键全部释放才结束。录制普通键时按物理位置保存稳定 `KeyId`,换 QWERTZ 等布局或切换输入法不会把同一位置变成另一绑定,展示标签仍按当前平台惯例本地化。 + +`HotkeyRecorder` 空闲时按钮显示「更改」,录制时只把同一按钮文案切换为「按下键位…」,不得在设置行下方额外展开录制提示块。`Esc` 仍静默取消且不修改原绑定;录制完成后的快捷键冲突警告继续单独显示。 **配套规则(实现必须遵守,见 [06 §7.3](06-code-architecture.md))**: -1. **组合键让路**:按住触发键期间若按下任何**普通键**(用户实际在用 `⌘C`、`AltGr+E` 等系统/输入组合键),立即静默取消本次录音并完全放行按键——右⌘/右Alt 的日常用法零干扰。这条规则是单修饰键触发可行性的根基。 +1. **组合键让路**:按住触发键期间若按下任何**普通键**(用户实际在用 `⌘C`、`Ctrl+C`、`AltGr+E` 等系统/输入组合键),立即静默让路并完全放行按键。Windows 在 75 ms 确认窗口内只能存在不可见候选流,不得触发 HUD、提示音、电平或业务会话;普通组合键取消候选,确认窗口后已经启动的正式录音则立即静默取消。这条规则是单修饰键触发可行性的根基。 2. **Windows Alt 单击吞键**:单击 Alt 会聚焦应用菜单栏,助手键短按(切换录音)时须经低级键盘钩子吞掉该次 keyup,防止菜单栏被误激活(仅此一处需要事件拦截,比拦截 `Ctrl+Space` 类组合简单得多)。 3. AltGr 布局(欧洲键盘):右 Alt 物理键即 AltGr——规则 1 已保证 `AltGr+字母` 打特殊字符不受影响;单独按住 AltGr 说话无副作用。 4. **漏 release 自恢复**:底层监听偶发漏掉触发键 release 时,下一次同触发键 down 不得永久静默;判定器应在短暂抖动窗口后重置旧状态并重新触发,最多丢弃上一段卡住的录音,不让快捷键进入“按了没反应”的持久状态。 +5. Windows 低级钩子必须忽略 Typex 自己通过 SendInput 注入的事件;`Right Ctrl+C`、物理 `Right Alt+普通键` 与典型 AltGr 原始序列(伪 `Left Ctrl` + `Right Alt`)在确认窗口内产生普通键时,不得产生任何 HUD、提示音或录音副作用。 -**已知残余情况**(onboarding 第 4 步当场改键兜底):MacBook 内置键盘无右 Ctrl(故 macOS 用右 ⌘);用户用 Karabiner/PowerToys 重映射过右侧修饰键;HHKB 等无右修饰键的小配列键盘。`HotkeyRecorder` 支持单修饰键、修饰键组合与普通键,录制时对系统热键表与常见软件冲突黄条警告;另提供预设建议:CapsLock(提示 macOS 中文用户注意中英切换冲突)、F13–F19、右键菜单键(仅 Win/Linux)。 +**已知残余情况**(onboarding 第 4 步当场改键兜底):MacBook 内置键盘无右 Ctrl(故 macOS 用右 ⌘);用户用 Karabiner/PowerToys 重映射过右侧修饰键;HHKB 等无右修饰键的小配列键盘。`HotkeyRecorder` 支持单修饰键、修饰键组合与普通键,录制时对系统热键表与常见软件冲突黄条警告;另提供预设建议:CapsLock(提示 macOS 中文用户注意中英切换冲突)、F13–F19、右键菜单键(仅 Win/Linux)。历史配置中的 `Return`/`Num1`/`LeftArrow`/`AltGr` 等后端名只作为迁移别名接受,新录制值统一保存为稳定 `KeyId`。 ### 7.2 回答弹窗内快捷键 @@ -250,6 +259,7 @@ macOS 权限卡片:麦克风、辅助功能(注入+读选区)、输入监 | NoSpeech | `没有听到声音` | [选择麦克风] | | NoFocus | `当前没有输入焦点,结果已复制` | — | | PermissionMissing | `缺少辅助功能权限,无法输入文字` | [去授权] | +| InjectionBlocked | `目标应用权限更高,结果已复制,请手动粘贴` | — | 所有失败路径必须保住已有产物:STT 成功后任何失败 → `复制原文` 兜底;注入失败 → 自动转剪贴板 + 明示。 diff --git a/docs/06-code-architecture.md b/docs/06-code-architecture.md index 2562b07..57033ec 100644 --- a/docs/06-code-architecture.md +++ b/docs/06-code-architecture.md @@ -25,10 +25,10 @@ IPC 使用 **tauri-specta** 自动生成 TS 类型绑定,杜绝前后端接口 | 用途 | crate | 备注 | |---|---|---| -| 全局按键监听(push-to-talk) | `rdev`(mac/win/X11)+ `ashpd`(Wayland Portal)| Handy 同款路线;支持单修饰键按住 | +| 全局按键监听(push-to-talk) | `rdev`(mac/X11)+ 原生 Win32 `WH_KEYBOARD_LL` + `ashpd`(Wayland Portal)| Windows 需要选择性吞右 Alt keyup、识别 AltGr 与注入事件,因此不用 rdev 监听后端 | | 录音 | `cpal` | 事实标准 | | 重采样 | `rubato` | 设备原生采样率 → 16 kHz mono | -| VAD | `vad-rs`(Silero) | 静音裁剪 + 长录音切片边界 | +| VAD | `silero-vad-crs 0.4`(Silero,内嵌权重)+ 能量门限 | 双路径静音裁剪 + 长录音切片边界 | | WAV 编码 | `hound` | 上传用 | | 提示音 | `rodio` | 开始/结束/错误音 | | 文本注入 | `enigo`(模拟 Cmd/Ctrl+V)+ `arboard`(剪贴板存取恢复) | Wayland 另有外部工具后端,见 §8 | @@ -39,8 +39,9 @@ IPC 使用 **tauri-specta** 自动生成 TS 类型绑定,杜绝前后端接口 | 本地数据 | `rusqlite`(历史)+ `tauri-plugin-store`(设置) | | | macOS 权限 | `tauri-plugin-macos-permissions` + `macos-accessibility-client` | 检测 + 引导跳转 | | 应用基建 | `tauri-plugin-single-instance` / `-autostart` / `-updater` / `-global-shortcut`(备用);托盘内置 | 全官方插件 | +| Windows 系统 API | target-specific `windows` crate | 最小 feature 集;`unsafe` 集中在 `platform/windows.rs` 与对应平台 backend 边界 | | Linux HUD | `gtk-layer-shell`(经 `gtk_window()` 句柄) | Handy 验证过的方案 | -| 本地推理([ADR-20](08-decisions.md)/[ADR-22](08-decisions.md)) | LLM+Qwen3-ASR:`llama-cpp-2`;SenseVoice/Whisper STT:`sherpa-onnx`(官方 crate,静态链接);硬件探测:`sysinfo` | feature flag `local-models` 集中管理,可 `--no-default-features` 裁剪;推荐档按硬件下载,高配模型手动选择([03 §8](03-model-providers.md));whisper.cpp 降为可选扩展 | +| 本地推理([ADR-20](08-decisions.md)/[ADR-22](08-decisions.md)) | LLM+Qwen3-ASR:`llama-cpp-2`;SenseVoice/Whisper STT:`sherpa-onnx`;硬件探测:`sysinfo` | feature flag `local-models` 集中管理,可 `--no-default-features` 裁剪;`llama-cpp-2` target-gate 为 macOS Metal / Windows Vulkan / 其他 CPU;GPU-loaded 模型仅在运行初始化/decode 错误且尚可安全重放时回退一次 CPU;Windows 的 sherpa/ONNX、Vulkan loader 与 MSVC runtime 采用安装目录内 app-local 部署;推荐档按硬件下载,高配模型手动选择([03 §8](03-model-providers.md)) | ## 2. 进程与窗口模型 @@ -151,6 +152,7 @@ typex/ │ ├── types/ # 跨层共享类型(serde + specta derive) │ │ ├── session.rs # SessionSnapshot, SessionMode, SessionPhase… │ │ ├── profile.rs # ProviderProfile, SlotKind… +│ │ ├── hotkey.rs # 稳定 KeyId 规范、别名迁移与 chord 归一化 │ │ └── diagnostics.rs │ ├── error.rs # TypexError 顶层错误 + 用户可见错误码(§5.4) │ └── logging.rs # tracing 初始化 + redact 层 @@ -207,9 +209,11 @@ pub enum SessionPhase { |---|---|---| | Tauri 主线程 | 事件循环 | 窗口/托盘/菜单(部分平台 API 要求主线程) | | tokio runtime | Tauri 自带 | orchestrator 执行器、全部 provider HTTP、settings/history IO | -| hotkey 线程 | `std::thread`(rdev 阻塞监听) | 键盘事件 → 判定逻辑 → `mpsc` 发给 orchestrator | +| hotkey 线程 | `std::thread`(mac/X11 为 rdev;Windows 为 Win32 消息循环) | 键盘事件 → 判定逻辑 → `mpsc` 发给 orchestrator;Windows 安装/卸载 `WH_KEYBOARD_LL` | +| UIA 线程(Windows) | 专用 COM STA/MTA worker | 初始化 COM、执行 `TextPattern.GetSelection` 与 bounds 查询;请求有超时且不阻塞 Tauri 主线程 | | cpal 回调线程 | 音频驱动回调 | **只做** ring buffer 写入(实时线程禁止分配/锁/日志) | -| audio worker 线程 | `std::thread` | 从 ring buffer 取样 → 重采样/电平/VAD → 电平事件节流 50ms 发前端 | +| audio worker 线程 | `std::thread` | 从 ring buffer 取样 → 重采样/电平/VAD;候选阶段只积累内存,提升为正式录音后才以 50ms 节流发电平事件 | +| 本地推理 worker | LLM 使用专属 `std::thread`;ASR 使用 `spawn_blocking` | 每个模型缓存用独占 inference lease 串行推理;条目携带 GPU/CPU load mode 与代际;GPU runtime fallback 先释放失败代际,再在缓存锁外以无设备、无 context/mtmd offload 的严格 CPU 参数加载,LLM 在首个可见 delta 前、ASR 在非流式返回前最多从头重试一次 | 跨线程通信统一 `tokio::sync::mpsc`(service → orchestrator)与 `watch`(配置广播);hotkey/audio 线程持有的发送端是它们与外界的唯一接口。 @@ -239,17 +243,18 @@ pub enum SessionPhase { - **除用户自己配置的端点外,无任何网络请求**(更新检查除外,可关)。无遥测(见 [ADR-5](08-decisions.md))。 - 音频不落盘;仅当转写失败等待重试时写入临时目录(内存优先,超大切片才落盘),成功或放弃后立即删除。 +- Windows 快捷键确认期间的候选音频严格只驻内存:只有匹配 token 被确认后才能成为会话音频;取消后立即释放,禁止写磁盘、历史、日志、Provider 或前端电平事件。Typex 不常驻占用麦克风,候选只从物理触发键按下开始,因此设备实际回调前的硬件启动时间无法追溯。 - 剪贴板注入前保存原内容、注入后恢复;文档中明示「恢复仅支持文本/图片格式」的已知妥协。 ## 7. 关键系统能力实现方案 ### 7.1 平台适配矩阵(总览) -当前可用平台是 macOS;Windows 与 Linux 是接下来要适配的目标平台。下表同时作为目标方案与验收边界,不表示所有平台后端已经落地。 +当前支持 macOS 与 Windows;Linux 是下一步适配目标。Windows 首发边界为 `x86_64-pc-windows-msvc`、Windows 10 22H2+ 与 Windows 11。 | 能力 | macOS | Windows | Linux X11 | Linux Wayland | |---|---|---|---|---| -| 全局按住说话 | rdev(需辅助功能/输入监听权限) | rdev(WH_KEYBOARD_LL) | rdev(XTEST/XRecord) | Portal GlobalShortcuts(ashpd);evdev 兜底 | +| 全局按住说话 | rdev(需辅助功能/输入监听权限) | 原生 `WH_KEYBOARD_LL` 消息循环 | rdev(XTEST/XRecord) | Portal GlobalShortcuts(ashpd);evdev 兜底 | | 文本注入 | 剪贴板 + CGEvent Cmd+V | 剪贴板 + SendInput Ctrl+V | 剪贴板 + XTEST Ctrl+V | wtype(wlroots 系)/ ydotool·dotool(GNOME/KDE)/ 仅复制降级 | | 读选中文本 | AX API → 静音 Cmd+C 降级 | UIA TextPattern → Ctrl+C 降级 | primary selection | primary selection(部分可用)→ 仅手动粘贴降级 | | HUD 置顶浮窗 | NSPanel(不抢焦点) | 原生支持 | 原生支持 | gtk-layer-shell(GNOME 不支持 → 降级为托盘状态) | @@ -263,25 +268,38 @@ pub enum SessionPhase { 4. **HUD 抢焦点会毁掉注入**:macOS 必须 NSPanel + nonactivating style;其他平台设置不可聚焦标志。 5. **逐字模拟键入在非美式布局/输入法激活时乱码** → 默认剪贴板粘贴路径;粘贴前 60 ms 级可调延迟(部分慢应用需要)。 6. **X11 组合键 release 事件顺序 bug**(global-hotkey#39)→ 用 rdev 自维护按键状态,不依赖热键 API 的 release。 -7. **Windows UIPI**:目标窗口是管理员进程时 SendInput 被拦截 → 检测失败并提示(或建议以管理员运行)。 +7. **Windows UIPI**:目标窗口是更高完整性进程时 SendInput 被拦截 → 预先比较完整性级别并检查发送计数;失败后把完整结果留在剪贴板并提示手动粘贴。Typex 不自动提权,也不建议长期以管理员身份运行。 8. **webkit2gtk**:NVIDIA 驱动下白屏/崩溃 → 启动时探测并自动注入 `WEBKIT_DISABLE_DMABUF_RENDERER=1`;仅支持 webkit2gtk-4.1 的发行版(Ubuntu 22.04+)。 9. **剪贴板恢复不保真**(arboard 仅文本/图片)→ 设置页明示;与剪贴板管理器可能互相干扰记录中间内容。 +10. **Windows 混合 DPI 与负坐标**:HUD/回答窗定位统一以目标 HWND 的 monitor work area 为源,在物理/逻辑坐标边界显式换算;禁止用 HUD 自身 monitor 推断目标屏幕。 +11. **Windows 原生运行库不能依赖开发机环境**:默认 feature 的 EXE 硬链接 sherpa/ONNX、MSVC C++/OpenMP runtime 与 Vulkan loader;NSIS 必须把四个 sherpa/ONNX DLL、`msvcp140.dll`、`vcruntime140.dll`、`vcruntime140_1.dll`、`vcomp140.dll` 和 `vulkan-1.dll` 放在 EXE 同目录。Vulkan loader 存在但没有可用 ICD/GPU 时直接加载 CPU 模型;GPU 模型已加载后的 context/decode 错误仅在无可见输出时从头 CPU 重试一次。不得因缺少 loader 在进程装载阶段退出,也不得在 LLM 已流式输出后重放造成重复文本。 ### 7.3 快捷键(push-to-talk 细节) -- 不用 `tauri-plugin-global-shortcut` 作为主路径(无法监听单个修饰键;X11 release 有 bug),改用 **rdev 独立线程**自维护 down/up 状态——默认键位为全修饰键三角方案(右⌘/右⌥ 及其组合,见 [05 §7.1](05-ux-spec.md)),必须支持单修饰键触发。 -- **组合键让路规则(核心)**:触发键按住期间收到任何**普通键** down 事件 → 判定用户在使用系统组合键(`⌘C`、`AltGr+E` 等),立即静默取消本次录音、不产生任何输出、按键完全放行。监听是 listen-only,普通键本来就不被拦截,此规则只是状态机层面的取消逻辑。 +- 不用 `tauri-plugin-global-shortcut` 作为主路径(无法监听单个修饰键;X11 release 有 bug)。macOS/X11 用 **rdev 独立线程**,Windows 用 `WH_KEYBOARD_LL` + 消息循环;两者都把归一化事件送入同一个纯判定器维护 down/up 状态——默认键位为全修饰键三角方案(见 [05 §7.1](05-ux-spec.md)),必须支持单修饰键触发。 +- **稳定 `KeyId` 契约**:持久化名称以物理 `KeyboardEvent.code` 为主,至少包括 `Enter`、`Digit0..9`、`ArrowLeft/Right/Up/Down`、`AltLeft/AltRight`、`MetaLeft/MetaRight`、`Menu`、`F1..F19`、`KeyA..Z`、`Semicolon`/`Period`/`Backquote`/`BracketLeft`/`BracketRight`/`Backslash` 与 `Numpad*`。`Menu` 是对浏览器 `ContextMenu` 的稳定例外。前端 code、rdev `Key` 与 Win32 VK/scan 必须在各自 adapter 显式映射,禁止把 crate `Debug` 文本当持久化协议。`Return`→`Enter`、`Num1`→`Digit1`、`LeftArrow`→`ArrowLeft`、`AltGr`→`AltRight`、`Alt`→`AltLeft`、`ContextMenu`→`Menu`、`SemiColon`→`Semicolon`、`Dot`→`Period`、`Kp*`→`Numpad*` 等旧名仅作读取/迁移别名。 +- 字母、数字行与标点是**物理位置语义**:浏览器读取 `KeyboardEvent.code`;左右修饰键额外用标准 `KeyboardEvent.location` 校正侧别,以兼容 Windows WebView2 将物理右 Shift 的 `code` 误报为 `ShiftLeft` 的情况。`location` 只修正 `Shift` / `Control` / `Alt` / `Meta` 的左右侧,不参与普通键映射。Windows 普通键优先按低级 hook 的 set-1 scan code 解码(VK 只作 scan 缺失时的后备),rdev 使用其物理 `Key` variant。不得用 `KeyboardEvent.key`、当前布局产生的字符或输入法结果作为绑定 ID。 +- 听写与助手的 `Vec` 各表示一个完整 chord;判定器持有全局 physical-held 集合,只有某 chord 全部 held 才发 `TriggerDown`。翻译 chord 是两功能 chord 的有序去重并集;已启动的听写/助手手势在该并集完整时发 `ModeUpgraded(Translation)`。partial chord 从未启动时,全释放不得误发 `TriggerUp`;手势已启动后仍等本次 tracked 触发键全部释放才发一次 `TriggerUp`。 +- 听写与助手 chord 必须非空且互不为子集;相同 chord 也按不可达配置处理。前端在 IPC 前阻止,`SettingsService::update` 再以 `InvalidRequest` 拒绝;启动读取到历史非法值时只恢复 `HotkeySettings::default()` 并写无键值 warning,其他设置不得丢失。 +- **组合键让路规则(核心)**:触发键按住期间收到任何**普通键** down 事件 → 判定用户在使用系统组合键(`⌘C`、`Ctrl+C`、`AltGr+E` 等),立即静默让路、不产生任何输出、按键完全放行。Windows adapter 对默认右 Ctrl / 右 Alt 的 `TriggerDown` 语义最多暂存 75 ms,同时立即发出带唯一 token 的内部 `CaptureCandidateStarted`;AudioService 异步开流并只在内存积累样本。窗口内普通键/AltGr 发匹配取消,双触发键或超时确认让 `TriggerDown` 携带同 token 并提升现有流。暂停、配置更新、hook 失败/终止与退出也必须取消未决 token;原始 down 时间戳必须保留用于 350 ms 长短按判定。 - **漏 release 自恢复**:rdev/CGEventTap 偶发漏掉触发键 release 时,`HotkeyDetector` 会残留 held 状态。修饰键正常不会自动连发,因此同一触发键在 250 ms 抖动窗口后再次 down 视为上一轮 release 丢失:旧 held 状态重置,必要时向状态机发 `Yielded` 取消卡住的录音,再发新的 `TriggerDown`,保证下一次按键恢复响应。 +- **配置热更新边界**:settings 桥接只广播归一化后确实变化的 `HotkeyConfig`,判定器和 Windows adapter 仍须对相同配置幂等。听写/助手 chord 改绑时若旧 chord 已触发,backend 必须先发送一次配对 `TriggerUp` 再替换配置;旧物理键随后到达的 release 只用于清理,不得再次结束会话。partial chord 从未触发则直接清空且不产生语义事件;Windows 75 ms 内尚未确认的候选必须发送匹配 token 的取消事件,禁止把未确认手势提交给会话状态机。已确认的 Windows 右 Alt 手势在改绑后保留一次性 release tombstone,只吞对应的下一次物理 RAlt keyup;未确认候选、AltGr、让路和未配置路径不得设置该 tombstone。 - **唯一需要事件拦截的点**:Windows 上单击 Alt 会聚焦菜单栏——助手键(右 Alt)短按时用低级钩子 `WH_KEYBOARD_LL` 吞掉对应 keyup。macOS/Linux 的修饰键单按无系统副作用,无需拦截。启动时自检钩子可用性,失败降级为「监听不拦截」+ UI 提示改键。 -- 长按/短按判定:press 后 350 ms 内 release = toggle(三种模式一致,含助手);超过 = push-to-talk(release 即停止)。**乐观启动**:触发键按下即开始录音,判定窗口内组合出第二触发键则无缝切换为翻译模式,音频保留。 +- Windows 事件解码必须区分 `VK_RCONTROL`、`VK_RMENU`、扩展键与 `LLKHF_INJECTED`;Typex 自己的 SendInput 事件不得反向触发会话。右 Ctrl / Ctrl+C、物理右 Alt / 普通键和 AltGr 常见的伪 `Left Ctrl` + `Right Alt` 序列必须经过同一 75 ms 副作用确认边界;只有已确认的右 Alt 助手/翻译手势可以吞对应 keyup。 +- Windows hook 的 health watch 是运行期安全信号而不只是诊断查询:从 Healthy 进入 `Failed` 或意外 `Stopped` 时,runner 必须只发送一次会话 `Cancel` 并刷新托盘为「快捷键不可用」,防止漏掉 TriggerUp 后持续占用麦克风。callback panic 或事件通道关闭进入 `Failed` 时,hook state 必须原子禁止后续 raw event、退出消息循环并卸钩,且该 `Failed` 不得在 `WM_QUIT` 收尾时被覆盖为 `Stopped`。启动失败走同一个可订阅 health 状态;应用主动退出使用独立的正常 `Shutdown` 终态,不取消、不报错。 +- rdev backend 必须观察暂停 watch 的版本变化而不只读取最终布尔值;任一暂停/恢复 transition 都先清空 detector held 状态,暂停期间到达的 release 不得在恢复后留下 stale gesture。 +- 长按/短按判定:press 后 350 ms 内 release = toggle(三种模式一致,含助手);超过 = push-to-talk(release 即停止)。**乐观启动**:非 Windows 默认修饰键在触发键按下即开始录音;Windows 默认右侧修饰键在 75 ms 内确认,但麦克风候选流从原始 keydown 即开始,确认时原位提升且不重开设备。普通组合键路径取消候选并保持完全静默。 - Wayland:探测 `XDG_SESSION_TYPE`;优先 `ashpd` 走 `org.freedesktop.portal.GlobalShortcuts`(KDE/GNOME≥48/Hyprland 支持,Activated/Deactivated 信号天然支持按住;注意 Portal 快捷键由 compositor 分配,未必能绑到「单独的右⌥」,此时默认键退化为 compositor 允许的组合键);不可用时提示 evdev 方案(用户加入 `input` 组)或 compositor 绑定 `typex toggle` CLI 命令(经 single-instance 转发)。 ### 7.4 录音 - cpal 以设备原生采样率(44.1/48 kHz)开流;callback 内只做 ring buffer 拷贝,重采样(rubato → 16 kHz mono f32)与电平计算在 worker 线程。 +- 输入流按 `cpal::SampleFormat` 分派并统一归一化为 mono f32;Windows WASAPI 至少覆盖 `f32`、`i16`、`u16`,不假定默认设备一定输出 f32。callback 使用有界缓冲,溢出只累计丢弃计数,不阻塞、不分配、不记录音频内容。 - HUD 波形数据:每 50 ms 推一次 RMS 电平数组(Tauri event),前端 Canvas 渲染。 -- VAD(Silero)用于:① 结束时裁剪首尾静音(省上传/省钱/提速);② 长录音在静音处切片(F-1 无上限要求)。 -- 设备选择:默认系统输入设备并跟随系统切换;设置中可固定设备。 +- VAD 有两条配置路径:能量模式按 30 ms 帧计算 RMS,严格使用 `RMS > energy_threshold`;神经网络模式使用 `silero-vad-crs 0.4` 的完整 f32 内嵌权重与兼容 SSE/NEON 自动加速,无 ONNX Runtime、外部 DLL 或运行时下载,每 512 个 16 kHz 样本推理。神经网络进入门限使用用户值,退出门限 `max(threshold - 0.15, 0.01)`,最短语音 90 ms、结束静音 100 ms;初始化或推理失败降级到同一快照中的能量门限。 +- 两条 VAD 都用于结束裁剪和长录音静音切片:首个语音区间前保留 300 ms,最后语音区间后保留 150 ms。未检出语音但存在连续至少 90 ms 弱信号时提交未裁剪 16 kHz 音频,真正静音返回 `NoSpeech`。录音开始时快照 VAD 配置并随 `Recording` 保存,失败重试与长录音切片不得重读实时设置。 +- 设备选择:空配置使用系统默认输入设备并在下次开流时跟随系统切换;设置中固定设备时,Windows 保存 WASAPI `IMMDevice` endpoint ID、界面只显示 friendly label。固定 ID 缺失必须返回 `AudioDevice`,禁止静默回退默认设备。历史版本保存的 display name 仅在当前枚举结果中唯一匹配时迁移为 ID;无匹配或同名歧义均明确失败。 +- 录音启动失败与运行时 stream error/设备拔出通过独立 `RecordingFailed` 事件主动通知 orchestrator;`Recording` 立即转 `Failed(Recording)` 并执行 `CancelRecording` 释放 active stream,不等待下一次 stop。该失败态的重试语义是重新打开麦克风并开始录音,不进入注入或 STT 阶段。 ### 7.5 文本注入(Injector 后端链) @@ -292,21 +310,21 @@ trait Injector { fn inject(&self, text: &str, target: &FocusInfo) -> Result<()>; 按平台组成后备链(每个后端失败自动尝试下一个,全失败 → 复制到剪贴板 + HUD 提示): - macOS: `paste`(保存剪贴板 → 写入 → CGEvent Cmd+V → 延迟 → 恢复) -- Windows: `paste`(SendInput Ctrl+V);备选 `type_direct`(SendInput KEYEVENTF_UNICODE,无剪贴板污染,用户可选) +- Windows: `paste`(原生 SendInput Ctrl+V,校验返回发送计数);备选 `type_direct`(SendInput `KEYEVENTF_UNICODE`,按 UTF-16 code unit 发送完整 down/up,正确处理 surrogate pair、emoji 与换行)。无焦点或 UIPI 阻止时只复制并返回明确分类,不自动提权 - Linux X11: `paste`(XTEST) - Linux Wayland: 探测 compositor → wlroots 系用 `wtype`;GNOME/KDE 用 `dotool`/`ydotool`(需 /dev/uinput 权限,设置页内嵌引导);全不可用 → 「已复制,请 Ctrl+V」模式 ### 7.6 读取选中文本(Selection 降级链) 1. macOS:`AXUIElement.kAXSelectedTextAttribute` → 失败:临时静音系统提示音 + CGEvent Cmd+C + 读剪贴板 + 恢复(禁止通过 enigo/rdev 查询输入法布局,避免 HIToolbox 主队列断言);**AX 属性可读但为空 = 明确无选区,直接返回、不走剪贴板降级**(降级只给不支持 AX 的应用);定位用 `AXSelectedTextRange` + `AXBoundsForRange`,不可得时回退居中。 -2. Windows:UIA `TextPattern.GetSelection()` → 失败:Ctrl+C 降级。 +2. Windows:专用 COM worker 上执行 UIA `TextPattern.GetSelection()`;可读但为空表示明确无选区,不走剪贴板;不支持 TextPattern、COM 错误或超时才走 Ctrl+C 降级。bounds 使用 UIA range bounding rectangles,并统一转换到目标 monitor 坐标。 3. Linux:X11 primary selection 直读;Wayland 下 primary selection 可用则用,否则明确降级提示。 -4. 降级链每步有 300 ms 超时;剪贴板法须处理「无选中时复制整行」的误触(对比复制前后剪贴板内容 + 长度启发式)。 +4. 降级链每步有 300 ms 超时。Windows Ctrl+C 降级先写 sentinel,再以 clipboard sequence 变化确认复制发生;文本与已知 metadata 必须在同一次打开剪贴板期间读取。部分应用(如 JetBrains IDE)会为同一次复制分批发布多个格式并连续推进 sequence:轮询与原子读取之间 sequence 不同时,只有二者的非空 clipboard owner HWND 相同才接受载荷,并以读取到的最终 sequence 作为恢复基线;owner 变化仍按用户或剪贴板管理器中途修改处理,禁止消费或覆盖。VS Code/Monaco 的 `vscode-editor-data` 只有在大小受限、JSON 合法、`version=1` 且 `isFromEmptySelection` 为布尔 `true` 时才证明无选区;格式缺失、损坏、超大、字段缺失、未知版本或未知编辑器都保守保留文本,禁止按长度或换行猜测。原剪贴板的可恢复 HGLOBAL 格式统一快照,且仅当读取后的 sequence 未再变化时恢复,不能覆盖用户或剪贴板管理器的中途修改。无可信空选区 marker、又把无选区 Ctrl+C 定义为复制整行的第三方编辑器仍是已知残余边界。 5. **读取时机 = 触发键松开、调 STT 的同时(并发执行,不增加延迟)**——绝不能在触发键按住期间执行:剪贴板降级要模拟 Cmd/Ctrl+C,合成的普通键 down 会命中「组合键让路」规则、把当前会话静默取消。选区在录音期间不会变(HUD 不抢焦点),松开后读取语义等价。 ## 8. Linux/Wayland 支持策略(明确的分级承诺) -**最低系统版本(建议值,随 CI 实测修正)**:macOS 12+(Monterey,覆盖 Tauri 2 与 NSPanel 方案的舒适区);Windows 10 1809+(WebView2 运行时要求,安装器自动引导安装 WebView2);Linux 需 webkit2gtk-4.1(Ubuntu 22.04+ / Debian 12+ / Fedora 38+)。 +**最低系统版本**:macOS 12+(Monterey);Windows 10 22H2+ / Windows 11 x64(MSVC ABI);Linux 需 webkit2gtk-4.1(Ubuntu 22.04+ / Debian 12+ / Fedora 38+)。Windows 安装器为 NSIS x64,并携带 WebView2 Evergreen Bootstrapper,在缺失 WebView2 时联网安装;本地推理依赖的 sherpa/ONNX、VC++ 2015-2022 x64 runtime 与 Vulkan loader 均 app-local 随包分发,不要求用户另装开发工具或系统级 VC/Vulkan runtime。 | 级别 | 环境 | 承诺 | |---|---|---| @@ -320,6 +338,9 @@ trait Injector { fn inject(&self, text: &str, target: &FocusInfo) -> Result<()>; - **网络代理**:所有 Provider 请求经统一的 reqwest 客户端工厂,默认**跟随系统代理**;设置-通用可改为「手动代理」(HTTP/SOCKS5,host:port + 可选认证)或「直连」。目标用户中访问国际端点需代理者众多;代理凭据若有同样按敏感配置字段处理,导出与日志必须脱敏。 - 设置文件:`tauri-plugin-store`(JSON,位于平台标准配置目录),带 `schema_version` 与迁移函数。 +- `hotkeys.dictation` / `hotkeys.assistant` 保存规范化完整 chord;`hotkeys.translation` 是二者的派生有序并集。schema v6 将历史后端/浏览器别名迁移为 §7.3 的稳定 `KeyId`,每次保存仍执行同一归一化以兼容外部编辑的配置。 +- schema v7 增加 `dictation.vad.{mode,energy_threshold,neural_threshold}`;旧配置迁移为神经网络模式。门限保存前必须为有限值且在规格范围内;加载时 VAD 子树无效只恢复该子树,不得丢弃其他设置。 +- `dictation.microphone`:空字符串表示系统默认;非空值表示平台稳定设备 ID(Windows 为 WASAPI endpoint ID)。旧版设备名称在运行时唯一匹配后写回稳定 ID。 - 密钥:随 profile 的 `credentials` 字段存入 `settings.json`,与其他配置项同路径;诊断包导出清空 `credentials`,日志 redact 层拦截 Authorization/密钥文本。旧版 `keyring://` 引用在迁移时清除,运行时也视为未配置。 - 日志:`tracing` + 滚动文件,默认 INFO;**全局 redact 层**保证 Authorization/密钥永不入日志(见 §5.5)。 @@ -344,6 +365,7 @@ trait Injector { fn inject(&self, text: &str, target: &FocusInfo) -> Result<()>; |---|---|---| | 会话 | `cancel_session` / `retry_session` / `dismiss_session` | HUD 按钮;toggle 录音的开始/停止走快捷键,不提供 command(避免两套触发路径) | | 配置 | `get_settings` / `update_settings(patch)` | patch 语义,返回完整新配置;F-10 词典作为 `Settings.dictionary.terms[]` 随设置同步 | +| 音频 | `list_audio_devices` | 返回 `Result`;每项为 `{ id, label }`,设置界面展示 label、保存 id,枚举失败不得伪装为空列表 | | Profile | `list_profiles` / `upsert_profile` / `delete_profile` / `activate_profile { slot, id }` / `set_profile_secret` / `test_profile { id }` | `profiles[]` 是全局服务配置池;`activate_profile` 只改功能槽位指针并校验 STT/LLM 能力兼容;密钥字段由 `set_profile_secret` 写入 profile credentials | | 本地模型 | `list_local_models` / `download_local_model { model_id, source? }` / `cancel_local_download { model_id }` / `delete_local_model { model_id, force }` / `get_hardware_tier` | `source` 为空时使用 settings.general.model_download_source;固定源只在模型管理页底部配置 | | 助手窗口 | `assistant_window_ready` | assistant WebView 注册完 `assistant://*` 监听器后上报;后端首次创建窗口时等待它,避免首轮 `assistant://started` 丢事件 | @@ -423,15 +445,18 @@ src/ ## 14. 编码、CI 与协作规范 -- **Rust**:Edition 2024;`rustfmt` 默认配置;公共 API 必须有 doc comment;`unsafe` 仅允许出现在 `platform/` 并逐处注释理由。 +- **Rust**:Edition 2024;`rustfmt` 默认配置;公共 API 必须有 doc comment;`unsafe` 仅允许出现在 `platform/` 或明确命名的平台 backend(如 `hotkey/windows_backend.rs`、`inject/windows.rs`、`selection/windows.rs`),并逐处注释理由与系统不变量。 - **前端**:ESLint + Prettier;组件 ` + + + + diff --git a/src/composables/usePlatform.test.ts b/src/composables/usePlatform.test.ts new file mode 100644 index 0000000..4c70dbe --- /dev/null +++ b/src/composables/usePlatform.test.ts @@ -0,0 +1,59 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const platformMock = vi.hoisted(() => vi.fn()); + +vi.mock("@tauri-apps/plugin-os", () => ({ + platform: platformMock, +})); + +async function loadPlatform(platform: "macos" | "windows" | "linux") { + platformMock.mockReturnValue(platform); + vi.resetModules(); + return import("./usePlatform"); +} + +describe("usePlatform", () => { + beforeEach(() => { + platformMock.mockReset(); + }); + + it("uses Windows defaults and key labels", async () => { + const { usePlatform } = await loadPlatform("windows"); + const platform = usePlatform(); + const labels: Record = { + "keys_windows.ControlRight": "Right Ctrl", + "keys_windows.AltRight": "Right Alt", + }; + const t = (key: string) => labels[key] ?? key; + const te = (key: string) => Object.hasOwn(labels, key); + + expect(platform.isWindows.value).toBe(true); + expect(platform.defaultHotkeys.value).toEqual({ + dictation: ["ControlRight"], + assistant: ["AltRight"], + translation: ["ControlRight", "AltRight"], + }); + expect(platform.keyLabel("ControlRight", t, te)).toBe("Right Ctrl"); + expect(platform.keyLabel("AltRight", t, te)).toBe("Right Alt"); + expect(platform.keyLabel("AltGr", t, te)).toBe("Right Alt"); + expect(platform.keyLabel("KeyA", t, te)).toBe("A"); + expect(platform.keyLabel("Digit1", t, te)).toBe("1"); + expect(platform.keyLabel("LeftArrow", t, te)).toBe("←"); + expect(platform.keyLabel("SemiColon", t, te)).toBe(";"); + expect(platform.hotkeyConflictKey("AltLeft")).toBe( + "components.hotkey.conflict_alt_windows", + ); + expect(platform.hotkeyConflictKey("Alt")).toBe( + "components.hotkey.conflict_alt_windows", + ); + }); + + it("keeps macOS defaults isolated from Windows", async () => { + const { usePlatform } = await loadPlatform("macos"); + const platform = usePlatform(); + + expect(platform.isMacOS.value).toBe(true); + expect(platform.defaultHotkeys.value.dictation).toEqual(["MetaRight"]); + expect(platform.hotkeyConflictKey("AltLeft")).toBe("components.hotkey.conflict_alt"); + }); +}); diff --git a/src/composables/usePlatform.ts b/src/composables/usePlatform.ts new file mode 100644 index 0000000..6bb2136 --- /dev/null +++ b/src/composables/usePlatform.ts @@ -0,0 +1,95 @@ +import { computed, readonly, ref } from "vue"; +import { platform as tauriPlatform } from "@tauri-apps/plugin-os"; +import { canonicalKeyId } from "@/shared/hotkeys"; + +export type TypexPlatform = "macos" | "windows" | "linux" | "other"; +type Translate = (key: string) => string; +type TranslationExists = (key: string) => boolean; + +const PHYSICAL_KEY_LABELS: Readonly> = { + ArrowLeft: "←", + ArrowRight: "→", + ArrowUp: "↑", + ArrowDown: "↓", + Backquote: "`", + Minus: "-", + Equal: "=", + BracketLeft: "[", + BracketRight: "]", + Backslash: "\\", + Semicolon: ";", + Quote: "'", + Comma: ",", + Period: ".", + Slash: "/", +}; + +function physicalKeyLabel(key: string): string { + const letter = /^Key([A-Z])$/.exec(key); + if (letter) return letter[1]; + const digit = /^Digit([0-9])$/.exec(key); + if (digit) return digit[1]; + const numpad = /^Numpad([0-9])$/.exec(key); + if (numpad) return `Num ${numpad[1]}`; + return PHYSICAL_KEY_LABELS[key] ?? key; +} + +function detectPlatform(): TypexPlatform { + try { + const value = tauriPlatform(); + if (value === "macos" || value === "windows" || value === "linux") return value; + } catch { + // Component tests and browser previews run without the Tauri plugin. + } + const fallback = typeof navigator === "undefined" ? "" : navigator.userAgent.toLowerCase(); + if (fallback.includes("windows")) return "windows"; + if (fallback.includes("mac os") || fallback.includes("macintosh")) return "macos"; + if (fallback.includes("linux")) return "linux"; + return "other"; +} + +const current = ref(detectPlatform()); + +export function usePlatform() { + const isMacOS = computed(() => current.value === "macos"); + const isWindows = computed(() => current.value === "windows"); + const defaultHotkeys = computed(() => + isMacOS.value + ? { + dictation: ["MetaRight"], + assistant: ["AltRight"], + translation: ["MetaRight", "AltRight"], + } + : { + dictation: ["ControlRight"], + assistant: ["AltRight"], + translation: ["ControlRight", "AltRight"], + }, + ); + + function keyLabel(key: string, t: Translate, te: TranslationExists): string { + const canonical = canonicalKeyId(key); + const platformKey = `${isMacOS.value ? "keys" : "keys_windows"}.${canonical}`; + if (te(platformKey)) return t(platformKey); + return te(`keys.${canonical}`) ? t(`keys.${canonical}`) : physicalKeyLabel(canonical); + } + + function hotkeyConflictKey(key: string): string | null { + const suffix = isMacOS.value ? "" : "_windows"; + const base: Record = { + CapsLock: `components.hotkey.conflict_capslock${suffix}`, + MetaLeft: `components.hotkey.conflict_meta_left${suffix}`, + AltLeft: `components.hotkey.conflict_alt${suffix}`, + }; + return base[canonicalKeyId(key)] ?? null; + } + + return { + platform: readonly(current), + isMacOS, + isWindows, + defaultHotkeys, + keyLabel, + hotkeyConflictKey, + }; +} diff --git a/src/i18n/en.json b/src/i18n/en.json index 6395ea5..ddbef04 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -8,6 +8,7 @@ "invalid_request": "Request rejected", "no_speech": "No speech detected", "no_focus": "No input focus — result copied to clipboard", + "injection_blocked": "The target app has higher privileges — result copied; paste it manually", "permission_missing": "Accessibility permission missing — cannot type text", "audio_device": "Microphone unavailable", "not_configured": "No model service configured", @@ -59,13 +60,28 @@ "keys": { "MetaLeft": "Left ⌘", "MetaRight": "Right ⌘", - "Alt": "Left ⌥", - "AltGr": "Right ⌥", + "AltLeft": "Left ⌥", + "AltRight": "Right ⌥", "ControlLeft": "Left Ctrl", "ControlRight": "Right Ctrl", "ShiftLeft": "Left ⇧", "ShiftRight": "Right ⇧", - "CapsLock": "CapsLock" + "CapsLock": "CapsLock", + "Enter": "Return", + "Menu": "Menu" + }, + "keys_windows": { + "MetaLeft": "Left Win", + "MetaRight": "Right Win", + "AltLeft": "Left Alt", + "AltRight": "Right Alt", + "ControlLeft": "Left Ctrl", + "ControlRight": "Right Ctrl", + "ShiftLeft": "Left Shift", + "ShiftRight": "Right Shift", + "CapsLock": "CapsLock", + "Enter": "Enter", + "Menu": "Menu key" }, "prompt": { "expand": "Expand ▾", @@ -92,6 +108,7 @@ "autostart": "Launch at login", "chimes": "Sound cues", "chimes_hint": "Start / success / error chimes; can be turned off", + "chimes_volume": "Sound cue volume", "proxy": "Network proxy", "proxy_system": "System", "proxy_manual": "Manual", @@ -110,6 +127,14 @@ "ph_help_dictionary": "personal dictionary (optional, F-10)", "ph_help_target_app": "target app (optional)", "ph_missing": "Missing required placeholder {ph} — cannot save", + "vad_mode": "Voice detection", + "vad_mode_hint": "Neural works best in most environments; energy threshold uses input volume only", + "vad_neural": "Neural network", + "vad_energy": "Energy threshold", + "vad_neural_threshold": "Neural threshold", + "vad_neural_threshold_hint": "Speech confidence. Lower is more sensitive; higher rejects more noise. The default suits most cases.", + "vad_energy_threshold": "Energy threshold", + "vad_energy_threshold_hint": "Input volume. Lower is more sensitive; higher filters more background noise. The default suits most cases.", "inject_method": "Typing method", "inject_auto": "Auto (recommended)", "inject_paste": "Clipboard paste", @@ -118,7 +143,8 @@ "esc_cancels": "Esc cancels recording", "microphone": "Microphone", "mic_default": "System default", - "level_preview": "Level preview" + "mic_unavailable": "Selected microphone unavailable", + "mic_load_failed": "Could not enumerate microphones" }, "translation": { "source": "Source language", @@ -223,7 +249,7 @@ "none_downloaded": "No models downloaded yet.", "req_gpu": "Requires GPU acceleration (this device: {check})", "req_ram": "Recommended ≥ {min} GB RAM (this device: {check})", - "check_ok_gpu": "✓ Metal", + "check_ok_gpu": "✓ {backend}", "check_ok_ram": "✓ {ram} GB", "check_fail": "✗ not met", "total": "Total {size} on disk", @@ -245,7 +271,8 @@ }, "hotkeys": { "desc": "Dictation and assistant keys are customizable; translation = both keys held together (follows automatically).", - "translation_hint": "Hold the dictation key + assistant key together" + "translation_hint": "Hold the dictation key + assistant key together", + "unreachable_chords": "Choose two non-empty shortcuts; neither may contain the other." }, "history": { "desc": "History is stored on this device only and contains no audio.", @@ -265,6 +292,12 @@ "perm_accessibility": "Accessibility permission (typing + reading selection)", "perm_input_monitoring": "Input monitoring permission (hotkeys)", "inject_backend": "Typing backend: {backend}", + "cap_keyboard_hook": "Low-level keyboard hook", + "cap_send_input": "Windows text injection", + "cap_ui_automation": "UI Automation selection", + "cap_webview2": "WebView2 Runtime", + "cap_integrity": "Process integrity level", + "hardware": "Local inference: {summary}", "open_log_dir": "Open log directory", "log_path": "Logs: {path}", "export": "Export diagnostics", @@ -393,11 +426,12 @@ "hotkey": { "press": "Press keys…", "change": "Change", - "recording": "Recording…", - "recording_hint": "Press the keys you want (a single modifier works); Esc to cancel.", "conflict_capslock": "CapsLock conflicts with the Chinese input method's language toggle on macOS — not recommended.", "conflict_meta_left": "Left ⌘ is part of many system shortcuts — high risk of accidental triggers.", - "conflict_alt": "Left ⌥ is used to type special characters and may interfere with normal typing." + "conflict_alt": "Left ⌥ is used to type special characters and may interfere with normal typing.", + "conflict_capslock_windows": "CapsLock may conflict with an input method or key remapping — not recommended.", + "conflict_meta_left_windows": "Left Win is part of many system shortcuts — high risk of accidental triggers.", + "conflict_alt_windows": "Left Alt is used by menus and system shortcuts and may interfere with normal typing." }, "provider_card": { "testing": "Testing…", diff --git a/src/i18n/zh-CN.json b/src/i18n/zh-CN.json index d7a3cec..df8b8db 100644 --- a/src/i18n/zh-CN.json +++ b/src/i18n/zh-CN.json @@ -8,6 +8,7 @@ "invalid_request": "请求被拒绝", "no_speech": "没有听到声音", "no_focus": "当前没有输入焦点,结果已复制", + "injection_blocked": "目标应用权限更高,结果已复制,请手动粘贴", "permission_missing": "缺少辅助功能权限,无法输入文字", "audio_device": "麦克风不可用", "not_configured": "尚未配置模型服务", @@ -59,13 +60,28 @@ "keys": { "MetaLeft": "左 ⌘", "MetaRight": "右 ⌘", - "Alt": "左 ⌥", - "AltGr": "右 ⌥", + "AltLeft": "左 ⌥", + "AltRight": "右 ⌥", "ControlLeft": "左 Ctrl", "ControlRight": "右 Ctrl", "ShiftLeft": "左 ⇧", "ShiftRight": "右 ⇧", - "CapsLock": "CapsLock" + "CapsLock": "CapsLock", + "Enter": "回车", + "Menu": "菜单键" + }, + "keys_windows": { + "MetaLeft": "左 Win", + "MetaRight": "右 Win", + "AltLeft": "左 Alt", + "AltRight": "右 Alt", + "ControlLeft": "左 Ctrl", + "ControlRight": "右 Ctrl", + "ShiftLeft": "左 Shift", + "ShiftRight": "右 Shift", + "CapsLock": "CapsLock", + "Enter": "Enter", + "Menu": "菜单键" }, "prompt": { "expand": "展开编辑 ▾", @@ -92,6 +108,7 @@ "autostart": "开机自启", "chimes": "提示音", "chimes_hint": "开始 / 成功 / 错误 三枚,可关", + "chimes_volume": "提示音音量", "proxy": "网络代理", "proxy_system": "跟随系统", "proxy_manual": "手动", @@ -110,6 +127,14 @@ "ph_help_dictionary": "个人词典(可选,F-10)", "ph_help_target_app": "目标应用(可选)", "ph_missing": "缺少必需占位符 {ph},无法保存", + "vad_mode": "语音活动检测", + "vad_mode_hint": "神经网络适合多数环境;能量门限仅按输入音量判断", + "vad_neural": "神经网络", + "vad_energy": "能量门限", + "vad_neural_threshold": "神经网络门限", + "vad_neural_threshold_hint": "语音置信度。越低越灵敏,越高越能过滤噪声;通常保持默认即可", + "vad_energy_threshold": "能量门限", + "vad_energy_threshold_hint": "输入音量。越低越灵敏,越高越能过滤底噪;通常保持默认即可", "inject_method": "注入方式", "inject_auto": "自动(推荐)", "inject_paste": "剪贴板粘贴", @@ -118,7 +143,8 @@ "esc_cancels": "Esc 取消录音", "microphone": "麦克风", "mic_default": "系统默认", - "level_preview": "电平预览" + "mic_unavailable": "已选麦克风不可用", + "mic_load_failed": "无法读取麦克风列表" }, "translation": { "source": "源语言", @@ -223,7 +249,7 @@ "none_downloaded": "尚未下载任何模型。", "req_gpu": "需 GPU 加速(本机 {check})", "req_ram": "建议 ≥ {min} GB 内存(本机 {check})", - "check_ok_gpu": "✓ Metal", + "check_ok_gpu": "✓ {backend}", "check_ok_ram": "✓ {ram} GB", "check_fail": "✗ 不满足", "total": "占用合计 {size}", @@ -245,7 +271,8 @@ }, "hotkeys": { "desc": "听写与助手键位可改;翻译 = 两键同按(自动跟随)。", - "translation_hint": "听写键 + 助手键 同时按住" + "translation_hint": "听写键 + 助手键 同时按住", + "unreachable_chords": "请选择两个非空快捷键,且任一组合都不能包含另一组合。" }, "history": { "desc": "历史仅保存在本机,不含音频。", @@ -265,6 +292,12 @@ "perm_accessibility": "辅助功能权限(注入 + 读选区)", "perm_input_monitoring": "输入监听权限(快捷键)", "inject_backend": "注入后端:{backend}", + "cap_keyboard_hook": "低级键盘钩子", + "cap_send_input": "Windows 文本注入", + "cap_ui_automation": "UI Automation 选区读取", + "cap_webview2": "WebView2 Runtime", + "cap_integrity": "进程完整性级别", + "hardware": "本地推理:{summary}", "open_log_dir": "打开日志目录", "log_path": "日志:{path}", "export": "导出诊断包", @@ -393,11 +426,12 @@ "hotkey": { "press": "按下键位…", "change": "更改", - "recording": "正在录制…", - "recording_hint": "按下想要的键位(支持单个修饰键);Esc 取消。", "conflict_capslock": "CapsLock 与 macOS 中文输入法的中英切换冲突,不推荐。", "conflict_meta_left": "左 ⌘ 参与大量系统组合键,误触发风险高。", - "conflict_alt": "左 ⌥ 常用于输入特殊字符,可能干扰日常输入。" + "conflict_alt": "左 ⌥ 常用于输入特殊字符,可能干扰日常输入。", + "conflict_capslock_windows": "CapsLock 可能与输入法或键位重映射冲突,不推荐。", + "conflict_meta_left_windows": "左 Win 参与大量系统快捷键,误触发风险高。", + "conflict_alt_windows": "左 Alt 参与菜单和系统快捷键,可能干扰日常输入。" }, "provider_card": { "testing": "测试中…", diff --git a/src/shared/hotkeys.test.ts b/src/shared/hotkeys.test.ts new file mode 100644 index 0000000..ef28e3a --- /dev/null +++ b/src/shared/hotkeys.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from "vitest"; +import { + canonicalKeyId, + deriveTranslationChord, + hotkeyChordsAreReachable, + keyIdFromKeyboardCode, + keyIdFromKeyboardEvent, + normalizeHotkeyChord, +} from "./hotkeys"; + +describe("stable hotkey KeyId contract", () => { + it.each([ + ["Enter", "Enter"], + ["Return", "Enter"], + ["Digit1", "Digit1"], + ["Num1", "Digit1"], + ["ArrowLeft", "ArrowLeft"], + ["LeftArrow", "ArrowLeft"], + ["AltRight", "AltRight"], + ["AltGr", "AltRight"], + ["Alt", "AltLeft"], + ["Win", "MetaLeft"], + ["WinRight", "MetaRight"], + ["ContextMenu", "Menu"], + ["Menu", "Menu"], + ["SemiColon", "Semicolon"], + ["Dot", "Period"], + ["BackQuote", "Backquote"], + ["LeftBracket", "BracketLeft"], + ["BackSlash", "Backslash"], + ["Kp1", "Numpad1"], + ["KpReturn", "NumpadEnter"], + ["KpDelete", "NumpadDecimal"], + ["KeyA", "KeyA"], + ["F13", "F13"], + ["F19", "F19"], + ])("normalizes %s to %s", (input, expected) => { + expect(canonicalKeyId(input)).toBe(expected); + }); + + it.each([ + ["Enter", "Enter"], + ["Digit1", "Digit1"], + ["ArrowLeft", "ArrowLeft"], + ["AltRight", "AltRight"], + ["ContextMenu", "Menu"], + ["Semicolon", "Semicolon"], + ["Period", "Period"], + ["KeyZ", "KeyZ"], + ["F13", "F13"], + ["F19", "F19"], + ])("maps browser code %s to %s", (code, expected) => { + expect(keyIdFromKeyboardCode(code)).toBe(expected); + }); + + it("rejects non-physical browser placeholders", () => { + expect(keyIdFromKeyboardCode("")).toBeNull(); + expect(keyIdFromKeyboardCode("Unidentified")).toBeNull(); + expect(keyIdFromKeyboardCode("Process")).toBeNull(); + }); + + it("uses DOM location to correct WebView sided-modifier misreports", () => { + expect(keyIdFromKeyboardEvent({ code: "ShiftLeft", location: 2 })).toBe("ShiftRight"); + expect(keyIdFromKeyboardEvent({ code: "ShiftRight", location: 1 })).toBe("ShiftLeft"); + expect(keyIdFromKeyboardEvent({ code: "Control", location: 2 })).toBe("ControlRight"); + expect(keyIdFromKeyboardEvent({ code: "KeyA", location: 2 })).toBe("KeyA"); + }); + + it("de-duplicates aliases and derives a multi-key translation union", () => { + expect(normalizeHotkeyChord(["ControlRight", "Num1", "Digit1"])).toEqual([ + "ControlRight", + "Digit1", + ]); + expect( + deriveTranslationChord( + ["ControlRight", "Num1"], + ["AltGr", "KeyA", "AltRight"], + ), + ).toEqual(["ControlRight", "Digit1", "AltRight", "KeyA"]); + }); + + it.each([ + [[], ["AltRight"]], + [["ControlRight"], ["ControlRight"]], + [["ControlRight"], ["ControlRight", "KeyA"]], + [["AltGr", "KeyA"], ["AltRight"]], + ])("rejects empty, equal, or subset functional chords", (dictation, assistant) => { + expect(hotkeyChordsAreReachable(dictation, assistant)).toBe(false); + }); + + it("allows shared keys when neither functional chord contains the other", () => { + expect( + hotkeyChordsAreReachable( + ["ControlRight", "KeyA"], + ["ControlRight", "KeyB"], + ), + ).toBe(true); + }); +}); diff --git a/src/shared/hotkeys.ts b/src/shared/hotkeys.ts new file mode 100644 index 0000000..278d7a5 --- /dev/null +++ b/src/shared/hotkeys.ts @@ -0,0 +1,126 @@ +/** Stable physical KeyId contract shared by browser recording and display. */ +const KEY_ID_ALIASES: Readonly> = { + Return: "Enter", + KpReturn: "NumpadEnter", + NumpadReturn: "NumpadEnter", + Num0: "Digit0", + Num1: "Digit1", + Num2: "Digit2", + Num3: "Digit3", + Num4: "Digit4", + Num5: "Digit5", + Num6: "Digit6", + Num7: "Digit7", + Num8: "Digit8", + Num9: "Digit9", + LeftArrow: "ArrowLeft", + RightArrow: "ArrowRight", + UpArrow: "ArrowUp", + DownArrow: "ArrowDown", + Alt: "AltLeft", + OptionLeft: "AltLeft", + AltGr: "AltRight", + RightAlt: "AltRight", + OptionRight: "AltRight", + Meta: "MetaLeft", + Win: "MetaLeft", + Super: "MetaLeft", + Command: "MetaLeft", + WinLeft: "MetaLeft", + SuperLeft: "MetaLeft", + CommandLeft: "MetaLeft", + OSLeft: "MetaLeft", + WinRight: "MetaRight", + SuperRight: "MetaRight", + CommandRight: "MetaRight", + OSRight: "MetaRight", + ContextMenu: "Menu", + Apps: "Menu", + SemiColon: "Semicolon", + Dot: "Period", + BackQuote: "Backquote", + LeftBracket: "BracketLeft", + RightBracket: "BracketRight", + BackSlash: "Backslash", + Kp0: "Numpad0", + Kp1: "Numpad1", + Kp2: "Numpad2", + Kp3: "Numpad3", + Kp4: "Numpad4", + Kp5: "Numpad5", + Kp6: "Numpad6", + Kp7: "Numpad7", + Kp8: "Numpad8", + Kp9: "Numpad9", + KpMinus: "NumpadSubtract", + KpPlus: "NumpadAdd", + KpMultiply: "NumpadMultiply", + KpDivide: "NumpadDivide", + KpDelete: "NumpadDecimal", + NumpadDelete: "NumpadDecimal", + Function: "Fn", + Esc: "Escape", + Del: "Delete", + Spacebar: "Space", +}; + +export function canonicalKeyId(raw: string): string { + const key = raw.trim(); + return KEY_ID_ALIASES[key] ?? key; +} + +/** KeyboardEvent.code is physical-position based and is never replaced with event.key. */ +export function keyIdFromKeyboardCode(code: string): string | null { + if (!code || code === "Unidentified" || code === "Process") return null; + return canonicalKeyId(code); +} + +type BrowserKeyboardIdentity = Pick; +const DOM_KEY_LOCATION_LEFT = 1; +const DOM_KEY_LOCATION_RIGHT = 2; + +/** Correct sided modifiers when WebView reports a left/generic code for the right key. */ +export function keyIdFromKeyboardEvent(event: BrowserKeyboardIdentity): string | null { + const modifier = /^(Shift|Control|Alt|Meta)(?:Left|Right)?$/.exec(event.code)?.[1]; + if (modifier && event.location === DOM_KEY_LOCATION_LEFT) return `${modifier}Left`; + if (modifier && event.location === DOM_KEY_LOCATION_RIGHT) return `${modifier}Right`; + return keyIdFromKeyboardCode(event.code); +} + +export function normalizeHotkeyChord(keys: readonly string[]): string[] { + const seen = new Set(); + const normalized: string[] = []; + for (const raw of keys) { + const key = canonicalKeyId(raw); + if (!key || seen.has(key)) continue; + seen.add(key); + normalized.push(key); + } + return normalized; +} + +export function deriveTranslationChord( + dictation: readonly string[], + assistant: readonly string[], +): string[] { + const normalizedDictation = normalizeHotkeyChord(dictation); + const normalizedAssistant = normalizeHotkeyChord(assistant); + if (normalizedDictation.length === 0 || normalizedAssistant.length === 0) return []; + return normalizeHotkeyChord([...normalizedDictation, ...normalizedAssistant]); +} + +export function hotkeyChordsAreReachable( + dictation: readonly string[], + assistant: readonly string[], +): boolean { + const normalizedDictation = normalizeHotkeyChord(dictation); + const normalizedAssistant = normalizeHotkeyChord(assistant); + if (normalizedDictation.length === 0 || normalizedAssistant.length === 0) return false; + + const isSubset = (left: readonly string[], right: readonly string[]) => + left.every((key) => right.includes(key)); + return ( + !isSubset(normalizedDictation, normalizedAssistant) && + !isSubset(normalizedAssistant, normalizedDictation) + ); +} diff --git a/src/windows/assistant/index.html b/src/windows/assistant/index.html index 30f6742..69b636b 100644 --- a/src/windows/assistant/index.html +++ b/src/windows/assistant/index.html @@ -3,6 +3,7 @@ + Typex diff --git a/src/windows/home/Home.test.ts b/src/windows/home/Home.test.ts index 14be7d2..e75ec56 100644 --- a/src/windows/home/Home.test.ts +++ b/src/windows/home/Home.test.ts @@ -29,7 +29,7 @@ vi.mock("@/ipc/bindings", () => ({ function makeSettings(): Settings { return { - schema_version: 4, + schema_version: 7, general: { theme: "system", language: "zh_cn", @@ -50,6 +50,11 @@ function makeSettings(): Settings { language: "auto", microphone: "", esc_cancels: true, + vad: { + mode: "neural", + energy_threshold: 0.01, + neural_threshold: 0.5, + }, }, translation: { source_language: "中文", @@ -62,8 +67,8 @@ function makeSettings(): Settings { history: { enabled: true, retention_days: 90, typing_wpm: 45 }, hotkeys: { dictation: ["MetaRight"], - assistant: ["AltGr"], - translation: ["MetaRight", "AltGr"], + assistant: ["AltRight"], + translation: ["MetaRight", "AltRight"], hold_threshold_ms: 350, }, dictionary: { terms: [] }, diff --git a/src/windows/home/Home.vue b/src/windows/home/Home.vue index 96d680d..f8d046f 100644 --- a/src/windows/home/Home.vue +++ b/src/windows/home/Home.vue @@ -7,9 +7,11 @@ import Button from "@/components/Button.vue"; import Input from "@/components/Input.vue"; import { commands, events, type HistoryItem, type HistoryStats } from "@/ipc/bindings"; import { useSettingsStore } from "@/stores/settings"; +import { usePlatform } from "@/composables/usePlatform"; const { t, te } = useI18n(); const store = useSettingsStore(); +const { defaultHotkeys, keyLabel } = usePlatform(); const DICTIONARY_MAX_TERMS = 100; const DICTIONARY_MAX_TERM_CHARS = 50; @@ -188,12 +190,12 @@ function toggleTheme() { } const dictKey = computed(() => { - const k = store.settings?.hotkeys.dictation[0] ?? "MetaRight"; - return te(`keys.${k}`) ? t(`keys.${k}`) : k; + const keys = store.settings?.hotkeys.dictation ?? defaultHotkeys.value.dictation; + return keys.map((key) => keyLabel(key, t, te)).join(" + "); }); const assistKey = computed(() => { - const k = store.settings?.hotkeys.assistant[0] ?? "AltGr"; - return te(`keys.${k}`) ? t(`keys.${k}`) : k; + const keys = store.settings?.hotkeys.assistant ?? defaultHotkeys.value.assistant; + return keys.map((key) => keyLabel(key, t, te)).join(" + "); }); onMounted(async () => { diff --git a/src/windows/home/index.html b/src/windows/home/index.html index 3f52d9a..bad20ee 100644 --- a/src/windows/home/index.html +++ b/src/windows/home/index.html @@ -3,6 +3,7 @@ + Typex + + + + + + {{ neuralThreshold.toFixed(2) }} + + + + {{ energyThreshold.toFixed(3) }} + + - -
- -
-
@@ -227,17 +302,13 @@ onUnmounted(() => unlisten?.()); font-size: 11px; color: var(--text-3); } -.bars { - display: flex; - align-items: center; - gap: 3px; - height: 18px; +.vad-slider { + width: 150px; } -.bars i { - width: 3.5px; - border-radius: 2px; - background: var(--voice); - display: block; - transition: height 0.08s linear; +.threshold-val { + min-width: 40px; + font-size: 11px; + color: var(--text-2); + text-align: right; } diff --git a/src/windows/settings/pages/GeneralPage.test.ts b/src/windows/settings/pages/GeneralPage.test.ts new file mode 100644 index 0000000..15c3bd9 --- /dev/null +++ b/src/windows/settings/pages/GeneralPage.test.ts @@ -0,0 +1,87 @@ +import { flushPromises, mount } from "@vue/test-utils"; +import { createPinia, setActivePinia } from "pinia"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { makeI18n } from "@/i18n"; +import type { Settings } from "@/ipc/bindings"; +import { useSettingsStore } from "@/stores/settings"; +import GeneralPage from "./GeneralPage.vue"; + +const updateSettings = vi.hoisted(() => vi.fn()); + +vi.mock("@/ipc/bindings", () => ({ + commands: { updateSettings }, + events: { + settingsChangedEvent: { listen: vi.fn(async () => () => {}) }, + }, +})); + +function makeSettings(chimesEnabled = true, chimesVolume = 0.6): Settings { + return { + general: { + language: "system", + theme: "system", + autostart: true, + chimes_enabled: chimesEnabled, + chimes_volume: chimesVolume, + proxy_mode: "system", + proxy_url: "", + model_download_source: "auto", + check_updates: true, + update_channel: "stable", + }, + } as Settings; +} + +function mountPage(chimesEnabled = true, chimesVolume = 0.6) { + const pinia = createPinia(); + setActivePinia(pinia); + const store = useSettingsStore(); + store.settings = makeSettings(chimesEnabled, chimesVolume); + return mount(GeneralPage, { + global: { plugins: [pinia, makeI18n("en")] }, + }); +} + +describe("GeneralPage sound cue volume", () => { + beforeEach(() => { + vi.clearAllMocks(); + updateSettings.mockImplementation(async (settings: Settings) => ({ + status: "ok", + data: settings, + })); + }); + + it("shows percent and persists the slider value as a 0-1 gain", async () => { + const wrapper = mountPage(); + const slider = wrapper.get('input[type="range"]'); + + expect(slider.element.value).toBe("60"); + expect(wrapper.text()).toContain("60%"); + + await slider.setValue("85"); + await flushPromises(); + + const saved = updateSettings.mock.calls.at(-1)?.[0] as Settings; + expect(saved.general.chimes_volume).toBeCloseTo(0.85); + expect(wrapper.text()).toContain("85%"); + }); + + it("disables the slider while muted and preserves its value when re-enabled", async () => { + const wrapper = mountPage(false, 0.35); + const slider = wrapper.get('input[type="range"]'); + const toggle = wrapper.findAll('button[role="switch"]')[1]; + + expect(slider.element.disabled).toBe(true); + expect(slider.element.value).toBe("35"); + expect(toggle.attributes("aria-checked")).toBe("false"); + + await toggle.trigger("click"); + await flushPromises(); + + expect(toggle.attributes("aria-checked")).toBe("true"); + const saved = updateSettings.mock.calls.at(-1)?.[0] as Settings; + expect(saved.general.chimes_enabled).toBe(true); + expect(saved.general.chimes_volume).toBeCloseTo(0.35); + expect(slider.element.disabled).toBe(false); + }); +}); diff --git a/src/windows/settings/pages/GeneralPage.vue b/src/windows/settings/pages/GeneralPage.vue index 6995456..3b2d780 100644 --- a/src/windows/settings/pages/GeneralPage.vue +++ b/src/windows/settings/pages/GeneralPage.vue @@ -1,5 +1,6 @@