fix(windows): gate main window visibility on startup readiness

This commit is contained in:
Cooper-X-Oak committed 2026-05-02 03:29:49 +08:00
1 parent 20d67da5c4
commit 31e3f541d1
4 files changed
+217 -5

No files matched your search

@@ -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"
}
@@ -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<Gate>\(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',
);
+1 -1
View File
@@ -26,7 +26,7 @@
"shadow": true,
"hiddenTitle": true,
"titleBarStyle": "Overlay",
"visible": true,
"visible": false,
"acceptFirstMouse": true
},
{
+35 -4
View File
@@ -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<Gate>(isTauri && os !== 'win' ? 'checking' : 'ready');
const [gate, setGate] = useState<Gate>(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(),