初始提交: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,25 @@
// swift-tools-version: 6.0
import PackageDescription
let package = Package(
name: "TXCore",
platforms: [
.macOS(.v14),
.iOS(.v17),
],
products: [
.library(name: "TXCore", targets: ["TXCore"]),
],
targets: [
// macOS `swift test`
.target(
name: "TXCore",
swiftSettings: [.swiftLanguageMode(.v6)]
),
.testTarget(
name: "TXCoreTests",
dependencies: ["TXCore"],
swiftSettings: [.swiftLanguageMode(.v6)]
),
]
)

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 {
/// DCSESC P 1000 p
public static let enter: [UInt8] = [0x1B, 0x50, 0x31, 0x30, 0x30, 0x30, 0x70]
/// 退 STESC \
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
}
}

View File

@@ -0,0 +1,128 @@
import Testing
@testable import TXCore
@Suite("会话生命周期状态机")
struct SessionMachineTests {
@Test("正常连接流程")
func happyPath() {
var m = SessionMachine()
#expect(m.reduce(.connectRequested) == [.startConnect])
#expect(m.phase == .connecting)
#expect(m.reduce(.authenticating) == [])
#expect(m.phase == .authenticating)
#expect(m.reduce(.established) == [.notify("已连接")])
#expect(m.phase == .connected)
}
@Test("认证失败为终态,不自动重连")
func authFailedTerminal() {
var m = SessionMachine()
_ = m.reduce(.connectRequested)
let effects = m.reduce(.authFailed(reason: "密码错误"))
#expect(m.phase == .failed(reason: "密码错误"))
#expect(effects.contains(.teardownTransport))
// failed
#expect(m.reduce(.transportClosed(reason: nil)) == [])
#expect(m.phase == .failed(reason: "密码错误"))
}
@Test("断线进入退避重连")
func disconnectSchedulesReconnect() {
var m = SessionMachine()
_ = m.reduce(.connectRequested)
_ = m.reduce(.established)
let effects = m.reduce(.transportClosed(reason: "eof"))
#expect(m.phase == .waitingToReconnect(attempt: 1))
#expect(effects.contains(.teardownTransport))
#expect(effects.contains(.scheduleReconnect(attempt: 1, delayMS: 500)))
}
@Test("重连计时→重连→成功")
func reconnectSuccess() {
var m = SessionMachine()
_ = m.reduce(.connectRequested)
_ = m.reduce(.established)
_ = m.reduce(.transportClosed(reason: nil))
#expect(m.reduce(.reconnectTimerFired) == [.startConnect])
#expect(m.phase == .reconnecting(attempt: 1))
#expect(m.reduce(.established) == [.notify("已重连")])
#expect(m.phase == .connected)
}
@Test("重连反复失败,退避递增,达上限转 failed")
func reconnectExhaustion() {
var m = SessionMachine(maxReconnectAttempts: 3, baseDelayMS: 500, maxDelayMS: 16000)
_ = m.reduce(.connectRequested)
_ = m.reduce(.established)
// 1
_ = m.reduce(.transportClosed(reason: nil))
#expect(m.phase == .waitingToReconnect(attempt: 1))
_ = m.reduce(.reconnectTimerFired) // reconnecting(1)
// 2attempt 2, delay 1000
let e2 = m.reduce(.transportClosed(reason: nil))
#expect(m.phase == .waitingToReconnect(attempt: 2))
#expect(e2.contains(.scheduleReconnect(attempt: 2, delayMS: 1000)))
_ = m.reduce(.reconnectTimerFired) // reconnecting(2)
// 3attempt 3, delay 2000
let e3 = m.reduce(.transportClosed(reason: nil))
#expect(m.phase == .waitingToReconnect(attempt: 3))
#expect(e3.contains(.scheduleReconnect(attempt: 3, delayMS: 2000)))
_ = m.reduce(.reconnectTimerFired) // reconnecting(3)
// 4 maxReconnectAttempts(3) failed
let e4 = m.reduce(.transportClosed(reason: "gone"))
if case .failed = m.phase {} else { Issue.record("期望 failed实际 \(m.phase)") }
#expect(e4.contains(.notify("重连失败,请手动重试")))
}
@Test("退避封顶")
func backoffCap() {
let m = SessionMachine(baseDelayMS: 500, maxDelayMS: 16000)
#expect(m.backoffDelayMS(attempt: 1) == 500)
#expect(m.backoffDelayMS(attempt: 2) == 1000)
#expect(m.backoffDelayMS(attempt: 5) == 8000)
#expect(m.backoffDelayMS(attempt: 6) == 16000)
#expect(m.backoffDelayMS(attempt: 10) == 16000) //
}
@Test("后台冻结与前台恢复")
func backgroundResume() {
var m = SessionMachine()
_ = m.reduce(.connectRequested)
_ = m.reduce(.established)
#expect(m.reduce(.enteredBackground) == [])
#expect(m.phase == .backgroundParked)
let effects = m.reduce(.enteredForeground)
#expect(m.phase == .reconnecting(attempt: 1))
#expect(effects.contains(.startConnect))
}
@Test("等待重连时进入后台取消计时")
func backgroundDuringWait() {
var m = SessionMachine()
_ = m.reduce(.connectRequested)
_ = m.reduce(.established)
_ = m.reduce(.transportClosed(reason: nil))
#expect(m.phase == .waitingToReconnect(attempt: 1))
#expect(m.reduce(.enteredBackground) == [.cancelReconnectTimer])
#expect(m.phase == .backgroundParked)
}
@Test("主动关闭与手动重试复位")
func closeAndRetry() {
var m = SessionMachine()
_ = m.reduce(.connectRequested)
_ = m.reduce(.established)
let closeEffects = m.reduce(.closeRequested)
#expect(m.phase == .closed)
#expect(closeEffects.contains(.teardownTransport))
// failed
var m2 = SessionMachine()
_ = m2.reduce(.connectRequested)
_ = m2.reduce(.authFailed(reason: "x"))
#expect(m2.reduce(.retryRequested) == [.startConnect])
#expect(m2.phase == .connecting)
}
}

