初始提交: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>
This commit is contained in:
kid
2026-07-24 10:20:46 +08:00
commit aa92d0e676
2761 changed files with 803505 additions and 0 deletions

View File

@@ -0,0 +1,164 @@
import Foundation
/// 会话生命周期状态机(切片② + M3 地基)。纯逻辑、零依赖、可单测。
///
/// 设计要点:`reduce(event)` 是纯函数式转移——输入事件、更新 `phase`、返回一组
/// `Effect`(副作用意图)由外层执行(发起连接、排程重连、更新 UI 等)。
/// 移动端核心诉求:断线/失败后按指数退避自动重连,前台恢复即重试。
public struct SessionMachine: Sendable, Equatable {
public enum Phase: Sendable, Equatable {
case idle
case connecting
case authenticating
case connected
/// 进入后台,连接冻结(保留最后画面),不主动断开。
case backgroundParked
/// 已断开/失败,等待下一次自动重连(记录第几次尝试)。
case waitingToReconnect(attempt: Int)
/// 正在执行自动重连。
case reconnecting(attempt: Int)
/// 终态失败:需要用户介入(认证失效等),不再自动重连。
case failed(reason: String)
/// 用户主动关闭。
case closed
}
public enum Event: Sendable, Equatable {
case connectRequested
case authenticating
case established // 传输层报告已连接
case transportClosed(reason: String?) // 网络断开(可重连)
case authFailed(reason: String) // 认证失败(终态,不重连)
case reconnectTimerFired
case enteredBackground
case enteredForeground
case closeRequested
case retryRequested // 用户手动重试(从 failed 复位)
}
public enum Effect: Sendable, Equatable {
case startConnect
case scheduleReconnect(attempt: Int, delayMS: Int)
case cancelReconnectTimer
case teardownTransport
case notify(String) // UI 提示
}
public private(set) var phase: Phase = .idle
/// 最大自动重连次数(超过则转 failed)。
public let maxReconnectAttempts: Int
/// 退避基数与上限(毫秒)。
private let baseDelayMS: Int
private let maxDelayMS: Int
public init(maxReconnectAttempts: Int = 6, baseDelayMS: Int = 500, maxDelayMS: Int = 16000) {
self.maxReconnectAttempts = maxReconnectAttempts
self.baseDelayMS = baseDelayMS
self.maxDelayMS = maxDelayMS
}
/// 指数退避:base * 2^(attempt-1),封顶 maxDelayMS。
public func backoffDelayMS(attempt: Int) -> Int {
guard attempt >= 1 else { return baseDelayMS }
let shift = min(attempt - 1, 30)
let raw = baseDelayMS * (1 << shift)
return min(raw, maxDelayMS)
}
/// 处理一个事件,就地更新 phase,返回需执行的副作用。
public mutating func reduce(_ event: Event) -> [Effect] {
switch (phase, event) {
// —— 发起连接 ——
case (.idle, .connectRequested),
(.closed, .connectRequested),
(.failed, .retryRequested):
phase = .connecting
return [.startConnect]
case (.connecting, .authenticating):
phase = .authenticating
return []
case (.connecting, .established), (.authenticating, .established):
phase = .connected
return [.notify("已连接")]
// —— 认证失败:终态,不自动重连 ——
case (.connecting, .authFailed(let r)), (.authenticating, .authFailed(let r)):
phase = .failed(reason: r)
return [.teardownTransport, .notify("认证失败:\(r)")]
// —— 网络断开:进入自动重连(从已连接或连接中)——
case (.connected, .transportClosed(let r)),
(.connecting, .transportClosed(let r)),
(.authenticating, .transportClosed(let r)):
return enterReconnect(fromAttempt: 0, reason: r)
// —— 重连计时到点:执行重连 ——
case (.waitingToReconnect(let attempt), .reconnectTimerFired):
phase = .reconnecting(attempt: attempt)
return [.startConnect]
// 重连成功
case (.reconnecting, .established):
phase = .connected
return [.notify("已重连")]
// 重连中的过渡态
case (.reconnecting, .authenticating):
return []
case (.reconnecting, .authFailed(let r)):
phase = .failed(reason: r)
return [.teardownTransport, .notify("认证失败:\(r)")]
// 重连又断:下一次退避(attempt 递增)
case (.reconnecting(let attempt), .transportClosed(let r)):
return enterReconnect(fromAttempt: attempt, reason: r)
// —— 后台/前台 ——
case (.connected, .enteredBackground):
phase = .backgroundParked
return []
case (.backgroundParked, .enteredForeground):
// 恢复:主动触发一次重连探测(attempt 从 1 起)
phase = .reconnecting(attempt: 1)
return [.startConnect, .notify("恢复中…")]
case (.backgroundParked, .transportClosed):
// 后台期间底层已断,回前台会重连;保持 parked
return []
// 等待重连期间进入后台:取消计时,parked
case (.waitingToReconnect, .enteredBackground):
phase = .backgroundParked
return [.cancelReconnectTimer]
// —— 关闭 ——
case (_, .closeRequested):
phase = .closed
return [.cancelReconnectTimer, .teardownTransport]
// 其它组合不改变状态
default:
return []
}
}
private mutating func enterReconnect(fromAttempt: Int, reason: String?) -> [Effect] {
let next = fromAttempt + 1
if next > maxReconnectAttempts {
phase = .failed(reason: reason ?? "连接中断,重连已达上限")
return [.teardownTransport, .notify("重连失败,请手动重试")]
}
phase = .waitingToReconnect(attempt: next)
let delay = backoffDelayMS(attempt: next)
let reasonText = reason.map { ":\($0)" } ?? ""
return [
.teardownTransport,
.scheduleReconnect(attempt: next, delayMS: delay),
.notify("连接中断\(reasonText),\(delay / 1000)s 后重连(第 \(next) 次)"),
]
}
}

