Skip to content

Commit 2c5e434

Browse files
committed
feat(windows): disable OpenFlow on Windows, implement port detection, add NSIS bundle config
OpenFlow depends on bash wrapper scripts that don't have Windows equivalents yet. Disable at every layer (UI + backend) for v1 so Windows users see a clear "coming soon" affordance instead of cryptic wrapper-spawn errors. - Add get_platform() Tauri command (std::env::consts::OS) + getPlatform() frontend wrapper + usePlatform() React hook with module-level cache and in-flight dedupe. - sidebar-openflow-section.tsx: Windows branch renders a disabled header with a Tooltip ("OpenFlow is not yet available on Windows"); expand toggle removed, "+" button disabled, NewRunDialog not mounted. - spawn_openflow_agents: early-return Err on cfg(windows) as a safety net for CLI / control-socket callers that bypass the UI. Port detection and reclamation now work on Windows: - ports.rs: detect_listening_ports() dispatches on cfg. Windows path shells out to netstat -ano with CREATE_NO_WINDOW (scan_ports runs every 3 s, so console suppression is non-negotiable), parses TCP + LISTENING rows for both IPv4 and [::]:port IPv6, applies the existing IGNORED and is_codemux_internal_port filters. Process names come from one tasklist /NH /FO csv call per scan (not per port) — PID -> name HashMap is built once. - ports.rs: read_ppid() dispatches on cfg. Windows path uses wmic ... get ParentProcessId /value with graceful None fallback (PPID walk is nice-to-have, not correctness-critical). - agent_browser.rs: kill_process_on_port() Windows branch replaced (was a no-op logging a warning). Now parses netstat -ano in Rust (not via findstr :port — avoids :9223 matching :92230) and runs taskkill /PID /F for each owning PID. Also uses CREATE_NO_WINDOW. - tauri.conf.json: add bundle.windows.nsis so the release pipeline can eventually produce a .exe installer. No code signing yet (budget decision); release.yml itself is still intentionally Linux-only. Out of scope, still tracked: openflow wrapper rewrite, release.yml Windows matrix, os_input.rs Tier 3 input. docs/plans/windows-support.md updated with check-offs and a new "Windows MVP Disable Strategy — OpenFlow" subsection documenting the disable approach and explicitly-deferred items. Verified: cargo check, cargo check --release, cargo test (432 lib + 65 git + 12 github), npm run check, npm run build, npm run test (298) all clean on Linux. Windows cross-compile will run on the existing windows-latest CI matrix.
1 parent 8131bcb commit 2c5e434

10 files changed

Lines changed: 473 additions & 15 deletions

File tree

docs/plans/windows-support.md

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ Web research conducted 2026-04-12 on the three unknowns that gate the Windows ef
106106
- [x] **`src-tauri/src/agent_browser.rs:470, 619, 670``fuser -k {port}/tcp` for port reclamation**
107107
- Frees ports stuck after crash
108108
- Windows: parse `netstat -ano` for PID + `taskkill /PID`, or use `GetExtendedTcpTable` from `windows` crate
109-
- **Partial (2026-04-12)**: extracted the three call sites into a helper `kill_process_on_port(port)`. Unix impl runs `fuser -k`. Windows impl is a no-op that logs a warning; full `netstat -ano` + `taskkill` port-reclamation is tracked as a follow-up.
109+
- **Done (2026-04-12)**: `kill_process_on_port(port)` now has a full `#[cfg(windows)]` implementation. Runs `netstat -ano` with `CREATE_NO_WINDOW` (no console flash), parses `TCP <local>:<port> ... LISTENING <pid>` rows (tolerant of both IPv4 `0.0.0.0:9223` and IPv6 `[::]:9223`), then `taskkill /PID <pid> /F` each owning PID. Filtering in Rust (not `findstr :{port}`) avoids false-positives on substring matches like `:92230`. Unix path (`fuser -k`) unchanged.
110110

111111
### Browser Automation / Tier 3 Input Injection
112112

@@ -203,9 +203,10 @@ Web research conducted 2026-04-12 on the three unknowns that gate the Windows ef
203203

204204
### Build & Distribution
205205

