- 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>
181 lines
6.4 KiB
Swift
181 lines
6.4 KiB
Swift
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)
|
||
}
|
||
}
|
||
}
|