View File

@@ -0,0 +1,118 @@
import Testing
import Foundation
@testable import TXCore
@Suite("tmux control-mode 解析器")
struct TmuxControlParserTests {
@Test("命令应答块按序号关联")
func commandBlock() {
let p = TmuxControlParser()
let events = p.feed("%begin 1678 7 1\nhello\nworld\n%end 1678 7 1\n")
#expect(events.count == 1)
guard case .commandResponse(let r) = events[0] else {
Issue.record("期望 commandResponse"); return
}
#expect(r.number == 7)
#expect(r.isError == false)
#expect(r.lines == ["hello", "world"])
}
@Test("%error 块标记为错误")
func errorBlock() {
let p = TmuxControlParser()
let events = p.feed("%begin 1 3 1\nno server\n%error 1 3 1\n")
guard case .commandResponse(let r) = events.first else {
Issue.record("期望 commandResponse"); return
}
#expect(r.isError)
#expect(r.number == 3)
}
@Test("块内以 % 开头的行不解释为通知")
func percentInsideBlock() {
let p = TmuxControlParser()
let events = p.feed("%begin 1 1 1\n%output not-a-notification\n%end 1 1 1\n")
#expect(events.count == 1)
guard case .commandResponse(let r) = events.first else {
Issue.record("期望 commandResponse"); return
}
#expect(r.lines == ["%output not-a-notification"])
}
@Test("%output 解析出 pane 与字节")
func output() {
let p = TmuxControlParser()
let events = p.feed("%output %2 ready\\012\n")
guard case .output(let pane, let data) = events.first else {
Issue.record("期望 output"); return
}
#expect(pane == TmuxPaneID(2))
#expect(data == Data("ready\n".utf8))
}
@Test("跨 feed 的残行被正确缓冲")
func splitAcrossFeeds() {
let p = TmuxControlParser()
#expect(p.feed("%output %0 par").isEmpty) //
let events = p.feed("tial\n")
guard case .output(_, let data) = events.first else {
Issue.record("期望 output"); return
}
#expect(data == Data("partial".utf8))
}
@Test("CRLF 行尾被容忍")
func crlf() {
let p = TmuxControlParser()
let events = p.feed("%window-add @5\r\n")
#expect(events == [.windowAdd(TmuxWindowID(5))])
}
@Test("窗口生命周期通知")
func windowNotifications() {
let p = TmuxControlParser()
#expect(p.feed("%window-add @1\n") == [.windowAdd(TmuxWindowID(1))])
#expect(p.feed("%window-close @1\n") == [.windowClose(TmuxWindowID(1))])
#expect(p.feed("%window-renamed @2 my shell\n")
== [.windowRenamed(TmuxWindowID(2), name: "my shell")])
}
@Test("会话与布局通知")
func sessionAndLayout() {
let p = TmuxControlParser()
#expect(p.feed("%session-changed $1 main\n")
== [.sessionChanged(TmuxSessionID(1), name: "main")])
let layout = p.feed("%layout-change @3 bc62,80x24,0,0,0\n")
#expect(layout == [.layoutChange(window: TmuxWindowID(3),
layout: "bc62,80x24,0,0,0",
visibleLayout: nil)])
}
@Test("流控与退出")
func flowControlAndExit() {
let p = TmuxControlParser()
#expect(p.feed("%pause %4\n") == [.pause(TmuxPaneID(4))])
#expect(p.feed("%continue %4\n") == [.unpause(TmuxPaneID(4))])
#expect(p.feed("%exit\n") == [.exit(reason: nil)])
#expect(p.feed("%exit server exited\n") == [.exit(reason: "server exited")])
}
@Test("未知通知降级为 .unknown 而非崩溃")
func unknownNotification() {
let p = TmuxControlParser()
let events = p.feed("%some-future-notification foo bar\n")
#expect(events == [.unknown(line: "%some-future-notification foo bar")])
}
@Test("attach 后的隐式空块")
func implicitEmptyBlock() {
let p = TmuxControlParser()
let events = p.feed("%begin 100 0 0\n%end 100 0 0\n")
guard case .commandResponse(let r) = events.first else {
Issue.record("期望空 commandResponse"); return
}
#expect(r.lines.isEmpty)
#expect(r.number == 0)
}
}