View File

@@ -0,0 +1,203 @@
import Foundation
/// tmux control-mode 行协议解析器。
///
/// 输入任意切片的字节流(`feed`),输出完整语义事件。**不涉及 UI、非 `@MainActor`**,
/// 设计为在 transport 的串行队列上运行。
///
/// 关键不变量(依据协议):
/// - 输出严格按行(`\n`),残行缓冲到下次 `feed`;容忍 `\r\n`。
/// - `%begin`…`%end`/`%error` 守卫块之间的所有行都是命令输出,**即使以 `%` 开头**也不解释为通知。
/// - `%output` payload 按字节八进制解码,绝不经 `String`(UTF-8 多字节字符可能跨多条 `%output`)。
/// - 未知 `%`-行降级为 `.unknown`,保证前向兼容。
public final class TmuxControlParser {
private enum State {
case idle
case inBlock(number: Int, lines: [String])
}
private var buffer: [UInt8] = []
private var state: State = .idle
public init() {}
/// 送入一段原始字节,返回本次可解析出的完整事件(可能为空)。
public func feed(_ chunk: Data) -> [TmuxEvent] {
buffer.append(contentsOf: chunk)
var events: [TmuxEvent] = []
while let nl = buffer.firstIndex(of: 0x0A) {
var line = Array(buffer[buffer.startIndex..<nl])
buffer.removeSubrange(buffer.startIndex...nl)
if line.last == 0x0D { line.removeLast() } // 容忍 CRLF
handle(line: line, into: &events)
}
return events
}
/// 字符串便捷入口(测试用)。
public func feed(_ text: String) -> [TmuxEvent] {
feed(Data(text.utf8))
}
// MARK: - 行处理
private func handle(line: [UInt8], into events: inout [TmuxEvent]) {
switch state {
case .inBlock(let number, var lines):
if let guardLine = parseBlockGuard(line), guardLine.number == number {
events.append(.commandResponse(
TmuxCommandResponse(number: number, isError: guardLine.isError, lines: lines)
))
state = .idle
} else {
lines.append(String(decoding: line, as: UTF8.self))
state = .inBlock(number: number, lines: lines)
}
case .idle:
if line.first == 0x25 /* % */ {
if let begin = parseBeginGuard(line) {
state = .inBlock(number: begin, lines: [])
} else {
parseNotification(line: line, into: &events)
}
} else {
// idle 态下的非 %-行属于协议噪声(如登录横幅)。
events.append(.unknown(line: String(decoding: line, as: UTF8.self)))
}
}
}
// MARK: - 守卫行
/// `%begin <time> <number> <flags>` → number
private func parseBeginGuard(_ line: [UInt8]) -> Int? {
let tokens = tokenize(line)
guard tokens.first == "%begin", tokens.count >= 3 else { return nil }
return Int(tokens[2])
}
/// `%end|%error <time> <number> <flags>`
private func parseBlockGuard(_ line: [UInt8]) -> (number: Int, isError: Bool)? {
let tokens = tokenize(line)
guard let head = tokens.first, head == "%end" || head == "%error",
tokens.count >= 3, let n = Int(tokens[2]) else { return nil }
return (n, head == "%error")
}
// MARK: - 通知
private func parseNotification(line: [UInt8], into events: inout [TmuxEvent]) {
let tokens = tokenize(line)
guard let head = tokens.first else {
events.append(.unknown(line: String(decoding: line, as: UTF8.self)))
return
}
switch head {
case "%output":
// %output %<pane> <data>;data 从第 2 个空格后取原始字节(含 raw space)。
guard tokens.count >= 2, let pane = TmuxPaneID.parse(tokens[1]) else { break }
let payload = TmuxOutputDecoder.decode(bytesAfterTokens(2, in: line))
events.append(.output(pane: pane, data: payload))
return
case "%extended-output":
// %extended-output %<pane> <age> : <data>
guard tokens.count >= 4, let pane = TmuxPaneID.parse(tokens[1]),
let age = Int(tokens[2]) else { break }
// tokens[3] 为 ":";data 从第 4 个空格后取。
let payload = TmuxOutputDecoder.decode(bytesAfterTokens(4, in: line))
events.append(.extendedOutput(pane: pane, ageMS: age, data: payload))
return
case "%window-add":
if tokens.count >= 2, let w = TmuxWindowID.parse(tokens[1]) {
events.append(.windowAdd(w)); return
}
case "%window-close":
if tokens.count >= 2, let w = TmuxWindowID.parse(tokens[1]) {
events.append(.windowClose(w)); return
}
case "%unlinked-window-close":
if tokens.count >= 2, let w = TmuxWindowID.parse(tokens[1]) {
events.append(.unlinkedWindowClose(w)); return
}
case "%window-renamed":
if tokens.count >= 3, let w = TmuxWindowID.parse(tokens[1]) {
events.append(.windowRenamed(w, name: tokens[2...].joined(separator: " "))); return
}
case "%window-pane-changed":
if tokens.count >= 3, let w = TmuxWindowID.parse(tokens[1]),
let p = TmuxPaneID.parse(tokens[2]) {
events.append(.windowPaneChanged(window: w, pane: p)); return
}
case "%layout-change":
// %layout-change @<w> <layout> [<visible-layout>] [<flags>]
if tokens.count >= 3, let w = TmuxWindowID.parse(tokens[1]) {
let visible = tokens.count >= 4 ? tokens[3] : nil
events.append(.layoutChange(window: w, layout: tokens[2], visibleLayout: visible)); return
}
case "%session-changed":
if tokens.count >= 3, let s = TmuxSessionID.parse(tokens[1]) {
events.append(.sessionChanged(s, name: tokens[2...].joined(separator: " "))); return
}
case "%session-renamed":
// 部分版本为 `%session-renamed <name>`(当前会话),也有带 $id 的形式;两者都容忍。
if tokens.count >= 3, let s = TmuxSessionID.parse(tokens[1]) {
events.append(.sessionRenamed(s, name: tokens[2...].joined(separator: " "))); return
}
case "%sessions-changed":
events.append(.sessionsChanged); return
case "%session-window-changed":
if tokens.count >= 3, let s = TmuxSessionID.parse(tokens[1]),
let w = TmuxWindowID.parse(tokens[2]) {
events.append(.sessionWindowChanged(session: s, window: w)); return
}
case "%pane-mode-changed":
if tokens.count >= 2, let p = TmuxPaneID.parse(tokens[1]) {
events.append(.paneModeChanged(p)); return
}
case "%pause":
if tokens.count >= 2, let p = TmuxPaneID.parse(tokens[1]) {
events.append(.pause(p)); return
}
case "%continue":
if tokens.count >= 2, let p = TmuxPaneID.parse(tokens[1]) {
events.append(.unpause(p)); return
}
case "%exit":
let reason = tokens.count >= 2 ? tokens[1...].joined(separator: " ") : nil
events.append(.exit(reason: reason)); return
default:
break
}
events.append(.unknown(line: String(decoding: line, as: UTF8.self)))
}
// MARK: - 字节级辅助
/// 按 ASCII 空格切分为 UTF-8 字符串 token(用于守卫行/通知头,非 payload)。
private func tokenize(_ line: [UInt8]) -> [String] {
line.split(separator: 0x20, omittingEmptySubsequences: true)
.map { String(decoding: $0, as: UTF8.self) }
}
/// 返回跳过前 `n` 个以空格分隔的 token 之后的原始字节切片(保留 payload 内的 raw space)。
private func bytesAfterTokens(_ n: Int, in line: [UInt8]) -> ArraySlice<UInt8> {
var idx = line.startIndex
var spacesSeen = 0
// 逐字节推进,跳过 n 段非空格 + 其后的单个分隔空格。
while idx < line.endIndex && spacesSeen < n {
// 跳过一个 token(非空格)
while idx < line.endIndex && line[idx] != 0x20 { idx = line.index(after: idx) }
// 跳过分隔空格
if idx < line.endIndex { idx = line.index(after: idx) }
spacesSeen += 1
}
return line[idx..<line.endIndex]
}
}

