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
74 changes: 74 additions & 0 deletions docs/github-tracking/issue-98-startup-visible-ready.md
Original file line number Diff line number Diff line change
@@ -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 脱钩`
46 changes: 46 additions & 0 deletions docs/github-tracking/pr-145-cold-start-first-paint.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
## 摘要

Closes #98
References #143

这条 PR 已经不再只是 tracking 入口,而是承接本轮 Windows startup lifecycle 主线修复的实际变更。

当前结论:

- `visible / ready` 脱钩的主问题已收敛
- 冷启动入口已从 backend immediate show 调整为 frontend-managed first show
- 最新人工回归反馈是:启动过程基本流畅,人眼很难再分辨出明显的一闪
- `#143` 现在更适合作为已收敛的 first-paint 症状票引用,而不是继续作为主 closure 目标

## 修复 / 新增 / 改进

- 收口 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 语义

## 兼容

- 不包含:主窗口圆角 / 外框 / 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] 命令:冷启动截图与人工主观回归
- [x] 结果:首屏体验明显改善,当前主观反馈为“几乎没有问题,人眼很难分辨”
- [x] 证据路径:`artifacts-cold-start-screenshot.png`、`artifacts-cold-start-screenshot-8s.png`、`artifacts-cold-start-screenshot-front-managed.png` 与当前线程回归记录
23 changes: 23 additions & 0 deletions docs/windows-ui-tracking/issue-143-cold-start-ui.md
Original file line number Diff line number Diff line change
@@ -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。
142 changes: 142 additions & 0 deletions openless-all/app/scripts/windows-cold-start.ps1
Original file line number Diff line number Diff line change
@@ -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"
}
Original file line number Diff line number Diff line change
@@ -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',
);
2 changes: 1 addition & 1 deletion openless-all/app/src-tauri/tauri.conf.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
"shadow": true,
"hiddenTitle": true,
"titleBarStyle": "Overlay",
"visible": true,
"visible": false,
"acceptFirstMouse": true
},
{
Expand Down
Loading
Loading