View File

@@ -0,0 +1,29 @@
import Testing
@testable import TXCore
@Suite("tmux -CC 哨兵序列检测")
struct TmuxControlSequenceTests {
@Test("查找进入 DCS")
func findEnter() {
let stream = Array("shell output\r\n".utf8) + TmuxControlSequence.enter + Array("%begin".utf8)
let idx = TmuxControlSequence.find(TmuxControlSequence.enter, in: stream)
#expect(idx == Array("shell output\r\n".utf8).count)
}
@Test("查找退出 ST")
func findExit() {
let stream = Array("%exit\n".utf8) + TmuxControlSequence.exit
#expect(TmuxControlSequence.find(TmuxControlSequence.exit, in: stream) == Array("%exit\n".utf8).count)
}
@Test("未出现返回 nil")
func notFound() {
#expect(TmuxControlSequence.find(TmuxControlSequence.enter, in: Array("normal text".utf8)) == nil)
}
@Test("enter 序列即 ESC P 1000 p")
func enterBytes() {
#expect(TmuxControlSequence.enter == [0x1B, 0x50, 0x31, 0x30, 0x30, 0x30, 0x70])
}
}

View File

@@ -0,0 +1,59 @@
import Testing
@testable import TXCore
@Suite("tmux layout 字符串解析")
struct TmuxLayoutTests {
@Test("单 pane")
func single() throws {
let l = try TmuxLayout.parse("bc62,80x24,0,0,0")
#expect(l.root == .leaf(pane: TmuxPaneID(0),
rect: .init(width: 80, height: 24, x: 0, y: 0)))
#expect(l.paneIDs == [TmuxPaneID(0)])
}
@Test("左右分屏 {} → horizontal")
func horizontalSplit() throws {
let l = try TmuxLayout.parse("a1b2,80x24,0,0{40x24,0,0,1,39x24,41,0,2}")
guard case .horizontal(let children, let rect) = l.root else {
Issue.record("期望 horizontal"); return
}
#expect(rect == .init(width: 80, height: 24, x: 0, y: 0))
#expect(children.count == 2)
#expect(children[0] == .leaf(pane: TmuxPaneID(1), rect: .init(width: 40, height: 24, x: 0, y: 0)))
#expect(children[1] == .leaf(pane: TmuxPaneID(2), rect: .init(width: 39, height: 24, x: 41, y: 0)))
#expect(l.paneIDs == [TmuxPaneID(1), TmuxPaneID(2)])
}
@Test("上下分屏 [] → vertical")
func verticalSplit() throws {
let l = try TmuxLayout.parse("ffff,80x24,0,0[80x12,0,0,1,80x11,0,13,2]")
guard case .vertical(let children, _) = l.root else {
Issue.record("期望 vertical"); return
}
#expect(children.count == 2)
#expect(l.paneIDs == [TmuxPaneID(1), TmuxPaneID(2)])
}
@Test("嵌套:左右分屏,右侧再上下分屏")
func nested() throws {
let l = try TmuxLayout.parse("0000,100x40,0,0{50x40,0,0,1,49x40,51,0[49x20,51,0,2,49x19,51,21,3]}")
guard case .horizontal(let top, _) = l.root else {
Issue.record("期望 horizontal"); return
}
#expect(top.count == 2)
#expect(top[0] == .leaf(pane: TmuxPaneID(1), rect: .init(width: 50, height: 40, x: 0, y: 0)))
guard case .vertical(let inner, _) = top[1] else {
Issue.record("期望嵌套 vertical"); return
}
#expect(inner.count == 2)
#expect(l.paneIDs == [TmuxPaneID(1), TmuxPaneID(2), TmuxPaneID(3)])
}
@Test("畸形字符串抛错而非崩溃")
func malformed() {
#expect(throws: TmuxLayout.ParseError.self) { try TmuxLayout.parse("garbage") }
#expect(throws: TmuxLayout.ParseError.self) { try TmuxLayout.parse("abcd,80x24,0,0{") }
#expect(throws: TmuxLayout.ParseError.self) { try TmuxLayout.parse("abcd,80x,0,0,0") }
}
}