View File

@@ -0,0 +1,41 @@
import Foundation
/// tmux `-CC`(iTerm2 风格)控制模式的进入/退出哨兵序列检测。
/// 进入:tmux 打印 DCS `ESC P 1000 p`;退出:`%exit` 后打印 ST `ESC \`。
/// 用于在原始字节流中把「普通 shell 输出」与「control-mode 协议流」分界。
public enum TmuxControlSequence {
/// 进入控制模式的 DCS:ESC P 1000 p
public static let enter: [UInt8] = [0x1B, 0x50, 0x31, 0x30, 0x30, 0x30, 0x70]
/// 退出控制模式的 ST:ESC \
public static let exit: [UInt8] = [0x1B, 0x5C]
/// 在 bytes 中查找 needle,返回其**起始**下标;未找到返回 nil。
public static func find(_ needle: [UInt8], in bytes: [UInt8]) -> Int? {
guard !needle.isEmpty, bytes.count >= needle.count else { return nil }
let last = bytes.count - needle.count
var i = 0
while i <= last {
var j = 0
while j < needle.count && bytes[i + j] == needle[j] { j += 1 }
if j == needle.count { return i }
i += 1
}
return nil
}
/// 为「跨 feed 边界」的哨兵匹配保留的尾部长度(needle 最长 - 1)。
public static var maxSentinelTail: Int { max(enter.count, exit.count) - 1 }
/// 返回 bytes 尾部与 needle 前缀相同的最大长度(1..<needle.count);无则 0。
/// 用于仅在「chunk 结尾恰是哨兵的部分前缀」时才滞留字节,避免延迟正常输出。
public static func partialTrailingMatchLength(_ needle: [UInt8], in bytes: [UInt8]) -> Int {
var k = min(needle.count - 1, bytes.count)
while k > 0 {
var match = true
for j in 0 ..< k where bytes[bytes.count - k + j] != needle[j] { match = false; break }
if match { return k }
k -= 1
}
return 0
}
}

