diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index e8899bb7..d769e900 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -34,7 +34,7 @@ jobs:
- name: Check PowerShell scripts
shell: pwsh
run: |
- foreach ($script in @("./scripts/windows-preflight.ps1", "./scripts/windows-build-gnu.ps1")) {
+ foreach ($script in @("./scripts/windows-preflight.ps1", "./scripts/windows-build-gnu.ps1", "./scripts/windows-runtime-smoke.ps1")) {
$errors = $null
[System.Management.Automation.PSParser]::Tokenize((Get-Content -Raw $script), [ref]$errors) | Out-Null
if ($errors) {
diff --git a/openless-all/app/.gitignore b/openless-all/app/.gitignore
index 112f301d..625e21f7 100644
--- a/openless-all/app/.gitignore
+++ b/openless-all/app/.gitignore
@@ -4,6 +4,7 @@ dist/
*.local
.env
.vite/
+.artifacts/
# Tauri
src-tauri/target/
diff --git a/openless-all/app/scripts/windows-build-gnu.ps1 b/openless-all/app/scripts/windows-build-gnu.ps1
index e9af3657..2d875d96 100644
--- a/openless-all/app/scripts/windows-build-gnu.ps1
+++ b/openless-all/app/scripts/windows-build-gnu.ps1
@@ -1,39 +1,86 @@
param(
- [string]$MirrorRoot = "$env:TEMP\openless-windows-gnu"
+ [string]$MirrorRoot = "$env:TEMP\openless-windows-gnu",
+ [string]$ArtifactsRoot = "",
+ [switch]$KeepMirror
)
$ErrorActionPreference = "Stop"
$appRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
$buildRoot = $appRoot
+$usedMirror = $false
+if ([string]::IsNullOrWhiteSpace($ArtifactsRoot)) {
+ $ArtifactsRoot = Join-Path $appRoot ".artifacts\windows-gnu"
+}
if ($appRoot -match "\s") {
Write-Host "[info] App path contains spaces: $appRoot"
- Write-Host "[info] Mirroring to no-space build root: $MirrorRoot"
+ Write-Host "[info] Mirroring to no-space scratch build root: $MirrorRoot"
New-Item -ItemType Directory -Force -Path $MirrorRoot | Out-Null
- robocopy $appRoot $MirrorRoot /MIR /XD "$appRoot\node_modules" "$appRoot\dist" "$appRoot\src-tauri\target" "$MirrorRoot\node_modules" "$MirrorRoot\dist" "$MirrorRoot\src-tauri\target" | Out-Host
+ robocopy $appRoot $MirrorRoot /MIR /XD "$appRoot\.artifacts" "$appRoot\node_modules" "$appRoot\dist" "$appRoot\src-tauri\target" "$MirrorRoot\.artifacts" "$MirrorRoot\node_modules" "$MirrorRoot\dist" "$MirrorRoot\src-tauri\target" | Out-Host
if ($LASTEXITCODE -gt 7) {
throw "robocopy failed with exit code $LASTEXITCODE"
}
$buildRoot = (Resolve-Path $MirrorRoot).Path
+ $usedMirror = $true
}
$env:PATH = "$env:USERPROFILE\.cargo\bin;$env:USERPROFILE\scoop\persist\rustup\.cargo\bin;$env:USERPROFILE\scoop\apps\rustup\current\.cargo\bin;$env:USERPROFILE\scoop\apps\mingw\current\bin;$env:PATH"
$env:RUSTUP_TOOLCHAIN = "stable-x86_64-pc-windows-gnu"
$env:CARGO_BUILD_TARGET = "x86_64-pc-windows-gnu"
+function Resolve-WebView2Loader {
+ $cargoHome = if ($env:CARGO_HOME) { $env:CARGO_HOME } else { Join-Path $env:USERPROFILE ".cargo" }
+ $registrySrc = Join-Path $cargoHome "registry\src"
+ $loader = Get-ChildItem -Path $registrySrc -Recurse -Filter WebView2Loader.dll -ErrorAction SilentlyContinue |
+ Where-Object { $_.FullName -match "\\x64\\WebView2Loader\.dll$" } |
+ Select-Object -First 1
+ if ($null -eq $loader) {
+ throw "WebView2Loader.dll x64 not found under $registrySrc"
+ }
+ return $loader.FullName
+}
+
Push-Location $buildRoot
try {
if (-not (Test-Path "node_modules")) {
npm ci
}
- npm run tauri build -- --target x86_64-pc-windows-gnu
+ npm run tauri build -- --target x86_64-pc-windows-gnu --no-bundle
+ $releaseRoot = Join-Path $buildRoot "src-tauri\target\x86_64-pc-windows-gnu\release"
+ $artifactDevRoot = Join-Path $ArtifactsRoot "dev"
+ New-Item -ItemType Directory -Force -Path $artifactDevRoot | Out-Null
+ Copy-Item -LiteralPath (Join-Path $releaseRoot "openless.exe") -Destination (Join-Path $artifactDevRoot "openless.exe") -Force
+ Copy-Item -LiteralPath (Resolve-WebView2Loader) -Destination (Join-Path $artifactDevRoot "WebView2Loader.dll") -Force
+
+ npm run tauri build -- --target x86_64-pc-windows-gnu --bundles msi nsis
} finally {
Pop-Location
}
+$releaseRoot = Join-Path $buildRoot "src-tauri\target\x86_64-pc-windows-gnu\release"
+$artifactReleaseRoot = Join-Path $ArtifactsRoot "release"
+New-Item -ItemType Directory -Force -Path $artifactReleaseRoot | Out-Null
+Remove-Item -LiteralPath (Join-Path $artifactReleaseRoot "openless.exe") -Force -ErrorAction SilentlyContinue
+
+if (Test-Path (Join-Path $releaseRoot "bundle")) {
+ Copy-Item -LiteralPath (Join-Path $releaseRoot "bundle") -Destination $artifactReleaseRoot -Recurse -Force
+}
+
+if ($usedMirror -and (-not $KeepMirror)) {
+ $resolvedMirror = (Resolve-Path $MirrorRoot).Path
+ $resolvedTemp = (Resolve-Path $env:TEMP).Path
+ if ($resolvedMirror.StartsWith($resolvedTemp, [System.StringComparison]::OrdinalIgnoreCase) -and
+ ((Split-Path $resolvedMirror -Leaf) -eq "openless-windows-gnu")) {
+ Write-Host "[info] Removing scratch build root: $resolvedMirror"
+ Remove-Item -LiteralPath $resolvedMirror -Recurse -Force
+ } else {
+ Write-Warning "Refusing to remove unexpected mirror path: $resolvedMirror"
+ }
+}
+
Write-Host ""
Write-Host "Windows GNU artifacts:"
-Write-Host "$buildRoot\src-tauri\target\x86_64-pc-windows-gnu\release\openless.exe"
-Write-Host "$buildRoot\src-tauri\target\x86_64-pc-windows-gnu\release\bundle\msi"
-Write-Host "$buildRoot\src-tauri\target\x86_64-pc-windows-gnu\release\bundle\nsis"
+Write-Host "$ArtifactsRoot\dev\openless.exe"
+Write-Host "$artifactReleaseRoot\bundle\msi"
+Write-Host "$artifactReleaseRoot\bundle\nsis"
diff --git a/openless-all/app/scripts/windows-open-dev.ps1 b/openless-all/app/scripts/windows-open-dev.ps1
new file mode 100644
index 00000000..f25bb8e7
--- /dev/null
+++ b/openless-all/app/scripts/windows-open-dev.ps1
@@ -0,0 +1,77 @@
+param(
+ [string]$ExePath = ""
+)
+
+$ErrorActionPreference = "Stop"
+
+if ([string]::IsNullOrWhiteSpace($ExePath)) {
+ $appRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
+ $ExePath = Join-Path $appRoot ".artifacts\windows-gnu\dev\openless.exe"
+}
+
+Add-Type @"
+using System;
+using System.Runtime.InteropServices;
+
+public static class OpenLessWindow {
+ [DllImport("user32.dll")]
+ public static extern bool ShowWindow(IntPtr hWnd, int nCmdShow);
+
+ [DllImport("user32.dll")]
+ public static extern bool SetForegroundWindow(IntPtr hWnd);
+}
+"@
+
+function Show-OpenLessWindow($Process) {
+ if ($null -eq $Process -or $Process.MainWindowHandle -eq 0) {
+ return $false
+ }
+
+ # 9 = SW_RESTORE. This restores minimized windows and leaves normal windows visible.
+ [OpenLessWindow]::ShowWindow($Process.MainWindowHandle, 9) | Out-Null
+ [OpenLessWindow]::SetForegroundWindow($Process.MainWindowHandle) | Out-Null
+ return $true
+}
+
+$running = Get-Process openless -ErrorAction SilentlyContinue |
+ Where-Object { $_.MainWindowHandle -ne 0 } |
+ Select-Object -First 1
+
+if (Show-OpenLessWindow $running) {
+ Write-Host "OpenLess is already running; brought window to foreground. pid=$($running.Id)"
+ exit 0
+}
+
+if (-not (Test-Path $ExePath)) {
+ throw "OpenLess executable not found: $ExePath. Run scripts/windows-build-gnu.ps1 first."
+}
+
+if (-not (Test-Path (Join-Path (Split-Path $ExePath -Parent) "WebView2Loader.dll"))) {
+ throw "WebView2Loader.dll not found beside $ExePath. Run scripts/windows-build-gnu.ps1 again."
+}
+
+if (-not $env:SystemDrive) {
+ $env:SystemDrive = "C:"
+}
+if (-not $env:ProgramData) {
+ $env:ProgramData = Join-Path $env:SystemDrive "ProgramData"
+}
+$env:PATH = "$env:USERPROFILE\.cargo\bin;$env:USERPROFILE\scoop\persist\rustup\.cargo\bin;$env:USERPROFILE\scoop\apps\rustup\current\.cargo\bin;$env:USERPROFILE\scoop\apps\mingw\current\bin;$env:PATH"
+$env:OPENLESS_SHOW_MAIN_ON_START = "1"
+try {
+ $process = Start-Process -FilePath $ExePath -WorkingDirectory (Split-Path $ExePath -Parent) -PassThru
+} finally {
+ Remove-Item Env:OPENLESS_SHOW_MAIN_ON_START -ErrorAction SilentlyContinue
+}
+$deadline = (Get-Date).AddSeconds(10)
+
+while ((Get-Date) -lt $deadline) {
+ Start-Sleep -Milliseconds 250
+ $current = Get-Process -Id $process.Id -ErrorAction SilentlyContinue
+ if (Show-OpenLessWindow $current) {
+ Write-Host "OpenLess started and brought to foreground. pid=$($current.Id)"
+ exit 0
+ }
+}
+
+throw "OpenLess started but no main window was visible within 10 seconds. pid=$($process.Id)"
diff --git a/openless-all/app/scripts/windows-runtime-smoke.ps1 b/openless-all/app/scripts/windows-runtime-smoke.ps1
new file mode 100644
index 00000000..237b153b
--- /dev/null
+++ b/openless-all/app/scripts/windows-runtime-smoke.ps1
@@ -0,0 +1,108 @@
+param(
+ [string]$ExePath = "",
+ [int]$StartupTimeoutSeconds = 12,
+ [switch]$RequireCredentials
+)
+
+$ErrorActionPreference = "Stop"
+
+if ([string]::IsNullOrWhiteSpace($ExePath)) {
+ $appRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
+ $ExePath = Join-Path $appRoot ".artifacts\windows-gnu\dev\openless.exe"
+}
+
+if (-not $env:SystemDrive) {
+ $env:SystemDrive = "C:"
+}
+if (-not $env:ProgramData) {
+ $env:ProgramData = Join-Path $env:SystemDrive "ProgramData"
+}
+
+function Test-CredentialValue($Value) {
+ return ($null -ne $Value) -and ($Value -is [string]) -and ($Value.Trim().Length -gt 0)
+}
+
+function Get-OpenLessCredentialStatus {
+ $path = Join-Path $env:APPDATA "OpenLess\credentials.json"
+ if (-not (Test-Path $path)) {
+ return [pscustomobject]@{
+ Path = $path
+ VolcengineConfigured = $false
+ ArkConfigured = $false
+ Present = $false
+ }
+ }
+
+ $json = Get-Content -Raw $path | ConvertFrom-Json
+ $asr = $json.providers.asr.volcengine
+ $llm = $json.providers.llm.ark
+ [pscustomobject]@{
+ Path = $path
+ Present = $true
+ VolcengineConfigured = (Test-CredentialValue $asr.appKey) -and (Test-CredentialValue $asr.accessKey)
+ ArkConfigured = Test-CredentialValue $llm.apiKey
+ }
+}
+
+function Wait-LogPattern($Path, $Pattern, $TimeoutSeconds) {
+ $deadline = (Get-Date).AddSeconds($TimeoutSeconds)
+ while ((Get-Date) -lt $deadline) {
+ if ((Test-Path $Path) -and ((Get-Content -Raw $Path) -match $Pattern)) {
+ return $true
+ }
+ Start-Sleep -Milliseconds 500
+ }
+ return $false
+}
+
+if (-not (Test-Path $ExePath)) {
+ throw "OpenLess executable not found: $ExePath"
+}
+
+$logPath = Join-Path $env:LOCALAPPDATA "OpenLess\Logs\openless.log"
+$credentialStatus = Get-OpenLessCredentialStatus
+
+Write-Host "== Credential status =="
+$credentialStatus | Format-List
+if (-not $credentialStatus.VolcengineConfigured) {
+ Write-Host "[warn] Volcengine ASR credentials are not configured; real transcription cannot be completed."
+}
+if (-not $credentialStatus.ArkConfigured) {
+ Write-Host "[warn] Ark LLM credentials are not configured; polishing will fall back or fail depending on mode."
+}
+if ($RequireCredentials -and (-not $credentialStatus.VolcengineConfigured -or -not $credentialStatus.ArkConfigured)) {
+ throw "Real regression requires configured Volcengine ASR and Ark LLM credentials."
+}
+
+Write-Host ""
+Write-Host "== Launch smoke =="
+$process = Start-Process -FilePath $ExePath -PassThru
+try {
+ Start-Sleep -Seconds 4
+ $live = Get-Process -Id $process.Id -ErrorAction SilentlyContinue
+ if (-not $live) {
+ throw "OpenLess exited during startup."
+ }
+ if (-not $live.Responding) {
+ throw "OpenLess process is not responding."
+ }
+ Write-Host "[ok] Process responding: id=$($live.Id), title='$($live.MainWindowTitle)'"
+
+ if (Wait-LogPattern $logPath "hotkey listener installed" $StartupTimeoutSeconds) {
+ Write-Host "[ok] Hotkey listener installed according to log."
+ } else {
+ throw "Hotkey listener did not report installed within $StartupTimeoutSeconds seconds."
+ }
+
+ Write-Host ""
+ Write-Host "Manual checks still required:"
+ Write-Host "- Press the configured physical global hotkey to start/stop recording."
+ Write-Host "- Speak a short phrase with valid ASR credentials configured."
+ Write-Host "- Focus Notepad or another text field and verify Windows insert status falls back to copied/Ctrl+V when insertion cannot be confirmed."
+ Write-Host "- Toggle Windows microphone privacy off/on and rerun Settings -> Permissions."
+} finally {
+ Get-Process openless -ErrorAction SilentlyContinue | Stop-Process -Force
+}
+
+Write-Host ""
+Write-Host "Runtime smoke passed."
diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs
index 915050d8..d441cb4d 100644
--- a/openless-all/app/src-tauri/src/lib.rs
+++ b/openless-all/app/src-tauri/src/lib.rs
@@ -76,8 +76,6 @@ pub fn run() {
}
}
- show_main_window(app.handle());
-
// 启动时主动弹 Accessibility 授权框(与 Swift `AppDelegate` 行为一致)。
// 用户首次必看到系统提示;已授权则静默返回。
#[cfg(target_os = "macos")]
@@ -94,31 +92,38 @@ pub fn run() {
// 与 Swift `StatusBarIcon.swift` 行为一致:用全彩 AppIcon,**不**走 template 模式
// (走 template 会被 macOS 染成单色 → 看起来像个黑方块)。
- let _tray = TrayIconBuilder::with_id("main-tray")
- .icon(app.default_window_icon().unwrap().clone())
- .icon_as_template(false)
- .menu(&menu)
- .show_menu_on_left_click(false)
- .on_menu_event(|app, event| match event.id.as_ref() {
- "toggle" => show_main_window(app),
- "quit" => app.exit(0),
- _ => {}
- })
- .on_tray_icon_event(|tray, event| {
- if let TrayIconEvent::Click {
- button: MouseButton::Left,
- ..
- } = event
- {
- show_main_window(tray.app_handle());
- }
- })
- .build(app)?;
+ if let Some(icon) = app.default_window_icon() {
+ let _tray = TrayIconBuilder::with_id("main-tray")
+ .icon(icon.clone())
+ .icon_as_template(false)
+ .menu(&menu)
+ .show_menu_on_left_click(false)
+ .on_menu_event(|app, event| match event.id.as_ref() {
+ "toggle" => show_main_window(app),
+ "quit" => app.exit(0),
+ _ => {}
+ })
+ .on_tray_icon_event(|tray, event| {
+ if let TrayIconEvent::Click {
+ button: MouseButton::Left,
+ ..
+ } = event
+ {
+ show_main_window(tray.app_handle());
+ }
+ })
+ .build(app)?;
+ } else {
+ log::warn!("[startup] default window icon missing; tray icon disabled");
+ }
// Spin up hotkey listener; coordinator owns the lifecycle.
let app_handle = app.handle().clone();
coordinator.bind_app(app_handle);
coordinator.start_hotkey_listener();
+ if std::env::var("OPENLESS_SHOW_MAIN_ON_START").ok().as_deref() == Some("1") {
+ show_main_window(app.handle());
+ }
Ok(())
})
diff --git a/openless-all/app/src/App.tsx b/openless-all/app/src/App.tsx
index e4550a7d..f0a22e59 100644
--- a/openless-all/app/src/App.tsx
+++ b/openless-all/app/src/App.tsx
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
import { Capsule } from './components/Capsule';
import { FloatingShell } from './components/FloatingShell';
import { Onboarding } from './components/Onboarding';
+import { detectOS } from './components/WindowChrome';
import { checkAccessibilityPermission, checkMicrophonePermission, isTauri } from './lib/ipc';
import { HotkeySettingsProvider } from './state/HotkeySettingsContext';
@@ -16,12 +17,27 @@ export function App({ isCapsule }: AppProps) {
return ;
}
- // 浏览器 dev 时跳过权限检查;只有真正在 Tauri 里才门控。
- const [gate, setGate] = useState(isTauri ? 'checking' : 'ready');
+ const os = detectOS();
+ // Windows 启动不应被权限探测阻塞首屏。
+ const [gate, setGate] = useState(isTauri && os !== 'win' ? 'checking' : 'ready');
useEffect(() => {
if (!isTauri) return;
let cancelled = false;
+ requestAnimationFrame(() => {
+ if (cancelled) return;
+ import('@tauri-apps/api/window')
+ .then(({ getCurrentWindow }) => getCurrentWindow().show())
+ .catch(error => console.warn('[startup] show main window failed', error));
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, []);
+
+ useEffect(() => {
+ if (!isTauri || os === 'win') return;
+ let cancelled = false;
(async () => {
const [a, m] = await Promise.all([
checkAccessibilityPermission(),
@@ -35,10 +51,10 @@ export function App({ isCapsule }: AppProps) {
return () => {
cancelled = true;
};
- }, []);
+ }, [os]);
if (gate === 'checking') {
- return null;
+ return ;
}
return (
@@ -46,3 +62,23 @@ export function App({ isCapsule }: AppProps) {
);
}
+
+function StartupShell() {
+ return (
+
+
+

+
OpenLess 正在启动
+
+
+ );
+}