fix(windows): gate main window visibility on startup readiness
This commit is contained in:
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',
|
||||
);
|
||||
@@ -26,7 +26,7 @@
|
||||
"shadow": true,
|
||||
"hiddenTitle": true,
|
||||
"titleBarStyle": "Overlay",
|
||||
"visible": true,
|
||||
"visible": false,
|
||||
"acceptFirstMouse": true
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in new issue
Block a user