import SwiftUI import UIKit import GhosttyTerminal import TXCore struct ContentView: View { @StateObject private var model = SSHTerminalModel() @StateObject private var hostStore = HostStore() @StateObject private var theme = TXThemeManager() @Environment(\.scenePhase) private var scenePhase var body: some View { Group { // M1 纯自检分支:仅 -txTsnetKey(无 -txHost)时跑 tsnet 加入+对端枚举自检。 if let tsnetKey = UserDefaults.standard.string(forKey: "txTsnetKey"), !tsnetKey.isEmpty, (UserDefaults.standard.string(forKey: "txHost") ?? "").isEmpty { TsnetProbeView(authKey: tsnetKey) } else if model.showsTerminal { TerminalScreen(model: model) } else { HomeView(model: model, store: hostStore) } } .environmentObject(theme) .preferredColorScheme(theme.palette.isDark ? .dark : .light) .onChange(of: theme.palette.id) { _, _ in // 主题切换:app 视图经 palette 响应式更新;这里把终端配色同步推给已连接的 surface。 model.applyTerminalTheme(theme.palette.terminalTheme) } .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)") } } } /// 主屏:主机列表(对标 Moshi)。卡片式主机 + 右下 FAB 加主机 + 右上设置入口。 struct HomeView: View { @ObservedObject var model: SSHTerminalModel @ObservedObject var store: HostStore @EnvironmentObject var theme: TXThemeManager @State private var showAdd = false @State private var showSettings = false private var p: TXPalette { theme.palette } var body: some View { NavigationStack { ZStack { p.bg.ignoresSafeArea() ScrollView { VStack(alignment: .leading, spacing: TX.Space.m) { sectionHeader("连接") if store.hosts.isEmpty { emptyState } else { ForEach(store.hosts) { host in HostCard(host: host) { model.connect(to: host) } .contextMenu { Button(role: .destructive) { store.remove(host) } label: { Label("删除", systemImage: "trash") } } } } if isBusy || model.phaseText.hasPrefix("失败") { statusRow } } .padding(TX.Space.m) .padding(.bottom, 90) // 给 FAB 留空 } fab } .background(p.bg) .toolbar { ToolbarItem(placement: .principal) { Text("terminalX") .font(TX.Font.mono(17, .semibold)) .foregroundStyle(p.textPrimary) } ToolbarItem(placement: .topBarTrailing) { Button { showSettings = true } label: { Image(systemName: "gearshape") .foregroundStyle(p.textSecondary) } } } .toolbarBackground(p.bg, for: .navigationBar) .toolbarBackground(.visible, for: .navigationBar) } .tint(p.accent) .sheet(isPresented: $showAdd) { AddHostSheet { store.add($0) } } .sheet(isPresented: $showSettings) { SettingsStubView() } } private func sectionHeader(_ text: String) -> some View { Text(text) .font(.system(size: 13, weight: .medium)) .foregroundStyle(p.textSecondary) .padding(.leading, 4) } private var emptyState: some View { VStack(spacing: TX.Space.s) { Image(systemName: "server.rack") .font(.system(size: 34, weight: .light)) .foregroundStyle(p.textTertiary) Text("还没有主机") .font(.system(size: 15)) .foregroundStyle(p.textSecondary) Text("点击右下角 + 添加一台服务器") .font(.system(size: 13)) .foregroundStyle(p.textTertiary) } .frame(maxWidth: .infinity) .padding(.vertical, 48) } /// 进行中(连接/认证/重连)——决定是否转圈。 private var isBusy: Bool { model.phaseText.hasSuffix("…") || model.phaseText.contains("重连") } private var statusRow: some View { HStack(spacing: TX.Space.s) { if isBusy { ProgressView().controlSize(.small).tint(p.accent) } Text(model.phaseText) .font(.system(size: 13)) .foregroundStyle(model.phaseText.hasPrefix("失败") ? .red : p.textSecondary) } .padding(.horizontal, 4) } private var fab: some View { VStack { Spacer() HStack { Spacer() Button { showAdd = true } label: { Image(systemName: "plus") .font(.system(size: 24, weight: .semibold)) .foregroundStyle(p.bg) .frame(width: 60, height: 60) .background(p.accent, in: Circle()) .shadow(color: p.accent.opacity(0.35), radius: 12, y: 4) } .padding(TX.Space.l) } } } } /// 主机卡片:服务器图标 + 状态点 / 名称 / user@ip:port(等宽)/ chevron。 struct HostCard: View { let host: SavedHost let onTap: () -> Void @EnvironmentObject var theme: TXThemeManager private var p: TXPalette { theme.palette } var body: some View { Button(action: onTap) { HStack(spacing: TX.Space.m) { ZStack(alignment: .topTrailing) { Image(systemName: "server.rack") .font(.system(size: 20, weight: .regular)) .foregroundStyle(p.textSecondary) .frame(width: 44, height: 44) .background(p.surfaceElevated, in: RoundedRectangle(cornerRadius: TX.Radius.chip)) if host.useMosh { Circle().fill(p.mosh).frame(width: 9, height: 9) .overlay(Circle().stroke(p.surface, lineWidth: 2)) .offset(x: 3, y: -3) } } VStack(alignment: .leading, spacing: 3) { Text(host.name) .font(.system(size: 16, weight: .semibold)) .foregroundStyle(p.textPrimary) Text(host.addressLine) .font(TX.Font.mono(13)) .foregroundStyle(p.textSecondary) } Spacer() Image(systemName: "chevron.right") .font(.system(size: 14, weight: .semibold)) .foregroundStyle(p.textTertiary) } .padding(TX.Space.m) .background(p.surface, in: RoundedRectangle(cornerRadius: TX.Radius.card)) .overlay(RoundedRectangle(cornerRadius: TX.Radius.card).stroke(p.border, lineWidth: 1)) } .buttonStyle(.plain) } } /// 添加主机表单(深色样式)。 struct AddHostSheet: View { let onSave: (SavedHost) -> Void @EnvironmentObject var theme: TXThemeManager @Environment(\.dismiss) private var dismiss @State private var name = "" @State private var host = "" @State private var port = "22" @State private var username = "" @State private var password = "" @State private var useMosh = false private var p: TXPalette { theme.palette } private var canSave: Bool { !host.isEmpty && !username.isEmpty } var body: some View { NavigationStack { ZStack { p.bg.ignoresSafeArea() Form { Section { field("名称", text: $name, placeholder: host.isEmpty ? "我的服务器" : host) field("主机 / IP", text: $host) field("端口", text: $port, keyboard: .numberPad) field("用户名", text: $username) SecureField("密码", text: $password) .foregroundStyle(p.textPrimary) .listRowBackground(p.surface) Toggle("使用 mosh", isOn: $useMosh) .tint(p.accent) .foregroundStyle(p.textPrimary) .listRowBackground(p.surface) } } .scrollContentBackground(.hidden) } .navigationTitle("添加主机") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { Button("取消") { dismiss() }.tint(p.textSecondary) } ToolbarItem(placement: .confirmationAction) { Button("保存") { onSave(SavedHost( name: name.isEmpty ? host : name, host: host, port: Int(port) ?? 22, username: username, password: password, useMosh: useMosh)) dismiss() } .disabled(!canSave) .tint(p.accent) } } .toolbarBackground(p.bg, for: .navigationBar) .toolbarBackground(.visible, for: .navigationBar) } .tint(p.accent) } private func field(_ title: String, text: Binding, placeholder: String = "", keyboard: UIKeyboardType = .default) -> some View { TextField(placeholder.isEmpty ? title : placeholder, text: text) .keyboardType(keyboard) .autocorrectionDisabled() .textInputAutocapitalization(.never) .foregroundStyle(p.textPrimary) .listRowBackground(p.surface) } } /// 设置页占位(后续填充:终端主题/字体/工具栏/关于)。 struct SettingsStubView: View { @EnvironmentObject var theme: TXThemeManager @Environment(\.dismiss) private var dismiss private var p: TXPalette { theme.palette } var body: some View { NavigationStack { ZStack { p.bg.ignoresSafeArea() List { Section("主题(app 与终端统一)") { ForEach(TXPalette.all) { pal in Button { theme.apply(pal) } label: { themeRow(pal) } .buttonStyle(.plain) } } .listRowBackground(p.surface) Section("终端") { row("textformat", "字体与大小", value: "系统等宽") } .listRowBackground(p.surface) Section("关于") { row("info.circle", "terminalX", value: "0.0.1") } .listRowBackground(p.surface) } .scrollContentBackground(.hidden) .foregroundStyle(p.textPrimary) } .navigationTitle("设置") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .confirmationAction) { Button("完成") { dismiss() }.tint(p.accent) } } .toolbarBackground(p.bg, for: .navigationBar) .toolbarBackground(.visible, for: .navigationBar) } .tint(p.accent) } /// 主题行:色板预览(底/面/强调)+ 名称 + 选中勾。 private func themeRow(_ pal: TXPalette) -> some View { HStack(spacing: TX.Space.m) { ZStack { RoundedRectangle(cornerRadius: 7).fill(pal.bg) HStack(spacing: 3) { Circle().fill(pal.accent).frame(width: 7, height: 7) Circle().fill(pal.mosh).frame(width: 7, height: 7) } } .frame(width: 40, height: 28) .overlay(RoundedRectangle(cornerRadius: 7).stroke(p.border, lineWidth: 1)) Text(pal.name).foregroundStyle(p.textPrimary) Spacer() if pal.id == theme.palette.id { Image(systemName: "checkmark").foregroundStyle(p.accent).font(.system(size: 14, weight: .semibold)) } } } private func row(_ icon: String, _ title: String, value: String) -> some View { HStack { Image(systemName: icon).foregroundStyle(p.textSecondary).frame(width: 26) Text(title).foregroundStyle(p.textPrimary) Spacer() Text(value).foregroundStyle(p.textSecondary).font(.system(size: 14)) } } } struct TerminalScreen: View { @ObservedObject var model: SSHTerminalModel @EnvironmentObject var theme: TXThemeManager private var p: TXPalette { theme.palette } var body: some View { VStack(spacing: 0) { TerminalTopBar(model: model) Divider().overlay(p.border) Group { if let tmux = model.tmuxController { TmuxTabbedView(controller: tmux) // tmux -CC → 原生 tab } else { RawTerminalView(model: model) // 普通单终端 } } } .background(p.bg.ignoresSafeArea()) } } /// 终端顶部状态行:返回主屏 · 会话标题 · 状态提示 · 传输徽章(SSH/mosh)。 struct TerminalTopBar: View { @ObservedObject var model: SSHTerminalModel @EnvironmentObject var theme: TXThemeManager private var p: TXPalette { theme.palette } private var connected: Bool { model.phaseText == "已连接" || model.phaseText == "mosh 已连接" } var body: some View { HStack(spacing: TX.Space.s) { Button { model.close() } label: { Image(systemName: "chevron.left") .font(.system(size: 15, weight: .semibold)) .foregroundStyle(p.textSecondary) .frame(width: 34, height: 34) .background(p.surface, in: Circle()) } .buttonStyle(.plain) VStack(alignment: .leading, spacing: 1) { Text(model.connectionTitle.isEmpty ? "终端" : model.connectionTitle) .font(.system(size: 15, weight: .semibold)) .foregroundStyle(p.textPrimary) .lineLimit(1) if !connected { Text(model.phaseText) .font(.system(size: 11)) .foregroundStyle(model.phaseText.hasPrefix("失败") ? .red : p.textSecondary) .lineLimit(1) } } Spacer() transportBadge } .padding(.horizontal, TX.Space.m) .padding(.vertical, TX.Space.s) .background(p.bg) } private var transportBadge: some View { let isMosh = model.moshEngaged let color = isMosh ? p.mosh : p.accent return HStack(spacing: 5) { Circle().fill(connected ? color : p.offline).frame(width: 6, height: 6) Text(isMosh ? "mosh" : "SSH") .font(TX.Font.mono(11, .medium)) .foregroundStyle(color) } .padding(.horizontal, 9).padding(.vertical, 5) .background(color.opacity(0.13), in: Capsule()) } } /// 普通(非 tmux)单终端视图。 struct RawTerminalView: View { @ObservedObject var model: SSHTerminalModel @FocusState private var focused: Bool var body: some View { TerminalSurfaceView(context: model.state) .terminalFocusOnAppear($focused) .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 @EnvironmentObject var theme: TXThemeManager @Environment(\.displayScale) private var displayScale private var p: TXPalette { theme.palette } var body: some View { VStack(spacing: 0) { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: TX.Space.xs) { ForEach(controller.windows) { win in TmuxTabChip(window: win, isActive: win.id == controller.activeWindowID) { controller.selectWindow(win.id) } } Button { controller.newWindow() } label: { Image(systemName: "plus") .font(.system(size: 14, weight: .semibold)) .foregroundStyle(p.textSecondary) .padding(.horizontal, 8).padding(.vertical, 6) } } .padding(.horizontal, TX.Space.s).padding(.vertical, TX.Space.xs) } .background(p.bg) Divider().overlay(p.border) Group { if controller.windows.isEmpty { Spacer(); Text("无 tmux 窗口").foregroundStyle(p.textSecondary); Spacer() } else { // 所有窗口常驻挂载、仅激活窗口可见:切 tab 不销毁其它窗口的 pane 视图, // 故 ghostty surface 不被释放、grid 内容保留(否则切回旧 tab 会空白), // 且后台窗口持续接收 %output 保持实时。非激活窗口关掉命中测试,避免吞触摸。 ZStack { ForEach(controller.windows) { win in TmuxWindowView(window: win, controller: controller) .opacity(win.id == controller.activeWindowID ? 1 : 0) .allowsHitTesting(win.id == controller.activeWindowID) } } } } .frame(maxWidth: .infinity, maxHeight: .infinity) // 容器几何测量放在这一层(windows 为空时同样占满剩余空间、尺寸相同): // pane grid 由容器像素决定,须在 attach 前拿到真实容器格子;放 TmuxWindowView 会与 // "windows 靠 list-windows 才有" 形成鸡生蛋死锁(见 TmuxController.kickoffIfReady)。 .background(GeometryReader { geo in Color.clear .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) } }) } } } struct TmuxTabChip: View { @ObservedObject var window: TmuxWindow let isActive: Bool let onTap: () -> Void @EnvironmentObject var theme: TXThemeManager private var p: TXPalette { theme.palette } var body: some View { Button(action: onTap) { Text(window.title) .font(TX.Font.mono(13, isActive ? .semibold : .regular)).lineLimit(1) .foregroundStyle(isActive ? p.accent : p.textSecondary) .padding(.horizontal, 12).padding(.vertical, 6) .background(isActive ? p.accentSoft : p.surface, in: RoundedRectangle(cornerRadius: TX.Radius.chip)) .overlay(RoundedRectangle(cornerRadius: TX.Radius.chip) .stroke(isActive ? p.accent.opacity(0.4) : p.border, lineWidth: 1)) } .buttonStyle(.plain) } } /// 一个 window:按 tmux 可见布局树把多 pane 绝对定位为分屏(每 pane 一 surface)。 struct TmuxWindowView: View { @ObservedObject var window: TmuxWindow let controller: TmuxController 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 } } // 容器尺寸测量已上移到 TmuxTabbedView(单一来源),此处不再驱动 containerResized。 } } /// 收集布局树叶子(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() } } }