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

@@ -66,7 +66,7 @@ sleep N; xcrun simctl io $UDID screenshot /tmp/x.png # 然后用 Read 工具
## 仓库结构
- `packages/TXCore` — 零依赖纯逻辑tmux 解析器/layout/`SessionMachine`(会话状态机+退避重连)/`TmuxControlSequence`(DCS 检测)。
- `packages/TXTransport``SSHSession`(libssh2, 支持外部 fd)/`MoshSession`(mosh_main 桥)/`Transport` 协议C shim: `CSSH`+`CSSHCore`(libssh2)、`CMosh`+`MoshCore`(mosh)。
- `apps/TerminalX/iOS``SSHTerminalModel`(连接编排+tsnet+tmux 路由+mosh 编排)/`ContentView`/`TmuxController`(tmux 网关)/`TsnetProbe`(M1 自检)。
- `apps/TerminalX/iOS``SSHTerminalModel`(连接编排+tsnet+tmux 路由+mosh 编排)/`ContentView`(HomeView 主机管理页+TerminalScreen 终端 chrome+设置)/`TmuxController`(tmux 网关)/`TsnetProbe`(M1 自检)/`Theme`(TXPalette+TXThemeManager 设计令牌,颜色封装供主题系统)/`HostStore`(主机列表 JSON 持久化)。UI 对标 Moshi(深蓝黑+终端绿+Catppuccin),见 `docs/HANDOFF.md §10`
- `vendor/` — 所有非 Swift 依赖本地化:`libghostty-spm`(GhosttyKit)/`MSDisplayLink`/`libssh2`/`mbedtls`/`tsnet-bridge`(Go)/`mosh`(blinksh/ios 分支)/`protobuf`(3.21.12)`build/mosh/`(手写 config.h+CMakeLists)。
- `artifacts/` — 预编译 xcframework**有意提交**,非 gitignore`CSSHCore`/`TsnetBridge`/`MoshCore`

View File

@@ -15,6 +15,7 @@
- **M3** mosh 挂起→恢复 <3s唤醒脉冲 + 会话状态机三相)✅
- **M4** 安全host key TOFU pinMITM 防护+ publickey 认证SE 优先/软件回退)✅
- **M5** 合规提审 暂缓
- **UI 产品化进行中** 对标 Moshi深蓝黑 + 终端绿 + Catppuccin主机管理页/终端 chrome/统一主题切换(app+终端同源4 Catppuccin) ✅;会话缩略卡/设置细化待续 `docs/HANDOFF.md §10`
四大核心需求libghostty / tailscale / tmux / mosh均已端到端验证
@@ -26,8 +27,9 @@ packages/
MoshConnect(解析)、SSH(线格式/ECDSA/host key)。50 单测。
TXTransport SSHSession(libssh2)、MoshSession(mosh_main 桥)、Transport 协议;
C shim: CSSH+CSSHCore(libssh2)、CMosh+MoshCore(mosh)。
apps/TerminalX iOS appXcodeGen 生成SSHTerminalModel(编排)/ContentView/
TmuxController(pane-per-surface 网关)/SigningKeyProvider/KeychainKnownHostsStore。
apps/TerminalX iOS appXcodeGen 生成SSHTerminalModel(编排)/ContentView(主机管理页+
终端 chrome+设置)/TmuxController(pane-per-surface 网关)/Theme(TXPalette+
主题管理)/HostStore(主机持久化)/SigningKeyProvider/KeychainKnownHostsStore。
vendor/ 非 Swift 依赖本地化(认证代理不支持 SwiftPM全 vendor
libghostty-spm、libssh2、mbedtls、tsnet-bridge(Go)、mosh、protobuf。
artifacts/ 预编译 xcframeworkCSSHCore/TsnetBridge/MoshCore**有意提交**,克隆即可构建)。

View File

