初始提交:terminalX 可运行态(M0/M1/M1.5 已真机验证)

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
kid
2026-07-24 10:20:46 +08:00
commit aa92d0e676
2761 changed files with 803505 additions and 0 deletions

View File

@@ -0,0 +1,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 +
// -txHostmodel 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-modewindow 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)
}
}
}

View 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: "已关闭"
}
}
}

View File

@@ -0,0 +1,10 @@
import SwiftUI
@main
struct TerminalXApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}

View File

@@ -0,0 +1,242 @@
import Foundation
import GhosttyTerminal
import TXCore
/// tmux windowMVP 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()
}
}

View 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) }
}
}

View 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