Files
terminalX/apps/TerminalX/iOS/ContentView.swift
kid 6dd23275ff feat: 修复 3 个渲染/tmux 显示 bug + UI 产品化第一波(对标 Moshi)
渲染/tmux 修复(e2e 复现+回归,模拟器 idb 点击+截图取证):
- 终端底部残影:ghostty iOS 释放 surface 不摘 IOSurfaceLayer(上游缺陷),
  platformSetup 清现存子层 + TerminalSurfaceCoordinator.onSurfaceFreed 钩子摘孤儿层
- tmux 切 tab 切回旧 tab 内容消失:改所有窗口常驻挂载(ZStack+opacity+hitTesting),
  surface 不释放、内容保留、后台窗口持续收 %output
- tmux pane 内容钉底/顶部残行:pane grid 由容器几何定(tab 条吃高度),75 行快照喂 73 行
  溢出上滚;几何测量上移 TmuxTabbedView + attach kickoff 双条件(attachAcked∧containerKnown)
  + capture-pane 铺快照前剔尾空行

UI 产品化(对标 Moshi getmoshi.app:深蓝黑 + 终端绿 + Catppuccin):
- 设计系统 Theme.swift:TXPalette(色角色 struct)+TXThemeManager(环境注入),颜色封装供主题系统
- 统一主题:抽离 4 套 Catppuccin(Mocha/Macchiato/Frappé/Latte),一套同时驱动 app+终端配色,
  设置里选主题 app 与终端一起变色 + UserDefaults 持久化
- 主屏/主机管理页 HomeView:HostCard 主机卡 + FAB 加主机(AddHostSheet) + 设置(主题选择器),
  HostStore 本地 JSON 持久化(密码暂存/TODO Keychain),点卡片 connect(to:)
- 终端外壳 chrome TerminalTopBar:圆形返回钮 + 会话标题 + SSH/mosh 传输徽章 + 状态提示,
  tmux tab 绿 pill 化,底部工具栏深色自适应,终端默认 Catppuccin Mocha

iPad mini(A17 Pro) live 端到端验证:加主机→连接→终端→切 tab→分屏点焦点→主题切换同步。
详见 docs/HANDOFF.md §10。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 22:43:00 +08:00

608 lines
24 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<String>,
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-modewindow 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 + rectidentity 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-catcherTerminalSurfaceView 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()
}
}
}