Skip to content

feat(windows): make Windows a first-class target through platform seams - #12

Merged
whackur merged 49 commits into
code0xff:devfrom
whackur:feat/windows-port-integration
Aug 1, 2026
Merged

feat(windows): make Windows a first-class target through platform seams#12
whackur merged 49 commits into
code0xff:devfrom
whackur:feat/windows-port-integration

Conversation

@whackur

@whackur whackur commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator

Makes nightcrow run on Windows as a first-class target, alongside macOS and
Linux, and CI now gates all three.

The port is done through platform seams rather than scattered cfg blocks:
one module owns the platform difference and the rest of the tree calls the
seam. Where a capability simply does not exist on one platform, the code says
so at the seam instead of pretending.

Platform seams

  • Daemon socket — routed through a transport seam (UnixStream /
    UnixListener shims) so the daemon, attach, and stop all speak one API.
    Socket restriction is applied per platform capability rather than assuming
    Unix file modes.
  • Detachsetsid on Unix, DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP
    on Windows.
  • Instance lock — taken through std::fs::File instead of a Unix-only
    primitive.
  • SignalsShutdownWatch covers both; Shutdown::Terminate is
    dead-code-allowed on Windows rather than removed, so the enum stays whole.
  • Recovery plugin — Windows support, with the shell-spawning tests gated
    Unix-only where they need a real POSIX shell.

What came with it

  • [shell] config section with per-platform defaults, so the pane shell is not
    hardcoded to a Unix one.
  • nightcrow stop — asks a running daemon to shut down over its socket,
    running the same graceful sequence as SIGINT/SIGTERM.
  • nightcrow -d attach — starts a session if none is running, then attaches.
    An already-running session is attached to as-is.
  • Repo dialog fixes: the typed path survives the first keypress, and the
    browser drops ./.. for a moving root.
  • Path handling: verbatim (\\?\) prefixes stripped from canonicalized paths;
    path-separator assumptions removed from tests.

Verification

  • CI matrix gains windows-latest, so all four gates (build, test,
    clippy -D warnings, fmt --check) run on macOS, Linux, and Windows.
  • compose.yml adds a unix-gate service to run the Linux gates from a
    Windows machine locally, before the push-and-wait cycle.
  • Hooks split: pre-commit runs fmt --check only, pre-push runs the full
    CI-equivalent gate scoped to what changed vs upstream/dev.

One known flaky test in containers —
a_reattaching_client_makes_an_alternate_screen_program_draw_again can fail on
PTY timing under Docker load. It passes on dev and on ubuntu-latest.

Docs

docs/getting-started.md gains a "Verifying on the other platform" section
(the cfg gates mean a green local build is not proof the others are green),
and the README states the three-platform target and leads with
nightcrow -d attach.

whackur added 30 commits August 1, 2026 02:42
Windows supports AF_UNIX SOCK_STREAM but std does not expose it.
Route the six daemon files through a single transport module that
re-exports std::os::unix::net on Unix and uds_windows on Windows.

See docs/internal/windows-port/pr-01-transport.md.
Replace libc::flock with std::fs::File::try_lock/unlock, which maps
to the same syscalls on both platforms. Update the libc dependency
comment and the lock_tests contract tests in the same commit.

See docs/internal/windows-port/pr-02-instance-lock-std.md.
Add Windows creation flags (DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP)
and split the shutdown watch per platform: signal_hook on Unix, ctrlc
with sync_channel on Windows. Move signal-hook to Unix-only deps and
add ctrlc for Windows. Wrap signal delivery tests in #[cfg(unix)].

See docs/internal/windows-port/pr-03-detach-signals.md.
Extract restrict_to_owner helper with #[cfg(unix)] using from_mode(0o600)
and #[cfg(windows)] as a documented no-op depending on the profile
directory's inherited ACL. Decision C requires the comment to name
what it depends on and when that dependency breaks.

See docs/internal/windows-port/pr-04-permissions.md.
Route recovery plugin socket types through a platform seam, fix the
HOME environment variable to use dirs::home_dir(), restrict ipc paths
per platform capability, and name the AF_UNIX 107-byte path length
limit when bind refuses.

See docs/internal/windows-port/pr-05-recovery-plugin.md.
…ntegration