@@ -1,25 +1,32 @@
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 +
// -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 {
} else if model.showsTerminal {
TerminalScreen(model: model)
} else {
ConnectionForm(model: model)
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 {
@@ -39,56 +46,328 @@ struct ContentView: View {
}
}
}
/// 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
}
/// M0 SSH tsnet
struct ConnectionForm: View {
@ObservedObject var model: SSHTerminalModel
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("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)
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)
}
}
.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))
}
}
}
.navigationTitle("terminalX")
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 {
@@ -96,6 +375,62 @@ struct TerminalScreen: View {
}
}
}
.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 {
@@ -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)
}
.background(.thinMaterial)
Divider()
if let win = controller.activeWindow {
TmuxWindowView(window: win, controller: controller)
.id(win.id.raw)
} else {
Spacer(); Text("无 tmux 窗口").foregroundStyle(.secondary); Spacer()
}
.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)
}
})
}
}
}
@@ -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
}
}

View File

@@ -0,0 +1,58 @@
import Foundation
///
/// `password` JSON demo Keychain SecItem HANDOFF
struct SavedHost: Identifiable, Codable, Equatable {
var id: UUID = UUID()
var name: String
var host: String
var port: Int = 22
var username: String
var password: String = ""
/// mosh
var useMosh: Bool = false
var addressLine: String { "\(username)@\(host):\(port)" }
}
/// Application Support JSON
@MainActor
final class HostStore: ObservableObject {
@Published private(set) var hosts: [SavedHost] = []
private let url: URL
init() {
let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
try? FileManager.default.createDirectory(at: base, withIntermediateDirectories: true)
url = base.appendingPathComponent("hosts.json")
load()
}
func add(_ host: SavedHost) {
hosts.append(host)
save()
}
func update(_ host: SavedHost) {
guard let i = hosts.firstIndex(where: { $0.id == host.id }) else { return }
hosts[i] = host
save()
}
func remove(_ host: SavedHost) {
hosts.removeAll { $0.id == host.id }
save()
}
private func load() {
guard let data = try? Data(contentsOf: url),
let decoded = try? JSONDecoder().decode([SavedHost].self, from: data) else { return }
hosts = decoded
}
private func save() {
guard let data = try? JSONEncoder().encode(hosts) else { return }
try? data.write(to: url, options: .atomic)
}
}

View File

