初始提交: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:
180
apps/TerminalX/iOS/ContentView.swift
Normal file
180
apps/TerminalX/iOS/ContentView.swift
Normal file
@@ -0,0 +1,180 @@
|
||||
import SwiftUI
|
||||
import GhosttyTerminal
|
||||
|
||||
struct ContentView: View {
|
||||
@StateObject private var model = SSHTerminalModel()
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
|
||||
var body: some View {
|
||||
// M1 纯自检分支:仅 -txTsnetKey(无 -txHost)时跑 tsnet 加入+对端枚举自检。
|
||||
// 若同时有 -txHost,则走正常终端流程(model 经 tsnet fd 桥连接)。
|
||||
if let tsnetKey = UserDefaults.standard.string(forKey: "txTsnetKey"), !tsnetKey.isEmpty,
|
||||
(UserDefaults.standard.string(forKey: "txHost") ?? "").isEmpty {
|
||||
TsnetProbeView(authKey: tsnetKey)
|
||||
} else {
|
||||
Group {
|
||||
if model.showsTerminal {
|
||||
TerminalScreen(model: model)
|
||||
} else {
|
||||
ConnectionForm(model: model)
|
||||
}
|
||||
}
|
||||
.onAppear { model.autoConnectIfConfigured() }
|
||||
.onChange(of: scenePhase) { _, phase in
|
||||
switch phase {
|
||||
case .background: model.enterBackground()
|
||||
case .active: model.enterForeground()
|
||||
default: break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// M0 连接表单:SSH 直连(不过 tsnet)。真实运维即输入自己的服务器。
|
||||
struct ConnectionForm: View {
|
||||
@ObservedObject var model: SSHTerminalModel
|
||||
@State private var host = ""
|
||||
@State private var port = "22"
|
||||
@State private var username = ""
|
||||
@State private var password = ""
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section("SSH 直连 (M0)") {
|
||||
TextField("主机 / IP", text: $host)
|
||||
.autocorrectionDisabled()
|
||||
.textInputAutocapitalization(.never)
|
||||
TextField("端口", text: $port)
|
||||
.keyboardType(.numberPad)
|
||||
TextField("用户名", text: $username)
|
||||
.autocorrectionDisabled()
|
||||
.textInputAutocapitalization(.never)
|
||||
SecureField("密码", text: $password)
|
||||
}
|
||||
Section {
|
||||
Button {
|
||||
model.connect(host: host, port: Int(port) ?? 22,
|
||||
username: username, password: password)
|
||||
} label: {
|
||||
Text("连接").frame(maxWidth: .infinity)
|
||||
}
|
||||
.disabled(host.isEmpty || username.isEmpty)
|
||||
}
|
||||
Section("状态") {
|
||||
Text(model.phaseText)
|
||||
.foregroundStyle(model.phaseText.hasPrefix("失败") ? .red : .secondary)
|
||||
if !model.banner.isEmpty {
|
||||
Text(model.banner).font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("terminalX")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct TerminalScreen: View {
|
||||
@ObservedObject var model: SSHTerminalModel
|
||||
|
||||
var body: some View {
|
||||
if let tmux = model.tmuxController {
|
||||
TmuxTabbedView(controller: tmux) // tmux -CC → 原生 tab
|
||||
} else {
|
||||
RawTerminalView(model: model) // 普通单终端
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 普通(非 tmux)单终端视图。
|
||||
struct RawTerminalView: View {
|
||||
@ObservedObject var model: SSHTerminalModel
|
||||
@FocusState private var focused: Bool
|
||||
|
||||
var body: some View {
|
||||
TerminalSurfaceView(context: model.state)
|
||||
.terminalFocusOnAppear($focused)
|
||||
.overlay(alignment: .top) {
|
||||
if model.phaseText != "已连接", !model.banner.isEmpty {
|
||||
Text(model.banner)
|
||||
.font(.caption)
|
||||
.padding(.horizontal, 10).padding(.vertical, 6)
|
||||
.background(.thinMaterial, in: Capsule())
|
||||
.padding(.top, 6)
|
||||
}
|
||||
}
|
||||
.task {
|
||||
for _ in 0 ..< 300 where model.state.surfaceSize == nil {
|
||||
try? await Task.sleep(nanoseconds: 30_000_000)
|
||||
}
|
||||
model.markSurfaceReady()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// tmux control-mode:window 映射为原生 tab 条 + 活动 window 终端。
|
||||
struct TmuxTabbedView: View {
|
||||
@ObservedObject var controller: TmuxController
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 6) {
|
||||
ForEach(controller.windows) { win in
|
||||
TmuxTabChip(window: win,
|
||||
isActive: win.id == controller.activeWindowID) {
|
||||
controller.selectWindow(win.id)
|
||||
}
|
||||
}
|
||||
Button { controller.newWindow() } label: {
|
||||
Image(systemName: "plus").padding(.horizontal, 6)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 8).padding(.vertical, 6)
|
||||
}
|
||||
.background(.thinMaterial)
|
||||
Divider()
|
||||
if let win = controller.activeWindow {
|
||||
TmuxWindowView(window: win, controller: controller)
|
||||
.id(win.id.raw)
|
||||
} else {
|
||||
Spacer(); Text("无 tmux 窗口").foregroundStyle(.secondary); Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct TmuxTabChip: View {
|
||||
@ObservedObject var window: TmuxWindow
|
||||
let isActive: Bool
|
||||
let onTap: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: onTap) {
|
||||
Text(window.title)
|
||||
.font(.caption).lineLimit(1)
|
||||
.padding(.horizontal, 12).padding(.vertical, 6)
|
||||
.background(isActive ? Color.accentColor.opacity(0.25) : Color.gray.opacity(0.12),
|
||||
in: RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
struct TmuxWindowView: View {
|
||||
@ObservedObject var window: TmuxWindow
|
||||
let controller: TmuxController
|
||||
@FocusState private var focused: Bool
|
||||
|
||||
var body: some View {
|
||||
TerminalSurfaceView(context: window.state)
|
||||
.terminalFocusOnAppear($focused)
|
||||
.task {
|
||||
for _ in 0 ..< 300 where window.state.surfaceSize == nil {
|
||||
try? await Task.sleep(nanoseconds: 30_000_000)
|
||||
}
|
||||
controller.markWindowReady(window.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
280
apps/TerminalX/iOS/SSHTerminalModel.swift
Normal file
280
apps/TerminalX/iOS/SSHTerminalModel.swift
Normal file
@@ -0,0 +1,280 @@
|
||||
import Foundation
|
||||
import GhosttyTerminal
|
||||
import TXCore
|
||||
import TXTransport
|
||||
import TsnetBridge
|
||||
|
||||
enum TsnetError: Error { case newNodeFailed, notUp }
|
||||
|
||||
/// 主 tsnet 节点管理器(线程安全,供 SSHTerminalModel 在后台线程 up/dial)。
|
||||
/// up 后节点常驻,重连时复用(只重新 dial fd,不重新加入 tailnet)。
|
||||
final class TsnetManager: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var node: TsnetbridgeNode?
|
||||
private let stateDir: String
|
||||
|
||||
init() {
|
||||
let base = NSSearchPathForDirectoriesInDomains(.applicationSupportDirectory, .userDomainMask, true)[0]
|
||||
stateDir = base + "/tsnet-main"
|
||||
try? FileManager.default.createDirectory(atPath: stateDir, withIntermediateDirectories: true)
|
||||
}
|
||||
|
||||
/// 确保节点已 up(幂等)。阻塞,需后台线程调用。
|
||||
func ensureUp(authKey: String, timeoutMs: Int) throws {
|
||||
lock.lock(); let existing = node; lock.unlock()
|
||||
if existing != nil { return }
|
||||
guard let n = TsnetbridgeNewNode(stateDir, "terminalx-ipad") else { throw TsnetError.newNodeFailed }
|
||||
try n.up(withAuthKey: authKey, timeoutMs: timeoutMs)
|
||||
lock.lock(); node = n; lock.unlock()
|
||||
}
|
||||
|
||||
/// 经 tsnet dial tailnet 内 host:port,返回已连接 fd。阻塞,需后台线程调用。
|
||||
/// 带重试:tsnet 刚 Up 后到 peer 的路径(DERP/直连打洞)预热期间首次 dial 可能 i/o timeout,
|
||||
/// 短退避重试若干次直到路径就绪。
|
||||
func dialFD(host: String, port: Int, timeoutMs: Int) throws -> Int32 {
|
||||
lock.lock(); let n = node; lock.unlock()
|
||||
guard let n else { throw TsnetError.notUp }
|
||||
var lastError: Error?
|
||||
for attempt in 0 ..< 6 {
|
||||
do {
|
||||
var fd: Int64 = -1
|
||||
try n.dialTCPFD(host, port: port, timeoutMs: 8000, ret0_: &fd)
|
||||
return Int32(fd)
|
||||
} catch {
|
||||
lastError = error
|
||||
if attempt < 5 { Thread.sleep(forTimeInterval: 2.0) } // 后台线程,路径预热
|
||||
}
|
||||
}
|
||||
throw lastError ?? TsnetError.notUp
|
||||
}
|
||||
}
|
||||
|
||||
/// 线程安全地持有 transport 引用,供 libghostty 的输入/resize 回调(非主线程)安全取用。
|
||||
final class TransportHolder: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var value: Transport?
|
||||
var transport: Transport? {
|
||||
get { lock.lock(); defer { lock.unlock() }; return value }
|
||||
set { lock.lock(); value = newValue; lock.unlock() }
|
||||
}
|
||||
}
|
||||
|
||||
/// 输出闸门:surface 未 attach 时 receive 会丢弃写入,故未就绪先缓冲、就绪后按序 flush(修白屏)。
|
||||
final class OutputGate: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private let session: InMemoryTerminalSession
|
||||
private var ready = false
|
||||
private var pending = Data()
|
||||
|
||||
init(session: InMemoryTerminalSession) { self.session = session }
|
||||
|
||||
func deliver(_ data: Data) {
|
||||
lock.lock()
|
||||
if ready { lock.unlock(); session.receive(data) }
|
||||
else { pending.append(data); lock.unlock() }
|
||||
}
|
||||
|
||||
func markReady() {
|
||||
lock.lock()
|
||||
if ready { lock.unlock(); return }
|
||||
ready = true
|
||||
let buffered = pending; pending = Data()
|
||||
lock.unlock()
|
||||
if !buffered.isEmpty { session.receive(buffered) }
|
||||
}
|
||||
}
|
||||
|
||||
/// M0 SSH 终端会话模型:SSHSession(libssh2) ⇄ GhosttyKit in-memory 终端,
|
||||
/// 由 TXCore.SessionMachine 编排生命周期(断线指数退避自动重连、后台冻结/前台恢复)。
|
||||
@MainActor
|
||||
final class SSHTerminalModel: ObservableObject {
|
||||
let state: TerminalViewState
|
||||
let session: InMemoryTerminalSession
|
||||
private let holder: TransportHolder
|
||||
private let gate: OutputGate
|
||||
|
||||
/// 是否展示终端页(连接后保持,重连/后台期间不退回表单)。
|
||||
@Published var showsTerminal = false
|
||||
@Published var banner: String = ""
|
||||
@Published var phaseText: String = "空闲"
|
||||
/// 非 nil 时进入 tmux control-mode(原生 tab)。
|
||||
@Published var tmuxController: TmuxController?
|
||||
|
||||
private var machine = SessionMachine()
|
||||
private var config: SSHConfig?
|
||||
private var reconnectTask: Task<Void, Never>?
|
||||
private var autoCommand: String?
|
||||
|
||||
/// tsnet 模式:非 nil 时经 tsnet fd 桥连接(而非系统直连),重连复用同一节点。
|
||||
private var tsnetAuthKey: String?
|
||||
private let tsnetMgr = TsnetManager()
|
||||
|
||||
init() {
|
||||
let holder = TransportHolder()
|
||||
let session = InMemoryTerminalSession(
|
||||
write: { data in holder.transport?.send(data) },
|
||||
resize: { vp in holder.transport?.resize(cols: vp.columns, rows: vp.rows) }
|
||||
)
|
||||
let state = TerminalViewState()
|
||||
state.configuration = TerminalSurfaceOptions(backend: .inMemory(session))
|
||||
self.holder = holder
|
||||
self.session = session
|
||||
self.state = state
|
||||
self.gate = OutputGate(session: session)
|
||||
}
|
||||
|
||||
// MARK: - 公开入口
|
||||
|
||||
func connect(host: String, port: Int, username: String, password: String) {
|
||||
config = SSHConfig(host: host, port: port, username: username,
|
||||
authentication: .password(password))
|
||||
run(machine.reduce(.connectRequested))
|
||||
}
|
||||
|
||||
func close() { run(machine.reduce(.closeRequested)) }
|
||||
func enterBackground() { run(machine.reduce(.enteredBackground)) }
|
||||
func enterForeground() { run(machine.reduce(.enteredForeground)) }
|
||||
|
||||
/// TerminalScreen 在 surface 首次布局后调用:放行缓冲输出(+ 无头自动命令)。
|
||||
func markSurfaceReady() {
|
||||
gate.markReady()
|
||||
if let cmd = autoCommand {
|
||||
autoCommand = nil
|
||||
let holder = self.holder
|
||||
Task {
|
||||
try? await Task.sleep(nanoseconds: 2_500_000_000)
|
||||
holder.transport?.send(Data("\n".utf8))
|
||||
try? await Task.sleep(nanoseconds: 900_000_000)
|
||||
holder.transport?.send(Data((cmd + "\n").utf8))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 无头验证:从启动参数自动连接(simctl launch --args -txHost … -txAutoCommand …)。
|
||||
func autoConnectIfConfigured() {
|
||||
let d = UserDefaults.standard
|
||||
guard let host = d.string(forKey: "txHost"), !host.isEmpty,
|
||||
let user = d.string(forKey: "txUser") else { return }
|
||||
let port = d.integer(forKey: "txPort")
|
||||
autoCommand = d.string(forKey: "txAutoCommand")
|
||||
if let key = d.string(forKey: "txTsnetKey"), !key.isEmpty { tsnetAuthKey = key }
|
||||
connect(host: host, port: port == 0 ? 22 : port,
|
||||
username: user, password: d.string(forKey: "txPass") ?? "")
|
||||
}
|
||||
|
||||
// MARK: - 状态机副作用执行
|
||||
|
||||
private func run(_ effects: [SessionMachine.Effect]) {
|
||||
for effect in effects {
|
||||
switch effect {
|
||||
case .startConnect: startSession()
|
||||
case .scheduleReconnect(_, let delayMS): scheduleReconnect(delayMS)
|
||||
case .cancelReconnectTimer: reconnectTask?.cancel(); reconnectTask = nil
|
||||
case .teardownTransport: holder.transport?.stop()
|
||||
case .notify(let message): banner = message
|
||||
}
|
||||
}
|
||||
syncUI()
|
||||
}
|
||||
|
||||
private func startSession() {
|
||||
guard let config else { return }
|
||||
if let key = tsnetAuthKey {
|
||||
// tsnet 模式:后台 up 节点 + dial fd,再用外部 fd 建 SSHSession。
|
||||
let mgr = tsnetMgr
|
||||
Task.detached {
|
||||
do {
|
||||
try mgr.ensureUp(authKey: key, timeoutMs: 45000)
|
||||
let fd = try mgr.dialFD(host: config.host, port: config.port, timeoutMs: 15000)
|
||||
await MainActor.run { self.wireAndStart(SSHSession(config: config, preconnectedFD: fd)) }
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
self.handleTransportState(.failed("tsnet: \(error.localizedDescription)"))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
wireAndStart(SSHSession(config: config))
|
||||
}
|
||||
}
|
||||
|
||||
private func wireAndStart(_ ssh: SSHSession) {
|
||||
exitTmux() // 新会话回原始模式(tmux 会话在服务端,重连后需重新 -CC attach)
|
||||
let router = tmuxRouter
|
||||
ssh.onBytes = { data in router.feed(data) } // 经路由器分流 raw / tmux 网关
|
||||
ssh.onState = { [weak self] newState in
|
||||
Task { @MainActor in self?.handleTransportState(newState) }
|
||||
}
|
||||
holder.transport = ssh
|
||||
ssh.start()
|
||||
}
|
||||
|
||||
private lazy var tmuxRouter = TmuxRouter(
|
||||
rawGate: gate,
|
||||
enterGateway: { [weak self] after in
|
||||
Task { @MainActor in self?.enterTmux(initialBytes: after) }
|
||||
},
|
||||
gatewayBytes: { [weak self] bytes in
|
||||
Task { @MainActor in self?.tmuxController?.feed(Data(bytes)) }
|
||||
}
|
||||
)
|
||||
|
||||
private func enterTmux(initialBytes: [UInt8]) {
|
||||
let holder = self.holder
|
||||
let controller = TmuxController(sendRaw: { data in holder.transport?.send(data) })
|
||||
controller.onExit = { [weak self] in Task { @MainActor in self?.exitTmux() } }
|
||||
tmuxController = controller
|
||||
if !initialBytes.isEmpty { controller.feed(Data(initialBytes)) }
|
||||
}
|
||||
|
||||
private func exitTmux() {
|
||||
if tmuxController != nil { tmuxController = nil }
|
||||
tmuxRouter.reset()
|
||||
}
|
||||
|
||||
private func handleTransportState(_ st: TransportState) {
|
||||
switch st {
|
||||
case .authenticating: run(machine.reduce(.authenticating))
|
||||
case .connected: run(machine.reduce(.established))
|
||||
case .disconnected(let reason): run(machine.reduce(.transportClosed(reason: reason)))
|
||||
case .failed(let message): run(machine.reduce(.authFailed(reason: message)))
|
||||
case .idle, .connecting: break
|
||||
}
|
||||
}
|
||||
|
||||
private func scheduleReconnect(_ delayMS: Int) {
|
||||
reconnectTask?.cancel()
|
||||
reconnectTask = Task { [weak self] in
|
||||
try? await Task.sleep(nanoseconds: UInt64(delayMS) * 1_000_000)
|
||||
guard !Task.isCancelled else { return }
|
||||
await MainActor.run {
|
||||
guard let self else { return }
|
||||
self.run(self.machine.reduce(.reconnectTimerFired))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func syncUI() {
|
||||
switch machine.phase {
|
||||
case .connected, .reconnecting, .waitingToReconnect, .backgroundParked:
|
||||
showsTerminal = true
|
||||
case .idle, .connecting, .authenticating, .closed, .failed:
|
||||
showsTerminal = false
|
||||
}
|
||||
phaseText = Self.describe(machine.phase)
|
||||
}
|
||||
|
||||
private static func describe(_ phase: SessionMachine.Phase) -> String {
|
||||
switch phase {
|
||||
case .idle: "空闲"
|
||||
case .connecting: "连接中…"
|
||||
case .authenticating: "认证中…"
|
||||
case .connected: "已连接"
|
||||
case .backgroundParked: "已挂起(后台)"
|
||||
case .waitingToReconnect(let n): "等待重连(第 \(n) 次)"
|
||||
case .reconnecting(let n): "重连中(第 \(n) 次)"
|
||||
case .failed(let r): "失败:\(r)"
|
||||
case .closed: "已关闭"
|
||||
}
|
||||
}
|
||||
}
|
||||
10
apps/TerminalX/iOS/TerminalXApp.swift
Normal file
10
apps/TerminalX/iOS/TerminalXApp.swift
Normal file
@@ -0,0 +1,10 @@
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct TerminalXApp: App {
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
}
|
||||
}
|
||||
}
|
||||
242
apps/TerminalX/iOS/TmuxController.swift
Normal file
242
apps/TerminalX/iOS/TmuxController.swift
Normal file
@@ -0,0 +1,242 @@
|
||||
import Foundation
|
||||
import GhosttyTerminal
|
||||
import TXCore
|
||||
|
||||
/// 一个 tmux window(MVP:显示其活动 pane)。每 window 独立终端会话 + surface。
|
||||
@MainActor
|
||||
final class TmuxWindow: ObservableObject, Identifiable {
|
||||
let id: TmuxWindowID
|
||||
@Published var title: String
|
||||
let state: TerminalViewState
|
||||
let session: InMemoryTerminalSession
|
||||
let gate: OutputGate
|
||||
var activePane: TmuxPaneID?
|
||||
|
||||
init(id: TmuxWindowID, title: String, onInput: @escaping @Sendable (Data) -> Void) {
|
||||
self.id = id
|
||||
self.title = title
|
||||
let session = InMemoryTerminalSession(
|
||||
write: { data in onInput(data) }, // 用户输入 → send-keys(由 controller 处理)
|
||||
resize: { _ in }
|
||||
)
|
||||
let state = TerminalViewState()
|
||||
state.configuration = TerminalSurfaceOptions(backend: .inMemory(session))
|
||||
self.session = session
|
||||
self.state = state
|
||||
self.gate = OutputGate(session: session)
|
||||
}
|
||||
}
|
||||
|
||||
/// tmux control-mode 网关:消费 `%`-协议流,把 tmux windows 映射为原生 tab。
|
||||
/// MVP:每 window 显示活动 pane;输入经 `send-keys -H`;切 tab 经 `select-window`。
|
||||
/// 已简化:多 pane 分屏只显示活动 pane;历史依赖 attach 重绘;命令不做严格应答匹配。
|
||||
@MainActor
|
||||
final class TmuxController: ObservableObject {
|
||||
@Published private(set) var windows: [TmuxWindow] = []
|
||||
@Published var activeWindowID: TmuxWindowID?
|
||||
|
||||
private let parser = TmuxControlParser()
|
||||
private var windowByID: [TmuxWindowID: TmuxWindow] = [:]
|
||||
private var paneToWindow: [TmuxPaneID: TmuxWindowID] = [:]
|
||||
private let sendRaw: @Sendable (Data) -> Void
|
||||
var onExit: (() -> Void)?
|
||||
|
||||
// control-mode 命令应答 FIFO 匹配(attach 场景需主动 list-windows 枚举)。
|
||||
private var attachAcked = false
|
||||
private enum PendingKind { case ignore, listWindows, listPanes(TmuxWindowID), capturePane(TmuxWindowID) }
|
||||
private var pending: [PendingKind] = []
|
||||
|
||||
init(sendRaw: @escaping @Sendable (Data) -> Void) {
|
||||
self.sendRaw = sendRaw
|
||||
}
|
||||
|
||||
var activeWindow: TmuxWindow? {
|
||||
guard let id = activeWindowID else { return windows.first }
|
||||
return windowByID[id]
|
||||
}
|
||||
|
||||
/// 送入 control-mode 字节流。
|
||||
func feed(_ data: Data) {
|
||||
for event in parser.feed(data) { apply(event) }
|
||||
}
|
||||
|
||||
// MARK: - 事件处理
|
||||
|
||||
private func apply(_ event: TmuxEvent) {
|
||||
switch event {
|
||||
case .windowAdd(let w):
|
||||
_ = ensureWindow(w)
|
||||
case .windowClose(let w), .unlinkedWindowClose(let w):
|
||||
removeWindow(w)
|
||||
case .windowRenamed(let w, let name):
|
||||
windowByID[w]?.title = name
|
||||
case .layoutChange(let w, let layout, _):
|
||||
let win = ensureWindow(w)
|
||||
if let parsed = try? TmuxLayout.parse(layout) {
|
||||
let panes = parsed.paneIDs
|
||||
for p in panes { paneToWindow[p] = w }
|
||||
if win.activePane == nil { win.activePane = panes.first }
|
||||
}
|
||||
case .windowPaneChanged(let w, let pane):
|
||||
paneToWindow[pane] = w
|
||||
windowByID[w]?.activePane = pane
|
||||
case .output(let pane, let data):
|
||||
if let wid = paneToWindow[pane] {
|
||||
windowByID[wid]?.gate.deliver(data)
|
||||
}
|
||||
case .extendedOutput(let pane, _, let data):
|
||||
if let wid = paneToWindow[pane] {
|
||||
windowByID[wid]?.gate.deliver(data)
|
||||
}
|
||||
case .sessionWindowChanged(_, let w):
|
||||
if windowByID[w] != nil { activeWindowID = w }
|
||||
case .commandResponse(let response):
|
||||
handleCommandResponse(response)
|
||||
case .exit:
|
||||
onExit?()
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
/// control-mode 命令应答。第一个应答是 attach 的隐式块(无对应命令)→ 触发 list-windows 枚举;
|
||||
/// 之后按 FIFO 匹配我方发出的命令。
|
||||
private func handleCommandResponse(_ r: TmuxCommandResponse) {
|
||||
if !attachAcked {
|
||||
attachAcked = true
|
||||
pending.append(.listWindows)
|
||||
sendCommand("list-windows -F \"#{window_id} #{window_name}\"")
|
||||
return
|
||||
}
|
||||
guard !pending.isEmpty else { return }
|
||||
switch pending.removeFirst() {
|
||||
case .ignore:
|
||||
break
|
||||
case .listWindows:
|
||||
for line in r.lines {
|
||||
let parts = line.split(separator: " ", maxSplits: 1)
|
||||
guard let first = parts.first, let w = TmuxWindowID.parse(first) else { continue }
|
||||
let win = ensureWindow(w)
|
||||
if parts.count > 1 { win.title = String(parts[1]) }
|
||||
pending.append(.listPanes(w))
|
||||
sendCommand("list-panes -t @\(w.raw) -F \"#{pane_id} #{pane_active}\"")
|
||||
}
|
||||
sendCommand("refresh-client") // 强制重绘 → 当前屏幕以 %output 下发
|
||||
pending.append(.ignore)
|
||||
case .listPanes(let w):
|
||||
for line in r.lines {
|
||||
let parts = line.split(separator: " ")
|
||||
guard let first = parts.first, let p = TmuxPaneID.parse(first) else { continue }
|
||||
paneToWindow[p] = w
|
||||
let active = parts.count > 1 && parts[1] == "1"
|
||||
if active || windowByID[w]?.activePane == nil {
|
||||
windowByID[w]?.activePane = p
|
||||
}
|
||||
}
|
||||
// 抓当前屏幕内容作初始渲染(初始 %output 可能在映射建立前已到达被丢)。
|
||||
if let pane = windowByID[w]?.activePane {
|
||||
pending.append(.capturePane(w))
|
||||
sendCommand("capture-pane -t %\(pane.raw) -p -e -J")
|
||||
}
|
||||
case .capturePane(let w):
|
||||
let screen = r.lines.joined(separator: "\r\n")
|
||||
if !screen.isEmpty {
|
||||
windowByID[w]?.gate.deliver(Data((screen + "\r\n").utf8))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func ensureWindow(_ w: TmuxWindowID) -> TmuxWindow {
|
||||
if let existing = windowByID[w] { return existing }
|
||||
let win = TmuxWindow(id: w, title: "窗口 \(w.raw)") { [weak self] data in
|
||||
self?.enqueueInput(window: w, data: data)
|
||||
}
|
||||
windowByID[w] = win
|
||||
windows.append(win)
|
||||
windows.sort { $0.id.raw < $1.id.raw }
|
||||
if activeWindowID == nil { activeWindowID = w }
|
||||
return win
|
||||
}
|
||||
|
||||
private func removeWindow(_ w: TmuxWindowID) {
|
||||
windowByID[w] = nil
|
||||
windows.removeAll { $0.id == w }
|
||||
paneToWindow = paneToWindow.filter { $0.value != w }
|
||||
if activeWindowID == w { activeWindowID = windows.first?.id }
|
||||
}
|
||||
|
||||
// MARK: - 命令 / 输入
|
||||
|
||||
func sendCommand(_ command: String) {
|
||||
sendRaw(Data((command + "\n").utf8))
|
||||
}
|
||||
|
||||
/// 用户在某 window 输入 → send-keys -H 到其活动 pane(非主线程安全:只在主线程调)。
|
||||
private nonisolated func enqueueInput(window w: TmuxWindowID, data: Data) {
|
||||
Task { @MainActor in
|
||||
guard let pane = self.windowByID[w]?.activePane else { return }
|
||||
let hex = data.map { String(format: "%02x", $0) }.joined(separator: " ")
|
||||
self.sendCommand("send-keys -t %\(pane.raw) -H \(hex)")
|
||||
}
|
||||
}
|
||||
|
||||
func selectWindow(_ w: TmuxWindowID) {
|
||||
activeWindowID = w
|
||||
sendCommand("select-window -t @\(w.raw)")
|
||||
}
|
||||
|
||||
func newWindow() { sendCommand("new-window") }
|
||||
func killWindow(_ w: TmuxWindowID) { sendCommand("kill-window -t @\(w.raw)") }
|
||||
|
||||
/// 某 window 的 surface 就绪 → 放行其缓冲输出。
|
||||
func markWindowReady(_ w: TmuxWindowID) { windowByID[w]?.gate.markReady() }
|
||||
}
|
||||
|
||||
/// 传输字节分流器:检测 `tmux -CC` 进入 DCS 前 → 原始终端;进入后 → tmux 网关。
|
||||
/// 在传输后台线程调用;原始字节走线程安全的 rawGate,网关字节经闭包 hop 到主线程。
|
||||
final class TmuxRouter: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private let rawGate: OutputGate
|
||||
private let enterGateway: @Sendable ([UInt8]) -> Void
|
||||
private let gatewayBytes: @Sendable ([UInt8]) -> Void
|
||||
private var carry: [UInt8] = []
|
||||
private var inGateway = false
|
||||
|
||||
init(rawGate: OutputGate,
|
||||
enterGateway: @escaping @Sendable ([UInt8]) -> Void,
|
||||
gatewayBytes: @escaping @Sendable ([UInt8]) -> Void) {
|
||||
self.rawGate = rawGate
|
||||
self.enterGateway = enterGateway
|
||||
self.gatewayBytes = gatewayBytes
|
||||
}
|
||||
|
||||
func feed(_ data: Data) {
|
||||
lock.lock(); defer { lock.unlock() }
|
||||
if inGateway {
|
||||
gatewayBytes(Array(data))
|
||||
return
|
||||
}
|
||||
let buf = carry + Array(data)
|
||||
if let idx = TmuxControlSequence.find(TmuxControlSequence.enter, in: buf) {
|
||||
let before = Array(buf[0 ..< idx])
|
||||
let after = Array(buf[(idx + TmuxControlSequence.enter.count)...])
|
||||
if !before.isEmpty { rawGate.deliver(Data(before)) }
|
||||
inGateway = true
|
||||
carry = []
|
||||
enterGateway(after)
|
||||
} else {
|
||||
// 仅当结尾是哨兵部分前缀时滞留,避免延迟正常输出。
|
||||
let k = TmuxControlSequence.partialTrailingMatchLength(TmuxControlSequence.enter, in: buf)
|
||||
if buf.count > k {
|
||||
rawGate.deliver(Data(buf[0 ..< (buf.count - k)]))
|
||||
}
|
||||
carry = k > 0 ? Array(buf.suffix(k)) : []
|
||||
}
|
||||
}
|
||||
|
||||
/// tmux 退出 → 回原始模式。
|
||||
func reset() {
|
||||
lock.lock(); inGateway = false; carry = []; lock.unlock()
|
||||
}
|
||||
}
|
||||
89
apps/TerminalX/iOS/TsnetProbe.swift
Normal file
89
apps/TerminalX/iOS/TsnetProbe.swift
Normal file
@@ -0,0 +1,89 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
import TsnetBridge
|
||||
|
||||
struct TsnetPeer: Codable, Identifiable {
|
||||
let name: String
|
||||
let ip: String
|
||||
let online: Bool
|
||||
let os: String
|
||||
var id: String { name + ip }
|
||||
}
|
||||
|
||||
/// M1 自检:app 进程内用 tsnet 加入 tailnet,显示分配 IP + 枚举 tailnet 对端(发现可连主机)。
|
||||
@MainActor
|
||||
final class TsnetProbe: ObservableObject {
|
||||
@Published var status = "准备中…"
|
||||
@Published var selfIP = ""
|
||||
@Published var peers: [TsnetPeer] = []
|
||||
@Published var done = false
|
||||
|
||||
func run(authKey: String) {
|
||||
let base = NSSearchPathForDirectoriesInDomains(.applicationSupportDirectory, .userDomainMask, true)[0]
|
||||
let dir = base + "/tsnet-probe"
|
||||
try? FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true)
|
||||
status = "加入 tailnet 中…(up 阻塞等待 Running)"
|
||||
|
||||
Task.detached {
|
||||
guard let node = TsnetbridgeNewNode(dir, "terminalx-ipad") else {
|
||||
await MainActor.run { self.status = "NewNode 失败"; self.done = true }
|
||||
return
|
||||
}
|
||||
do {
|
||||
try node.up(withAuthKey: authKey, timeoutMs: 45000)
|
||||
let ip = node.selfIP()
|
||||
try? await Task.sleep(nanoseconds: 5_000_000_000) // 等 netmap 同步
|
||||
let peersJSON = node.peersJSON()
|
||||
let decoded = (try? JSONDecoder().decode([TsnetPeer].self,
|
||||
from: Data(peersJSON.utf8))) ?? []
|
||||
await MainActor.run {
|
||||
self.selfIP = ip
|
||||
self.peers = decoded.sorted { $0.online && !$1.online }
|
||||
self.status = ip.isEmpty ? "已 Up 但未取到 IP" : "已加入 tailnet ✓"
|
||||
self.done = true
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
self.status = "失败:\(error.localizedDescription)"
|
||||
self.done = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct TsnetProbeView: View {
|
||||
let authKey: String
|
||||
@StateObject private var probe = TsnetProbe()
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 12) {
|
||||
Text("terminalX · tsnet 自检 (M1)").font(.headline)
|
||||
ProgressView().opacity(probe.done ? 0 : 1)
|
||||
Text(probe.status).foregroundStyle(probe.selfIP.isEmpty ? Color.secondary : Color.green)
|
||||
if !probe.selfIP.isEmpty {
|
||||
Text("本机 Tailscale IP:\(probe.selfIP)")
|
||||
.font(.system(.body, design: .monospaced)).bold().foregroundStyle(.green)
|
||||
}
|
||||
if probe.done {
|
||||
Text("tailnet 对端:\(probe.peers.count)").font(.subheadline).padding(.top, 8)
|
||||
}
|
||||
if !probe.peers.isEmpty {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
ForEach(probe.peers) { p in
|
||||
HStack(spacing: 8) {
|
||||
Circle().fill(p.online ? .green : .gray).frame(width: 8, height: 8)
|
||||
Text(p.ip).font(.system(.caption, design: .monospaced))
|
||||
Text(p.name).font(.caption).foregroundStyle(.secondary).lineLimit(1)
|
||||
Text(p.os).font(.caption2).foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
}.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}.frame(maxHeight: 500)
|
||||
}
|
||||
}
|
||||
.padding(28)
|
||||
.task { probe.run(authKey: authKey) }
|
||||
}
|
||||
}
|
||||
47
apps/TerminalX/project.yml
Normal file
47
apps/TerminalX/project.yml
Normal file
@@ -0,0 +1,47 @@
|
||||
name: TerminalX
|
||||
options:
|
||||
bundleIdPrefix: ai.athom.terminalx
|
||||
deploymentTarget:
|
||||
iOS: "17.0"
|
||||
createIntermediateGroups: true
|
||||
|
||||
packages:
|
||||
# 本地 vendored(构建环境的认证代理不被 SwiftPM 支持,改用 local-path 零网络)。
|
||||
GhosttyKit:
|
||||
path: ../../vendor/libghostty-spm
|
||||
TXTransport:
|
||||
path: ../../packages/TXTransport
|
||||
TXCore:
|
||||
path: ../../packages/TXCore
|
||||
|
||||
targets:
|
||||
TerminalX:
|
||||
type: application
|
||||
platform: iOS
|
||||
deploymentTarget: "17.0"
|
||||
sources:
|
||||
- path: iOS
|
||||
dependencies:
|
||||
- package: GhosttyKit
|
||||
product: GhosttyTerminal
|
||||
- package: GhosttyKit
|
||||
product: GhosttyTheme
|
||||
- package: TXTransport
|
||||
product: TXTransport
|
||||
- package: TXCore
|
||||
product: TXCore
|
||||
# gomobile bind 产出的 tsnet 桥接(动态 framework,需嵌入+签名拷贝)。
|
||||
- framework: ../../artifacts/TsnetBridge.xcframework
|
||||
embed: true
|
||||
settings:
|
||||
base:
|
||||
PRODUCT_BUNDLE_IDENTIFIER: ai.athom.terminalx
|
||||
MARKETING_VERSION: "0.0.1"
|
||||
CURRENT_PROJECT_VERSION: "1"
|
||||
SWIFT_VERSION: "6.0"
|
||||
GENERATE_INFOPLIST_FILE: YES
|
||||
TARGETED_DEVICE_FAMILY: "1,2"
|
||||
INFOPLIST_KEY_UILaunchScreen_Generation: YES
|
||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents: YES
|
||||
# M0 渲染自检无需签名(仅模拟器);真机/上架在 M4/M5 配置。
|
||||
CODE_SIGNING_ALLOWED: NO
|
||||
Reference in New Issue
Block a user