View File

@@ -0,0 +1,60 @@
import Foundation
/// control-mode 解析器输出的语义事件。
///
/// 协议参考:https://github.com/tmux/tmux/wiki/Control-Mode
/// - `%output`/`%extended-output` 承载 pane 的原始 PTY 字节(已从八进制转义还原为 `Data`)。
/// - 命令应答由 `%begin`/`%end`/`%error` 守卫块包裹,按命令发送顺序返回。
/// - 其余 `%`-前缀行是异步通知。
public enum TmuxEvent: Sendable, Equatable {
/// `%output %<pane> <data>` — pane 的 PTY 输出(已解码为原始字节)。
case output(pane: TmuxPaneID, data: Data)
/// `%extended-output %<pane> <age-ms> : <data>` — 带滞后毫秒数的输出(流控场景)。
case extendedOutput(pane: TmuxPaneID, ageMS: Int, data: Data)
/// 一条命令的完整应答(`%begin`…`%end`/`%error` 之间)。
case commandResponse(TmuxCommandResponse)
case windowAdd(TmuxWindowID)
case windowClose(TmuxWindowID)
case unlinkedWindowClose(TmuxWindowID)
case windowRenamed(TmuxWindowID, name: String)
case windowPaneChanged(window: TmuxWindowID, pane: TmuxPaneID)
case layoutChange(window: TmuxWindowID, layout: String, visibleLayout: String?)
case sessionChanged(TmuxSessionID, name: String)
case sessionRenamed(TmuxSessionID, name: String)
case sessionsChanged
case sessionWindowChanged(session: TmuxSessionID, window: TmuxWindowID)
case paneModeChanged(TmuxPaneID)
/// 流控:某 pane 输出被暂停 / 恢复。
case pause(TmuxPaneID)
case unpause(TmuxPaneID)
case exit(reason: String?)
/// 前向兼容:未识别的行(未知通知或协议噪声)。上层应容忍并记录。
case unknown(line: String)
}
/// `%begin <t> <num> <flags>` … `%end|%error <t> <num> <flags>` 之间的原始输出。
public struct TmuxCommandResponse: Sendable, Equatable {
/// 命令序号(守卫行的第 2 个字段),用于与发送队列 FIFO 关联。
public let number: Int
/// 是否以 `%error` 结束。
public let isError: Bool
/// 守卫块内的原始行(不含守卫行本身)。
public let lines: [String]
public init(number: Int, isError: Bool, lines: [String]) {
self.number = number
self.isError = isError
self.lines = lines
}
/// 便捷:整块合并为字符串。
public var text: String { lines.joined(separator: "\n") }
}

