Windows × Tauri guardrails (tempo-term)
Development happens on macOS. Two GitHub Actions cover Windows: .github/workflows/windows-check.yml runs cargo check with warnings-as-errors on a windows-latest runner for every PR and push to master (added in #191), and .github/workflows/windows-build.yml builds the actual bundle only on a v* tag (or manual dispatch). So a compile or warning regression now fails the PR that caused it; but a runtime, bundle, or behavior bug still compiles clean and only surfaces in the tagged Windows build or on a user's machine. Historically there was no per-PR gate at all — which is why every Windows bug in this repo's history before #191 reached a release.
This skill is the checklist that closes the remaining gap: everything cargo check cannot see. Read it before touching any of the surfaces below, and run the pre-flight list before a release.
Pre-flight checklist
Apply while writing, not after. Each line links to the detailed rule.
- Spawning a process? Set
CREATE_NO_WINDOWon Windows, or a console flashes and each call costs ~100ms in a release build. → - Resolving an executable on disk? Windows binaries are
gh.exe/.cmd/.bat, home isUSERPROFILE, install dirs differ. → - A subprocess with no timeout? A hung
gh/gitblocks the caller thread forever; add a timeout. → - Injecting a command into a pty? Terminate with
\r, not\n, or ConPTY leaves it on a>>continuation line. → - Waiting on pty reader EOF to detect anything? ConPTY never EOFs while the pseudo console is open; detect child exit with
child.wait()and close the console to unblock the reader (catalog row #272). - Building a path that bash will run? Use forward slashes; never inject a bare
.sh; quote Windows paths with double quotes. → - A
#[cfg(unix)]API, a cfg-split command, or a new native crate? It must compile warning-free on Windows —windows-check.ymlrunscargo checkwith-D warningsper PR. Provide a Windows arm or cfg-gate cleanly, andallow(dead_code)/allow(unused_variables)the mac-only bits. → - Reaching into
/proc,lsof, a unix socket, pty foreground? No Windows backend exists; skip the feature there or return a cleanNone. → - Frontend behavior gated on platform? Route Windows through
IS_WINDOWS, do not leave itIS_MAC-only. → - Creating a window or webview from a
#[tauri::command]? The command must beasync(and wrap the build inspawn_blocking), or WebView2 deadlocks. → - Release?
windows-build.ymlis the bundle/link gate (per-PRcargo checkalready covered compile);latest.jsonnotes get re-patched; functional test on a real Windows host. →
Failure catalog (what this repo has actually hit)
| Symptom on Windows | Root cause | Rule | Shipped fix |
|---|---|---|---|
| Black console flashes on every git/gh call; UI stalls | Release build owns no console (windows_subsystem = "windows"); a spawn without CREATE_NO_WINDOW allocates one |
Set CREATE_NO_WINDOW on every Command |
#82, #105 |
"PATH 上找不到 gh CLI" although gh is installed |
Searched for bare gh; Windows binary is gh.exe; used HOME and macOS install dirs |
Append .exe/.cmd/.bat, use USERPROFILE, Windows install dirs |
#89 |
Launcher command sits under a >> prompt, never runs |
ConPTY/PSReadLine binds CR to submit, LF to continuation; we injected LF | Terminate injected commands with \r |
#160 |
| Status hook never fires / spams "Open With" dialog | bash escapes \; a bare .sh under cmd triggers ShellExecute picker |
Forward-slash paths; do not inject a bare .sh on Windows |
#76, #155 (open) |
cargo build fails on a stock Windows box |
aws-lc-sys (via russh default aws-lc-rs) needs NASM |
Use russh ring backend |
#75 |
| Windows release fails to compile (E0599) once SSH landed | AgentClient::connect_env is #[cfg(unix)] |
Windows arm: connect_named_pipe / connect_pageant |
#52, #54 |
Windows warns unused variable / dead_code, then fails under -D warnings |
A cfg-split command's structs/params are only read in the #[cfg(target_os = "macos")] arm; the Windows arm is a no-op stub |
#[cfg_attr(not(target_os = "macos"), allow(dead_code))] on the model, allow(unused_variables) on the fn |
#190, gated #191 |
| Terminal paste does nothing | Native paste suppressed everywhere; smart-paste mac-only; backend empty off mac | Route Ctrl+V via IS_WINDOWS; read clipboard with clipboard-win |
#75 |
| cwd tracking dead / 1.2s poll fires empty IPC every terminal | pty_cwd has no Windows backend (/proc, lsof absent) |
Skip the poll on Windows, or use OSC 7 shell integration | #105, #115 |
| Updater "更新內容" dialog empty after a release | Windows CI tauri-action rewrites latest.json and wipes notes |
Re-patch notes post-build from CHANGELOG-NEXT.md |
#43 |
| Diff-open lag only in the release build | Same console-flash spawn cost, per git subprocess | CREATE_NO_WINDOW (see row 1) |
#82 |
| Ctrl+N opens a blank unclosable window; shortcuts die app-wide | WebviewWindowBuilder::build() in a sync command deadlocks the WebView2 event loop (wry#583) |
Window-creating commands must be async + spawn_blocking |
#209 |
Pane hangs dead after exit; on_exit never fires |
ConPTY's reader never sees EOF while the pseudo console is open (microsoft/terminal#1810), and the session registry kept the master alive — teardown sequenced behind reader EOF deadlocks | Detect exit via child.wait() in a waiter thread, then drop the master (closes the pseudo console) to unblock the reader; never gate teardown on reader EOF |
#272 |
1. Every subprocess spawn needs CREATE_NO_WINDOW
A release build sets windows_subsystem = "windows" (src-tauri/src/main.rs:2) and owns no console, so Windows allocates a fresh console for every child process spawned without the flag: a visible window flash plus ~100ms per call. tauri dev owns a console, so this never reproduces in dev — only in a release build or on a user's machine.
Copy the established helper shape (src-tauri/src/modules/git/mod.rs:545, src-tauri/src/modules/pr/mod.rs:185):
use std::process::Command;
fn spawn_thing(exe: &str) -> Command {
let mut command = Command::new(exe);
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
command.creation_flags(CREATE_NO_WINDOW);
}
command
}
Rule: any new Command that shells out (git, gh, a formatter, anything) routes through a helper that sets this flag. A no-op on Unix and on debug builds; mandatory in the Windows release.
2. Executable resolution differs on Windows
find_gh() searched for a file literally named gh; on Windows the binary is gh.exe, so it never matched (#89). Three separate assumptions break:
- Extension: probe
gh, thengh.exe,gh.cmd,gh.baton Windows. Seeexe_names()insrc-tauri/src/modules/pr/mod.rs. - Home var: Windows usually has no
HOME; fall back toUSERPROFILE. - Install dirs: not
/opt/homebrew/bin; useProgram Files\GitHub CLI, chocolatey, scoop shims.
Parameterize the platform (windows: bool) so both arms are unit-testable on the mac CI runner, rather than hiding the Windows path behind #[cfg] where it never gets exercised locally.
3. Give external commands a timeout
Command::output() has no timeout. A hung gh (network stall, an interactive auth prompt) blocks the calling thread forever, and on a poll timer those pile up (#105). Wrap spawns that can hang with a timeout (this repo uses the wait-timeout crate — see run_gh in src-tauri/src/modules/pr/mod.rs, 5s kill).
4. Inject pty commands with CR, not LF
On Windows ConPTY, PSReadLine binds CR (\r) to AcceptLine (submit) and LF (\n) to AddLine (a >> continuation line). An injected \n never runs the command (#160). macOS/Linux tolerate \n only because the pty line discipline (ICRNL) maps CR→LF on input, hiding the bug.
Rule: any code that auto-types a command into a pane (runCommandInTerminal, launcher command injection) terminates with \r. CR is what a real Enter key sends and what xterm emits, so it submits on all three platforms.
5. Paths: forward slashes for bash, double quotes for shells
- **bash escapes
\.** A Windows backslash path handed to anything Claude Code / git-bash runs collapses (C:\Users\...→C:Users...). Store and compare paths in a single forward-slash form; git-bash accepts them. Seenormalize()insrc-tauri/src/modules/claude_status_hook/mod.rs. - Never inject a bare
.shcommand on Windows. cmd cannot execute a.shpath and pops the "Open With" picker on every hook event (#155, still open). If a shell script must run, name the interpreter explicitly (bash -c "..."/ the git-bashbash.exe), or ship a.cmd/.ps1/native shim. - Quote paths with double quotes, accepted by cmd, PowerShell, and git-bash. POSIX single quotes are wrong on Windows (#75).
6. Unix-only APIs and native crates must still build on Windows
The dev box cannot cross-compile the Windows bundle — git2, font-kit, portable-pty, and crypto crates all pull native code, so only windows-build.yml on a Windows runner catches a break. Two recurring traps:
- A
#[cfg(unix)]API used unconditionally fails to compile on Windows (E0599).AgentClient::connect_env(unix socket) needed a Windows arm (connect_named_pipe/connect_pageant) and the shared loop extracted into a generic helper (#52, #54). When an API has no Windows form, cfg-gate it and provide a Windows path or a clean error — do not leave the call unconditional. - A cfg-split command warns on Windows even when it compiles.
set_native_menu(src-tauri/src/modules/menu.rs) keeps itsNativeMenuModelstructs and itsappparam only for the#[cfg(target_os = "macos")]arm; on Windows they are dead code / an unused variable, sowindows-check.yml'scargo check -D warningsfails the PR (#190 shipped the warning, #191 added the gate). Guard the mac-only bits:#[cfg_attr(not(target_os = "macos"), allow(dead_code))]on the types,#[cfg_attr(not(target_os = "macos"), allow(unused_variables))]on the fn. Prefer these cfg-scopedallows over an_-prefix, which would also hide the value from the macOS arm that needs it. - A new native crate may need a Windows toolchain.
aws-lc-sysneeds NASM; switching russh to itsringbackend removed that socargo buildworks on a stock Windows MSVC setup (#75). Before adding a crate with a-sysdependency, check its Windows build requirements.
windows-check.yml now compiles the Windows target on every PR with warnings-as-errors, so a broken cfg-gate or a Windows-only warning fails the PR automatically — no manual step. For anything cargo check cannot prove (bundling, native-crate link, runtime), still kick windows-build.yml (workflow_dispatch) and confirm it is green before merging.
7. No Windows backend: skip, do not crash or spin
Some capabilities have no Windows implementation and must degrade cleanly, not error or busy-loop:
- pty foreground process:
portable-ptyexposes none on Windows, so detecting thatclaude/codexis running (for image auto-attach) is mac/Linux only (#75). - process cwd: no
/proc, nolsof, soread_process_cwdreturnsNone. The frontend must not keep pollingpty_cwdevery 1.2s per terminal into a dead backend — guard the poll off on Windows (#105), or drive cwd from OSC 7 shell integration instead (#115).
Rule: when a feature's backend is a no-op on Windows, gate the frontend caller too, so it does not fire pointless IPC or show a broken control.
8. Frontend: gate on IS_WINDOWS, not just IS_MAC
Platform branches written as "mac vs everything else" silently break Windows. Terminal paste was suppressed on all platforms and the smart-paste shortcut was IS_MAC-only, so Windows had no paste at all (#75). Window decorations are native on Windows and need a custom TitleBar (#67). Use IS_WINDOWS from src/lib/platform.ts and give Windows its own explicit branch.
9. Window creation deadlocks in a sync command
WebviewWindowBuilder::build() (and WindowBuilder/WebviewBuilder) deadlocks on Windows when called from a synchronous #[tauri::command] or event handler. WebView2 dispatches window creation as a message to the main event loop and blocks on the reply; a sync command runs on that same thread, so it waits on itself (upstream wry#583, called out in the Tauri builder docs). macOS/WKWebView has no such constraint, so this never reproduces in dev on the mac — only on a user's Windows machine.
The symptom is nasty and non-obvious: the OS window shell appears painted only with the builder's background_color (a blank dark rectangle — the webview never initializes), decorations(false) means no native frame and the React TitleBar never renders so there is no close button, and the wedged event loop kills every subsequent IPC-based shortcut app-wide. That was #208: a user pressing Ctrl+N (wired to the open_new_window command) got an unclosable blank window and a dead app.
The fix is what the Tauri docs prescribe — make the command async so it runs off the main thread — plus spawn_blocking so the blocking main-thread round-trips (build(), inner_size(), scale_factor()) stay off the shared async worker pool, matching how every other async command here handles blocking work:
#[tauri::command]
async fn open_new_window(app: tauri::AppHandle) -> Result<(), String> {
tauri::async_runtime::spawn_blocking(move || modules::menu::create_new_window(&app))
.await
.map_err(|e| e.to_string())?
.map_err(|e| e.to_string())
}
Any command that builds a window or webview must follow this shape. Note that async commands can now run concurrently, so window-label allocation (next_window_label) can race across two rapid invocations — benign here (one window opens, the duplicate build() errors), but worth knowing if label collisions ever matter.
Release checklist (Windows)
The release is two-phase: scripts/release.sh runs on the mac (build, notarize, latest.json, gh release create with the v* tag), and the tag push triggers windows-build.yml, which builds on Windows, uploads .exe/.msi, and merges the windows-x86_64 platform into latest.json.
- Windows CI is green.
windows-check.ymlgates compilation per PR, butwindows-build.ymlis the only thing that bundles and links the actual Windows binary; a red build means the release ships no working Windows binary. - The tagged commit still has a populated
CHANGELOG-NEXT.md.windows-build.ymlre-patcheslatest.json'snotesfrom the changelog at the tagged commit. If the changelog was already archived/emptied, notes (macOS included) get wiped (#43). Do not point the tag at a master HEAD whose changelog was cleared. - Do not hand-patch
latest.jsonbefore CI finishes —tauri-actionrewrites it mid-run and would clobber your patch. Patch only after CI, vianode scripts/patchManifestNotes.mjs <manifest> <changelog>thengh release upload --clobber. - Functional-test on a real Windows host. CI proves it compiles and bundles, not that it works. The recurring gap in this repo's PRs is the unchecked "Windows real verification" box. Use the CI artifact from the branch to smoke-test paste, launcher, terminal cwd, and the updater dialog.
How to actually verify on Windows
- Dev never shows console-flash / spawn-cost bugs — a debug build owns a console. Reproduce these only in a release build (or the CI artifact).
- No Windows box? The per-PR
windows-check.ymlalready gates compilation (warnings included); to test behavior, push the branch and runwindows-build.ymlviaworkflow_dispatch, then download the artifact. - Keep platform logic in pure functions parameterized by
windows: bool(seepr/mod.rs) so the Windows path has real unit tests that run on the mac, instead of#[cfg(windows)]code that is never executed until a user hits it.