port clide runtime to Windows (ConPTY, AF_UNIX, PATHEXT)
Bring the runtime up on Windows without disturbing the POSIX paths. PTY: introduce a platform-neutral PtySession contract with a factory that picks NativePty (posix_openpt/posix_spawn) or the new WindowsPty (ConPTY via CreatePseudoConsole). The pane registry programs against the interface; NativePty now implements it. IPC: the per-workspace AF_UNIX socket lives under %LOCALAPPDATA% and is hashed from a canonical workspace key (backslash + ASCII-folded case) so the Dart server and the C client agree despite NTFS case- insensitivity. The C client grows a Win32 shim (winsock afunix); chmod is a no-op on Windows where the per-user ACL is the gate. Toolchain: PATH probing splits on ';' and tries PATHEXT extensions; the shell defaults to PowerShell (pwsh, then powershell); tmux is treated as optional since it has no Windows build; dugite falls back to PATH git for now. Build: add `make build-windows`, a clide-cli MSVC build wrapped by ci/build_cli_windows.sh, and a ConPTY smoke-test suite that self- skips off-platform. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" <<EOF
|
||||
@call "$VSROOT\\Common7\\Tools\\VsDevCmd.bat" -arch=amd64 -no_logo
|
||||
@cl /nologo /O2 /W4 /D_CRT_SECURE_NO_WARNINGS native\\clide-cli\\clide.c /Fonative\\windows-x64\\ /Fe:native\\windows-x64\\clide.exe ws2_32.lib
|
||||
EOF
|
||||
cmd.exe //c "$(cygpath -w "$BAT")"
|
||||
rm -f native/windows-x64/clide.obj
|
||||
+3
-1
@@ -35,7 +35,9 @@ echo "==> 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
|
||||
|
||||
@@ -66,18 +66,16 @@ class _TerminalPaneState extends State<TerminalPane> {
|
||||
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) {
|
||||
|
||||
@@ -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<String, String> 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<String, String> env) => '${env['HOME'] ?? env['USERPROFILE'] ?? ''}/.local/bin';
|
||||
|
||||
/// Where to find the C client to install from: a `CLIDE_CLI_BIN` dev
|
||||
/// override first, then `<exe-dir>/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<String> _defaultBundledCandidates(String resolvedExecutable, Map<String, String> 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/<platform>/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
|
||||
|
||||
@@ -19,7 +19,9 @@ class ToolCheck extends ChangeNotifier {
|
||||
|
||||
Future<void> 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;
|
||||
}
|
||||
|
||||
@@ -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<String> get missing => [if (_git == null) 'git', if (_pql == null) 'pql', if (_tmux == null) 'tmux'];
|
||||
List<String> 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<void> waitForResolution() {
|
||||
|
||||
@@ -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<String, String>? get gitEnv => _paths.gitEnv;
|
||||
@override
|
||||
@@ -60,7 +60,13 @@ class _StaticToolchain implements ToolchainView {
|
||||
@override
|
||||
bool get allOk => missing.isEmpty;
|
||||
@override
|
||||
List<String> get missing => [if (_paths.git == null) 'git', if (_paths.pql == null) 'pql', if (_paths.tmux == null) 'tmux'];
|
||||
List<String> 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 = <String>[];
|
||||
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;
|
||||
}
|
||||
|
||||
+31
-5
@@ -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/<hash>.sock`
|
||||
/// macOS: `$HOME/Library/Caches/clide/<hash>.sock`
|
||||
/// Linux: `$XDG_RUNTIME_DIR/clide/<hash>.sock`
|
||||
/// macOS: `$HOME/Library/Caches/clide/<hash>.sock`
|
||||
/// Windows: `%LOCALAPPDATA%\clide\<hash>.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';
|
||||
|
||||
@@ -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<void> _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) {
|
||||
|
||||
@@ -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<String, Pane> _panes = {};
|
||||
final Map<String, NativePty> _sessions = {};
|
||||
final Map<String, PtySession> _sessions = {};
|
||||
final Map<String, StreamSubscription<Uint8List>> _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;
|
||||
|
||||
@@ -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<Uint8List>.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<Uint8List> 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<int> bytes) {
|
||||
if (_dead || bytes.isEmpty) return 0;
|
||||
final buf = malloc<ffi.Uint8>(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<void> close() async {
|
||||
if (_dead) return;
|
||||
_dead = true;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<Uint8List> get output;
|
||||
|
||||
bool get isClosed;
|
||||
|
||||
/// Write bytes to the child's stdin. Returns the bytes written.
|
||||
int write(List<int> 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<void> 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<String> arguments = const [],
|
||||
required int columns,
|
||||
required int rows,
|
||||
String? workingDirectory,
|
||||
Map<String, String> 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,
|
||||
);
|
||||
}
|
||||
@@ -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 `<processthreadsapi.h>`; 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<ffi.Void> lpReserved;
|
||||
external ffi.Pointer<ffi.Void> lpDesktop;
|
||||
external ffi.Pointer<ffi.Void> 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<ffi.Void> lpReserved2;
|
||||
external ffi.Pointer<ffi.Void> hStdInput;
|
||||
external ffi.Pointer<ffi.Void> hStdOutput;
|
||||
external ffi.Pointer<ffi.Void> hStdError;
|
||||
external ffi.Pointer<ffi.Void> lpAttributeList;
|
||||
}
|
||||
|
||||
/// Win32 `PROCESS_INFORMATION`.
|
||||
final class _ProcessInformation extends ffi.Struct {
|
||||
external ffi.Pointer<ffi.Void> hProcess;
|
||||
external ffi.Pointer<ffi.Void> 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<ffi.Void>;
|
||||
|
||||
final _createPipe = _k32
|
||||
.lookupFunction<
|
||||
ffi.Int32 Function(ffi.Pointer<_Handle>, ffi.Pointer<_Handle>, ffi.Pointer<ffi.Void>, ffi.Uint32),
|
||||
int Function(ffi.Pointer<_Handle>, ffi.Pointer<_Handle>, ffi.Pointer<ffi.Void>, 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<ffi.Int32 Function(_Handle, _Coord), int Function(_Handle, _Coord)>('ResizePseudoConsole');
|
||||
|
||||
final _closePseudoConsole = _k32.lookupFunction<ffi.Void Function(_Handle), void Function(_Handle)>('ClosePseudoConsole');
|
||||
|
||||
final _initAttrList = _k32
|
||||
.lookupFunction<
|
||||
ffi.Int32 Function(ffi.Pointer<ffi.Void>, ffi.Uint32, ffi.Uint32, ffi.Pointer<ffi.IntPtr>),
|
||||
int Function(ffi.Pointer<ffi.Void>, int, int, ffi.Pointer<ffi.IntPtr>)
|
||||
>('InitializeProcThreadAttributeList');
|
||||
|
||||
final _updateAttr = _k32
|
||||
.lookupFunction<
|
||||
ffi.Int32 Function(ffi.Pointer<ffi.Void>, ffi.Uint32, ffi.IntPtr, ffi.Pointer<ffi.Void>, ffi.IntPtr, ffi.Pointer<ffi.Void>, ffi.Pointer<ffi.Void>),
|
||||
int Function(ffi.Pointer<ffi.Void>, int, int, ffi.Pointer<ffi.Void>, int, ffi.Pointer<ffi.Void>, ffi.Pointer<ffi.Void>)
|
||||
>('UpdateProcThreadAttribute');
|
||||
|
||||
final _deleteAttrList = _k32.lookupFunction<ffi.Void Function(ffi.Pointer<ffi.Void>), void Function(ffi.Pointer<ffi.Void>)>('DeleteProcThreadAttributeList');
|
||||
|
||||
final _createProcessW = _k32
|
||||
.lookupFunction<
|
||||
ffi.Int32 Function(
|
||||
ffi.Pointer<Utf16>,
|
||||
ffi.Pointer<Utf16>,
|
||||
ffi.Pointer<ffi.Void>,
|
||||
ffi.Pointer<ffi.Void>,
|
||||
ffi.Int32,
|
||||
ffi.Uint32,
|
||||
ffi.Pointer<ffi.Void>,
|
||||
ffi.Pointer<Utf16>,
|
||||
ffi.Pointer<_StartupInfoExW>,
|
||||
ffi.Pointer<_ProcessInformation>,
|
||||
),
|
||||
int Function(
|
||||
ffi.Pointer<Utf16>,
|
||||
ffi.Pointer<Utf16>,
|
||||
ffi.Pointer<ffi.Void>,
|
||||
ffi.Pointer<ffi.Void>,
|
||||
int,
|
||||
int,
|
||||
ffi.Pointer<ffi.Void>,
|
||||
ffi.Pointer<Utf16>,
|
||||
ffi.Pointer<_StartupInfoExW>,
|
||||
ffi.Pointer<_ProcessInformation>,
|
||||
)
|
||||
>('CreateProcessW');
|
||||
|
||||
final _writeFile = _k32
|
||||
.lookupFunction<
|
||||
ffi.Int32 Function(_Handle, ffi.Pointer<ffi.Uint8>, ffi.Uint32, ffi.Pointer<ffi.Uint32>, ffi.Pointer<ffi.Void>),
|
||||
int Function(_Handle, ffi.Pointer<ffi.Uint8>, int, ffi.Pointer<ffi.Uint32>, ffi.Pointer<ffi.Void>)
|
||||
>('WriteFile');
|
||||
|
||||
final _closeHandle = _k32.lookupFunction<ffi.Int32 Function(_Handle), int Function(_Handle)>('CloseHandle');
|
||||
|
||||
final _getLastError = _k32.lookupFunction<ffi.Uint32 Function(), int Function()>('GetLastError');
|
||||
|
||||
final _terminateProcess = _k32.lookupFunction<ffi.Int32 Function(_Handle, ffi.Uint32), int Function(_Handle, int)>('TerminateProcess');
|
||||
|
||||
final _getExitCodeProcess = _k32.lookupFunction<ffi.Int32 Function(_Handle, ffi.Pointer<ffi.Uint32>), int Function(_Handle, ffi.Pointer<ffi.Uint32>)>(
|
||||
'GetExitCodeProcess',
|
||||
);
|
||||
|
||||
// Constants — duplicated from <processthreadsapi.h> / <winbase.h>.
|
||||
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<ffi.Void> _hpc;
|
||||
final ffi.Pointer<ffi.Void> _hProcess;
|
||||
final ffi.Pointer<ffi.Void> _hThread;
|
||||
|
||||
/// Our end of the child-stdin pipe (we write, ConPTY reads).
|
||||
final ffi.Pointer<ffi.Void> _inWrite;
|
||||
|
||||
/// Our end of the child-stdout pipe (ConPTY writes, we read).
|
||||
final ffi.Pointer<ffi.Void> _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<ffi.Void> _conptyInRead;
|
||||
final ffi.Pointer<ffi.Void> _conptyOutWrite;
|
||||
|
||||
@override
|
||||
final int pid;
|
||||
|
||||
final _out = StreamController<Uint8List>.broadcast();
|
||||
bool _dead = false;
|
||||
bool _handlesReleased = false;
|
||||
|
||||
Future<void>? _readerReady;
|
||||
Isolate? _readerIsolate;
|
||||
ReceivePort? _readerPort;
|
||||
Completer<void>? _readerExited;
|
||||
ReceivePort? _waiterPort;
|
||||
|
||||
@override
|
||||
Stream<Uint8List> 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<String> arguments = const [],
|
||||
required int columns,
|
||||
required int rows,
|
||||
String? workingDirectory,
|
||||
Map<String, String> 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<ffi.IntPtr>();
|
||||
_initAttrList(ffi.nullptr, 1, 0, sizeOut); // sizing call; "fails" with ERROR_INSUFFICIENT_BUFFER by design
|
||||
final attrBytes = sizeOut.value;
|
||||
final attrList = calloc<ffi.Uint8>(attrBytes).cast<ffi.Void>();
|
||||
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<ffi.Uint8>());
|
||||
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<void> _spawnReaderAsync() async {
|
||||
final rp = ReceivePort();
|
||||
_readerPort = rp;
|
||||
_readerExited = Completer<void>();
|
||||
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<ffi.Void>.fromAddress(handleAddr);
|
||||
final k32 = ffi.DynamicLibrary.open('kernel32.dll');
|
||||
final readFile = k32
|
||||
.lookupFunction<
|
||||
ffi.Int32 Function(_Handle, ffi.Pointer<ffi.Uint8>, ffi.Uint32, ffi.Pointer<ffi.Uint32>, ffi.Pointer<ffi.Void>),
|
||||
int Function(_Handle, ffi.Pointer<ffi.Uint8>, int, ffi.Pointer<ffi.Uint32>, ffi.Pointer<ffi.Void>)
|
||||
>('ReadFile');
|
||||
|
||||
final buf = malloc<ffi.Uint8>(65536);
|
||||
final nRead = calloc<ffi.Uint32>();
|
||||
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<Isolate> type; unused
|
||||
});
|
||||
}
|
||||
|
||||
static void _waitLoop((SendPort, int) msg) {
|
||||
final (port, handleAddr) = msg;
|
||||
final k32 = ffi.DynamicLibrary.open('kernel32.dll');
|
||||
final wait = k32.lookupFunction<ffi.Uint32 Function(_Handle, ffi.Uint32), int Function(_Handle, int)>('WaitForSingleObject');
|
||||
wait(ffi.Pointer<ffi.Void>.fromAddress(handleAddr), _kInfinite);
|
||||
port.send(null);
|
||||
}
|
||||
|
||||
@override
|
||||
int write(List<int> bytes) {
|
||||
if (_dead || bytes.isEmpty) return 0;
|
||||
final buf = malloc<ffi.Uint8>(bytes.length);
|
||||
final nWritten = calloc<ffi.Uint32>();
|
||||
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<ffi.Uint32>();
|
||||
_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<void> 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<String, String> 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<String> 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<Utf16> _environmentBlock(Map<String, String> 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);
|
||||
}
|
||||
}
|
||||
+14
-6
@@ -120,20 +120,28 @@ class _ClideTestAppState extends State<ClideTestApp> {
|
||||
|
||||
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('');
|
||||
|
||||
|
||||
+156
-41
@@ -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/
|
||||
* <hash>.sock; macOS: $HOME/Library/Caches/clide/<hash>.sock).
|
||||
* <hash>.sock; macOS: $HOME/Library/Caches/clide/<hash>.sock;
|
||||
* Windows: %LOCALAPPDATA%\clide\<hash>.sock — AF_UNIX works on
|
||||
* Windows 10 1803+ via afunix.h).
|
||||
* 3. Connects, sends `{"v":1,"type":"request","id":"<pid>",
|
||||
* "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 <winsock2.h>
|
||||
#include <afunix.h>
|
||||
#include <windows.h>
|
||||
#include <process.h>
|
||||
#else
|
||||
#define _POSIX_C_SOURCE 200809L
|
||||
#include <sys/socket.h>
|
||||
#include <sys/stat.h>
|
||||
#include <sys/types.h>
|
||||
#include <sys/un.h>
|
||||
#include <unistd.h>
|
||||
#endif
|
||||
|
||||
#include <ctype.h>
|
||||
#include <errno.h>
|
||||
#include <inttypes.h>
|
||||
@@ -37,11 +58,6 @@
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/socket.h>
|
||||
#include <sys/stat.h>
|
||||
#include <sys/types.h>
|
||||
#include <sys/un.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#ifdef __APPLE__
|
||||
#include <TargetConditionals.h>
|
||||
@@ -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\<hash>.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;
|
||||
|
||||
@@ -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<void>();
|
||||
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<void>();
|
||||
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<void>();
|
||||
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<PtyException>().having((e) => e.errno, 'errno', 2)),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Collect output until [needle] appears or [limit] elapses.
|
||||
Future<String> _readUntil(WindowsPty s, String needle, Duration limit) async {
|
||||
final buf = StringBuffer();
|
||||
final found = Completer<String>();
|
||||
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<String> _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<void>.delayed(const Duration(milliseconds: 50));
|
||||
}
|
||||
return buf.toString();
|
||||
}
|
||||
Reference in New Issue
Block a user