View File

@@ -0,0 +1,51 @@
import Testing
import Foundation
@testable import TXCore
@Suite("tmux %output 八进制解码")
struct TmuxOutputDecoderTests {
// API decodedecoder internal
private func decode(_ escaped: String) -> Data {
let parser = TmuxControlParser()
let events = parser.feed("%output %0 \(escaped)\n")
guard case .output(_, let data) = events.first else {
Issue.record("期望 .output得到 \(events)")
return Data()
}
return data
}
@Test("普通字符原样通过")
func plain() {
#expect(decode("hello") == Data("hello".utf8))
}
@Test("空格不转义,原样保留")
func rawSpace() {
#expect(decode("hello\\040world") == Data("hello world".utf8))
// payload
#expect(decode("a b") == Data("a b".utf8))
}
@Test("控制字符八进制还原")
func controlBytes() {
#expect(decode("line\\015\\012") == Data([0x6C, 0x69, 0x6E, 0x65, 0x0D, 0x0A]))
}
@Test("反斜杠自身 \\134")
func backslash() {
#expect(decode("a\\134b") == Data([0x61, 0x5C, 0x62]))
}
@Test("不完整转义序列原样保留")
func malformedEscape() {
// +
#expect(decode("x\\12") == Data("x\\12".utf8))
}
@Test("非八进制数字8/9不视为转义")
func nonOctalDigits() {
#expect(decode("\\189") == Data("\\189".utf8))
}
}