View File

@@ -0,0 +1,42 @@
import Foundation
/// tmux 对象标识。control mode 里以带前缀的十进制文本出现:
/// pane `%3`、window `@2`、session `$1`。
public struct TmuxPaneID: Hashable, Sendable, CustomStringConvertible {
public let raw: Int
public init(_ raw: Int) { self.raw = raw }
public var description: String { "%\(raw)" }
/// 解析 `%3` → TmuxPaneID(3)。前缀不符或非数字返回 nil。
public static func parse(_ token: some StringProtocol) -> TmuxPaneID? {
TmuxID.parse(token, prefix: "%").map(TmuxPaneID.init)
}
}
public struct TmuxWindowID: Hashable, Sendable, CustomStringConvertible {
public let raw: Int
public init(_ raw: Int) { self.raw = raw }
public var description: String { "@\(raw)" }
public static func parse(_ token: some StringProtocol) -> TmuxWindowID? {
TmuxID.parse(token, prefix: "@").map(TmuxWindowID.init)
}
}
public struct TmuxSessionID: Hashable, Sendable, CustomStringConvertible {
public let raw: Int
public init(_ raw: Int) { self.raw = raw }
public var description: String { "$\(raw)" }
public static func parse(_ token: some StringProtocol) -> TmuxSessionID? {
TmuxID.parse(token, prefix: "$").map(TmuxSessionID.init)
}
}
enum TmuxID {
/// 校验前缀并解析其后的十进制整数。
static func parse(_ token: some StringProtocol, prefix: Character) -> Int? {
guard let first = token.first, first == prefix else { return nil }
return Int(token.dropFirst())
}
}

View File

