From 20d67da5c49605d580f5db92c0a08a54a0512dae Mon Sep 17 00:00:00 2001 From: Cooper-X-Oak Date: Sat, 2 May 2026 01:23:22 +0800 Subject: [PATCH 1/4] =?UTF-8?q?docs(windows):=20=E4=B8=BA=20issue=20143=20?= =?UTF-8?q?=E5=BB=BA=E7=AB=8B=E5=86=B7=E5=90=AF=E5=8A=A8=E5=8D=A0=E4=BD=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../issue-143-cold-start-ui.md | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 docs/windows-ui-tracking/issue-143-cold-start-ui.md diff --git a/docs/windows-ui-tracking/issue-143-cold-start-ui.md b/docs/windows-ui-tracking/issue-143-cold-start-ui.md new file mode 100644 index 00000000..9cfe70a5 --- /dev/null +++ b/docs/windows-ui-tracking/issue-143-cold-start-ui.md @@ -0,0 +1,23 @@ +# Issue #143 Placeholder / 占位 + +## 中文摘要 + +本 PR 是 issue #143 的 draft 占位,专门跟踪 Windows 冷启动前几秒加载异常、闪烁与 ready 前展示错位问题。 +当前只记录时序边界、现象入口和后续修复出口,不引入无关功能修改。 + +## Scope / 范围 + +- visible / ready timing +- first stable paint +- startup shell exposure +- Windows cold start UX + +## Evidence / 证据入口 + +- `openless-all/app/src-tauri/tauri.conf.json` +- `openless-all/app/src/App.tsx` +- `openless-all/app/src/components/FloatingShell.tsx` + +## Merge Rule / 合并规则 + +- 仅当 issue #143 的启动时序统一且完成 Windows cold-start smoke 后才允许从 draft 转为 ready。 From 31e3f541d13dd2afd8716d76696a9a3fa779d2dd Mon Sep 17 00:00:00 2001 From: Cooper-X-Oak Date: Sat, 2 May 2026 03:29:49 +0800 Subject: [PATCH 2/4] 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(), From da13e8ca371727458fe8616d8af596009aabb9ce Mon Sep 17 00:00:00 2001 From: Cooper-X-Oak Date: Sat, 2 May 2026 05:18:01 +0800 Subject: [PATCH 3/4] docs(windows): add tracking docs for issue 98 and pr 145 --- .../issue-98-startup-visible-ready.md | 74 +++++++++++++++++++ .../pr-145-cold-start-first-paint.md | 40 ++++++++++ 2 files changed, 114 insertions(+) create mode 100644 docs/github-tracking/issue-98-startup-visible-ready.md create mode 100644 docs/github-tracking/pr-145-cold-start-first-paint.md diff --git a/docs/github-tracking/issue-98-startup-visible-ready.md b/docs/github-tracking/issue-98-startup-visible-ready.md new file mode 100644 index 00000000..40e357a1 --- /dev/null +++ b/docs/github-tracking/issue-98-startup-visible-ready.md @@ -0,0 +1,74 @@ +## 现象 / Symptom + +Windows 冷启动路径里,`visible` 与 `ready` 目前是脱钩的:主窗口可以先被用户看见,但 global hotkey / runtime lifecycle 还在后台异步安装。 + +这不是单纯的 UI 小闪烁,而是 startup lifecycle ownership 不统一: + +- `main` 在配置层默认 `visible:false` +- backend 负责 `show_main_window()` / tray reopen / single-instance focus +- frontend `App.tsx` 又在 mount 后主动 `currentWindow.show()` +- Windows 路径下 `gate` 初始值直接是 `ready` + +### 证据 / Evidence + +- `openless-all/app/src-tauri/tauri.conf.json:17-30` + - `main.visible = false` +- `openless-all/app/src-tauri/src/lib.rs:314-356` + - backend 明确拥有 `show_main_window()` / `hide_main_window()` 生命周期入口 +- `openless-all/app/src-tauri/src/lib.rs:158-163` + - hotkey listener 与 QA hotkey listener 在 setup 后异步启动 +- `openless-all/app/src/App.tsx:23-52` + - Windows 路径初始化时直接 `gate='ready'` + - mount 后又在 `requestAnimationFrame` 里调用 `currentWindow.show()` +- [2026-05-02-platform-lifecycle-audit.md](/D:/Users/cooper/Practice-Project/202604/openless/docs/2026-05-02-platform-lifecycle-audit.md) + - 审计已将该问题归类为 startup lifecycle ownership 偏差 + +### 5 Whys / 根因分析 + +1. 为什么用户会看到一个看似 ready 的窗口,但热键/运行态未必已经 ready? + - 因为窗口可见时机和 runtime readiness 时机不是一个 source of truth。 +2. 为什么这两个时机分离了? + - 因为 backend 和 frontend 同时持有 `main` visibility 的一部分控制权。 +3. 为什么 Windows 上更明显? + - 因为 Windows 启动路径跳过了 macOS 那种明确的 permission gate / startup shell,正式 UI 更早暴露。 +4. 为什么这偏离了 macOS 的原始设计意图? + - 原始意图是“用户看见主窗口时,它已经进入可用或可解释的阶段”;Windows 当前更像“窗口先到,能力后到”。 +5. 为什么之前没被系统性识别? + - 现有 smoke 主要验证“进程活着 + 稍后日志出现 hotkey installed”,没有验证“first visible frame == operationally ready”。 + +### 平台边界 / Platform Scope + +- 直接症状范围:当前主要在 Windows 冷启动观察到。 +- 问题层面:startup lifecycle ownership、window visibility contract、runtime readiness contract。 +- 全平台风险判断:这是全平台架构层风险,但 Windows 因跳过 startup gate、前端主动 show,最先表现为真实用户问题。 + +### 认领 / Ownership + +- owner intent:`@Cooper-X-Oak` +- 对应 draft PR:待创建 + +### 当前状态 / Current status + +- startup lifecycle 主线修复已生效 +- 最新测试入口改为 frontend-managed first show,不再用 backend immediate show 污染结果 +- 人工冷启动体验反馈:几乎没有问题,人眼很难分辨 +- 当前建议:保留 draft,继续观察 first-paint / startup latency,而不是继续扩大主修补丁 + +## 影响 / Impact + +- 用户会把尚未 ready 的窗口误判为已经 ready +- 会放大“热键没反应 / 运行态未安装”的首屏困惑 +- 让后续任何 Windows 启动问题更难分辨是 UI 问题、hotkey 问题,还是 lifecycle contract 问题 + +## 建议接受标准 / Proposed Acceptance Criteria + +- [ ] `main` 窗口的首次可见时机只由一个 owner 控制 +- [ ] first visible frame 与 runtime readiness 的关系被明确定义并可验证 +- [ ] Windows 冷启动下,用户首次看到主窗口时,至少处于明确的 `startup` 或 `ready` 状态,而不是 ambiguous ready +- [ ] 增加一条启动 smoke:覆盖 `visible`、`hotkey installed`、`first usable state` 的先后顺序 + +## TODO / 不确定项 + +- 是否应把 `main` visibility 完全收回 backend,frontend 只负责内容 gate +- 是否要把现有 `issue #143` 的 first-paint 问题作为本 issue 的下游视觉子问题处理,还是继续分票并行跟踪 +建议 issue 标题:`[tauri][windows] 冷启动时 visible 与 ready 脱钩` diff --git a/docs/github-tracking/pr-145-cold-start-first-paint.md b/docs/github-tracking/pr-145-cold-start-first-paint.md new file mode 100644 index 00000000..27bfc582 --- /dev/null +++ b/docs/github-tracking/pr-145-cold-start-first-paint.md @@ -0,0 +1,40 @@ +## 摘要 + +Closes #98 +References #143 + +这个 draft PR 继续承接 Windows cold-start first paint 生命周期问题,但状态已经从“纯问题定义”推进到“主线修复已见效、继续观察”。 + +本轮最新结论: + +- 之前可见的一闪,主要与测试入口使用 backend immediate show 有关 +- 冷启动脚本改成 frontend-managed first show 后,首屏体验明显更顺 +- 实测主观体验是“几乎没有问题,人眼很难分辨” +- 但为了避免把 startup latency 优化和 lifecycle contract 修复混成一团,这条 PR 仍保持 draft +- `#143` 更适合作为已收敛的 first-paint 症状票;当前主线 closure 由 `#98` 承接 + +## 修复 / 新增 / 改进 + +- 保持 draft 角色,继续跟踪 `created -> shown -> first stable paint -> ready` +- 记录测试入口从 backend immediate show 切换到 frontend-managed first show 的影响 +- 作为后续 cold-start visual smoke 与更细粒度 startup latency 优化的承接入口 + +## 兼容 + +- 不包含:主窗口圆角 / 外框 / 其他视觉适配 +- 对现有用户 / 本地环境 / 构建流程的影响:继续只聚焦 startup lifecycle 主线 + +## 测试计划 + +- [x] 命令:`powershell -ExecutionPolicy Bypass -File openless-all/app/scripts/windows-cold-start.ps1 -PreferDebug -ShowMain` +- [x] 结果:能够走 frontend-managed first show +- [x] 证据路径:本地命令输出 + +- [x] 命令:3 秒与 8 秒冷启动截图对比 +- [x] 结果:3 秒可见 startup shell;修正测试入口后正式首屏体验显著改善 +- [x] 证据路径:`artifacts-cold-start-screenshot.png`、`artifacts-cold-start-screenshot-8s.png`、`artifacts-cold-start-screenshot-front-managed.png` + +- [x] 命令:人工主观回归 +- [x] 结果:冷启动过程“几乎没有问题,至少人眼很难分辨” +- [x] 证据路径:当前线程回归记录 +关联 issue 建议标题:`[ui][windows] 冷启动前几秒出现 UI flash 和 layout drift` From 4f6df5674494308538a4c8bfc0e11ebffd6eae52 Mon Sep 17 00:00:00 2001 From: Cooper-X-Oak Date: Sat, 2 May 2026 05:25:11 +0800 Subject: [PATCH 4/4] docs(windows): refresh pr 145 review-ready tracking summary --- .../pr-145-cold-start-first-paint.md | 50 +++++++++++-------- 1 file changed, 28 insertions(+), 22 deletions(-) diff --git a/docs/github-tracking/pr-145-cold-start-first-paint.md b/docs/github-tracking/pr-145-cold-start-first-paint.md index 27bfc582..d74843df 100644 --- a/docs/github-tracking/pr-145-cold-start-first-paint.md +++ b/docs/github-tracking/pr-145-cold-start-first-paint.md @@ -1,40 +1,46 @@ -## 摘要 +## 摘要 -Closes #98 +Closes #98 References #143 -这个 draft PR 继续承接 Windows cold-start first paint 生命周期问题,但状态已经从“纯问题定义”推进到“主线修复已见效、继续观察”。 +这条 PR 已经不再只是 tracking 入口,而是承接本轮 Windows startup lifecycle 主线修复的实际变更。 -本轮最新结论: +当前结论: -- 之前可见的一闪,主要与测试入口使用 backend immediate show 有关 -- 冷启动脚本改成 frontend-managed first show 后,首屏体验明显更顺 -- 实测主观体验是“几乎没有问题,人眼很难分辨” -- 但为了避免把 startup latency 优化和 lifecycle contract 修复混成一团,这条 PR 仍保持 draft -- `#143` 更适合作为已收敛的 first-paint 症状票;当前主线 closure 由 `#98` 承接 +- `visible / ready` 脱钩的主问题已收敛 +- 冷启动入口已从 backend immediate show 调整为 frontend-managed first show +- 最新人工回归反馈是:启动过程基本流畅,人眼很难再分辨出明显的一闪 +- `#143` 现在更适合作为已收敛的 first-paint 症状票引用,而不是继续作为主 closure 目标 ## 修复 / 新增 / 改进 -- 保持 draft 角色,继续跟踪 `created -> shown -> first stable paint -> ready` -- 记录测试入口从 backend immediate show 切换到 frontend-managed first show 的影响 -- 作为后续 cold-start visual smoke 与更细粒度 startup latency 优化的承接入口 +- 收口 Windows 启动阶段的 first-show ownership +- 在 `checking -> ready` 之间加入明确 gate,避免正式壳层在 startup transient phase 过早暴露 +- 增加冷启动测试脚本,默认优先拉最新 debug build,并区分: + - frontend-managed first show + - backend immediate show(仅调试用) +- 增加 startup lifecycle contract test,锁住 hidden-on-create 与 readiness gate 语义 ## 兼容 -- 不包含:主窗口圆角 / 外框 / 其他视觉适配 -- 对现有用户 / 本地环境 / 构建流程的影响:继续只聚焦 startup lifecycle 主线 +- 不包含:主窗口圆角 / 外框 / titlebar frame 等纯视觉适配 +- 不包含:更细粒度 startup latency 优化 +- 对现有用户 / 本地环境 / 构建流程的影响:聚焦 startup lifecycle 主线,不扩张到 UI polish 线 ## 测试计划 +- [x] 命令:`node openless-all/app/scripts/windows-startup-lifecycle-contract.test.mjs` +- [x] 结果:通过 +- [x] 证据路径:本地命令输出 + +- [x] 命令:`npm run build` +- [x] 结果:通过 +- [x] 证据路径:本地命令输出 + - [x] 命令:`powershell -ExecutionPolicy Bypass -File openless-all/app/scripts/windows-cold-start.ps1 -PreferDebug -ShowMain` - [x] 结果:能够走 frontend-managed first show - [x] 证据路径:本地命令输出 -- [x] 命令:3 秒与 8 秒冷启动截图对比 -- [x] 结果:3 秒可见 startup shell;修正测试入口后正式首屏体验显著改善 -- [x] 证据路径:`artifacts-cold-start-screenshot.png`、`artifacts-cold-start-screenshot-8s.png`、`artifacts-cold-start-screenshot-front-managed.png` - -- [x] 命令:人工主观回归 -- [x] 结果:冷启动过程“几乎没有问题,至少人眼很难分辨” -- [x] 证据路径:当前线程回归记录 -关联 issue 建议标题:`[ui][windows] 冷启动前几秒出现 UI flash 和 layout drift` +- [x] 命令:冷启动截图与人工主观回归 +- [x] 结果:首屏体验明显改善,当前主观反馈为“几乎没有问题,人眼很难分辨” +- [x] 证据路径:`artifacts-cold-start-screenshot.png`、`artifacts-cold-start-screenshot-8s.png`、`artifacts-cold-start-screenshot-front-managed.png` 与当前线程回归记录