- TXCore/SSH: SSHWire(string/mpint 编码)、ECDSAConv(DER→SSH 签名/P256 blob)、 HostKey(opensshFingerprint/evaluate/HostTriple/KnownHostsStore),+10 单测(含真实指纹向量、高位 DER) - TXTransport: SSHConfig.hostKeyVerifier 闭包 + SSHError.hostKeyMismatch; SSHSession 握手后 libssh2_session_hostkey 取 blob 交 verifier,拒绝则断开抛错 - app: 文件后端 KnownHostsStore(未签名 app 无 keychain 权限 SecItem -34018,host key 是 公钥非机密,沙盒文件对 TOFU 足够)+ TOFU verifier + firstUse 横幅 + mismatch alert(信任/取消) - 验证(192.168.9.199):首次信任 pin+接受连上;假 pin→不符→断开+告警,本次指纹与 ssh-keygen 逐字符一致 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
255 lines
10 KiB
Swift
255 lines
10 KiB
Swift
import SwiftUI
|
||
import GhosttyTerminal
|
||
import TXCore
|
||
|
||
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
|
||
}
|
||
}
|
||
.alert("⚠️ Host Key 已变化", isPresented: Binding(
|
||
get: { model.hostKeyMismatch != nil },
|
||
set: { if !$0 { model.dismissHostKeyMismatch() } }
|
||
), presenting: model.hostKeyMismatch) { _ in
|
||
Button("信任新密钥", role: .destructive) { model.trustNewHostKey() }
|
||
Button("取消", role: .cancel) { model.dismissHostKeyMismatch() }
|
||
} message: { info in
|
||
Text("\(info.egress)/\(info.host):\(info.port) 的 host key 与已记住的不同,可能是中间人攻击。\n\n已记住:\(info.storedFp)\n本次:\(info.presentedFp)")
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 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)
|
||
}
|
||
}
|
||
|
||
/// 一个 window:按 tmux 可见布局树把多 pane 绝对定位为分屏(每 pane 一 surface)。
|
||
struct TmuxWindowView: View {
|
||
@ObservedObject var window: TmuxWindow
|
||
let controller: TmuxController
|
||
@Environment(\.displayScale) private var displayScale
|
||
|
||
var body: some View {
|
||
GeometryReader { geo in
|
||
Group {
|
||
if let layout = window.visibleLayout {
|
||
let root = layout.root.rect
|
||
let cw = geo.size.width / CGFloat(max(1, root.width))
|
||
let ch = geo.size.height / CGFloat(max(1, root.height))
|
||
ZStack(alignment: .topLeading) {
|
||
ForEach(leaves(layout.root), id: \.pane.raw) { item in
|
||
if let surface = window.panes[item.pane] {
|
||
TmuxPaneView(
|
||
surface: surface,
|
||
isActive: window.activePane == item.pane,
|
||
onTap: { controller.selectPane(item.pane, in: window.id) },
|
||
onReady: { controller.markPaneReady(item.pane) }
|
||
)
|
||
.frame(width: CGFloat(item.rect.width) * cw,
|
||
height: CGFloat(item.rect.height) * ch)
|
||
.position(x: (CGFloat(item.rect.x) + CGFloat(item.rect.width) / 2) * cw,
|
||
y: (CGFloat(item.rect.y) + CGFloat(item.rect.height) / 2) * ch)
|
||
}
|
||
}
|
||
}
|
||
} else {
|
||
Color.clear
|
||
}
|
||
}
|
||
// iPadOS 可自由拖拽窗口尺寸 → 容器变化即重算总格子并 refresh-client -C(controller 内 debounce)。
|
||
.onAppear { controller.containerResized(widthPt: geo.size.width, heightPt: geo.size.height, scale: displayScale) }
|
||
.onChange(of: geo.size) { _, s in
|
||
controller.containerResized(widthPt: s.width, heightPt: s.height, scale: displayScale)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 收集布局树叶子(pane + 绝对 rect),identity 只用 paneID → 布局变化不重建 surface view。
|
||
private func leaves(_ node: TmuxLayout.Node) -> [(pane: TmuxPaneID, rect: TmuxLayout.Rect)] {
|
||
switch node {
|
||
case .leaf(let p, let r): return [(p, r)]
|
||
case .horizontal(let cs, _), .vertical(let cs, _): return cs.flatMap(leaves)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 单个 pane 的终端面 + 焦点边框 + 点击选中。
|
||
struct TmuxPaneView: View {
|
||
@ObservedObject var surface: TmuxPaneSurface
|
||
let isActive: Bool
|
||
let onTap: () -> Void
|
||
let onReady: () -> Void
|
||
@FocusState private var focused: Bool
|
||
|
||
var body: some View {
|
||
TerminalSurfaceView(context: surface.state)
|
||
.terminalFocusOnAppear($focused)
|
||
.overlay(
|
||
Rectangle().stroke(isActive ? Color.accentColor : Color.gray.opacity(0.35),
|
||
lineWidth: isActive ? 2 : 0.5)
|
||
)
|
||
// 非活动 pane 叠透明 tap-catcher(TerminalSurfaceView 会吞触摸);活动 pane 直通终端。
|
||
.overlay {
|
||
if !isActive {
|
||
Color.black.opacity(0.04).contentShape(Rectangle()).onTapGesture { onTap() }
|
||
}
|
||
}
|
||
.onChange(of: isActive) { _, active in focused = active }
|
||
.task {
|
||
for _ in 0 ..< 300 where surface.state.surfaceSize == nil {
|
||
try? await Task.sleep(nanoseconds: 30_000_000)
|
||
}
|
||
onReady()
|
||
}
|
||
}
|
||
}
|