206-
- [ ] **`src-tauri/tauri.conf.json:31``bundle.targets = "all"` but only `linux` section configured**
206+
- [x] **`src-tauri/tauri.conf.json:31``bundle.targets = "all"` but only `linux` section configured**
207207
- No Windows installer config present
208208
- Add `"windows": { "nsis": { "languages": ["English"], "displayLanguageSelector": false } }`. Recommend NSIS over MSI for v1 — smaller, simpler, no Windows SDK required
209+
- **Done (2026-04-12)**: added a `bundle.windows.nsis` section with `languages: ["English"]`, `displayLanguageSelector: false`, and explicit null `installerIcon`/`headerImage`/`sidebarImage` (use Tauri defaults). No code signing yet — that's gated on a budget decision. The release pipeline (`release.yml`) itself is still intentionally Linux-only; this config unblocks the eventual Windows build job so it can produce a `.exe` installer once signing and the release-matrix rewrite land.
209210
- [ ] **`src-tauri/tauri.conf.json:6-9``beforeBuildCommand` / `beforeDevCommand` invoke `bash scripts/copy-agent-browser.sh`**
210211
- Hard-codes `bash`
211212
- Replace with Node.js script (`scripts/copy-agent-browser.mjs`) for true cross-platform, or conditionally invoke a `.ps1` variant
@@ -232,21 +233,44 @@ Web research conducted 2026-04-12 on the three unknowns that gate the Windows ef
232233
- cmd.exe uses `%VAR%`, PowerShell uses `$env:VAR` — bare `$VAR` won't expand
233234
- Verify env is passed via `Command::env()` not via shell string; if spawning goes through a shell, rewrite to pass directly
234235

