diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1e2a68df..4b10e357 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -60,6 +60,9 @@ jobs: scripts/verify-ghostty-resources.sh build/Build/Products/Debug/Glint.app + - name: Test bundled CLI + run: python3 scripts/test-cli.py build/Build/Products/Debug/Glint.app/Contents/Helpers/glint + - name: Test Web Remote browser input run: node --test scripts/test-web-remote-*.mjs diff --git a/CLI/main.swift b/CLI/main.swift new file mode 100644 index 00000000..b9fd42f0 --- /dev/null +++ b/CLI/main.swift @@ -0,0 +1,306 @@ +import Foundation +import Darwin +import MachO + +private let help = """ +Glint CLI — local terminal control / 本地终端控制 + +Usage / 用法: + glint [--debug | --socket PATH] panes list + glint [--debug | --socket PATH] pane read PANE [--scrollback] [--max-lines N] [--json] + glint [--debug | --socket PATH] pane send PANE --text TEXT [--enter] + glint [--debug | --socket PATH] pane key PANE KEY [KEY ...] + glint [--debug | --socket PATH] pane focus PANE + glint install [--directory DIR] + glint uninstall [--directory DIR] + glint --help + +Enable Settings > Terminal > External control in Glint first. +请先在 Glint 设置 > 终端 > 外部控制中开启控制开关。 +The CLI never enables control or launches Glint. / CLI 不会开启控制或启动 Glint。 +Custom sockets require a private directory (0700) and socket/token files (0600), owned by your user. +自定义 socket 的目录须为 0700,socket 与 token 文件须为 0600,且均归当前用户所有。 +Encoded requests are limited to 1 MiB. / 编码后的请求最多 1 MiB。 +Default: ~/.glint/run/control.sock; --debug selects control-debug.sock. +默认连接正式版;--debug 连接开发版。--socket selects an explicit socket / 指定 socket。 +PANE is the full handle returned by `panes list` / PANE 使用列表返回的完整标识。 +List/send/key/focus return JSON; read returns text unless --json is supplied. +列表和操作返回 JSON;读取默认返回纯文本,--json 返回完整 JSON。 +Read defaults to viewport, max 1000 lines (allowed: 1–10000). +读取默认只含可见内容,默认最多 1000 行(范围 1–10000)。 +Keys / 按键: enter, return, esc, escape, tab, space, up, down, left, right, A-Z, a-z, 0-9. +Reads and input keep local focus; only explicit focus switches workspace. +读取和输入不切换本地工作区,只有显式 focus 才切换。 +Install creates ~/.local/bin/glint; add ~/.local/bin to PATH if needed. +安装创建 ~/.local/bin/glint 链接;必要时将 ~/.local/bin 加入 PATH。 +Reinstall is refused; uninstall removes only this CLI's link. +已安装时拒绝重复安装;uninstall 只移除此 CLI 的命令链接。 +Exit / 退出码: 0 success / 成功; 1 unavailable or rejected / 不可用或拒绝; 2 usage / 参数错误。 +""" + +private struct CLIError: Error { + let message: String + let code: Int32 + init(_ message: String, code: Int32 = 1) { self.message = message; self.code = code } +} + +private struct Invocation { + var socketPath: String + var request: [String: Any] + var plainText = false +} + +private func parseSocketOptions(_ args: inout ArraySlice) throws -> String { + var socketPath = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".glint/run/control.sock").path + var selectedSocket = false + while let first = args.first, first.hasPrefix("--"), first != "--help" { + args.removeFirst() + guard !selectedSocket else { throw CLIError("Choose only one socket / 只能指定一个 socket", code: 2) } + selectedSocket = true + switch first { + case "--debug": + socketPath = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".glint/run/control-debug.sock").path + case "--socket": + guard let value = args.first, !value.isEmpty else { + throw CLIError("--socket requires a path / 需要路径", code: 2) + } + socketPath = (value as NSString).expandingTildeInPath + args.removeFirst() + default: throw CLIError("Unknown option / 未知选项: \(first)", code: 2) + } + } + return socketPath +} + +private func parse(_ arguments: [String], socketPath: String) throws -> Invocation { + var args = arguments[...] + if Array(args) == ["panes", "list"] { + return Invocation(socketPath: socketPath, request: ["cmd": "list"]) + } + guard args.count >= 3, args.removeFirst() == "pane" else { + throw CLIError("Expected a command; use glint --help / 请输入命令,使用 glint --help 查看用法", code: 2) + } + let command = args.removeFirst() + let pane = args.removeFirst() + let parts = pane.split(separator: ":", omittingEmptySubsequences: false) + guard parts.count == 2, UUID(uuidString: String(parts[0])) != nil, + UInt32(parts[1]) != nil else { + throw CLIError("Invalid pane handle; use panes list / 窗格标识不合法,请先运行 panes list", code: 2) + } + var request: [String: Any] = ["pane": pane] + var plainText = false + switch command { + case "read": + request["cmd"] = "read-text" + plainText = true + var seen = Set() + while let option = args.first { + args.removeFirst() + guard seen.insert(option).inserted else { throw CLIError("Repeated option / 重复选项: \(option)", code: 2) } + switch option { + case "--scrollback": request["scrollback"] = true + case "--json": plainText = false + case "--max-lines": + guard let value = args.first, let lines = Int(value), (1...10_000).contains(lines) else { + throw CLIError("--max-lines must be 1–10000 / 行数必须为 1–10000", code: 2) + } + args.removeFirst() + request["maxLines"] = lines + default: throw CLIError("Unknown read option / 未知读取选项: \(option)", code: 2) + } + } + case "send": + request["cmd"] = "send-text" + var seen = Set() + while let option = args.first { + args.removeFirst() + guard seen.insert(option).inserted else { throw CLIError("Repeated option / 重复选项: \(option)", code: 2) } + switch option { + case "--text": + guard let text = args.first else { throw CLIError("--text requires text / 需要文字", code: 2) } + args.removeFirst() + request["text"] = text + case "--enter": request["enter"] = true + default: throw CLIError("Unknown send option / 未知输入选项: \(option)", code: 2) + } + } + guard request["text"] != nil else { throw CLIError("--text is required / 必须指定 --text", code: 2) } + case "key": + request["cmd"] = "send-key" + let specials: Set = ["enter", "return", "esc", "escape", "tab", "space", "up", "down", "left", "right"] + guard !args.isEmpty, args.allSatisfy({ key in + specials.contains(key) || (key.utf8.count == 1 && key.utf8.allSatisfy { + (65...90).contains($0) || (97...122).contains($0) || (48...57).contains($0) + }) + }) else { throw CLIError("Unknown or missing key; use --help / 按键为空或不支持,请查看 --help", code: 2) } + request["keys"] = Array(args) + case "focus": + guard args.isEmpty else { throw CLIError("focus accepts only PANE / focus 只接受窗格标识", code: 2) } + request["cmd"] = "focus" + default: throw CLIError("Unknown pane command / 未知窗格命令: \(command)", code: 2) + } + return Invocation(socketPath: socketPath, request: request, plainText: plainText) +} + +private func validateControlSocket(_ path: String) throws { + let directory = URL(fileURLWithPath: path).deletingLastPathComponent().resolvingSymlinksInPath().path + var parent = stat(), endpoint = stat() + guard lstat(path, &endpoint) == 0 else { + throw CLIError("Glint is unavailable or external control is off. Enable External control in Glint Settings. / Glint 未运行或外部控制已关闭,请在设置中开启。") + } + guard stat(directory, &parent) == 0, parent.st_uid == getuid(), + parent.st_mode & S_IFMT == S_IFDIR, parent.st_mode & 0o077 == 0, + endpoint.st_uid == getuid(), endpoint.st_mode & S_IFMT == S_IFSOCK, + endpoint.st_mode & 0o077 == 0 else { + throw CLIError("Unsafe control socket: use a private directory (0700) and socket (0600) owned by your user. / 控制 socket 不安全:目录须为 0700,socket 须为 0600,且均归当前用户所有。") + } +} + +private func readControlToken(socketPath: String) throws -> String { + let path = URL(fileURLWithPath: socketPath).deletingPathExtension().appendingPathExtension("token").path + let fd = open(path, O_RDONLY | O_NOFOLLOW | O_NONBLOCK) + guard fd >= 0 else { throw CLIError("Control token is unavailable or unsafe. Re-enable External control in Glint. / 控制令牌不可用或不安全,请重新开启外部控制。") } + let file = FileHandle(fileDescriptor: fd, closeOnDealloc: true) + var info = stat() + guard fstat(fd, &info) == 0, info.st_uid == getuid(), + info.st_mode & S_IFMT == S_IFREG, info.st_mode & 0o077 == 0, + info.st_size > 0, info.st_size <= 4096 else { + throw CLIError("Unsafe control token: use a regular file (0600) owned by your user. / 控制令牌不安全:须为归当前用户所有的 0600 普通文件。") + } + let token = String(decoding: file.readDataToEndOfFile(), as: UTF8.self) + .trimmingCharacters(in: .whitespacesAndNewlines) + guard !token.isEmpty else { throw CLIError("Control token is empty / 控制令牌为空") } + return token +} + +private func transact(_ invocation: Invocation) throws -> [String: Any] { + try validateControlSocket(invocation.socketPath) + let fd = socket(AF_UNIX, SOCK_STREAM, 0) + guard fd >= 0 else { throw CLIError("Cannot create socket / 无法创建 socket") } + defer { close(fd) } + var timeout = timeval(tv_sec: 5, tv_usec: 0) + var noSigPipe: Int32 = 1 + setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &timeout, socklen_t(MemoryLayout.size)) + setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &timeout, socklen_t(MemoryLayout.size)) + setsockopt(fd, SOL_SOCKET, SO_NOSIGPIPE, &noSigPipe, socklen_t(MemoryLayout.size)) + var address = sockaddr_un() + address.sun_family = sa_family_t(AF_UNIX) + guard invocation.socketPath.utf8.count < MemoryLayout.size(ofValue: address.sun_path) else { + throw CLIError("Socket path too long / socket 路径过长", code: 2) + } + invocation.socketPath.withCString { source in + withUnsafeMutableBytes(of: &address.sun_path) { target in + _ = strlcpy(target.baseAddress!.assumingMemoryBound(to: CChar.self), source, target.count) + } + } + var request = invocation.request + if ["send-text", "send-key", "read-text"].contains(request["cmd"] as? String ?? "") { + request["token"] = try readControlToken(socketPath: invocation.socketPath) + } + var payload = try JSONSerialization.data(withJSONObject: request, options: [.sortedKeys]) + guard payload.count <= 1 << 20 else { + throw CLIError("request-too-large: encoded requests must not exceed 1 MiB / 编码后的请求不得超过 1 MiB", code: 2) + } + payload.append(0x0A) + let connected = withUnsafePointer(to: &address) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { + Darwin.connect(fd, $0, socklen_t(MemoryLayout.size)) + } + } + guard connected == 0 else { + throw CLIError("Glint is unavailable or external control is off. Open Glint and enable External control in Settings. / Glint 未运行或外部控制已关闭,请在设置中开启。") + } + try payload.withUnsafeBytes { raw in + var offset = 0 + while offset < raw.count { + let n = Darwin.write(fd, raw.baseAddress!.advanced(by: offset), raw.count - offset) + if n < 0 && errno == EINTR { continue } + guard n > 0 else { throw CLIError("Control connection closed or timed out / 控制连接已关闭或超时") } + offset += n + } + } + var response = Data() + var buffer = [UInt8](repeating: 0, count: 16_384) + while true { + let n = Darwin.read(fd, &buffer, buffer.count) + if n < 0 && errno == EINTR { continue } + guard n > 0 else { throw CLIError("Control connection closed or timed out / 控制连接已关闭或超时") } + let chunkStart = response.count + response.append(buffer, count: n) + // Earlier chunks contain no delimiter; scan only the bytes just received. + if let newline = response[chunkStart...].firstIndex(of: 0x0A) { + guard let object = try? JSONSerialization.jsonObject(with: response.prefix(upTo: newline)) as? [String: Any], + object["ok"] is Bool else { throw CLIError("Invalid Glint response / Glint 响应不合法") } + return object + } + guard response.count <= 64 * 1024 * 1024 else { throw CLIError("Response too large / 响应过大") } + } +} + +private func run() throws { + var remaining = CommandLine.arguments.dropFirst() + let socketPath = try parseSocketOptions(&remaining) + let args = Array(remaining) + let helpPrefixes = [[], ["panes"], ["pane"], ["pane", "read"], ["pane", "send"], + ["pane", "key"], ["pane", "focus"], ["install"], ["uninstall"]] + if args.isEmpty || args == ["help"] + || (args.last == "--help" && helpPrefixes.contains(Array(args.dropLast()))) { + print(help) + return + } + if args.first == "install" || args.first == "uninstall" { + let directory: URL + if args.count == 1 { + directory = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".local/bin") + } else if args.count == 3, args[1] == "--directory" { + directory = URL(fileURLWithPath: (args[2] as NSString).expandingTildeInPath, isDirectory: true) + } else { throw CLIError("Usage / 用法: glint install|uninstall [--directory DIR]", code: 2) } + // Bundle.main refers to the enclosing GUI app when this helper lives + // in Contents/Helpers. dyld identifies the actual CLI, including PATH links. + var pathSize: UInt32 = 0 + _ = _NSGetExecutablePath(nil, &pathSize) + var pathBuffer = [CChar](repeating: 0, count: Int(pathSize)) + guard _NSGetExecutablePath(&pathBuffer, &pathSize) == 0 else { + throw CLIError("Cannot locate CLI executable / 无法定位 CLI 可执行文件") + } + let executable = URL(fileURLWithPath: String(cString: pathBuffer)).resolvingSymlinksInPath() + if args.first == "uninstall" { + try CLIInstallation.uninstall(executable: executable, directory: directory) + print("CLI link removed / CLI 命令链接已移除: \(directory.appendingPathComponent("glint").path)") + } else { + let installed = try CLIInstallation.install(executable: executable, directory: directory) + print("Installed / 已安装: \(installed.path)\nAdd its directory to PATH if needed / 必要时将该目录加入 PATH") + } + return + } + let invocation = try parse(args, socketPath: socketPath) + let response = try transact(invocation) + guard response["ok"] as? Bool == true else { + // Never echo requests or token values in errors. + let error = response["error"] as? String ?? "unknown-error" + if error == "pane-requires-local-open" { + throw CLIError("Open this terminal in Glint first (or explicitly use pane focus PANE), then retry. Pending history, startup and agent restoration require a local view. / 请先在 Glint 中打开此终端(或明确使用 pane focus PANE),再重试;待恢复历史、待启动和 agent 恢复命令须先在本地视图中处理。") + } + throw CLIError("Glint rejected the command / Glint 拒绝命令: \(error)") + } + if invocation.plainText { + guard let text = response["text"] as? String else { throw CLIError("Missing terminal text / 响应缺少终端文字") } + FileHandle.standardOutput.write(Data(text.utf8)) + if !text.isEmpty && !text.hasSuffix("\n") { FileHandle.standardOutput.write(Data([0x0A])) } + } else { + var output = try JSONSerialization.data(withJSONObject: response, options: [.sortedKeys, .withoutEscapingSlashes]) + output.append(0x0A) + FileHandle.standardOutput.write(output) + } +} + +do { try run() } +catch let error as CLIError { + FileHandle.standardError.write(Data((error.message + "\n").utf8)) + exit(error.code) +} catch { + FileHandle.standardError.write(Data((error.localizedDescription + "\n").utf8)) + exit(1) +} diff --git a/Glint.xcodeproj/project.pbxproj b/Glint.xcodeproj/project.pbxproj index f7f5ba14..cb4d4897 100644 --- a/Glint.xcodeproj/project.pbxproj +++ b/Glint.xcodeproj/project.pbxproj @@ -21,6 +21,7 @@ 21C03B3AE912F4D30FA6F92C /* PaneAgentState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 41DDB9C7B5A277B7924270A3 /* PaneAgentState.swift */; }; 2218BACA355875F975446783 /* GhosttySurfaceAccessibility.swift in Sources */ = {isa = PBXBuildFile; fileRef = 454D414FF362C09886EA963F /* GhosttySurfaceAccessibility.swift */; }; 262CB25BEB2B28B12C162F66 /* ghostty in Resources */ = {isa = PBXBuildFile; fileRef = 1CD90B7C77381E05E8A2BA8C /* ghostty */; }; + 2B4E37FBF98983D80AC6D093 /* ControlBridgeIntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0759D0452D589BAA2384D9D3 /* ControlBridgeIntegrationTests.swift */; }; 2D9012046800A06555DEA381 /* GitRefreshCoordinatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65BE76E8E627A5347D565968 /* GitRefreshCoordinatorTests.swift */; }; 2DD19A3FDAE094EF32B0B9F0 /* zsh-autosuggestions.zsh in Resources */ = {isa = PBXBuildFile; fileRef = D03AFC613028C5BB0D99238D /* zsh-autosuggestions.zsh */; }; 2E52C3AC5AD998CD3604D6BE /* GhosttyKit.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 81DADCF27E710CC77846E961 /* GhosttyKit.xcframework */; }; @@ -44,8 +45,11 @@ 4ED4428EEC2AFB777092FAEF /* ShellRcBlockTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3B91F000C01FEA1C7E61F5A /* ShellRcBlockTests.swift */; }; 5157C5439862F21202BCC44D /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E67CF5D8552AE1F51386161 /* AppDelegate.swift */; }; 57D99FDEAF8596DCDBC55ABE /* GrokHookInstallerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BDAAD70DC4C2FC80F6DEA51 /* GrokHookInstallerTests.swift */; }; + 59A68E0E396F948F6CDA7984 /* CLIInstallation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D247212AF50E779FC07DD96 /* CLIInstallation.swift */; }; 6290F26DFE2D0D5E1FEB9FA9 /* GitRefreshCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = EBB695363FAF3A996EF0C9FF /* GitRefreshCoordinator.swift */; }; 632C9446752A79B9C95A5D0D /* addon-fit-LICENSE.txt in Resources */ = {isa = PBXBuildFile; fileRef = 677808A57BE53607DA391FC8 /* addon-fit-LICENSE.txt */; }; + 66F384C4ABAC9DE1CC7E2C49 /* main.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CDC0A8429C70EC3F4D3532A /* main.swift */; }; + 679D2F2C16D2459A356A3559 /* CLIInstallation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D247212AF50E779FC07DD96 /* CLIInstallation.swift */; }; 692D1A7D490E07D796535EAA /* UpdaterController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4408F9761E584DE2AB6630E2 /* UpdaterController.swift */; }; 6B6B183329BCCDE5474D88AF /* web-remote.css in Resources */ = {isa = PBXBuildFile; fileRef = ABF0871355239B5D7C80B719 /* web-remote.css */; }; 6BE78104E4CC97417ED400B4 /* xterm.mjs in Resources */ = {isa = PBXBuildFile; fileRef = 36D0A3B6CB143C412B8EDA7B /* xterm.mjs */; }; @@ -112,6 +116,13 @@ /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ + 09ABC4E3016909C2F99F4393 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = BE6DB378EC4FB00E8DB46BA7 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 0B917384056BCF13B6E0D69C; + remoteInfo = GlintCLI; + }; E573422D753FD4F162230F22 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = BE6DB378EC4FB00E8DB46BA7 /* Project object */; @@ -125,11 +136,15 @@ 01DD41E0F48A7A20C41F3AE6 /* AccessibilityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccessibilityTests.swift; sourceTree = ""; }; 050E267AE14712D4D6633B4F /* Theme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Theme.swift; sourceTree = ""; }; 05F2CA2243872C8111048EF2 /* AgentBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentBridge.swift; sourceTree = ""; }; + 0759D0452D589BAA2384D9D3 /* ControlBridgeIntegrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ControlBridgeIntegrationTests.swift; sourceTree = ""; }; 07C2CEDE2C717743C29E6026 /* web-remote.js */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.javascript; path = "web-remote.js"; sourceTree = ""; }; 08FC2FBC9E8729A5ECBF857C /* PaneView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaneView.swift; sourceTree = ""; }; 0C5FD3EEA1ECD40F78394862 /* AgentLaunchChooser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentLaunchChooser.swift; sourceTree = ""; }; 0D29C0D2447094B07854C193 /* NewWorkspaceSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewWorkspaceSheet.swift; sourceTree = ""; }; 0D5B5D0462A1225A56E6EE85 /* AEBridge.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = AEBridge.m; sourceTree = ""; }; + 358C545756026B40E9358E3C /* glint */ = {isa = PBXFileReference; includeInIndex = 0; path = glint; sourceTree = BUILT_PRODUCTS_DIR; }; + 6CDC0A8429C70EC3F4D3532A /* main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = ""; }; + 8D247212AF50E779FC07DD96 /* CLIInstallation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CLIInstallation.swift; sourceTree = ""; }; 9F3D06E27B4C15A8D2E93B50 /* ChildWindowExceptionGuard.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = ChildWindowExceptionGuard.m; sourceTree = ""; }; 0D751E30DFF2EFA6F705A374 /* GhosttyTickSchedulerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyTickSchedulerTests.swift; sourceTree = ""; }; 0F58E2127BAE2C9F5155CF07 /* DevinHookInstallerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DevinHookInstallerTests.swift; sourceTree = ""; }; @@ -313,6 +328,7 @@ 6AB338782EF6F22B7B381DA8 /* CodexHomeStoreTests.swift */, 26141150D2DB4C0589558ECC /* CodexHookInstallerTests.swift */, 0FBF22AEC81DBE92987A8CB4 /* CodexUsageReaderTests.swift */, + 0759D0452D589BAA2384D9D3 /* ControlBridgeIntegrationTests.swift */, 0F58E2127BAE2C9F5155CF07 /* DevinHookInstallerTests.swift */, 7D42F0B915C6A38E640B2C71 /* ExceptionBackstopTests.swift */, 0D751E30DFF2EFA6F705A374 /* GhosttyTickSchedulerTests.swift */, @@ -380,6 +396,14 @@ path = Git; sourceTree = ""; }; + 784B713652B6DC2C1F133E7D /* Shared */ = { + isa = PBXGroup; + children = ( + 8D247212AF50E779FC07DD96 /* CLIInstallation.swift */, + ); + path = Shared; + sourceTree = ""; + }; 846EE2A0E01AF110EE275AFD /* Frameworks */ = { isa = PBXGroup; children = ( @@ -448,6 +472,14 @@ path = Glint; sourceTree = ""; }; + C7CCB0F28353CD96F7329EF3 /* CLI */ = { + isa = PBXGroup; + children = ( + 6CDC0A8429C70EC3F4D3532A /* main.swift */, + ); + path = CLI; + sourceTree = ""; + }; CE6E633968E3B9EE7F187610 /* App */ = { isa = PBXGroup; children = ( @@ -474,9 +506,11 @@ EA6921C9B6CB486D18C07534 = { isa = PBXGroup; children = ( + C7CCB0F28353CD96F7329EF3 /* CLI */, 62413EC8DC0D611EDA5E85CA /* GhosttyResources */, BB4ECEBC994408CE9F33EC5A /* Glint */, 5F9010EBEE9DCDE13D866A74 /* GlintTests */, + 784B713652B6DC2C1F133E7D /* Shared */, 846EE2A0E01AF110EE275AFD /* Frameworks */, F0DEBFD467843DF4F582D172 /* Products */, ); @@ -485,6 +519,7 @@ F0DEBFD467843DF4F582D172 /* Products */ = { isa = PBXGroup; children = ( + 358C545756026B40E9358E3C /* glint */, 3E2E78BBA22734DD486C760B /* Glint.app */, E94AB23640CA1E6007F8B2B4 /* GlintTests.xctest */, ); @@ -494,6 +529,23 @@ /* End PBXGroup section */ /* Begin PBXNativeTarget section */ + 0B917384056BCF13B6E0D69C /* GlintCLI */ = { + isa = PBXNativeTarget; + buildConfigurationList = F0FAAB9A0446F8A5053933E5 /* Build configuration list for PBXNativeTarget "GlintCLI" */; + buildPhases = ( + E722ED3D8930B6DD121E3BF3 /* Sources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = GlintCLI; + packageProductDependencies = ( + ); + productName = GlintCLI; + productReference = 358C545756026B40E9358E3C /* glint */; + productType = "com.apple.product-type.tool"; + }; 370743E3F9FFC81627C407C4 /* Glint */ = { isa = PBXNativeTarget; buildConfigurationList = 4AC9F6345A7677531E105A3D /* Build configuration list for PBXNativeTarget "Glint" */; @@ -501,10 +553,12 @@ 86AD9E59837D6EEC7E56E0F0 /* Sources */, 15DB57E4B5291059A1FB8D4A /* Resources */, 98BEE1CADB909FBA0A70E63B /* Frameworks */, + D4CDAB16B8249199ECDAA28C /* Bundle Glint CLI */, ); buildRules = ( ); dependencies = ( + 801F4A62BCA14E5741DC1DE4 /* PBXTargetDependency */, ); name = Glint; packageProductDependencies = ( @@ -566,6 +620,7 @@ projectRoot = ""; targets = ( 370743E3F9FFC81627C407C4 /* Glint */, + 0B917384056BCF13B6E0D69C /* GlintCLI */, 900A6C235CB23B81714F0821 /* GlintTests */, ); }; @@ -603,6 +658,29 @@ }; /* End PBXResourcesBuildPhase section */ +/* Begin PBXShellScriptBuildPhase section */ + D4CDAB16B8249199ECDAA28C /* Bundle Glint CLI */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "$(BUILT_PRODUCTS_DIR)/glint", + ); + name = "Bundle Glint CLI"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(TARGET_BUILD_DIR)/$(CONTENTS_FOLDER_PATH)/Helpers/glint", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "set -eu\n/bin/mkdir -p \"$TARGET_BUILD_DIR/$CONTENTS_FOLDER_PATH/Helpers\"\n/bin/cp \"$BUILT_PRODUCTS_DIR/glint\" \"$TARGET_BUILD_DIR/$CONTENTS_FOLDER_PATH/Helpers/glint\"\n"; + }; +/* End PBXShellScriptBuildPhase section */ + /* Begin PBXSourcesBuildPhase section */ 3827C1790EDA945C32F356F3 /* Sources */ = { isa = PBXSourcesBuildPhase; @@ -616,6 +694,7 @@ C41119D04864077E5B554F59 /* CodexHomeStoreTests.swift in Sources */, 0FEECB27EB1ACE11E29ECCEF /* CodexHookInstallerTests.swift in Sources */, FEBC3158CB471E9ABDF931BB /* CodexUsageReaderTests.swift in Sources */, + 2B4E37FBF98983D80AC6D093 /* ControlBridgeIntegrationTests.swift in Sources */, 34997DD4A4F9B64A754FE77F /* DevinHookInstallerTests.swift in Sources */, 3A6E1C48B27D05F49E31A8D0 /* ExceptionBackstopTests.swift in Sources */, 180444BD20493AE4822BD372 /* GhosttyTickSchedulerTests.swift in Sources */, @@ -653,6 +732,7 @@ C95E09498A4974B811D36575 /* AgentLaunchChooser.swift in Sources */, C48BD1224EA2C6D3C87B9120 /* AgentPaneSummary.swift in Sources */, 5157C5439862F21202BCC44D /* AppDelegate.swift in Sources */, + 59A68E0E396F948F6CDA7984 /* CLIInstallation.swift in Sources */, D0C5A885F5B3FE6BD5AFC54A /* CloseOnCmdW.swift in Sources */, 07817DC6A98F8ED2E236CFA1 /* CodexHome.swift in Sources */, C0E8D97C08BA3F0FA33F31E7 /* CommandPalette.swift in Sources */, @@ -696,9 +776,23 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + E722ED3D8930B6DD121E3BF3 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 679D2F2C16D2459A356A3559 /* CLIInstallation.swift in Sources */, + 66F384C4ABAC9DE1CC7E2C49 /* main.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ + 801F4A62BCA14E5741DC1DE4 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 0B917384056BCF13B6E0D69C /* GlintCLI */; + targetProxy = 09ABC4E3016909C2F99F4393 /* PBXContainerItemProxy */; + }; D3E9FF39F2716E0829281863 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 370743E3F9FFC81627C407C4 /* Glint */; @@ -770,6 +864,24 @@ }; name = Release; }; + 44EEA4A9F2E5095CB782DC20 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_IDENTITY = "-"; + COMBINE_HIDPI_IMAGES = YES; + GENERATE_INFOPLIST_FILE = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = app.glint.GlintCLI; + PRODUCT_MODULE_NAME = GlintCLI; + PRODUCT_NAME = glint; + SDKROOT = macosx; + SKIP_INSTALL = YES; + }; + name = Debug; + }; 737FFBE4050FEC0AF1931C18 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -853,6 +965,24 @@ }; name = Release; }; + B067B73C976D1E7F2C606CBF /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_IDENTITY = "-"; + COMBINE_HIDPI_IMAGES = YES; + GENERATE_INFOPLIST_FILE = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = app.glint.GlintCLI; + PRODUCT_MODULE_NAME = GlintCLI; + PRODUCT_NAME = glint; + SDKROOT = macosx; + SKIP_INSTALL = YES; + }; + name = Release; + }; D716E255757C327EE86E1B65 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -988,6 +1118,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Debug; }; + F0FAAB9A0446F8A5053933E5 /* Build configuration list for PBXNativeTarget "GlintCLI" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 44EEA4A9F2E5095CB782DC20 /* Debug */, + B067B73C976D1E7F2C606CBF /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ diff --git a/Glint/Agent/ControlBridge.swift b/Glint/Agent/ControlBridge.swift index f2e46a3e..4bc59ebe 100644 --- a/Glint/Agent/ControlBridge.swift +++ b/Glint/Agent/ControlBridge.swift @@ -5,16 +5,17 @@ import Security /// Inbound control channel — the reverse of `AgentBridge`. Where the agent /// socket only *receives* hook events to drive sidebar state, this one /// *receives commands* and drives panes: focus a pane, inject text/keys, -/// list panes. It speaks line-delimited JSON, one request → one response +/// list panes, read terminal text. It speaks line-delimited JSON, one request → one response /// (vs. `agent.sock`'s fire-and-forget). /// /// >> {"cmd":"focus","pane":":"} << {"ok":true} /// >> {"cmd":"send-text","pane":"…","text":"yes","enter":true,"token":"…"} /// >> {"cmd":"send-key","pane":"…","keys":["down","enter"],"token":"…"} +/// >> {"cmd":"read-text","pane":"…","scrollback":true,"maxLines":1000,"token":"…"} /// >> {"cmd":"list"} << {"ok":true,"panes":[{"pane":…,"title":…,"cwd":…,"agent":…}]} /// /// Auth is **stateless**: each command carries an optional `token`; the -/// server validates per-line. State-changing commands (`send-*`) require a +/// server validates per-line. Terminal read/write commands (`read-text`, `send-*`) require a /// matching token; `focus`/`list` are allowed without one. The token is /// regenerated every launch into a 0600 file under `~/.glint/run/` — being /// able to read it ≈ already being able to read the user's private files. @@ -33,14 +34,21 @@ final class ControlBridge { /// the externalControlEnabled toggle / app launch). private(set) var isRunning = false - private init() {} + private let runDirectory: URL + private let isEnabled: @MainActor () -> Bool + private var sessionID = UUID() + + init(runDirectory: URL? = nil, + isEnabled: @escaping @MainActor () -> Bool = { WorkspaceStore.current?.externalControlEnabled ?? false }) { + self.runDirectory = runDirectory ?? FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".glint/run", isDirectory: true) + self.isEnabled = isEnabled + } /// Canonical socket + token paths. Debug builds use separate filenames so /// a dev Glint and a prod Glint don't collide on the same path. - private static func socketPaths() -> (socket: String, token: String) { - let runDir = FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent(".glint", isDirectory: true) - .appendingPathComponent("run", isDirectory: true) + private func socketPaths() -> (socket: String, token: String) { + let runDir = runDirectory #if DEBUG return (runDir.appendingPathComponent("control-debug.sock").path, runDir.appendingPathComponent("control-debug.token").path) @@ -55,10 +63,7 @@ final class ControlBridge { /// `start()` for the rationale on why a private parent dir matters here. func start() { guard !isRunning else { return } - let home = FileManager.default.homeDirectoryForCurrentUser - let runDir = home - .appendingPathComponent(".glint", isDirectory: true) - .appendingPathComponent("run", isDirectory: true) + let runDir = runDirectory do { try FileManager.default.createDirectory( at: runDir, @@ -71,7 +76,7 @@ final class ControlBridge { } chmod(runDir.path, 0o700) - let (path, tokPath) = Self.socketPaths() + let (path, tokPath) = socketPaths() socketPath = path tokenPath = tokPath @@ -117,7 +122,10 @@ final class ControlBridge { listenFD = fd let src = DispatchSource.makeReadSource(fileDescriptor: fd, queue: queue) - src.setEventHandler { [weak self] in self?.acceptOne() } + let sessionID = UUID() + self.sessionID = sessionID + src.setEventHandler { [weak self] in self?.acceptOne(listenFD: fd, sessionID: sessionID) } + src.setCancelHandler { close(fd) } src.resume() acceptSource = src isRunning = true @@ -126,14 +134,17 @@ final class ControlBridge { /// Stop listening and remove the socket + token so the /// externalControlEnabled toggle can revoke access live — no app restart. - /// Safe when already stopped. Connections already accepted finish on their - /// own; no new ones are accepted. + /// Safe when already stopped. Previously accepted connections can only + /// receive disabled errors, including after a subsequent restart. func stop() { guard isRunning else { return } isRunning = false acceptSource?.cancel() acceptSource = nil - if listenFD >= 0 { close(listenFD); listenFD = -1 } + // The cancel handler closes the captured listener after queued accept + // handlers finish, so a restart cannot reuse its fd underneath them. + listenFD = -1 + token = "" if !socketPath.isEmpty { unlink(socketPath) } // Drop the token too — a fresh one is minted on the next start(), so a // stale on-disk token can't authorize anything while we're off. @@ -146,7 +157,7 @@ final class ControlBridge { /// covers a crash / force-quit where stop() never ran. No-op if running. func reapStale() { guard !isRunning else { return } - let (sock, tok) = Self.socketPaths() + let (sock, tok) = socketPaths() unlink(sock) unlink(tok) } @@ -188,18 +199,21 @@ final class ControlBridge { // MARK: accept / serve - private func acceptOne() { + private func acceptOne(listenFD: Int32, sessionID: UUID) { let client = accept(listenFD, nil, nil) guard client >= 0 else { return } + var noSigPipe: Int32 = 1 + _ = setsockopt(client, SOL_SOCKET, SO_NOSIGPIPE, &noSigPipe, socklen_t(MemoryLayout.size)) var tv = timeval(tv_sec: 5, tv_usec: 0) + _ = setsockopt(client, SOL_SOCKET, SO_SNDTIMEO, &tv, socklen_t(MemoryLayout.size)) _ = setsockopt(client, SOL_SOCKET, SO_RCVTIMEO, &tv, socklen_t(MemoryLayout.size)) - DispatchQueue.global(qos: .utility).async { [weak self] in self?.serve(fd: client) } + DispatchQueue.global(qos: .utility).async { [weak self] in self?.serve(fd: client, sessionID: sessionID) } } /// Runs off the accept queue. Each request line is parsed here, dispatched /// onto the main queue (where the store + surfaces live), and the response /// line written straight back on this connection. - private func serve(fd: Int32) { + private func serve(fd: Int32, sessionID: UUID) { defer { close(fd) } var buf = Data() var tmp = [UInt8](repeating: 0, count: 4096) @@ -212,36 +226,53 @@ final class ControlBridge { while let nl = buf.firstIndex(of: 0x0A) { let line = buf.subdata(in: buf.startIndex.. (1 << 20) + ? Self.errorResponse("request-too-large") : handle(line: line, sessionID: sessionID) resp.append(0x0A) - resp.withUnsafeBytes { raw in + let sent = resp.withUnsafeBytes { raw -> Bool in + var offset = 0 + while offset < raw.count { + let written = Darwin.write(fd, raw.baseAddress!.advanced(by: offset), raw.count - offset) + if written < 0 && errno == EINTR { continue } + guard written > 0 else { return false } + offset += written + } + return true + } + if !sent { return } + } + if buf.count > (1 << 20) { + var response = Self.errorResponse("request-too-large") + response.append(0x0A) + response.withUnsafeBytes { raw in _ = Darwin.write(fd, raw.baseAddress, raw.count) } + return } - if buf.count > (1 << 20) { break } } } - private func handle(line: Data) -> Data { + private func handle(line: Data, sessionID: UUID) -> Data { guard let obj = (try? JSONSerialization.jsonObject(with: line)) as? [String: Any], let cmd = obj["cmd"] as? String else { return Self.errorResponse("bad-request") } - // Command-level gating: send-* mutate the terminal, so they require a - // token; focus/list don't (see §4.4). Auth is checked off the main - // thread — it's a pure comparison. - let requiresToken = (cmd == "send-text" || cmd == "send-key") - if requiresToken && !tokenMatches(obj["token"] as? String) { - return Self.errorResponse("unauthorized") - } - var response = Self.errorResponse("unknown-cmd") + var readGrid: (pane: String, data: Data, scrollback: Bool, maxLines: Int)? DispatchQueue.main.sync { guard let store = WorkspaceStore.current else { response = Self.errorResponse("bad-request") return } + // Check on the main queue alongside start/stop: disabling access + // also revokes commands on already accepted connections. + guard self.isEnabled(), self.isRunning, self.sessionID == sessionID else { + response = Self.errorResponse("disabled"); return + } + if Self.requiresToken(for: cmd) && !self.tokenMatches(obj["token"] as? String) { + response = Self.errorResponse("unauthorized"); return + } switch cmd { case "list": response = Self.okResponse(["panes": store.controlListPanes()]) @@ -252,6 +283,20 @@ final class ControlBridge { } response = store.controlFocus(pane: pane).map(Self.errorResponse) ?? Self.okResponse([:]) + case "read-text": + guard let pane = obj["pane"] as? String, + obj["scrollback"] == nil || obj["scrollback"] is Bool, + let maxLines = Self.readTextMaxLines(obj["maxLines"]) else { + response = Self.errorResponse("bad-request"); return + } + switch store.controlReadGrid(pane: pane, + scrollback: obj["scrollback"] as? Bool ?? false, + maxLines: maxLines) { + case let .success(grid): + readGrid = (pane, grid, obj["scrollback"] as? Bool ?? false, maxLines) + case let .failure(error): response = Self.errorResponse(error) + } + case "send-text": guard let pane = obj["pane"] as? String, let text = obj["text"] as? String else { response = Self.errorResponse("bad-request"); return @@ -275,9 +320,32 @@ final class ControlBridge { response = Self.errorResponse("unknown-cmd") } } + // The main queue owns Ghostty; decoding, span assembly and response JSON + // belong to this connection's serve thread, especially for large history. + if let readGrid { + guard let text = GhosttySurfaceView.controlText(fromRenderGrid: readGrid.data, + scrollback: readGrid.scrollback, maxLines: readGrid.maxLines) else { + return Self.errorResponse("pane-not-ready") + } + return Self.okResponse(["pane": readGrid.pane, "text": text]) + } return response } + static func requiresToken(for command: String) -> Bool { + command == "send-text" || command == "send-key" || command == "read-text" + } + + static func readTextMaxLines(_ value: Any?) -> Int? { + guard let value else { return 1000 } + guard let number = value as? NSNumber, + CFGetTypeID(number) != CFBooleanGetTypeID(), + number.doubleValue.isFinite, + number.doubleValue.rounded() == number.doubleValue, + (1...10_000).contains(number.doubleValue) else { return nil } + return number.intValue + } + // MARK: responses private static func okResponse(_ extra: [String: Any]) -> Data { @@ -295,3 +363,50 @@ final class ControlBridge { ?? Data(#"{"ok":false,"error":"bad-request"}"#.utf8) } } + +/// Discovery and installation live in the app; terminal access always goes +/// through ControlBridge, including commands launched by the bundled CLI. +enum ControlCLIIntegration { + static var executableURL: URL { + Bundle.main.bundleURL.appendingPathComponent("Contents/Helpers/glint") + } + + private static var installationDirectory: URL { + FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".local/bin") + } + + static var installationState: CLIInstallation.State { + CLIInstallation.state(executable: executableURL, directory: installationDirectory) + } + + static func install() throws -> URL { + try CLIInstallation.install(executable: executableURL, directory: installationDirectory) + } + + static func uninstall() throws { + try CLIInstallation.uninstall(executable: executableURL, directory: installationDirectory) + } + + static var agentInstructions: String { + #if DEBUG + let debug = true + #else + let debug = false + #endif + return agentInstructions(executable: executableURL, debug: debug) + } + + static func agentInstructions(executable: URL, debug: Bool) -> String { + let command = "'" + executable.path.replacingOccurrences(of: "'", with: "'\\''") + "'" + + (debug ? " --debug" : "") + return String(localized: "Use the following Glint CLI to control terminals on this Mac. Start with --help and panes list to identify the target pane, then read its contents before sending input. Replace PANE with the full handle from the list.") + + "\n\n```sh\n" + + "\(command) --help\n" + + "\(command) panes list\n" + + "\(command) pane read PANE --scrollback --max-lines 1000\n" + + "\(command) pane send PANE --text 'pwd' --enter\n" + + "\(command) pane key PANE down enter\n" + + "```\n\n" + + String(localized: "After input, read the terminal again to verify the result. Ordinary reads and input preserve local workspace selection. Use pane focus PANE only when explicitly asked to switch local focus. External control must be enabled in Glint Settings; if access is unavailable, report that instead of bypassing the switch. The CLI reads its token locally; never print or copy the token.") + } +} diff --git a/Glint/Chrome/SettingsView.swift b/Glint/Chrome/SettingsView.swift index 6d0f4f8c..3f3cabcf 100644 --- a/Glint/Chrome/SettingsView.swift +++ b/Glint/Chrome/SettingsView.swift @@ -991,6 +991,8 @@ private struct TerminalPane: View { @State private var confirmingWebRemoteKeyReset = false @State private var resettingWebRemoteCredentials = false @State private var webRemoteAlert: WebRemoteAlert? + @State private var cliMessage: String? + @State private var cliInstallationState = ControlCLIIntegration.installationState private let scrollbackSizeChoices: [Int] = [5, 10, 25, 50, 100, 250] .map { $0 * 1_000_000 } @@ -1122,7 +1124,7 @@ private struct TerminalPane: View { } SettingsCard("External control", - footer: "Exposes a local socket under ~/.glint/run/ so other apps on this Mac can focus panes and inject text/keys. Any process that can read the 0600 token file may drive your terminals — off by default. Toggling takes effect immediately.") { + footer: "Exposes a local socket under ~/.glint/run/ so other apps on this Mac can focus panes, read terminal contents and inject text/keys. Reading and input require the 0600 token file. Ordinary reads and input do not switch workspaces. Off by default; toggling takes effect immediately.") { SettingsRow("Allow external control", subtitle: store.externalControlEnabled ? "Listening for external commands." @@ -1130,6 +1132,51 @@ private struct TerminalPane: View { Toggle("", isOn: $store.externalControlEnabled) .toggleStyle(.switch).labelsHidden() } + SettingsDivider() + SettingsRow("Command-line access", + subtitle: cliInstallationSubtitle) { + HStack(spacing: 8) { + StatusPill(label: cliInstallationLabel, + tone: cliInstallationState == .installed ? .ok + : ((cliInstallationState == .conflict || cliInstallationState == .repairable) ? .warn : .neutral)) + Button("Install CLI") { + defer { refreshCLIInstallation() } + do { + let path = try ControlCLIIntegration.install() + cliMessage = String(format: String(localized: "Installed at %@. Add ~/.local/bin to PATH if needed. External control remains governed by the switch above."), path.path) + } catch { cliMessage = error.localizedDescription } + } + .disabled(cliInstallationState != .notInstalled && cliInstallationState != .repairable) + if cliInstallationState == .installed || cliInstallationState == .repairable { + Button("Uninstall") { + defer { refreshCLIInstallation() } + do { + try ControlCLIIntegration.uninstall() + cliMessage = String(localized: "CLI link removed from ~/.local/bin/glint.") + } catch { cliMessage = error.localizedDescription } + } + } + } + .controlSize(.small) + } + SettingsDivider() + SettingsRow("Agent instructions", subtitle: "Copies CLI commands for an agent. No token is copied.") { + Button("Copy Agent instructions") { + copyWebRemoteValue(ControlCLIIntegration.agentInstructions) + } + .controlSize(.small) + } + } + .onAppear { refreshCLIInstallation() } + .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in + refreshCLIInstallation() + } + .alert("Glint CLI", isPresented: Binding( + get: { cliMessage != nil }, set: { if !$0 { cliMessage = nil } } + )) { + Button("OK", role: .cancel) { cliMessage = nil } + } message: { + Text(verbatim: cliMessage ?? "") } SettingsCard("Web remote control", @@ -1371,6 +1418,35 @@ private struct TerminalPane: View { copyWebRemoteValue(WebRemoteAccessURL.redacted(from: value)) } + private var cliInstallationLabel: String { + switch cliInstallationState { + case .installed: return String(localized: "Installed") + case .notInstalled: return String(localized: "Not installed") + case .conflict: return String(localized: "Command name in use") + case .repairable: return String(localized: "Needs repair") + case .linkedElsewhere: return String(localized: "Linked to another Glint") + } + } + + private var cliInstallationSubtitle: String { + switch cliInstallationState { + case .installed: + return String(localized: "Installed at ~/.local/bin/glint. Uninstall removes only the command link.") + case .repairable: + return String(localized: "The CLI link points to a missing Glint installation. Install again to repair it, or uninstall the link.") + case .linkedElsewhere: + return String(localized: "The CLI is linked to another existing Glint installation. It was left unchanged.") + case .notInstalled: + return String(localized: "Installs ~/.local/bin/glint. Installation does not enable external control.") + case .conflict: + return String(localized: "Another command already uses ~/.local/bin/glint.") + } + } + + private func refreshCLIInstallation() { + cliInstallationState = ControlCLIIntegration.installationState + } + private func copyWebRemoteValue(_ value: String) { NSPasteboard.general.clearContents() NSPasteboard.general.setString(value, forType: .string) diff --git a/Glint/Chrome/SidebarView.swift b/Glint/Chrome/SidebarView.swift index 1efd9682..5daf55c1 100644 --- a/Glint/Chrome/SidebarView.swift +++ b/Glint/Chrome/SidebarView.swift @@ -727,6 +727,19 @@ private struct WorkspaceCard: View { .truncationMode(.tail) } secondaryRow(summary: summary, active: active) + ForEach(store.workspaceControlSources(ws.id), id: \.self) { source in + Label { + Text(verbatim: source.title) + } icon: { + Image(systemName: "dot.radiowaves.left.and.right") + } + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(Theme.orange) + .padding(.horizontal, 6) + .padding(.vertical, 3) + .background(Capsule().fill(Theme.orange.opacity(0.14))) + .help("Reads and input keep your local workspace unchanged. Local activity stays marked for 30 seconds; browser control stays marked while connected.") + } } Spacer(minLength: 4) if !multiInfos.isEmpty { @@ -820,9 +833,11 @@ private struct WorkspaceCard: View { .accessibilityAddTraits(active ? [.isButton, .isSelected] : [.isButton]) .accessibilityIdentifier("workspace-\(ws.id.uuidString)") .accessibilityLabel(Text(verbatim: ws.displayName)) - .accessibilityValue(Text(verbatim: archived + .accessibilityValue(Text(verbatim: (archived ? String(localized: "Archived") - : accessibilityStatus(summary: summary))) + : accessibilityStatus(summary: summary)) + + store.workspaceControlSources(ws.id) + .map { ", " + $0.title }.joined())) // Lift the dragged card above its neighbours. .scaleEffect(isDragging ? 1.03 : 1.0, anchor: .center) .shadow(color: Color.black.opacity(isDragging ? 0.35 : 0), diff --git a/Glint/Pane/GhosttySurfaceView.swift b/Glint/Pane/GhosttySurfaceView.swift index 6463e6bf..53632fb6 100644 --- a/Glint/Pane/GhosttySurfaceView.swift +++ b/Glint/Pane/GhosttySurfaceView.swift @@ -100,6 +100,7 @@ final class GhosttySurfaceView: NSView, NSTextInputClient { /// One-shot launch input. Cleared after the first successful surface /// creation so waking an offline terminal never re-runs an agent command. private var pendingInitialInput: String? + var hasPendingInitialInput: Bool { pendingInitialInput?.isEmpty == false } /// Latest cwd pushed by ghostty via OSC 7 / PWD action. Preferred over /// proc_pidinfo polling because it's event-driven. var cachedCwd: String? @@ -125,6 +126,15 @@ final class GhosttySurfaceView: NSView, NSTextInputClient { private var scrollbackID: String? { paneKey.map { ScrollbackArchive.fileID(forPaneKey: $0) } } + /// A local socket has no grid-size/restore completion handshake. Do not + /// start its shell or accept input until archived history can be replayed + /// by a local view (or by Web Remote's explicit grid + completion barrier). + var requiresLocalScrollbackRestore: Bool { + if pendingRestoreData != nil { return true } + guard surface == nil, scrollbackEnabled, let id = scrollbackID else { return false } + return ScrollbackArchive.read(id: id)?.isEmpty == false + } + /// The exact history text echoed into this surface at restore (the prior /// session's saved scrollback). Kept verbatim so future snapshots can /// re-attach it as a STABLE prefix and capture only this session's NEW @@ -2378,6 +2388,54 @@ final class GhosttySurfaceView: NSView, NSTextInputClient { markScrollbackDirty() } + /// Fresh text from the live terminal, independent of the AX cache. Grid + /// export bounds history while preserving Unicode and soft-wrapped lines. + func controlReadGrid(scrollback: Bool, maxLines: Int) -> Data? { + guard let surface else { return nil } + let json = ghostty_surface_render_grid_json(surface, "", 0, 0, + scrollback ? UInt(maxLines) : 0) + defer { ghostty_string_free(json) } + guard let pointer = json.ptr, json.len > 0 else { return nil } + return Data(bytes: pointer, count: Int(json.len)) + } + + nonisolated static func controlText(fromRenderGrid data: Data, scrollback: Bool, maxLines: Int) -> String? { + guard maxLines > 0, + let grid = try? JSONDecoder().decode(RenderGrid.self, from: data) else { return nil } + func region(_ spans: [RenderGrid.Span], rows: Int, wraps: [Bool]) -> [(String, Bool)] { + let byRow = Dictionary(grouping: spans, by: \.row) + return (0.. WebRemoteTerminalSnapshot? { guard let s = surface else { return nil } let json = ghostty_surface_render_grid_json(s, "", 0, 0, UInt(maxLines)) diff --git a/Glint/Pane/PaneView.swift b/Glint/Pane/PaneView.swift index 83452444..0ec459e9 100644 --- a/Glint/Pane/PaneView.swift +++ b/Glint/Pane/PaneView.swift @@ -71,16 +71,22 @@ struct PaneView: View { Color.black.opacity(unfocusedDimOpacity) .allowsHitTesting(false) } - if store.isWebRemoteControlled(workspaceID: workspaceID, paneID: paneID) { - VStack { - VStack(alignment: .leading, spacing: 2) { - Label("Controlled from web", systemImage: "network") + let controlSources = store.paneControlSources(workspaceID: workspaceID, paneID: paneID) + if !controlSources.isEmpty { + VStack(spacing: 4) { + ForEach(controlSources, id: \.self) { source in + VStack(alignment: .leading, spacing: 2) { + Label { + Text(verbatim: source.title) + } icon: { + Image(systemName: "dot.radiowaves.left.and.right") + } .font(.system(size: 10.5, weight: .semibold)) .foregroundStyle(Theme.text1) - Text("Layout may be inaccurate while controlled. It will restore automatically after disconnecting.") - .font(.system(size: 9.5, weight: .medium)) - .foregroundStyle(Theme.text3) - } + Text(verbatim: source.detail) + .font(.system(size: 9.5, weight: .medium)) + .foregroundStyle(Theme.text3) + } .padding(.horizontal, 10) .padding(.vertical, 7) .background( @@ -92,9 +98,10 @@ struct PaneView: View { .stroke(Theme.overlay(0.12), lineWidth: 1) ) .shadow(color: Color.black.opacity(0.22), radius: 8, y: 3) - .padding(.top, 8) + } Spacer() } + .padding(.top, 8) .allowsHitTesting(false) } } diff --git a/Glint/Resources/Localizable.xcstrings b/Glint/Resources/Localizable.xcstrings index 6212cc88..9a07a271 100644 --- a/Glint/Resources/Localizable.xcstrings +++ b/Glint/Resources/Localizable.xcstrings @@ -1,6 +1,303 @@ { "sourceLanguage": "en", "strings": { + "Linked to another Glint": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已链接到另一个 Glint" + } + } + } + }, + "The CLI is linked to another existing Glint installation. It was left unchanged.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "CLI 已链接到另一份仍然存在的 Glint 安装,未作更改。" + } + } + } + }, + "Needs repair": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "需要修复" + } + } + } + }, + "The CLI link points to a missing Glint installation. Install again to repair it, or uninstall the link.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "CLI 链接指向已不存在的 Glint 安装位置。重新安装可修复链接,也可卸载此链接。" + } + } + } + }, + "Local apps can read and send input to this terminal.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "本地应用可以读取和操作此终端。" + } + } + } + }, + "Local control": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "本地控制" + } + } + } + }, + "Reads and input keep your local workspace unchanged. Local activity stays marked for 30 seconds; browser control stays marked while connected.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "读取和输入不会切换电脑上的工作区。本地操作后标识保留 30 秒,网页控制在连接期间持续显示标识。" + } + } + } + }, + "CLI is already installed.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "CLI 已安装,请勿重复安装。" + } + } + } + }, + "CLI is not installed.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "CLI 尚未安装。" + } + } + } + }, + "CLI link removed from ~/.local/bin/glint.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已移除 ~/.local/bin/glint 命令链接。" + } + } + } + }, + "Command name in use": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "命令名已被占用" + } + } + } + }, + "Installed at ~/.local/bin/glint. Uninstall removes only the command link.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已安装到 ~/.local/bin/glint。卸载只移除命令链接。" + } + } + } + }, + "Another command already uses ~/.local/bin/glint.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "~/.local/bin/glint 已被其他命令占用。" + } + } + } + }, + "Agent instructions": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Agent 使用说明" + } + } + } + }, + "Copies CLI commands for an agent. No token is copied.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "复制供 Agent 使用的 CLI 命令说明,不包含令牌。" + } + } + } + }, + "The bundled CLI is missing. Reinstall Glint.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "未找到随附的 CLI,请重新安装 Glint。" + } + } + } + }, + "The destination already contains another command. It was left unchanged.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "目标位置已有其他命令,未对其进行修改。" + } + } + } + }, + "Command-line access": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "命令行访问" + } + } + } + }, + "Installs ~/.local/bin/glint. Installation does not enable external control.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "安装到 ~/.local/bin/glint。安装不会开启外部控制。" + } + } + } + }, + "Install CLI": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "安装 CLI" + } + } + } + }, + "Copy Agent instructions": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "复制 Agent 使用说明" + } + } + } + }, + "Glint CLI": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Glint CLI" + } + } + } + }, + "Installed at %@. Add ~/.local/bin to PATH if needed. External control remains governed by the switch above.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已安装到 %@。必要时将 ~/.local/bin 加入 PATH。外部控制仍由上方开关决定。" + } + } + } + }, + "Use the following Glint CLI to control terminals on this Mac. Start with --help and panes list to identify the target pane, then read its contents before sending input. Replace PANE with the full handle from the list.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "请通过以下 Glint CLI 操作本机终端。先运行 --help 和 panes list,确认目标窗格,再读取内容后输入。将 PANE 替换为列表返回的完整窗格标识。" + } + } + } + }, + "After input, read the terminal again to verify the result. Ordinary reads and input preserve local workspace selection. Use pane focus PANE only when explicitly asked to switch local focus. External control must be enabled in Glint Settings; if access is unavailable, report that instead of bypassing the switch. The CLI reads its token locally; never print or copy the token.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "输入后再次读取终端,验证操作结果。普通读取与输入保持本地工作区选择不变。只有明确要求切换本地焦点时,才使用 pane focus PANE。必须在 Glint 设置中开启外部控制;不可用时请报告原因,不要绕过开关。CLI 在本机读取令牌,不要输出或复制令牌。" + } + } + } + }, + "Remote control active": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在被远程控制" + } + } + } + }, + "Exposes a local socket under ~/.glint/run/ so other apps on this Mac can focus panes, read terminal contents and inject text/keys. Reading and input require the 0600 token file. Ordinary reads and input do not switch workspaces. Off by default; toggling takes effect immediately.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "在 ~/.glint/run/ 下开放本地 socket,让本机其他应用聚焦窗格、读取终端内容并输入文字或按键。读取和输入需要权限为 0600 的 token 文件。普通读取与输入不会切换工作区。默认关闭,切换立即生效。" + } + } + } + }, "\"%@\" is not executable.": { "extractionState": "manual", "localizations": { @@ -1308,17 +1605,6 @@ } } }, - "Controlled from web": { - "extractionState": "manual", - "localizations": { - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "正在通过网页控制" - } - } - } - }, "Copied in; your original checkout keeps them too.": { "extractionState": "manual", "localizations": { diff --git a/Glint/Resources/WebRemote/web-remote.js b/Glint/Resources/WebRemote/web-remote.js index fab816dc..900b1883 100644 --- a/Glint/Resources/WebRemote/web-remote.js +++ b/Glint/Resources/WebRemote/web-remote.js @@ -118,6 +118,7 @@ const translations = { terminal_closed: "终端已关闭", terminal_created: "已新建终端", terminal_not_ready: "终端尚未准备好", + terminal_requires_local_open: "请先在 Glint 中打开此终端,再重试;远程访问不会自动恢复 agent 或执行待启动命令。", terminal_ready: "终端已就绪", unable_connect: "无法连接", unauthorized: "访问密钥无效或已经失效。", @@ -189,6 +190,7 @@ const translations = { terminal_closed: "Terminal closed", terminal_created: "Terminal created", terminal_not_ready: "Terminal is not ready", + terminal_requires_local_open: "Open this terminal in Glint first, then retry. Remote access does not run pending startup or agent restore commands.", terminal_ready: "Terminal ready", unable_connect: "Unable to connect", unauthorized: "The access key is invalid or has expired.", @@ -836,6 +838,7 @@ function errorLabel(code) { "workspace-archived": t("workspace_archived"), "last-terminal": t("last_terminal"), "terminal-not-ready": t("terminal_not_ready"), + "pane-requires-local-open": t("terminal_requires_local_open"), "unknown-command": t("unknown_command"), }; return labels[code] || t("operation_failed", { code }); diff --git a/Glint/WebRemote/WebRemoteServer.swift b/Glint/WebRemote/WebRemoteServer.swift index c048fd1f..1d3a2737 100644 --- a/Glint/WebRemote/WebRemoteServer.swift +++ b/Glint/WebRemote/WebRemoteServer.swift @@ -979,17 +979,6 @@ final class WebRemoteServer: @unchecked Sendable { DispatchQueue.main.async { [weak self] in guard let self, let store = WorkspaceStore.current else { return } - if let error = store.controlFocus(pane: pane, activateApp: false) { - self.queue.async { [weak self] in - self?.finishSelectionFailure( - error, - pane: pane, - generation: selectionGeneration, - clientID: clientID - ) - } - return - } store.webRemoteTerminalSnapshot(pane: pane, size: size) { [weak self] result in self?.queue.async { [weak self] in guard let self else { return } diff --git a/Glint/Workspace/WorkspaceStore.swift b/Glint/Workspace/WorkspaceStore.swift index f1506dd1..70864df4 100644 --- a/Glint/Workspace/WorkspaceStore.swift +++ b/Glint/Workspace/WorkspaceStore.swift @@ -1198,7 +1198,10 @@ final class WorkspaceStore: ObservableObject { didSet { UserDefaults.standard.set(externalControlEnabled, forKey: "glint.externalControlEnabled") if externalControlEnabled { ControlBridge.shared.start() } - else { ControlBridge.shared.stop() } + else { + ControlBridge.shared.stop() + clearExternalControlActivity() + } } } @@ -1236,6 +1239,84 @@ final class WorkspaceStore: ObservableObject { @Published private(set) var webRemoteStatus: WebRemoteStatus = .stopped @Published private var webRemoteControlledPanes = Set() + @Published private var externalControlledPanes = Set() + private var externalControlExpiry: [WorkspacePaneKey: Date] = [:] + private var externalControlExpiryWork: DispatchWorkItem? + private let externalControlNow: () -> Date + + enum WorkspaceControlSource: Hashable { + case local, web + + var title: String { + switch self { + case .local: return String(localized: "Local control") + case .web: return String(localized: "Remote control active") + } + } + + var detail: String { + switch self { + case .local: + return String(localized: "Local apps can read and send input to this terminal.") + case .web: + return String(localized: "Layout may be inaccurate while controlled. It will restore automatically after disconnecting.") + } + } + } + + func paneControlSources(workspaceID: UUID, paneID: PaneID) -> [WorkspaceControlSource] { + let key = WorkspacePaneKey(workspace: workspaceID, pane: paneID) + var sources: [WorkspaceControlSource] = [] + if externalControlledPanes.contains(key) { sources.append(.local) } + if webRemoteControlledPanes.contains(key) { sources.append(.web) } + return sources + } + + func workspaceControlSources(_ id: UUID) -> [WorkspaceControlSource] { + var sources: [WorkspaceControlSource] = [] + if externalControlledPanes.contains(where: { $0.workspace == id }) { sources.append(.local) } + if webRemoteControlledPanes.contains(where: { $0.workspace == id }) { sources.append(.web) } + return sources + } + + func isWorkspaceRemotelyControlled(_ id: UUID) -> Bool { + !workspaceControlSources(id).isEmpty + } + + private func noteExternalControlActivity(_ key: WorkspacePaneKey) { + externalControlExpiry[key] = externalControlNow().addingTimeInterval(30) + if !externalControlledPanes.contains(key) { externalControlledPanes.insert(key) } + scheduleExternalControlExpiry() + } + + private func scheduleExternalControlExpiry() { + // Polling refreshes only a timestamp: retain the current wakeup until it fires. + guard externalControlExpiryWork == nil, + let next = externalControlExpiry.values.min() else { return } + let expiry = DispatchWorkItem { [weak self] in self?.expireExternalControlActivity() } + externalControlExpiryWork = expiry + DispatchQueue.main.asyncAfter(deadline: .now() + max(0, next.timeIntervalSince(externalControlNow())), + execute: expiry) + } + + func expireExternalControlActivity() { + externalControlExpiryWork?.cancel() + externalControlExpiryWork = nil + let now = externalControlNow() + let expired = Set(externalControlExpiry.compactMap { $0.value <= now ? $0.key : nil }) + if !expired.isEmpty { + externalControlledPanes.subtract(expired) + for key in expired { externalControlExpiry.removeValue(forKey: key) } + } + scheduleExternalControlExpiry() + } + + private func clearExternalControlActivity() { + externalControlExpiryWork?.cancel() + externalControlExpiryWork = nil + externalControlExpiry.removeAll() + if !externalControlledPanes.isEmpty { externalControlledPanes.removeAll() } + } var webRemoteAccessURLs: [String] { guard case let .ready(urls) = webRemoteStatus else { return [] } @@ -1675,7 +1756,8 @@ final class WorkspaceStore: ObservableObject { self.init(activity: PaneActivityStore()) } - init(activity: PaneActivityStore) { + init(activity: PaneActivityStore, externalControlNow: @escaping () -> Date = Date.init) { + self.externalControlNow = externalControlNow self.activity = activity let loaded = Persistence.load() ?? PersistedState.fresh self.workspaces = loaded.workspaces @@ -1927,6 +2009,7 @@ final class WorkspaceStore: ObservableObject { } deinit { + externalControlExpiryWork?.cancel() fallbackTimer?.invalidate() for token in observerTokens { NotificationCenter.default.removeObserver(token) @@ -1953,7 +2036,7 @@ final class WorkspaceStore: ObservableObject { guard freeIdleTerminalsEnabled else { return } let timeout = TimeInterval(idleTerminalTimeoutSeconds) for (key, view) in surfaceViews { - guard !webRemoteControlledPanes.contains(key) else { continue } + guard !webRemoteControlledPanes.contains(key), !externalControlledPanes.contains(key) else { continue } view.takeOfflineIfEligible( enabled: true, timeout: timeout, @@ -1996,16 +2079,8 @@ final class WorkspaceStore: ObservableObject { // previous session instead of collapsing onto the most-recent one // (#45). No id captured ⇒ fall back to "resume most recent" — handles // pre-fix data and panes where no hook fired before shutdown. - let restoreCommand: String? = { - if let pending = pendingInitialInput.removeValue(forKey: key) { return pending } - guard let pane = workspaces.first(where: { $0.id == workspaceID })?.panes[paneID], - let token = pane.lastAgent, - let kind = PaneAgentKind(rawValue: token), - restoreEnabled(for: kind) else { return nil } - let sid = pane.sessionIds[kind.rawValue] - .flatMap { Self.isValidSessionId($0) ? $0 : nil } - return kind.restoreCommand(sessionId: sid, codexHome: pane.codexHome) - }() + let restoreCommand = initialCommand(for: key) + pendingInitialInput.removeValue(forKey: key) let v = GhosttySurfaceView( frame: .zero, initialCwd: cwd, @@ -2018,6 +2093,44 @@ final class WorkspaceStore: ObservableObject { return v } + private func initialCommand(for key: WorkspacePaneKey) -> String? { + if let pending = pendingInitialInput[key] { return pending } + guard let pane = workspaces.first(where: { $0.id == key.workspace })?.panes[key.pane], + let token = pane.lastAgent, let kind = PaneAgentKind(rawValue: token), + restoreEnabled(for: kind) else { return nil } + let sid = pane.sessionIds[kind.rawValue].flatMap { Self.isValidSessionId($0) ? $0 : nil } + return kind.restoreCommand(sessionId: sid, codexHome: pane.codexHome) + } + + /// Remote control may wake an ordinary shell without selecting its workspace. + /// Agent restoration and one-shot commands require an explicit local mount. + private func remoteControlView( + for key: WorkspacePaneKey, + source: WorkspaceControlSource = .local + ) -> Result { + let view: GhosttySurfaceView + if let existing = surfaceViews[key] { + view = existing + } else { + guard initialCommand(for: key) == nil else { return .failure(.requiresLocalOpen) } + let cwd = workspaces.first(where: { $0.id == key.workspace })?.panes[key.pane]?.workingDirectory + view = surfaceView(workspaceID: key.workspace, paneID: key.pane, cwd: cwd) + } + guard view.hasLiveSurface || !view.hasPendingInitialInput else { + return .failure(.requiresLocalOpen) + } + // Check before ensureLiveSurface: creating a zero-sized shell now and + // restoring history on its eventual mount would invert output order. + if source == .local && view.requiresLocalScrollbackRestore { + return .failure(.requiresLocalOpen) + } + return .success(view) + } + + private enum RemoteControlError: String, Error { + case requiresLocalOpen = "pane-requires-local-open" + } + /// Walks the split tree to decide whether `target` sits flush with the /// top edge of the window. Returns nil if the leaf isn't in this tree. /// Horizontal splits don't change top-alignment (both sides reach the @@ -2829,6 +2942,9 @@ final class WorkspaceStore: ObservableObject { // ghost entries forever. paneAgentState.removeValue(forKey: key) paneProcesses.removeValue(forKey: key) + webRemoteControlledPanes.remove(key) + externalControlExpiry.removeValue(forKey: key) + externalControlledPanes.remove(key) clearDockBadge(for: key) workspaces[i].tabs[t].focusedPane = survivor ?? workspaces[i].tabs[t].root.leaves.first @@ -2942,13 +3058,15 @@ final class WorkspaceStore: ObservableObject { func controlSendText(pane: String, text: String, enter: Bool) -> String? { guard let key = Self.parsePaneKey(pane) else { return "bad-request" } guard paneExists(key) else { return "unknown-pane" } - // surfaceViews only holds panes that have actually rendered; a real - // pane in a background tab/workspace may have no live surface yet. - // Report that distinctly instead of dropping the inject and lying with - // {"ok":true}, or misreporting it as a nonexistent pane. - guard let view = surfaceViews[key], view.hasLiveSurface else { return "pane-not-ready" } + let view: GhosttySurfaceView + switch remoteControlView(for: key) { + case let .success(value): view = value + case let .failure(error): return error.rawValue + } + guard view.ensureLiveForWebRemoteControl() else { return "pane-not-ready" } view.injectText(text) if enter { view.injectKey(.special(keycode: 36)) } + noteExternalControlActivity(key) return nil } @@ -2962,11 +3080,40 @@ final class WorkspaceStore: ObservableObject { parsed.append(ik) } guard paneExists(key) else { return "unknown-pane" } - guard let view = surfaceViews[key], view.hasLiveSurface else { return "pane-not-ready" } + let view: GhosttySurfaceView + switch remoteControlView(for: key) { + case let .success(value): view = value + case let .failure(error): return error.rawValue + } + guard view.ensureLiveForWebRemoteControl() else { return "pane-not-ready" } for ik in parsed { view.injectKey(ik) } + noteExternalControlActivity(key) return nil } + enum ControlGridResult { + case success(Data) + case failure(String) + } + + func controlReadGrid(pane: String, scrollback: Bool, maxLines: Int) -> ControlGridResult { + guard let key = Self.parsePaneKey(pane), (1...10_000).contains(maxLines) else { + return .failure("bad-request") + } + guard paneExists(key) else { return .failure("unknown-pane") } + let view: GhosttySurfaceView + switch remoteControlView(for: key) { + case let .success(value): view = value + case let .failure(error): return .failure(error.rawValue) + } + guard view.ensureLiveForWebRemoteControl(), + let grid = view.controlReadGrid(scrollback: scrollback, maxLines: maxLines) else { + return .failure("pane-not-ready") + } + noteExternalControlActivity(key) + return .success(grid) + } + func controlFocus(pane: String, activateApp: Bool = true) -> String? { guard let key = Self.parsePaneKey(pane) else { return "bad-request" } guard let wsIdx = workspaces.firstIndex(where: { $0.id == key.workspace }), @@ -3076,14 +3223,16 @@ final class WorkspaceStore: ObservableObject { guard paneExists(key) else { completion(.failure("unknown-pane")); return } - guard let view = surfaceViews[key] else { - completion(.failure("pane-not-ready")); return + let view: GhosttySurfaceView + switch remoteControlView(for: key, source: .web) { + case let .success(value): view = value + case let .failure(error): completion(.failure(error.rawValue)); return } - webRemoteControlledPanes.insert(key) - view.webRemoteSnapshot(size: size) { snapshot in - guard let snapshot else { + view.webRemoteSnapshot(size: size) { [weak self] snapshot in + guard let self, self.paneExists(key), let snapshot else { completion(.failure("pane-not-ready")); return } + if !self.webRemoteControlledPanes.contains(key) { self.webRemoteControlledPanes.insert(key) } completion(.success(snapshot)) } } @@ -3323,6 +3472,8 @@ final class WorkspaceStore: ObservableObject { paneAgentState.removeValue(forKey: key) paneProcesses.removeValue(forKey: key) webRemoteControlledPanes.remove(key) + externalControlExpiry.removeValue(forKey: key) + externalControlledPanes.remove(key) clearDockBadge(for: key) } @@ -3417,6 +3568,11 @@ final class WorkspaceStore: ObservableObject { // Drop every surface tied to this workspace (their ghostty surfaces // get freed in GhosttySurfaceView.deinit when the dict releases them). surfaceViews = surfaceViews.filter { $0.key.workspace != id } + webRemoteControlledPanes = webRemoteControlledPanes.filter { $0.workspace != id } + for key in Array(externalControlExpiry.keys) where key.workspace == id { + externalControlExpiry.removeValue(forKey: key) + externalControlledPanes.remove(key) + } clearDockBadges(for: workspaces[idx].panes.keys.map { WorkspacePaneKey(workspace: id, pane: $0) }) // And their scrollback snapshots. for paneID in workspaces[idx].panes.keys { diff --git a/GlintTests/ControlBridgeIntegrationTests.swift b/GlintTests/ControlBridgeIntegrationTests.swift new file mode 100644 index 00000000..6af9621f --- /dev/null +++ b/GlintTests/ControlBridgeIntegrationTests.swift @@ -0,0 +1,292 @@ +import Darwin +import GhosttyKit +import XCTest +@testable import Glint + +/// Uses a private control directory, the real server and the bundled CLI. +/// Never opens the user's control socket or changes their control preference. +@MainActor +final class ControlBridgeIntegrationTests: XCTestCase { + private struct CLIResult { + let status: Int32 + let output: String + let error: String + } + + private func cli(_ arguments: [String]) async throws -> CLIResult { + let executable = ControlCLIIntegration.executableURL + return try await Task.detached { + let process = Process() + process.executableURL = executable + process.arguments = arguments + let output = Pipe(), error = Pipe() + process.standardOutput = output + process.standardError = error + try process.run() + let data = output.fileHandleForReading.readDataToEndOfFile() + let diagnostics = error.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + return CLIResult(status: process.terminationStatus, + output: String(decoding: data, as: UTF8.self), + error: String(decoding: diagnostics, as: UTF8.self)) + }.value + } + + private func directory() throws -> URL { + let root = URL(fileURLWithPath: "/tmp/gc-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + addTeardownBlock { try? FileManager.default.removeItem(at: root) } + return root + } + + private nonisolated static func connect(_ path: String) throws -> Int32 { + let fd = socket(AF_UNIX, SOCK_STREAM, 0) + guard fd >= 0 else { throw POSIXError(.EIO) } + var timeout = timeval(tv_sec: 3, tv_usec: 0) + setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &timeout, socklen_t(MemoryLayout.size)) + var noSigPipe: Int32 = 1 + setsockopt(fd, SOL_SOCKET, SO_NOSIGPIPE, &noSigPipe, socklen_t(MemoryLayout.size)) + var address = sockaddr_un() + address.sun_family = sa_family_t(AF_UNIX) + path.withCString { source in + withUnsafeMutableBytes(of: &address.sun_path) { buffer in + _ = strlcpy(buffer.baseAddress!.assumingMemoryBound(to: CChar.self), source, buffer.count) + } + } + let rc = withUnsafePointer(to: &address) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { + Darwin.connect(fd, $0, socklen_t(MemoryLayout.size)) + } + } + guard rc == 0 else { close(fd); throw POSIXError(.ECONNREFUSED) } + return fd + } + + private nonisolated static func exchange(_ fd: Int32, _ object: [String: Any]) throws -> [String: Any] { + var data = try JSONSerialization.data(withJSONObject: object) + data.append(0x0A) + try data.withUnsafeBytes { bytes in + var offset = 0 + while offset < bytes.count { + let n = Darwin.write(fd, bytes.baseAddress!.advanced(by: offset), bytes.count - offset) + guard n > 0 else { throw POSIXError(.EIO) } + offset += n + } + } + var response = Data() + var buffer = [UInt8](repeating: 0, count: 4096) + while true { + let n = Darwin.read(fd, &buffer, buffer.count) + guard n > 0 else { throw POSIXError(.EIO) } + response.append(buffer, count: n) + if let newline = response.firstIndex(of: 0x0A) { + return try XCTUnwrap(JSONSerialization.jsonObject(with: response.prefix(upTo: newline)) as? [String: Any]) + } + } + } + + func testCLIReadsAndWritesRealBackgroundTerminal() async throws { + let root = try directory() + let store = WorkspaceStore(activity: PaneActivityStore()) + let local = Workspace.fresh(name: "Local", accentHex: "5E5CE6", symbol: "L") + let target = Workspace.fresh(name: "CLI", accentHex: "5E5CE6", symbol: "C") + store.workspaces = [local, target] + store.selectedWorkspaceID = local.id + let pane = try XCTUnwrap(target.selectedTab?.focusedPane) + let handle = "\(target.id.uuidString):\(pane.value)" + let bridge = ControlBridge(runDirectory: root, isEnabled: { true }) + bridge.start() + defer { bridge.stop() } + XCTAssertTrue(bridge.isRunning) + let prefix = ["--socket", bridge.socketPath] + let listed = try await cli(prefix + ["panes", "list"]) + XCTAssertEqual(listed.status, 0, listed.error) + XCTAssertTrue(listed.output.contains(handle)) + let firstRead = try await cli(prefix + ["pane", "read", handle]) + XCTAssertEqual(firstRead.status, 0, firstRead.error) + XCTAssertEqual(store.selectedWorkspaceID, local.id) + let view = store.surfaceView(workspaceID: target.id, paneID: pane, cwd: "/tmp") + let surface = try XCTUnwrap(view.surface) + let marker = "\u{1b}[2J\u{1b}[HCLI 你好😀\r\n" + marker.withCString { ghostty_surface_process_output(surface, $0, UInt(marker.utf8.count)) } + let read = try await cli(prefix + ["pane", "read", handle]) + XCTAssertEqual(read.status, 0, read.error) + XCTAssertTrue(read.output.contains("CLI 你好😀"), read.output) + let sent = try await cli(prefix + ["pane", "send", handle, "--text", "CLI-input-你好"]) + XCTAssertEqual(sent.status, 0, sent.error) + let key = try await cli(prefix + ["pane", "key", handle, "left"]) + XCTAssertEqual(key.status, 0, key.error) + XCTAssertEqual(store.selectedWorkspaceID, local.id) + XCTAssertTrue(store.isWorkspaceRemotelyControlled(target.id)) + let focused = try await cli(prefix + ["pane", "focus", handle]) + XCTAssertEqual(focused.status, 0, focused.error) + XCTAssertEqual(store.selectedWorkspaceID, target.id) + } + + func testOffSwitchAndRestartRevokeEveryCommandAndOldConnections() async throws { + let root = try directory() + let store = WorkspaceStore(activity: PaneActivityStore()) + let local = Workspace.fresh(name: "Local", accentHex: "5E5CE6", symbol: "L") + let target = Workspace.fresh(name: "Target", accentHex: "5E5CE6", symbol: "T") + store.workspaces = [local, target] + store.selectedWorkspaceID = local.id + let pane = try XCTUnwrap(target.selectedTab?.focusedPane) + let handle = "\(target.id.uuidString):\(pane.value)" + var enabled = true + let bridge = ControlBridge(runDirectory: root, isEnabled: { enabled }) + bridge.start() + defer { bridge.stop() } + let path = bridge.socketPath + let oldToken = try String(contentsOfFile: bridge.tokenPath) + let connection = try await Task.detached { try Self.connect(path) }.value + defer { close(connection) } + let first = try await Task.detached { try Self.exchange(connection, ["cmd": "list"]) }.value + XCTAssertEqual(first["ok"] as? Bool, true) + + // This is the dispatch-side switch guard, independently of unlinking. + enabled = false + for command in ["list", "focus", "read-text", "send-text", "send-key"] { + let response = try await Task.detached { + try Self.exchange(connection, ["cmd": command, "pane": handle, "text": "blocked", + "keys": ["enter"], "token": oldToken]) + }.value + XCTAssertEqual(response["error"] as? String, "disabled", command) + } + XCTAssertEqual(store.selectedWorkspaceID, local.id) + XCTAssertFalse(store.isWorkspaceRemotelyControlled(target.id)) + bridge.stop() + XCTAssertFalse(FileManager.default.fileExists(atPath: path)) + XCTAssertFalse(FileManager.default.fileExists(atPath: bridge.tokenPath)) + let off = try await cli(["--socket", path, "panes", "list"]) + XCTAssertEqual(off.status, 1) + XCTAssertTrue(off.error.contains("External control")) + + enabled = true + bridge.start() + let oldConnection = try await Task.detached { try Self.exchange(connection, ["cmd": "list"]) }.value + XCTAssertEqual(oldConnection["error"] as? String, "disabled") + let freshConnection = try await Task.detached { try Self.connect(path) }.value + defer { close(freshConnection) } + let staleToken = try await Task.detached { + try Self.exchange(freshConnection, ["cmd": "read-text", "pane": handle, "token": oldToken]) + }.value + XCTAssertEqual(staleToken["error"] as? String, "unauthorized") + let on = try await cli(["--socket", path, "panes", "list"]) + XCTAssertEqual(on.status, 0, on.error) + XCTAssertEqual(store.selectedWorkspaceID, local.id) + } + + func testOversizedSocketRequestReturnsExplicitError() async throws { + let store = WorkspaceStore(activity: PaneActivityStore()) + let bridge = ControlBridge(runDirectory: try directory(), isEnabled: { true }) + bridge.start() + defer { bridge.stop(); withExtendedLifetime(store) {} } + let path = bridge.socketPath + let response = try await Task.detached { () throws -> [String: Any] in + let fd = try Self.connect(path) + defer { close(fd) } + // No newline: the server must reject an oversized in-progress frame. + let payload = Data(repeating: 0x61, count: (1 << 20) + 1) + try payload.withUnsafeBytes { raw in + var sent = 0 + while sent < raw.count { + let n = Darwin.write(fd, raw.baseAddress!.advanced(by: sent), raw.count - sent) + guard n > 0 else { throw POSIXError(.EIO) } + sent += n + } + } + var buffer = [UInt8](repeating: 0, count: 4096) + let count = Darwin.read(fd, &buffer, buffer.count) + guard count > 0 else { throw POSIXError(.EIO) } + return try XCTUnwrap(JSONSerialization.jsonObject(with: Data(buffer.prefix(count))) as? [String: Any]) + }.value + XCTAssertEqual(response["ok"] as? Bool, false) + XCTAssertEqual(response["error"] as? String, "request-too-large") + } + + func testDefaultBridgeRejectsAccessWhenPreferenceIsOff() async throws { + let store = WorkspaceStore(activity: PaneActivityStore()) + // Preserve the user's defaults; no listener is started through the toggle. + let old = UserDefaults.standard.object(forKey: "glint.externalControlEnabled") + UserDefaults.standard.set(false, forKey: "glint.externalControlEnabled") + defer { + if let old { UserDefaults.standard.set(old, forKey: "glint.externalControlEnabled") } + else { UserDefaults.standard.removeObject(forKey: "glint.externalControlEnabled") } + } + // The actual gate reads the current store's switch, not CLI metadata. + // WorkspaceStore initially reads the persisted preference; use a fresh store. + let disabledStore = WorkspaceStore(activity: PaneActivityStore()) + let bridge = ControlBridge(runDirectory: try directory()) + bridge.start() + defer { bridge.stop(); withExtendedLifetime(store) {} } + XCTAssertFalse(disabledStore.externalControlEnabled) + let result = try await cli(["--socket", bridge.socketPath, "panes", "list"]) + XCTAssertEqual(result.status, 1) + XCTAssertTrue(result.error.contains("disabled")) + withExtendedLifetime(disabledStore) {} + } + + func testCLIInstallationStateRejectsRepeatInstallAndUninstallsOnlyLink() throws { + let root = try directory() + let tool = root.appendingPathComponent("tool") + try Data("#!/bin/sh\nexit 0\n".utf8).write(to: tool) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: tool.path) + let bin = root.appendingPathComponent("bin") + XCTAssertEqual(CLIInstallation.state(executable: tool, directory: bin), .notInstalled) + let link = try CLIInstallation.install(executable: tool, directory: bin) + XCTAssertEqual(CLIInstallation.state(executable: tool, directory: bin), .installed) + XCTAssertThrowsError(try CLIInstallation.install(executable: tool, directory: bin)) + XCTAssertEqual(try FileManager.default.destinationOfSymbolicLink(atPath: link.path), tool.path) + try CLIInstallation.uninstall(executable: tool, directory: bin) + XCTAssertEqual(CLIInstallation.state(executable: tool, directory: bin), .notInstalled) + XCTAssertTrue(FileManager.default.isExecutableFile(atPath: tool.path)) + XCTAssertThrowsError(try CLIInstallation.uninstall(executable: tool, directory: bin)) + _ = try CLIInstallation.install(executable: tool, directory: bin) + XCTAssertEqual(CLIInstallation.state(executable: tool, directory: bin), .installed) + } + + func testCLIInstallationDistinguishesExistingGlintFromDanglingLink() throws { + let root = try directory() + let executable = URL(fileURLWithPath: "/bin/sh") + let other = root.appendingPathComponent("Other/Glint.app/Contents/Helpers/glint") + try FileManager.default.createDirectory(at: other.deletingLastPathComponent(), withIntermediateDirectories: true) + try Data("#!/bin/sh\nexit 0\n".utf8).write(to: other) + let link = root.appendingPathComponent("glint") + try FileManager.default.createSymbolicLink(at: link, withDestinationURL: other) + XCTAssertEqual(CLIInstallation.state(executable: executable, directory: root), .linkedElsewhere) + XCTAssertThrowsError(try CLIInstallation.install(executable: executable, directory: root)) + XCTAssertThrowsError(try CLIInstallation.uninstall(executable: executable, directory: root)) + XCTAssertEqual(try FileManager.default.destinationOfSymbolicLink(atPath: link.path), other.path) + XCTAssertTrue(FileManager.default.fileExists(atPath: other.path)) + try FileManager.default.removeItem(at: other) + XCTAssertEqual(CLIInstallation.state(executable: executable, directory: root), .repairable) + _ = try CLIInstallation.install(executable: executable, directory: root) + XCTAssertEqual(CLIInstallation.state(executable: executable, directory: root), .installed) + } + + func testCLIUninstallRefusesReplacedFilesAndForeignLinks() throws { + let root = try directory() + let tool = URL(fileURLWithPath: "/bin/sh") + let link = root.appendingPathComponent("glint") + try Data("another command".utf8).write(to: link) + XCTAssertEqual(CLIInstallation.state(executable: tool, directory: root), .conflict) + XCTAssertThrowsError(try CLIInstallation.uninstall(executable: tool, directory: root)) + XCTAssertEqual(try String(contentsOf: link), "another command") + try FileManager.default.removeItem(at: link) + try FileManager.default.createSymbolicLink(atPath: link.path, withDestinationPath: "/missing/tool") + XCTAssertEqual(CLIInstallation.state(executable: tool, directory: root), .conflict) + XCTAssertThrowsError(try CLIInstallation.uninstall(executable: tool, directory: root)) + XCTAssertEqual(try FileManager.default.destinationOfSymbolicLink(atPath: link.path), "/missing/tool") + } + + func testCopiedAgentInstructionsChooseMatchingBuildWithoutCredentials() { + let binary = URL(fileURLWithPath: "/Applications/Glint's App.app/Contents/Helpers/glint") + let debug = ControlCLIIntegration.agentInstructions(executable: binary, debug: true) + let release = ControlCLIIntegration.agentInstructions(executable: binary, debug: false) + XCTAssertTrue(debug.contains("Glint'\\''s App.app")) + XCTAssertTrue(debug.contains("--debug panes list")) + XCTAssertTrue(release.contains("pane read PANE")) + XCTAssertFalse(release.contains("--debug")) + XCTAssertFalse(debug.contains("control.token")) + } +} diff --git a/GlintTests/WebRemoteProtocolTests.swift b/GlintTests/WebRemoteProtocolTests.swift index 1c7c0a5f..248b65e3 100644 --- a/GlintTests/WebRemoteProtocolTests.swift +++ b/GlintTests/WebRemoteProtocolTests.swift @@ -1,5 +1,7 @@ import AppKit import CryptoKit +import Combine +import GhosttyKit import XCTest @testable import Glint @@ -400,6 +402,7 @@ final class WebRemoteProtocolTests: XCTestCase { XCTAssertTrue(script.contains("if (autoFocusTerminal) terminal.focus()")) } + func testBundledClientRecoversStaleWebSocketConnections() throws { let scriptURL = try XCTUnwrap( Bundle.main.url(forResource: "web-remote", withExtension: "js") @@ -905,3 +908,291 @@ final class WebRemoteProtocolTests: XCTestCase { XCTAssertNil(WebRemoteAssets.asset(for: "/crypto.js")) } } + + +@MainActor +final class ExternalControlTests: XCTestCase { + func testReadsRequireTokenAndLineLimitRejectsInvalidJSONNumbers() { + XCTAssertTrue(ControlBridge.requiresToken(for: "read-text")) + XCTAssertTrue(ControlBridge.requiresToken(for: "send-text")) + XCTAssertTrue(ControlBridge.requiresToken(for: "send-key")) + XCTAssertFalse(ControlBridge.requiresToken(for: "list")) + XCTAssertEqual(ControlBridge.readTextMaxLines(nil), 1000) + XCTAssertEqual(ControlBridge.readTextMaxLines(10_000), 10_000) + for invalid: Any in [true, false, 0, -1, 10_001, 2.5, "100", NSNull()] { + XCTAssertNil(ControlBridge.readTextMaxLines(invalid)) + } + } + + func testReadGridJoinsSoftWrapsAndKeepsUnicodeAndColumnGaps() throws { + let grid = Data(#""" + { + "styles": [], "rows": 4, + "row_spans": [ + {"row":0,"column":0,"style_id":0,"cell_width":4,"text":"你好"}, + {"row":1,"column":0,"style_id":0,"cell_width":2,"text":"😀"}, + {"row":1,"column":4,"style_id":1,"cell_width":1,"text":"X"}, + {"row":2,"column":0,"style_id":0,"cell_width":6,"text":"done "} + ], + "row_wraps": [true, false, false, false], + "scrollback_rows": 1, + "scrollback_spans": [ + {"row":0,"column":0,"style_id":0,"cell_width":7,"text":"history"} + ], "scrollback_row_wraps": [false] + } + """#.utf8) + XCTAssertEqual(GhosttySurfaceView.controlText(fromRenderGrid: grid, scrollback: false, maxLines: 1000), + "你好😀 X\ndone") + XCTAssertEqual(GhosttySurfaceView.controlText(fromRenderGrid: grid, scrollback: true, maxLines: 1000), + "history\n你好😀 X\ndone") + XCTAssertEqual(GhosttySurfaceView.controlText(fromRenderGrid: grid, scrollback: true, maxLines: 1), "done") + } + + func testBackgroundTerminalReadAndWebSelectionPreserveLocalWorkspace() async throws { + let local = Workspace.fresh(name: "Local", accentHex: "5E5CE6", symbol: "L") + let remote = Workspace.fresh(name: "Controlled", accentHex: "5E5CE6", symbol: "R") + let pane = try XCTUnwrap(remote.selectedTab?.focusedPane) + let handle = "\(remote.id.uuidString):\(pane.value)" + var now = Date() + let store = WorkspaceStore(activity: PaneActivityStore(), externalControlNow: { now }) + store.workspaces = [local, remote] + store.selectedWorkspaceID = local.id + let view = store.surfaceView(workspaceID: remote.id, paneID: pane, cwd: "/tmp") + XCTAssertTrue(view.ensureLiveForWebRemoteControl()) + let surface = try XCTUnwrap(view.surface) + let output = "\u{1b}[2J\u{1b}[Hcontrol-read-你好😀" + output.withCString { ghostty_surface_process_output(surface, $0, UInt(output.utf8.count)) } + switch store.controlReadGrid(pane: handle, scrollback: false, maxLines: 1000) { + case let .success(grid): XCTAssertTrue(GhosttySurfaceView.controlText(fromRenderGrid: grid, scrollback: false, maxLines: 1000)?.contains("control-read-你好😀") == true) + case let .failure(error): XCTFail(error) + } + XCTAssertEqual(store.selectedWorkspaceID, local.id) + XCTAssertEqual(store.workspaces[1].selectedTabID, remote.selectedTabID) + XCTAssertTrue(store.isWorkspaceRemotelyControlled(remote.id)) + XCTAssertEqual(store.workspaceControlSources(remote.id), [.local]) + XCTAssertEqual(store.paneControlSources(workspaceID: remote.id, paneID: pane), [.local]) + XCTAssertEqual(store.paneControlSources(workspaceID: remote.id, paneID: PaneID(value: 1)), []) + XCTAssertEqual(store.paneControlSources(workspaceID: local.id, paneID: pane), []) + XCTAssertFalse(store.isWorkspaceRemotelyControlled(local.id)) + let snapshot: WebRemoteTerminalSnapshotResult = await withCheckedContinuation { continuation in + store.webRemoteTerminalSnapshot(pane: handle, size: WebRemoteTerminalSize(columns: 80, rows: 24)) { + continuation.resume(returning: $0) + } + } + switch snapshot { + case .success: break + case let .failure(error): XCTFail(error) + } + XCTAssertEqual(store.selectedWorkspaceID, local.id) + XCTAssertEqual(store.workspaceControlSources(remote.id), [.local, .web]) + XCTAssertEqual(store.paneControlSources(workspaceID: remote.id, paneID: pane), [.local, .web]) + XCTAssertNil(store.controlSendText(pane: handle, text: "external-input", enter: false)) + XCTAssertNil(store.controlSendKeys(pane: handle, keys: ["left"])) + XCTAssertEqual(store.selectedWorkspaceID, local.id) + var publications = 0 + let observation = store.objectWillChange.sink { publications += 1 } + for _ in 0..<10 { + if case let .failure(error) = store.controlReadGrid(pane: handle, scrollback: false, maxLines: 1000) { XCTFail(error) } + } + XCTAssertEqual(publications, 0, "Repeated reads must not invalidate workspace views") + withExtendedLifetime(observation) {} + // Socket activity expires without clearing the browser's ownership. + now = now.addingTimeInterval(15.2) + store.expireExternalControlActivity() + XCTAssertEqual(store.workspaceControlSources(remote.id), [.local, .web]) + XCTAssertEqual(store.paneControlSources(workspaceID: remote.id, paneID: pane), [.local, .web]) + now = now.addingTimeInterval(15) + store.expireExternalControlActivity() + XCTAssertTrue(store.isWorkspaceRemotelyControlled(remote.id)) + XCTAssertEqual(store.workspaceControlSources(remote.id), [.web]) + XCTAssertEqual(store.paneControlSources(workspaceID: remote.id, paneID: pane), [.web]) + store.webRemoteReleaseTerminalSize(pane: handle) + XCTAssertFalse(store.isWorkspaceRemotelyControlled(remote.id)) + XCTAssertEqual(store.workspaceControlSources(remote.id), []) + XCTAssertEqual(store.paneControlSources(workspaceID: remote.id, paneID: pane), []) + if case let .failure(error) = store.controlReadGrid(pane: handle, scrollback: false, maxLines: 1000) { + XCTFail(error) + } + XCTAssertTrue(store.isWorkspaceRemotelyControlled(remote.id)) + let oldEnabled = store.externalControlEnabled + defer { store.externalControlEnabled = oldEnabled } + store.externalControlEnabled = false + XCTAssertFalse(store.isWorkspaceRemotelyControlled(remote.id)) + } + + func testInvalidReadsDoNotClaimControlOrChangeWorkspace() throws { + let workspace = Workspace.fresh(name: "Unready", accentHex: "5E5CE6", symbol: "U") + let store = WorkspaceStore(activity: PaneActivityStore()) + store.workspaces = [workspace] + store.selectedWorkspaceID = workspace.id + let pane = try XCTUnwrap(workspace.selectedTab?.focusedPane) + for (handle, limit, expected) in [ + ("invalid", 1000, "bad-request"), + ("\(workspace.id.uuidString):\(pane.value)", 0, "bad-request"), + ("\(UUID().uuidString):1", 1000, "unknown-pane") + ] { + switch store.controlReadGrid(pane: handle, scrollback: false, maxLines: limit) { + case .success: XCTFail("Expected \(expected)") + case let .failure(error): XCTAssertEqual(error, expected) + } + } + XCTAssertEqual(store.selectedWorkspaceID, workspace.id) + XCTAssertFalse(store.isWorkspaceRemotelyControlled(workspace.id)) + } + func testUnvisitedBackgroundPanesWakeForEveryControlCommand() throws { + let local = Workspace.fresh(name: "Local", accentHex: "5E5CE6", symbol: "L") + for command in ["read", "send", "key"] { + let remote = Workspace.fresh(name: "Unvisited", accentHex: "5E5CE6", symbol: "R") + let pane = try XCTUnwrap(remote.selectedTab?.focusedPane) + let handle = "\(remote.id.uuidString):\(pane.value)" + let store = WorkspaceStore(activity: PaneActivityStore()) + store.workspaces = [local, remote] + store.selectedWorkspaceID = local.id + switch command { + case "read": + if case let .failure(error) = store.controlReadGrid(pane: handle, scrollback: false, maxLines: 1000) { XCTFail(error) } + case "send": XCTAssertNil(store.controlSendText(pane: handle, text: "hello", enter: false)) + default: XCTAssertNil(store.controlSendKeys(pane: handle, keys: ["left"])) + } + let view = store.surfaceView(workspaceID: remote.id, paneID: pane, cwd: nil) + XCTAssertTrue(view.hasLiveSurface, command) + XCTAssertNil(view.window) + XCTAssertEqual(store.selectedWorkspaceID, local.id) + XCTAssertEqual(store.workspaces[1].selectedTabID, remote.selectedTabID) + } + } + + func testRemoteAccessDoesNotConsumePendingStartupCommands() async throws { + let store = WorkspaceStore(activity: PaneActivityStore()) + store.addWorkspace(agentCommand: "echo MUST_WAIT_FOR_LOCAL_VIEW") + let remote = try XCTUnwrap(store.workspaces.last) + let pane = try XCTUnwrap(remote.selectedTab?.focusedPane) + let local = Workspace.fresh(name: "Local", accentHex: "5E5CE6", symbol: "L") + store.workspaces.append(local) + store.selectedWorkspaceID = local.id + let handle = "\(remote.id.uuidString):\(pane.value)" + if case let .failure(error) = store.controlReadGrid(pane: handle, scrollback: false, maxLines: 1000) { + XCTAssertEqual(error, "pane-requires-local-open") + } else { XCTFail("Pending launch must wait for a local view") } + XCTAssertEqual(store.controlSendText(pane: handle, text: "x", enter: false), "pane-requires-local-open") + XCTAssertEqual(store.controlSendKeys(pane: handle, keys: ["enter"]), "pane-requires-local-open") + let result: WebRemoteTerminalSnapshotResult = await withCheckedContinuation { continuation in + store.webRemoteTerminalSnapshot(pane: handle, size: WebRemoteTerminalSize(columns: 80, rows: 24)) { continuation.resume(returning: $0) } + } + if case let .failure(error) = result { XCTAssertEqual(error, "pane-requires-local-open") } + else { XCTFail("Web selection must report pending local launch") } + XCTAssertEqual(store.selectedWorkspaceID, local.id) + XCTAssertEqual(store.workspaceControlSources(remote.id), []) + // A rejected remote attempt must leave the one-shot command intact. + let view = store.surfaceView(workspaceID: remote.id, paneID: pane, cwd: nil) + XCTAssertTrue(view.hasPendingInitialInput) + XCTAssertFalse(view.hasLiveSurface) + } + + func testControlActivityRefreshExtendsDeadlineWithoutPublishing() throws { + var now = Date() + let store = WorkspaceStore(activity: PaneActivityStore(), externalControlNow: { now }) + let workspace = Workspace.fresh(name: "Expiry", accentHex: "5E5CE6", symbol: "E") + store.workspaces = [workspace] + let pane = try XCTUnwrap(workspace.selectedTab?.focusedPane) + let handle = "\(workspace.id.uuidString):\(pane.value)" + if case let .failure(error) = store.controlReadGrid(pane: handle, scrollback: false, maxLines: 1) { XCTFail(error) } + var publications = 0 + let observation = store.objectWillChange.sink { publications += 1 } + now = now.addingTimeInterval(20) + XCTAssertNil(store.controlSendKeys(pane: handle, keys: ["left"])) + now = now.addingTimeInterval(11) + store.expireExternalControlActivity() + XCTAssertEqual(store.workspaceControlSources(workspace.id), [.local]) + XCTAssertEqual(publications, 0) + now = now.addingTimeInterval(19) + store.expireExternalControlActivity() + XCTAssertEqual(store.workspaceControlSources(workspace.id), []) + XCTAssertEqual(publications, 1) + withExtendedLifetime(observation) {} + } + + func testRemoteReadDefersAutomaticAgentRestore() throws { + let store = WorkspaceStore(activity: PaneActivityStore()) + let old = store.restoreCodexSession + store.restoreCodexSession = true + defer { store.restoreCodexSession = old } + var remote = Workspace.fresh(name: "Restore", accentHex: "5E5CE6", symbol: "R") + let pane = try XCTUnwrap(remote.selectedTab?.focusedPane) + remote.panes[pane]?.lastAgent = "codex" + let local = Workspace.fresh(name: "Local", accentHex: "5E5CE6", symbol: "L") + store.workspaces = [local, remote] + store.selectedWorkspaceID = local.id + let handle = "\(remote.id.uuidString):\(pane.value)" + if case let .failure(error) = store.controlReadGrid(pane: handle, scrollback: false, maxLines: 1) { + XCTAssertEqual(error, "pane-requires-local-open") + } else { XCTFail("Automatic agent restore must wait for a local view") } + XCTAssertEqual(store.selectedWorkspaceID, local.id) + XCTAssertEqual(store.workspaceControlSources(remote.id), []) + let view = store.surfaceView(workspaceID: remote.id, paneID: pane, cwd: nil) + XCTAssertTrue(view.hasPendingInitialInput) + XCTAssertFalse(view.hasLiveSurface) + } + + func testCLIRequiresLocalOpenBeforeWakingPaneWithArchivedHistory() async throws { + let defaults = UserDefaults.standard + let setting = "glint.restoreTerminalScrollback" + let previous = defaults.object(forKey: setting) + defaults.set(true, forKey: setting) + defer { + if let previous { defaults.set(previous, forKey: setting) } + else { defaults.removeObject(forKey: setting) } + } + let local = Workspace.fresh(name: "Local", accentHex: "5E5CE6", symbol: "L") + let remote = Workspace.fresh(name: "Archived history", accentHex: "5E5CE6", symbol: "R") + let pane = try XCTUnwrap(remote.selectedTab?.focusedPane) + let handle = "\(remote.id.uuidString):\(pane.value)" + let archiveID = ScrollbackArchive.fileID(forPaneKey: handle) + let directory = try XCTUnwrap(SupportDir.url).appendingPathComponent("scrollback", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let archive = directory.appendingPathComponent("\(archiveID).ansi") + defer { try? FileManager.default.removeItem(at: archive) } + let oldMarker = "PR132_ARCHIVED_HISTORY" + let history = Data((oldMarker + "\n").utf8) + let store = WorkspaceStore(activity: PaneActivityStore()) + store.workspaces = [local, remote] + store.selectedWorkspaceID = local.id + // Finish the store initialization prune before creating this test-only archive. + ScrollbackArchive.drain() + try history.write(to: archive, options: .atomic) + + if case let .failure(error) = store.controlReadGrid(pane: handle, scrollback: true, maxLines: 1000) { + XCTAssertEqual(error, "pane-requires-local-open") + } else { XCTFail("CLI must not wake a shell before its archive can be restored") } + XCTAssertEqual(store.controlSendText(pane: handle, text: "must-not-run", enter: true), "pane-requires-local-open") + XCTAssertEqual(store.controlSendKeys(pane: handle, keys: ["enter"]), "pane-requires-local-open") + let view = store.surfaceView(workspaceID: remote.id, paneID: pane, cwd: nil) + XCTAssertFalse(view.hasLiveSurface) + XCTAssertEqual(ScrollbackArchive.read(id: archiveID), history) + XCTAssertEqual(store.workspaceControlSources(remote.id), []) + XCTAssertEqual(store.selectedWorkspaceID, local.id) + + // Web has an explicit grid and a restore completion barrier; keep that path usable. + let snapshot: WebRemoteTerminalSnapshotResult = await withCheckedContinuation { continuation in + store.webRemoteTerminalSnapshot(pane: handle, size: WebRemoteTerminalSize(columns: 100, rows: 30)) { + continuation.resume(returning: $0) + } + } + if case let .success(value) = snapshot { + XCTAssertTrue(String(decoding: value.payload, as: UTF8.self).contains(oldMarker)) + } else { XCTFail("Web must still restore archived history at its requested grid size") } + let surface = try XCTUnwrap(view.surface) + let newMarker = "PR132_NEW_SESSION_OUTPUT" + let output = newMarker + "\r\n" + output.withCString { ghostty_surface_process_output(surface, $0, UInt(output.utf8.count)) } + guard case let .success(grid) = store.controlReadGrid(pane: handle, scrollback: true, maxLines: 1000), + let text = GhosttySurfaceView.controlText(fromRenderGrid: grid, scrollback: true, maxLines: 1000) else { + return XCTFail("A completed restore must permit subsequent CLI reads") + } + let oldRange = try XCTUnwrap(text.range(of: oldMarker)) + let newRange = try XCTUnwrap(text.range(of: newMarker)) + XCTAssertLessThan(oldRange.lowerBound, newRange.lowerBound) + XCTAssertEqual(store.selectedWorkspaceID, local.id) + } + +} diff --git a/GlintTests/WebRemoteServerIntegrationTests.swift b/GlintTests/WebRemoteServerIntegrationTests.swift index d88a6d48..5d1479ec 100644 --- a/GlintTests/WebRemoteServerIntegrationTests.swift +++ b/GlintTests/WebRemoteServerIntegrationTests.swift @@ -116,6 +116,74 @@ final class WebRemoteServerIntegrationTests: XCTestCase { XCTAssertNotNil(http.value(forHTTPHeaderField: "Content-Length")) } + @MainActor + func testBrowserSelectionReadsBackgroundTerminalWithoutSwitchingLocalWorkspace() async throws { + let local = Workspace.fresh(name: "Local", accentHex: "5E5CE6", symbol: "L") + let remote = Workspace.fresh(name: "Remote", accentHex: "5E5CE6", symbol: "R") + let unvisited = Workspace.fresh(name: "Unvisited", accentHex: "5E5CE6", symbol: "U") + let pane = try XCTUnwrap(remote.selectedTab?.focusedPane) + let handle = "\(remote.id.uuidString):\(pane.value)" + let store = WorkspaceStore(activity: PaneActivityStore()) + store.workspaces = [local, remote, unvisited] + store.selectedWorkspaceID = local.id + let view = store.surfaceView(workspaceID: remote.id, paneID: pane, cwd: "/tmp") + XCTAssertTrue(view.ensureLiveForWebRemoteControl()) + let tokenKey = try XCTUnwrap(WebRemoteCrypto.tokenKey(from: XCTUnwrap(readyToken))) + let task = makeWebSocket() + defer { task.cancel(with: .goingAway, reason: nil) } + let challenge = try await receiveAuthChallenge(task) + let proof = WebRemoteCrypto.proof(tokenKey: tokenKey, challenge: challenge) + try await send(task, ["type": "authenticate", "proof": proof.base64EncodedString()]) + let keys = WebRemoteCrypto.sessionKeys(tokenKey: tokenKey, challenge: challenge) + let authenticated = try await receiveEncryptedJSON(task, key: keys.s2c) + XCTAssertEqual(authenticated["type"] as? String, "authenticated") + let state = try await receiveEncryptedJSON(task, key: keys.s2c) + XCTAssertEqual(state["type"] as? String, "state") + let select = try JSONSerialization.data(withJSONObject: [ + "type": "select", "pane": handle, "columns": 80, "rows": 24 + ]) + let frame = try XCTUnwrap(WebRemoteCrypto.sealFrame(plaintext: select, key: keys.c2s, counter: 0)) + try await task.send(.data(frame)) + let snapshot = try await receiveEncryptedJSON(task, key: keys.s2c) + XCTAssertEqual(snapshot["type"] as? String, "snapshot") + XCTAssertEqual(snapshot["pane"] as? String, handle) + XCTAssertNotNil(snapshot["data"] as? String) + XCTAssertEqual(store.selectedWorkspaceID, local.id) + XCTAssertEqual(store.workspaces[1].selectedTabID, remote.selectedTabID) + XCTAssertTrue(store.isWorkspaceRemotelyControlled(remote.id)) + XCTAssertEqual(store.workspaceControlSources(remote.id), [.web]) + + // Switch the browser to a workspace never mounted on the desktop. + let unvisitedPane = try XCTUnwrap(unvisited.selectedTab?.focusedPane) + let secondHandle = "\(unvisited.id.uuidString):\(unvisitedPane.value)" + let secondSelect = try JSONSerialization.data(withJSONObject: [ + "type": "select", "pane": secondHandle, "columns": 80, "rows": 24 + ]) + let secondFrame = try XCTUnwrap(WebRemoteCrypto.sealFrame(plaintext: secondSelect, key: keys.c2s, counter: 1)) + try await task.send(.data(secondFrame)) + var secondSnapshot: [String: Any] = [:] + for _ in 0..<20 { + let message = try await receiveEncryptedJSON(task, key: keys.s2c) + if message["type"] as? String == "snapshot", message["pane"] as? String == secondHandle { + secondSnapshot = message + break + } + } + XCTAssertEqual(secondSnapshot["type"] as? String, "snapshot") + XCTAssertEqual(store.selectedWorkspaceID, local.id) + XCTAssertEqual(store.workspaces[0].selectedTabID, local.selectedTabID) + XCTAssertEqual(store.workspaces[1].selectedTabID, remote.selectedTabID) + XCTAssertEqual(store.workspaces[2].selectedTabID, unvisited.selectedTabID) + XCTAssertEqual(store.workspaceControlSources(unvisited.id), [.web]) + XCTAssertEqual(store.workspaceControlSources(remote.id), []) + task.cancel(with: .goingAway, reason: nil) + for _ in 0..<50 { + if store.workspaceControlSources(unvisited.id).isEmpty { break } + try await Task.sleep(for: .milliseconds(20)) + } + XCTAssertEqual(store.workspaceControlSources(unvisited.id), []) + } + // MARK: - WebSocket authentication (challenge-response + AES-GCM) func testWebSocketRejectsWrongProofThenAcceptsCorrectProof() async throws { @@ -546,7 +614,10 @@ final class WebRemoteServerIntegrationTests: XCTestCase { /// them after these probes close; no persisted production ports are reused. private func availableHTTPPort() throws -> UInt16 { for _ in 0..<100 { - let port = UInt16.random(in: 49152...65533) + // Stay outside macOS's 49152–65535 ephemeral client range: an + // outgoing URLSession connection can claim a just-probed port + // before NWListener binds it, producing unrelated EADDRINUSE. + let port = UInt16.random(in: 20_000...40_000) let http = socket(AF_INET, SOCK_STREAM, 0) let webSocket = socket(AF_INET, SOCK_STREAM, 0) guard http >= 0, webSocket >= 0 else { diff --git a/README.en.md b/README.en.md index 547abce9..a942bf6b 100644 --- a/README.en.md +++ b/README.en.md @@ -29,6 +29,32 @@ xattr -dr com.apple.quarantine /Applications/Glint.app The Homebrew Cask does this automatically. +## External control CLI + +In Settings → Terminal → External control, **Install CLI** creates `~/.local/bin/glint`. Add `~/.local/bin` to `PATH` if needed. The native CLI ships with Glint and requires no Node.js, Python, or MCP. **Copy Agent instructions** includes the full executable path and works without installing the link. + +Settings shows the installed state and disables repeat installation. Use **Uninstall** or `glint uninstall` to remove its command link. Uninstalling preserves the bundled CLI and the control preference. After moving Glint, dangling links to a missing Glint installation can be repaired by installing again, or removed with uninstall. A valid link to another existing Glint is shown separately and left unchanged by install and uninstall. Other tools’ files and links are also preserved. + +Installing the CLI does not grant control. **Allow external control** must be enabled for reads, input, and focus. Turning it off revokes access immediately; old connections and tokens remain invalid after re-enabling it. The CLI never launches Glint or enables control automatically. + +```sh +glint --help +glint panes list +glint pane read '' --scrollback --max-lines 1000 +glint pane send '' --text 'pwd' --enter +glint pane key '' down enter +``` + +Use the full pane handle from the list. Reads and input preserve local workspace selection; only explicit `glint pane focus ''` switches focus. Reads return plain text unless `--json` is supplied; other commands return JSON. Use `glint --debug …` for a development build. You can also run `/Applications/Glint.app/Contents/Helpers/glint` directly, or use its `install` subcommand to install the link. + +The CLI can read and write background terminals that are already ready, and wake terminals with no pending history or startup tasks, without changing the local workspace. + +**Background access after restarting Glint:** scrollback restoration is enabled by default. If a background pane has an archive awaiting restoration after an app restart, both CLI reads and input return `pane-requires-local-open`. Open that terminal in Glint and let restoration complete before retrying; this also applies to reads without `--scrollback`. This version therefore cannot directly wake every archived background terminal after a restart through the CLI. Web Remote can still restore history at the browser’s explicit grid size. + +For pending startup or agent restore commands, both the CLI and Web prompt you to open the terminal in Glint first; remote access does not run those commands without a local view. + +Custom `--socket` endpoints require a user-owned private directory (0700) and user-owned socket/token files (0600); token symlinks are rejected. Encoded requests are limited to 1 MiB, with explicit errors for oversized requests. + ## Upgrade & uninstall ```bash diff --git a/README.md b/README.md index 56253b4e..7d51bbd4 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,32 @@ xattr -dr com.apple.quarantine /Applications/Glint.app Cask 安装方式会自动帮你做这一步。 +## 外部控制 CLI + +在设置 → 终端 → 外部控制中,点击「安装 CLI」会创建 `~/.local/bin/glint` 链接;必要时将 `~/.local/bin` 加入 `PATH`。CLI 随应用打包,无需 Node.js、Python 或 MCP。「复制 Agent 使用说明」可复制包含完整 CLI 路径的操作说明,不安装链接也能使用。 + +已安装时设置页显示状态并禁用重复安装,可点击「卸载」移除命令链接;命令行也支持 `glint uninstall`。卸载不会删除应用内的 CLI,也不会修改控制开关。应用移动后,指向已不存在 Glint 安装位置的悬空链接可重新安装修复或卸载。指向另一份现存 Glint 的正常链接会单独显示,安装或卸载不会更改该链接;其他程序的文件和链接也不会被覆盖。 + +安装与控制授权相互独立。必须开启「允许外部控制」才能读取、输入或聚焦终端;关闭立即撤销访问,重新开启后旧连接和旧令牌仍不可用。CLI 不会自动启动 Glint 或开启控制。 + +```sh +glint --help +glint panes list +glint pane read '' --scrollback --max-lines 1000 +glint pane send '' --text 'pwd' --enter +glint pane key '' down enter +``` + +`` 使用列表返回的完整标识。普通读写不会切换本地工作区;只有显式 `glint pane focus ''` 才切换。读取默认返回纯文本,可加 `--json`;其他命令返回 JSON。开发版使用 `glint --debug …`。也可直接运行 `/Applications/Glint.app/Contents/Helpers/glint`,或执行该程序的 `install` 子命令安装链接。 + +CLI 可直接读写已就绪的后台终端,也可唤醒没有待恢复历史或启动任务的终端,不切换本地工作区。 + +**重启后的后台访问限制:**滚动历史恢复默认开启。应用重启后,后台 pane 若有待恢复存档,CLI 的读取与输入都会返回 `pane-requires-local-open`,需要先在 Glint 中打开该终端、完成恢复后再操作;不带 `--scrollback` 的读取也受此限制。因此,本版本不能直接通过 CLI 唤醒所有重启后有存档的后台终端。Web 仍可按浏览器指定网格完成历史恢复。 + +若终端有待执行的一次性启动命令或 agent 自动恢复,CLI 和 Web 也会提示先在 Glint 中打开,不会在无本地视图时隐式执行这些命令。 + +自定义 `--socket` 要求目录归当前用户所有且权限为 0700,socket 和 token 文件归当前用户所有且权限为 0600;token 不可为软链接。编码后的单条请求最多 1 MiB,超限会返回明确错误。 + ## 升级与卸载 ```bash diff --git a/Shared/CLIInstallation.swift b/Shared/CLIInstallation.swift new file mode 100644 index 00000000..01d36612 --- /dev/null +++ b/Shared/CLIInstallation.swift @@ -0,0 +1,65 @@ +import Foundation + +enum CLIInstallation { + enum State { case notInstalled, installed, repairable, linkedElsewhere, conflict } + + static func state(executable: URL, directory: URL) -> State { + let fm = FileManager.default + let destination = directory.appendingPathComponent("glint") + if let existing = try? fm.destinationOfSymbolicLink(atPath: destination.path) { + let target = URL(fileURLWithPath: existing, relativeTo: directory) + .standardizedFileURL.resolvingSymlinksInPath() + if target == executable.standardizedFileURL.resolvingSymlinksInPath() { return .installed } + // Only dangling Glint links are repairable. An existing Release/Debug + // installation is a valid owner of this link and must not be replaced. + guard target.path.hasSuffix("/Glint.app/Contents/Helpers/glint") else { return .conflict } + return fm.fileExists(atPath: target.path) ? .linkedElsewhere : .repairable + } + return fm.fileExists(atPath: destination.path) ? .conflict : .notInstalled + } + + static func install(executable: URL, directory: URL) throws -> URL { + let fm = FileManager.default + guard fm.isExecutableFile(atPath: executable.path) else { + throw NSError(domain: "GlintCLI", code: 1, userInfo: [NSLocalizedDescriptionKey: + String(localized: "The bundled CLI is missing. Reinstall Glint.")]) + } + switch state(executable: executable, directory: directory) { + case .installed: + throw NSError(domain: "GlintCLI", code: 3, userInfo: [NSLocalizedDescriptionKey: + String(localized: "CLI is already installed.")]) + case .conflict: throw occupiedPathError() + case .linkedElsewhere: throw linkedElsewhereError() + case .notInstalled: break + case .repairable: + try fm.removeItem(at: directory.appendingPathComponent("glint")) + } + try fm.createDirectory(at: directory, withIntermediateDirectories: true) + let destination = directory.appendingPathComponent("glint") + try fm.createSymbolicLink(at: destination, withDestinationURL: executable) + return destination + } + + static func uninstall(executable: URL, directory: URL) throws { + switch state(executable: executable, directory: directory) { + case .installed, .repairable: + // Remove only the verified link, never the executable or another tool. + try FileManager.default.removeItem(at: directory.appendingPathComponent("glint")) + case .conflict: throw occupiedPathError() + case .linkedElsewhere: throw linkedElsewhereError() + case .notInstalled: + throw NSError(domain: "GlintCLI", code: 4, userInfo: [NSLocalizedDescriptionKey: + String(localized: "CLI is not installed.")]) + } + } + + private static func linkedElsewhereError() -> NSError { + NSError(domain: "GlintCLI", code: 5, userInfo: [NSLocalizedDescriptionKey: + String(localized: "The CLI is linked to another existing Glint installation. It was left unchanged.")]) + } + + private static func occupiedPathError() -> NSError { + NSError(domain: "GlintCLI", code: 2, userInfo: [NSLocalizedDescriptionKey: + String(localized: "The destination already contains another command. It was left unchanged.")]) + } +} diff --git a/project.yml b/project.yml index 318191e6..536a24db 100644 --- a/project.yml +++ b/project.yml @@ -24,6 +24,7 @@ targets: type: application platform: macOS sources: + - path: Shared - path: Glint excludes: - "**/.DS_Store" @@ -78,11 +79,39 @@ targets: # the prod glint.* defaults across (see GlintApp.init). Debug: PRODUCT_BUNDLE_IDENTIFIER: app.glint.Glint.dev + postBuildScripts: + - name: Bundle Glint CLI + script: | + set -eu + /bin/mkdir -p "$TARGET_BUILD_DIR/$CONTENTS_FOLDER_PATH/Helpers" + /bin/cp "$BUILT_PRODUCTS_DIR/glint" "$TARGET_BUILD_DIR/$CONTENTS_FOLDER_PATH/Helpers/glint" + inputFiles: + - "$(BUILT_PRODUCTS_DIR)/glint" + outputFiles: + - "$(TARGET_BUILD_DIR)/$(CONTENTS_FOLDER_PATH)/Helpers/glint" dependencies: + - target: GlintCLI + embed: false + link: false - framework: Vendor/GhosttyKit.xcframework embed: false - package: Sparkle + GlintCLI: + type: tool + productName: glint + platform: macOS + sources: + - path: CLI + - path: Shared + settings: + base: + PRODUCT_NAME: glint + PRODUCT_MODULE_NAME: GlintCLI + SKIP_INSTALL: YES + GENERATE_INFOPLIST_FILE: YES + CODE_SIGN_IDENTITY: "-" + GlintTests: type: bundle.unit-test platform: macOS diff --git a/scripts/test-cli.py b/scripts/test-cli.py new file mode 100644 index 00000000..b29ea828 --- /dev/null +++ b/scripts/test-cli.py @@ -0,0 +1,313 @@ +#!/usr/bin/env python3 +"""Exercise the shipped CLI as a subprocess against a real Unix socket.""" +import json +import os +from pathlib import Path +import socket +import subprocess +import sys +import tempfile +import threading +import unittest + +BINARY = Path(sys.argv.pop(1) if len(sys.argv) > 1 else + 'build/Build/Products/Debug/Glint.app/Contents/Helpers/glint').resolve() +PANE = '12345678-1234-1234-1234-123456789abc:1' + + +class CLITests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory(prefix='glint-cli-', dir='/tmp') + self.root = Path(self.temp.name) + self.path = self.root / 'control.sock' + self.token = 'test-only-token' + self.path.with_suffix('.token').write_text(self.token) + self.path.with_suffix('.token').chmod(0o600) + self.addCleanup(self.temp.cleanup) + + def run_cli(self, *args): + return subprocess.run([str(BINARY), '--socket', str(self.path), *args], + text=True, capture_output=True, timeout=8) + + def exchange(self, args, response): + listener = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + listener.bind(str(self.path)) + self.path.chmod(0o600) + listener.listen(1) + listener.settimeout(5) + captured = [] + errors = [] + + def serve(): + try: + conn, _ = listener.accept() + with conn: + with conn.makefile('rb') as reader: + captured.append(json.loads(reader.readline())) + payload = (json.dumps(response, ensure_ascii=False) + '\n').encode() + for offset in range(0, len(payload), 2048): + conn.sendall(payload[offset:offset + 2048]) + except Exception as error: + errors.append(error) + finally: + listener.close() + + worker = threading.Thread(target=serve, daemon=True) + worker.start() + result = self.run_cli(*args) + worker.join(6) + self.assertFalse(worker.is_alive()) + self.assertFalse(errors, errors) + self.assertEqual(len(captured), 1) + return result, captured[0] + + def test_list(self): + result, request = self.exchange(['panes', 'list'], {'ok': True, 'panes': [{'pane': PANE}]}) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(request, {'cmd': 'list'}) + self.assertEqual(json.loads(result.stdout)['panes'][0]['pane'], PANE) + + def test_read_unicode_scrollback_and_large_partial_response(self): + # Keep enough chunks to catch repeated scans of the accumulated response. + text = ('你好😀\t output\n' * 300000) + 'end' + result, request = self.exchange( + ['pane', 'read', PANE, '--scrollback', '--max-lines', '10000'], + {'ok': True, 'pane': PANE, 'text': text}) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout, text + '\n') + self.assertEqual(request, {'cmd': 'read-text', 'pane': PANE, 'scrollback': True, + 'maxLines': 10000, 'token': self.token}) + self.assertNotIn(self.token, result.stdout + result.stderr) + + def test_read_json(self): + response = {'ok': True, 'text': 'hello\nworld'} + result, _ = self.exchange(['pane', 'read', PANE, '--json'], response) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(json.loads(result.stdout), response) + + def test_send_quoted_unicode_and_newlines(self): + text = "literal '$HOME' `whoami`\n你好😀" + result, request = self.exchange(['pane', 'send', PANE, '--text', text, '--enter'], {'ok': True}) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(request['text'], text) + self.assertIs(request['enter'], True) + self.assertEqual(request['token'], self.token) + + def test_help_text_can_be_sent_as_input(self): + result, request = self.exchange(['pane', 'send', PANE, '--text', '--help'], {'ok': True}) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(request['text'], '--help') + + def test_help_after_socket_options_without_control(self): + for prefix in [[], ['--debug'], ['--socket', str(self.path)]]: + for command in [[], ['panes'], ['pane'], ['pane', 'read'], + ['pane', 'send'], ['pane', 'key'], ['pane', 'focus']]: + with self.subTest(prefix=prefix, command=command): + result = subprocess.run([str(BINARY), *prefix, *command, '--help'], + capture_output=True, text=True, timeout=5) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn('Usage / 用法:', result.stdout) + self.assertEqual(result.stderr, '') + self.assertFalse(self.path.exists()) + + def test_help_does_not_hide_invalid_socket_options(self): + for options in [['--debug', '--debug'], ['--socket', str(self.path), '--debug'], + ['--debug', '--socket', str(self.path)], ['--unknown']]: + with self.subTest(options=options): + result = subprocess.run([str(BINARY), *options, '--help'], + capture_output=True, text=True, timeout=5) + self.assertEqual(result.returncode, 2) + self.assertEqual(result.stdout, '') + + def test_keys(self): + result, request = self.exchange(['pane', 'key', PANE, 'down', 'A', 'enter'], {'ok': True}) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(request['keys'], ['down', 'A', 'enter']) + self.assertEqual(request['token'], self.token) + + def test_focus_is_explicit(self): + result, request = self.exchange(['pane', 'focus', PANE], {'ok': True}) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(request, {'cmd': 'focus', 'pane': PANE}) + + def test_server_rejection_has_failure_exit_and_no_token_leak(self): + result, _ = self.exchange(['pane', 'read', PANE], {'ok': False, 'error': 'disabled'}) + self.assertEqual(result.returncode, 1) + self.assertIn('disabled', result.stderr) + self.assertEqual(result.stdout, '') + self.assertNotIn(self.token, result.stderr) + + def test_offline_does_not_start_glint_or_create_socket(self): + self.path.with_suffix('.token').unlink() + for args in [['panes', 'list'], ['pane', 'read', PANE], + ['pane', 'send', PANE, '--text', 'hello'], ['pane', 'focus', PANE]]: + result = self.run_cli(*args) + self.assertEqual(result.returncode, 1) + self.assertIn('External control', result.stderr) + self.assertFalse(self.path.exists()) + self.assertFalse(self.path.with_suffix('.token').exists()) + + def test_invalid_arguments_are_rejected_before_connecting(self): + for args in [['unknown'], ['pane', 'read', 'bad'], ['pane', 'read', PANE, '--max-lines', '0'], + ['pane', 'read', PANE, '--max-lines', '10001'], + ['pane', 'read', PANE, '--max-lines', '2.5'], + ['pane', 'send', PANE, '--enter'], ['pane', 'key', PANE, 'ctrl-c'], + ['pane', 'focus', PANE, 'extra']]: + result = self.run_cli(*args) + self.assertEqual(result.returncode, 2, (args, result.stderr)) + + def test_install_link_and_help_without_control(self): + directory = self.root / 'bin' + args = [str(BINARY), 'install', '--directory', str(directory)] + result = subprocess.run(args, capture_output=True, text=True, timeout=5) + self.assertEqual(result.returncode, 0, result.stderr) + inode = (directory / 'glint').lstat().st_ino + result = subprocess.run(args, capture_output=True, text=True, timeout=5) + self.assertEqual(result.returncode, 1) + self.assertEqual((directory / 'glint').lstat().st_ino, inode) + link = directory / 'glint' + self.assertTrue(link.is_symlink()) + self.assertEqual(link.resolve(), BINARY) + result = subprocess.run(['glint', '--help'], env={**os.environ, 'PATH': str(directory)}, + capture_output=True, text=True, timeout=5) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn('External control', result.stdout) + self.assertFalse(self.path.exists()) + # Uninstalling through PATH resolves this CLI, not cwd/glint or the GUI. + result = subprocess.run(['glint', 'uninstall', '--directory', str(directory)], + env={**os.environ, 'PATH': str(directory)}, + capture_output=True, text=True, timeout=5) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertFalse(link.exists()) + self.assertTrue(BINARY.is_file()) + result = subprocess.run(args, capture_output=True, text=True, timeout=5) + self.assertEqual(result.returncode, 0, result.stderr) + + def test_install_repairs_only_links_to_other_glint_bundles(self): + directory = self.root / 'bin' + directory.mkdir() + link = directory / 'glint' + old = self.root / 'Moved/Glint.app/Contents/Helpers/glint' + link.symlink_to(old) + result = subprocess.run([str(BINARY), 'install', '--directory', str(directory)], + capture_output=True, text=True, timeout=5) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(link.resolve(), BINARY) + link.unlink() + link.symlink_to(old) + result = subprocess.run([str(BINARY), 'uninstall', '--directory', str(directory)], + capture_output=True, text=True, timeout=5) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertFalse(link.is_symlink()) + + def test_install_and_uninstall_preserve_another_existing_glint(self): + directory = self.root / 'bin' + directory.mkdir() + other = self.root / 'Other/Glint.app/Contents/Helpers/glint' + other.parent.mkdir(parents=True) + other.write_text('#!/bin/sh\nexit 0\n') + other.chmod(0o755) + link = directory / 'glint' + link.symlink_to(other) + inode = link.lstat().st_ino + for command in ['install', 'uninstall']: + result = subprocess.run([str(BINARY), command, '--directory', str(directory)], + capture_output=True, text=True, timeout=5) + self.assertEqual(result.returncode, 1, result.stderr) + self.assertEqual(link.resolve(), other.resolve()) + self.assertEqual(link.lstat().st_ino, inode) + self.assertIn('another', result.stderr) + self.assertTrue(other.is_file()) + + def test_custom_socket_rejects_unsafe_permissions_without_sending_token(self): + for target in ['directory', 'socket', 'token', 'token-symlink']: + with self.subTest(target=target): + listener = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + listener.bind(str(self.path)) + listener.listen(1) + listener.settimeout(0.3) + self.path.chmod(0o600) + token = self.path.with_suffix('.token') + if target == 'directory': self.root.chmod(0o755) + if target == 'socket': self.path.chmod(0o666) + if target == 'token': token.chmod(0o644) + if target == 'token-symlink': + original = self.root / 'original-token' + token.rename(original) + token.symlink_to(original) + requests = [] + def serve(): + try: + conn, _ = listener.accept() + with conn: + requests.append(conn.recv(4096)) + conn.sendall(b'{"ok":true,"text":"unsafe"}\n') + except (TimeoutError, BrokenPipeError): pass + finally: listener.close() + worker = threading.Thread(target=serve, daemon=True) + worker.start() + result = self.run_cli('pane', 'read', PANE) + worker.join(1) + self.root.chmod(0o700) + token.unlink() + token.write_text(self.token) + token.chmod(0o600) + self.path.unlink() + self.assertEqual(result.returncode, 1, result.stderr) + self.assertIn('unsafe', result.stderr.lower()) + self.assertEqual(requests, []) + + def test_oversized_encoded_request_is_rejected_before_connecting(self): + listener = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + self.addCleanup(listener.close) + listener.bind(str(self.path)) + self.path.chmod(0o600) + listener.listen(1) + listener.settimeout(0.1) + # JSON escaping expands this under-argv-limit input beyond the protocol limit. + result = self.run_cli('pane', 'send', PANE, '--text', '\x01' * 180000) + self.assertEqual(result.returncode, 2, result.stderr) + self.assertIn('request-too-large', result.stderr) + self.assertNotIn(self.token, result.stderr) + with self.assertRaises(TimeoutError): listener.accept() + + def test_uninstall_preserves_other_files_and_links(self): + directory = self.root / 'bin' + directory.mkdir() + link = directory / 'glint' + link.write_text('another tool') + args = [str(BINARY), 'uninstall', '--directory', str(directory)] + result = subprocess.run(args, capture_output=True, text=True, timeout=5) + self.assertEqual(result.returncode, 1) + self.assertEqual(link.read_text(), 'another tool') + link.unlink() + link.symlink_to('/missing/another-tool') + result = subprocess.run(args, capture_output=True, text=True, timeout=5) + self.assertEqual(result.returncode, 1) + self.assertEqual(os.readlink(link), '/missing/another-tool') + + def test_uninstall_absent_reports_not_installed(self): + result = subprocess.run([str(BINARY), 'uninstall', '--directory', str(self.root / 'absent')], + capture_output=True, text=True, timeout=5) + self.assertEqual(result.returncode, 1) + self.assertFalse((self.root / 'absent').exists()) + + def test_install_preserves_other_files_and_links(self): + directory = self.root / 'bin' + directory.mkdir() + link = directory / 'glint' + link.write_text('other command') + result = subprocess.run([str(BINARY), 'install', '--directory', str(directory)], + capture_output=True, text=True, timeout=5) + self.assertEqual(result.returncode, 1) + self.assertEqual(link.read_text(), 'other command') + link.unlink() + link.symlink_to('/missing/other-command') + result = subprocess.run([str(BINARY), 'install', '--directory', str(directory)], + capture_output=True, text=True, timeout=5) + self.assertEqual(result.returncode, 1) + self.assertEqual(os.readlink(link), '/missing/other-command') + + +if __name__ == '__main__': + unittest.main()