# Conflicts:
#	src/daemon/socket.rs
Reorder imports across daemon files to satisfy rustfmt after the
transport seam merge.
Windows has no SIGTERM equivalent; the variant is only constructed on
Unix. Annotate it rather than removing it — the nightcrow stop protocol
path may use it on both platforms.
Route socket types through the platform seam in test files, gate
permission-mode assertions and Unix-only fixtures with #[cfg(unix)],
add junction-based equivalents for symlink tests on Windows, and
split the executable fixture per platform. Watcher filter verified
stable across 5 runs on Windows.

See docs/internal/windows-port/pr-06-tests.md.
Promote shell to a config section. Unix defaults to \ or /bin/sh
with -lc; Windows defaults to %ComSpec% or cmd.exe with /C. The command
text stays a single argv item so the shell handles quoting. No Git Bash
auto-detection (decision B).

See docs/internal/windows-port/pr-07-shell.md.
Add a Stop subcommand that sends a Shutdown message over the daemon
socket. The signal path and socket path converge on one channel so
both run the same graceful shutdown. No force kill — the point is to
reap child shells (decision A).

See docs/internal/windows-port/pr-08-stop.md.
Start without bracketed paste when the console lacks it, read the
local time zone on Windows via windows-sys, strip verbatim \\?\ path
prefixes for display, and resolve .exe/.cmd/.bat extensions in the
plugin registry.

See docs/internal/windows-port/pr-09-display-defects.md.
Add windows-latest to the OS matrix and set fail-fast: false so one
OS failing does not hide another's results. This is the first time the
MSVC toolchain is exercised — git2 vendored OpenSSL may need a follow-up
if it breaks.

See docs/internal/windows-port/pr-10-ci.md.
PTY and terminal tests that spawn real shells and assert on Unix sh -lc
output are meaningless on Windows where the default shell is cmd.exe.
Gate them with #[cfg(unix)]. Fix path_complete and path_tree to handle
Windows path separators.
Fix is_executable PATHEXT comparison (extension has no dot, PATHEXT
entries do), normalize backslashes to forward slashes in for_display
on Windows, normalize home directory paths before strip_prefix in
display_path and home_relative_path, strip verbatim prefix in path
test fixtures, and document missing leader commands in README.
…omparison

Gate RELAUNCH_MARKER and PTY_TEST_DEADLINE with #[cfg(unix)] since the
tests using them are Unix-only. Replace manual char comparison closures
with array patterns per clippy::manual_pattern_char_comparison.
The path tree normalizes separators to forward slashes for display, so
the assertions must expect forward slashes regardless of platform.
The status_line and helper tests spawn real shells (echo, printf, cat,
sleep) and assert on their output. On Windows the default shell is
cmd.exe, so these tests are meaningless. Gate them with #[cfg(unix)].
Gate the ENOUGH/BRIEF/Instant constants they use as well.
whackur added 19 commits August 1, 2026 04:20
The signal_delivery module is nested under the tests module, so
super::ShutdownWatch does not resolve. Import from
crate::platform::signals instead. Also add .opencode/ to .gitignore.
docker-compose.yml runs the four AGENTS.md gates (fmt, clippy, build,
test) inside a rust:latest container with named-volume caches for
cargo registry and target. Windows developers can catch Unix regressions
locally before pushing, without waiting for CI.

Document the workflow in AGENTS.md and verification.md. Note the known
flaky reattach test that fails in Docker but passes in CI.
…ose.yml

Move the Docker unix-gate instructions and build/test prerequisites from
AGENTS.md and verification.md into docs/getting-started.md under a
'Building and testing' section. Rename docker-compose.yml to compose.yml
per the modern Compose convention. AGENTS.md and verification.md now
point to the getting-started section instead of duplicating the content.
std::fs::canonicalize on Windows prepends \\?\ to the result. cmd.exe
treats that as a UNC path and falls back to C:\Windows, so the terminal
pane opened in the wrong directory. Add canonicalize_clean() that strips
the prefix while preserving native separators, and use it in
resolve_repo_path and spawn_pane.
The directory browser now prepends . and .. rows at the root level so
the user can pick the current directory or step up without leaving the
browser, matching every file manager. They are depth-0 only to avoid
clutter and infinite recursion; expand skips them, and selected_path
skips the . component so root/.// collapses to root/.