@@ -0,0 +1,127 @@
import Foundation
/// tmux window layout 字符串的解析结果。
///
/// 格式(递归):`<csum>,<cell>`,其中
/// - `cell` = `WxH,X,Y` 后接三选一:`,<paneId>`(叶子)、`{c,c,…}`(左右并列)、`[c,c,…]`(上下并列)。
/// - 前导 `<csum>` 是 4 位十六进制校验和,解析时忽略。
///
/// 例:`bc62,80x24,0,0,0`(单 pane)、
/// `a1b2,80x24,0,0{40x24,0,0,1,39x24,41,0,2}`(两 pane 左右分屏)。
public struct TmuxLayout: Sendable, Equatable {
public let root: Node
public indirect enum Node: Sendable, Equatable {
case leaf(pane: TmuxPaneID, rect: Rect)
/// 子节点沿水平方向并排(`{}`)。
case horizontal([Node], rect: Rect)
/// 子节点沿垂直方向堆叠(`[]`)。
case vertical([Node], rect: Rect)
public var rect: Rect {
switch self {
case .leaf(_, let r), .horizontal(_, let r), .vertical(_, let r): return r
}
}
}
public struct Rect: Sendable, Equatable {
public let width: Int, height: Int, x: Int, y: Int
public init(width: Int, height: Int, x: Int, y: Int) {
self.width = width; self.height = height; self.x = x; self.y = y
}
}
public enum ParseError: Error, Equatable {
case empty
case malformed(at: Int, reason: String)
case trailingGarbage(at: Int)
}
/// 收集 layout 中出现的全部 pane(用于与现有 pane 集合做 diff)。
public var paneIDs: [TmuxPaneID] {
var acc: [TmuxPaneID] = []
func walk(_ n: Node) {
switch n {
case .leaf(let p, _): acc.append(p)
case .horizontal(let cs, _), .vertical(let cs, _): cs.forEach(walk)
}
}
walk(root)
return acc
}
public static func parse(_ s: String) throws -> TmuxLayout {
guard let comma = s.firstIndex(of: ",") else { throw ParseError.empty }
let body = s[s.index(after: comma)...]
var cur = Cursor(Array(body))
let root = try cur.parseCell()
guard cur.isAtEnd else { throw ParseError.trailingGarbage(at: cur.offset) }
return TmuxLayout(root: root)
}
}
private struct Cursor {
let chars: [Character]
var i = 0
init(_ chars: [Character]) { self.chars = chars }
var isAtEnd: Bool { i >= chars.count }
var offset: Int { i }
private func peek() -> Character? { i < chars.count ? chars[i] : nil }
mutating func parseCell() throws -> TmuxLayout.Node {
let start = i
let w = try parseInt()
try expect("x")
let h = try parseInt()
try expect(",")
let x = try parseInt()
try expect(",")
let y = try parseInt()
let rect = TmuxLayout.Rect(width: w, height: h, x: x, y: y)
switch peek() {
case "{":
let children = try parseChildren(open: "{", close: "}")
return .horizontal(children, rect: rect)
case "[":
let children = try parseChildren(open: "[", close: "]")
return .vertical(children, rect: rect)
case ",":
i += 1
let paneRaw = try parseInt()
return .leaf(pane: TmuxPaneID(paneRaw), rect: rect)
default:
throw TmuxLayout.ParseError.malformed(at: start, reason: "cell 缺少 pane id 或子节点")
}
}
private mutating func parseChildren(open: Character, close: Character) throws -> [TmuxLayout.Node] {
try expect(open)
var nodes: [TmuxLayout.Node] = []
while true {
nodes.append(try parseCell())
if peek() == "," { i += 1; continue }
break
}
try expect(close)
return nodes
}
private mutating func parseInt() throws -> Int {
let start = i
while let c = peek(), c.isNumber { i += 1 }
guard i > start, let v = Int(String(chars[start..<i])) else {
throw TmuxLayout.ParseError.malformed(at: start, reason: "期望数字")
}
return v
}
private mutating func expect(_ c: Character) throws {
guard peek() == c else {
throw TmuxLayout.ParseError.malformed(at: i, reason: "期望 '\(c)'")
}
i += 1
}
}

View File

@@ -0,0 +1,46 @@
import Foundation
/// `%output` 的 payload 转义还原。
///
/// tmux 在 control mode 下对 pane 输出中 **ASCII < 32 的字节与反斜杠本身**
/// 用三位八进制转义(`\ooo`),其余字节(含空格 0x20)原样传输。
/// 例:换行 `\n`(0x0A) → `\012`,反斜杠 `\`(0x5C) → `\134`。
enum TmuxOutputDecoder {
/// 将转义态字节还原为原始字节。非法/不完整的 `\` 序列按原样保留(稳健优先)。
static func decode(_ bytes: ArraySlice<UInt8>) -> Data {
var out = [UInt8]()
out.reserveCapacity(bytes.count)
var i = bytes.startIndex
let end = bytes.endIndex
while i < end {
let b = bytes[i]
guard b == 0x5C /* backslash */ else {
out.append(b)
i = bytes.index(after: i)
continue
}
// 需要其后正好三位八进制数字(0-7)。
let d0 = bytes.index(i, offsetBy: 1, limitedBy: end) ?? end
let d1 = bytes.index(i, offsetBy: 2, limitedBy: end) ?? end
let d2 = bytes.index(i, offsetBy: 3, limitedBy: end) ?? end
if d2 < end,
let v0 = octalValue(bytes[d0]),
let v1 = octalValue(bytes[d1]),
let v2 = octalValue(bytes[d2]) {
out.append(UInt8(v0 * 64 + v1 * 8 + v2))
i = bytes.index(i, offsetBy: 4)
} else {
// 不是合法八进制转义,原样保留反斜杠。
out.append(b)
i = bytes.index(after: i)
}
}
return Data(out)
}
private static func octalValue(_ b: UInt8) -> Int? {
(0x30...0x37).contains(b) ? Int(b - 0x30) : nil
}
}