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>
This commit is contained in:
kid
2026-07-24 22:43:00 +08:00
parent d4ab55db48
commit 6dd23275ff
10 changed files with 754 additions and 96 deletions

View File

@@ -1,99 +1,434 @@
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 {
// M1 -txTsnetKey -txHost tsnet +
// -txHostmodel 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)
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)
}
}
}
.onAppear { model.autoConnectIfConfigured() }
.onChange(of: scenePhase) { _, phase in
switch phase {
case .background: model.enterBackground()
case .active: model.enterForeground()
default: break
.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)
}
}
.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)")
.padding(TX.Space.l)
}
}
}
}
/// M0 SSH tsnet
struct ConnectionForm: View {
@ObservedObject var model: SSHTerminalModel
/// + / / 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 {
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)
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)
}
.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)
.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)
}
}
.navigationTitle("terminalX")
.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 {
if let tmux = model.tmuxController {
TmuxTabbedView(controller: tmux) // tmux -CC tab
} else {
RawTerminalView(model: model) //
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())
}
}
@@ -105,15 +440,6 @@ struct RawTerminalView: View {
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)
@@ -126,11 +452,14 @@ struct RawTerminalView: View {
/// 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: 6) {
HStack(spacing: TX.Space.xs) {
ForEach(controller.windows) { win in
TmuxTabChip(window: win,
isActive: win.id == controller.activeWindowID) {
@@ -138,19 +467,43 @@ struct TmuxTabbedView: View {
}
}
Button { controller.newWindow() } label: {
Image(systemName: "plus").padding(.horizontal, 6)
Image(systemName: "plus")
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(p.textSecondary)
.padding(.horizontal, 8).padding(.vertical, 6)
}
}
.padding(.horizontal, 8).padding(.vertical, 6)
.padding(.horizontal, TX.Space.s).padding(.vertical, TX.Space.xs)
}
.background(.thinMaterial)
Divider()
if let win = controller.activeWindow {
TmuxWindowView(window: win, controller: controller)
.id(win.id.raw)
} else {
Spacer(); Text("无 tmux 窗口").foregroundStyle(.secondary); Spacer()
.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)
}
})
}
}
}
@@ -159,14 +512,19 @@ 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(.caption).lineLimit(1)
.font(TX.Font.mono(13, isActive ? .semibold : .regular)).lineLimit(1)
.foregroundStyle(isActive ? p.accent : p.textSecondary)
.padding(.horizontal, 12).padding(.vertical, 6)
.background(isActive ? Color.accentColor.opacity(0.25) : Color.gray.opacity(0.12),
in: RoundedRectangle(cornerRadius: 8))
.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)
}
@@ -176,7 +534,6 @@ struct TmuxTabChip: View {
struct TmuxWindowView: View {
@ObservedObject var window: TmuxWindow
let controller: TmuxController
@Environment(\.displayScale) private var displayScale
var body: some View {
GeometryReader { geo in
@@ -205,11 +562,7 @@ struct TmuxWindowView: View {
Color.clear
}
}
// iPadOS refresh-client -Ccontroller 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)
}
// TmuxTabbedView containerResized
}
}