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:
@@ -66,7 +66,7 @@ sleep N; xcrun simctl io $UDID screenshot /tmp/x.png # 然后用 Read 工具
|
|||||||
## 仓库结构
|
## 仓库结构
|
||||||
- `packages/TXCore` — 零依赖纯逻辑:tmux 解析器/layout/`SessionMachine`(会话状态机+退避重连)/`TmuxControlSequence`(DCS 检测)。
|
- `packages/TXCore` — 零依赖纯逻辑:tmux 解析器/layout/`SessionMachine`(会话状态机+退避重连)/`TmuxControlSequence`(DCS 检测)。
|
||||||
- `packages/TXTransport` — `SSHSession`(libssh2, 支持外部 fd)/`MoshSession`(mosh_main 桥)/`Transport` 协议;C shim: `CSSH`+`CSSHCore`(libssh2)、`CMosh`+`MoshCore`(mosh)。
|
- `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)。
|
- `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`。
|
- `artifacts/` — 预编译 xcframework(**有意提交**,非 gitignore):`CSSHCore`/`TsnetBridge`/`MoshCore`。
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
- **M3** mosh 挂起→恢复 <3s(唤醒脉冲 + 会话状态机三相)✅
|
- **M3** mosh 挂起→恢复 <3s(唤醒脉冲 + 会话状态机三相)✅
|
||||||
- **M4** 安全:host key TOFU pin(MITM 防护)+ publickey 认证(SE 优先/软件回退)✅
|
- **M4** 安全:host key TOFU pin(MITM 防护)+ publickey 认证(SE 优先/软件回退)✅
|
||||||
- **M5** 合规提审 ⏸️ 暂缓
|
- **M5** 合规提审 ⏸️ 暂缓
|
||||||
|
- **UI 产品化(进行中)** 对标 Moshi:深蓝黑 + 终端绿 + Catppuccin;主机管理页/终端 chrome/统一主题切换(app+终端同源,4 套 Catppuccin) ✅;会话缩略卡/设置细化待续。见 `docs/HANDOFF.md §10`。
|
||||||
|
|
||||||
四大核心需求(libghostty / tailscale / tmux / mosh)均已端到端验证。
|
四大核心需求(libghostty / tailscale / tmux / mosh)均已端到端验证。
|
||||||
|
|
||||||
@@ -26,8 +27,9 @@ packages/
|
|||||||
MoshConnect(解析)、SSH(线格式/ECDSA/host key)。50 单测。
|
MoshConnect(解析)、SSH(线格式/ECDSA/host key)。50 单测。
|
||||||
TXTransport SSHSession(libssh2)、MoshSession(mosh_main 桥)、Transport 协议;
|
TXTransport SSHSession(libssh2)、MoshSession(mosh_main 桥)、Transport 协议;
|
||||||
C shim: CSSH+CSSHCore(libssh2)、CMosh+MoshCore(mosh)。
|
C shim: CSSH+CSSHCore(libssh2)、CMosh+MoshCore(mosh)。
|
||||||
apps/TerminalX iOS app(XcodeGen 生成):SSHTerminalModel(编排)/ContentView/
|
apps/TerminalX iOS app(XcodeGen 生成):SSHTerminalModel(编排)/ContentView(主机管理页+
|
||||||
TmuxController(pane-per-surface 网关)/SigningKeyProvider/KeychainKnownHostsStore。
|
终端 chrome+设置)/TmuxController(pane-per-surface 网关)/Theme(TXPalette+
|
||||||
|
主题管理)/HostStore(主机持久化)/SigningKeyProvider/KeychainKnownHostsStore。
|
||||||
vendor/ 非 Swift 依赖本地化(认证代理不支持 SwiftPM,全 vendor):
|
vendor/ 非 Swift 依赖本地化(认证代理不支持 SwiftPM,全 vendor):
|
||||||
libghostty-spm、libssh2、mbedtls、tsnet-bridge(Go)、mosh、protobuf。
|
libghostty-spm、libssh2、mbedtls、tsnet-bridge(Go)、mosh、protobuf。
|
||||||
artifacts/ 预编译 xcframework(CSSHCore/TsnetBridge/MoshCore,**有意提交**,克隆即可构建)。
|
artifacts/ 预编译 xcframework(CSSHCore/TsnetBridge/MoshCore,**有意提交**,克隆即可构建)。
|
||||||
|
|||||||
@@ -1,99 +1,434 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import UIKit
|
||||||
import GhosttyTerminal
|
import GhosttyTerminal
|
||||||
import TXCore
|
import TXCore
|
||||||
|
|
||||||
struct ContentView: View {
|
struct ContentView: View {
|
||||||
@StateObject private var model = SSHTerminalModel()
|
@StateObject private var model = SSHTerminalModel()
|
||||||
|
@StateObject private var hostStore = HostStore()
|
||||||
|
@StateObject private var theme = TXThemeManager()
|
||||||
@Environment(\.scenePhase) private var scenePhase
|
@Environment(\.scenePhase) private var scenePhase
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
// M1 纯自检分支:仅 -txTsnetKey(无 -txHost)时跑 tsnet 加入+对端枚举自检。
|
Group {
|
||||||
// 若同时有 -txHost,则走正常终端流程(model 经 tsnet fd 桥连接)。
|
// M1 纯自检分支:仅 -txTsnetKey(无 -txHost)时跑 tsnet 加入+对端枚举自检。
|
||||||
if let tsnetKey = UserDefaults.standard.string(forKey: "txTsnetKey"), !tsnetKey.isEmpty,
|
if let tsnetKey = UserDefaults.standard.string(forKey: "txTsnetKey"), !tsnetKey.isEmpty,
|
||||||
(UserDefaults.standard.string(forKey: "txHost") ?? "").isEmpty {
|
(UserDefaults.standard.string(forKey: "txHost") ?? "").isEmpty {
|
||||||
TsnetProbeView(authKey: tsnetKey)
|
TsnetProbeView(authKey: tsnetKey)
|
||||||
} else {
|
} else if model.showsTerminal {
|
||||||
Group {
|
TerminalScreen(model: model)
|
||||||
if model.showsTerminal {
|
} else {
|
||||||
TerminalScreen(model: model)
|
HomeView(model: model, store: hostStore)
|
||||||
} else {
|
}
|
||||||
ConnectionForm(model: model)
|
}
|
||||||
|
.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() }
|
.toolbarBackground(p.bg, for: .navigationBar)
|
||||||
.onChange(of: scenePhase) { _, phase in
|
.toolbarBackground(.visible, for: .navigationBar)
|
||||||
switch phase {
|
}
|
||||||
case .background: model.enterBackground()
|
.tint(p.accent)
|
||||||
case .active: model.enterForeground()
|
.sheet(isPresented: $showAdd) {
|
||||||
default: break
|
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)
|
||||||
.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)")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// M0 连接表单:SSH 直连(不过 tsnet)。真实运维即输入自己的服务器。
|
/// 主机卡片:服务器图标 + 状态点 / 名称 / user@ip:port(等宽)/ chevron。
|
||||||
struct ConnectionForm: View {
|
struct HostCard: View {
|
||||||
@ObservedObject var model: SSHTerminalModel
|
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 host = ""
|
||||||
@State private var port = "22"
|
@State private var port = "22"
|
||||||
@State private var username = ""
|
@State private var username = ""
|
||||||
@State private var password = ""
|
@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 {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
Form {
|
ZStack {
|
||||||
Section("SSH 直连 (M0)") {
|
p.bg.ignoresSafeArea()
|
||||||
TextField("主机 / IP", text: $host)
|
Form {
|
||||||
.autocorrectionDisabled()
|
Section {
|
||||||
.textInputAutocapitalization(.never)
|
field("名称", text: $name, placeholder: host.isEmpty ? "我的服务器" : host)
|
||||||
TextField("端口", text: $port)
|
field("主机 / IP", text: $host)
|
||||||
.keyboardType(.numberPad)
|
field("端口", text: $port, keyboard: .numberPad)
|
||||||
TextField("用户名", text: $username)
|
field("用户名", text: $username)
|
||||||
.autocorrectionDisabled()
|
SecureField("密码", text: $password)
|
||||||
.textInputAutocapitalization(.never)
|
.foregroundStyle(p.textPrimary)
|
||||||
SecureField("密码", text: $password)
|
.listRowBackground(p.surface)
|
||||||
}
|
Toggle("使用 mosh", isOn: $useMosh)
|
||||||
Section {
|
.tint(p.accent)
|
||||||
Button {
|
.foregroundStyle(p.textPrimary)
|
||||||
model.connect(host: host, port: Int(port) ?? 22,
|
.listRowBackground(p.surface)
|
||||||
username: username, password: password)
|
|
||||||
} label: {
|
|
||||||
Text("连接").frame(maxWidth: .infinity)
|
|
||||||
}
|
}
|
||||||
.disabled(host.isEmpty || username.isEmpty)
|
|
||||||
}
|
}
|
||||||
Section("状态") {
|
.scrollContentBackground(.hidden)
|
||||||
Text(model.phaseText)
|
}
|
||||||
.foregroundStyle(model.phaseText.hasPrefix("失败") ? .red : .secondary)
|
.navigationTitle("添加主机")
|
||||||
if !model.banner.isEmpty {
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
Text(model.banner).font(.caption).foregroundStyle(.secondary)
|
.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 {
|
struct TerminalScreen: View {
|
||||||
@ObservedObject var model: SSHTerminalModel
|
@ObservedObject var model: SSHTerminalModel
|
||||||
|
@EnvironmentObject var theme: TXThemeManager
|
||||||
|
private var p: TXPalette { theme.palette }
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
if let tmux = model.tmuxController {
|
VStack(spacing: 0) {
|
||||||
TmuxTabbedView(controller: tmux) // tmux -CC → 原生 tab
|
TerminalTopBar(model: model)
|
||||||
} else {
|
Divider().overlay(p.border)
|
||||||
RawTerminalView(model: model) // 普通单终端
|
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 {
|
var body: some View {
|
||||||
TerminalSurfaceView(context: model.state)
|
TerminalSurfaceView(context: model.state)
|
||||||
.terminalFocusOnAppear($focused)
|
.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 {
|
.task {
|
||||||
for _ in 0 ..< 300 where model.state.surfaceSize == nil {
|
for _ in 0 ..< 300 where model.state.surfaceSize == nil {
|
||||||
try? await Task.sleep(nanoseconds: 30_000_000)
|
try? await Task.sleep(nanoseconds: 30_000_000)
|
||||||
@@ -126,11 +452,14 @@ struct RawTerminalView: View {
|
|||||||
/// tmux control-mode:window 映射为原生 tab 条 + 活动 window 终端。
|
/// tmux control-mode:window 映射为原生 tab 条 + 活动 window 终端。
|
||||||
struct TmuxTabbedView: View {
|
struct TmuxTabbedView: View {
|
||||||
@ObservedObject var controller: TmuxController
|
@ObservedObject var controller: TmuxController
|
||||||
|
@EnvironmentObject var theme: TXThemeManager
|
||||||
|
@Environment(\.displayScale) private var displayScale
|
||||||
|
private var p: TXPalette { theme.palette }
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
ScrollView(.horizontal, showsIndicators: false) {
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
HStack(spacing: 6) {
|
HStack(spacing: TX.Space.xs) {
|
||||||
ForEach(controller.windows) { win in
|
ForEach(controller.windows) { win in
|
||||||
TmuxTabChip(window: win,
|
TmuxTabChip(window: win,
|
||||||
isActive: win.id == controller.activeWindowID) {
|
isActive: win.id == controller.activeWindowID) {
|
||||||
@@ -138,19 +467,43 @@ struct TmuxTabbedView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Button { controller.newWindow() } label: {
|
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)
|
.background(p.bg)
|
||||||
Divider()
|
Divider().overlay(p.border)
|
||||||
if let win = controller.activeWindow {
|
Group {
|
||||||
TmuxWindowView(window: win, controller: controller)
|
if controller.windows.isEmpty {
|
||||||
.id(win.id.raw)
|
Spacer(); Text("无 tmux 窗口").foregroundStyle(p.textSecondary); Spacer()
|
||||||
} else {
|
} else {
|
||||||
Spacer(); Text("无 tmux 窗口").foregroundStyle(.secondary); Spacer()
|
// 所有窗口常驻挂载、仅激活窗口可见:切 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
|
@ObservedObject var window: TmuxWindow
|
||||||
let isActive: Bool
|
let isActive: Bool
|
||||||
let onTap: () -> Void
|
let onTap: () -> Void
|
||||||
|
@EnvironmentObject var theme: TXThemeManager
|
||||||
|
private var p: TXPalette { theme.palette }
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Button(action: onTap) {
|
Button(action: onTap) {
|
||||||
Text(window.title)
|
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)
|
.padding(.horizontal, 12).padding(.vertical, 6)
|
||||||
.background(isActive ? Color.accentColor.opacity(0.25) : Color.gray.opacity(0.12),
|
.background(isActive ? p.accentSoft : p.surface,
|
||||||
in: RoundedRectangle(cornerRadius: 8))
|
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)
|
.buttonStyle(.plain)
|
||||||
}
|
}
|
||||||
@@ -176,7 +534,6 @@ struct TmuxTabChip: View {
|
|||||||
struct TmuxWindowView: View {
|
struct TmuxWindowView: View {
|
||||||
@ObservedObject var window: TmuxWindow
|
@ObservedObject var window: TmuxWindow
|
||||||
let controller: TmuxController
|
let controller: TmuxController
|
||||||
@Environment(\.displayScale) private var displayScale
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
GeometryReader { geo in
|
GeometryReader { geo in
|
||||||
@@ -205,11 +562,7 @@ struct TmuxWindowView: View {
|
|||||||
Color.clear
|
Color.clear
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// iPadOS 可自由拖拽窗口尺寸 → 容器变化即重算总格子并 refresh-client -C(controller 内 debounce)。
|
// 容器尺寸测量已上移到 TmuxTabbedView(单一来源),此处不再驱动 containerResized。
|
||||||
.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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
58
apps/TerminalX/iOS/HostStore.swift
Normal file
58
apps/TerminalX/iOS/HostStore.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -167,6 +167,10 @@ final class SSHTerminalModel: ObservableObject {
|
|||||||
@Published var tmuxController: TmuxController?
|
@Published var tmuxController: TmuxController?
|
||||||
/// M4:host key 与 pin 不符 → 非 nil 触发 SwiftUI alert(信任新密钥 / 取消)。
|
/// M4:host key 与 pin 不符 → 非 nil 触发 SwiftUI alert(信任新密钥 / 取消)。
|
||||||
@Published var hostKeyMismatch: HostKeyMismatchInfo?
|
@Published var hostKeyMismatch: HostKeyMismatchInfo?
|
||||||
|
/// 终端顶部状态行:当前连接标题(主机名/地址)。
|
||||||
|
@Published var connectionTitle: String = ""
|
||||||
|
/// mosh 是否已接管(传输徽章 SSH/mosh)。
|
||||||
|
@Published var moshEngaged: Bool = false
|
||||||
|
|
||||||
private let knownHosts = KeychainKnownHostsStore()
|
private let knownHosts = KeychainKnownHostsStore()
|
||||||
|
|
||||||
@@ -219,6 +223,7 @@ final class SSHTerminalModel: ObservableObject {
|
|||||||
)
|
)
|
||||||
let state = TerminalViewState()
|
let state = TerminalViewState()
|
||||||
state.configuration = TerminalSurfaceOptions(backend: .inMemory(session))
|
state.configuration = TerminalSurfaceOptions(backend: .inMemory(session))
|
||||||
|
_ = state.controller.setTheme(.txDefault) // Catppuccin Mocha 终端配色
|
||||||
self.holder = holder
|
self.holder = holder
|
||||||
self.moshHolder = moshHolder
|
self.moshHolder = moshHolder
|
||||||
self.session = session
|
self.session = session
|
||||||
@@ -243,7 +248,16 @@ final class SSHTerminalModel: ObservableObject {
|
|||||||
/// M4b:非 nil 时用 publickey(SE/软件签名器) 认证而非密码。
|
/// M4b:非 nil 时用 publickey(SE/软件签名器) 认证而非密码。
|
||||||
private var pubkeySigner: SigningKeyProvider?
|
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) {
|
func connect(host: String, port: Int, username: String, password: String) {
|
||||||
|
connectionTitle = host
|
||||||
|
moshEngaged = false
|
||||||
let egress = tsnetAuthKey != nil ? "tsnet" : "direct"
|
let egress = tsnetAuthKey != nil ? "tsnet" : "direct"
|
||||||
let auth: SSHConfig.Authentication = pubkeySigner.map { .publicKeyCallback($0) } ?? .password(password)
|
let auth: SSHConfig.Authentication = pubkeySigner.map { .publicKeyCallback($0) } ?? .password(password)
|
||||||
config = SSHConfig(host: host, port: port, username: username,
|
config = SSHConfig(host: host, port: port, username: username,
|
||||||
@@ -289,6 +303,12 @@ final class SSHTerminalModel: ObservableObject {
|
|||||||
/// 用户取消 → 保持断开。
|
/// 用户取消 → 保持断开。
|
||||||
func dismissHostKeyMismatch() { hostKeyMismatch = nil }
|
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 close() { run(machine.reduce(.closeRequested)) }
|
||||||
func enterBackground() {
|
func enterBackground() {
|
||||||
if moshMode { beginBgTask() } // 争取 ~30s 后台执行,mosh 每 3s ack 保活 NAT/WG/DERP
|
if moshMode { beginBgTask() } // 争取 ~30s 后台执行,mosh 每 3s ack 保活 NAT/WG/DERP
|
||||||
@@ -499,6 +519,7 @@ final class SSHTerminalModel: ObservableObject {
|
|||||||
moshSession = mosh
|
moshSession = mosh
|
||||||
moshHolder.session = mosh // 输入/resize 从此路由到 mosh
|
moshHolder.session = mosh // 输入/resize 从此路由到 mosh
|
||||||
mosh.start()
|
mosh.start()
|
||||||
|
moshEngaged = true
|
||||||
NSLog("MOSHDBG activateMosh ip=\(ip) port=\(port) started")
|
NSLog("MOSHDBG activateMosh ip=\(ip) port=\(port) started")
|
||||||
// mosh 接管成功 → 状态机拆掉底层 idle SSH(moshActive 之后忽略其 transportClosed)。
|
// mosh 接管成功 → 状态机拆掉底层 idle SSH(moshActive 之后忽略其 transportClosed)。
|
||||||
run(machine.reduce(.moshEstablished))
|
run(machine.reduce(.moshEstablished))
|
||||||
@@ -557,6 +578,7 @@ final class SSHTerminalModel: ObservableObject {
|
|||||||
/// 拆除 mosh 会话(兜底全量重建 / 关闭时)。重置引导状态,允许重新引导 mosh-server。
|
/// 拆除 mosh 会话(兜底全量重建 / 关闭时)。重置引导状态,允许重新引导 mosh-server。
|
||||||
private func teardownMosh() {
|
private func teardownMosh() {
|
||||||
cancelResumeWatchdog()
|
cancelResumeWatchdog()
|
||||||
|
moshEngaged = false
|
||||||
moshHolder.session = nil
|
moshHolder.session = nil
|
||||||
moshSession?.close(); moshSession = nil
|
moshSession?.close(); moshSession = nil
|
||||||
try? moshRelay?.close(); moshRelay = nil
|
try? moshRelay?.close(); moshRelay = nil
|
||||||
|
|||||||
153
apps/TerminalX/iOS/Theme.swift
Normal file
153
apps/TerminalX/iOS/Theme.swift
Normal 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 的核心色构造统一 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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,6 +26,7 @@ final class TmuxPaneSurface: ObservableObject, Identifiable {
|
|||||||
)
|
)
|
||||||
let state = TerminalViewState()
|
let state = TerminalViewState()
|
||||||
state.configuration = TerminalSurfaceOptions(backend: .inMemory(session))
|
state.configuration = TerminalSurfaceOptions(backend: .inMemory(session))
|
||||||
|
_ = state.controller.setTheme(.txDefault) // Catppuccin Mocha 终端配色
|
||||||
self.session = session
|
self.session = session
|
||||||
self.state = state
|
self.state = state
|
||||||
self.gate = OutputGate(session: session)
|
self.gate = OutputGate(session: session)
|
||||||
@@ -72,10 +73,28 @@ final class TmuxController: ObservableObject {
|
|||||||
private enum PendingKind { case ignore, listWindows, capturePane(TmuxPaneID) }
|
private enum PendingKind { case ignore, listWindows, capturePane(TmuxPaneID) }
|
||||||
private var pending: [PendingKind] = []
|
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) {
|
init(sendRaw: @escaping @Sendable (Data) -> Void) {
|
||||||
self.sendRaw = sendRaw
|
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 布局)。
|
/// 由 model 在 attach 前告知全屏格子(tmux 据此 refresh-client -C 布局)。
|
||||||
func setClientSize(cols: Int, rows: Int) { clientCols = max(1, cols); clientRows = max(1, rows) }
|
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
|
lastSentCols = cols; lastSentRows = rows
|
||||||
clientCols = cols; clientRows = rows
|
clientCols = cols; clientRows = rows
|
||||||
NSLog("TMUXDBG containerResized pt=\(Int(widthPt))x\(Int(heightPt)) scale=\(scale) cell=\(cellWpx)x\(cellHpx) -> \(cols)x\(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?.cancel()
|
||||||
resizeDebounce = Task { [weak self] in
|
resizeDebounce = Task { [weak self] in
|
||||||
try? await Task.sleep(nanoseconds: 200_000_000) // 合并拖拽期间的连续变化
|
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)")
|
NSLog("TMUXDBG refresh-client -C \(cols)x\(rows)")
|
||||||
self.sendCommand("refresh-client -C \(cols)x\(rows)") // tmux 重排 → 回 %layout-change → 更新 frame
|
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) {
|
private func handleCommandResponse(_ r: TmuxCommandResponse) {
|
||||||
if !attachAcked {
|
if !attachAcked {
|
||||||
attachAcked = true
|
attachAcked = true
|
||||||
// 先设客户端尺寸(tmux 据此布局),再枚举——枚举到的 layout 已是我方尺寸,避免先渲一帧再重排的闪跳。
|
// kickoff 延后到容器几何已知(见 kickoffIfReady):避免用 raw 全屏格子 attach 导致
|
||||||
sendCommand("refresh-client -C \(clientCols)x\(clientRows)")
|
// 快照行数 > pane grid 行数、铺快照溢出上滚。几何先到则此处直接触发。
|
||||||
pending.append(.ignore)
|
kickoffIfReady()
|
||||||
sendCommand("list-windows -F \"#{window_id}\t#{window_name}\t#{window_active}\t#{window_layout}\t#{window_visible_layout}\"")
|
|
||||||
pending.append(.listWindows)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
guard !pending.isEmpty else { 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]))
|
applyLayout(window: w, full: String(parts[3]), visible: String(parts[4]))
|
||||||
}
|
}
|
||||||
case .capturePane(let p):
|
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 let s = surfaceByPane[p] {
|
||||||
if !snapshot.isEmpty {
|
if !snapshot.isEmpty {
|
||||||
// 归位后铺快照(不尾随换行,避免滚屏)。
|
// 归位后铺快照(不尾随换行,避免滚屏)。
|
||||||
@@ -284,6 +309,11 @@ final class TmuxController: ObservableObject {
|
|||||||
|
|
||||||
/// 某 pane 的 surface 就绪 → 放行其缓冲输出。
|
/// 某 pane 的 surface 就绪 → 放行其缓冲输出。
|
||||||
func markPaneReady(_ p: TmuxPaneID) { surfaceByPane[p]?.gate.markReady() }
|
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 网关。
|
/// 传输字节分流器:检测 `tmux -CC` 进入 DCS 前 → 原始终端;进入后 → tmux 网关。
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
|
|
||||||
## 0. 一句话现状
|
## 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 模拟器验证(未上真机/未签名)。
|
一个**原生 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→接管)。
|
> **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+心跳=真连。
|
> - **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 数据报语义) 证伪**。
|
> - **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`。
|
- **绝不臆想工具结果**:调用后等真实返回再推理,绝不编造 BUILD/日志/截图。曾因连续编造工具输出(含假 BUILD SUCCEEDED、错误归因 GhosttyKit)而误诊——真相全靠老实 `Read` 完整 log。此规则已写入全局 `~/.claude/CLAUDE.md`。
|
||||||
- 每步用真实证据验证:`swift test` 看真实计数、`xcodebuild` 看真实 BUILD 结果、模拟器 `screenshot` + `Read` 看真实画面。
|
- 每步用真实证据验证:`swift test` 看真实计数、`xcodebuild` 看真实 BUILD 结果、模拟器 `screenshot` + `Read` 看真实画面。
|
||||||
- 与用户用**简体中文**;commit/push 只在明确要求时。
|
- 与用户用**简体中文**;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)。
|
||||||
</content>
|
</content>
|
||||||
|
|||||||
@@ -112,6 +112,15 @@
|
|||||||
}
|
}
|
||||||
core.platformSetup = { [weak self] config in
|
core.platformSetup = { [weak self] config in
|
||||||
guard let self else { return }
|
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_tag = GHOSTTY_PLATFORM_IOS
|
||||||
config.platform = ghostty_platform_u(
|
config.platform = ghostty_platform_u(
|
||||||
ios: ghostty_platform_ios_s(
|
ios: ghostty_platform_ios_s(
|
||||||
@@ -128,6 +137,13 @@
|
|||||||
core.onPostRender = { [weak self] in
|
core.onPostRender = { [weak self] in
|
||||||
self?.enforceSublayerScale()
|
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()
|
setupApplicationLifecycleObservers()
|
||||||
syncApplicationActiveState()
|
syncApplicationActiveState()
|
||||||
|
|||||||
@@ -45,6 +45,10 @@ final class TerminalSurfaceCoordinator {
|
|||||||
var platformSetup: ((inout ghostty_surface_config_s) -> Void)?
|
var platformSetup: ((inout ghostty_surface_config_s) -> Void)?
|
||||||
var onMetricsUpdate: (() -> Void)?
|
var onMetricsUpdate: (() -> Void)?
|
||||||
var onCellSizeDidChange: (() -> 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`).
|
/// Called after every display-link render (`tick`).
|
||||||
///
|
///
|
||||||
@@ -328,6 +332,7 @@ final class TerminalSurfaceCoordinator {
|
|||||||
if hadSurface {
|
if hadSurface {
|
||||||
(delegate as? any TerminalSurfaceLifecycleDelegate)?
|
(delegate as? any TerminalSurfaceLifecycleDelegate)?
|
||||||
.terminalDidDetachSurface()
|
.terminalDidDetachSurface()
|
||||||
|
onSurfaceFreed?() // iOS:摘除 ghostty 泄漏的死 IOSurfaceLayer 子层
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user