Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
1 change: 1 addition & 0 deletions openless-all/app/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ dist/
*.local
.env
.vite/
.artifacts/

# Tauri
src-tauri/target/
Expand Down
61 changes: 54 additions & 7 deletions openless-all/app/scripts/windows-build-gnu.ps1
Original file line number Diff line number Diff line change
@@ -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"
77 changes: 77 additions & 0 deletions openless-all/app/scripts/windows-open-dev.ps1
Original file line number Diff line number Diff line change
@@ -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)"
108 changes: 108 additions & 0 deletions openless-all/app/scripts/windows-runtime-smoke.ps1
Original file line number Diff line number Diff line change
@@ -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
}
Comment on lines +47 to +52
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: Log polling should tolerate transient read errors on the log file

Here Get-Content -Raw $Path can intermittently fail (e.g. sharing violations during log rotation or exclusive locks), which will terminate the whole smoke script instead of allowing another poll.

Consider treating these as transient by adding -ErrorAction SilentlyContinue or wrapping the read in try/catch and treating failures like a non-match so the loop continues until timeout.

Suggested change
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
}
function Wait-LogPattern($Path, $Pattern, $TimeoutSeconds) {
$deadline = (Get-Date).AddSeconds($TimeoutSeconds)
while ((Get-Date) -lt $deadline) {
if (Test-Path $Path) {
try {
$content = Get-Content -Raw -ErrorAction Stop $Path
if ($content -match $Pattern) {
return $true
}
} catch {
# Treat transient read errors (e.g. sharing violations) as non-matches
# and allow the loop to continue until the timeout is reached.
}
}

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
Comment on lines +103 to +104
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (bug_risk): Stopping all openless processes on the machine may kill unrelated user sessions

The teardown logic appears to call Get-Process openless | Stop-Process -Force, which would kill every openless.exe on the machine, including existing user sessions. Consider tracking and stopping only the process you launch (e.g. via $process), and treating other running instances more gently (e.g. warn instead of force-killing) to avoid side effects.

}

Write-Host ""
Write-Host "Runtime smoke passed."
49 changes: 27 additions & 22 deletions openless-all/app/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -76,8 +76,6 @@ pub fn run() {
}
}

show_main_window(app.handle());

// 启动时主动弹 Accessibility 授权框(与 Swift `AppDelegate` 行为一致)。
// 用户首次必看到系统提示;已授权则静默返回。
#[cfg(target_os = "macos")]
Expand All @@ -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(())
})
Expand Down
Loading
Loading