初始提交: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:
25
packages/TXCore/Package.swift
Normal file
25
packages/TXCore/Package.swift
Normal 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)]
|
||||
),
|
||||
]
|
||||
)
|
||||
164
packages/TXCore/Sources/TXCore/Session/SessionMachine.swift
Normal file
164
packages/TXCore/Sources/TXCore/Session/SessionMachine.swift
Normal 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) 次)"),
|
||||
]
|
||||
}
|
||||
}
|
||||
203
packages/TXCore/Sources/TXCore/Tmux/TmuxControlParser.swift
Normal file
203
packages/TXCore/Sources/TXCore/Tmux/TmuxControlParser.swift
Normal 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]
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
60
packages/TXCore/Sources/TXCore/Tmux/TmuxEvent.swift
Normal file
60
packages/TXCore/Sources/TXCore/Tmux/TmuxEvent.swift
Normal 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") }
|
||||
}
|
||||
42
packages/TXCore/Sources/TXCore/Tmux/TmuxIDs.swift
Normal file
42
packages/TXCore/Sources/TXCore/Tmux/TmuxIDs.swift
Normal 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())
|
||||
}
|
||||
}
|
||||
127
packages/TXCore/Sources/TXCore/Tmux/TmuxLayout.swift
Normal file
127
packages/TXCore/Sources/TXCore/Tmux/TmuxLayout.swift
Normal 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
|
||||
}
|
||||
}
|
||||
46
packages/TXCore/Sources/TXCore/Tmux/TmuxOutputDecoder.swift
Normal file
46
packages/TXCore/Sources/TXCore/Tmux/TmuxOutputDecoder.swift
Normal 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
|
||||
}
|
||||
}
|
||||
128
packages/TXCore/Tests/TXCoreTests/SessionMachineTests.swift
Normal file
128
packages/TXCore/Tests/TXCoreTests/SessionMachineTests.swift
Normal 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)
|
||||
// 第2次断(attempt 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)
|
||||
// 第3次断(attempt 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)
|
||||
}
|
||||
}
|
||||
118
packages/TXCore/Tests/TXCoreTests/TmuxControlParserTests.swift
Normal file
118
packages/TXCore/Tests/TXCoreTests/TmuxControlParserTests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
59
packages/TXCore/Tests/TXCoreTests/TmuxLayoutTests.swift
Normal file
59
packages/TXCore/Tests/TXCoreTests/TmuxLayoutTests.swift
Normal 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") }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import TXCore
|
||||
|
||||
@Suite("tmux %output 八进制解码")
|
||||
struct TmuxOutputDecoderTests {
|
||||
|
||||
// 通过公开 API 间接验证 decode(decoder 为 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))
|
||||
}
|
||||
}
|
||||
31
packages/TXTransport/Package.swift
Normal file
31
packages/TXTransport/Package.swift
Normal file
@@ -0,0 +1,31 @@
|
||||
// swift-tools-version: 6.0
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "TXTransport",
|
||||
platforms: [
|
||||
.macOS(.v14),
|
||||
.iOS(.v17),
|
||||
],
|
||||
products: [
|
||||
.library(name: "TXTransport", targets: ["TXTransport"]),
|
||||
],
|
||||
dependencies: [
|
||||
.package(path: "../TXCore"),
|
||||
],
|
||||
targets: [
|
||||
// libssh2 + mbedTLS 静态库切片(vendor/build/build-cssh.sh,library-only)。
|
||||
.binaryTarget(name: "CSSHCore", path: "../../artifacts/CSSHCore.xcframework"),
|
||||
// 头文件 + modulemap 载体(供 `import CSSH`);库符号来自 CSSHCore。
|
||||
.target(name: "CSSH", publicHeadersPath: "include"),
|
||||
.target(
|
||||
name: "TXTransport",
|
||||
dependencies: [
|
||||
"CSSH",
|
||||
"CSSHCore",
|
||||
.product(name: "TXCore", package: "TXCore"),
|
||||
],
|
||||
swiftSettings: [.swiftLanguageMode(.v6)]
|
||||
),
|
||||
]
|
||||
)
|
||||
1516
packages/TXTransport/Sources/CSSH/include/libssh2.h
Normal file
1516
packages/TXTransport/Sources/CSSH/include/libssh2.h
Normal file
File diff suppressed because it is too large
Load Diff
128
packages/TXTransport/Sources/CSSH/include/libssh2_publickey.h
Normal file
128
packages/TXTransport/Sources/CSSH/include/libssh2_publickey.h
Normal file
@@ -0,0 +1,128 @@
|
||||
/* Copyright (C) Sara Golemon <sarag@libssh2.org>
|
||||
* All rights reserved.
|
||||
*
|
||||
* Redistribution and use in source and binary forms,
|
||||
* with or without modification, are permitted provided
|
||||
* that the following conditions are met:
|
||||
*
|
||||
* Redistributions of source code must retain the above
|
||||
* copyright notice, this list of conditions and the
|
||||
* following disclaimer.
|
||||
*
|
||||
* Redistributions in binary form must reproduce the above
|
||||
* copyright notice, this list of conditions and the following
|
||||
* disclaimer in the documentation and/or other materials
|
||||
* provided with the distribution.
|
||||
*
|
||||
* Neither the name of the copyright holder nor the names
|
||||
* of any other contributors may be used to endorse or
|
||||
* promote products derived from this software without
|
||||
* specific prior written permission.
|
||||
*
|
||||
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND
|
||||
* CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES,
|
||||
* INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
|
||||
* OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
||||
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
|
||||
* CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
|
||||
* SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
|
||||
* BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||
* SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
||||
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,
|
||||
* WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
|
||||
* NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE
|
||||
* USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY
|
||||
* OF SUCH DAMAGE.
|
||||
*/
|
||||
|
||||
/* Note: This include file is only needed for using the
|
||||
* publickey SUBSYSTEM which is not the same as publickey
|
||||
* authentication. For authentication you only need libssh2.h
|
||||
*
|
||||
* For more information on the publickey subsystem,
|
||||
* refer to IETF draft: secsh-publickey
|
||||
*
|
||||
* SPDX-License-Identifier: BSD-3-Clause
|
||||
*/
|
||||
|
||||
#ifndef LIBSSH2_PUBLICKEY_H
|
||||
#define LIBSSH2_PUBLICKEY_H 1
|
||||
|
||||
#include "libssh2.h"
|
||||
|
||||
typedef struct _LIBSSH2_PUBLICKEY LIBSSH2_PUBLICKEY;
|
||||
|
||||
typedef struct _libssh2_publickey_attribute {
|
||||
const char *name;
|
||||
unsigned long name_len;
|
||||
const char *value;
|
||||
unsigned long value_len;
|
||||
char mandatory;
|
||||
} libssh2_publickey_attribute;
|
||||
|
||||
typedef struct _libssh2_publickey_list {
|
||||
unsigned char *packet; /* For freeing */
|
||||
|
||||
const unsigned char *name;
|
||||
unsigned long name_len;
|
||||
const unsigned char *blob;
|
||||
unsigned long blob_len;
|
||||
unsigned long num_attrs;
|
||||
libssh2_publickey_attribute *attrs; /* free me */
|
||||
} libssh2_publickey_list;
|
||||
|
||||
/* Generally use the first macro here, but if both name and value are string
|
||||
literals, you can use _fast() to take advantage of preprocessing */
|
||||
#define libssh2_publickey_attribute(name, value, mandatory) \
|
||||
{ (name), strlen(name), (value), strlen(value), (mandatory) },
|
||||
#define libssh2_publickey_attribute_fast(name, value, mandatory) \
|
||||
{ (name), sizeof(name) - 1, (value), sizeof(value) - 1, (mandatory) },
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/* Publickey Subsystem */
|
||||
LIBSSH2_API LIBSSH2_PUBLICKEY *
|
||||
libssh2_publickey_init(LIBSSH2_SESSION *session);
|
||||
|
||||
LIBSSH2_API int
|
||||
libssh2_publickey_add_ex(LIBSSH2_PUBLICKEY *pkey,
|
||||
const unsigned char *name,
|
||||
unsigned long name_len,
|
||||
const unsigned char *blob,
|
||||
unsigned long blob_len, char overwrite,
|
||||
unsigned long num_attrs,
|
||||
const libssh2_publickey_attribute attrs[]);
|
||||
#define libssh2_publickey_add(pkey, name, blob, blob_len, overwrite, \
|
||||
num_attrs, attrs) \
|
||||
libssh2_publickey_add_ex((pkey), \
|
||||
(name), strlen(name), \
|
||||
(blob), (blob_len), \
|
||||
(overwrite), (num_attrs), (attrs))
|
||||
|
||||
LIBSSH2_API int libssh2_publickey_remove_ex(LIBSSH2_PUBLICKEY *pkey,
|
||||
const unsigned char *name,
|
||||
unsigned long name_len,
|
||||
const unsigned char *blob,
|
||||
unsigned long blob_len);
|
||||
#define libssh2_publickey_remove(pkey, name, blob, blob_len) \
|
||||
libssh2_publickey_remove_ex((pkey), \
|
||||
(name), strlen(name), \
|
||||
(blob), (blob_len))
|
||||
|
||||
LIBSSH2_API int
|
||||
libssh2_publickey_list_fetch(LIBSSH2_PUBLICKEY *pkey,
|
||||
unsigned long *num_keys,
|
||||
libssh2_publickey_list **pkey_list);
|
||||
LIBSSH2_API void
|
||||
libssh2_publickey_list_free(LIBSSH2_PUBLICKEY *pkey,
|
||||
libssh2_publickey_list *pkey_list);
|
||||
|
||||
LIBSSH2_API int libssh2_publickey_shutdown(LIBSSH2_PUBLICKEY *pkey);
|
||||
|
||||
#ifdef __cplusplus
|
||||
} /* extern "C" */
|
||||
#endif
|
||||
|
||||
#endif /* LIBSSH2_PUBLICKEY_H */
|
||||
382
packages/TXTransport/Sources/CSSH/include/libssh2_sftp.h
Normal file
382
packages/TXTransport/Sources/CSSH/include/libssh2_sftp.h
Normal file
@@ -0,0 +1,382 @@
|
||||
/* Copyright (C) Sara Golemon <sarag@libssh2.org>
|
||||
* All rights reserved.
|
||||
*
|
||||
* Redistribution and use in source and binary forms,
|
||||
* with or without modification, are permitted provided
|
||||
* that the following conditions are met:
|
||||
*
|
||||
* Redistributions of source code must retain the above
|
||||
* copyright notice, this list of conditions and the
|
||||
* following disclaimer.
|
||||
*
|
||||
* Redistributions in binary form must reproduce the above
|
||||
* copyright notice, this list of conditions and the following
|
||||
* disclaimer in the documentation and/or other materials
|
||||
* provided with the distribution.
|
||||
*
|
||||
* Neither the name of the copyright holder nor the names
|
||||
* of any other contributors may be used to endorse or
|
||||
* promote products derived from this software without
|
||||
* specific prior written permission.
|
||||
*
|
||||
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND
|
||||
* CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES,
|
||||
* INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
|
||||
* OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
||||
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
|
||||
* CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
|
||||
* SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
|
||||
* BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||
* SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
||||
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,
|
||||
* WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
|
||||
* NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE
|
||||
* USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY
|
||||
* OF SUCH DAMAGE.
|
||||
*
|
||||
* SPDX-License-Identifier: BSD-3-Clause
|
||||
*/
|
||||
|
||||
#ifndef LIBSSH2_SFTP_H
|
||||
#define LIBSSH2_SFTP_H 1
|
||||
|
||||
#include "libssh2.h"
|
||||
|
||||
#ifndef _WIN32
|
||||
#include <unistd.h>
|
||||
#endif
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/* Note: Version 6 was documented at the time of writing
|
||||
* However it was marked as "DO NOT IMPLEMENT" due to pending changes
|
||||
*
|
||||
* Let's start with Version 3 (The version found in OpenSSH) and go from there
|
||||
*/
|
||||
#define LIBSSH2_SFTP_VERSION 3
|
||||
|
||||
typedef struct _LIBSSH2_SFTP LIBSSH2_SFTP;
|
||||
typedef struct _LIBSSH2_SFTP_HANDLE LIBSSH2_SFTP_HANDLE;
|
||||
typedef struct _LIBSSH2_SFTP_ATTRIBUTES LIBSSH2_SFTP_ATTRIBUTES;
|
||||
typedef struct _LIBSSH2_SFTP_STATVFS LIBSSH2_SFTP_STATVFS;
|
||||
|
||||
/* Flags for open_ex() */
|
||||
#define LIBSSH2_SFTP_OPENFILE 0
|
||||
#define LIBSSH2_SFTP_OPENDIR 1
|
||||
|
||||
/* Flags for rename_ex() */
|
||||
#define LIBSSH2_SFTP_RENAME_OVERWRITE 0x00000001
|
||||
#define LIBSSH2_SFTP_RENAME_ATOMIC 0x00000002
|
||||
#define LIBSSH2_SFTP_RENAME_NATIVE 0x00000004
|
||||
|
||||
/* Flags for stat_ex() */
|
||||
#define LIBSSH2_SFTP_STAT 0
|
||||
#define LIBSSH2_SFTP_LSTAT 1
|
||||
#define LIBSSH2_SFTP_SETSTAT 2
|
||||
|
||||
/* Flags for symlink_ex() */
|
||||
#define LIBSSH2_SFTP_SYMLINK 0
|
||||
#define LIBSSH2_SFTP_READLINK 1
|
||||
#define LIBSSH2_SFTP_REALPATH 2
|
||||
|
||||
/* Flags for sftp_mkdir() */
|
||||
#define LIBSSH2_SFTP_DEFAULT_MODE -1
|
||||
|
||||
/* SFTP attribute flag bits */
|
||||
#define LIBSSH2_SFTP_ATTR_SIZE 0x00000001
|
||||
#define LIBSSH2_SFTP_ATTR_UIDGID 0x00000002
|
||||
#define LIBSSH2_SFTP_ATTR_PERMISSIONS 0x00000004
|
||||
#define LIBSSH2_SFTP_ATTR_ACMODTIME 0x00000008
|
||||
#define LIBSSH2_SFTP_ATTR_EXTENDED 0x80000000
|
||||
|
||||
/* SFTP statvfs flag bits */
|
||||
#define LIBSSH2_SFTP_ST_RDONLY 0x00000001
|
||||
#define LIBSSH2_SFTP_ST_NOSUID 0x00000002
|
||||
|
||||
struct _LIBSSH2_SFTP_ATTRIBUTES {
|
||||
/* If flags & ATTR_* bit is set, then the value in this struct will be
|
||||
* meaningful Otherwise it should be ignored
|
||||
*/
|
||||
unsigned long flags;
|
||||
|
||||
libssh2_uint64_t filesize;
|
||||
unsigned long uid, gid;
|
||||
unsigned long permissions;
|
||||
unsigned long atime, mtime;
|
||||
};
|
||||
|
||||
struct _LIBSSH2_SFTP_STATVFS {
|
||||
libssh2_uint64_t f_bsize; /* file system block size */
|
||||
libssh2_uint64_t f_frsize; /* fragment size */
|
||||
libssh2_uint64_t f_blocks; /* size of fs in f_frsize units */
|
||||
libssh2_uint64_t f_bfree; /* # free blocks */
|
||||
libssh2_uint64_t f_bavail; /* # free blocks for non-root */
|
||||
libssh2_uint64_t f_files; /* # inodes */
|
||||
libssh2_uint64_t f_ffree; /* # free inodes */
|
||||
libssh2_uint64_t f_favail; /* # free inodes for non-root */
|
||||
libssh2_uint64_t f_fsid; /* file system ID */
|
||||
libssh2_uint64_t f_flag; /* mount flags */
|
||||
libssh2_uint64_t f_namemax; /* maximum filename length */
|
||||
};
|
||||
|
||||
/* SFTP filetypes */
|
||||
#define LIBSSH2_SFTP_TYPE_REGULAR 1
|
||||
#define LIBSSH2_SFTP_TYPE_DIRECTORY 2
|
||||
#define LIBSSH2_SFTP_TYPE_SYMLINK 3
|
||||
#define LIBSSH2_SFTP_TYPE_SPECIAL 4
|
||||
#define LIBSSH2_SFTP_TYPE_UNKNOWN 5
|
||||
#define LIBSSH2_SFTP_TYPE_SOCKET 6
|
||||
#define LIBSSH2_SFTP_TYPE_CHAR_DEVICE 7
|
||||
#define LIBSSH2_SFTP_TYPE_BLOCK_DEVICE 8
|
||||
#define LIBSSH2_SFTP_TYPE_FIFO 9
|
||||
|
||||
/*
|
||||
* Reproduce the POSIX file modes here for systems that are not POSIX
|
||||
* compliant.
|
||||
*
|
||||
* These is used in "permissions" of "struct _LIBSSH2_SFTP_ATTRIBUTES"
|
||||
*/
|
||||
/* File type */
|
||||
#define LIBSSH2_SFTP_S_IFMT 0170000 /* type of file mask */
|
||||
#define LIBSSH2_SFTP_S_IFIFO 0010000 /* named pipe (fifo) */
|
||||
#define LIBSSH2_SFTP_S_IFCHR 0020000 /* character special */
|
||||
#define LIBSSH2_SFTP_S_IFDIR 0040000 /* directory */
|
||||
#define LIBSSH2_SFTP_S_IFBLK 0060000 /* block special */
|
||||
#define LIBSSH2_SFTP_S_IFREG 0100000 /* regular */
|
||||
#define LIBSSH2_SFTP_S_IFLNK 0120000 /* symbolic link */
|
||||
#define LIBSSH2_SFTP_S_IFSOCK 0140000 /* socket */
|
||||
|
||||
/* File mode */
|
||||
/* Read, write, execute/search by owner */
|
||||
#define LIBSSH2_SFTP_S_IRWXU 0000700 /* RWX mask for owner */
|
||||
#define LIBSSH2_SFTP_S_IRUSR 0000400 /* R for owner */
|
||||
#define LIBSSH2_SFTP_S_IWUSR 0000200 /* W for owner */
|
||||
#define LIBSSH2_SFTP_S_IXUSR 0000100 /* X for owner */
|
||||
/* Read, write, execute/search by group */
|
||||
#define LIBSSH2_SFTP_S_IRWXG 0000070 /* RWX mask for group */
|
||||
#define LIBSSH2_SFTP_S_IRGRP 0000040 /* R for group */
|
||||
#define LIBSSH2_SFTP_S_IWGRP 0000020 /* W for group */
|
||||
#define LIBSSH2_SFTP_S_IXGRP 0000010 /* X for group */
|
||||
/* Read, write, execute/search by others */
|
||||
#define LIBSSH2_SFTP_S_IRWXO 0000007 /* RWX mask for other */
|
||||
#define LIBSSH2_SFTP_S_IROTH 0000004 /* R for other */
|
||||
#define LIBSSH2_SFTP_S_IWOTH 0000002 /* W for other */
|
||||
#define LIBSSH2_SFTP_S_IXOTH 0000001 /* X for other */
|
||||
|
||||
/* macros to check for specific file types, added in 1.2.5 */
|
||||
#define LIBSSH2_SFTP_S_ISLNK(m) \
|
||||
(((m) & LIBSSH2_SFTP_S_IFMT) == LIBSSH2_SFTP_S_IFLNK)
|
||||
#define LIBSSH2_SFTP_S_ISREG(m) \
|
||||
(((m) & LIBSSH2_SFTP_S_IFMT) == LIBSSH2_SFTP_S_IFREG)
|
||||
#define LIBSSH2_SFTP_S_ISDIR(m) \
|
||||
(((m) & LIBSSH2_SFTP_S_IFMT) == LIBSSH2_SFTP_S_IFDIR)
|
||||
#define LIBSSH2_SFTP_S_ISCHR(m) \
|
||||
(((m) & LIBSSH2_SFTP_S_IFMT) == LIBSSH2_SFTP_S_IFCHR)
|
||||
#define LIBSSH2_SFTP_S_ISBLK(m) \
|
||||
(((m) & LIBSSH2_SFTP_S_IFMT) == LIBSSH2_SFTP_S_IFBLK)
|
||||
#define LIBSSH2_SFTP_S_ISFIFO(m) \
|
||||
(((m) & LIBSSH2_SFTP_S_IFMT) == LIBSSH2_SFTP_S_IFIFO)
|
||||
#define LIBSSH2_SFTP_S_ISSOCK(m) \
|
||||
(((m) & LIBSSH2_SFTP_S_IFMT) == LIBSSH2_SFTP_S_IFSOCK)
|
||||
|
||||
/* SFTP File Transfer Flags -- (e.g. flags parameter to sftp_open())
|
||||
* Danger will robinson... APPEND doesn't have any effect on OpenSSH servers */
|
||||
#define LIBSSH2_FXF_READ 0x00000001
|
||||
#define LIBSSH2_FXF_WRITE 0x00000002
|
||||
#define LIBSSH2_FXF_APPEND 0x00000004
|
||||
#define LIBSSH2_FXF_CREAT 0x00000008
|
||||
#define LIBSSH2_FXF_TRUNC 0x00000010
|
||||
#define LIBSSH2_FXF_EXCL 0x00000020
|
||||
|
||||
/* SFTP Status Codes (returned by libssh2_sftp_last_error() ) */
|
||||
#define LIBSSH2_FX_OK 0UL
|
||||
#define LIBSSH2_FX_EOF 1UL
|
||||
#define LIBSSH2_FX_NO_SUCH_FILE 2UL
|
||||
#define LIBSSH2_FX_PERMISSION_DENIED 3UL
|
||||
#define LIBSSH2_FX_FAILURE 4UL
|
||||
#define LIBSSH2_FX_BAD_MESSAGE 5UL
|
||||
#define LIBSSH2_FX_NO_CONNECTION 6UL
|
||||
#define LIBSSH2_FX_CONNECTION_LOST 7UL
|
||||
#define LIBSSH2_FX_OP_UNSUPPORTED 8UL
|
||||
#define LIBSSH2_FX_INVALID_HANDLE 9UL
|
||||
#define LIBSSH2_FX_NO_SUCH_PATH 10UL
|
||||
#define LIBSSH2_FX_FILE_ALREADY_EXISTS 11UL
|
||||
#define LIBSSH2_FX_WRITE_PROTECT 12UL
|
||||
#define LIBSSH2_FX_NO_MEDIA 13UL
|
||||
#define LIBSSH2_FX_NO_SPACE_ON_FILESYSTEM 14UL
|
||||
#define LIBSSH2_FX_QUOTA_EXCEEDED 15UL
|
||||
#define LIBSSH2_FX_UNKNOWN_PRINCIPLE 16UL /* Initial mis-spelling */
|
||||
#define LIBSSH2_FX_UNKNOWN_PRINCIPAL 16UL
|
||||
#define LIBSSH2_FX_LOCK_CONFlICT 17UL /* Initial mis-spelling */
|
||||
#define LIBSSH2_FX_LOCK_CONFLICT 17UL
|
||||
#define LIBSSH2_FX_DIR_NOT_EMPTY 18UL
|
||||
#define LIBSSH2_FX_NOT_A_DIRECTORY 19UL
|
||||
#define LIBSSH2_FX_INVALID_FILENAME 20UL
|
||||
#define LIBSSH2_FX_LINK_LOOP 21UL
|
||||
|
||||
/* Returned by any function that would block during a read/write operation */
|
||||
#define LIBSSH2SFTP_EAGAIN LIBSSH2_ERROR_EAGAIN
|
||||
|
||||
/* SFTP API */
|
||||
LIBSSH2_API LIBSSH2_SFTP *libssh2_sftp_init(LIBSSH2_SESSION *session);
|
||||
LIBSSH2_API int libssh2_sftp_shutdown(LIBSSH2_SFTP *sftp);
|
||||
LIBSSH2_API unsigned long libssh2_sftp_last_error(LIBSSH2_SFTP *sftp);
|
||||
LIBSSH2_API LIBSSH2_CHANNEL *libssh2_sftp_get_channel(LIBSSH2_SFTP *sftp);
|
||||
|
||||
/* File / Directory Ops */
|
||||
LIBSSH2_API LIBSSH2_SFTP_HANDLE *
|
||||
libssh2_sftp_open_ex(LIBSSH2_SFTP *sftp,
|
||||
const char *filename,
|
||||
unsigned int filename_len,
|
||||
unsigned long flags,
|
||||
long mode, int open_type);
|
||||
#define libssh2_sftp_open(sftp, filename, flags, mode) \
|
||||
libssh2_sftp_open_ex((sftp), \
|
||||
(filename), (unsigned int)strlen(filename), \
|
||||
(flags), (mode), LIBSSH2_SFTP_OPENFILE)
|
||||
#define libssh2_sftp_opendir(sftp, path) \
|
||||
libssh2_sftp_open_ex((sftp), \
|
||||
(path), (unsigned int)strlen(path), \
|
||||
0, 0, LIBSSH2_SFTP_OPENDIR)
|
||||
LIBSSH2_API LIBSSH2_SFTP_HANDLE *
|
||||
libssh2_sftp_open_ex_r(LIBSSH2_SFTP *sftp,
|
||||
const char *filename,
|
||||
size_t filename_len,
|
||||
unsigned long flags,
|
||||
long mode, int open_type,
|
||||
LIBSSH2_SFTP_ATTRIBUTES *attrs);
|
||||
#define libssh2_sftp_open_r(sftp, filename, flags, mode, attrs) \
|
||||
libssh2_sftp_open_ex_r((sftp), (filename), strlen(filename), \
|
||||
(flags), (mode), LIBSSH2_SFTP_OPENFILE, \
|
||||
(attrs))
|
||||
|
||||
LIBSSH2_API ssize_t libssh2_sftp_read(LIBSSH2_SFTP_HANDLE *handle,
|
||||
char *buffer, size_t buffer_maxlen);
|
||||
|
||||
LIBSSH2_API int libssh2_sftp_readdir_ex(LIBSSH2_SFTP_HANDLE *handle, \
|
||||
char *buffer, size_t buffer_maxlen,
|
||||
char *longentry,
|
||||
size_t longentry_maxlen,
|
||||
LIBSSH2_SFTP_ATTRIBUTES *attrs);
|
||||
#define libssh2_sftp_readdir(handle, buffer, buffer_maxlen, attrs) \
|
||||
libssh2_sftp_readdir_ex((handle), (buffer), (buffer_maxlen), NULL, 0, \
|
||||
(attrs))
|
||||
|
||||
LIBSSH2_API ssize_t libssh2_sftp_write(LIBSSH2_SFTP_HANDLE *handle,
|
||||
const char *buffer, size_t count);
|
||||
LIBSSH2_API int libssh2_sftp_fsync(LIBSSH2_SFTP_HANDLE *handle);
|
||||
|
||||
LIBSSH2_API int libssh2_sftp_close_handle(LIBSSH2_SFTP_HANDLE *handle);
|
||||
#define libssh2_sftp_close(handle) libssh2_sftp_close_handle(handle)
|
||||
#define libssh2_sftp_closedir(handle) libssh2_sftp_close_handle(handle)
|
||||
|
||||
LIBSSH2_API void libssh2_sftp_seek(LIBSSH2_SFTP_HANDLE *handle, size_t offset);
|
||||
LIBSSH2_API void libssh2_sftp_seek64(LIBSSH2_SFTP_HANDLE *handle,
|
||||
libssh2_uint64_t offset);
|
||||
#define libssh2_sftp_rewind(handle) libssh2_sftp_seek64((handle), 0)
|
||||
|
||||
LIBSSH2_API size_t libssh2_sftp_tell(LIBSSH2_SFTP_HANDLE *handle);
|
||||
LIBSSH2_API libssh2_uint64_t libssh2_sftp_tell64(LIBSSH2_SFTP_HANDLE *handle);
|
||||
|
||||
LIBSSH2_API int libssh2_sftp_fstat_ex(LIBSSH2_SFTP_HANDLE *handle,
|
||||
LIBSSH2_SFTP_ATTRIBUTES *attrs,
|
||||
int setstat);
|
||||
#define libssh2_sftp_fstat(handle, attrs) \
|
||||
libssh2_sftp_fstat_ex((handle), (attrs), 0)
|
||||
#define libssh2_sftp_fsetstat(handle, attrs) \
|
||||
libssh2_sftp_fstat_ex((handle), (attrs), 1)
|
||||
|
||||
/* Miscellaneous Ops */
|
||||
LIBSSH2_API int libssh2_sftp_rename_ex(LIBSSH2_SFTP *sftp,
|
||||
const char *source_filename,
|
||||
unsigned int srouce_filename_len,
|
||||
const char *dest_filename,
|
||||
unsigned int dest_filename_len,
|
||||
long flags);
|
||||
#define libssh2_sftp_rename(sftp, sourcefile, destfile) \
|
||||
libssh2_sftp_rename_ex((sftp), \
|
||||
(sourcefile), (unsigned int)strlen(sourcefile), \
|
||||
(destfile), (unsigned int)strlen(destfile), \
|
||||
LIBSSH2_SFTP_RENAME_OVERWRITE | \
|
||||
LIBSSH2_SFTP_RENAME_ATOMIC | \
|
||||
LIBSSH2_SFTP_RENAME_NATIVE)
|
||||
|
||||
LIBSSH2_API int libssh2_sftp_posix_rename_ex(LIBSSH2_SFTP *sftp,
|
||||
const char *source_filename,
|
||||
size_t srouce_filename_len,
|
||||
const char *dest_filename,
|
||||
size_t dest_filename_len);
|
||||
#define libssh2_sftp_posix_rename(sftp, sourcefile, destfile) \
|
||||
libssh2_sftp_posix_rename_ex((sftp), (sourcefile), strlen(sourcefile), \
|
||||
(destfile), strlen(destfile))
|
||||
|
||||
LIBSSH2_API int libssh2_sftp_unlink_ex(LIBSSH2_SFTP *sftp,
|
||||
const char *filename,
|
||||
unsigned int filename_len);
|
||||
#define libssh2_sftp_unlink(sftp, filename) \
|
||||
libssh2_sftp_unlink_ex((sftp), (filename), (unsigned int)strlen(filename))
|
||||
|
||||
LIBSSH2_API int libssh2_sftp_fstatvfs(LIBSSH2_SFTP_HANDLE *handle,
|
||||
LIBSSH2_SFTP_STATVFS *st);
|
||||
|
||||
LIBSSH2_API int libssh2_sftp_statvfs(LIBSSH2_SFTP *sftp,
|
||||
const char *path,
|
||||
size_t path_len,
|
||||
LIBSSH2_SFTP_STATVFS *st);
|
||||
|
||||
LIBSSH2_API int libssh2_sftp_mkdir_ex(LIBSSH2_SFTP *sftp,
|
||||
const char *path,
|
||||
unsigned int path_len, long mode);
|
||||
#define libssh2_sftp_mkdir(sftp, path, mode) \
|
||||
libssh2_sftp_mkdir_ex((sftp), (path), (unsigned int)strlen(path), (mode))
|
||||
|
||||
LIBSSH2_API int libssh2_sftp_rmdir_ex(LIBSSH2_SFTP *sftp,
|
||||
const char *path,
|
||||
unsigned int path_len);
|
||||
#define libssh2_sftp_rmdir(sftp, path) \
|
||||
libssh2_sftp_rmdir_ex((sftp), (path), (unsigned int)strlen(path))
|
||||
|
||||
LIBSSH2_API int libssh2_sftp_stat_ex(LIBSSH2_SFTP *sftp,
|
||||
const char *path,
|
||||
unsigned int path_len,
|
||||
int stat_type,
|
||||
LIBSSH2_SFTP_ATTRIBUTES *attrs);
|
||||
#define libssh2_sftp_stat(sftp, path, attrs) \
|
||||
libssh2_sftp_stat_ex((sftp), (path), (unsigned int)strlen(path), \
|
||||
LIBSSH2_SFTP_STAT, (attrs))
|
||||
#define libssh2_sftp_lstat(sftp, path, attrs) \
|
||||
libssh2_sftp_stat_ex((sftp), (path), (unsigned int)strlen(path), \
|
||||
LIBSSH2_SFTP_LSTAT, (attrs))
|
||||
#define libssh2_sftp_setstat(sftp, path, attrs) \
|
||||
libssh2_sftp_stat_ex((sftp), (path), (unsigned int)strlen(path), \
|
||||
LIBSSH2_SFTP_SETSTAT, (attrs))
|
||||
|
||||
LIBSSH2_API int libssh2_sftp_symlink_ex(LIBSSH2_SFTP *sftp,
|
||||
const char *path,
|
||||
unsigned int path_len,
|
||||
char *target,
|
||||
unsigned int target_len,
|
||||
int link_type);
|
||||
#define libssh2_sftp_symlink(sftp, orig, linkpath) \
|
||||
libssh2_sftp_symlink_ex((sftp), \
|
||||
(orig), (unsigned int)strlen(orig), \
|
||||
(linkpath), (unsigned int)strlen(linkpath), \
|
||||
LIBSSH2_SFTP_SYMLINK)
|
||||
#define libssh2_sftp_readlink(sftp, path, target, maxlen) \
|
||||
libssh2_sftp_symlink_ex((sftp), \
|
||||
(path), (unsigned int)strlen(path), \
|
||||
(target), (maxlen), \
|
||||
LIBSSH2_SFTP_READLINK)
|
||||
#define libssh2_sftp_realpath(sftp, path, target, maxlen) \
|
||||
libssh2_sftp_symlink_ex((sftp), \
|
||||
(path), (unsigned int)strlen(path), \
|
||||
(target), (maxlen), \
|
||||
LIBSSH2_SFTP_REALPATH)
|
||||
|
||||
#ifdef __cplusplus
|
||||
} /* extern "C" */
|
||||
#endif
|
||||
|
||||
#endif /* LIBSSH2_SFTP_H */
|
||||
@@ -0,0 +1,6 @@
|
||||
module CSSH {
|
||||
header "libssh2.h"
|
||||
header "libssh2_sftp.h"
|
||||
header "libssh2_publickey.h"
|
||||
export *
|
||||
}
|
||||
3
packages/TXTransport/Sources/CSSH/shim.c
Normal file
3
packages/TXTransport/Sources/CSSH/shim.c
Normal file
@@ -0,0 +1,3 @@
|
||||
// CSSH 是 libssh2 头文件 + modulemap 的载体 target(供 `import CSSH`);
|
||||
// 实际库符号来自 CSSHCore.xcframework(静态库)。此处仅需一个非空翻译单元。
|
||||
typedef int cssh_shim_placeholder;
|
||||
282
packages/TXTransport/Sources/TXTransport/SSHSession.swift
Normal file
282
packages/TXTransport/Sources/TXTransport/SSHSession.swift
Normal file
@@ -0,0 +1,282 @@
|
||||
import Foundation
|
||||
import Darwin
|
||||
import CSSH
|
||||
|
||||
/// 基于 libssh2 的 SSH 传输。M0:系统 socket 直连(不过 tsnet),密码 / 私钥认证,
|
||||
/// 打开 PTY + shell,非阻塞事件循环把 PTY 输出投递给 `onBytes`。
|
||||
///
|
||||
/// 线程模型:所有 libssh2 调用都在单条串行队列上执行(libssh2 会话非线程安全)。
|
||||
/// `send`/`resize` 只把数据放进加锁缓冲,由事件循环 drain。
|
||||
///
|
||||
/// ⚠️ M0 暂不校验 known_hosts host key(接受任意)。真正的指纹固定放到 TXSecurity(M4)。
|
||||
public final class SSHSession: Transport, @unchecked Sendable {
|
||||
public var onBytes: (@Sendable (Data) -> Void)?
|
||||
public var onState: (@Sendable (TransportState) -> Void)?
|
||||
|
||||
private let config: SSHConfig
|
||||
private let queue = DispatchQueue(label: "ai.athom.terminalx.ssh")
|
||||
|
||||
private var session: OpaquePointer?
|
||||
private var channel: OpaquePointer?
|
||||
private var sock: Int32 = -1
|
||||
private var stopped = false
|
||||
/// >=0 时使用外部已连接 fd(tsnet fd 桥),跳过自身 DNS/connect。
|
||||
private let preconnectedFD: Int32
|
||||
|
||||
private let lock = NSLock()
|
||||
private var pendingWrite = Data()
|
||||
private var pendingResize: (cols: UInt16, rows: UInt16)?
|
||||
|
||||
private static let eagain = Int(LIBSSH2_ERROR_EAGAIN)
|
||||
private static let initOnce: Int32 = libssh2_init(0)
|
||||
|
||||
public init(config: SSHConfig, preconnectedFD: Int32 = -1) {
|
||||
self.config = config
|
||||
self.preconnectedFD = preconnectedFD
|
||||
_ = SSHSession.initOnce
|
||||
}
|
||||
|
||||
// MARK: - Transport
|
||||
|
||||
public func start() {
|
||||
queue.async { [self] in
|
||||
do {
|
||||
setState(.connecting)
|
||||
try connect()
|
||||
setState(.authenticating)
|
||||
try authenticate()
|
||||
try openShell()
|
||||
setState(.connected)
|
||||
libssh2_session_set_blocking(session, 0)
|
||||
runLoop()
|
||||
} catch let error as SSHError {
|
||||
setState(.failed(String(describing: error)))
|
||||
cleanup()
|
||||
} catch {
|
||||
setState(.failed("\(error)"))
|
||||
cleanup()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func send(_ data: Data) {
|
||||
lock.lock(); pendingWrite.append(data); lock.unlock()
|
||||
}
|
||||
|
||||
public func resize(cols: UInt16, rows: UInt16) {
|
||||
lock.lock(); pendingResize = (cols, rows); lock.unlock()
|
||||
}
|
||||
|
||||
public func stop() {
|
||||
queue.async { [self] in stopped = true }
|
||||
}
|
||||
|
||||
// MARK: - Connect / Handshake
|
||||
|
||||
private func connect() throws {
|
||||
let fd: Int32
|
||||
if preconnectedFD >= 0 {
|
||||
fd = preconnectedFD // tsnet fd 桥:使用已连接 socket,跳过 DNS/connect
|
||||
} else {
|
||||
var hints = addrinfo()
|
||||
hints.ai_family = AF_UNSPEC
|
||||
hints.ai_socktype = SOCK_STREAM
|
||||
hints.ai_protocol = IPPROTO_TCP
|
||||
|
||||
var res: UnsafeMutablePointer<addrinfo>?
|
||||
guard getaddrinfo(config.host, String(config.port), &hints, &res) == 0,
|
||||
let list = res else {
|
||||
throw SSHError.socket("DNS 解析失败: \(config.host)")
|
||||
}
|
||||
defer { freeaddrinfo(list) }
|
||||
|
||||
var dialed: Int32 = -1
|
||||
var node: UnsafeMutablePointer<addrinfo>? = list
|
||||
while let addr = node {
|
||||
dialed = socket(addr.pointee.ai_family, addr.pointee.ai_socktype, addr.pointee.ai_protocol)
|
||||
if dialed >= 0 {
|
||||
if Darwin.connect(dialed, addr.pointee.ai_addr, addr.pointee.ai_addrlen) == 0 { break }
|
||||
close(dialed); dialed = -1
|
||||
}
|
||||
node = addr.pointee.ai_next
|
||||
}
|
||||
guard dialed >= 0 else { throw SSHError.socket("连接失败: \(config.host):\(config.port)") }
|
||||
fd = dialed
|
||||
}
|
||||
|
||||
var on: Int32 = 1
|
||||
setsockopt(fd, SOL_SOCKET, SO_NOSIGPIPE, &on, socklen_t(MemoryLayout<Int32>.size))
|
||||
sock = fd
|
||||
|
||||
guard let s = libssh2_session_init_ex(nil, nil, nil, nil) else {
|
||||
throw SSHError.handshake(-1)
|
||||
}
|
||||
session = s
|
||||
libssh2_session_set_blocking(s, 1)
|
||||
let rc = libssh2_session_handshake(s, sock)
|
||||
guard rc == 0 else { throw SSHError.handshake(Int(rc)) }
|
||||
// TODO(M4/TXSecurity): 校验 host key 指纹(known_hosts 三元组)。M0 暂接受任意。
|
||||
}
|
||||
|
||||
// MARK: - Auth
|
||||
|
||||
private func authenticate() throws {
|
||||
guard let session else { throw SSHError.authentication(-1) }
|
||||
let user = config.username
|
||||
let rc: Int32
|
||||
|
||||
switch config.authentication {
|
||||
case .password(let password):
|
||||
rc = user.withCString { u in
|
||||
password.withCString { p in
|
||||
libssh2_userauth_password_ex(
|
||||
session, u, UInt32(strlen(u)), p, UInt32(strlen(p)), nil
|
||||
)
|
||||
}
|
||||
}
|
||||
case .privateKey(let pem, let passphrase):
|
||||
rc = user.withCString { u in
|
||||
pem.withCString { priv in
|
||||
let auth: (UnsafePointer<CChar>?) -> Int32 = { pass in
|
||||
libssh2_userauth_publickey_frommemory(
|
||||
session,
|
||||
u, strlen(u),
|
||||
nil, 0,
|
||||
priv, strlen(priv),
|
||||
pass
|
||||
)
|
||||
}
|
||||
if let passphrase {
|
||||
return passphrase.withCString { auth($0) }
|
||||
} else {
|
||||
return auth(nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
guard rc == 0 else { throw SSHError.authentication(Int(rc)) }
|
||||
}
|
||||
|
||||
// MARK: - Shell
|
||||
|
||||
private func openShell() throws {
|
||||
guard let session else { throw SSHError.channel("无会话") }
|
||||
guard let ch = libssh2_channel_open_ex(
|
||||
session, "session", 7, 2 * 1024 * 1024, 32768, nil, 0
|
||||
) else {
|
||||
throw SSHError.channel("打开 channel 失败")
|
||||
}
|
||||
channel = ch
|
||||
|
||||
let ptyRC = config.terminalType.withCString {
|
||||
libssh2_channel_request_pty_ex(
|
||||
ch, $0, UInt32(strlen($0)), nil, 0,
|
||||
Int32(config.initialCols), Int32(config.initialRows), 0, 0
|
||||
)
|
||||
}
|
||||
guard ptyRC == 0 else { throw SSHError.channel("请求 PTY 失败 (\(ptyRC))") }
|
||||
|
||||
let shellRC = "shell".withCString {
|
||||
libssh2_channel_process_startup(ch, $0, 5, nil, 0)
|
||||
}
|
||||
guard shellRC == 0 else { throw SSHError.channel("启动 shell 失败 (\(shellRC))") }
|
||||
}
|
||||
|
||||
// MARK: - Event Loop
|
||||
|
||||
private func runLoop() {
|
||||
guard let channel else { return }
|
||||
var buffer = [Int8](repeating: 0, count: 32768)
|
||||
|
||||
while !stopped {
|
||||
var pfd = pollfd(fd: sock, events: Int16(POLLIN), revents: 0)
|
||||
_ = poll(&pfd, 1, 100)
|
||||
|
||||
var closed = false
|
||||
readLoop: while true {
|
||||
let n = buffer.withUnsafeMutableBufferPointer { ptr in
|
||||
libssh2_channel_read_ex(channel, 0, ptr.baseAddress, ptr.count)
|
||||
}
|
||||
if n > 0 {
|
||||
let data = buffer.withUnsafeBytes { raw in
|
||||
Data(bytes: raw.baseAddress!, count: Int(n))
|
||||
}
|
||||
onBytes?(data)
|
||||
} else if n == Self.eagain {
|
||||
break readLoop
|
||||
} else if n == 0 {
|
||||
break readLoop
|
||||
} else {
|
||||
setState(.disconnected(reason: "读取错误 (\(n))"))
|
||||
closed = true
|
||||
break readLoop
|
||||
}
|
||||
}
|
||||
if closed { break }
|
||||
|
||||
if libssh2_channel_eof(channel) == 1 {
|
||||
setState(.disconnected(reason: nil))
|
||||
break
|
||||
}
|
||||
|
||||
flushWrites()
|
||||
applyResizeIfNeeded()
|
||||
}
|
||||
cleanup()
|
||||
}
|
||||
|
||||
private func flushWrites() {
|
||||
guard let channel else { return }
|
||||
lock.lock(); let data = pendingWrite; pendingWrite = Data(); lock.unlock()
|
||||
guard !data.isEmpty else { return }
|
||||
|
||||
data.withUnsafeBytes { (raw: UnsafeRawBufferPointer) in
|
||||
let base = raw.bindMemory(to: CChar.self).baseAddress!
|
||||
var offset = 0
|
||||
while offset < data.count {
|
||||
let n = libssh2_channel_write_ex(channel, 0, base + offset, data.count - offset)
|
||||
if n == Self.eagain {
|
||||
let remainder = data.subdata(in: (data.startIndex + offset)..<data.endIndex)
|
||||
lock.lock(); pendingWrite = remainder + pendingWrite; lock.unlock()
|
||||
return
|
||||
} else if n < 0 {
|
||||
return // 写错误:丢弃本批,下轮循环会发现连接问题
|
||||
} else {
|
||||
offset += Int(n)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func applyResizeIfNeeded() {
|
||||
lock.lock(); let r = pendingResize; pendingResize = nil; lock.unlock()
|
||||
guard let r, let channel else { return }
|
||||
_ = libssh2_channel_request_pty_size_ex(channel, Int32(r.cols), Int32(r.rows), 0, 0)
|
||||
}
|
||||
|
||||
// MARK: - Cleanup
|
||||
|
||||
private func cleanup() {
|
||||
if let channel {
|
||||
libssh2_channel_free(channel)
|
||||
self.channel = nil
|
||||
}
|
||||
if let session {
|
||||
"bye".withCString { reason in
|
||||
"".withCString { lang in
|
||||
_ = libssh2_session_disconnect_ex(session, 11 /* BY_APPLICATION */, reason, lang)
|
||||
}
|
||||
}
|
||||
libssh2_session_free(session)
|
||||
self.session = nil
|
||||
}
|
||||
if sock >= 0 {
|
||||
close(sock)
|
||||
sock = -1
|
||||
}
|
||||
}
|
||||
|
||||
private func setState(_ state: TransportState) {
|
||||
onState?(state)
|
||||
}
|
||||
}
|
||||
64
packages/TXTransport/Sources/TXTransport/Transport.swift
Normal file
64
packages/TXTransport/Sources/TXTransport/Transport.swift
Normal file
@@ -0,0 +1,64 @@
|
||||
import Foundation
|
||||
|
||||
/// 终端字节传输的抽象。SSH / mosh / 本地 PTY 各实现之;上层 PaneEngine 只依赖此协议,
|
||||
/// 不关心底层是系统直连还是经 tsnet(M1 起 DialerRouter 在此协议之下注入出口)。
|
||||
public protocol Transport: AnyObject {
|
||||
/// 远端 → 终端 的字节(PTY 输出)。在传输内部队列上回调,消费方自行 hop 线程。
|
||||
var onBytes: (@Sendable (Data) -> Void)? { get set }
|
||||
/// 连接状态变化。
|
||||
var onState: (@Sendable (TransportState) -> Void)? { get set }
|
||||
func start()
|
||||
/// 终端 → 远端 的字节(用户输入)。
|
||||
func send(_ data: Data)
|
||||
func resize(cols: UInt16, rows: UInt16)
|
||||
func stop()
|
||||
}
|
||||
|
||||
public enum TransportState: Sendable, Equatable {
|
||||
case idle
|
||||
case connecting
|
||||
case authenticating
|
||||
case connected
|
||||
case disconnected(reason: String?)
|
||||
case failed(String)
|
||||
}
|
||||
|
||||
public struct SSHConfig: Sendable {
|
||||
public var host: String
|
||||
public var port: Int
|
||||
public var username: String
|
||||
public var authentication: Authentication
|
||||
public var terminalType: String
|
||||
public var initialCols: UInt16
|
||||
public var initialRows: UInt16
|
||||
|
||||
public enum Authentication: Sendable {
|
||||
case password(String)
|
||||
case privateKey(pem: String, passphrase: String?)
|
||||
}
|
||||
|
||||
public init(
|
||||
host: String,
|
||||
port: Int = 22,
|
||||
username: String,
|
||||
authentication: Authentication,
|
||||
terminalType: String = "xterm-256color",
|
||||
initialCols: UInt16 = 80,
|
||||
initialRows: UInt16 = 24
|
||||
) {
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.username = username
|
||||
self.authentication = authentication
|
||||
self.terminalType = terminalType
|
||||
self.initialCols = initialCols
|
||||
self.initialRows = initialRows
|
||||
}
|
||||
}
|
||||
|
||||
public enum SSHError: Error, Sendable, Equatable {
|
||||
case socket(String)
|
||||
case handshake(Int)
|
||||
case authentication(Int)
|
||||
case channel(String)
|
||||
}
|
||||
Reference in New Issue
Block a user