diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f7e1d23..fc504106 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,9 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit. ### Added +- **Windows desktop support.** clide builds and runs on Windows: ConPTY-backed + terminals, an AF_UNIX `clide` CLI client, PowerShell as the default shell, and + a `make build-windows` target. tmux is optional there (no Windows build). - **Live tail inside expanded Bash activity cards.** A Bash card that follows a file (`tail -f …`) now shows a live, scrolling read-only tail of that file below the result — connected only while the card is expanded. Commands with no diff --git a/Makefile b/Makefile index 86048b02..67561adf 100644 --- a/Makefile +++ b/Makefile @@ -182,6 +182,10 @@ build-linux: gen-build-info ## flutter build linux (desktop bundle). build-macos: gen-build-info ## flutter build macos (desktop bundle). flutter build macos +.PHONY: build-windows +build-windows: gen-build-info ## flutter build windows (desktop bundle). + flutter build windows + # -- install / uninstall ----------------------------------------------------- # Install prefix. Bundle lands at $(INSTALL_PREFIX)/clide/ with a @@ -196,6 +200,9 @@ ifeq ($(FLUTTER_OS),linux) else ifeq ($(FLUTTER_OS),macos) BUNDLE_DIR := build/macos/Build/Products/Release/clide.app CLI_BUNDLE_DEST := $(BUNDLE_DIR)/Contents/MacOS/clide-cli +else ifeq ($(FLUTTER_OS),windows) + BUNDLE_DIR := build/windows/x64/runner/Release + CLI_BUNDLE_DEST := $(BUNDLE_DIR)/clide-cli.exe endif ICON_SIZES := 16 32 48 128 192 256 512 @@ -291,7 +298,11 @@ dugite-clean: ## Remove the dugite-native directory. # target picks up whatever `cc` is on PATH. CLIDE_CLI_SRC := native/clide-cli/clide.c -CLIDE_CLI_BIN := native/$(if $(filter Darwin,$(shell uname -s)),macos,linux)-$(shell uname -m | sed 's/x86_64/x64/;s/aarch64/arm64/')/clide +ifeq ($(FLUTTER_OS),windows) + CLIDE_CLI_BIN := native/windows-x64/clide.exe +else + CLIDE_CLI_BIN := native/$(if $(filter Darwin,$(shell uname -s)),macos,linux)-$(shell uname -m | sed 's/x86_64/x64/;s/aarch64/arm64/')/clide +endif CC ?= cc .PHONY: clide-cli @@ -299,7 +310,11 @@ clide-cli: $(CLIDE_CLI_BIN) ## Compile the C `clide` shell client. $(CLIDE_CLI_BIN): $(CLIDE_CLI_SRC) @mkdir -p $(dir $(CLIDE_CLI_BIN)) +ifeq ($(FLUTTER_OS),windows) + ci/build_cli_windows.sh +else $(CC) -std=c99 -O2 -Wall -Wextra -o $(CLIDE_CLI_BIN) $(CLIDE_CLI_SRC) +endif @echo "==> built $(CLIDE_CLI_BIN)" .PHONY: clide-cli-clean diff --git a/ci/build_cli_windows.sh b/ci/build_cli_windows.sh new file mode 100644 index 00000000..17aba2e0 --- /dev/null +++ b/ci/build_cli_windows.sh @@ -0,0 +1,33 @@ +#!/bin/sh +# Build the C `clide` client with MSVC on Windows (Git Bash / MSYS). +# Wrapped by `make clide-cli` — don't run directly (see CLAUDE.md +# tooling discipline). Finds the VC++ toolset via vswhere, loads the +# x64 dev environment, compiles: +# native/clide-cli/clide.c -> native/windows-x64/clide.exe +# ws2_32.lib supplies winsock (AF_UNIX socket support). +set -e + +VSWHERE="/c/Program Files (x86)/Microsoft Visual Studio/Installer/vswhere.exe" +if [ ! -x "$VSWHERE" ]; then + echo "vswhere.exe not found — install Visual Studio (Build Tools) with the C++ workload" >&2 + exit 1 +fi +VSROOT=$("$VSWHERE" -products '*' -latest -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath | tr -d '\r') +if [ -z "$VSROOT" ]; then + echo "no Visual Studio C++ x64 toolset found (vswhere returned nothing)" >&2 + exit 1 +fi + +mkdir -p native/windows-x64 +# A generated .bat sidesteps the unwinnable sh->cmd quote escaping for +# the space-laden VS path. //c keeps MSYS from path-mangling cmd's /c +# switch; /Fo drops the .obj next to the .exe so the repo root stays +# clean. +BAT=$(mktemp --suffix=.bat) +trap 'rm -f "$BAT"' EXIT +cat > "$BAT" < dart test (pty — unreliable under the flutter test runner; serial)" # --concurrency=1: these spawn real PTYs and compete for fds when run in # parallel, which flaked them (registry/session). Serialize — the proper fix # for resource-bound tests, vs. the old per-test `retry:` band-aid. (T-193) -dart test -r "$REPORTER" --concurrency=1 --tags pty test/pty/session_test.dart test/panes/registry_test.dart +# windows_pty_test is the ConPTY sibling of session_test; each suite +# self-skips off-platform, so the union always contributes tests. +dart test -r "$REPORTER" --concurrency=1 --tags pty test/pty/session_test.dart test/panes/registry_test.dart test/pty/windows_pty_test.dart # The parallel pool excludes both pty (runs under dart test, above) and # serial-tagged tests (concurrency-vulnerable — run in their own --concurrency=1 diff --git a/lib/builtin/terminal/src/terminal_pane.dart b/lib/builtin/terminal/src/terminal_pane.dart index 397dba5e..c03ce928 100644 --- a/lib/builtin/terminal/src/terminal_pane.dart +++ b/lib/builtin/terminal/src/terminal_pane.dart @@ -66,18 +66,16 @@ class _TerminalPaneState extends State { return; } - final shell = Platform.environment['SHELL'] ?? '/bin/bash'; + // Windows has no $SHELL convention and no login-shell flag — + // PowerShell 7 first, classic PowerShell as the always-there + // fallback. + final shell = Platform.isWindows ? null : (Platform.environment['SHELL'] ?? '/bin/bash'); + final argv = shell != null ? [shell, '-l'] : ['powershell.exe', '-NoLogo']; final cwd = Directory.current.path; final response = await ipc.request( 'pane.spawn', - args: { - 'argv': [shell, '-l'], - 'kind': PaneKind.terminal.wire, - 'cwd': cwd, - 'cols': _terminal.viewWidth, - 'rows': _terminal.viewHeight, - }, + args: {'argv': argv, 'kind': PaneKind.terminal.wire, 'cwd': cwd, 'cols': _terminal.viewWidth, 'rows': _terminal.viewHeight}, ); if (!mounted) return; if (!response.ok) { diff --git a/lib/kernel/src/cli_install.dart b/lib/kernel/src/cli_install.dart index 991cf536..affbd28e 100644 --- a/lib/kernel/src/cli_install.dart +++ b/lib/kernel/src/cli_install.dart @@ -127,7 +127,7 @@ class CliInstaller { '`make build` so the C client ships inside the app bundle.', ); } - final dest = '${_normalize(installDir)}/clide'; + final dest = '${_normalize(installDir)}/clide${Platform.isWindows ? '.exe' : ''}'; try { Directory(installDir).createSync(recursive: true); // Delete any existing entry first so a stale symlink (e.g. one into @@ -180,42 +180,60 @@ class CliInstaller { bool _dirOnPath(String dir) { final norm = _normalize(dir); - return _expandedPath().split(':').any((d) => d.isNotEmpty && _normalize(d) == norm); + return _expandedPath().split(_pathSep).any((d) => d.isNotEmpty && _normalize(d) == norm); } - String _normalize(String p) => p.length > 1 && p.endsWith('/') ? p.substring(0, p.length - 1) : p; + /// Trim a trailing slash; on Windows also fold separators and case so + /// `C:\Users\x/.local/bin` and `c:\users\x\.local\bin` compare equal. + String _normalize(String p) { + var s = p; + if (Platform.isWindows) s = s.replaceAll('\\', '/').toLowerCase(); + return s.length > 1 && s.endsWith('/') ? s.substring(0, s.length - 1) : s; + } String? _findOnPath(String name) { - for (final dir in _expandedPath().split(':')) { + for (final dir in _expandedPath().split(_pathSep)) { if (dir.isEmpty) continue; - final f = File('$dir/$name'); - if (f.existsSync()) return f.path; + if (Platform.isWindows) { + for (final ext in const ['.exe', '.bat', '.cmd', '']) { + final f = File('$dir\\$name$ext'); + if (f.existsSync()) return f.path; + } + } else { + final f = File('$dir/$name'); + if (f.existsSync()) return f.path; + } } return null; } + static String get _pathSep => Platform.isWindows ? ';' : ':'; + String _expandedPath() => expandedPath(env['PATH'] ?? '', macOS: Platform.isMacOS, home: env['HOME'] ?? ''); - static String _defaultInstallDir(Map env) => '${env['HOME'] ?? ''}/.local/bin'; + /// `~/.local/bin` on every platform — on Windows that is + /// `%USERPROFILE%\.local\bin`, the same convention the claude and + /// pql installers use there. + static String _defaultInstallDir(Map env) => '${env['HOME'] ?? env['USERPROFILE'] ?? ''}/.local/bin'; /// Where to find the C client to install from: a `CLIDE_CLI_BIN` dev /// override first, then `/clide-cli` — where `make build` drops it - /// inside the bundle (next to the GUI runner on Linux, in + /// inside the bundle (next to the GUI runner on Linux and Windows, in /// `Contents/MacOS/` on macOS). static List _defaultBundledCandidates(String resolvedExecutable, Map env) { final exeDir = File(resolvedExecutable).parent.path; - return [if ((env['CLIDE_CLI_BIN'] ?? '').isNotEmpty) env['CLIDE_CLI_BIN']!, '$exeDir/clide-cli']; + return [if ((env['CLIDE_CLI_BIN'] ?? '').isNotEmpty) env['CLIDE_CLI_BIN']!, if (Platform.isWindows) '$exeDir/clide-cli.exe' else '$exeDir/clide-cli']; } } -final RegExp _devTreeClient = RegExp(r'(^|/)native/(linux|macos)-(x64|arm64)/clide$'); +final RegExp _devTreeClient = RegExp(r'(^|/)native/(linux|macos|windows)-(x64|arm64)/clide(\.exe)?$'); /// True when [path] is a dev-tree C-client build artifact — /// `native//clide`, the Makefile's `CLIDE_CLI_BIN` output. On a clide /// checkout `make run` points `CLIDE_CLI_BIN` there and a dev may put it on /// PATH; it's a working client but not a packaged production install, so it's /// classified separately (T-256) rather than as a clean install. -bool isDevTreeClient(String path) => _devTreeClient.hasMatch(path); +bool isDevTreeClient(String path) => _devTreeClient.hasMatch(path.replaceAll('\\', '/')); /// Expand a `PATH` value. Mirrors `toolchain_paths.dart`: macOS GUI apps /// launch with a sparse PATH that omits the usual user/homebrew bins, so on diff --git a/lib/kernel/src/tool_check.dart b/lib/kernel/src/tool_check.dart index 04e869ff..56557e2f 100644 --- a/lib/kernel/src/tool_check.dart +++ b/lib/kernel/src/tool_check.dart @@ -19,7 +19,9 @@ class ToolCheck extends ChangeNotifier { Future check() async { pqlOk = _existsOnPath('pql'); - tmuxOk = _existsOnPath('tmux'); + // tmux has no Windows build; absence there is the documented + // no-tmux mode, not a failed check. + tmuxOk = Platform.isWindows || _existsOnPath('tmux'); gitOk = _existsOnPath('git'); checked = true; notifyListeners(); @@ -29,9 +31,16 @@ class ToolCheck extends ChangeNotifier { /// Uses direct file-existence checks — works inside a macOS sandbox /// without needing to exec `which`. static bool _existsOnPath(String name) { - for (final dir in expandedPath.split(':')) { + final sep = Platform.isWindows ? ';' : ':'; + for (final dir in expandedPath.split(sep)) { if (dir.isEmpty) continue; - if (File('$dir/$name').existsSync()) return true; + if (Platform.isWindows) { + for (final ext in const ['.exe', '.bat', '.cmd', '.com', '']) { + if (File('$dir\\$name$ext').existsSync()) return true; + } + } else { + if (File('$dir/$name').existsSync()) return true; + } } return false; } diff --git a/lib/kernel/src/toolchain.dart b/lib/kernel/src/toolchain.dart index 6927137c..8b02cf48 100644 --- a/lib/kernel/src/toolchain.dart +++ b/lib/kernel/src/toolchain.dart @@ -10,6 +10,7 @@ library; import 'dart:async'; +import 'dart:io' show Platform; import 'package:flutter/foundation.dart'; @@ -32,7 +33,7 @@ class Toolchain extends ChangeNotifier implements ToolchainView { @override String get tmux => _tmux ?? 'tmux'; @override - String get shell => _shell ?? '/bin/bash'; + String get shell => _shell ?? (Platform.isWindows ? 'powershell.exe' : '/bin/bash'); /// Extra environment variables for git (e.g. GIT_EXEC_PATH for dugite). @override @@ -44,7 +45,13 @@ class Toolchain extends ChangeNotifier implements ToolchainView { bool get allOk => _resolved && missing.isEmpty; @override - List get missing => [if (_git == null) 'git', if (_pql == null) 'pql', if (_tmux == null) 'tmux']; + List get missing => [ + if (_git == null) 'git', + if (_pql == null) 'pql', + // tmux has no Windows build; its absence there is the documented + // no-tmux mode, not a missing tool. + if (_tmux == null && !Platform.isWindows) 'tmux', + ]; /// Returns a Future that completes when resolution finishes. Future waitForResolution() { diff --git a/lib/kernel/src/toolchain_paths.dart b/lib/kernel/src/toolchain_paths.dart index 8f951e16..c9e78925 100644 --- a/lib/kernel/src/toolchain_paths.dart +++ b/lib/kernel/src/toolchain_paths.dart @@ -52,7 +52,7 @@ class _StaticToolchain implements ToolchainView { @override String get tmux => _paths.tmux ?? 'tmux'; @override - String get shell => _paths.shell ?? '/bin/bash'; + String get shell => _paths.shell ?? (Platform.isWindows ? 'powershell.exe' : '/bin/bash'); @override Map? get gitEnv => _paths.gitEnv; @override @@ -60,7 +60,13 @@ class _StaticToolchain implements ToolchainView { @override bool get allOk => missing.isEmpty; @override - List get missing => [if (_paths.git == null) 'git', if (_paths.pql == null) 'pql', if (_paths.tmux == null) 'tmux']; + List get missing => [ + if (_paths.git == null) 'git', + if (_paths.pql == null) 'pql', + // tmux has no Windows build; its absence there is the documented + // no-tmux mode, not a missing tool. + if (_paths.tmux == null && !Platform.isWindows) 'tmux', + ]; } /// Top-level function for compute/isolate use. Returns a plain-data @@ -83,13 +89,17 @@ ResolvedPaths resolveToolchainPaths() { git = _findOnPath('git'); } - return ResolvedPaths( - git: git, - pql: _findOnPath('pql'), - tmux: _findOnPath('tmux'), - shell: _findOnPath(Platform.environment['SHELL']?.split('/').last ?? 'bash'), - gitEnv: gitEnv, - ); + return ResolvedPaths(git: git, pql: _findOnPath('pql'), tmux: _findOnPath('tmux'), shell: _resolveShell(), gitEnv: gitEnv); +} + +/// The user's interactive shell. POSIX honours `$SHELL`; Windows has +/// no such convention — prefer PowerShell 7 (`pwsh`), fall back to +/// Windows PowerShell (present on every supported Windows). +String? _resolveShell() { + if (Platform.isWindows) { + return _findOnPath('pwsh') ?? _findOnPath('powershell'); + } + return _findOnPath(Platform.environment['SHELL']?.split('/').last ?? 'bash'); } /// Locate the dugite-bundled git binary in trusted install locations @@ -104,6 +114,10 @@ ResolvedPaths resolveToolchainPaths() { /// /// Returns null if no dugite is found; caller falls back to PATH git. String? _resolveDugiteGit() { + // dugite-native's Windows layout differs (cmd\git.exe, mingw64 + // libexec) and isn't wired up yet — PATH git serves Windows until + // the bundle work lands. + if (Platform.isWindows) return null; final candidates = []; final envDir = Platform.environment['CLIDE_DUGITE_DIR']; if (envDir != null && envDir.isNotEmpty) { @@ -116,10 +130,19 @@ String? _resolveDugiteGit() { } String? _findOnPath(String name) { - for (final dir in _expandedPath().split(':')) { + final sep = Platform.isWindows ? ';' : ':'; + for (final dir in _expandedPath().split(sep)) { if (dir.isEmpty) continue; - final f = File('$dir/$name'); - if (f.existsSync()) return f.path; + if (Platform.isWindows) { + // PATHEXT-style probe — a bare `pql` on PATH is really pql.exe. + for (final ext in const ['.exe', '.bat', '.cmd', '.com', '']) { + final f = File('$dir\\$name$ext'); + if (f.existsSync()) return f.path; + } + } else { + final f = File('$dir/$name'); + if (f.existsSync()) return f.path; + } } return null; } diff --git a/lib/src/ipc/paths.dart b/lib/src/ipc/paths.dart index cca1e313..658d5f18 100644 --- a/lib/src/ipc/paths.dart +++ b/lib/src/ipc/paths.dart @@ -4,21 +4,47 @@ import 'dart:io'; /// Resolve the per-workspace Unix-domain socket path served by the /// running clide app. Per D-70: /// -/// Linux: `$XDG_RUNTIME_DIR/clide/.sock` -/// macOS: `$HOME/Library/Caches/clide/.sock` +/// Linux: `$XDG_RUNTIME_DIR/clide/.sock` +/// macOS: `$HOME/Library/Caches/clide/.sock` +/// Windows: `%LOCALAPPDATA%\clide\.sock` (AF_UNIX — supported +/// by winsock since Windows 10 1803 and by dart:io) /// /// The C `clide` client and any other consumer derive the same path /// from the same workspace root, so server + client always agree /// without configuration. String workspaceSocketPath(String workspaceRoot) { final dir = socketDirectory(); - return '$dir/${_hash(workspaceRoot)}.sock'; + return '$dir/${_hash(canonicalWorkspaceKey(workspaceRoot))}.sock'; +} + +/// Canonical form of the workspace root used as the FNV hash input. +/// +/// On Windows one directory has many spellings — either slash kind, +/// any letter case (NTFS is case-insensitive and getcwd preserves +/// whatever the shell typed) — so the server and the C client could +/// derive different hashes for the same workspace. Backslash + +/// ASCII-lower-case is the canonical spelling; the C client applies +/// the same byte-level fold (which is why this is NOT Unicode +/// `toLowerCase()` — the fold must be reproducible over raw UTF-8 +/// bytes in C). POSIX paths pass through untouched. +String canonicalWorkspaceKey(String workspaceRoot) { + if (!Platform.isWindows) return workspaceRoot; + final folded = workspaceRoot.replaceAll('/', r'\'); + final units = folded.codeUnits.map((u) => (u >= 0x41 && u <= 0x5a) ? u + 0x20 : u).toList(); + return String.fromCharCodes(units); } /// Parent directory that holds every per-workspace socket for this -/// user. Created with `0700` on bind (see D-71). Exposed separately -/// so the server can prepare/perm-fix the directory before binding. +/// user. Created with `0700` on bind (see D-71; on Windows the +/// per-user ACL on `%LOCALAPPDATA%` is the equivalent gate). Exposed +/// separately so the server can prepare/perm-fix the directory before +/// binding. String socketDirectory() { + if (Platform.isWindows) { + final local = Platform.environment['LOCALAPPDATA']; + final base = (local != null && local.isNotEmpty) ? local : '${Platform.environment['USERPROFILE'] ?? r'C:\'}\\AppData\\Local'; + return '$base\\clide'; + } if (Platform.isMacOS) { final home = Platform.environment['HOME'] ?? '/tmp'; return '$home/Library/Caches/clide'; diff --git a/lib/src/ipc/server.dart b/lib/src/ipc/server.dart index fd7742a2..b0bf14e7 100644 --- a/lib/src/ipc/server.dart +++ b/lib/src/ipc/server.dart @@ -410,8 +410,12 @@ class IpcServer { // -- internals ------------------------------------------------------------ /// `chmod` via `chmod(1)` because dart:io doesn't expose the - /// syscall on unix. Cheap; only runs at start/stop. + /// syscall on unix. Cheap; only runs at start/stop. No-op on + /// Windows: POSIX modes don't exist there, and the socket lives + /// under `%LOCALAPPDATA%`, whose per-user ACL already provides the + /// user-only gate D-71 wants. Future _chmod(String path, int modeBits) async { + if (Platform.isWindows) return; final octal = modeBits.toRadixString(8).padLeft(3, '0'); final r = await Process.run('chmod', [octal, path]); if (r.exitCode != 0) { diff --git a/lib/src/panes/registry.dart b/lib/src/panes/registry.dart index 5f6c0858..ebca165d 100644 --- a/lib/src/panes/registry.dart +++ b/lib/src/panes/registry.dart @@ -1,6 +1,6 @@ /// [PaneRegistry] — backend-side state for all live panes. /// -/// Owns the [NativePty] per pane, generates `p_N` ids, and forwards +/// Owns the [PtySession] per pane, generates `p_N` ids, and forwards /// pty output + lifecycle changes as IPC events via a [DaemonEventSink]. /// Pane commands (pane.spawn / list / write / resize / close) resolve /// against this registry; extension UIs subscribe to the emitted events. @@ -12,7 +12,7 @@ import 'dart:io' show Platform; import 'dart:typed_data'; import '../ipc/envelope.dart'; -import '../pty/native_pty.dart'; +import '../pty/pty_session.dart'; import 'event_sink.dart'; import 'pane.dart'; @@ -21,7 +21,7 @@ class PaneRegistry { final DaemonEventSink events; final Map _panes = {}; - final Map _sessions = {}; + final Map _sessions = {}; final Map> _subs = {}; int _nextId = 1; @@ -55,7 +55,7 @@ class PaneRegistry { ...?env, }; - final session = NativePty.start(executable: executable, arguments: arguments, columns: cols, rows: rows, workingDirectory: cwd, environment: fullEnv); + final session = startPtySession(executable: executable, arguments: arguments, columns: cols, rows: rows, workingDirectory: cwd, environment: fullEnv); final pane = Pane(id: id, kind: kind, pid: session.pid, argv: argv, cwd: cwd, title: title); _panes[id] = pane; _sessions[id] = session; diff --git a/lib/src/pty/native_pty.dart b/lib/src/pty/native_pty.dart index 27dc1108..d5902f4c 100644 --- a/lib/src/pty/native_pty.dart +++ b/lib/src/pty/native_pty.dart @@ -27,6 +27,7 @@ import 'package:ffi/ffi.dart'; import 'errors.dart'; import '../ipc/errno_mapping.dart' show PosixErrno; import 'ffi/libc.dart' as libc; +import 'pty_session.dart'; // -- structs ---------------------------------------------------------------- @@ -137,8 +138,9 @@ const _kWnohang = 1; // -- NativePty -------------------------------------------------------------- /// A pseudo-terminal backed by forkpty() via Dart FFI. -class NativePty { +class NativePty implements PtySession { final int _fd; + @override final int pid; final _out = StreamController.broadcast(); bool _dead = false; @@ -153,8 +155,10 @@ class NativePty { NativePty._(this._fd, this.pid); /// Byte stream of data produced by the child. + @override Stream get output => _out.stream; + @override bool get isClosed => _dead; /// Spawn a new PTY running [executable] with [arguments]. @@ -393,6 +397,7 @@ class NativePty { /// Write bytes to the child's stdin. Loops on short writes; throws /// [PtyException] (with errno) on failure. Returns the total bytes /// written, which is always [bytes.length] on success. + @override int write(List bytes) { if (_dead || bytes.isEmpty) return 0; final buf = malloc(bytes.length); @@ -420,6 +425,7 @@ class NativePty { /// Resize the terminal. Silently no-ops if the fd is already /// closed; flips [_dead] on EBADF so subsequent calls short-circuit. + @override void resize({required int cols, required int rows}) { if (_dead) return; final ws = calloc<_Winsize>() @@ -435,10 +441,11 @@ class NativePty { _nativeKill(pid, libc.sigwinch); } - /// Send a signal to the child. - bool kill([int signal = libc.sighup]) { + /// Send a signal to the child. Null means SIGHUP. + @override + bool kill([int? signal]) { if (_dead) return false; - return _nativeKill(pid, signal) == 0; + return _nativeKill(pid, signal ?? libc.sighup) == 0; } void _reap() { @@ -457,6 +464,7 @@ class NativePty { /// closing it before the isolate exits creates a window where the /// fd number could be reused and the isolate would briefly poll /// the wrong file. + @override Future close() async { if (_dead) return; _dead = true; diff --git a/lib/src/pty/pty.dart b/lib/src/pty/pty.dart index 5061de0a..2c5691ed 100644 --- a/lib/src/pty/pty.dart +++ b/lib/src/pty/pty.dart @@ -1,8 +1,10 @@ -/// PTY subsystem — spawn child processes under a PTY via posix_openpt() -/// + posix_spawn(), expose their master fd as a byte stream. Desktop -/// IDE's pane model (terminal / Claude / future tmux wrappers) rides on -/// this. +/// PTY subsystem — spawn child processes under a PTY and expose their +/// output as a byte stream. POSIX uses posix_openpt() + posix_spawn(); +/// Windows uses ConPTY. Desktop IDE's pane model (terminal / Claude / +/// future tmux wrappers) rides on this. library; export 'env.dart' show clidePtyEnvDefaults, mergePtyEnv; export 'native_pty.dart' show NativePty; +export 'pty_session.dart' show PtySession, startPtySession; +export 'windows_pty.dart' show WindowsPty; diff --git a/lib/src/pty/pty_session.dart b/lib/src/pty/pty_session.dart new file mode 100644 index 00000000..1bff349c --- /dev/null +++ b/lib/src/pty/pty_session.dart @@ -0,0 +1,71 @@ +/// Platform-neutral PTY session contract + factory. +/// +/// The pane registry (and anything else that spawns PTY children) +/// programs against [PtySession]; [startPtySession] picks the +/// platform backend — `posix_openpt` + `posix_spawn` on Linux/macOS +/// ([NativePty]), ConPTY on Windows ([WindowsPty]). Both backends +/// share the same lifecycle: spawn → byte stream out → write/resize +/// in → EOF on child exit → close() reaps. +library; + +import 'dart:io' show Platform; +import 'dart:typed_data'; + +import 'native_pty.dart'; +import 'windows_pty.dart'; + +abstract interface class PtySession { + /// OS process id of the spawned child. + int get pid; + + /// Byte stream of data produced by the child. + Stream get output; + + bool get isClosed; + + /// Write bytes to the child's stdin. Returns the bytes written. + int write(List bytes); + + /// Resize the terminal. + void resize({required int cols, required int rows}); + + /// Signal the child. [signal] is a POSIX signal number; backends + /// without signals (Windows) treat any value as terminate. Null + /// means the backend's default hang-up behaviour. + bool kill([int? signal]); + + /// Kill the child and release resources. + Future close(); +} + +/// Spawn a child under a PTY using the platform backend. +/// +/// [environment] must be the complete environment — it goes straight +/// to the child. Merge `Platform.environment` before calling. +PtySession startPtySession({ + required String executable, + List arguments = const [], + required int columns, + required int rows, + String? workingDirectory, + Map environment = const {}, +}) { + if (Platform.isWindows) { + return WindowsPty.start( + executable: executable, + arguments: arguments, + columns: columns, + rows: rows, + workingDirectory: workingDirectory, + environment: environment, + ); + } + return NativePty.start( + executable: executable, + arguments: arguments, + columns: columns, + rows: rows, + workingDirectory: workingDirectory, + environment: environment, + ); +} diff --git a/lib/src/pty/windows_pty.dart b/lib/src/pty/windows_pty.dart new file mode 100644 index 00000000..fb88dd88 --- /dev/null +++ b/lib/src/pty/windows_pty.dart @@ -0,0 +1,662 @@ +/// Native PTY on Windows via ConPTY (`CreatePseudoConsole`). +/// +/// Mirrors the POSIX [NativePty] lifecycle (see `native_pty.dart`): +/// spawn a child attached to a pseudo-console, surface its output as +/// a byte stream, accept writes / resizes / kills, reap on close. +/// +/// The Win32 sequence: +/// +/// 1. Two anonymous pipes — one ConPTY reads child input from, one +/// it writes rendered VT output to. +/// 2. `CreatePseudoConsole(size, inRead, outWrite)` → `HPCON`. The +/// conpty-side ends (`inRead` / `outWrite`) must stay open for +/// the pseudo console's whole lifetime: on current Windows 11 +/// the conpty host runs IN-PROCESS and uses these very handles +/// (the old "conhost dups them, close immediately" advice from +/// the EchoCon sample era silently breaks output — the freed +/// handle slot gets recycled and conhost writes land wherever +/// it now points, observed empirically as output appearing on +/// the parent's console). +/// 3. `CreateProcessW` with `EXTENDED_STARTUPINFO_PRESENT`, the +/// `HPCON` attached via `PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE`, +/// and `STARTF_USESTDHANDLES` with NULL std handles — without +/// that a console parent's std handles leak into the child and +/// its stdout bypasses the conpty entirely (also empirical; the +/// conpty handshake still fires, which makes it look attached). +/// 4. A reader isolate blocks on `ReadFile(outRead)`; a waiter +/// isolate blocks on `WaitForSingleObject(hProcess, INFINITE)`. +/// On child exit the waiter reports back and the main isolate +/// calls `ClosePseudoConsole` and closes the conpty-side pipe +/// ends — that breaks the output pipe, so the reader drains +/// whatever is still buffered, sees `ERROR_BROKEN_PIPE`, and +/// sends EOF. +/// +/// Requires Windows 10 1809+ (first ConPTY release). All symbols +/// live in kernel32.dll. Errors carry `GetLastError()` in +/// [PtyException.errno] (a Win32 error code, not a POSIX errno). +library; + +import 'dart:async'; +import 'dart:ffi' as ffi; +import 'dart:io' show File, Platform; +import 'dart:isolate'; +import 'dart:typed_data'; + +import 'package:ffi/ffi.dart'; + +import 'errors.dart'; +import 'pty_session.dart'; + +// -- structs ---------------------------------------------------------------- + +/// Win32 `COORD` — passed BY VALUE to Create/ResizePseudoConsole. +final class _Coord extends ffi.Struct { + @ffi.Int16() + external int x; + @ffi.Int16() + external int y; +} + +/// Win32 `STARTUPINFOEXW`. Field names follow the Win32 struct so the +/// layout is checkable against ``; Dart FFI derives +/// offsets from declaration order + C alignment rules, which match MSVC +/// here (cb is followed by 4 bytes of padding before the first pointer). +final class _StartupInfoExW extends ffi.Struct { + @ffi.Uint32() + external int cb; + external ffi.Pointer lpReserved; + external ffi.Pointer lpDesktop; + external ffi.Pointer lpTitle; + @ffi.Uint32() + external int dwX; + @ffi.Uint32() + external int dwY; + @ffi.Uint32() + external int dwXSize; + @ffi.Uint32() + external int dwYSize; + @ffi.Uint32() + external int dwXCountChars; + @ffi.Uint32() + external int dwYCountChars; + @ffi.Uint32() + external int dwFillAttribute; + @ffi.Uint32() + external int dwFlags; + @ffi.Uint16() + external int wShowWindow; + @ffi.Uint16() + external int cbReserved2; + external ffi.Pointer lpReserved2; + external ffi.Pointer hStdInput; + external ffi.Pointer hStdOutput; + external ffi.Pointer hStdError; + external ffi.Pointer lpAttributeList; +} + +/// Win32 `PROCESS_INFORMATION`. +final class _ProcessInformation extends ffi.Struct { + external ffi.Pointer hProcess; + external ffi.Pointer hThread; + @ffi.Uint32() + external int dwProcessId; + @ffi.Uint32() + external int dwThreadId; +} + +// -- FFI bindings ----------------------------------------------------------- + +final ffi.DynamicLibrary _k32 = ffi.DynamicLibrary.open('kernel32.dll'); + +typedef _Handle = ffi.Pointer; + +final _createPipe = _k32 + .lookupFunction< + ffi.Int32 Function(ffi.Pointer<_Handle>, ffi.Pointer<_Handle>, ffi.Pointer, ffi.Uint32), + int Function(ffi.Pointer<_Handle>, ffi.Pointer<_Handle>, ffi.Pointer, int) + >('CreatePipe'); + +final _createPseudoConsole = _k32 + .lookupFunction< + ffi.Int32 Function(_Coord, _Handle, _Handle, ffi.Uint32, ffi.Pointer<_Handle>), + int Function(_Coord, _Handle, _Handle, int, ffi.Pointer<_Handle>) + >('CreatePseudoConsole'); + +final _resizePseudoConsole = _k32.lookupFunction('ResizePseudoConsole'); + +final _closePseudoConsole = _k32.lookupFunction('ClosePseudoConsole'); + +final _initAttrList = _k32 + .lookupFunction< + ffi.Int32 Function(ffi.Pointer, ffi.Uint32, ffi.Uint32, ffi.Pointer), + int Function(ffi.Pointer, int, int, ffi.Pointer) + >('InitializeProcThreadAttributeList'); + +final _updateAttr = _k32 + .lookupFunction< + ffi.Int32 Function(ffi.Pointer, ffi.Uint32, ffi.IntPtr, ffi.Pointer, ffi.IntPtr, ffi.Pointer, ffi.Pointer), + int Function(ffi.Pointer, int, int, ffi.Pointer, int, ffi.Pointer, ffi.Pointer) + >('UpdateProcThreadAttribute'); + +final _deleteAttrList = _k32.lookupFunction), void Function(ffi.Pointer)>('DeleteProcThreadAttributeList'); + +final _createProcessW = _k32 + .lookupFunction< + ffi.Int32 Function( + ffi.Pointer, + ffi.Pointer, + ffi.Pointer, + ffi.Pointer, + ffi.Int32, + ffi.Uint32, + ffi.Pointer, + ffi.Pointer, + ffi.Pointer<_StartupInfoExW>, + ffi.Pointer<_ProcessInformation>, + ), + int Function( + ffi.Pointer, + ffi.Pointer, + ffi.Pointer, + ffi.Pointer, + int, + int, + ffi.Pointer, + ffi.Pointer, + ffi.Pointer<_StartupInfoExW>, + ffi.Pointer<_ProcessInformation>, + ) + >('CreateProcessW'); + +final _writeFile = _k32 + .lookupFunction< + ffi.Int32 Function(_Handle, ffi.Pointer, ffi.Uint32, ffi.Pointer, ffi.Pointer), + int Function(_Handle, ffi.Pointer, int, ffi.Pointer, ffi.Pointer) + >('WriteFile'); + +final _closeHandle = _k32.lookupFunction('CloseHandle'); + +final _getLastError = _k32.lookupFunction('GetLastError'); + +final _terminateProcess = _k32.lookupFunction('TerminateProcess'); + +final _getExitCodeProcess = _k32.lookupFunction), int Function(_Handle, ffi.Pointer)>( + 'GetExitCodeProcess', +); + +// Constants — duplicated from / . +const int _kExtendedStartupinfoPresent = 0x00080000; +const int _kCreateUnicodeEnvironment = 0x00000400; +const int _kProcThreadAttributePseudoconsole = 0x00020016; +const int _kInfinite = 0xffffffff; +const int _kErrorBrokenPipe = 109; +const int _kStartfUseStdHandles = 0x00000100; + +// -- WindowsPty ------------------------------------------------------------- + +/// A pseudo-terminal backed by ConPTY via Dart FFI. +class WindowsPty implements PtySession { + WindowsPty._(this._hpc, this._hProcess, this._hThread, this._inWrite, this._outRead, this._conptyInRead, this._conptyOutWrite, this.pid); + + /// HPCON — owned until [close] / child exit. + ffi.Pointer _hpc; + final ffi.Pointer _hProcess; + final ffi.Pointer _hThread; + + /// Our end of the child-stdin pipe (we write, ConPTY reads). + final ffi.Pointer _inWrite; + + /// Our end of the child-stdout pipe (ConPTY writes, we read). + final ffi.Pointer _outRead; + + /// The conpty-side pipe ends. Open for the HPCON's lifetime — the + /// in-process conpty host uses them directly; released together + /// with it in [_closeConsole]. Closing our `_conptyOutWrite` copy + /// is also what finally breaks the pipe for the reader's EOF. + final ffi.Pointer _conptyInRead; + final ffi.Pointer _conptyOutWrite; + + @override + final int pid; + + final _out = StreamController.broadcast(); + bool _dead = false; + bool _handlesReleased = false; + + Future? _readerReady; + Isolate? _readerIsolate; + ReceivePort? _readerPort; + Completer? _readerExited; + ReceivePort? _waiterPort; + + @override + Stream get output => _out.stream; + + @override + bool get isClosed => _dead; + + /// Spawn a new ConPTY running [executable] with [arguments]. + /// + /// [environment] must be the complete environment — it becomes the + /// child's whole environment block. Merge `Platform.environment` + /// before calling. + static WindowsPty start({ + required String executable, + List arguments = const [], + required int columns, + required int rows, + String? workingDirectory, + Map environment = const {}, + }) { + executable = _resolveExecutable(executable, environment); + + // ---- Pipes + pseudo console --------------------------------------- + final ha = calloc<_Handle>(); + final hb = calloc<_Handle>(); + if (_createPipe(ha, hb, ffi.nullptr, 0) == 0) { + final err = _getLastError(); + calloc.free(ha); + calloc.free(hb); + throw PtyException('CreatePipe', 'stdin pipe creation failed', errno: err); + } + final inRead = ha.value; + final inWrite = hb.value; + if (_createPipe(ha, hb, ffi.nullptr, 0) == 0) { + final err = _getLastError(); + _closeHandle(inRead); + _closeHandle(inWrite); + calloc.free(ha); + calloc.free(hb); + throw PtyException('CreatePipe', 'stdout pipe creation failed', errno: err); + } + final outRead = ha.value; + final outWrite = hb.value; + calloc.free(ha); + calloc.free(hb); + + final size = calloc<_Coord>() + ..ref.x = columns + ..ref.y = rows; + final hpcOut = calloc<_Handle>(); + final hr = _createPseudoConsole(size.ref, inRead, outWrite, 0, hpcOut); + calloc.free(size); + if (hr != 0) { + _closeHandle(inRead); + _closeHandle(inWrite); + _closeHandle(outRead); + _closeHandle(outWrite); + calloc.free(hpcOut); + throw PtyException('CreatePseudoConsole', 'HRESULT 0x${(hr & 0xffffffff).toRadixString(16)}'); + } + final hpc = hpcOut.value; + calloc.free(hpcOut); + // inRead / outWrite deliberately stay open — the in-process conpty + // uses them for its whole lifetime (see the library docstring). + // _closeConsole() releases them together with the HPCON. + + // ---- Attribute list (attaches the HPCON to the child) ------------- + final sizeOut = calloc(); + _initAttrList(ffi.nullptr, 1, 0, sizeOut); // sizing call; "fails" with ERROR_INSUFFICIENT_BUFFER by design + final attrBytes = sizeOut.value; + final attrList = calloc(attrBytes).cast(); + void freeAttrs() { + calloc.free(attrList); + calloc.free(sizeOut); + } + + void bail(String op, String message) { + final err = _getLastError(); + freeAttrs(); + _closePseudoConsole(hpc); + _closeHandle(inRead); + _closeHandle(outWrite); + _closeHandle(inWrite); + _closeHandle(outRead); + throw PtyException(op, message, errno: err); + } + + if (_initAttrList(attrList, 1, 0, sizeOut) == 0) { + bail('InitializeProcThreadAttributeList', 'attribute list init failed'); + } + // The HPCON itself is lpValue — the attribute machinery stores the + // pointer, it does NOT copy through it. Passing a pointer-to-slot + // here "succeeds" but hands the child a garbage console and ConPTY + // silently produces no output. (Matches the EchoCon sample.) + if (_updateAttr(attrList, 0, _kProcThreadAttributePseudoconsole, hpc, ffi.sizeOf<_Handle>(), ffi.nullptr, ffi.nullptr) == 0) { + _deleteAttrList(attrList); + bail('UpdateProcThreadAttribute', 'attaching HPCON failed'); + } + + // ---- Marshal command line + environment + cwd ---------------------- + // App name stays null so CreateProcessW does its own first-token + // parse (which also gives .bat/.cmd their cmd.exe host); the + // executable is pre-resolved to an absolute path above so no PATH + // ambiguity is left at this point. + final cmdLine = [executable, ...arguments].map(_quoteArg).join(' ').toNativeUtf16(allocator: malloc); + final envBlock = _environmentBlock(environment); + final cwdN = workingDirectory == null ? ffi.nullptr : workingDirectory.toNativeUtf16(allocator: malloc); + + // STARTF_USESTDHANDLES with NULL std handles (calloc zeroes them): + // the console subsystem then assigns conpty-backed handles at + // client connect instead of leaking the parent's (docstring §3). + final si = calloc<_StartupInfoExW>() + ..ref.cb = ffi.sizeOf<_StartupInfoExW>() + ..ref.dwFlags = _kStartfUseStdHandles + ..ref.lpAttributeList = attrList; + final pi = calloc<_ProcessInformation>(); + + final ok = _createProcessW( + ffi.nullptr, + cmdLine, + ffi.nullptr, + ffi.nullptr, + 0, + _kExtendedStartupinfoPresent | _kCreateUnicodeEnvironment, + envBlock.cast(), + cwdN.cast(), + si, + pi, + ); + final spawnErr = ok == 0 ? _getLastError() : 0; + + _deleteAttrList(attrList); + freeAttrs(); + malloc.free(cmdLine); + malloc.free(envBlock); + if (cwdN != ffi.nullptr) malloc.free(cwdN.cast()); + calloc.free(si); + + if (ok == 0) { + calloc.free(pi); + _closePseudoConsole(hpc); + _closeHandle(inRead); + _closeHandle(outWrite); + _closeHandle(inWrite); + _closeHandle(outRead); + throw PtyException('CreateProcessW', 'spawn of $executable failed', errno: spawnErr); + } + + final hProcess = pi.ref.hProcess; + final hThread = pi.ref.hThread; + final childPid = pi.ref.dwProcessId; + calloc.free(pi); + + final pty = WindowsPty._(hpc, hProcess, hThread, inWrite, outRead, inRead, outWrite, childPid); + pty._spawnReader(); + pty._spawnWaiter(); + return pty; + } + + // -- I/O -------------------------------------------------------------- + + void _spawnReader() { + _readerReady = _spawnReaderAsync(); + } + + Future _spawnReaderAsync() async { + final rp = ReceivePort(); + _readerPort = rp; + _readerExited = Completer(); + rp.listen((msg) { + if (msg == null) { + if (!_out.isClosed) _out.close(); + rp.close(); + _readerPort = null; + if (!_readerExited!.isCompleted) _readerExited!.complete(); + _reap(); + } else { + if (!_out.isClosed) _out.add(msg as Uint8List); + } + }); + try { + _readerIsolate = await Isolate.spawn(_readLoop, (rp.sendPort, _outRead.address)); + } catch (e) { + _dead = true; + if (!_out.isClosed) _out.addError(PtyException('reader-spawn', '$e')); + rp.close(); + _readerPort = null; + if (!_readerExited!.isCompleted) _readerExited!.complete(); + } + } + + /// Isolate entry — blocking ReadFile until the ConPTY side closes. + static void _readLoop((SendPort, int) msg) { + final (port, handleAddr) = msg; + final handle = ffi.Pointer.fromAddress(handleAddr); + final k32 = ffi.DynamicLibrary.open('kernel32.dll'); + final readFile = k32 + .lookupFunction< + ffi.Int32 Function(_Handle, ffi.Pointer, ffi.Uint32, ffi.Pointer, ffi.Pointer), + int Function(_Handle, ffi.Pointer, int, ffi.Pointer, ffi.Pointer) + >('ReadFile'); + + final buf = malloc(65536); + final nRead = calloc(); + try { + while (true) { + // Blocks until data, broken pipe (ConPTY closed), or invalid + // handle (close() already released it). + final ok = readFile(handle, buf, 65536, nRead, ffi.nullptr); + if (ok == 0) break; + final n = nRead.value; + if (n == 0) break; + port.send(Uint8List.fromList(buf.asTypedList(n))); + } + } finally { + calloc.free(nRead); + malloc.free(buf); + } + port.send(null); + } + + /// Watches for child exit so the pseudo console can be torn down — + /// without ClosePseudoConsole the output pipe never breaks and the + /// reader would block forever on an exited child. + void _spawnWaiter() { + final wp = ReceivePort(); + _waiterPort = wp; + wp.listen((_) { + wp.close(); + _waiterPort = null; + _closeConsole(); + }); + Isolate.spawn(_waitLoop, (wp.sendPort, _hProcess.address)).catchError((Object e) { + // Fall back to close()-driven teardown; the child just won't be + // auto-reaped on self-exit. + wp.close(); + _waiterPort = null; + return Isolate.current; // satisfies the Future type; unused + }); + } + + static void _waitLoop((SendPort, int) msg) { + final (port, handleAddr) = msg; + final k32 = ffi.DynamicLibrary.open('kernel32.dll'); + final wait = k32.lookupFunction('WaitForSingleObject'); + wait(ffi.Pointer.fromAddress(handleAddr), _kInfinite); + port.send(null); + } + + @override + int write(List bytes) { + if (_dead || bytes.isEmpty) return 0; + final buf = malloc(bytes.length); + final nWritten = calloc(); + try { + for (var i = 0; i < bytes.length; i++) { + buf[i] = bytes[i]; + } + var written = 0; + while (written < bytes.length) { + final ok = _writeFile(_inWrite, buf + written, bytes.length - written, nWritten, ffi.nullptr); + if (ok == 0) { + final err = _getLastError(); + if (err == _kErrorBrokenPipe) _dead = true; + throw PtyException('WriteFile', 'write to ConPTY failed', errno: err); + } + if (nWritten.value == 0) break; + written += nWritten.value; + } + return written; + } finally { + malloc.free(buf); + calloc.free(nWritten); + } + } + + @override + void resize({required int cols, required int rows}) { + if (_dead || _hpc == ffi.nullptr) return; + final size = calloc<_Coord>() + ..ref.x = cols + ..ref.y = rows; + _resizePseudoConsole(_hpc, size.ref); + calloc.free(size); + } + + /// Windows has no signals — any [signal] terminates the child. + @override + bool kill([int? signal]) { + if (_dead || _handlesReleased) return false; + return _terminateProcess(_hProcess, 1) != 0; + } + + /// Close the HPCON and the conpty-side pipe ends, once. With every + /// write end of the output pipe gone the reader drains what's left + /// and EOFs. + void _closeConsole() { + final hpc = _hpc; + if (hpc == ffi.nullptr) return; + _hpc = ffi.nullptr; + _closePseudoConsole(hpc); + _closeHandle(_conptyInRead); + _closeHandle(_conptyOutWrite); + } + + void _reap() { + if (_dead) return; + _dead = true; + _closeConsole(); + _releaseHandles(); + } + + void _releaseHandles() { + if (_handlesReleased) return; + _handlesReleased = true; + final code = calloc(); + _getExitCodeProcess(_hProcess, code); + calloc.free(code); + _closeHandle(_inWrite); + _closeHandle(_outRead); + _closeHandle(_hThread); + _closeHandle(_hProcess); + } + + /// Kill the child and release resources. + /// + /// Order matters, mirroring the POSIX close(): terminate the child, + /// break the output pipe (ClosePseudoConsole), wait for the reader + /// to EOF so nothing touches the handles after we close them. + @override + Future close() async { + if (_dead) return; + _dead = true; + + await _readerReady; + + _terminateProcess(_hProcess, 1); + _closeConsole(); + + if (_readerExited != null) { + await _readerExited!.future.timeout(const Duration(milliseconds: 500), onTimeout: () {}); + } + + _readerIsolate?.kill(priority: Isolate.immediate); + _readerIsolate = null; + _readerPort?.close(); + _readerPort = null; + _waiterPort?.close(); + _waiterPort = null; + + _releaseHandles(); + if (!_out.isClosed) await _out.close(); + } + + // -- spawn helpers ------------------------------------------------------ + + /// Resolve a bare command name against the environment's PATH + + /// PATHEXT (mirrors what the POSIX side does with `:`-split PATH — + /// visible/debuggable resolution instead of CreateProcess magic). + static String _resolveExecutable(String executable, Map environment) { + final pathext = (environment['PATHEXT'] ?? Platform.environment['PATHEXT'] ?? '.COM;.EXE;.BAT;.CMD').split(';').where((e) => e.isNotEmpty).toList(); + final hasKnownExt = pathext.any((e) => executable.toLowerCase().endsWith(e.toLowerCase())); + + Iterable candidates(String base) sync* { + if (hasKnownExt) { + yield base; + } else { + yield base; + for (final ext in pathext) { + yield '$base$ext'; + } + } + } + + if (executable.contains('\\') || executable.contains('/')) { + for (final c in candidates(executable)) { + if (File(c).existsSync()) return c; + } + return executable; + } + final path = environment['PATH'] ?? Platform.environment['PATH'] ?? ''; + for (final dir in path.split(';')) { + if (dir.isEmpty) continue; + for (final c in candidates('$dir\\$executable')) { + if (File(c).existsSync()) return c; + } + } + return executable; + } + + /// Quote one argument per MSVCRT command-line parsing rules. + static String _quoteArg(String arg) { + if (arg.isNotEmpty && !arg.contains(RegExp(r'[ \t"\n\v]'))) return arg; + final b = StringBuffer('"'); + var backslashes = 0; + for (final ch in arg.runes) { + final c = String.fromCharCode(ch); + if (c == r'\') { + backslashes++; + continue; + } + if (c == '"') { + b.write(r'\' * (backslashes * 2 + 1)); + b.write('"'); + backslashes = 0; + continue; + } + if (backslashes > 0) { + b.write(r'\' * backslashes); + backslashes = 0; + } + b.write(c); + } + b.write(r'\' * (backslashes * 2)); + b.write('"'); + return b.toString(); + } + + /// Compose a CREATE_UNICODE_ENVIRONMENT block: `K=V\0...\0\0`, + /// entries sorted case-insensitively by key per CreateProcess docs. + static ffi.Pointer _environmentBlock(Map environment) { + final entries = environment.entries.toList()..sort((a, b) => a.key.toUpperCase().compareTo(b.key.toUpperCase())); + // NUL via fromCharCode — an inline NUL escape in a string literal + // is invisible in review and trips up text tooling. + final nul = String.fromCharCode(0); + final joined = entries.map((e) => '${e.key}=${e.value}$nul').join(); + // toNativeUtf16 appends the final terminating NUL; the explicit one + // after the last entry completes the required double-NUL ending (and + // keeps an empty environment block valid too). + return '$joined$nul'.toNativeUtf16(allocator: malloc); + } +} diff --git a/lib/test_app.dart b/lib/test_app.dart index dc402ffb..e542b8cb 100644 --- a/lib/test_app.dart +++ b/lib/test_app.dart @@ -120,20 +120,28 @@ class _ClideTestAppState extends State { await _testExists('git', tc.git); await _testExists('pql', tc.pql); - await _testExists('tmux', tc.tmux); + // tmux has no Windows build — its absence there is the documented + // no-tmux mode, so the probes would only report a non-failure. + if (!Platform.isWindows) await _testExists('tmux', tc.tmux); await _testExists('shell', tc.shell); _say(''); await _testExec('git --version', tc.git, ['--version'], workDir); await _testExec('pql --version', tc.pql, ['--version'], workDir); - await _testExec('tmux -V', tc.tmux, ['-V'], workDir); - await _testExec('shell --version', tc.shell, ['--version'], workDir); + if (!Platform.isWindows) await _testExec('tmux -V', tc.tmux, ['-V'], workDir); + // PowerShell has no --version flag; ask for the version variable + // through the same -c path the passthrough tests use. + await _testExec('shell --version', tc.shell, Platform.isWindows ? ['-c', r'$PSVersionTable.PSVersion.ToString()'] : ['--version'], workDir); _say(''); // Shell passthrough — use the resolved shell, not a hardcoded path - await _testExec('shell -c git', tc.shell, ['-c', '${tc.git} --version'], workDir); - await _testExec('shell -c pql', tc.shell, ['-c', '${tc.pql} --version'], workDir); - await _testExec('shell -c tmux', tc.shell, ['-c', '${tc.tmux} -V'], workDir); + // (-c works for POSIX shells and as PowerShell's -Command alias). + // PowerShell needs the & call operator to run a quoted path; POSIX + // shells take the bare path. + String shellCall(String exe, String args) => Platform.isWindows ? "& '$exe' $args" : '$exe $args'; + await _testExec('shell -c git', tc.shell, ['-c', shellCall(tc.git, '--version')], workDir); + await _testExec('shell -c pql', tc.shell, ['-c', shellCall(tc.pql, '--version')], workDir); + if (!Platform.isWindows) await _testExec('shell -c tmux', tc.shell, ['-c', shellCall(tc.tmux, '-V')], workDir); await _testExec('shell -c git (bare)', tc.shell, ['-c', 'git --version'], workDir); _say(''); diff --git a/native/clide-cli/clide.c b/native/clide-cli/clide.c index 1ccd5c40..2162d2cc 100644 --- a/native/clide-cli/clide.c +++ b/native/clide-cli/clide.c @@ -7,7 +7,9 @@ * root, same definition the Flutter app uses on boot). * 2. Hashes that path with FNV-1a 64-bit and resolves the per- * workspace socket path per D-70 (Linux: $XDG_RUNTIME_DIR/clide/ - * .sock; macOS: $HOME/Library/Caches/clide/.sock). + * .sock; macOS: $HOME/Library/Caches/clide/.sock; + * Windows: %LOCALAPPDATA%\clide\.sock — AF_UNIX works on + * Windows 10 1803+ via afunix.h). * 3. Connects, sends `{"v":1,"type":"request","id":"", * "cmd":"_argv","args":{"argv":[...]}}` (the server runs * parseArgv on it per T-125), reads the JSON-line response, @@ -15,21 +17,40 @@ * exits with the response's exit code. * * Design notes: - * - No third-party deps. Standard POSIX + a minimal JSON writer - * (string-escape only — we never PARSE JSON, just emit argv into - * it; the response is read whole then printed as-is to stdout). + * - No third-party deps. Standard POSIX / Win32 + a minimal JSON + * writer (string-escape only — we never PARSE JSON, just emit argv + * into it; the response is read whole then printed as-is). * - The argv→IpcRequest translator lives in Dart (T-125). We just * ship argv across the wire under a sentinel cmd `_argv`; the * server unpacks it. * - Workspace-root discovery: we look for `.git` (dir OR file — * submodules use a file). If we don't find one walking upward, * exit with EX_USAGE. + * - Windows hashes the CANONICAL workspace key: backslash + * separators + ASCII-lower-cased UTF-8 bytes, matching + * `canonicalWorkspaceKey` in lib/src/ipc/paths.dart. NTFS is + * case-insensitive, so the same workspace can be spelled many + * ways; both sides fold to one spelling before hashing. * - * Build: `make clide-cli` (see Makefile). Pure C99, builds with - * any gcc / clang / cc. + * Build: `make clide-cli` (see Makefile). Pure C99: gcc / clang / cc + * on POSIX, MSVC cl (+ ws2_32.lib) on Windows. */ +#ifdef _WIN32 +#define WIN32_LEAN_AND_MEAN +#include +#include +#include +#include +#else #define _POSIX_C_SOURCE 200809L +#include +#include +#include +#include +#include +#endif + #include #include #include @@ -37,11 +58,6 @@ #include #include #include -#include -#include -#include -#include -#include #ifdef __APPLE__ #include @@ -52,6 +68,32 @@ #define EX_OSERR 71 #define EX_UNAVAILABLE 69 +/* -- tiny platform shim ------------------------------------------------- */ + +#ifdef _WIN32 +typedef SOCKET sock_t; +#define NET_INVALID INVALID_SOCKET +static int net_read(sock_t s, char *buf, int n) { return recv(s, buf, n, 0); } +static int net_write(sock_t s, const char *buf, int n) { return send(s, buf, n, 0); } +static void net_close(sock_t s) { closesocket(s); } +static int net_errno(void) { return WSAGetLastError(); } +static const char *net_strerror(int e) { + static char msg[256]; + snprintf(msg, sizeof(msg), "winsock error %d", e); + return msg; +} +#define clide_getpid _getpid +#else +typedef int sock_t; +#define NET_INVALID (-1) +static int net_read(sock_t s, char *buf, int n) { return (int)read(s, buf, (size_t)n); } +static int net_write(sock_t s, const char *buf, int n) { return (int)write(s, buf, (size_t)n); } +static void net_close(sock_t s) { close(s); } +static int net_errno(void) { return errno; } +static const char *net_strerror(int e) { return strerror(e); } +#define clide_getpid getpid +#endif + static const uint64_t FNV_OFFSET = 0xcbf29ce484222325ULL; static const uint64_t FNV_PRIME = 0x100000001b3ULL; @@ -65,6 +107,64 @@ static void fnv1a64_hex(const char *s, char out[17]) { snprintf(out, 17, "%016" PRIx64, h); } +#ifdef _WIN32 + +/* Walk CWD upward looking for `.git` using the wide API (the path can + * contain anything; ANSI getcwd would mangle non-ACP characters), then + * emit the CANONICAL UTF-8 key: backslashes + ASCII-folded lower case. + * Mirrors `canonicalWorkspaceKey` in lib/src/ipc/paths.dart. */ +static int find_workspace_root(const char *start, char *out, size_t out_size) { + (void)start; /* CWD-only on Windows; start override is unused. */ + wchar_t cwd[4096]; + DWORD n = GetCurrentDirectoryW(4096, cwd); + if (n == 0 || n >= 4096) return -1; + while (1) { + size_t len = wcslen(cwd); + wchar_t probe[4200]; + _snwprintf(probe, 4200, (len > 0 && cwd[len - 1] == L'\\') ? L"%s.git" : L"%s\\.git", cwd); + probe[4199] = L'\0'; + if (GetFileAttributesW(probe) != INVALID_FILE_ATTRIBUTES) { + int r = WideCharToMultiByte(CP_UTF8, 0, cwd, -1, out, (int)out_size, NULL, NULL); + if (r <= 0) return -1; + /* Canonical fold: '/' -> '\', ASCII upper -> lower. UTF-8 + * continuation bytes have the high bit set, so the ASCII + * fold never touches multi-byte sequences. */ + for (char *p = out; *p; p++) { + if (*p == '/') *p = '\\'; + else if (*p >= 'A' && *p <= 'Z') *p = (char)(*p + 32); + } + return 0; + } + /* Climb one. `C:\foo` -> `C:\`; stop once the drive/UNC root + * itself has been probed. */ + wchar_t *slash = wcsrchr(cwd, L'\\'); + if (!slash) return -1; + if (len <= 3 && cwd[1] == L':') return -1; /* at "X:\" already */ + if (slash == cwd + 2 && cwd[1] == L':') { + cwd[3] = L'\0'; /* keep the root's backslash: "X:\" */ + } else if (slash == cwd) { + return -1; + } else { + *slash = L'\0'; + } + } +} + +/* `%LOCALAPPDATA%\clide\.sock` */ +static int socket_path_for(const char *workspace_root, char *out, size_t out_size) { + char hash[17]; + fnv1a64_hex(workspace_root, hash); + const char *local = getenv("LOCALAPPDATA"); + if (!local || !*local) { + const char *prof = getenv("USERPROFILE"); + if (!prof || !*prof) return -1; + return snprintf(out, out_size, "%s\\AppData\\Local\\clide\\%s.sock", prof, hash); + } + return snprintf(out, out_size, "%s\\clide\\%s.sock", local, hash); +} + +#else /* !_WIN32 */ + /* Walk `start` upward looking for an entry named `.git`. Writes the * containing directory into `out` (PATH_MAX). Returns 0 on success, * -1 if no .git was found before /. */ @@ -112,25 +212,39 @@ static int socket_path_for(const char *workspace_root, char *out, size_t out_siz #endif } -/* Open a UNIX-domain stream socket connected to `path`. Returns fd - * on success, -1 on failure (errno set). */ -static int connect_unix(const char *path) { - int fd = socket(AF_UNIX, SOCK_STREAM, 0); - if (fd < 0) return -1; +#endif /* _WIN32 */ + +/* Open a UNIX-domain stream socket connected to `path`. Returns the + * socket on success, NET_INVALID on failure (net_errno() set). */ +static sock_t connect_unix(const char *path) { +#ifdef _WIN32 + WSADATA wsa; + if (WSAStartup(MAKEWORD(2, 2), &wsa) != 0) return NET_INVALID; + SOCKADDR_UN addr; +#else struct sockaddr_un addr; +#endif + sock_t fd = socket(AF_UNIX, SOCK_STREAM, 0); + if (fd == NET_INVALID) return NET_INVALID; memset(&addr, 0, sizeof(addr)); addr.sun_family = AF_UNIX; if (strlen(path) >= sizeof(addr.sun_path)) { - close(fd); + net_close(fd); +#ifndef _WIN32 errno = ENAMETOOLONG; - return -1; +#endif + return NET_INVALID; } strncpy(addr.sun_path, path, sizeof(addr.sun_path) - 1); if (connect(fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) { - int saved = errno; - close(fd); + int saved = net_errno(); + net_close(fd); +#ifndef _WIN32 errno = saved; - return -1; +#else + WSASetLastError(saved); +#endif + return NET_INVALID; } return fd; } @@ -165,11 +279,11 @@ static void json_escape(const char *s, char *out, size_t out_size) { /* Build the request envelope and write it to `out`. Returns 0 on * success, -1 if any input was too large. */ -static int build_request(int argc, char **argv, pid_t pid, char *out, size_t out_size) { +static int build_request(int argc, char **argv, long long pid, char *out, size_t out_size) { /* Compute argv array size: each arg gets its own escaped JSON. */ int n = snprintf(out, out_size, "{\"type\":\"request\",\"v\":1,\"id\":\"c%lld\",\"cmd\":\"_argv\",\"args\":{\"argv\":[", - (long long)pid); + pid); if (n < 0 || (size_t)n >= out_size) return -1; for (int i = 0; i < argc; i++) { char esc[4096]; @@ -181,15 +295,17 @@ static int build_request(int argc, char **argv, pid_t pid, char *out, size_t out return (n < 0 || (size_t)n >= out_size) ? -1 : 0; } -/* Read one line (terminated by \n) from fd into out. Returns 0 on - * success, -1 on EOF / error. The trailing \n is stripped. */ -static int read_line(int fd, char *out, size_t out_size) { +/* Read one line (terminated by \n) from the socket into out. Returns + * 0 on success, -1 on EOF / error. The trailing \n is stripped. */ +static int read_line(sock_t fd, char *out, size_t out_size) { size_t i = 0; while (i + 1 < out_size) { char c; - ssize_t r = read(fd, &c, 1); + int r = net_read(fd, &c, 1); if (r <= 0) { +#ifndef _WIN32 if (r < 0 && errno == EINTR) continue; +#endif return -1; } if (c == '\n') { @@ -263,32 +379,31 @@ int main(int argc, char **argv) { return EX_SOFTWARE; } - int fd = connect_unix(sock_path); - if (fd < 0) { - fprintf(stderr, "clide: cannot connect to %s: %s\n", sock_path, strerror(errno)); + sock_t fd = connect_unix(sock_path); + if (fd == NET_INVALID) { + fprintf(stderr, "clide: cannot connect to %s: %s\n", sock_path, net_strerror(net_errno())); return EX_UNAVAILABLE; } /* Build + send request. Worst-case envelope sizing: argv totals * plus JSON overhead. 64 KB envelope handles 4 KB args * 16. */ char req[65536]; - if (build_request(argc - 1, argv + 1, getpid(), req, sizeof(req)) != 0) { + if (build_request(argc - 1, argv + 1, (long long)clide_getpid(), req, sizeof(req)) != 0) { fprintf(stderr, "clide: request payload too large\n"); - close(fd); + net_close(fd); return EX_USAGE; } - if (write(fd, req, strlen(req)) != (ssize_t)strlen(req)) { - fprintf(stderr, "clide: write failed: %s\n", strerror(errno)); - close(fd); + if (net_write(fd, req, (int)strlen(req)) != (int)strlen(req)) { + fprintf(stderr, "clide: write failed: %s\n", net_strerror(net_errno())); + net_close(fd); return EX_OSERR; } /* Read the response — one JSON line. */ char resp[65536]; if (read_line(fd, resp, sizeof(resp)) != 0) { - fprintf(stderr, "clide: response read failed: %s\n", - errno ? strerror(errno) : "short read"); - close(fd); + fprintf(stderr, "clide: response read failed: %s\n", net_strerror(net_errno())); + net_close(fd); return EX_OSERR; } @@ -325,14 +440,14 @@ int main(int argc, char **argv) { fputc('\n', stdout); fflush(stdout); } - close(fd); + net_close(fd); return 0; } } - close(fd); + net_close(fd); return 0; } - close(fd); + net_close(fd); const char *code_v = json_value(resp, "code", &code_len); const char *msg_v = json_value(resp, "message", &msg_len); int exit_code = code_v ? (int)strtol(code_v, NULL, 10) : EX_SOFTWARE; diff --git a/test/pty/windows_pty_test.dart b/test/pty/windows_pty_test.dart new file mode 100644 index 00000000..34b17022 --- /dev/null +++ b/test/pty/windows_pty_test.dart @@ -0,0 +1,150 @@ +/// WindowsPty (ConPTY) smoke tests — the Windows sibling of +/// `session_test.dart`. Windows only; skipped elsewhere. +/// +/// Same `tags: ['pty']` discipline as the POSIX suite: tests that +/// depend on the reader isolate delivering ConPTY output run serially +/// via `dart test` per `ci/test.sh`; only the synchronous-throw test +/// stays untagged. +library; + +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:clide/src/pty/errors.dart'; +import 'package:clide/src/pty/windows_pty.dart'; +import 'package:test/test.dart'; + +import '../helpers/timeouts.dart'; + +void main() { + if (!Platform.isWindows) return; + + group('WindowsPty', () { + test('spawns cmd /c echo and reads output', tags: ['pty'], () async { + final s = WindowsPty.start( + executable: 'cmd.exe', + arguments: ['/c', 'echo hello-pty'], + columns: 80, + rows: 24, + environment: {...Platform.environment, 'TERM': 'xterm-256color'}, + ); + addTearDown(s.close); + + final got = await _readUntil(s, 'hello-pty', ioTimeout); + expect(got, contains('hello-pty')); + }); + + test('write sends keystrokes to child', tags: ['pty'], () async { + final s = WindowsPty.start(executable: 'cmd.exe', arguments: [], columns: 80, rows: 24, environment: {...Platform.environment, 'TERM': 'xterm-256color'}); + addTearDown(s.close); + + final buf = StringBuffer(); + final firstByte = Completer(); + final sub = s.output.listen((bytes) { + buf.write(utf8.decode(bytes, allowMalformed: true)); + if (!firstByte.isCompleted) firstByte.complete(); + }); + addTearDown(sub.cancel); + + await firstByte.future.timeout(ioTimeout, onTimeout: () => fail('shell never produced its first byte within ${ioTimeout.inSeconds}s')); + + s.write(utf8.encode('echo write-test-ok\r\n')); + + final result = await _waitForBuffer(buf, 'write-test-ok', ioTimeout); + expect(result, contains('write-test-ok')); + }); + + test('child exit closes the output stream without close()', tags: ['pty'], () async { + // The waiter isolate must ClosePseudoConsole on child exit, or the + // reader blocks forever and pane.exit never fires. + final s = WindowsPty.start(executable: 'cmd.exe', arguments: ['/c', 'echo bye'], columns: 80, rows: 24, environment: {...Platform.environment}); + addTearDown(s.close); + + final done = Completer(); + s.output.listen((_) {}, onDone: () => done.complete()); + await done.future.timeout(ioTimeout, onTimeout: () => fail('output stream did not close within ${ioTimeout.inSeconds}s of child exit')); + expect(s.isClosed, isTrue); + }); + + test('close kills child and closes output', tags: ['pty'], () async { + final s = WindowsPty.start(executable: 'cmd.exe', arguments: [], columns: 80, rows: 24, environment: {...Platform.environment}); + + final done = Completer(); + s.output.listen((_) {}, onDone: () => done.complete()); + + await s.close(); + await done.future.timeout(ioTimeout, onTimeout: () => fail('output stream did not close within ${ioTimeout.inSeconds}s after s.close()')); + expect(s.isClosed, isTrue); + }); + + test('bare command name resolves via PATH + PATHEXT', tags: ['pty'], () async { + // 'cmd' is bare and extension-less; resolution must find cmd.exe. + final s = WindowsPty.start( + executable: 'cmd', + arguments: ['/c', 'echo path-resolution-ok'], + columns: 80, + rows: 24, + environment: {...Platform.environment}, + ); + addTearDown(s.close); + + final got = await _readUntil(s, 'path-resolution-ok', ioTimeout); + expect(got, contains('path-resolution-ok')); + }); + + test('resize survives a live session', tags: ['pty'], () async { + final s = WindowsPty.start(executable: 'cmd.exe', arguments: [], columns: 80, rows: 24, environment: {...Platform.environment}); + addTearDown(s.close); + s.resize(cols: 120, rows: 40); + expect(s.isClosed, isFalse); + }); + + test('non-existent executable surfaces a PtyException at spawn time', () { + // CreateProcessW fails with ERROR_FILE_NOT_FOUND (2) — same code + // POSIX ENOENT happens to use, but asserted independently here. + expect( + () => WindowsPty.start( + executable: 'C:\\clide-no-such-binary-${DateTime.now().microsecondsSinceEpoch}.exe', + arguments: const [], + columns: 80, + rows: 24, + environment: {...Platform.environment}, + ), + throwsA(isA().having((e) => e.errno, 'errno', 2)), + ); + }); + }); +} + +/// Collect output until [needle] appears or [limit] elapses. +Future _readUntil(WindowsPty s, String needle, Duration limit) async { + final buf = StringBuffer(); + final found = Completer(); + final sub = s.output.listen( + (bytes) { + buf.write(utf8.decode(bytes, allowMalformed: true)); + if (!found.isCompleted && buf.toString().contains(needle)) { + found.complete(buf.toString()); + } + }, + onDone: () { + if (!found.isCompleted) found.complete(buf.toString()); + }, + ); + try { + return await found.future.timeout(limit, onTimeout: () => buf.toString()); + } finally { + await sub.cancel(); + } +} + +/// Poll [buf] until it contains [needle] or [limit] elapses. +Future _waitForBuffer(StringBuffer buf, String needle, Duration limit) async { + final deadline = DateTime.now().add(limit); + while (DateTime.now().isBefore(deadline)) { + if (buf.toString().contains(needle)) return buf.toString(); + await Future.delayed(const Duration(milliseconds: 50)); + } + return buf.toString(); +}