236+
### Windows MVP Disable Strategy — OpenFlow
237+
238+
OpenFlow depends on the bash wrapper scripts generated in `openflow/prompts.rs` (`ensure_wrapper_exists` / `ensure_claude_wrapper_exists`). Those don't have Windows equivalents yet. Rather than ship a broken feature that fails with cryptic wrapper-spawn errors, OpenFlow is disabled at every layer on Windows for v1. Re-enabling is a follow-up blocked on the wrapper rewrite — Option B under the Agent Integration item above (refactor to spawn adapters directly from Rust, eliminating the wrapper entirely, is the preferred path).
239+
240+
- [x] **`src-tauri/src/commands/mod.rs` — new `get_platform()` Tauri command**
241+
- Returns `std::env::consts::OS` as a `String`. Enables feature gating from the React frontend without needing per-feature cfg flags.
242+
- Wired into `lib.rs` invoke_handler as `commands::get_platform`.
243+
- **Done (2026-04-12)**: new module-level command in `commands/mod.rs` just above `get_detected_ports`. Sibling frontend wrapper `getPlatform()` added to `src/tauri/commands.ts`.
244+
- [x] **`src/hooks/use-platform.ts``usePlatform()` React hook**
245+
- Module-level cache keyed on first successful resolve (OS doesn't change during a session). In-flight invocations are deduped via a shared promise so N concurrent `usePlatform()` consumers share one IPC round-trip. Failure falls back to an empty string so a broken invoke never hides OpenFlow from Linux users (false-positive Windows detection).
246+
- **Done (2026-04-12)**: exposes `{ os, loading, isWindows }`; second + subsequent mounts return the cached value synchronously on the first render.
247+
- [x] **`src/components/layout/sidebar-openflow-section.tsx` — disabled header on Windows**
248+
- Renders a greyed-out header with a `Tooltip` explaining "OpenFlow is not yet available on Windows" when `isWindows`. The `+` button is `disabled` / `aria-disabled`, the expand toggle is removed, and `NewRunDialog` is not mounted at all — nothing to open it from. The section stays visible so Windows users know the feature exists and is coming.
249+
- **Done (2026-04-12)**: Windows branch is a completely separate `if (isWindows) return ...` at the top of the component, leaving the Linux code path byte-identical.
250+
- [x] **`src-tauri/src/commands/openflow.rs:498``spawn_openflow_agents` Windows guard**
251+
- Safety net for CLI or socket-driven callers that might bypass the UI (e.g. the `codemux` control socket). Early-returns `Err("OpenFlow is not yet available on Windows")` when `cfg!(windows)`, before any wrapper-script creation attempts.
252+
- **Done (2026-04-12)**: guard added as the first statement of the command body, with an inline comment pointing at the wrapper dependency and the UI-layer disable.
253+
254+
**Explicitly out of scope for this pass**: no refactoring of `src-tauri/src/openflow/` wrapper scripts, no changes to `release.yml`, no changes to `os_input.rs` (Tier 3 input is deferred). Those remain tracked under their own items above.
255+
235256
---
236257

237258
## Degraded (feature broken, app runs)
238259

239260
### Process / Port Detection
240261

241-
- [ ] **`src-tauri/src/ports.rs:38-63``detect_listening_ports()` parses `/proc/net/tcp` and `/proc/net/tcp6`**
262+
- [x] **`src-tauri/src/ports.rs:38-63``detect_listening_ports()` parses `/proc/net/tcp` and `/proc/net/tcp6`**
242263
- Port discovery for UI
243264
- Windows: `GetExtendedTcpTable()` via `windows` crate, or accept empty list on Windows (users type port manually)
244-
- [ ] **`src-tauri/src/ports.rs:120``/proc/*/fd/` symlink scan to map sockets → PIDs**
265+
- **Done (2026-04-12)**: `detect_listening_ports()` now dispatches on cfg. Linux path uses the existing `/proc/net/tcp` + `/proc/*/fd/` scan unchanged. Windows path (`windows_impl::detect_listening_ports`) shells out to `netstat -ano` with `CREATE_NO_WINDOW` (no console flash — `scan_ports` runs every 3 s, so the suppression flag is non-negotiable), filters for `TCP ... LISTENING` rows, and parses `<local>:<port> <pid>` for both IPv4 `0.0.0.0:135` and IPv6 `[::]:135` addresses. Existing `IGNORED_PORTS` and `is_codemux_internal_port` filters are applied on both platforms.
266+
- [x] **`src-tauri/src/ports.rs:120``/proc/*/fd/` symlink scan to map sockets → PIDs**
245267
- Owner process attribution for detected ports
246268
- `GetExtendedTcpTable` returns PID directly; no fd scan needed
247-
- [ ] **`src-tauri/src/ports.rs:156-166``read_ppid()` reads `/proc/{pid}/stat` for parent PID walk**
269+
- **Done (2026-04-12)**: the Windows path gets PID directly from `netstat -ano` — no fd scan. Process names come from a single `tasklist /NH /FO csv` call per scan (NOT per port — we build a PID→name `HashMap` once), parsing the first two CSV cells as `name,pid`. Shelling out chose over `GetExtendedTcpTable` to avoid adding the `windows` crate dep for MVP; can swap later if the 3-second-interval `netstat` spawn proves too slow.
270+
- [x] **`src-tauri/src/ports.rs:156-166``read_ppid()` reads `/proc/{pid}/stat` for parent PID walk**
248271
- Walks up process tree to attribute ports to workspaces
249272
- Windows: `CreateToolhelp32Snapshot` + `Process32First/Next`
273+
- **Done (2026-04-12)**: `read_ppid` now dispatches on cfg. Linux unchanged. Windows implementation shells out to `wmic process where "ProcessId=<pid>" get ParentProcessId /value` and parses the `ParentProcessId=<n>` line. Deliberately avoids `CreateToolhelp32Snapshot` (would require the `windows` crate) — the PPID walk is a nice-to-have for workspace attribution, not a correctness requirement, so `None` is an acceptable graceful degradation. Follow-up: `wmic` is deprecated on Windows 11 24H2+; if this stops working, swap to PowerShell `Get-CimInstance` or add the `windows` crate dep.
250274
- [ ] **`src-tauri/src/hooks.rs:195-219``shell_is_foreground()` reads `/proc/{shell_pid}/stat` for tpgid vs pgrp**
251275
- Suppresses hook notifications when shell is backgrounded
252276
- Already has `#[cfg(not(target_os = "linux"))]` stub returning `false`; hook notifications always fire on Windows (acceptable)

src-tauri/src/agent_browser.rs

Lines changed: 57 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -104,13 +104,63 @@ pub fn kill_stream_daemons() {
104104
fn kill_process_on_port(port: u16) {
105105
#[cfg(windows)]
106106
{
107-
// TODO: parse `netstat -ano | findstr :{port}` for PIDs and run
108-
// `taskkill /PID {pid} /F`. For now, log and skip — the agent-browser
109-
// daemon handles most reclamation itself via session name.
110-
eprintln!(
111-
"[codemux::browser] kill_process_on_port({}) skipped on Windows (not yet implemented)",
112-
port
113-
);
107+
// Parse `netstat -ano` for rows with `TCP <local>:<port> ... LISTENING <pid>`
108+
// and then `taskkill /PID {pid} /F` each owning PID. We do the filter in
109+
// Rust (rather than piping through `findstr :{port}`) so we can tolerate
110+
// both IPv4 (`0.0.0.0:9223`) and IPv6 (`[::]:9223`) rows uniformly and
111+
// skip unrelated ports that happen to contain the port number as a
112+
// substring (e.g. `:92230`).
113+
//
114+
// CREATE_NO_WINDOW suppresses a console flash each time kill_process_on_port
115+
// runs (startup, port allocation, session close).
116+
use std::os::windows::process::CommandExt;
117+
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
118+
119+
let Ok(netstat) = std::process::Command::new("netstat")
120+
.args(["-ano"])
121+
.creation_flags(CREATE_NO_WINDOW)
122+
.output()
123+
else {
124+
eprintln!(
125+
"[codemux::browser] kill_process_on_port({}): failed to spawn netstat",
126+
port
127+
);
128+
return;
129+
};
130+
if !netstat.status.success() {
131+
return;
132+
}
133+
134+
let stdout = String::from_utf8_lossy(&netstat.stdout);
135+
let mut pids: std::collections::HashSet<u32> = std::collections::HashSet::new();
136+
for line in stdout.lines() {
137+
let fields: Vec<&str> = line.split_whitespace().collect();
138+
if fields.len() < 5 {
139+
continue;
140+
}
141+
if fields[0] != "TCP" || fields[3] != "LISTENING" {
142+
continue;
143+
}
144+
let Some(port_str) = fields[1].rsplit(':').next() else {
145+
continue;
146+
};
147+
let Ok(listen_port) = port_str.parse::<u16>() else {
148+
continue;
149+
};
150+
if listen_port != port {
151+
continue;
152+
}
153+
if let Ok(pid) = fields[4].parse::<u32>() {
154+
pids.insert(pid);
155+
}
156+
}
157+
158+
for pid in pids {
159+
let _ = std::process::Command::new("taskkill")
160+
.args(["/PID", &pid.to_string(), "/F"])
161+
.creation_flags(CREATE_NO_WINDOW)
162+
.output();
163+
}
114164
}
115165
#[cfg(not(windows))]
116166
{

src-tauri/src/commands/mod.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,19 @@ pub async fn pick_files_dialog<R: Runtime>(
261261
rx.await.map_err(|error| error.to_string())
262262
}
263263

264+
// ---- Platform info ----
265+
266+
/// Returns the current OS as reported by `std::env::consts::OS`.
267+
///
268+
/// Values are the standard Rust target strings: `"linux"`, `"macos"`,
269+
/// `"windows"`, `"freebsd"`, `"android"`, `"ios"`, etc. The frontend uses this
270+
/// to gate Windows-incompatible features (e.g., OpenFlow — the bash wrapper
271+
/// scripts in `openflow::prompts` do not have Windows equivalents yet).
272+
#[tauri::command]
273+
pub fn get_platform() -> String {
274+
std::env::consts::OS.to_string()
275+
}
276+
264277
// ---- Port management ----
265278

266279
#[tauri::command]

src-tauri/src/commands/openflow.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -506,6 +506,14 @@ pub fn spawn_openflow_agents(
506506
working_directory: String,
507507
agent_configs: Vec<AgentConfig>,
508508
) -> Result<Vec<String>, String> {
509+
// OpenFlow depends on bash wrapper scripts (ensure_wrapper_exists /
510+
// ensure_claude_wrapper_exists below) that do not have Windows equivalents
511+
// yet. The UI already hides/disables the section on Windows; this is the
512+
// safety net for CLI or socket-driven callers that bypass the sidebar.
513+
if cfg!(windows) {
514+
return Err("OpenFlow is not yet available on Windows".to_string());
515+
}
516+
509517
let log_path = Orchestrator::comm_log_path(&run_id);
510518
if let Some(parent) = log_path.parent() {
511519
std::fs::create_dir_all(parent)

src-tauri/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -547,6 +547,7 @@ pub fn run() {
547547
Ok(())
548548
})
549549
.invoke_handler(tauri::generate_handler![
550+
commands::get_platform,
550551
commands::get_current_theme,
551552
commands::get_shell_appearance,
552553
commands::get_app_state,

0 commit comments

Comments
 (0)