From ef3be825b4362b720d0653922578c663ff3764fa Mon Sep 17 00:00:00 2001 From: shenglong Date: Mon, 24 Aug 2026 21:26:26 +0800 Subject: [PATCH 1/7] fix(windows): compile against windows 0.62 and ship a gating NSIS x64 installer Repair the HWND/SAPI/UIA import boundary, add a WebView2 download bootstrapper NSIS package path, and require Windows CI fmt, clippy, tests, and packaging. --- .github/workflows/ci.yml | 27 ++++-- src-tauri/src/overlay.rs | 19 ++-- src-tauri/src/platform/windows/mod.rs | 4 +- src-tauri/src/platform/windows/selection.rs | 20 +++- src-tauri/src/platform/windows/speech.rs | 37 +++++-- src-tauri/src/platform/windows/window.rs | 16 +++- src-tauri/src/services/settings.rs | 5 +- src-tauri/tauri.conf.json | 10 ++ tools/release/audit-windows-bundle.ps1 | 57 +++++++++++ tools/release/audit-windows-bundle.test.ts | 101 ++++++++++++++++++++ 10 files changed, 264 insertions(+), 32 deletions(-) create mode 100644 tools/release/audit-windows-bundle.ps1 create mode 100644 tools/release/audit-windows-bundle.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a0d9e7a..15411c5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -78,12 +78,8 @@ jobs: run: cargo test --manifest-path src-tauri/Cargo.toml windows: - name: Windows compile check + name: Windows runs-on: windows-latest - # The Windows adapters are written but have never run on a Windows host, so - # this job measures the gap rather than gating the build. Remove - # continue-on-error once it passes and Windows is a supported target. - continue-on-error: true steps: - uses: actions/checkout@v4 @@ -97,6 +93,8 @@ jobs: cache: pnpm - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy - uses: swatinem/rust-cache@v2 with: @@ -104,9 +102,22 @@ jobs: - run: pnpm install --frozen-lockfile - # tauri::generate_context! needs the renderer output to exist. - name: Build renderer run: pnpm build - - name: Check - run: cargo check --manifest-path src-tauri/Cargo.toml --all-targets + - name: Format + run: cargo fmt --manifest-path src-tauri/Cargo.toml -- --check + + - name: Clippy + run: cargo clippy --manifest-path src-tauri/Cargo.toml --all-targets -- -D warnings + + # Real-host UI Automation fixtures stay ignored here: a CI runner has no + # interactive desktop to select text in. + - name: Test + run: cargo test --manifest-path src-tauri/Cargo.toml + + - name: Package NSIS + run: pnpm tauri build --bundles nsis + + - name: Audit Windows bundle + run: powershell -NoProfile -ExecutionPolicy Bypass -File tools/release/audit-windows-bundle.ps1 src-tauri/target/release diff --git a/src-tauri/src/overlay.rs b/src-tauri/src/overlay.rs index d465780..f25edf6 100644 --- a/src-tauri/src/overlay.rs +++ b/src-tauri/src/overlay.rs @@ -453,9 +453,7 @@ fn position_overlay( let work_area = monitor_work_area_for(anchor)?; let placement = place_overlay_on_monitors(anchor, &[work_area], logical_size, OVERLAY_GAP) .ok_or_else(|| overlay_error("Overlay position could not be resolved"))?; - let hwnd = window - .hwnd() - .map_err(|_| overlay_error("Native overlay handle is unavailable"))?; + let hwnd = overlay_hwnd(window)?; position_non_activating_tool_window(hwnd, &placement) } @@ -538,13 +536,20 @@ fn position_overlay( } #[cfg(target_os = "windows")] -fn configure_native_nonactivation(window: &WebviewWindow) -> Result<(), AppError> { - use crate::platform::windows::apply_non_activating_tool_window; +fn overlay_hwnd(window: &WebviewWindow) -> Result { + use crate::platform::windows::hwnd_from_raw_pointer; - let hwnd = window + let handle = window .hwnd() .map_err(|_| overlay_error("Native overlay handle is unavailable"))?; - apply_non_activating_tool_window(hwnd) + Ok(hwnd_from_raw_pointer(handle.0)) +} + +#[cfg(target_os = "windows")] +fn configure_native_nonactivation(window: &WebviewWindow) -> Result<(), AppError> { + use crate::platform::windows::apply_non_activating_tool_window; + + apply_non_activating_tool_window(overlay_hwnd(window)?) } fn overlay_error(message: &'static str) -> AppError { diff --git a/src-tauri/src/platform/windows/mod.rs b/src-tauri/src/platform/windows/mod.rs index a98c434..bbd7dea 100644 --- a/src-tauri/src/platform/windows/mod.rs +++ b/src-tauri/src/platform/windows/mod.rs @@ -17,6 +17,6 @@ pub use input::{ pub use selection::WindowsSelectionAdapter; pub use speech::WindowsSpeechAdapter; pub use window::{ - WindowsOverlayContent, WindowsOverlayContentDispatcher, WindowsOverlayMetrics, - WindowsOverlayWindow, + apply_non_activating_tool_window, hwnd_from_raw_pointer, WindowsOverlayContent, + WindowsOverlayContentDispatcher, WindowsOverlayMetrics, WindowsOverlayWindow, }; diff --git a/src-tauri/src/platform/windows/selection.rs b/src-tauri/src/platform/windows/selection.rs index bdf2c94..9cfedf3 100644 --- a/src-tauri/src/platform/windows/selection.rs +++ b/src-tauri/src/platform/windows/selection.rs @@ -12,7 +12,7 @@ use std::{ use async_trait::async_trait; use windows::{ - core::Error as WindowsError, + core::{Error as WindowsError, HRESULT}, Win32::{ Foundation::E_ACCESSDENIED, System::{ @@ -277,7 +277,9 @@ fn copy_f64_safearray(array: *mut SAFEARRAY) -> Result, AppError> { fn rectangles_from_flat_values(values: &[f64]) -> Vec { values - .chunks_exact(4) + .as_chunks::<4>() + .0 + .iter() .filter_map(|quad| { let rect = PhysicalRect { x: quad[0], @@ -301,7 +303,10 @@ fn map_uia_error(error: WindowsError) -> AppError { } fn map_uia_hresult(code: windows::core::HRESULT) -> AppError { - if code == E_ACCESSDENIED || code == UIA_E_NOTSUPPORTED || code == UIA_E_ELEMENTNOTAVAILABLE { + if code == E_ACCESSDENIED + || code == HRESULT(UIA_E_NOTSUPPORTED as i32) + || code == HRESULT(UIA_E_ELEMENTNOTAVAILABLE as i32) + { unsupported("control is protected, elevated, or does not expose TextPattern") } else { internal("UI Automation could not resolve the selection") @@ -414,7 +419,9 @@ impl Future for ReplyReceiver { mod tests { use std::{future::Future, pin::Pin, task::Context}; + use windows::core::HRESULT; use windows::Win32::Foundation::E_ACCESSDENIED; + use windows::Win32::UI::Accessibility::UIA_E_NOTSUPPORTED; use crate::contracts::AppErrorCode; @@ -455,6 +462,13 @@ mod tests { assert!(!error.retryable); } + #[test] + fn maps_uia_not_supported_constant_to_unsupported_control() { + let error = map_uia_hresult(HRESULT(UIA_E_NOTSUPPORTED as i32)); + assert_eq!(error.code, AppErrorCode::UnsupportedControl); + assert!(!error.retryable); + } + #[test] fn compares_signed_uia_process_ids_without_wrapping() { assert!(is_own_process(42, 42)); diff --git a/src-tauri/src/platform/windows/speech.rs b/src-tauri/src/platform/windows/speech.rs index a417697..1c25b13 100644 --- a/src-tauri/src/platform/windows/speech.rs +++ b/src-tauri/src/platform/windows/speech.rs @@ -12,12 +12,10 @@ use async_trait::async_trait; use windows::{ core::PCWSTR, Win32::{ - Globalization::{ - LocaleNameToLCID, ResolveLocaleName, LOCALE_ALLOW_NEUTRAL_NAMES, LOCALE_NAME_MAX_LENGTH, - }, + Globalization::{LocaleNameToLCID, ResolveLocaleName, LOCALE_ALLOW_NEUTRAL_NAMES}, Media::Speech::{ ISpObjectToken, ISpObjectTokenCategory, ISpVoice, SpObjectTokenCategory, SpVoice, - SPCAT_VOICES, SPF_ASYNC, SPF_PURGEBEFORESPEAK, + SPCAT_VOICES, SPEAKFLAGS, SPF_ASYNC, SPF_PURGEBEFORESPEAK, }, System::Com::{ CoCreateInstance, CoInitializeEx, CoUninitialize, CLSCTX_INPROC_SERVER, @@ -31,6 +29,10 @@ use crate::{ platform::SpeechAdapter, }; +/// Win32 `LOCALE_NAME_MAX_LENGTH`, including the terminating NUL. +/// The `windows` 0.62 crate no longer exports this Globalization constant. +const LOCALE_NAME_MAX_LENGTH: usize = 85; + enum SpeechCommand { IsAvailable { language: String, @@ -180,9 +182,10 @@ fn run_speech_worker( SpeechCommand::Stop { reply } => { // SAFETY: a null text pointer with PURGEBEFORESPEAK is SAPI's // documented cancellation operation for the current queue. - let result = unsafe { voice.Speak(PCWSTR::null(), SPF_PURGEBEFORESPEAK, None) } - .map(|_| ()) - .map_err(|_| internal("could not stop Windows speech")); + let result = + unsafe { voice.Speak(PCWSTR::null(), speak_flags(SPF_PURGEBEFORESPEAK), None) } + .map(|_| ()) + .map_err(|_| internal("could not stop Windows speech")); reply.complete(result); } } @@ -216,7 +219,7 @@ fn speak_with_voice( .and_then(|_| { voice.Speak( PCWSTR(text.as_ptr()), - SPF_ASYNC | SPF_PURGEBEFORESPEAK, + speak_flags(SPF_ASYNC) | speak_flags(SPF_PURGEBEFORESPEAK), None, ) }) @@ -250,7 +253,7 @@ fn language_attribute(language: &str) -> Option { return None; } let wide_language = wide(&normalized); - let mut resolved = [0u16; LOCALE_NAME_MAX_LENGTH as usize]; + let mut resolved = [0u16; LOCALE_NAME_MAX_LENGTH]; // SAFETY: the source is NUL-terminated and `resolved` supplies the exact // writable capacity passed to ResolveLocaleName. let resolved_len = @@ -277,6 +280,10 @@ fn wide(value: &str) -> Vec { value.encode_utf16().chain(std::iter::once(0)).collect() } +fn speak_flags(flag: SPEAKFLAGS) -> u32 { + flag.0 as u32 +} + fn internal(message: &'static str) -> AppError { AppError::new(AppErrorCode::Internal, message, false) } @@ -352,9 +359,11 @@ impl Future for ReplyReceiver { mod tests { use std::{future::Future, pin::Pin, task::Context}; + use windows::Win32::Media::Speech::{SPF_ASYNC, SPF_PURGEBEFORESPEAK}; + use crate::contracts::AppErrorCode; - use super::{language_attribute, reply_channel, voice_language_attribute, wide}; + use super::{language_attribute, reply_channel, speak_flags, voice_language_attribute, wide}; #[test] fn wide_strings_are_nul_terminated() { @@ -372,6 +381,14 @@ mod tests { assert_eq!(voice_language_attribute(0x1234_0804), "Language=0804"); } + #[test] + fn speak_flags_encode_as_u32_without_bitor_on_speakflags() { + assert_eq!( + speak_flags(SPF_ASYNC) | speak_flags(SPF_PURGEBEFORESPEAK), + 3 + ); + } + #[test] fn dropped_sapi_reply_completes_with_stable_error() { let (reply, mut response) = diff --git a/src-tauri/src/platform/windows/window.rs b/src-tauri/src/platform/windows/window.rs index e7f364e..551ea09 100644 --- a/src-tauri/src/platform/windows/window.rs +++ b/src-tauri/src/platform/windows/window.rs @@ -31,6 +31,14 @@ pub const fn non_activating_tool_styles(base: WINDOW_EX_STYLE) -> WINDOW_EX_STYL WINDOW_EX_STYLE((base.0 | WS_EX_NOACTIVATE.0 | WS_EX_TOOLWINDOW.0) & !WS_EX_APPWINDOW.0) } +/// Reconstructs a `windows` 0.62 `HWND` from another crate's public pointer. +/// +/// Tauri/`raw-window-handle` currently compile against `windows` 0.61, so overlay +/// glue must convert at this import boundary instead of transmuting the newtype. +pub fn hwnd_from_raw_pointer(pointer: *mut c_void) -> HWND { + HWND(pointer) +} + /// Applies the non-activating tool-window policy to an existing native window. pub fn apply_non_activating_tool_window(hwnd: HWND) -> Result<(), AppError> { // SAFETY: `hwnd` is supplied by the window owner and remains valid for this @@ -403,7 +411,7 @@ mod tests { }; use super::{ - finite_i32, hwnd_topmost, non_activating_tool_styles, positive_i32, + finite_i32, hwnd_from_raw_pointer, hwnd_topmost, non_activating_tool_styles, positive_i32, validate_overlay_metrics, WindowsOverlayMetrics, }; @@ -438,4 +446,10 @@ mod tests { fn topmost_sentinel_preserves_signed_pointer_value() { assert_eq!(hwnd_topmost().0 as isize, -1); } + + #[test] + fn hwnd_from_raw_pointer_uses_public_inner_value() { + let pointer = 0x1234usize as *mut std::ffi::c_void; + assert_eq!(hwnd_from_raw_pointer(pointer).0, pointer); + } } diff --git a/src-tauri/src/services/settings.rs b/src-tauri/src/services/settings.rs index 7b6b3f8..4064ad1 100644 --- a/src-tauri/src/services/settings.rs +++ b/src-tauri/src/services/settings.rs @@ -4,11 +4,14 @@ //! or source-application history, making content persistence unavailable by construction. use std::{ - fs::{self, File}, + fs, io::{self, BufWriter, Write}, path::{Path, PathBuf}, }; +#[cfg(unix)] +use std::fs::File; + use serde::Deserialize; use crate::{ diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 9900db0..70f3376 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -32,6 +32,16 @@ "macOS": { "minimumSystemVersion": "11.0", "signingIdentity": "-" + }, + "windows": { + "webviewInstallMode": { + "type": "downloadBootstrapper", + "silent": true + }, + "nsis": { + "installMode": "currentUser", + "compression": "lzma" + } } } } diff --git a/tools/release/audit-windows-bundle.ps1 b/tools/release/audit-windows-bundle.ps1 new file mode 100644 index 0000000..e25cc5d --- /dev/null +++ b/tools/release/audit-windows-bundle.ps1 @@ -0,0 +1,57 @@ +param( + [Parameter(Mandatory = $true, Position = 0)] + [string]$ReleaseDir +) + +$ErrorActionPreference = "Stop" + +function Fail([string]$Message) { + [Console]::Error.WriteLine($Message) + exit 1 +} + +if (-not (Test-Path -LiteralPath $ReleaseDir -PathType Container)) { + Fail "release directory is missing: $ReleaseDir" +} + +$nsisDir = Join-Path $ReleaseDir "bundle\nsis" +$setup = $null +if (Test-Path -LiteralPath $nsisDir -PathType Container) { + $setup = Get-ChildItem -LiteralPath $nsisDir -File -ErrorAction SilentlyContinue | + Where-Object { $_.Name -like "*_x64-setup.exe" } | + Select-Object -First 1 +} +if (-not $setup) { + Fail "NSIS x64 installer is missing (expected *_x64-setup.exe under bundle/nsis)" +} + +$exe = Join-Path $ReleaseDir "desktop-translator.exe" +if (-not (Test-Path -LiteralPath $exe -PathType Leaf)) { + Fail "native executable is missing: desktop-translator.exe" +} + +$bytes = (Get-Item -LiteralPath $exe).Length +$limit = 24 * 1024 * 1024 +if ($bytes -le 0) { + Fail "release executable is empty" +} +if ($bytes -gt $limit) { + Fail "release executable exceeds compactness budget: $bytes > $limit" +} + +$developer = @( + Get-ChildItem -LiteralPath $ReleaseDir -Recurse -File -ErrorAction SilentlyContinue | + Where-Object { $_.Name -match "^(node|python|pythonw|rustc|cargo|pnpm)\.exe$" } +) +if ($developer.Count -ne 0) { + Fail "release contains a developer-machine runtime: $($developer.Name -join ', ')" +} + +$textbooks = @( + Get-ChildItem -LiteralPath $ReleaseDir -Recurse -File -Filter "starter-en-zh.sqlite3" -ErrorAction SilentlyContinue +) +if ($textbooks.Count -ne 0) { + Fail "bundled textbook must be embedded once in the executable" +} + +Write-Output "bundle audit passed: executable=$bytes bytes installer=$($setup.Name)" diff --git a/tools/release/audit-windows-bundle.test.ts b/tools/release/audit-windows-bundle.test.ts new file mode 100644 index 0000000..f82b73d --- /dev/null +++ b/tools/release/audit-windows-bundle.test.ts @@ -0,0 +1,101 @@ +// @vitest-environment node +import { spawnSync } from "node:child_process"; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +const script = join(dirname(fileURLToPath(import.meta.url)), "audit-windows-bundle.ps1"); + +function runAudit(releaseDir: string) { + return spawnSync( + "powershell.exe", + ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", script, releaseDir], + { encoding: "utf8" }, + ); +} + +function makeReleaseLayout(options: { + setup?: boolean; + exe?: boolean; + developerRuntime?: boolean; + looseTextbook?: boolean; +}) { + const root = mkdtempSync(join(tmpdir(), "windows-bundle-audit-")); + mkdirSync(join(root, "bundle", "nsis"), { recursive: true }); + if (options.setup !== false) { + writeFileSync( + join(root, "bundle", "nsis", "Desktop Translator_0.3.0_x64-setup.exe"), + "setup", + ); + } + if (options.exe !== false) { + writeFileSync(join(root, "desktop-translator.exe"), "exe"); + } + if (options.developerRuntime) { + writeFileSync(join(root, "node.exe"), "node"); + } + if (options.looseTextbook) { + mkdirSync(join(root, "resources", "textbooks"), { recursive: true }); + writeFileSync(join(root, "resources", "textbooks", "starter-en-zh.sqlite3"), "db"); + } + return root; +} + +describe("Windows bundle audit", () => { + it("fails when the NSIS x64 installer is missing", () => { + const root = makeReleaseLayout({ setup: false }); + try { + const result = runAudit(root); + expect(result.status).not.toBe(0); + expect(`${result.stdout}${result.stderr}`).toMatch(/nsis|setup|installer/i); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it("fails when the native executable is missing", () => { + const root = makeReleaseLayout({ exe: false }); + try { + const result = runAudit(root); + expect(result.status).not.toBe(0); + expect(`${result.stdout}${result.stderr}`).toMatch(/desktop-translator\.exe/i); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it("fails when a developer runtime is packaged", () => { + const root = makeReleaseLayout({ developerRuntime: true }); + try { + const result = runAudit(root); + expect(result.status).not.toBe(0); + expect(`${result.stdout}${result.stderr}`).toMatch(/developer/i); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it("fails when the starter textbook is a loose resource file", () => { + const root = makeReleaseLayout({ looseTextbook: true }); + try { + const result = runAudit(root); + expect(result.status).not.toBe(0); + expect(`${result.stdout}${result.stderr}`).toMatch(/textbook|sqlite/i); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it("passes a compact x64 NSIS layout without developer runtimes", () => { + const root = makeReleaseLayout({}); + try { + const result = runAudit(root); + expect(result.status, `${result.stdout}${result.stderr}`).toBe(0); + expect(`${result.stdout}${result.stderr}`).toMatch(/bundle audit passed/i); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); From 4cd9086e8385890ad0dacb86024680c1183eb293 Mon Sep 17 00:00:00 2001 From: shenglong Date: Mon, 24 Aug 2026 21:28:56 +0800 Subject: [PATCH 2/7] docs(windows): record host evidence and publish unsigned NSIS artifacts Document the Windows 11 25H2 qualification limits, add the supply-chain plan, and isolate a Windows release job with checksums and attestations. --- .github/workflows/release.yml | 73 +++++++++++++++++++++++- README.en.md | 6 ++ README.md | 36 +++++++++--- README.zh-CN.md | 14 +++++ docs/platform-test-matrix.md | 44 ++++++++------ docs/windows-signing-and-supply-chain.md | 64 +++++++++++++++++++++ 6 files changed, 207 insertions(+), 30 deletions(-) create mode 100644 docs/windows-signing-and-supply-chain.md diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f103234..9d0dbfd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -95,5 +95,74 @@ jobs: ## Platform support - macOS only. The Windows adapters are written but have not been - qualified on a Windows host and are not distributed yet. + macOS (universal `.dmg` / `.app`) and Windows 10/11 x64 (NSIS + `*_x64-setup.exe`). Windows installers are **not Authenticode + signed**. See `docs/windows-signing-and-supply-chain.md`. + + windows: + name: Windows x64 + runs-on: windows-latest + permissions: + contents: write + id-token: write + attestations: write + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.inputs.tag || github.ref }} + + - uses: pnpm/action-setup@v4 + with: + version: 10 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + + - uses: swatinem/rust-cache@v2 + with: + workspaces: src-tauri + + - run: pnpm install --frozen-lockfile + + - name: Build NSIS + run: pnpm tauri build --bundles nsis + + - name: Audit Windows bundle + run: powershell -NoProfile -ExecutionPolicy Bypass -File tools/release/audit-windows-bundle.ps1 src-tauri/target/release + + - name: SHA-256 checksums + shell: pwsh + run: | + $nsis = Get-ChildItem "src-tauri/target/release/bundle/nsis/*_x64-setup.exe" | Select-Object -First 1 + $exe = Get-Item "src-tauri/target/release/desktop-translator.exe" + @( + (Get-FileHash -Algorithm SHA256 $nsis.FullName | ForEach-Object { "$($_.Hash.ToLower()) $($nsis.Name)" }) + (Get-FileHash -Algorithm SHA256 $exe.FullName | ForEach-Object { "$($_.Hash.ToLower()) $($exe.Name)" }) + ) | Set-Content -Encoding ascii "windows-sha256.txt" + Get-Content "windows-sha256.txt" + + - uses: tauri-apps/tauri-action@v0 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + tagName: ${{ github.event.inputs.tag || github.ref_name }} + releaseName: Desktop Translator ${{ github.event.inputs.tag || github.ref_name }} + releaseDraft: false + prerelease: false + includeUpdaterJson: false + args: --bundles nsis + + - name: Upload Windows checksums + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: gh release upload "${{ github.event.inputs.tag || github.ref_name }}" windows-sha256.txt --clobber + + - uses: actions/attest-build-provenance@v2 + with: + subject-path: src-tauri/target/release/bundle/nsis/*_x64-setup.exe diff --git a/README.en.md b/README.en.md index 5954aa3..969e41b 100644 --- a/README.en.md +++ b/README.en.md @@ -24,3 +24,9 @@ involved. Textbook attribution and source links remain visible in the study UI. Translation lookup follows this order: personal wordbook, active downloaded textbook, then the configured online translation provider. A textbook hit is promoted into the personal wordbook for later study. + +Windows 10/11 x64 is distributed as an unsigned NSIS installer. Compile, gating +CI, and silent install/launch/uninstall were recorded on Windows 11 25H2 x64; +interactive overlay and speech fixtures remain manual-only. See +[README.md](README.md) and +[docs/windows-signing-and-supply-chain.md](docs/windows-signing-and-supply-chain.md). diff --git a/README.md b/README.md index 957e383..2c4ddad 100644 --- a/README.md +++ b/README.md @@ -98,18 +98,23 @@ The study window brings four tools together: | Platform | Status | | --- | --- | | macOS 11+ | Supported | -| Windows 10/11 | Implemented but unqualified — not distributed yet | +| Windows 10/11 x64 | Packaged (unsigned NSIS) — compile, CI, and installer smoke passed; interactive UI fixtures remain manual-only | | Linux | Not planned | > [!NOTE] -> The Windows adapters (UI Automation selection, low-level mouse hook, SAPI -> speech) are written and compile in CI, but they have never been exercised on a -> real Windows host, so no Windows build is published. See -> [`docs/platform-test-matrix.md`](docs/platform-test-matrix.md) for what is and -> is not qualified. +> Windows 11 25H2 x64 (build 26200.9168) produced +> `Desktop Translator_0.3.0_x64-setup.exe` via `pnpm tauri build --bundles nsis`. +> Silent current-user install, launch, reinstall, and uninstall succeeded. +> UI Automation selection, the non-activating overlay against another app, SAPI, +> the credential prompt, start-at-login, and Vocabulary Study chrome were **not** +> exercised interactively on this host. See +> [`docs/platform-test-matrix.md`](docs/platform-test-matrix.md) and +> [`docs/windows-signing-and-supply-chain.md`](docs/windows-signing-and-supply-chain.md). ## Install +### macOS + Download the `.dmg` from the [latest release](https://github.com/Ldsystem/desktop-translator/releases/latest) and drag the app into Applications. The build is universal, so one download @@ -123,6 +128,18 @@ runs natively on both Apple Silicon and Intel Macs. > xattr -dr com.apple.quarantine "/Applications/Desktop Translator.app" > ``` +### Windows 10/11 x64 + +Download `Desktop Translator_*_x64-setup.exe` from the same Releases page and +run it. The installer is NSIS, current-user, and fetches WebView2 with the +Microsoft download bootstrapper when the runtime is missing. It does not +require Node.js, Rust, Python, or an administrator account. + +> [!IMPORTANT] +> Windows builds are **not Authenticode-signed**. SmartScreen may warn on first +> run; use **More info → Run anyway**. See +> [`docs/windows-signing-and-supply-chain.md`](docs/windows-signing-and-supply-chain.md). + ## Setup Two things are needed before the first online translation. Vocabulary Study is @@ -162,8 +179,8 @@ menu-bar menu, choose a service, and use its native credential prompt: > never silently falls back to another online provider. Credentials are entered in native secure prompts and stored in the macOS -Keychain. They never pass through the WebView and are never written to the -settings file. +Keychain or Windows Credential Manager. They never pass through the WebView and +are never written to the settings file. ## Usage @@ -214,7 +231,8 @@ pnpm tauri dev | `pnpm tauri dev` | Run the app against a live-reloading renderer | | `pnpm check` | Typecheck, frontend tests, and renderer build | | `pnpm test:platform` | Rust unit and integration tests | -| `pnpm tauri build` | Produce a `.app` and `.dmg` | +| `pnpm tauri build` | Produce a `.app` and `.dmg` on macOS | +| `pnpm tauri build --bundles nsis` | Produce the Windows x64 NSIS setup exe | > [!TIP] > On macOS, run [`tools/macos/create-dev-signing-identity.sh`](tools/macos/create-dev-signing-identity.sh) diff --git a/README.zh-CN.md b/README.zh-CN.md index 270e2fd..e201554 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -37,6 +37,8 @@ ## 安装 +### macOS + 从 [最新 Release](https://github.com/Ldsystem/desktop-translator/releases/latest) 下载 `.dmg`, 将应用拖入“应用程序”文件夹。安装包为通用版本,同时支持 Apple 芯片和 Intel Mac, 并已包含界面、数据库运行库和离线入门词书,不需要安装 Node.js、Python 或 SQLite。 @@ -47,6 +49,17 @@ xattr -dr com.apple.quarantine "/Applications/Desktop Translator.app" ``` +### Windows 10/11 x64 + +从同一 Releases 页面下载 `Desktop Translator_*_x64-setup.exe`(NSIS,当前用户安装)。 +若本机没有 WebView2,安装程序会通过微软官方 bootstrapper 下载。不需要 Node.js、Rust、 +Python 或管理员权限。构建**未经 Authenticode 签名**,SmartScreen 可能提示 +“更多信息 → 仍要运行”。 + +本机 Windows 11 25H2 x64 已完成编译、CI 门禁和静默安装/启动/卸载;针对第二个应用的 +划词叠加层、SAPI、凭据提示等交互夹具仍待手动执行。详见英文 +[README.md](README.md) 与 [平台矩阵](docs/platform-test-matrix.md)。 + ## 初次设置 1. 在“系统设置 → 隐私与安全性 → 辅助功能”中允许桌面翻译,然后从菜单栏退出并重新打开。 @@ -75,5 +88,6 @@ pnpm tauri dev | `pnpm check` | TypeScript 检查、前端测试和构建 | | `pnpm test:platform` | Rust 单元与集成测试 | | `pnpm tauri build` | 生成自包含的 `.app` 和 `.dmg` | +| `pnpm tauri build --bundles nsis` | 生成 Windows x64 NSIS 安装包 | 详细的架构、平台支持和权限说明请参阅 [英文 README](README.md)。 diff --git a/docs/platform-test-matrix.md b/docs/platform-test-matrix.md index f84bb6d..90e7480 100644 --- a/docs/platform-test-matrix.md +++ b/docs/platform-test-matrix.md @@ -1,6 +1,6 @@ # Platform Qualification Matrix -Updated: 2026-08-13 +Updated: 2026-08-24 This matrix separates deterministic automated evidence from real-host evidence. A row is `Passed` only when the stated fixture was exercised on the named host. `Pending` is not @@ -10,27 +10,28 @@ treated as release evidence. - **macOS host:** macOS 26.5.2 (25F84), Apple silicon arm64, 16 GiB RAM, Node.js 20.20.0, pnpm 10.30.2, rustc 1.97.1. -- **Windows host:** pending access to a Windows 10/11 x64 host with a normal - unelevated session and UI Automation enabled. - -> The Windows target does not compile. The non-gating `windows-latest` job in -> `.github/workflows/ci.yml` first ran on 2026-08-13 and reported drift against -> `windows` crate 0.62: `apply_non_activating_tool_window` is not exported from -> `platform::windows` (`overlay.rs:365`), `LOCALE_NAME_MAX_LENGTH` is no longer -> under `Win32::Globalization` (`speech.rs:16`), argument types changed at -> `selection.rs:304` and `speech.rs:183`, and `SPEAKFLAGS` no longer implements -> `BitOr` (`speech.rs:219`). Windows qualification therefore starts from a -> compile repair, not from host fixtures. +- **Windows host:** Windows 11 25H2 x64 (build 26200.9168), unelevated session, + Node.js via Volta, pnpm 11.20.0, rustc 1.98.0. Display topology is a single + primary 1536×960 32-bit surface (`\\.\DISPLAY1`); mixed-DPI and multi-monitor + placement were **not** available on this host. + +> Compile against locked `windows` 0.62 is repaired. Gating Windows CI now runs +> renderer build, `cargo fmt`, Clippy `-D warnings`, `cargo test`, NSIS package, +> and `tools/release/audit-windows-bundle.ps1`. Real-host UI Automation selection, +> overlay against a second app, SAPI, credential prompt, start-at-login, and +> Vocabulary Study chrome remain **manual-only** until those fixtures are run +> interactively. See [`windows-signing-and-supply-chain.md`](windows-signing-and-supply-chain.md). ## Deterministic gates | Gate | macOS result | Windows result | Evidence | | --- | --- | --- | --- | -| Frontend typecheck, tests, production build | Passed | Pending | `pnpm check`; 6 files and 22 tests passed | -| Rust unit and integration suite | Passed | Pending | `cargo test --manifest-path src-tauri/Cargo.toml`; 73 passed and 6 explicit manual fixtures ignored | -| Strict Rust lint and formatting | Passed | Pending | `cargo fmt --check`; `cargo clippy --all-targets -- -D warnings` | -| Native unsigned release build | Passed | Pending | `pnpm tauri build` on macOS | -| Performance harness parser and budgets | Passed | Pending | `pnpm test:perf`; process-tree parsing, percentile calculation, CLI forwarding, and multi-budget failures | +| Frontend typecheck, tests, production build | Passed | Passed | `pnpm check` on this Windows host; 12 files and 80 tests passed including the NSIS audit suite | +| Rust unit and integration suite | Passed | Passed | `cargo test --manifest-path src-tauri/Cargo.toml`; 135 passed, 1 ignored network fixture | +| Strict Rust lint and formatting | Passed | Passed | `cargo fmt --check`; `cargo clippy --all-targets -- -D warnings` | +| Native unsigned release build | Passed | Passed | `pnpm tauri build --bundles nsis`; `Desktop Translator_0.3.0_x64-setup.exe` 3 589 056 bytes SHA-256 `9DD565CF2FA5487D90EDD3B8638C92C76B88C32CBEBED1054A885CD0D799DCC5`; exe 9 397 248 bytes SHA-256 `FF33835546FF6A6F6D4DAE30241DA3871956D6C9D08C3D46AE601AFB53A21ECF` | +| Installer silent install / launch / reinstall / uninstall | n/a | Passed | NSIS `/S` current-user install to `%LOCALAPPDATA%\Desktop Translator`, process started, `/S` reinstall, `uninstall.exe /S` removed the directory | +| Performance harness parser and budgets | Passed | Passed (parser) | `pnpm test:perf` is included in `pnpm check`; warmed RSS/latency on Windows remain pending | ## Selection and lifecycle fixtures @@ -48,7 +49,7 @@ treated as release evidence. | Protected/secure field suppression | Pending | Pending | Run the ignored secure-field fixture and verify no overlay, log, or request | | Unsupported canvas/PDF control | Pending | Pending | Verify no overlay when AX/UIA exposes no selected range and geometry | | Permission denied or elevated target | Pending | Pending | Revoke macOS Accessibility and test guidance; test a Windows elevated target from an unelevated app | -| Mixed-DPI, multi-monitor, and work-area edges | Automated | Pending | Placement and macOS display-normalization tests pass; real multi-display placement remains pending | +| Mixed-DPI, multi-monitor, and work-area edges | Automated | Topology limited | Windows unit placement tests pass; this host has one 1536×960 display so mixed-DPI/multi-monitor was not exercised | | Display topology change | Automated | Pending | Pure placement recomputation test passes; real hot-plug remains pending | | Sleep/wake and observer restart | Automated | Pending | Observer stop/restart lifecycle tests pass; real sleep/wake remains pending | | Double/triple-click word or paragraph selection | Automated | Pending | macOS gesture-state tests pass; real-host fixture remains pending | @@ -105,9 +106,14 @@ Current macOS pre-WebView release measurement: ## Release blockers +Windows compile, NSIS packaging, gating CI, and silent installer smoke passed on +Windows 11 25H2 x64. Interactive UI Automation selection, overlay, SAPI, +credential prompt, start-at-login, and Vocabulary Study chrome are still +manual-only. + Task 008 cannot be accepted until: -1. the Windows deterministic build and real-host fixture rows pass on Windows 10/11; +1. remaining Windows real-host UI fixture rows are executed on Windows 10/11; 2. pending macOS real-host fixtures are executed; 3. warmed resource measurements and externally observed latency samples pass; 4. manual theme, reduced-motion, and real multi-display checks are recorded. diff --git a/docs/windows-signing-and-supply-chain.md b/docs/windows-signing-and-supply-chain.md new file mode 100644 index 0000000..83c43c6 --- /dev/null +++ b/docs/windows-signing-and-supply-chain.md @@ -0,0 +1,64 @@ +# Windows signing and software supply chain + +This document separates **controls this repository already implements** from +**external certificate and account work** that must not be stored in Git. + +Windows release artifacts are currently **unsigned**. That is intentional until +a publisher certificate is procured outside this repository. Do not buy, commit, +log, or embed a code-signing certificate here. + +## Implemented in CI and packaging + +| Control | Where it lives | Status | +| --- | --- | --- | +| Lockfile integrity | `pnpm-lock.yaml`, `src-tauri/Cargo.lock`, `pnpm install --frozen-lockfile` | Implemented | +| Gating Windows compile and package | `.github/workflows/ci.yml` Windows job | Implemented | +| NSIS x64 installer + WebView2 download bootstrapper | `src-tauri/tauri.conf.json` `bundle.windows` | Implemented | +| Bundle audit (exe, NSIS x64 setup, no developer runtimes, textbook remains embedded) | `tools/release/audit-windows-bundle.ps1` | Implemented | +| Artifact hashes | Release job writes SHA-256 for the NSIS setup and native exe | Implemented | +| Provenance attestation | `actions/attest-build-provenance` on tag/workflow_dispatch only | Implemented | +| Least-privilege installation | NSIS `installMode: currentUser` (no administrator required) | Implemented | +| Credential isolation | Native Windows Credential Manager via `keyring`; credentials never enter the WebView, settings JSON, logs, or docs | Implemented | +| Fork / pull-request isolation | `release.yml` does not run on `pull_request`; it never receives signing secrets | Implemented | +| macOS path isolation | Windows and macOS release jobs are separate; artifacts keep platform-specific names | Implemented | + +Dependency review for pull requests remains the GitHub-hosted default for this +public repository. There is no committed SBOM generator yet; tag provenance +attestations are the implemented substitute. + +## External prerequisites (not in this repository) + +These require a human publisher identity and must use a protected GitHub +Environment or an offline ceremony. Untrusted forks must never receive them. + +| Prerequisite | Purpose | +| --- | --- | +| Authenticode code-signing certificate | Publisher identity for the NSIS setup and exe | +| Certificate custody and PIN/HSM or cloud KMS | Private key never in Git, logs, or `pull_request` secrets | +| RFC 3161 timestamping authority | Signatures remain verifiable after the cert expires | +| Publisher name alignment | SmartScreen reputation accrues to one stable identity | +| SmartScreen / Microsoft reputation | New publishers are warned until reputation exists | +| Malware scanning of published installers | Optional extra gate before a production tag | +| Key rotation and revocation plan | Replace a compromised or expired cert; publish a new installer | +| Rollback | Yank or replace a GitHub Release; do not reuse a burned version | + +Until those exist, document SmartScreen warnings as expected for unsigned +installers. Users may need to choose **More info → Run anyway**. That is not a +substitute for Authenticode. + +## Certificate custody rules + +- Store signing material only in a protected GitHub Environment or an external + signing service. +- Grant `id-token` / `attestations` on the tag workflow; do not pass + `WINDOWS_CERTIFICATE*` (or any analog) into pull-request jobs. +- Rotate by issuing a new cert, signing a new installer, and publishing new + hashes. Revoke the old cert with the CA when it is compromised. +- Never put `.pfx`, passwords, or thumbprints that unlock a private key into + `src-tauri/tauri.conf.json` on this branch. + +## Related macOS note + +macOS ad-hoc signing and Accessibility identity are documented in +[`macos-development-signing.md`](macos-development-signing.md). Windows does not +use that runner. Do not copy macOS signing identities into the Windows job. From 46982aefa9f25bc252de58bca86e68c939592f0b Mon Sep 17 00:00:00 2001 From: shenglong Date: Mon, 24 Aug 2026 22:30:36 +0800 Subject: [PATCH 3/7] checkpoint before checking out main --- src-tauri/src/commands.rs | 23 ++++- src-tauri/src/credential_prompt.rs | 148 ++++++++++++++++++++++++++--- 2 files changed, 152 insertions(+), 19 deletions(-) diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 0f1e25c..c4a9ce8 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -579,6 +579,7 @@ pub fn get_credential_status( /// Opens a native secure prompt and stores the entered key directly in the OS vault. #[tauri::command] pub fn prompt_and_save_credential( + window: WebviewWindow, state: State<'_, RuntimeState>, provider: TranslationProviderId, field: String, @@ -602,11 +603,7 @@ pub fn prompt_and_save_credential( (TranslationProviderId::Microsoft, _) => "Microsoft Translator Subscription Key", _ => "Google Cloud Translation API Key", }; - let Some(mut api_key) = crate::credential_prompt::prompt_secure_text( - title, - "The key is stored directly in the operating-system credential vault.", - )? - else { + let Some(mut api_key) = prompt_credential_secret(&window, title)? else { return Ok(false); }; let result = state.credentials.set(provider, secret_field, &api_key); @@ -614,6 +611,22 @@ pub fn prompt_and_save_credential( result.map(|_| true) } +fn prompt_credential_secret( + window: &WebviewWindow, + title: &str, +) -> Result, AppError> { + const MESSAGE: &str = "The key is stored directly in the operating-system credential vault."; + #[cfg(target_os = "windows")] + { + crate::credential_prompt::prompt_secure_text_for_window(window, title, MESSAGE) + } + #[cfg(not(target_os = "windows"))] + { + let _ = window; + crate::credential_prompt::prompt_secure_text(title, MESSAGE) + } +} + /// Validates the stored credential without returning credential material. #[tauri::command] pub async fn test_credential( diff --git a/src-tauri/src/credential_prompt.rs b/src-tauri/src/credential_prompt.rs index b7bb0ca..01abb71 100644 --- a/src-tauri/src/credential_prompt.rs +++ b/src-tauri/src/credential_prompt.rs @@ -1,17 +1,63 @@ //! Native secure credential entry that never places the API key in WebView state. +use std::sync::atomic::{AtomicBool, Ordering}; + use crate::contracts::{AppError, AppErrorCode}; +static PROMPT_IN_PROGRESS: AtomicBool = AtomicBool::new(false); + /// Prompts for an API key using the platform's native secure credential control. #[cfg(target_os = "macos")] pub fn prompt_secure_text(title: &str, message: &str) -> Result, AppError> { + let _guard = acquire_prompt_guard()?; macos::prompt(title, message) } /// Prompts for an API key using Windows Credential UI without persistence. #[cfg(target_os = "windows")] pub fn prompt_secure_text(title: &str, message: &str) -> Result, AppError> { - windows::prompt(title, message) + prompt_secure_text_with_parent(title, message, std::ptr::null_mut()) +} + +/// Owns the credential dialog with the invoking window so it cannot fall behind. +#[cfg(target_os = "windows")] +pub fn prompt_secure_text_for_window( + window: &tauri::WebviewWindow, + title: &str, + message: &str, +) -> Result, AppError> { + use std::sync::mpsc; + + let parent = window + .hwnd() + .ok() + .map(|handle| handle.0 as isize) + .unwrap_or(0); + let _ = window.show(); + let _ = window.unminimize(); + let _ = window.set_focus(); + + let title = title.to_owned(); + let message = message.to_owned(); + let (sender, receiver) = mpsc::sync_channel(1); + window + .run_on_main_thread(move || { + let parent = parent as *mut std::ffi::c_void; + let result = prompt_secure_text_with_parent(&title, &message, parent); + let _ = sender.send(result); + }) + .map_err(|_| prompt_error())?; + receiver.recv().map_err(|_| prompt_error())? +} + +#[cfg(target_os = "windows")] +fn prompt_secure_text_with_parent( + title: &str, + message: &str, + parent: *mut std::ffi::c_void, +) -> Result, AppError> { + let _guard = acquire_prompt_guard()?; + windows::prompt(title, message, parent) } fn prompt_error() -> AppError { @@ -22,6 +68,36 @@ fn prompt_error() -> AppError { ) } +fn try_begin_prompt() -> bool { + PROMPT_IN_PROGRESS + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .is_ok() +} + +fn end_prompt() { + PROMPT_IN_PROGRESS.store(false, Ordering::Release); +} + +struct PromptGuard; + +impl Drop for PromptGuard { + fn drop(&mut self) { + end_prompt(); + } +} + +fn acquire_prompt_guard() -> Result { + if try_begin_prompt() { + Ok(PromptGuard) + } else { + Err(AppError::new( + AppErrorCode::Internal, + "A credential prompt is already open.", + false, + )) + } +} + #[cfg(target_os = "macos")] mod macos { use std::ffi::{c_char, c_void, CStr, CString}; @@ -156,14 +232,28 @@ mod windows { const MAX_PASSWORD: usize = 512; #[repr(C)] - struct CredUiInfo { - size: u32, - parent: *mut c_void, + pub(super) struct CredUiInfo { + pub(super) size: u32, + pub(super) parent: *mut c_void, message_text: *const u16, caption_text: *const u16, banner: *mut c_void, } + pub(super) fn wide(value: &str) -> Vec { + value.encode_utf16().chain(std::iter::once(0)).collect() + } + + pub(super) fn cred_ui_info(title: &[u16], message: &[u16], parent: *mut c_void) -> CredUiInfo { + CredUiInfo { + size: mem::size_of::() as u32, + parent, + message_text: message.as_ptr(), + caption_text: title.as_ptr(), + banner: ptr::null_mut(), + } + } + #[link(name = "Credui")] unsafe extern "system" { fn CredUIPromptForCredentialsW( @@ -180,20 +270,27 @@ mod windows { ) -> u32; } - pub fn prompt(title: &str, message: &str) -> Result, AppError> { + pub fn prompt( + title: &str, + message: &str, + parent: *mut c_void, + ) -> Result, AppError> { let title = wide(title); let message = wide(message); let target = wide("Desktop Translator Google Cloud Translation"); let mut username = [0_u16; MAX_USERNAME]; let mut password = [0_u16; MAX_PASSWORD]; let mut save = 0; - let info = CredUiInfo { - size: mem::size_of::() as u32, - parent: ptr::null_mut(), - message_text: message.as_ptr(), - caption_text: title.as_ptr(), - banner: ptr::null_mut(), - }; + let info = cred_ui_info(&title, &message, parent); + if !parent.is_null() { + let hwnd = ::windows::Win32::Foundation::HWND(parent); + // SAFETY: `parent` is the invoking Tauri window HWND and remains + // valid for this synchronous CredUI call on the UI thread. + unsafe { + let _ = ::windows::Win32::UI::WindowsAndMessaging::SetForegroundWindow(hwnd); + let _ = ::windows::Win32::UI::WindowsAndMessaging::BringWindowToTop(hwnd); + } + } // SAFETY: all pointers refer to live, correctly sized buffers for the call. let status = unsafe { CredUIPromptForCredentialsW( @@ -223,8 +320,31 @@ mod windows { password.fill(0); Ok(Some(value)) } +} - fn wide(value: &str) -> Vec { - value.encode_utf16().chain(std::iter::once(0)).collect() +#[cfg(test)] +mod tests { + use super::*; + + #[cfg(target_os = "windows")] + #[test] + fn cred_ui_info_uses_the_invoking_window_as_owner() { + let title = windows::wide("Google Cloud Translation API Key"); + let message = + windows::wide("The key is stored directly in the operating-system credential vault."); + let parent = 0x00BEEF_usize as *mut std::ffi::c_void; + let info = windows::cred_ui_info(&title, &message, parent); + assert_eq!(info.parent, parent); + assert_eq!(info.size, std::mem::size_of::() as u32); + assert!(!info.parent.is_null()); + } + + #[test] + fn overlapping_credential_prompts_are_rejected() { + assert!(try_begin_prompt()); + assert!(!try_begin_prompt()); + end_prompt(); + assert!(try_begin_prompt()); + end_prompt(); } } From 794e50f98436afb871dd5c6e189484746bf444f5 Mon Sep 17 00:00:00 2001 From: shenglong Date: Mon, 24 Aug 2026 22:41:55 +0800 Subject: [PATCH 4/7] checkpoint before checking out main --- src/app/App.test.tsx | 85 +++++++++++++++++++ src/app/App.tsx | 5 +- .../quick/QuickTranslatePanel.test.tsx | 15 ++++ src/components/quick/QuickTranslatePanel.tsx | 8 ++ .../settings/SettingsPanel.test.tsx | 31 +++++++ src/components/settings/SettingsPanel.tsx | 16 +++- 6 files changed, 156 insertions(+), 4 deletions(-) create mode 100644 src/app/App.test.tsx diff --git a/src/app/App.test.tsx b/src/app/App.test.tsx new file mode 100644 index 0000000..3b739b8 --- /dev/null +++ b/src/app/App.test.tsx @@ -0,0 +1,85 @@ +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { UserSettings } from "../contracts/ipc"; +import type { OverlayState } from "../state/overlayMachine"; +import App from "./App"; + +const settings: UserSettings = { + schemaVersion: 2, + enabled: true, + sourceLanguage: "auto", + targetLanguage: "en", + startAtLogin: false, + theme: "system", + maxSelectionCodePoints: 5_000, + uiLocale: "en", + translationProvider: "google", + microsoftCloud: "global", +}; + +const overlayState: OverlayState = { + mode: "button-visible", + generation: 1, + selection: { + id: 7, + text: "retrieval", + boundsPhysicalPx: [{ x: 20, y: 20, width: 80, height: 24 }], + anchorPhysicalPx: { x: 20, y: 20, width: 80, height: 24 }, + capturedAtEpochMs: 1, + }, +}; + +describe("App overlay settings", () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + it("uses a later saved target language for selection translation", () => { + const onTranslate = vi.fn(); + act(() => + root.render( + , + ), + ); + act(() => + root.render( + , + ), + ); + + const button = container.querySelector( + 'button[aria-label="Translate Selected Text"]', + ); + expect(button).not.toBeNull(); + act(() => button?.click()); + expect(onTranslate).toHaveBeenCalledWith({ + selectionId: 7, + text: "retrieval", + sourceLanguage: "auto", + targetLanguage: "zh-CN", + }); + }); +}); diff --git a/src/app/App.tsx b/src/app/App.tsx index baa7860..32e4cbc 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import type { LanguageCode, @@ -111,6 +111,9 @@ export default function App({ onQuit = ignore, }: AppProps) { const [settings, setSettings] = useState(initialSettings); + useEffect(() => { + setSettings(initialSettings); + }, [initialSettings]); document.documentElement.lang = settings.uiLocale; if (mode === "overlay") { diff --git a/src/components/quick/QuickTranslatePanel.test.tsx b/src/components/quick/QuickTranslatePanel.test.tsx index 9a6e5f7..6a56e51 100644 --- a/src/components/quick/QuickTranslatePanel.test.tsx +++ b/src/components/quick/QuickTranslatePanel.test.tsx @@ -68,6 +68,21 @@ describe("QuickTranslatePanel", () => { }); }); + it("adopts a later default target language from settings", () => { + const onTranslate = vi.fn(); + render({ mode: "idle" }, { onTranslate, targetLanguage: "en" }); + render({ mode: "idle" }, { onTranslate, targetLanguage: "zh-CN" }); + typeInto("retrieval"); + submit(); + + expect(onTranslate).toHaveBeenCalledWith({ + selectionId: 0, + text: "retrieval", + sourceLanguage: "auto", + targetLanguage: "zh-CN", + }); + }); + it("does not translate blank input", () => { const props = render(); typeInto(" "); diff --git a/src/components/quick/QuickTranslatePanel.tsx b/src/components/quick/QuickTranslatePanel.tsx index 2835a5d..99ab889 100644 --- a/src/components/quick/QuickTranslatePanel.tsx +++ b/src/components/quick/QuickTranslatePanel.tsx @@ -53,6 +53,14 @@ export function QuickTranslatePanel({ const [target, setTarget] = useState(targetLanguage); const input = useRef(null); + useEffect(() => { + setSource(sourceLanguage); + }, [sourceLanguage]); + + useEffect(() => { + setTarget(targetLanguage); + }, [targetLanguage]); + useEffect(() => { input.current?.focus(); }, []); diff --git a/src/components/settings/SettingsPanel.test.tsx b/src/components/settings/SettingsPanel.test.tsx index 138a870..b062d33 100644 --- a/src/components/settings/SettingsPanel.test.tsx +++ b/src/components/settings/SettingsPanel.test.tsx @@ -119,6 +119,37 @@ describe("SettingsPanel", () => { ); }); + it("persists the target language when the dropdown changes", () => { + const onSave = vi.fn(); + act(() => + root.render( + , + ), + ); + + const target = container.querySelector("#target-language"); + act(() => { + if (target) { + target.value = "zh-CN"; + target.dispatchEvent(new Event("change", { bubbles: true })); + } + }); + + expect(onSave).toHaveBeenCalledWith( + expect.objectContaining({ targetLanguage: "zh-CN", sourceLanguage: "auto" }), + ); + }); + it("explains privacy, explicit sending, and API cost constraints", () => { act(() => root.render( diff --git a/src/components/settings/SettingsPanel.tsx b/src/components/settings/SettingsPanel.tsx index 7d5eb67..07819ba 100644 --- a/src/components/settings/SettingsPanel.tsx +++ b/src/components/settings/SettingsPanel.tsx @@ -45,13 +45,23 @@ export function SettingsPanel({ useEffect(() => setDraft(settings), [settings]); + const persist = (next: UserSettings) => { + onSave({ ...next, enabled: permissionStatus !== "denied" && next.enabled }); + }; + const update = (key: Key, value: UserSettings[Key]) => { setDraft((current) => ({ ...current, [key]: value })); }; + const updateLanguage = (key: "sourceLanguage" | "targetLanguage", value: string) => { + const next = { ...draft, [key]: value }; + setDraft(next); + persist(next); + }; + const submit = (event: FormEvent) => { event.preventDefault(); - onSave({ ...draft, enabled: monitoringEnabled }); + persist(draft); }; return ( @@ -137,7 +147,7 @@ export function SettingsPanel({ id="source-language" name="sourceLanguage" value={draft.sourceLanguage} - onChange={(event) => update("sourceLanguage", event.currentTarget.value)} + onChange={(event) => updateLanguage("sourceLanguage", event.currentTarget.value)} > {defaultLanguages.map((language) => ( @@ -152,7 +162,7 @@ export function SettingsPanel({ id="target-language" name="targetLanguage" value={draft.targetLanguage} - onChange={(event) => update("targetLanguage", event.currentTarget.value)} + onChange={(event) => updateLanguage("targetLanguage", event.currentTarget.value)} > {defaultLanguages.map((language) => ( From c668fe61c4566781f264c29f7da489f435c59514 Mon Sep 17 00:00:00 2001 From: shenglong Date: Tue, 25 Aug 2026 09:40:24 +0800 Subject: [PATCH 5/7] fix(windows): persist target language and keep the tray panel on-screen Language-only settings saves no longer abort on autostart, selection translation uses the saved target, the tray panel stays inside the work area, and Windows release builds hide the extra console. Co-authored-by: Cursor --- src-tauri/src/commands.rs | 35 ++++++++++++- src-tauri/src/contracts.rs | 29 +++++++++++ src-tauri/src/lib.rs | 13 +++++ src-tauri/src/main.rs | 4 ++ src-tauri/src/placement.rs | 87 +++++++++++++++++++++++++++++++- src-tauri/src/quick_translate.rs | 41 ++++++++++----- 6 files changed, 192 insertions(+), 17 deletions(-) diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index c4a9ce8..afc43bc 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -538,14 +538,17 @@ pub async fn save_settings( )); } let previous = state.settings.load()?; - sync_start_at_login(&app, settings.start_at_login)?; if let Err(error) = state.settings.save(&settings) { - let _ = sync_start_at_login(&app, previous.start_at_login); return Err(error); } let _ = app.emit("settings-changed", &settings); crate::tray::refresh_window_titles(&app, settings.ui_locale); state.coordinator.update_policy(selection_policy(&settings)); + if start_at_login_changed(&previous, &settings) { + if let Err(error) = sync_start_at_login(&app, settings.start_at_login) { + return Err(error); + } + } if settings.enabled { if let Err(error) = crate::start_global_monitor(&app) { let _ = state.settings.save(&previous); @@ -654,6 +657,8 @@ pub async fn translate_selection( state: State<'_, RuntimeState>, request: TranslationRequest, ) -> Result { + let settings = state.settings.load()?; + let request = settings.apply_to_selection_request(request); let tracks_vocabulary = is_vocabulary_eligible(&request.text); let result = state.coordinator.translate(request).await?; if tracks_vocabulary { @@ -1017,6 +1022,10 @@ fn sync_start_at_login(app: &AppHandle, enabled: bool) -> Result<(), AppError> { .map_err(|_| internal_error("Start-at-login setting could not be updated")) } +fn start_at_login_changed(previous: &UserSettings, next: &UserSettings) -> bool { + previous.start_at_login != next.start_at_login +} + fn internal_error(message: &'static str) -> AppError { AppError::new(AppErrorCode::Internal, message, false) } @@ -1063,3 +1072,25 @@ fn platform_permission_granted() -> bool { true } } + +#[cfg(test)] +mod save_settings_tests { + use super::start_at_login_changed; + use crate::services::settings::default_user_settings; + + #[test] + fn language_only_changes_do_not_sync_autostart() { + let previous = default_user_settings(); + let mut next = previous.clone(); + next.target_language = "zh-CN".into(); + assert!(!start_at_login_changed(&previous, &next)); + } + + #[test] + fn toggling_start_at_login_does_sync_autostart() { + let previous = default_user_settings(); + let mut next = previous.clone(); + next.start_at_login = true; + assert!(start_at_login_changed(&previous, &next)); + } +} diff --git a/src-tauri/src/contracts.rs b/src-tauri/src/contracts.rs index 8a63272..e89d719 100644 --- a/src-tauri/src/contracts.rs +++ b/src-tauri/src/contracts.rs @@ -170,6 +170,16 @@ pub struct UserSettings { pub microsoft_region: Option, } +impl UserSettings { + /// Selection overlay has no target picker; the saved default is authoritative. + pub fn apply_to_selection_request(&self, request: TranslationRequest) -> TranslationRequest { + TranslationRequest { + target_language: self.target_language.clone(), + ..request + } + } +} + /// Validated request passed to a translation provider. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -704,4 +714,23 @@ mod tests { }"#; assert!(serde_json::from_str::(unknown_part_of_speech).is_err()); } + + #[test] + fn selection_translation_uses_the_saved_target_language() { + let raw = include_str!("../../src/contracts/fixtures.json"); + let fixtures: Fixtures = serde_json::from_str(raw).expect("fixtures must deserialize"); + let mut settings = fixtures.settings; + settings.target_language = "zh-CN".into(); + let request = TranslationRequest { + selection_id: 7, + text: "persistence".into(), + source_language: "auto".into(), + target_language: "en".into(), + }; + + let applied = settings.apply_to_selection_request(request); + assert_eq!(applied.target_language, "zh-CN"); + assert_eq!(applied.source_language, "auto"); + assert_eq!(applied.text, "persistence"); + } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 328586d..7f46b6e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -338,4 +338,17 @@ mod tests { assert!(routing.should_forward_press(false)); assert!(routing.should_forward_release()); } + + #[test] + fn windows_release_hides_the_console_subsystem() { + let source = include_str!("main.rs"); + assert!( + source.contains("windows_subsystem = \"windows\""), + "Windows release builds must use the windows subsystem so Explorer does not attach a console" + ); + assert!( + source.contains("cfg_attr(not(debug_assertions)"), + "debug builds should keep a console for logs" + ); + } } diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 1e46c39..c7ac407 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -1,3 +1,7 @@ +// Hide the extra console window on Windows release builds. Debug keeps a +// console so logs stay visible during development. +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] + fn main() { desktop_translator_lib::run(); } diff --git a/src-tauri/src/placement.rs b/src-tauri/src/placement.rs index 3948be7..4bc4a04 100644 --- a/src-tauri/src/placement.rs +++ b/src-tauri/src/placement.rs @@ -59,6 +59,31 @@ fn place_overlay( } } +/// Centers a tray popover on the click. Prefers below the icon, flips above when +/// that would overflow, then clamps both axes to the work area. +pub fn place_tray_panel( + anchor: PhysicalPoint, + work_area: PhysicalRect, + panel: PhysicalSize, + gap: f64, +) -> PhysicalPoint { + let work_right = work_area.x + work_area.width; + let work_bottom = work_area.y + work_area.height; + let max_x = (work_right - panel.width).max(work_area.x); + let max_y = (work_bottom - panel.height).max(work_area.y); + let x = (anchor.x - panel.width / 2.0).clamp(work_area.x, max_x); + let below = anchor.y + gap; + let y = if below + panel.height <= work_bottom { + below + } else { + anchor.y - panel.height - gap + }; + PhysicalPoint { + x, + y: y.clamp(work_area.y, max_y), + } +} + /// Returns the final valid accessibility rectangle in provider-supplied reading order. pub fn final_visible_line(rectangles: &[PhysicalRect]) -> Option { rectangles @@ -143,8 +168,8 @@ mod tests { use crate::contracts::PhysicalRect; use super::{ - final_visible_line, place_overlay, place_overlay_on_monitors, MonitorWorkArea, - PhysicalPoint, PhysicalSize, + final_visible_line, place_overlay, place_overlay_on_monitors, place_tray_panel, + MonitorWorkArea, PhysicalPoint, PhysicalSize, }; const WORK_AREA: PhysicalRect = PhysicalRect { @@ -391,4 +416,62 @@ mod tests { assert_eq!(invalid, None); } + + #[test] + fn tray_panel_opens_below_when_the_work_area_has_room() { + let point = place_tray_panel( + PhysicalPoint { x: 200.0, y: 28.0 }, + PhysicalRect { + x: 0.0, + y: 0.0, + width: 1920.0, + height: 1080.0, + }, + PhysicalSize { + width: 400.0, + height: 470.0, + }, + 6.0, + ); + + assert_eq!( + point, + PhysicalPoint { + x: 0.0, + y: 34.0 + } + ); + } + + #[test] + fn tray_panel_flips_above_and_clamps_to_the_work_area_near_the_bottom_right() { + let point = place_tray_panel( + PhysicalPoint { + x: 1900.0, + y: 1064.0, + }, + PhysicalRect { + x: 0.0, + y: 0.0, + width: 1920.0, + height: 1040.0, + }, + PhysicalSize { + width: 400.0, + height: 470.0, + }, + 6.0, + ); + + assert_eq!( + point, + PhysicalPoint { + x: 1520.0, + y: 570.0 + } + ); + assert!(point.x + 400.0 <= 1920.0); + assert!(point.y + 470.0 <= 1040.0); + assert!(point.y + 470.0 + 6.0 <= 1064.0); + } } diff --git a/src-tauri/src/quick_translate.rs b/src-tauri/src/quick_translate.rs index ca74ccf..c565ee6 100644 --- a/src-tauri/src/quick_translate.rs +++ b/src-tauri/src/quick_translate.rs @@ -4,7 +4,10 @@ use tauri::{ AppHandle, Emitter, Manager, PhysicalPosition, WebviewUrl, WebviewWindow, WebviewWindowBuilder, }; -use crate::contracts::{AppError, AppErrorCode}; +use crate::{ + contracts::{AppError, AppErrorCode, PhysicalRect}, + placement::{place_tray_panel, PhysicalPoint, PhysicalSize}, +}; /// Window label for the tray panel. pub const QUICK_LABEL: &str = "quick"; @@ -26,7 +29,7 @@ pub fn toggle(app: &AppHandle, anchor: PhysicalPosition) -> Result<(), AppE show(app, anchor) } -/// Presents the panel below the tray icon and gives it keyboard focus. +/// Presents the panel next to the tray icon and gives it keyboard focus. pub fn show(app: &AppHandle, anchor: PhysicalPosition) -> Result<(), AppError> { let window = ensure_window(app)?; position_panel(&window, anchor)?; @@ -74,7 +77,7 @@ fn ensure_window(app: &AppHandle) -> Result { .map_err(|_| quick_error("Quick translation panel could not be created")) } -/// Centers the panel under the tray anchor, clamped to the anchored monitor. +/// Centers the panel on the tray click, flipping above when below would overflow. fn position_panel(window: &WebviewWindow, anchor: PhysicalPosition) -> Result<(), AppError> { let size = window .outer_size() @@ -86,18 +89,30 @@ fn position_panel(window: &WebviewWindow, anchor: PhysicalPosition) -> Resu .or_else(|| window.primary_monitor().ok().flatten()) .ok_or_else(|| quick_error("Monitor topology is unavailable"))?; - let scale = monitor.scale_factor(); - let area_x = monitor.position().x as f64; - let area_y = monitor.position().y as f64; - let area_right = area_x + monitor.size().width as f64; - let width = size.width as f64; - let gap = QUICK_GAP * scale; - - let x = (anchor.x - width / 2.0).clamp(area_x, (area_right - width).max(area_x)); - let y = (anchor.y + gap).max(area_y); + let work = monitor.work_area(); + let point = place_tray_panel( + PhysicalPoint { + x: anchor.x, + y: anchor.y, + }, + PhysicalRect { + x: f64::from(work.position.x), + y: f64::from(work.position.y), + width: f64::from(work.size.width), + height: f64::from(work.size.height), + }, + PhysicalSize { + width: f64::from(size.width), + height: f64::from(size.height), + }, + QUICK_GAP * monitor.scale_factor(), + ); window - .set_position(PhysicalPosition::new(x.round() as i32, y.round() as i32)) + .set_position(PhysicalPosition::new( + point.x.round() as i32, + point.y.round() as i32, + )) .map_err(|_| quick_error("Quick translation panel could not be positioned")) } From 6fc153569b16b44fe25b55db30369ccee1b99772 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E5=8D=87=E9=BE=99?= Date: Tue, 25 Aug 2026 10:03:47 +0800 Subject: [PATCH 6/7] fix(release): qualify Windows v0.4.0 artifacts Run the Windows audit tests on their native host, publish the exact audited binaries, make settings and credential prompts transactional, and include the pending macOS overlay and tray repairs. Refs #4 --- .github/workflows/ci.yml | 3 + .github/workflows/release.yml | 27 +++--- package.json | 2 +- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/src/commands.rs | 106 ++++++++++++++++++--- src-tauri/src/placement.rs | 8 +- src-tauri/src/platform/macos/window.rs | 97 ++++++++++++++----- src-tauri/src/tray.rs | 12 ++- src-tauri/tauri.conf.json | 2 +- src/styles/app.css | 4 +- src/styles/tokens.css | 3 + tools/release/audit-windows-bundle.test.ts | 29 +++++- 13 files changed, 228 insertions(+), 69 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 15411c5..e6f549b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -105,6 +105,9 @@ jobs: - name: Build renderer run: pnpm build + - name: Test renderer and release audit + run: pnpm test + - name: Format run: cargo fmt --manifest-path src-tauri/Cargo.toml -- --check diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9d0dbfd..8a91721 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -101,6 +101,7 @@ jobs: windows: name: Windows x64 + needs: macos runs-on: windows-latest permissions: contents: write @@ -147,22 +148,16 @@ jobs: ) | Set-Content -Encoding ascii "windows-sha256.txt" Get-Content "windows-sha256.txt" - - uses: tauri-apps/tauri-action@v0 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - tagName: ${{ github.event.inputs.tag || github.ref_name }} - releaseName: Desktop Translator ${{ github.event.inputs.tag || github.ref_name }} - releaseDraft: false - prerelease: false - includeUpdaterJson: false - args: --bundles nsis - - - name: Upload Windows checksums - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: gh release upload "${{ github.event.inputs.tag || github.ref_name }}" windows-sha256.txt --clobber - - uses: actions/attest-build-provenance@v2 with: subject-path: src-tauri/target/release/bundle/nsis/*_x64-setup.exe + + - name: Upload audited Windows artifacts + shell: pwsh + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RELEASE_TAG: ${{ github.event.inputs.tag || github.ref_name }} + run: | + $nsis = Get-ChildItem "src-tauri/target/release/bundle/nsis/*_x64-setup.exe" | Select-Object -First 1 + $exe = Get-Item "src-tauri/target/release/desktop-translator.exe" + gh release upload "$env:RELEASE_TAG" $nsis.FullName $exe.FullName "windows-sha256.txt" --clobber diff --git a/package.json b/package.json index 9a81883..625138b 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "desktop-translator", "private": true, - "version": "0.3.0", + "version": "0.4.0", "type": "module", "scripts": { "dev": "vite", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 80bdf49..1e513a3 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -880,7 +880,7 @@ dependencies = [ [[package]] name = "desktop-translator" -version = "0.3.0" +version = "0.4.0" dependencies = [ "apple-native-keyring-store", "async-trait", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 888d956..09e4990 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "desktop-translator" -version = "0.3.0" +version = "0.4.0" description = "Lightweight cross-platform selection translator" authors = ["Desktop Translator Contributors"] edition = "2021" diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index afc43bc..aad567c 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -538,17 +538,15 @@ pub async fn save_settings( )); } let previous = state.settings.load()?; - if let Err(error) = state.settings.save(&settings) { - return Err(error); - } + persist_settings_with_autostart( + &previous, + &settings, + |enabled| sync_start_at_login(&app, enabled), + || state.settings.save(&settings), + )?; let _ = app.emit("settings-changed", &settings); crate::tray::refresh_window_titles(&app, settings.ui_locale); state.coordinator.update_policy(selection_policy(&settings)); - if start_at_login_changed(&previous, &settings) { - if let Err(error) = sync_start_at_login(&app, settings.start_at_login) { - return Err(error); - } - } if settings.enabled { if let Err(error) = crate::start_global_monitor(&app) { let _ = state.settings.save(&previous); @@ -581,7 +579,7 @@ pub fn get_credential_status( /// Opens a native secure prompt and stores the entered key directly in the OS vault. #[tauri::command] -pub fn prompt_and_save_credential( +pub async fn prompt_and_save_credential( window: WebviewWindow, state: State<'_, RuntimeState>, provider: TranslationProviderId, @@ -606,7 +604,16 @@ pub fn prompt_and_save_credential( (TranslationProviderId::Microsoft, _) => "Microsoft Translator Subscription Key", _ => "Google Cloud Translation API Key", }; - let Some(mut api_key) = prompt_credential_secret(&window, title)? else { + // The Windows credential dialog must execute on the UI thread, while this + // command waits off that thread. A synchronous command could otherwise + // block the same event loop that `run_on_main_thread` needs to enter. + let prompt_window = window.clone(); + let prompted = tauri::async_runtime::spawn_blocking(move || { + prompt_credential_secret(&prompt_window, title) + }) + .await + .map_err(|_| internal_error("Credential prompt could not be opened"))?; + let Some(mut api_key) = prompted? else { return Ok(false); }; let result = state.credentials.set(provider, secret_field, &api_key); @@ -1026,6 +1033,25 @@ fn start_at_login_changed(previous: &UserSettings, next: &UserSettings) -> bool previous.start_at_login != next.start_at_login } +fn persist_settings_with_autostart( + previous: &UserSettings, + next: &UserSettings, + mut sync: impl FnMut(bool) -> Result<(), E>, + save: impl FnOnce() -> Result<(), E>, +) -> Result<(), E> { + let changed = start_at_login_changed(previous, next); + if changed { + sync(next.start_at_login)?; + } + if let Err(error) = save() { + if changed { + let _ = sync(previous.start_at_login); + } + return Err(error); + } + Ok(()) +} + fn internal_error(message: &'static str) -> AppError { AppError::new(AppErrorCode::Internal, message, false) } @@ -1075,7 +1101,9 @@ fn platform_permission_granted() -> bool { #[cfg(test)] mod save_settings_tests { - use super::start_at_login_changed; + use std::{cell::RefCell, rc::Rc}; + + use super::{persist_settings_with_autostart, start_at_login_changed}; use crate::services::settings::default_user_settings; #[test] @@ -1093,4 +1121,60 @@ mod save_settings_tests { next.start_at_login = true; assert!(start_at_login_changed(&previous, &next)); } + + #[test] + fn autostart_failure_does_not_persist_the_new_settings() { + let previous = default_user_settings(); + let mut next = previous.clone(); + next.start_at_login = true; + let calls = Rc::new(RefCell::new(Vec::new())); + let sync_calls = Rc::clone(&calls); + let save_calls = Rc::clone(&calls); + + let result = persist_settings_with_autostart( + &previous, + &next, + move |enabled| { + sync_calls + .borrow_mut() + .push(if enabled { "sync-on" } else { "sync-off" }); + Err("sync failed") + }, + move || { + save_calls.borrow_mut().push("save"); + Ok(()) + }, + ); + + assert_eq!(result, Err("sync failed")); + assert_eq!(&*calls.borrow(), &["sync-on"]); + } + + #[test] + fn settings_failure_rolls_autostart_back() { + let previous = default_user_settings(); + let mut next = previous.clone(); + next.start_at_login = true; + let calls = Rc::new(RefCell::new(Vec::new())); + let sync_calls = Rc::clone(&calls); + let save_calls = Rc::clone(&calls); + + let result = persist_settings_with_autostart( + &previous, + &next, + move |enabled| { + sync_calls + .borrow_mut() + .push(if enabled { "sync-on" } else { "sync-off" }); + Ok(()) + }, + move || { + save_calls.borrow_mut().push("save"); + Err("save failed") + }, + ); + + assert_eq!(result, Err("save failed")); + assert_eq!(&*calls.borrow(), &["sync-on", "save", "sync-off"]); + } } diff --git a/src-tauri/src/placement.rs b/src-tauri/src/placement.rs index 4bc4a04..d73ed68 100644 --- a/src-tauri/src/placement.rs +++ b/src-tauri/src/placement.rs @@ -434,13 +434,7 @@ mod tests { 6.0, ); - assert_eq!( - point, - PhysicalPoint { - x: 0.0, - y: 34.0 - } - ); + assert_eq!(point, PhysicalPoint { x: 0.0, y: 34.0 }); } #[test] diff --git a/src-tauri/src/platform/macos/window.rs b/src-tauri/src/platform/macos/window.rs index 11632e3..cd62c56 100644 --- a/src-tauri/src/platform/macos/window.rs +++ b/src-tauri/src/platform/macos/window.rs @@ -174,6 +174,19 @@ pub struct NonActivatingPanelPolicy { pub becomes_key_only_if_needed: bool, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct NativeConfigurationPlan { + apply_nonactivating_panel_style: bool, + set_becomes_key_only_if_needed: bool, +} + +fn native_configuration_plan(supports_panel_only_policy: bool) -> NativeConfigurationPlan { + NativeConfigurationPlan { + apply_nonactivating_panel_style: supports_panel_only_policy, + set_becomes_key_only_if_needed: supports_panel_only_policy, + } +} + impl Default for NonActivatingPanelPolicy { fn default() -> Self { Self { @@ -181,7 +194,10 @@ impl Default for NonActivatingPanelPolicy { collection_behavior: CAN_JOIN_ALL_SPACES | IGNORES_CYCLE | FULL_SCREEN_AUXILIARY, level: STATUS_WINDOW_LEVEL, opaque: false, - has_shadow: true, + // The transparent WebView draws the card shadow itself. A native + // NSWindow shadow follows the full window bounds and creates a + // second, conspicuous outline around the floating surface. + has_shadow: false, hides_on_deactivate: false, released_when_closed: false, becomes_key_only_if_needed: true, @@ -276,43 +292,57 @@ impl OverlayController for MacOverlayWindow { } } -/// Applies the non-activating policy to an NSPanel. +/// Applies the non-activating policy to an NSWindow-compatible object. /// /// # Safety /// -/// `panel` must be a live NSPanel pointer and this function must execute on the -/// AppKit main thread. The function does not retain the panel. +/// `window` must be a live NSWindow-compatible pointer and this function must +/// execute on the AppKit main thread. The function does not retain the window. pub unsafe fn configure_nonactivating_panel( - panel: *mut c_void, + window: *mut c_void, policy: NonActivatingPanelPolicy, ) -> Result<(), &'static str> { - if panel.is_null() { - return Err("NSPanel pointer is null"); + if window.is_null() { + return Err("NSWindow pointer is null"); } - // SAFETY: caller guarantees a live NSPanel on the AppKit main thread; each - // selector has the exact ABI represented by the typed helper. + // Tauri supplies its own NSWindow subclass (`TaoWindow`) rather than an + // NSPanel. NSNonactivatingPanelMask and setBecomesKeyOnlyIfNeeded: are + // NSPanel-only policy and raise an Objective-C exception when sent to that + // object. Preserve the policy when a future native host really is an + // NSPanel, while configuring only shared NSWindow behavior today. + let supports_panel_only_policy = + unsafe { responds_to_selector(window, "setBecomesKeyOnlyIfNeeded:")? }; + let plan = native_configuration_plan(supports_panel_only_policy); + + // SAFETY: caller guarantees a live NSWindow-compatible object on the + // AppKit main thread; each selector has the exact ABI represented by the + // typed helper. unsafe { - send_usize(panel, "setStyleMask:", policy.style_mask)?; - send_usize(panel, "setCollectionBehavior:", policy.collection_behavior)?; - send_isize(panel, "setLevel:", policy.level)?; - send_bool(panel, "setOpaque:", objc_bool(policy.opaque))?; - send_bool(panel, "setHasShadow:", objc_bool(policy.has_shadow))?; + if plan.apply_nonactivating_panel_style { + send_usize(window, "setStyleMask:", policy.style_mask)?; + } + send_usize(window, "setCollectionBehavior:", policy.collection_behavior)?; + send_isize(window, "setLevel:", policy.level)?; + send_bool(window, "setOpaque:", objc_bool(policy.opaque))?; + send_bool(window, "setHasShadow:", objc_bool(policy.has_shadow))?; send_bool( - panel, + window, "setHidesOnDeactivate:", objc_bool(policy.hides_on_deactivate), )?; send_bool( - panel, + window, "setReleasedWhenClosed:", objc_bool(policy.released_when_closed), )?; - send_bool( - panel, - "setBecomesKeyOnlyIfNeeded:", - objc_bool(policy.becomes_key_only_if_needed), - )?; + if plan.set_becomes_key_only_if_needed { + send_bool( + window, + "setBecomesKeyOnlyIfNeeded:", + objc_bool(policy.becomes_key_only_if_needed), + )?; + } } Ok(()) } @@ -393,6 +423,15 @@ unsafe fn send_bool(object: Id, name: &str, value: ObjcBool) -> Result<(), &'sta Ok(()) } +unsafe fn responds_to_selector(object: Id, name: &str) -> Result { + let candidate = selector(name)?; + let responds_to_selector = selector("respondsToSelector:")?; + type Send = unsafe extern "C" fn(Id, Sel, Sel) -> ObjcBool; + // SAFETY: respondsToSelector: accepts one selector and returns Objective-C BOOL. + let send: Send = unsafe { mem::transmute(objc_msgSend as *const ()) }; + Ok(unsafe { send(object, responds_to_selector, candidate) } != NO) +} + unsafe fn send_no_args(object: Id, name: &str) -> Result<(), &'static str> { let selector = selector(name)?; type Send = unsafe extern "C" fn(Id, Sel); @@ -421,9 +460,10 @@ mod tests { }; use super::{ - logical_window_bounds, place_overlay_in_screen_points, point_is_inside_overlay, - MacOverlayWindow, MacScreenGeometry, NonActivatingPanelPolicy, OverlayCommand, - CAN_JOIN_ALL_SPACES, FULL_SCREEN_AUXILIARY, IGNORES_CYCLE, NONACTIVATING_PANEL_MASK, + logical_window_bounds, native_configuration_plan, place_overlay_in_screen_points, + point_is_inside_overlay, MacOverlayWindow, MacScreenGeometry, NonActivatingPanelPolicy, + OverlayCommand, CAN_JOIN_ALL_SPACES, FULL_SCREEN_AUXILIARY, IGNORES_CYCLE, + NONACTIVATING_PANEL_MASK, }; #[test] @@ -509,10 +549,19 @@ mod tests { assert_ne!(policy.collection_behavior & CAN_JOIN_ALL_SPACES, 0); assert_ne!(policy.collection_behavior & FULL_SCREEN_AUXILIARY, 0); assert_ne!(policy.collection_behavior & IGNORES_CYCLE, 0); + assert!(!policy.has_shadow); assert!(!policy.hides_on_deactivate); assert!(policy.becomes_key_only_if_needed); } + #[test] + fn tauri_nswindow_skips_ns_panel_only_configuration() { + let plan = native_configuration_plan(false); + + assert!(!plan.apply_nonactivating_panel_style); + assert!(!plan.set_becomes_key_only_if_needed); + } + #[tokio::test] async fn concrete_overlay_dispatches_correlated_state_and_hide() { let commands = Arc::new(Mutex::new(Vec::new())); diff --git a/src-tauri/src/tray.rs b/src-tauri/src/tray.rs index 7d26660..1654c56 100644 --- a/src-tauri/src/tray.rs +++ b/src-tauri/src/tray.rs @@ -160,10 +160,14 @@ pub fn install(app: &mut App) -> Result<(), Box> { } } SETTINGS_ID => { - let _ = show_settings(app); + if let Err(error) = show_settings(app) { + eprintln!("settings tray action failed: {}", error.message); + } } STUDY_ID => { - let _ = show_study(app); + if let Err(error) = show_study(app) { + eprintln!("vocabulary tray action failed: {}", error.message); + } } LOCALE_EN_ID => { let _ = set_locale(app, UiLocale::English); @@ -206,7 +210,9 @@ pub fn install(app: &mut App) -> Result<(), Box> { .. } = event { - let _ = crate::quick_translate::toggle(tray.app_handle(), position); + if let Err(error) = crate::quick_translate::toggle(tray.app_handle(), position) { + eprintln!("quick translation tray action failed: {}", error.message); + } } }); if let Some(icon) = tray_icon() { diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 70f3376..f399cd9 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Desktop Translator", - "version": "0.3.0", + "version": "0.4.0", "identifier": "com.desktoptranslator.desktop", "build": { "beforeDevCommand": "pnpm dev", diff --git a/src/styles/app.css b/src/styles/app.css index 2dac02b..1f87750 100644 --- a/src/styles/app.css +++ b/src/styles/app.css @@ -560,7 +560,7 @@ input:focus-visible, flex-direction: column; max-height: 100%; overflow: hidden; - border: 1px solid var(--line); + border: 1px solid color-mix(in srgb, var(--line) 52%, transparent); border-radius: var(--radius-lg); background: var(--surface); box-shadow: var(--shadow-panel); @@ -656,7 +656,7 @@ input:focus-visible, border: 1px solid var(--line); border-radius: var(--radius-lg); background: var(--surface); - box-shadow: var(--shadow-card); + box-shadow: var(--shadow-overlay); animation: card-enter 160ms ease-out both; } diff --git a/src/styles/tokens.css b/src/styles/tokens.css index 662afbe..13931dc 100644 --- a/src/styles/tokens.css +++ b/src/styles/tokens.css @@ -23,6 +23,7 @@ --warning-soft: #fff2d2; --focus: #078cbd; --shadow-card: 0 20px 50px rgb(15 35 31 / 16%), 0 2px 10px rgb(15 35 31 / 10%); + --shadow-overlay: 0 16px 36px rgb(15 35 31 / 11%), 0 2px 8px rgb(15 35 31 / 6%); --shadow-panel: 0 1px 2px rgb(15 35 31 / 5%), 0 8px 20px rgb(15 35 31 / 9%); --radius-sm: 9px; --radius-md: 14px; @@ -64,6 +65,7 @@ --warning-soft: #40351f; --focus: #61c9ef; --shadow-card: 0 22px 55px rgb(0 0 0 / 38%), 0 2px 12px rgb(0 0 0 / 28%); + --shadow-overlay: 0 18px 42px rgb(0 0 0 / 30%), 0 2px 8px rgb(0 0 0 / 20%); --shadow-trigger: 0 3px 10px rgb(0 0 0 / 45%), 0 1px 2px rgb(0 0 0 / 35%); --shadow-panel: 0 1px 2px rgb(0 0 0 / 30%), 0 8px 20px rgb(0 0 0 / 38%); --study-ink: #9be0ce; @@ -91,6 +93,7 @@ --warning-soft: #40351f; --focus: #61c9ef; --shadow-card: 0 22px 55px rgb(0 0 0 / 38%), 0 2px 12px rgb(0 0 0 / 28%); + --shadow-overlay: 0 18px 42px rgb(0 0 0 / 30%), 0 2px 8px rgb(0 0 0 / 20%); --shadow-trigger: 0 3px 10px rgb(0 0 0 / 45%), 0 1px 2px rgb(0 0 0 / 35%); --shadow-panel: 0 1px 2px rgb(0 0 0 / 30%), 0 8px 20px rgb(0 0 0 / 38%); --study-ink: #9be0ce; diff --git a/tools/release/audit-windows-bundle.test.ts b/tools/release/audit-windows-bundle.test.ts index f82b73d..3d64e4a 100644 --- a/tools/release/audit-windows-bundle.test.ts +++ b/tools/release/audit-windows-bundle.test.ts @@ -1,12 +1,14 @@ // @vitest-environment node import { spawnSync } from "node:child_process"; -import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; +import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; const script = join(dirname(fileURLToPath(import.meta.url)), "audit-windows-bundle.ps1"); +const repositoryRoot = join(dirname(fileURLToPath(import.meta.url)), "..", ".."); +const describeWindows = process.platform === "win32" ? describe : describe.skip; function runAudit(releaseDir: string) { return spawnSync( @@ -43,7 +45,7 @@ function makeReleaseLayout(options: { return root; } -describe("Windows bundle audit", () => { +describeWindows("Windows bundle audit", () => { it("fails when the NSIS x64 installer is missing", () => { const root = makeReleaseLayout({ setup: false }); try { @@ -99,3 +101,26 @@ describe("Windows bundle audit", () => { } }); }); + +describe("Windows workflow integration", () => { + it("runs the PowerShell audit tests on the Windows CI host", () => { + const workflow = readFileSync(join(repositoryRoot, ".github", "workflows", "ci.yml"), "utf8"); + const windowsJob = workflow.split("\n windows:")[1] ?? ""; + + expect(windowsJob).toContain("pnpm test"); + }); + + it("uploads the exact audited Windows artifacts after one NSIS build", () => { + const workflow = readFileSync( + join(repositoryRoot, ".github", "workflows", "release.yml"), + "utf8", + ); + const windowsJob = workflow.split("\n windows:")[1] ?? ""; + + expect(windowsJob.match(/pnpm tauri build --bundles nsis/g)).toHaveLength(1); + expect(windowsJob).not.toContain("tauri-apps/tauri-action"); + expect(windowsJob).toContain("gh release upload"); + expect(windowsJob).toContain("desktop-translator.exe"); + expect(windowsJob).toContain("windows-sha256.txt"); + }); +}); From 6c42269c766086b5f04d7d32f8fc642d50ca04d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E5=8D=87=E9=BE=99?= Date: Tue, 25 Aug 2026 10:15:50 +0800 Subject: [PATCH 7/7] fix(windows): embed manifest in test executables Apply the Common Controls v6 manifest to every Windows MSVC link target so cargo test reaches the Rust harness instead of exiting with STATUS_ENTRYPOINT_NOT_FOUND. Refs #4 --- src-tauri/build.rs | 24 ++++++++++++++++++++++++ src-tauri/src/lib.rs | 12 ++++++++++++ src-tauri/windows-app-manifest.xml | 14 ++++++++++++++ 3 files changed, 50 insertions(+) create mode 100644 src-tauri/windows-app-manifest.xml diff --git a/src-tauri/build.rs b/src-tauri/build.rs index d860e1e..dec5981 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -1,3 +1,27 @@ fn main() { + #[cfg(target_os = "windows")] + { + let attributes = tauri_build::Attributes::new() + .windows_attributes(tauri_build::WindowsAttributes::new_without_app_manifest()); + tauri_build::try_build(attributes).expect("failed to run Tauri build script"); + embed_windows_manifest_for_all_targets(); + } + + #[cfg(not(target_os = "windows"))] tauri_build::build() } + +/// Tauri's default resource arguments attach the Common Controls v6 manifest +/// only to the application binary. Unit-test executables also link dialog code, +/// so they must receive the same manifest or Windows exits before the Rust test +/// harness starts with STATUS_ENTRYPOINT_NOT_FOUND. +#[cfg(target_os = "windows")] +fn embed_windows_manifest_for_all_targets() { + let manifest = std::env::current_dir() + .expect("current source directory") + .join("windows-app-manifest.xml"); + println!("cargo:rerun-if-changed={}", manifest.display()); + println!("cargo:rustc-link-arg=/MANIFEST:EMBED"); + println!("cargo:rustc-link-arg=/MANIFESTINPUT:{}", manifest.display()); + println!("cargo:rustc-link-arg=/WX"); +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 7f46b6e..cec1a9a 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -351,4 +351,16 @@ mod tests { "debug builds should keep a console for logs" ); } + + #[test] + fn windows_tests_receive_the_common_controls_manifest() { + let build_script = include_str!("../build.rs"); + let manifest = include_str!("../windows-app-manifest.xml"); + + assert!(build_script.contains("new_without_app_manifest")); + assert!(build_script.contains("cargo:rustc-link-arg=/MANIFEST:EMBED")); + assert!(build_script.contains("cargo:rustc-link-arg=/MANIFESTINPUT:")); + assert!(manifest.contains("Microsoft.Windows.Common-Controls")); + assert!(manifest.contains("version=\"6.0.0.0\"")); + } } diff --git a/src-tauri/windows-app-manifest.xml b/src-tauri/windows-app-manifest.xml new file mode 100644 index 0000000..2d510ed --- /dev/null +++ b/src-tauri/windows-app-manifest.xml @@ -0,0 +1,14 @@ + + + + + + +