初始提交: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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user