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:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user