diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6f7453e3..78069eab 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,7 +23,9 @@ jobs: - os: macos-latest label: macOS preflight: false - - os: windows-latest + # Rust 1.78+ 生成的 Windows 测试运行器会使用 WaitOnAddress。 + # 固定 Server 2022,减少 windows-latest/Server 2025 的镜像变量。 + - os: windows-2022 label: Windows preflight: true - os: ubuntu-latest @@ -72,6 +74,21 @@ jobs: shell: pwsh run: ./scripts/windows-preflight.ps1 -Toolchain msvc + - name: Check MSVC developer command prompt + if: runner.os == 'Windows' + shell: pwsh + run: | + $vswhere = Join-Path ${env:ProgramFiles(x86)} "Microsoft Visual Studio\Installer\vswhere.exe" + $vsInstall = & $vswhere -latest -products * -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath + if (-not $vsInstall) { + throw "Visual Studio C++ toolchain not found" + } + $vsDevCmd = Join-Path $vsInstall "Common7\Tools\VsDevCmd.bat" + if (-not (Test-Path $vsDevCmd)) { + throw "VsDevCmd.bat not found: $vsDevCmd" + } + Write-Host "[ok] VsDevCmd.bat -> $vsDevCmd" + - name: Check PowerShell scripts if: matrix.preflight shell: pwsh @@ -89,18 +106,89 @@ jobs: run: npm run build - name: Check Tauri backend (cargo check) + if: runner.os != 'Windows' run: cargo check --manifest-path src-tauri/Cargo.toml + - name: Check Tauri backend (cargo check, Windows MSVC) + if: runner.os == 'Windows' + shell: pwsh + run: | + $vswhere = Join-Path ${env:ProgramFiles(x86)} "Microsoft Visual Studio\Installer\vswhere.exe" + $vsInstall = & $vswhere -latest -products * -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath + $vsDevCmd = Join-Path $vsInstall "Common7\Tools\VsDevCmd.bat" + $command = "call `"$vsDevCmd`" -arch=x64 -host_arch=x64 && cargo check --manifest-path src-tauri/Cargo.toml" + & cmd.exe /d /s /c $command + if ($LASTEXITCODE -ne 0) { + exit $LASTEXITCODE + } + - name: Run Rust backend unit tests if: runner.os != 'Windows' run: cargo test --manifest-path src-tauri/Cargo.toml --lib - - name: Compile Rust backend unit tests (Windows) - # Windows runner 能链接 lib test binary,但干净镜像缺少可选 native runtime - # DLL entrypoint 时,进程会在 test harness 启动前退出。这里保留 cfg/link - # 覆盖;共享单测在 macOS / Linux 上实际执行。 + - name: Run Rust backend unit tests (Windows) if: runner.os == 'Windows' - run: cargo test --manifest-path src-tauri/Cargo.toml --lib --no-run + shell: pwsh + run: | + # 产品默认构建仍通过 cargo check 覆盖 Foundry 链接形态,单测门禁 + # 用无 Foundry native 链接的构建来真正执行 Windows 后端测试。 + # stable test harness 在 runner 上会直接导入 + # api-ms-win-core-synch-l1-2-0.dll/WaitOnAddress 并在单测开始前 + # loader 失败。先 no-run 生成测试 exe,把该 import 指向更常规 + # 的 Kernel32.dll,再直接运行已修补的 harness,避免 cargo 重新链接。 + $env:CARGO_TARGET_DIR = Join-Path (Get-Location) "src-tauri\target\windows-unit-tests" + $vswhere = Join-Path ${env:ProgramFiles(x86)} "Microsoft Visual Studio\Installer\vswhere.exe" + $vsInstall = & $vswhere -latest -products * -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath + $vsDevCmd = Join-Path $vsInstall "Common7\Tools\VsDevCmd.bat" + $targetDir = $env:CARGO_TARGET_DIR + $buildCommand = "call `"$vsDevCmd`" -arch=x64 -host_arch=x64 && set `"CARGO_TARGET_DIR=$targetDir`" && cargo test --manifest-path src-tauri/Cargo.toml --lib --no-default-features --features custom-protocol --no-run" + & cmd.exe /d /s /c $buildCommand + if ($LASTEXITCODE -ne 0) { + exit $LASTEXITCODE + } + + $depsDir = Join-Path $env:CARGO_TARGET_DIR "debug\deps" + $needleText = "api-ms-win-core-synch-l1-2-0.dll" + [char]0 + $replacementText = "kernel32.dll" + [char]0 + $encoding = [System.Text.Encoding]::GetEncoding("iso-8859-1") + $needle = $encoding.GetBytes($needleText) + $replacement = New-Object byte[] $needle.Length + $replacementBytes = $encoding.GetBytes($replacementText) + [Array]::Copy($replacementBytes, $replacement, $replacementBytes.Length) + $patchedExes = @() + Get-ChildItem -Path $depsDir -Filter "openless_lib-*.exe" | ForEach-Object { + $bytes = [System.IO.File]::ReadAllBytes($_.FullName) + $text = $encoding.GetString($bytes) + $offset = $text.IndexOf($needleText, [System.StringComparison]::Ordinal) + $patchedThis = $false + while ($offset -ge 0) { + [Array]::Copy($replacement, 0, $bytes, $offset, $replacement.Length) + $patchedThis = $true + $offset = $text.IndexOf($needleText, $offset + $needle.Length, [System.StringComparison]::Ordinal) + } + if ($patchedThis) { + [System.IO.File]::WriteAllBytes($_.FullName, $bytes) + $patchedExes += $_.FullName + Write-Host "[ci] Patched Windows Rust test import to Kernel32.dll: $($_.FullName)" + } + } + if ($patchedExes.Count -eq 0) { + throw "api-ms-win-core-synch-l1-2-0.dll import not found in Windows Rust test executable." + } + + foreach ($testExe in $patchedExes) { + $patchedText = $encoding.GetString([System.IO.File]::ReadAllBytes($testExe)) + if ($patchedText.Contains($needleText)) { + throw "Windows Rust test executable still imports api-ms-win-core-synch-l1-2-0.dll: $testExe" + } + Write-Host "[ci] Running patched Windows Rust test executable: $testExe" + & $testExe + if ($LASTEXITCODE -ne 0) { + $unsignedExit = $LASTEXITCODE -band 0xffffffff + Write-Host ("[ci] Patched Windows Rust test executable failed with exit 0x{0:X8}" -f $unsignedExit) + exit $LASTEXITCODE + } + } - name: Verify version sync across all 5 files # 两个平台都跑这个校验:Windows runner 自带 git-bash,跨 shell 表现一致。 diff --git a/openless-all/app/src-tauri/Cargo.toml b/openless-all/app/src-tauri/Cargo.toml index eead6568..132ddecb 100644 --- a/openless-all/app/src-tauri/Cargo.toml +++ b/openless-all/app/src-tauri/Cargo.toml @@ -78,7 +78,7 @@ objc2-app-kit = "0.2" libc = "0.2" [target.'cfg(target_os = "windows")'.dependencies] -foundry-local-sdk = { version = "1.1.0", features = ["winml"] } +foundry-local-sdk = { version = "1.1.0", features = ["winml"], optional = true } raw-window-handle = "0.6" windows = { version = "0.58", features = [ "Win32_Foundation", @@ -103,5 +103,6 @@ winreg = "0.52" window-vibrancy = "0.7" [features] -default = ["custom-protocol"] +default = ["custom-protocol", "foundry-local-runtime"] custom-protocol = ["tauri/custom-protocol"] +foundry-local-runtime = ["dep:foundry-local-sdk"] diff --git a/openless-all/app/src-tauri/src/asr/local/foundry_runtime.rs b/openless-all/app/src-tauri/src/asr/local/foundry_runtime.rs index 7a762fcb..3f8676fb 100644 --- a/openless-all/app/src-tauri/src/asr/local/foundry_runtime.rs +++ b/openless-all/app/src-tauri/src/asr/local/foundry_runtime.rs @@ -1,4 +1,4 @@ -#[cfg(target_os = "windows")] +#[cfg(all(target_os = "windows", feature = "foundry-local-runtime"))] #[allow(dead_code)] mod imp { use std::path::{Path, PathBuf}; @@ -579,23 +579,27 @@ mod imp { } } -#[cfg(target_os = "windows")] +#[cfg(all(target_os = "windows", feature = "foundry-local-runtime"))] pub use imp::FoundryLocalRuntime; -#[cfg(not(target_os = "windows"))] -pub struct FoundryLocalRuntime; +#[cfg(any(not(target_os = "windows"), all(target_os = "windows", not(feature = "foundry-local-runtime"))))] +pub struct FoundryLocalRuntime { + cancel_prepare: std::sync::atomic::AtomicBool, +} -#[cfg(not(target_os = "windows"))] +#[cfg(any(not(target_os = "windows"), all(target_os = "windows", not(feature = "foundry-local-runtime"))))] impl Default for FoundryLocalRuntime { fn default() -> Self { Self::new() } } -#[cfg(not(target_os = "windows"))] +#[cfg(any(not(target_os = "windows"), all(target_os = "windows", not(feature = "foundry-local-runtime"))))] impl FoundryLocalRuntime { pub fn new() -> Self { - Self + Self { + cancel_prepare: std::sync::atomic::AtomicBool::new(false), + } } pub async fn status_snapshot( @@ -605,7 +609,7 @@ impl FoundryLocalRuntime { ) -> super::foundry::FoundryRuntimeStatus { let mut status = super::foundry::FoundryRuntimeStatus::unavailable( active_model.to_string(), - "Foundry Local Whisper is only available on Windows", + "Foundry Local Whisper is unavailable in this build", ); status.runtime_source = super::foundry_native::normalize_runtime_source_str(runtime_source); status @@ -616,7 +620,7 @@ impl FoundryLocalRuntime { alias: &str, _runtime_source: &str, ) -> anyhow::Result { - anyhow::bail!("Foundry Local Whisper is only available on Windows: {alias}"); + anyhow::bail!("Foundry Local Whisper is unavailable in this build: {alias}"); } pub async fn ensure_loaded_with_progress( @@ -628,10 +632,19 @@ impl FoundryLocalRuntime { where F: Fn(super::foundry::FoundryPrepareProgressPayload) + Send + Sync + 'static, { - anyhow::bail!("Foundry Local Whisper is only available on Windows: {alias}"); + anyhow::bail!("Foundry Local Whisper is unavailable in this build: {alias}"); + } + + pub fn request_cancel_prepare(&self) { + self.cancel_prepare + .store(true, std::sync::atomic::Ordering::SeqCst); } - pub fn request_cancel_prepare(&self) {} + #[cfg(test)] + pub(crate) fn cancel_prepare_requested_for_tests(&self) -> bool { + self.cancel_prepare + .load(std::sync::atomic::Ordering::SeqCst) + } pub async fn catalog_snapshot( &self, @@ -647,7 +660,7 @@ impl FoundryLocalRuntime { _audio_path: &std::path::Path, _audio_timeout: std::time::Duration, ) -> anyhow::Result { - anyhow::bail!("Foundry Local Whisper is only available on Windows: {alias}"); + anyhow::bail!("Foundry Local Whisper is unavailable in this build: {alias}"); } pub async fn release_now(&self) -> anyhow::Result<()> {