Tab no longer escalates to the browser on a second press — it only
completes the path. The browser opens with the down arrow alone, so
repeated Tab presses never leave the field.
On Windows the reader thread never saw EOF, so a pane whose shell exited
stayed open. portable-pty keeps the pseudoconsole alive for as long as the
master is held, and ClosePseudoConsole — which releases the output pipe's
write end — only runs when that master drops. The wait thread already knew
the child was gone but threw the result away.

It now reports ChildExited, and an ExitPhase state machine holds the exit
back until the channel is dry and a short grace has passed, so output the
host is still copying is not cut off by the caller destroying the pane.

Both new tests answer the pseudoconsole's cursor query first: it is created
with INHERIT_CURSOR, so the child does not run at all until a DSR reply
arrives.
The caret sat still. Driven by our own clock rather than SLOW_BLINK, which
Windows conhost and others ignore outright — the event loop already redraws
every 16 ms whether or not an event arrived, so a phase read per frame is
all the animation needs. The dark half draws a blank so the row does not
shift as it blinks.
`-d` was ignored next to `attach`, so the one-command way in did not exist:
the session had to be started and attached to separately. It now probes the
socket, starts a background session if nothing answers, waits for it to
accept, and attaches.

Probing by connecting rather than by testing the path, because a daemon that
died leaves the socket file behind on Unix. The background copy also drops
`attach` from its arguments — it is always the daemon, never a client.
The browser mixed two metaphors: `→` expanded a tree in place while `.` and
`..` offered file-manager navigation as list rows. Enter could not serve
both — on `..` it answered with `root/../` and closed the browser, which is
the opposite of what reaching for `..` means.

Rows are answers, keys are verbs. `←` on a collapsed depth-0 row already
re-roots to the parent and selects the directory just left, so the way out
was never missing, only duplicated as a row. Enter now means the same thing
everywhere.

Adds the workflow test the moving root exists for: browsing from one
checkout to a sibling without leaving the browser.
…st key

Typing a letter over the prefilled repo path cleared the whole buffer. The
prefill exists to supply a shared prefix — a sibling checkout is the common
case — so throwing it away on the first keystroke discards exactly what it
was there for, with nothing on screen to undo it. Typing now extends.

That leaves nothing reading `RepoInput::prefilled`, so the flag goes, and
with it `repo_input_accept_prefill` and its →/End binding, which existed
only to leave prefill mode. Discarding a path is Esc or Backspace.
nightcrow already builds and tests on macOS, Linux, and Windows in CI, but
nothing said so as a requirement — getting-started only said "Unix and
Windows", and no rule told an author where a platform branch belongs. A
cfg gate added in the wrong place is cheap to write and expensive to find.

Names the three platforms, points at the existing seams (`src/platform/`,
`src/daemon/transport.rs`), and says that gating a test off a platform costs
its coverage there and needs a reason.
The one-command way in is now backgrounding the session and attaching to
it, so the quick start opens with that rather than with the foreground
run — the separate pieces (`-d`, `attach`, foreground, `stop`) follow it
for the cases that want them apart.

Also says out loud what the code has targeted for a while: macOS, Linux,
and Windows, one binary and the same surfaces on all three.
Every hint-bar string already reads `q: detach` and the attach loop logs
"nightcrow detached" when it returns, but the keybinding tables still
said "Quit" — from before the TUI became a client of a session it does
not own. There is no path where the chord ends the daemon; `nightcrow
stop` is that.

Left `q`=Quit in architecture/ui.md alone: that one names the Action
variant, not the label.
`Prefix` holds a root twice — as given and as the filesystem reports it —
so a path that arrives spelled differently can still be placed against
the tree. On Windows the second spelling was `canonicalize`'s verbatim
(`\?\`) form, which nothing in the process ever produces: not libgit2,
not the watcher. It was a prefix of nothing, leaving the literal
spelling to carry the comparison alone.

Where the two disagree — an 8.3 short name in the path against the long
name the filesystem reports — nothing places, and a repository whose
`.git` sits inside its own tree reads as having an external git dir.
`canonicalize_clean` is the form the rest of the tree already compares
against.
@whackur
whackur merged commit 068086a into code0xff:dev Aug 1, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant