Files
kid aa92d0e676 初始提交:terminalX 可运行态(M0/M1/M1.5 已真机验证)
- M0: libghostty SSH 终端(渲染/输入/连接)+ 白屏修复(OutputGate) + 会话状态机/自动重连
- M1: tsnet 用户态组网 + SSH-over-tsnet(fd 桥),shell 级真机验证;R5(Go+gvisor+C+++Swift 同进程) retire
- M1.5: tmux -CC 原生 tab(MVP)
- 结构: packages/(TXCore·TXTransport), apps/TerminalX, vendor/(libghostty-spm/libssh2/mbedtls/tsnet-bridge), artifacts/
- 文档: CLAUDE.md + docs/HANDOFF.md(新会话入口)
- 环境: 认证代理→依赖 vendor 本地化;Go 在 ~/.local/go;仅模拟器/未签名
- 待续: M2 mosh, tmux 多 pane, M4 安全(host key/SE)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 10:20:46 +08:00

237 lines
7.1 KiB
Swift

import Foundation
import GhosttyTerminal
@resultBuilder
public enum ShellCommandBuilder {
public static func buildBlock(_ components: [ShellCommand]...) -> [ShellCommand] {
components.flatMap(\.self)
}
public static func buildExpression(_ expression: ShellCommand) -> [ShellCommand] {
[expression]
}
public static func buildOptional(_ component: [ShellCommand]?) -> [ShellCommand] {
component ?? []
}
public static func buildEither(first component: [ShellCommand]) -> [ShellCommand] {
component
}
public static func buildEither(second component: [ShellCommand]) -> [ShellCommand] {
component
}
public static func buildArray(_ components: [[ShellCommand]]) -> [ShellCommand] {
components.flatMap(\.self)
}
}
public struct ShellDefinition: Sendable {
public let prompt: String
public let welcomeMessage: String
public let fallbackMessage: @Sendable (String) -> String
let promptDisplayWidth: Int
private let commands: [String: ShellCommand]
private let commandOrder: [String]
public init(
prompt: String = "$ ",
welcomeMessage: String? = nil,
fallback: (@Sendable (String) -> String)? = nil,
@ShellCommandBuilder _ build: () -> [ShellCommand]
) {
self.prompt = prompt
self.welcomeMessage = welcomeMessage ?? Self.defaultWelcomeMessage
fallbackMessage = fallback ?? { cmd in "\(cmd): command not found" }
promptDisplayWidth = prompt.terminalDisplayWidth
let userCommands = build()
var ordered: [String] = []
var map: [String: ShellCommand] = [:]
for cmd in userCommands {
let key = cmd.name.lowercased()
if map[key] == nil {
ordered.append(key)
}
map[key] = cmd
}
commandOrder = ordered
commands = map
}
func processCommand(
_ input: String,
username: String,
terminalSize: InMemoryTerminalViewport
) -> CommandResult {
let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else {
return .output("")
}
let parts = trimmed.split(separator: " ", maxSplits: 1)
let cmd = String(parts[0]).lowercased()
let args = parts.count > 1 ? String(parts[1]) : ""
if cmd == "help" {
return .output(generateHelp())
}
let context = CommandContext(
command: cmd,
arguments: args,
username: username,
terminalSize: terminalSize
)
guard let command = commands[cmd] else {
return .output(fallbackMessage(trimmed) + "\r\n")
}
return command.execute(context)
}
private func generateHelp() -> String {
var lines = ["Available commands:\r"]
let maxNameLen = max(
commandOrder.map(\.count).max() ?? 0,
4 // "help"
)
lines.append(" \("help".padding(toLength: maxNameLen + 2, withPad: " ", startingAt: 0))- Show this help message\r")
for name in commandOrder {
guard let cmd = commands[name] else { continue }
let summary = cmd.summary.isEmpty ? cmd.name : cmd.summary
lines.append(" \(name.padding(toLength: maxNameLen + 2, withPad: " ", startingAt: 0))- \(summary)\r")
}
lines.append("")
return lines.joined(separator: "\n")
}
private static let defaultWelcomeMessage = """
\r\n ShellCraftKit Sandbox Demo\r
\r\n This terminal runs inside App Sandbox.\r
No subprocesses are spawned.\r
Type 'help' for available commands.\r\n\r\n
"""
}
extension String {
var terminalDisplayWidth: Int {
var width = 0
var scalars = unicodeScalars.makeIterator()
while let scalar = scalars.next() {
guard scalar == "\u{1B}" else {
width += scalar.terminalCellWidth
continue
}
guard let next = scalars.next() else {
break
}
guard next == "[" else {
continue
}
while let parameter = scalars.next() {
if (0x40 ... 0x7E).contains(parameter.value) {
break
}
}
}
return width
}
}
private extension UnicodeScalar {
var terminalCellWidth: Int {
// Terminal width needs a tailored policy instead of raw wcwidth():
// - UAX #11 says East_Asian_Width is useful, but "not intended for use by
// modern terminal emulators without appropriate tailoring on a
// case-by-case basis", and ambiguous characters should default to narrow
// when context is unknown.
// - EastAsianWidth.txt provides the normative W/F defaults and wide
// defaults for the ideographic planes we mirror below.
// - POSIX / Apple wcwidth() is locale-dependent, which made test and
// runtime behavior diverge in this project.
//
// References:
// https://www.unicode.org/reports/tr11/
// https://www.unicode.org/Public/UCD/latest/ucd/EastAsianWidth.txt
// https://developer.apple.com/library/archive/documentation/System/Conceptual/ManPages_iPhoneOS/man3/wcwidth.3.html
if properties.generalCategory == .control ||
properties.generalCategory == .format ||
properties.generalCategory == .nonspacingMark ||
properties.generalCategory == .enclosingMark
{
return 0
}
switch value {
case 0x1100 ... 0x115F,
0x231A ... 0x231B,
0x2329 ... 0x232A,
0x23E9 ... 0x23EC,
0x23F0,
0x23F3,
0x25FD ... 0x25FE,
0x2614 ... 0x2615,
0x2648 ... 0x2653,
0x267F,
0x2693,
0x26A1,
0x26AA ... 0x26AB,
0x26BD ... 0x26BE,
0x26C4 ... 0x26C5,
0x26CE,
0x26D4,
0x26EA,
0x26F2 ... 0x26F3,
0x26F5,
0x26FA,
0x26FD,
0x2705,
0x270A ... 0x270B,
0x2728,
0x274C,
0x274E,
0x2753 ... 0x2755,
0x2757,
0x2795 ... 0x2797,
0x27B0,
0x27BF,
0x2B1B ... 0x2B1C,
0x2B50,
0x2B55,
0x2E80 ... 0x2FFB,
0x3000 ... 0x303E,
0x3041 ... 0x33FF,
0x3400 ... 0x4DBF,
0x4E00 ... 0xA4C6,
0xA960 ... 0xA97C,
0xAC00 ... 0xD7A3,
0xF900 ... 0xFAFF,
0xFE10 ... 0xFE19,
0xFE30 ... 0xFE6B,
0xFF01 ... 0xFF60,
0xFFE0 ... 0xFFE6,
0x1F300 ... 0x1F64F,
0x1F900 ... 0x1F9FF,
0x20000 ... 0x2FFFD,
0x30000 ... 0x3FFFD:
return 2
default:
return 1
}
}
}