diff --git a/CLAUDE.md b/CLAUDE.md index 399f315..5b3ad27 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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`。 diff --git a/README.md b/README.md index 8f3bf5c..49b069c 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ - **M3** mosh 挂起→恢复 <3s(唤醒脉冲 + 会话状态机三相)✅ - **M4** 安全:host key TOFU pin(MITM 防护)+ 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 app(XcodeGen 生成):SSHTerminalModel(编排)/ContentView/ - TmuxController(pane-per-surface 网关)/SigningKeyProvider/KeychainKnownHostsStore。 +apps/TerminalX iOS app(XcodeGen 生成):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/ 预编译 xcframework(CSSHCore/TsnetBridge/MoshCore,**有意提交**,克隆即可构建)。 diff --git a/apps/TerminalX/iOS/ContentView.swift b/apps/TerminalX/iOS/ContentView.swift index 22ea4a6..43debc4 100644 --- a/apps/TerminalX/iOS/ContentView.swift +++ b/apps/TerminalX/iOS/ContentView.swift @@ -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 加入+对端枚举自检。 - // 若同时有 -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) + 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, + 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-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: 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 -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) - } + // 容器尺寸测量已上移到 TmuxTabbedView(单一来源),此处不再驱动 containerResized。 } } diff --git a/apps/TerminalX/iOS/HostStore.swift b/apps/TerminalX/iOS/HostStore.swift new file mode 100644 index 0000000..a13a2db --- /dev/null +++ b/apps/TerminalX/iOS/HostStore.swift @@ -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) + } +} diff --git a/apps/TerminalX/iOS/SSHTerminalModel.swift b/apps/TerminalX/iOS/SSHTerminalModel.swift index 0b55731..4377b91 100644 --- a/apps/TerminalX/iOS/SSHTerminalModel.swift +++ b/apps/TerminalX/iOS/SSHTerminalModel.swift @@ -167,6 +167,10 @@ final class SSHTerminalModel: ObservableObject { @Published var tmuxController: TmuxController? /// M4:host 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 SSH(moshActive 之后忽略其 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 diff --git a/apps/TerminalX/iOS/Theme.swift b/apps/TerminalX/iOS/Theme.swift new file mode 100644 index 0000000..d73f35d --- /dev/null +++ b/apps/TerminalX/iOS/Theme.swift @@ -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 的核心色构造统一 palette(app 与终端同源)。 + 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 + ) + } +} diff --git a/apps/TerminalX/iOS/TmuxController.swift b/apps/TerminalX/iOS/TmuxController.swift index ed55e8b..0d3173c 100644 --- a/apps/TerminalX/iOS/TmuxController.swift +++ b/apps/TerminalX/iOS/TmuxController.swift @@ -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 kickoff(refresh-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 网关。 diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index 7e1a312..c950c8e 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -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 模拟器 slice);tsnet 桥加 UDP relay(`StartMoshRelay`);`MoshSession`(pipe↔FILE*/pthread/SIGWINCH)+`MoshConnectScanner`(TXCore)+`SSHTerminalModel` 编排(SSH 引导 mosh-server→解析 MOSH CONNECT→接管)。 > - **LAN 直连端到端验证**(192.168.9.199):mosh-server 存活 104s≫60s 无客户端超时+子 shell+心跳=真连。 > - **mosh over tsnet 端到端验证**(vohive-vm 100.69.201.101):app 日志 `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 显示 bug(e2e 复现+修复+回归,均模拟器 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 行 capture,75 行快照喂 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)。 diff --git a/vendor/libghostty-spm/Sources/GhosttyTerminal/Platform/UIKit/UITerminalView.swift b/vendor/libghostty-spm/Sources/GhosttyTerminal/Platform/UIKit/UITerminalView.swift index 7bb1e98..7e7f93e 100644 --- a/vendor/libghostty-spm/Sources/GhosttyTerminal/Platform/UIKit/UITerminalView.swift +++ b/vendor/libghostty-spm/Sources/GhosttyTerminal/Platform/UIKit/UITerminalView.swift @@ -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() diff --git a/vendor/libghostty-spm/Sources/GhosttyTerminal/Surface/TerminalSurfaceCoordinator.swift b/vendor/libghostty-spm/Sources/GhosttyTerminal/Surface/TerminalSurfaceCoordinator.swift index 1fb2cce..3acdec5 100644 --- a/vendor/libghostty-spm/Sources/GhosttyTerminal/Surface/TerminalSurfaceCoordinator.swift +++ b/vendor/libghostty-spm/Sources/GhosttyTerminal/Surface/TerminalSurfaceCoordinator.swift @@ -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 子层 } }