From 31e3f541d13dd2afd8716d76696a9a3fa779d2dd Mon Sep 17 00:00:00 2001 From: Cooper-X-Oak Date: Sat, 2 May 2026 03:29:49 +0800 Subject: [PATCH] fix(windows): gate main window visibility on startup readiness --- .../app/scripts/windows-cold-start.ps1 | 142 ++++++++++++++++++ ...indows-startup-lifecycle-contract.test.mjs | 39 +++++ openless-all/app/src-tauri/tauri.conf.json | 2 +- openless-all/app/src/App.tsx | 39 ++++- 4 files changed, 217 insertions(+), 5 deletions(-) create mode 100644 openless-all/app/scripts/windows-cold-start.ps1 create mode 100644 openless-all/app/scripts/windows-startup-lifecycle-contract.test.mjs diff --git a/openless-all/app/scripts/windows-cold-start.ps1 b/openless-all/app/scripts/windows-cold-start.ps1 new file mode 100644 index 00000000..82cd2f75 --- /dev/null +++ b/openless-all/app/scripts/windows-cold-start.ps1 @@ -0,0 +1,142 @@ +param( + [string]$ExePath = "", + [switch]$FreshBuild, + [switch]$PreferDebug, + [switch]$ShowMain, + [switch]$KeepLogs, + [switch]$ForceImmediateShow +) + +$ErrorActionPreference = "Stop" + +$appRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path +$artifactExe = Join-Path $appRoot ".artifacts\windows-gnu\dev\openless.exe" +$debugExe = Join-Path $appRoot "src-tauri\target\debug\openless.exe" + +function Resolve-DefaultExePath { + param( + [string]$ArtifactExe, + [string]$DebugExe, + [switch]$PreferDebug + ) + + $artifactItem = if (Test-Path $ArtifactExe) { Get-Item $ArtifactExe } else { $null } + $debugItem = if (Test-Path $DebugExe) { Get-Item $DebugExe } else { $null } + + if ($PreferDebug -and $debugItem) { + return $debugItem.FullName + } + if ($debugItem -and (-not $artifactItem -or $debugItem.LastWriteTime -gt $artifactItem.LastWriteTime)) { + return $debugItem.FullName + } + if ($artifactItem) { + return $artifactItem.FullName + } + if ($debugItem) { + return $debugItem.FullName + } + return $ArtifactExe +} + +if ($FreshBuild) { + Push-Location $appRoot + try { + Write-Host "Building frontend dist..." + npm run build + Write-Host "Building backend debug exe..." + cargo build --manifest-path src-tauri/Cargo.toml + } finally { + Pop-Location + } +} + +if ([string]::IsNullOrWhiteSpace($ExePath)) { + $ExePath = Resolve-DefaultExePath -ArtifactExe $artifactExe -DebugExe $debugExe -PreferDebug:$PreferDebug +} + +if (-not (Test-Path $ExePath)) { + throw "OpenLess executable not found: $ExePath" +} + +if (-not $env:SystemDrive) { + $env:SystemDrive = "C:" +} +if (-not $env:ProgramData) { + $env:ProgramData = Join-Path $env:SystemDrive "ProgramData" +} + +$logPath = Join-Path $env:LOCALAPPDATA "OpenLess\Logs\openless.log" +$workingDirectory = Split-Path $ExePath -Parent + +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 + } + + [OpenLessWindow]::ShowWindow($Process.MainWindowHandle, 9) | Out-Null + [OpenLessWindow]::SetForegroundWindow($Process.MainWindowHandle) | Out-Null + return $true +} + +Write-Host "== Windows cold start ==" +Write-Host "ExePath: $ExePath" + +$running = Get-Process openless -ErrorAction SilentlyContinue +if ($running) { + Write-Host "Stopping existing OpenLess processes..." + $running | Stop-Process -Force + Start-Sleep -Milliseconds 600 +} + +if (-not $KeepLogs -and (Test-Path $logPath)) { + Remove-Item -LiteralPath $logPath -Force -ErrorAction SilentlyContinue + Write-Host "Cleared log: $logPath" +} + +$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" +$useImmediateShow = $ShowMain -and $ForceImmediateShow +if ($useImmediateShow) { + $env:OPENLESS_SHOW_MAIN_ON_START = "1" +} + +try { + $process = Start-Process -FilePath $ExePath -WorkingDirectory $workingDirectory -PassThru +} finally { + if ($useImmediateShow) { + Remove-Item Env:OPENLESS_SHOW_MAIN_ON_START -ErrorAction SilentlyContinue + } +} + +Write-Host "Started OpenLess cold. pid=$($process.Id)" +Write-Host "Log path: $logPath" +if ($ShowMain) { + if ($ForceImmediateShow) { + Write-Host "Mode: backend immediate show (debug-only, may expose startup shell)" + } else { + Write-Host "Mode: frontend-managed first show (recommended for startup contract testing)" + $deadline = (Get-Date).AddSeconds(15) + while ((Get-Date) -lt $deadline) { + Start-Sleep -Milliseconds 250 + $current = Get-Process -Id $process.Id -ErrorAction SilentlyContinue + if (Show-OpenLessWindow $current) { + Write-Host "OpenLess main window became visible and was brought to foreground. pid=$($current.Id)" + break + } + } + } +} else { + Write-Host "Mode: startup-default visibility" +} diff --git a/openless-all/app/scripts/windows-startup-lifecycle-contract.test.mjs b/openless-all/app/scripts/windows-startup-lifecycle-contract.test.mjs new file mode 100644 index 00000000..e4080749 --- /dev/null +++ b/openless-all/app/scripts/windows-startup-lifecycle-contract.test.mjs @@ -0,0 +1,39 @@ +import { readFile } from 'node:fs/promises'; + +function assertEqual(actual, expected, name) { + if (actual !== expected) { + throw new Error(`${name}: expected ${expected}, got ${actual}`); + } +} + +function assertMatch(source, pattern, name) { + if (!pattern.test(source)) { + throw new Error(`${name}: pattern ${pattern} not found`); + } +} + +const raw = await readFile(new URL('../src-tauri/tauri.conf.json', import.meta.url), 'utf-8'); +const config = JSON.parse(raw); +const mainWindow = config.app.windows.find(window => window.label === 'main'); +const appTsx = await readFile(new URL('../src/App.tsx', import.meta.url), 'utf-8'); + +if (!mainWindow) { + throw new Error('main window config missing'); +} + +assertEqual(mainWindow.visible, false, 'main window should stay hidden until startup contract allows first show'); +assertMatch( + appTsx, + /const \[gate, setGate\] = useState\(isTauri \? 'checking' : 'ready'\);/, + 'desktop app should start in checking gate before claiming ready', +); +assertMatch( + appTsx, + /if \(os === 'win' && gate === 'checking'\) return;/, + 'windows should not show the main shell while startup gate is still checking', +); +assertMatch( + appTsx, + /const pollHotkeyStatus = async \(\) => \{[\s\S]*?if \(status\.state !== 'starting'\) \{[\s\S]*?setGate\('ready'\);/m, + 'windows startup should wait for hotkey status to leave the starting phase before entering ready', +); diff --git a/openless-all/app/src-tauri/tauri.conf.json b/openless-all/app/src-tauri/tauri.conf.json index 8dd3aa78..da9e0a36 100644 --- a/openless-all/app/src-tauri/tauri.conf.json +++ b/openless-all/app/src-tauri/tauri.conf.json @@ -26,7 +26,7 @@ "shadow": true, "hiddenTitle": true, "titleBarStyle": "Overlay", - "visible": true, + "visible": false, "acceptFirstMouse": true }, { diff --git a/openless-all/app/src/App.tsx b/openless-all/app/src/App.tsx index 01792c0a..1aa7cea6 100644 --- a/openless-all/app/src/App.tsx +++ b/openless-all/app/src/App.tsx @@ -6,6 +6,7 @@ import { detectOS } from './components/WindowChrome'; import { checkAccessibilityPermission, checkMicrophonePermission, + getHotkeyStatus, handleWindowHotkeyEvent, isTauri, } from './lib/ipc'; @@ -29,25 +30,55 @@ export function App({ isCapsule, isQa }: AppProps) { const os = detectOS(); // Windows 启动不应被权限探测阻塞首屏。 - const [gate, setGate] = useState(isTauri && os !== 'win' ? 'checking' : 'ready'); + const [gate, setGate] = useState(isTauri ? 'checking' : 'ready'); useEffect(() => { if (!isTauri) return; + if (os === 'win' && gate === 'checking') return; let cancelled = false; requestAnimationFrame(() => { if (cancelled) return; import('@tauri-apps/api/window') - .then(({ getCurrentWindow }) => getCurrentWindow().show()) + .then(async ({ getCurrentWindow }) => { + const currentWindow = getCurrentWindow(); + if (!(await currentWindow.isVisible())) { + await currentWindow.show(); + } + }) .catch(error => console.warn('[startup] show main window failed', error)); }); return () => { cancelled = true; }; - }, []); + }, [gate, os]); useEffect(() => { - if (!isTauri || os === 'win') return; + if (!isTauri) return; let cancelled = false; + + if (os === 'win') { + const pollHotkeyStatus = async () => { + while (!cancelled) { + const status = await getHotkeyStatus(); + if (cancelled) return; + if (status.state !== 'starting') { + setGate('ready'); + return; + } + await new Promise(resolve => window.setTimeout(resolve, 200)); + } + }; + void pollHotkeyStatus().catch(error => { + console.warn('[startup] hotkey status polling failed', error); + if (!cancelled) { + setGate('ready'); + } + }); + return () => { + cancelled = true; + }; + } + (async () => { const [a, m] = await Promise.all([ checkAccessibilityPermission(),