feat: UI 商业化改造 + 连接前 tmux 会话选择(真机验证)
按 Claude Design 定稿(归档在 docs/design/)重做 UI,并把 tmux 从 「从不自动进」修成「连接前探测 → 让用户选会话」。 设计令牌与导航地基 - Theme 拆三层:TXAccent(恒定 blurple) / TXChrome(中性阶×4 表) / TXFlavor(Catppuccin 终端) - vendor JetBrains Mono 四权重(附 OFL 许可) - AppRouter + SessionManager:多会话并存,路由与会话生命周期解耦 - SessionCanvas 常驻挂载会话 surface(摘除即丢内容,也是缩回动画的前提) 按设计稿落地的屏 - 沉浸轨道页:56pt 轨道 + 浮起标题/状态胶囊 + 侧边栏三态(遮罩不 resize、Pin 各一次) - 首页:活动会话卡(readViewportText 文本镜像)+ 主机网格 + 筛选 chips - 关闭二次确认(tmux 仅断开 / 原生窗口两套文案)、分屏菜单、pane 拖拽条 - 空状态:首次运行 / 无会话 / 搜索无命中 / 连接中·失败·已关闭 tmux 真实链路(真机查出并修掉 4 个 bug) - 全代码库从来没人发起 attach → 连接前探测 + 会话选择器(接回 / 新建 / 原生终端) - format 分隔符 tab 经 PTY 变成下划线 → 改用 |:| - controller 变化不冒泡到 session → Combine 转发(否则数据解析对了 UI 不刷新) - 当前会话名不能靠 session_attached 反推 → 改用 display-message 传输层:SSH connect 加超时(原来阻塞到系统 TCP 超时 75s+) 无头验证设施:假会话 fixture · terminalx://ui/* 驱动 · 横屏截图脚本 · 对拍走查法 TXCore 45 tests 绿(新增 5 个会话探测单测) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
80
apps/TerminalX/iOS/TailscaleStore.swift
Normal file
80
apps/TerminalX/iOS/TailscaleStore.swift
Normal file
@@ -0,0 +1,80 @@
|
||||
import Foundation
|
||||
|
||||
/// 一个 Tailscale 连接(= 一个常驻 tsnet 节点)。可添加多个;host 可绑定其一作为出口。
|
||||
/// 每连接独立 stateDir(`tsnet-<uuid>`,创建即固化、终身不变——tsnet 无文件锁,同 Dir 双 Up 会损坏状态)。
|
||||
struct TailscaleConnection: Identifiable, Codable, Equatable {
|
||||
let id: UUID
|
||||
var name: String
|
||||
/// tailnet 内展示的主机名。
|
||||
var hostname: String
|
||||
/// tsnet 状态目录名(Application Support 下),创建即固化。
|
||||
let stateDirName: String
|
||||
/// true = 从未注册 或 被 tailnet 清理 → 需重填 authKey 才能 Up。
|
||||
var needsAuthKey: Bool
|
||||
/// 展示用缓存:上次 Up 后的本节点 tailnet IP。
|
||||
var lastSelfIP: String?
|
||||
|
||||
init(id: UUID = UUID(), name: String, hostname: String = "terminalx-ipad",
|
||||
needsAuthKey: Bool = true, lastSelfIP: String? = nil) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.hostname = hostname
|
||||
self.stateDirName = "tsnet-\(id.uuidString)"
|
||||
self.needsAuthKey = needsAuthKey
|
||||
self.lastSelfIP = lastSelfIP
|
||||
}
|
||||
}
|
||||
|
||||
/// Tailscale 连接列表持久化(tailnets.json)。本体无机密(authKey 默认注册后即丢弃)。
|
||||
@MainActor
|
||||
final class TailscaleStore: ObservableObject {
|
||||
@Published private(set) var connections: [TailscaleConnection] = []
|
||||
|
||||
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("tailnets.json")
|
||||
load()
|
||||
}
|
||||
|
||||
func add(_ conn: TailscaleConnection) { connections.append(conn); save() }
|
||||
|
||||
func update(_ conn: TailscaleConnection) {
|
||||
guard let i = connections.firstIndex(where: { $0.id == conn.id }) else { return }
|
||||
connections[i] = conn
|
||||
save()
|
||||
}
|
||||
|
||||
func remove(_ conn: TailscaleConnection) {
|
||||
connections.removeAll { $0.id == conn.id }
|
||||
save()
|
||||
// 删连接 → 删其 tsnet 状态目录,避免残留身份。
|
||||
let base = NSSearchPathForDirectoriesInDomains(.applicationSupportDirectory, .userDomainMask, true)[0]
|
||||
try? FileManager.default.removeItem(atPath: base + "/" + conn.stateDirName)
|
||||
}
|
||||
|
||||
func connection(id: UUID?) -> TailscaleConnection? {
|
||||
guard let id else { return nil }
|
||||
return connections.first { $0.id == id }
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
/// 无头 UI 验证:注入示意 tailnet(**不写盘**,不污染真实 tailnets.json)。
|
||||
func injectFixtureConnections(_ list: [TailscaleConnection]) {
|
||||
connections = list
|
||||
}
|
||||
#endif
|
||||
|
||||
private func load() {
|
||||
guard let data = try? Data(contentsOf: url),
|
||||
let decoded = try? JSONDecoder().decode([TailscaleConnection].self, from: data) else { return }
|
||||
connections = decoded
|
||||
}
|
||||
|
||||
private func save() {
|
||||
guard let data = try? JSONEncoder().encode(connections) else { return }
|
||||
try? data.write(to: url, options: .atomic)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user