@@ -167,6 +167,10 @@ final class SSHTerminalModel: ObservableObject {
@Published var tmuxController: TmuxController?
/// M4host key pin nil SwiftUI alert /
@Published var hostKeyMismatch: HostKeyMismatchInfo?
/// /
@Published var connectionTitle: String = ""
/// mosh SSH/mosh
@Published var moshEngaged: Bool = false
private let knownHosts = KeychainKnownHostsStore()
@@ -219,6 +223,7 @@ final class SSHTerminalModel: ObservableObject {
)
let state = TerminalViewState()
state.configuration = TerminalSurfaceOptions(backend: .inMemory(session))
_ = state.controller.setTheme(.txDefault) // Catppuccin Mocha
self.holder = holder
self.moshHolder = moshHolder
self.session = session
@@ -243,7 +248,16 @@ final class SSHTerminalModel: ObservableObject {
/// M4b nil publickey(SE/)
private var pubkeySigner: SigningKeyProvider?
/// mosh
func connect(to h: SavedHost) {
moshMode = h.useMosh
connect(host: h.host, port: h.port, username: h.username, password: h.password)
connectionTitle = h.name
}
func connect(host: String, port: Int, username: String, password: String) {
connectionTitle = host
moshEngaged = false
let egress = tsnetAuthKey != nil ? "tsnet" : "direct"
let auth: SSHConfig.Authentication = pubkeySigner.map { .publicKeyCallback($0) } ?? .password(password)
config = SSHConfig(host: host, port: port, username: username,
@@ -289,6 +303,12 @@ final class SSHTerminalModel: ObservableObject {
///
func dismissHostKeyMismatch() { hostKeyMismatch = nil }
/// raw surface tmux pane TerminalTheme.txDefault
func applyTerminalTheme(_ t: TerminalTheme) {
_ = state.controller.setTheme(t)
tmuxController?.applyTerminalTheme(t)
}
func close() { run(machine.reduce(.closeRequested)) }
func enterBackground() {
if moshMode { beginBgTask() } // ~30s mosh 3s ack NAT/WG/DERP
@@ -499,6 +519,7 @@ final class SSHTerminalModel: ObservableObject {
moshSession = mosh
moshHolder.session = mosh // /resize mosh
mosh.start()
moshEngaged = true
NSLog("MOSHDBG activateMosh ip=\(ip) port=\(port) started")
// mosh idle SSHmoshActive transportClosed
run(machine.reduce(.moshEstablished))
@@ -557,6 +578,7 @@ final class SSHTerminalModel: ObservableObject {
/// mosh / mosh-server
private func teardownMosh() {
cancelResumeWatchdog()
moshEngaged = false
moshHolder.session = nil
moshSession?.close(); moshSession = nil
try? moshRelay?.close(); moshRelay = nil

View File

@@ -0,0 +1,153 @@
import SwiftUI
import GhosttyTerminal
import GhosttyTheme
/// **app UI** ****`ghosttyName` GhosttyTheme
/// app = `static let`
struct TXPalette: Equatable, Identifiable {
let id: String
let name: String
let isDark: Bool
/// GhosttyTheme app Catppuccin flavor
let ghosttyName: String
let bg: Color
let surface: Color
let surfaceElevated: Color
let accent: Color
let accentSoft: Color
let textPrimary: Color
let textSecondary: Color
let textTertiary: Color
let border: Color
let borderStrong: Color
let running: Color
let mosh: Color
let warning: Color
let offline: Color
/// GhosttyTheme TerminalTheme退
var terminalTheme: TerminalTheme {
GhosttyThemeCatalog.theme(named: ghosttyName)?.toTerminalTheme() ?? .default
}
}
extension TXPalette {
/// Catppuccin flavor paletteapp
private static func catppuccin(
id: String, name: String, ghostty: String, isDark: Bool,
base: UInt32, surface0: UInt32, surface1: UInt32,
text: UInt32, subtext0: UInt32, overlay0: UInt32,
green: UInt32, teal: UInt32, yellow: UInt32
) -> TXPalette {
TXPalette(
id: id, name: name, isDark: isDark, ghosttyName: ghostty,
bg: Color(hex: base),
surface: Color(hex: surface0),
surfaceElevated: Color(hex: surface1),
accent: Color(hex: green),
accentSoft: Color(hex: green).opacity(0.16),
textPrimary: Color(hex: text),
textSecondary: Color(hex: subtext0),
textTertiary: Color(hex: overlay0),
border: (isDark ? Color.white : Color.black).opacity(0.08),
borderStrong: (isDark ? Color.white : Color.black).opacity(0.14),
running: Color(hex: green),
mosh: Color(hex: teal),
warning: Color(hex: yellow),
offline: Color(hex: overlay0)
)
}
static let catppuccinMocha = catppuccin(
id: "catppuccin-mocha", name: "Catppuccin Mocha", ghostty: "Catppuccin Mocha", isDark: true,
base: 0x1E1E2E, surface0: 0x313244, surface1: 0x45475A,
text: 0xCDD6F4, subtext0: 0xA6ADC8, overlay0: 0x6C7086,
green: 0xA6E3A1, teal: 0x94E2D5, yellow: 0xF9E2AF)
static let catppuccinMacchiato = catppuccin(
id: "catppuccin-macchiato", name: "Catppuccin Macchiato", ghostty: "Catppuccin Macchiato", isDark: true,
base: 0x24273A, surface0: 0x363A4F, surface1: 0x494D64,
text: 0xCAD3F5, subtext0: 0xA5ADCB, overlay0: 0x6E738D,
green: 0xA6DA95, teal: 0x8BD5CA, yellow: 0xEED49F)
static let catppuccinFrappe = catppuccin(
id: "catppuccin-frappe", name: "Catppuccin Frappé", ghostty: "Catppuccin Frappe", isDark: true,
base: 0x303446, surface0: 0x414559, surface1: 0x51576D,
text: 0xC6D0F5, subtext0: 0xA5ADCE, overlay0: 0x737994,
green: 0xA6D189, teal: 0x81C8BE, yellow: 0xE5C890)
static let catppuccinLatte = catppuccin(
id: "catppuccin-latte", name: "Catppuccin Latte", ghostty: "Catppuccin Latte", isDark: false,
base: 0xEFF1F5, surface0: 0xCCD0DA, surface1: 0xBCC0CC,
text: 0x4C4F69, subtext0: 0x6C6F85, overlay0: 0x9CA0B0,
green: 0x40A02B, teal: 0x179299, yellow: 0xDF8E1D)
///
static let all: [TXPalette] = [catppuccinMocha, catppuccinMacchiato, catppuccinFrappe, catppuccinLatte]
static let `default` = catppuccinMocha
static func byID(_ id: String?) -> TXPalette {
all.first { $0.id == id } ?? .default
}
}
/// app + UserDefaults `TerminalTheme.txDefault`
/// nonisolated app
let txThemeStorageKey = "txThemeID"
@MainActor
final class TXThemeManager: ObservableObject {
@Published var palette: TXPalette
init() {
palette = TXPalette.byID(UserDefaults.standard.string(forKey: txThemeStorageKey))
}
/// + app ContentView palette.id
func apply(_ palette: TXPalette) {
self.palette = palette
UserDefaults.standard.set(palette.id, forKey: txThemeStorageKey)
}
}
extension TerminalTheme {
/// app model / tmux pane
static var txDefault: TerminalTheme {
TXPalette.byID(UserDefaults.standard.string(forKey: txThemeStorageKey)).terminalTheme
}
}
/// //
enum TX {
enum Radius {
static let card: CGFloat = 16
static let chip: CGFloat = 10
static let field: CGFloat = 12
}
enum Space {
static let xs: CGFloat = 6
static let s: CGFloat = 10
static let m: CGFloat = 16
static let l: CGFloat = 22
static let xl: CGFloat = 32
}
enum Font {
static func mono(_ size: CGFloat, _ weight: SwiftUI.Font.Weight = .regular) -> SwiftUI.Font {
.system(size: size, weight: weight, design: .monospaced)
}
}
}
extension Color {
/// 0xRRGGBB
init(hex: UInt32) {
self.init(
.sRGB,
red: Double((hex >> 16) & 0xFF) / 255,
green: Double((hex >> 8) & 0xFF) / 255,
blue: Double(hex & 0xFF) / 255,
opacity: 1
)
}
}

View File

@@ -26,6 +26,7 @@ final class TmuxPaneSurface: ObservableObject, Identifiable {
)
let state = TerminalViewState()
state.configuration = TerminalSurfaceOptions(backend: .inMemory(session))
_ = state.controller.setTheme(.txDefault) // Catppuccin Mocha
self.session = session
self.state = state
self.gate = OutputGate(session: session)
@@ -72,10 +73,28 @@ final class TmuxController: ObservableObject {
private enum PendingKind { case ignore, listWindows, capturePane(TmuxPaneID) }
private var pending: [PendingKind] = []
// attach kickoffrefresh-client + list-windows + pane capture""
// pane ghostty grid tab raw
// raw attach+capture > pane grid /
// kickoff attach
private var containerKnown = false
private var kickoffDone = false
init(sendRaw: @escaping @Sendable (Data) -> Void) {
self.sendRaw = sendRaw
}
/// attach attach refresh-client
private func kickoffIfReady() {
guard attachAcked, containerKnown, !kickoffDone else { return }
kickoffDone = true
NSLog("TMUXDBG kickoff refresh-client -C \(clientCols)x\(clientRows)")
sendCommand("refresh-client -C \(clientCols)x\(clientRows)") //
pending.append(.ignore)
sendCommand("list-windows -F \"#{window_id}\t#{window_name}\t#{window_active}\t#{window_layout}\t#{window_visible_layout}\"")
pending.append(.listWindows)
}
/// model attach tmux refresh-client -C
func setClientSize(cols: Int, rows: Int) { clientCols = max(1, cols); clientRows = max(1, rows) }
@@ -94,10 +113,13 @@ final class TmuxController: ObservableObject {
lastSentCols = cols; lastSentRows = rows
clientCols = cols; clientRows = rows
NSLog("TMUXDBG containerResized pt=\(Int(widthPt))x\(Int(heightPt)) scale=\(scale) cell=\(cellWpx)x\(cellHpx) -> \(cols)x\(rows)")
// attach kickoff debounce attach client ==
containerKnown = true
if !kickoffDone { kickoffIfReady(); return }
resizeDebounce?.cancel()
resizeDebounce = Task { [weak self] in
try? await Task.sleep(nanoseconds: 200_000_000) //
guard !Task.isCancelled, let self, self.attachAcked else { return }
guard !Task.isCancelled, let self, self.kickoffDone else { return }
NSLog("TMUXDBG refresh-client -C \(cols)x\(rows)")
self.sendCommand("refresh-client -C \(cols)x\(rows)") // tmux %layout-change frame
}
@@ -203,11 +225,9 @@ final class TmuxController: ObservableObject {
private func handleCommandResponse(_ r: TmuxCommandResponse) {
if !attachAcked {
attachAcked = true
// tmux layout
sendCommand("refresh-client -C \(clientCols)x\(clientRows)")
pending.append(.ignore)
sendCommand("list-windows -F \"#{window_id}\t#{window_name}\t#{window_active}\t#{window_layout}\t#{window_visible_layout}\"")
pending.append(.listWindows)
// kickoff kickoffIfReady raw attach
// > pane grid
kickoffIfReady()
return
}
guard !pending.isEmpty else { return }
@@ -224,7 +244,12 @@ final class TmuxController: ObservableObject {
applyLayout(window: w, full: String(parts[3]), visible: String(parts[4]))
}
case .capturePane(let p):
let snapshot = r.lines.joined(separator: "\r\n")
// capture-pane pane > pane grid
// ESC[H + N-1 \r\n/
// ESC[H grid trim
var lines = r.lines
while lines.last?.isEmpty == true { lines.removeLast() }
let snapshot = lines.joined(separator: "\r\n")
if let s = surfaceByPane[p] {
if !snapshot.isEmpty {
//
@@ -284,6 +309,11 @@ final class TmuxController: ObservableObject {
/// pane surface
func markPaneReady(_ p: TmuxPaneID) { surfaceByPane[p]?.gate.markReady() }
/// pane surface
func applyTerminalTheme(_ t: TerminalTheme) {
for s in surfaceByPane.values { _ = s.state.controller.setTheme(t) }
}
}
/// `tmux -CC` DCS tmux

View File

@@ -7,6 +7,7 @@
## 0. 一句话现状
一个**原生 iPad SSH 终端**已可用并真机验证libghostty 渲染 + 键盘输入 + SSH 直连 + **SSH-over-tsnet(Tailscale 用户态)** + **tmux -CC 原生 tab + 多 pane 分屏** + **mosh(LAN + over-tsnet 均已端到端验证)** + 断线自动重连 + **mosh 挂起→恢复<3s(M3)**。四大核心需求libghostty/tailscale/tmux/**mosh 均已验证**)。仅在 iOS 模拟器验证(未上真机/未签名)。
> **2026-07-24 本轮**:修复 3 个渲染/tmux 显示 bug + 完成 UI 产品化第一波(对标 Moshi。详见 **§10**。UI 已从 demo 态变为深色 Catppuccin 主题 + 主机管理页 + 终端 chrome + 统一主题切换。
> **mosh 状态2026-07-24 全部验证通过)**mosh(C++,blinksh/ios)+protobuf-lite 交叉编译成 `MoshCore.xcframework`CommonCrypto 后端,含 arm64 模拟器 slicetsnet 桥加 UDP relay(`StartMoshRelay`)`MoshSession`(pipe↔FILE*/pthread/SIGWINCH)+`MoshConnectScanner`(TXCore)+`SSHTerminalModel` 编排SSH 引导 mosh-server→解析 MOSH CONNECT→接管
> - **LAN 直连端到端验证**192.168.9.199mosh-server 存活 104s≫60s 无客户端超时+子 shell+心跳=真连。
> - **mosh over tsnet 端到端验证**vohive-vm 100.69.201.101app 日志 `parsed MOSH CONNECT port=60002`→`relay up localPort=63542`→`activateMosh started`;屏幕渲染远端 shell 且无 mosh 断连 overlay=relay UDP 双向流通。**R1(tsnet UDP 数据报语义) 证伪**。
@@ -96,4 +97,22 @@
- **绝不臆想工具结果**调用后等真实返回再推理绝不编造 BUILD/日志/截图曾因连续编造工具输出含假 BUILD SUCCEEDED错误归因 GhosttyKit而误诊——真相全靠老实 `Read` 完整 log此规则已写入全局 `~/.claude/CLAUDE.md`
- 每步用真实证据验证`swift test` 看真实计数`xcodebuild` 看真实 BUILD 结果模拟器 `screenshot` + `Read` 看真实画面
- 与用户用**简体中文**commit/push 只在明确要求时
## 10. UI 产品化 + 渲染修复2026-07-24 本轮)
### 10.1 修复的 3 个渲染/tmux 显示 buge2e 复现+修复+回归,均模拟器 idb 点击+截图取证)
1. **终端底部残影**旧帧钉屏底ghostty iOS `addSublayer:` IOSurfaceLayer `Metal.deinit` 从不 `removeFromSuperlayer`上游 iOS-only 缺陷SwiftUI attach 抖动导致同一 view 多次建 surface 时旧层泄漏冻结小尺寸首帧 CA `topLeft` gravity 钉在屏底vendor `UITerminalView.platformSetup` surface 前清现存子层 + `TerminalSurfaceCoordinator.onSurfaceFreed` 钩子`tearDownSurface` 后回调 `commonInit` 里摘子层。(诊断经 Fable 顾问在 ghostty 源码层锚定。)
2. **tmux 切 tab 切回旧 tab 内容消失**`TmuxTabbedView` 原只渲 activeWindow `.id(win.id)`切走销毁 pane 视图释放 surface内容丢失改成**所有窗口常驻挂载仅激活可见**`ZStack`+`opacity`+`allowsHitTesting`surface 不释放内容保留后台窗口持续收 %output
3. **tmux pane 内容钉底/顶部残行**pane ghostty grid 由容器像素几何决定tab 条吃高度73 attach raw 全屏 75 capture75 行快照喂 73 grid 溢出上滚(A) 容器几何测量上移到 `TmuxTabbedView` 内容槽破鸡生蛋死锁(B) `TmuxController` attach kickoff `attachAcked ∧ containerKnown` 双条件`kickoffIfReady`(C) `capture-pane` 铺快照前剔尾部空行
### 10.2 UI 产品化(对标 Moshi getmoshi.app深蓝黑底 + 终端绿 + 圆角卡片)
- **设计系统颜色封装为主题系统**`apps/TerminalX/iOS/Theme.swift``TXPalette`(所有色角色 struct)+`TXThemeManager:ObservableObject`(环境注入)结构量走 `enum TX`(圆角/间距/等宽字体)。
- **统一主题** GhosttyTheme 抽离 4 Catppuccin(Mocha/Macchiato/Frappé/Latte)每套同时定义 app 配色+终端配色(`ghosttyName``terminalTheme`)。设置里选主题 **app 与终端一起变色 + 持久化**(`UserDefaults(txThemeStorageKey)`)`ContentView` `.onChange(palette.id)→model.applyTerminalTheme()`新终端/pane `TerminalTheme.txDefault` 读持久化
- **主屏/主机管理页**(`ContentView.HomeView`)主机卡(`HostCard`)+FAB 加主机(`AddHostSheet`)+设置(`SettingsStubView` 含主题选择器)`HostStore`(Application Support/hosts.json 持久化密码暂存本地/TODO Keychain)点卡片 `SSHTerminalModel.connect(to:)`
- **终端外壳 chrome**(`ContentView.TerminalScreen`+`TerminalTopBar`)顶栏(圆形返回钮+会话标题`model.connectionTitle`+传输徽章`model.moshEngaged`?mosh青:SSH绿+连接态点+状态提示)`TmuxTabChip` 绿 pill 底部 GhosttyKit 工具栏深色自适应终端默认 Catppuccin Mocha
- **验证**iPad mini(A17 Pro) live 验证全流程加主机连接终端 tab分屏点焦点主题切换 app+终端同步)。
### 10.3 待续UI
- 会话缩略卡区Moshi 首屏实时终端预览横滑需会话管理设置页字体/工具栏配置导航壳打磨终端工具栏 accent tint`inputAccessoryStyle` SwiftUI 未暴露需桥接)。
- 无头 UI 测试`idb ui tap x y`Python3.14 需事件循环 shim `/tmp/idbrun.py`坐标=屏幕像素×0.5,注意设备朝向)。诊断埋点(GKDBG/txDbg*)已清理MOSHDBG/TMUXDBG/M4DBG 仍在( cleanup-todo)。
</content>

View File

@@ -112,6 +112,15 @@
}
core.platformSetup = { [weak self] config in
guard let self else { return }
// ghostty `ghostty_surface_new` IOSurfaceLayer
// view.layer surface free detach/reattach
// rebuild
// surface free
// ghostty view ghostty
if let stale = self.layer.sublayers, !stale.isEmpty {
TerminalDebugLog.log(.lifecycle, "removing stale sublayers count=\(stale.count)")
for sub in stale { sub.removeFromSuperlayer() }
}
config.platform_tag = GHOSTTY_PLATFORM_IOS
config.platform = ghostty_platform_u(
ios: ghostty_platform_ios_s(
@@ -128,6 +137,13 @@
core.onPostRender = { [weak self] in
self?.enforceSublayerScale()
}
core.onSurfaceFreed = { [weak self] in
// ghostty(iOS) surface teardown
// createSurface teardown/ view ghostty
guard let sublayers = self?.layer.sublayers, !sublayers.isEmpty else { return }
TerminalDebugLog.log(.lifecycle, "onSurfaceFreed removing sublayers count=\(sublayers.count)")
for sub in sublayers { sub.removeFromSuperlayer() }
}
setupApplicationLifecycleObservers()
syncApplicationActiveState()

View File

@@ -45,6 +45,10 @@ final class TerminalSurfaceCoordinator {
var platformSetup: ((inout ghostty_surface_config_s) -> Void)?
var onMetricsUpdate: (() -> Void)?
var onCellSizeDidChange: (() -> Void)?
/// iOS surface ghostty iOS addSublayer IOSurfaceLayer
/// removeFromSuperlayer CA topLeft gravity
/// teardown
var onSurfaceFreed: (() -> Void)?
/// Called after every display-link render (`tick`).
///
@@ -328,6 +332,7 @@ final class TerminalSurfaceCoordinator {
if hadSurface {
(delegate as? any TerminalSurfaceLifecycleDelegate)?
.terminalDidDetachSurface()
onSurfaceFreed?() // iOS ghostty IOSurfaceLayer
}
}