diff --git a/CLAUDE.md b/CLAUDE.md index 5b3ad27..e55f02a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -58,15 +58,25 @@ sleep N; xcrun simctl io $UDID screenshot /tmp/x.png # 然后用 Read 工具 - **mosh 直连(LAN)**:`-txHost H -txPort 22 -txUser U -txPass PW -txMosh 1`(登录后跑 mosh-server→解析 MOSH CONNECT→mosh_main 直连 UDP) - **mosh over tsnet**:额外加 `-txTsnetKey `(-txHost 填 tailnet IP;relay=loopback↔tsnet UDP) - 自定义 mosh-server 命令:`-txMoshServerCmd "…"`(默认 `mosh-server new -s -c 256 -l LANG=en_US.UTF-8 -l LC_ALL=en_US.UTF-8`) +- **tmux 自动进入**:默认开(`SavedHost.useTmux`)。`-txTmux 0` 关闭(回归 raw 终端路径)、`-txTmuxSession <名>` 指定会话名(默认 `main`)。 - **M4 publickey 认证**:`-txPubkeyAuth 1`(不给 -txPass;SigningKeyProvider 造 P-256 key,日志打 authorized_keys 行,加到服务器后重连免密登录) - **M4 host key mismatch 测试**:`-txHostKeyPinOverrideBase64 `(播种假 pin → 真 host key 不符 → 弹告警) +**UI 改造期无头验证(推荐走脚本)**:`scripts/tx-shot.sh [-- <启动参数>]` 一步完成装/启/截图/修正方向。 +- **`-txLandscape 1`**:app 侧 `requestGeometryUpdate` 转横屏(设计基准 iPad Pro 13″ 1366×1024)。无 GUI 时模拟器帧缓冲恒竖屏,故脚本截完 `sips -r 90` 转回来(内容已横向渲染)。 +- **`-txUIFixture 1`**:造 3 条**假会话**(mac-studio/ubuntu-cn2/nas-home,含 direct/relay/重连三种状态)灌示意输出进 in-memory 终端,**不建任何传输连接**。设计稿的首页会话卡/轨道多头像/状态徽标都要多会话才有内容,用真机连接验证太慢。见 `UIPreviewFixture.swift`(DEBUG only),与 `-txHost` 互斥。 +- **`terminalx://ui/<动作>`**(`xcrun simctl openurl`):无头驱动导航与浮层——`home` / `minimize` / `close`(弹确认) / `close-now`(直接关) / `split/v|h`(分屏菜单) / `dismiss` / `session/` / `sidebar/toggle|pin`。比坐标点击稳(不依赖 idb companion,不随布局失效)。模态状态放在 `AppRouter.modal` 而非视图 `@State`,快捷键与 URL 才能共用同一入口。 +- **对拍走查法(还原度靠这个,别靠目测)**:截图 → 用 python+PIL 把设计稿 PNG 与截图统一缩放到 1366×1024 → 裁同区域并排 → 再对元素列做垂直投影扫描量化 y 坐标。缩略图里 `#1E1E2E` 与 `#11121C` 目测分不出,必须采样像素。 +- **坑:`statusBarHidden` 不能挂在 `TerminalScreen`**(它常驻挂载,会连首页一起隐藏)→ 挂 `RootView` 按 `router.route` 判断。 +- 设计稿本体在 `docs/design/`:`UI-DESIGN-HANDOFF.md`(token/规格/交互全表) + `terminalX-iPadOS.dc.html`(13 屏画板) + `render-screens.py`(拆屏,配 headless Chrome 逐屏截 PNG 对拍)。 + **M3 挂起→恢复无头验证**:退后台 `simctl openurl $UDID https://apple.com`;冻结 `kill -STOP $(pgrep -x TerminalX)`;解冻 `kill -CONT`;前台 `simctl openurl $UDID "terminalx://resume"`(URL scheme 已注册)。日志 `log stream | grep -E 'MOSHDBG|TXM3'` 看 `wake pulse`→`TXM3 recovered` 时间差判 <3s。**坑:模拟器看门狗会杀掉被 STOP 冻结过久(>~30s)的 app;且模拟器不复现 socket defunct/WG 过期,深挂起需真机锁屏补测。** ## 仓库结构 - `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`(HomeView 主机管理页+TerminalScreen 终端 chrome+设置)/`TmuxController`(tmux 网关)/`TsnetProbe`(M1 自检)/`Theme`(TXPalette+TXThemeManager 设计令牌,颜色封装供主题系统)/`HostStore`(主机列表 JSON 持久化)。UI 对标 Moshi(深蓝黑+终端绿+Catppuccin),见 `docs/HANDOFF.md §10`。 +- `apps/TerminalX/iOS` — `TerminalSession`(**一实例=一条会话**:连接编排+tsnet+tmux 路由+mosh 编排+viewport 快照)/`SessionManager`(多会话持有+最小化/关闭语义+生命周期广播)/`AppRouter`(AppRoute .home|.terminal(id) + SidebarState 三态)/`ContentView`(RootView+**SessionCanvas 会话常驻挂载**+TerminalScreen 沉浸轨道页+TmuxStage)/`HomeView`(首页会话卡+主机网格)/`RailSidebar`(56pt 轨道+288pt 面板三态)/`TerminalChrome`(浮起标题/状态胶囊+提示条)/`TXParts`(设计令牌小件+终端文本镜像)/`TmuxController`(tmux 网关)/`TsnetProbe`(M1 自检)/`Theme`(TXAccent 恒定 blurple + TXChrome 中性阶×4 + TXFlavor 终端配色)/`HostStore`(主机列表 JSON 持久化)/`UIPreviewFixture`(DEBUG 无头 UI 验证脚手架)。 +- UI 已从 Demo 转入**商业化改造**:设计定稿见 `docs/design/UI-DESIGN-HANDOFF.md`(Nocturne chrome + Catppuccin 终端 + 沉浸轨道),路线图与进度见 `docs/HANDOFF.md §12`。 - `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`。 @@ -79,8 +89,17 @@ sleep N; xcrun simctl io $UDID screenshot /tmp/x.png # 然后用 Read 工具 - **Swift6 `SendNonSendable` pass 可能崩溃**(`pthread_create`+`Unmanaged` 上下文+C 入口的组合):改用 `Foundation.Thread`+线程内 `pthread_self()` 规避(见 `MoshSession`)。 - GhosttyKit 的 `InMemoryTerminalSession.receive` 在 surface 未 attach 时**丢弃**写入 → 用 `OutputGate` 缓冲到 `surfaceSize != nil` 再 flush。 - tmux `-CC attach` **不推 %window-add** → 必须主动 `list-windows` 枚举。 +- **tmux format 的字段分隔符不能用 tab**:真机实测 tab 经 PTY 送到 tmux 后**变成下划线**,`list-windows`/`list-sessions` 整行分不开(`count=1`)→ title/cwd/会话名全丢、UI 显示兜底名「窗口 N」。改用 `TmuxController.fieldSep = "|:|"`(可打印、不被 PTY 处理、`#` 会被 tmux format 当特殊字符所以不能用)。**排查提醒**:`os_log` 会把控制字符显示成 `_`,光看日志分不出「真下划线」还是「被显示替换的 tab」——要打印 `parts.count` 才能确诊。 +- **tmux 当前会话名要用 `display-message -p "#{session_name}"`**,不能拿 `list-sessions` 的 `session_attached` 反推:服务器上别的客户端 attach 着其它会话时会猜错(真机把用户自己的 `demo` 当成了当前会话)。 +- **轨道/侧边栏显示 `#{window_index}` 而非 window_id**:`@2` 是内部 id,用户看到的编号是 index(0 起,删过 window 后两者会差很远)。 +- **`TerminalSession` 必须转发 `TmuxController.objectWillChange`**:`tmuxSummary` 等是读 controller 的 computed property,而轨道/胶囊/侧边栏只 `@ObservedObject` 订阅 session。没有这条转发,数据解析全对但 UI 不刷新(真机表现为轨道空、第三格只有「tmux」)。同一个坑的另一面见下条。 +- **连接流程 = 点主机 → 留在首页探测 → 首页弹选择器 → 选完才进终端**(用户定稿,别改回"自动 attach"或"先进终端再弹框"):SSH 连上 → 在**普通 shell** 里跑 `TmuxSessionProbe.command` → 选择器给三种连接方式(接回已有会话 / 新建会话 / **原生终端**,不用"跳过"这种模糊措辞)。`SessionManager.enterRequest` 决定何时进终端;`isProbingTmux` **必须在 `.connected` 当刻置位**,否则连接建立与探测开始(延迟 2.5s)之间的空窗会让 `evaluateEnter` 提前把人送进终端页。**没装 tmux 时不弹框**,直接原生终端 + 安装引导横幅。探测的三个坑:① 命令会被 PTY **回显**,标记必须写成相邻字符串拼接(`"__TX""_SESSIONS_BEGIN"`)否则回显行被当成结果起点;② 「没装 tmux」与「装了但没会话」的 stderr 都被 `2>/dev/null` 吞掉、标记之间同样空,只能靠 `tmux -V` 的版本行分开(两者 UI 完全不同);③ 探测命令与输出会脏屏 → 命令末尾接 `printf '\033[H\033[2J\033[3J'` 清屏(刚连上时清掉的只有 motd)。 +- **tmux 不会自己进**:`TmuxRouter` 只被动检测字节流里的 DCS 前导,**必须由 app 主动发 `tmux -CC new -A -s <名>`**(登录后 2.5s 等提示符就绪,沿用 mosh 引导已验证的延迟)。`-A` = 同名会话存在就接回。4s 内没进 control mode 判定无 tmux → 降级客户端窗口 + 行内安装引导横幅(设计 2c),**不弹窗不强推**。曾经漏了这一步 → 真机连上永远显示「无 tmux」。 - **tmux 多 pane 尺寸走"路线 b"**:app 按全屏格子发 `refresh-client -C WxH` 让 tmux 布局,pane surface 尺寸严格取 layout rect(%output 按 tmux pane 宽高排版,二者不一致必换行/清屏错乱);pane surface 的 resize 回调**只断言不反向驱动 tmux**(防反馈环)。diff 用 full layout、渲染用 visible layout(zoom 免费);SwiftUI identity 只用 paneID(布局变不重建 surface)。新 pane 在 `capture-pane` %end 前丢弃其 %output(tmux 单线程串行保证快照不缺不重)。 - **tmux 网关字节必须单消费者保序**:多个 `Task{@MainActor}` 到主线程顺序无保证,多 pane 高吞吐会打碎 % 协议 → 用 `TmuxByteChannel`(AsyncStream) 单消费者。 +- **导航不得销毁会话**:终端**不能**走 `fullScreenCover`——① surface 一旦从视图树摘除,ghostty 释放 grid,内容全丢;② matchedGeometry(最小化缩回首页卡)要求两端同层级,独立 presentation 层做不到。故 `SessionCanvas` 把所有会话 ZStack 常驻挂载、只切 opacity + `allowsHitTesting`(沿用 tmux 切 tab 已验证的手法),画布恒全屏 → 切路由不触发 PTY resize。 +- **首页缩略卡不许再开 surface**:一个 ghostty surface 只有一个 IOSurfaceLayer,同处两地/离屏 Metal 渲染是已修过的泄漏雷区。缩略卡走 `TerminalSession.captureSnapshot()`(`readViewportText()` 抓尾部若干行)+ `TXTerminalMirror` 文本渲染(底对齐),代价只是丢 ANSI 颜色。 +- **嵌套 ObservableObject 不冒泡**:`SessionManager` 的 `@Published sessions` 不会因某条会话内部 `@Published` 变化而发布。凡显示会话状态的行/卡片都要抽成独立视图用 `@ObservedObject var session:` 直接订阅(见 `SessionSidebarRow`/`SessionStatusRow`)。 - SourceKit "No such module" 是索引噪声,**以 `swift build`/`xcodebuild` 为准**。 - 密钥/密码**绝不写进仓库**;测试主机凭据由用户即时提供。 - 提交/推送 git 只在用户明确要求时(main 分支已有 6 次提交,未 push、无 remote)。 diff --git a/apps/TerminalX/Resources/Fonts/JetBrainsMono-Bold.ttf b/apps/TerminalX/Resources/Fonts/JetBrainsMono-Bold.ttf new file mode 100644 index 0000000..8c93043 Binary files /dev/null and b/apps/TerminalX/Resources/Fonts/JetBrainsMono-Bold.ttf differ diff --git a/apps/TerminalX/Resources/Fonts/JetBrainsMono-Medium.ttf b/apps/TerminalX/Resources/Fonts/JetBrainsMono-Medium.ttf new file mode 100644 index 0000000..9767115 Binary files /dev/null and b/apps/TerminalX/Resources/Fonts/JetBrainsMono-Medium.ttf differ diff --git a/apps/TerminalX/Resources/Fonts/JetBrainsMono-Regular.ttf b/apps/TerminalX/Resources/Fonts/JetBrainsMono-Regular.ttf new file mode 100644 index 0000000..dff66cc Binary files /dev/null and b/apps/TerminalX/Resources/Fonts/JetBrainsMono-Regular.ttf differ diff --git a/apps/TerminalX/Resources/Fonts/JetBrainsMono-SemiBold.ttf b/apps/TerminalX/Resources/Fonts/JetBrainsMono-SemiBold.ttf new file mode 100644 index 0000000..a70e69b Binary files /dev/null and b/apps/TerminalX/Resources/Fonts/JetBrainsMono-SemiBold.ttf differ diff --git a/apps/TerminalX/Resources/Fonts/OFL.txt b/apps/TerminalX/Resources/Fonts/OFL.txt new file mode 100644 index 0000000..8bee414 --- /dev/null +++ b/apps/TerminalX/Resources/Fonts/OFL.txt @@ -0,0 +1,93 @@ +Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/apps/TerminalX/Resources/Fonts/README.md b/apps/TerminalX/Resources/Fonts/README.md new file mode 100644 index 0000000..1461db7 --- /dev/null +++ b/apps/TerminalX/Resources/Fonts/README.md @@ -0,0 +1,2 @@ +JetBrains Mono 2.304 — SIL Open Font License 1.1(见 OFL.txt)。 +仅打包 400/500/600/700 四权重(设计交付所需),来源 github.com/JetBrains/JetBrainsMono releases。 diff --git a/apps/TerminalX/TerminalX-Info.plist b/apps/TerminalX/TerminalX-Info.plist index 04b5d3e..c57dcf9 100644 --- a/apps/TerminalX/TerminalX-Info.plist +++ b/apps/TerminalX/TerminalX-Info.plist @@ -2,8 +2,16 @@ - + + UIAppFonts + + JetBrainsMono-Regular.ttf + JetBrainsMono-Medium.ttf + JetBrainsMono-SemiBold.ttf + JetBrainsMono-Bold.ttf + CFBundleURLTypes diff --git a/apps/TerminalX/iOS/AppRouter.swift b/apps/TerminalX/iOS/AppRouter.swift new file mode 100644 index 0000000..fac341a --- /dev/null +++ b/apps/TerminalX/iOS/AppRouter.swift @@ -0,0 +1,97 @@ +import SwiftUI + +/// 顶层去处。设计定稿只有两处落点:首页(主机管理)与终端。 +/// +/// 关键:路由与**会话生命周期解耦** —— 回首页/最小化只改 route,会话照跑 +/// (会话 surface 由 `SessionCanvas` 常驻挂载,见 ContentView)。 +enum AppRoute: Equatable { + case home + case terminal(UUID) + + var sessionID: UUID? { + if case .terminal(let id) = self { return id } + return nil + } +} + +/// 侧边栏三态。同一条栏,元素不增不减,只是展示密度不同。 +enum SidebarState: Equatable { + /// 56pt 轨道:只显图标与两字缩写 + case collapsed + /// 288pt 面板**盖住**轨道,右侧终端压遮罩;点终端任意处收起。终端不 reflow、不触发 PTY resize。 + case overlay + /// 288pt 钉在左侧:遮罩消失,终端变窄。**只在钉住/解除时各 resize 一次**。 + case pinned + + var isExpanded: Bool { self != .collapsed } + + /// 该状态下侧边栏占据的布局宽度(overlay 是浮层,不占位)。 + var layoutWidth: CGFloat { + switch self { + case .collapsed, .overlay: TX.Layout.railWidth + case .pinned: TX.Layout.sidebarWidth + } + } +} + +/// 终端页上的模态浮层。放在 router 而不是视图 `@State`:⌘W/⌘D 快捷键与无头 URL 驱动 +/// 都要能打开它,视图内部状态触达不到。 +enum AppModal: Equatable { + /// 关闭确认(3c) + case closeConfirm + /// 分屏菜单(3b)。`vertical` = 竖分(⌘D)/ 横分(⌘⇧D) + case splitMenu(vertical: Bool) +} + +/// 导航状态持有者。视图只读它、只经方法改它。 +@MainActor +final class AppRouter: ObservableObject { + @Published var route: AppRoute = .home + @Published var sidebar: SidebarState = .collapsed + /// 当前模态浮层(关闭确认 / 分屏菜单)。 + @Published var modal: AppModal? + + /// 状态切换动效:设计定稿 200–260ms easeOut(侧边栏浮层从左滑入 + 遮罩淡入)。 + /// 所有侧边栏状态变更都经这里,键盘快捷键与点击拿到同一条曲线。 + static let sidebarAnimation = Animation.easeOut(duration: 0.24) + + private func animateSidebar(_ change: () -> Void) { + withAnimation(Self.sidebarAnimation, change) + } + + /// 进入某会话(点会话卡 / 侧边栏会话行 / 新建连接后)。恢复焦点由会话自身持有。 + func enter(_ sessionID: UUID) { + route = .terminal(sessionID) + } + + /// 回首页:会话完全不动(⌘⇧H / 侧边栏首页行)。 + func goHome() { + modal = nil + route = .home + if sidebar == .overlay { animateSidebar { sidebar = .collapsed } } + } + + /// 开合侧边栏(⌘\ / 开合键)。折叠 ⇄ 遮罩;Pin 态视作展开,收起回折叠。 + func toggleSidebar() { + animateSidebar { + switch sidebar { + case .collapsed: sidebar = .overlay + case .overlay, .pinned: sidebar = .collapsed + } + } + } + + /// Pin 切换(只在展开态出现)。钉住/解除各触发一次终端 resize。 + func togglePin() { + animateSidebar { sidebar = sidebar == .pinned ? .overlay : .pinned } + } + + /// 打开/关闭模态浮层(红点、⌘W、⌘D、⌘⇧D 与无头 URL 共用同一入口)。 + func present(_ modal: AppModal) { self.modal = modal } + func dismissModal() { modal = nil } + + /// 点终端任意处:遮罩态收起(Pin 态不受影响)。 + func dismissOverlaySidebar() { + if sidebar == .overlay { animateSidebar { sidebar = .collapsed } } + } +} diff --git a/apps/TerminalX/iOS/ContentView.swift b/apps/TerminalX/iOS/ContentView.swift index 43debc4..376b4a1 100644 --- a/apps/TerminalX/iOS/ContentView.swift +++ b/apps/TerminalX/iOS/ContentView.swift @@ -4,8 +4,11 @@ import GhosttyTerminal import TXCore struct ContentView: View { - @StateObject private var model = SSHTerminalModel() + @StateObject private var manager = SessionManager() + @StateObject private var router = AppRouter() @StateObject private var hostStore = HostStore() + @StateObject private var credStore = FileCredentialStore() + @StateObject private var tailscaleStore = TailscaleStore() @StateObject private var theme = TXThemeManager() @Environment(\.scenePhase) private var scenePhase @@ -15,426 +18,381 @@ struct ContentView: View { 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) + RootView(manager: manager, router: router, hostStore: hostStore, + credStore: credStore, tailscaleStore: tailscaleStore) } } .environmentObject(theme) .preferredColorScheme(theme.palette.isDark ? .dark : .light) .onChange(of: theme.palette.id) { _, _ in - // 主题切换:app 视图经 palette 响应式更新;这里把终端配色同步推给已连接的 surface。 - model.applyTerminalTheme(theme.palette.terminalTheme) + // 主题切换:app 视图经 palette 响应式更新;这里把终端配色同步推给所有会话的 surface。 + manager.applyTerminalTheme(theme.palette.terminalTheme) + } + .onAppear { + manager.bind(credentials: credStore, tailscale: tailscaleStore) + #if DEBUG + UIPreviewFixture.forceLandscapeIfRequested() + // 无头 UI 验证:造假会话+假主机供截图对拍(不建连接、不写盘),与 -txHost 真实连接互斥。 + if UIPreviewFixture.isEnabled { + hostStore.injectFixtureHosts(UIPreviewFixture.hosts) + tailscaleStore.injectFixtureConnections(UIPreviewFixture.tailnets) + if let s = manager.spawnFixtureSessions() { router.enter(s.id) } + return + } + #endif + #if DEBUG + // 无头验证**真实的「首页点主机」路径**(`-txOpenAsHost 1`):把启动参数造成一台主机塞进 + // 内存主机表,再走 manager.open(host:) —— 即探测 tmux → 弹选择器 → 选完才进终端。 + // 与 `-txHost` 的老路径(autoConnectIfConfigured:直连 + 立刻进终端)互斥。 + if UserDefaults.standard.bool(forKey: "txOpenAsHost"), + let h = UIPreviewFixture.hostFromLaunchArgs() { + hostStore.injectFixtureHosts([h]) + manager.open(host: h) + return + } + #endif + // 无头验证:启动参数带 -txHost 时建会话并直接进终端(老路径,保留给回归脚本)。 + if let s = manager.autoConnectIfConfigured() { router.enter(s.id) } } - .onAppear { model.autoConnectIfConfigured() } .onChange(of: scenePhase) { _, phase in switch phase { - case .background: model.enterBackground() - case .active: model.enterForeground() + case .background: manager.enterBackground() + case .active: manager.enterForeground() default: break } } + #if DEBUG + // 无头验证:terminalx://ui/<动作> 驱动导航(见 UIPreviewFixture.handle)。 + // 非 ui/ 前缀的 URL(如 terminalx://resume)只用于把 app 拉回前台,无需处理。 + .onOpenURL { url in + guard url.host == "ui" else { return } + let parts = url.pathComponents.filter { $0 != "/" } + _ = UIPreviewFixture.handle(uiCommand: parts, manager: manager, router: router) + } + #endif + } +} + +/// 顶层壳:会话画布常驻在底,首页盖在其上。 +/// +/// **为什么不用 fullScreenCover**:会话必须在离开终端后继续跑并保留内容,且「最小化缩回首页卡片」 +/// 需要 matchedGeometry —— 那要求两端在同一视图层级,独立 presentation 层做不到。 +struct RootView: View { + @ObservedObject var manager: SessionManager + @ObservedObject var router: AppRouter + @ObservedObject var hostStore: HostStore + @ObservedObject var credStore: FileCredentialStore + @ObservedObject var tailscaleStore: TailscaleStore + @EnvironmentObject var theme: TXThemeManager + private var p: TXPalette { theme.palette } + + var body: some View { + ZStack { + SessionCanvas(manager: manager, router: router, + hostStore: hostStore, credStore: credStore, tailscaleStore: tailscaleStore) + if router.route == .home { + HomeView(manager: manager, router: router, hostStore: hostStore, + credStore: credStore, tailscaleStore: tailscaleStore) + .transition(.opacity) + } + } + .background(p.bgDeep.ignoresSafeArea()) + // 沉浸只在终端页:设计稿 1b/3a 顶部就是胶囊/logo(无状态栏),1e 首页与 1g 设置有状态栏。 + .statusBarHidden(router.route != .home) + // 连接流程的一部分:探测出 tmux 会话后在**首页上**弹选择器,选完才进终端。 + // 挂在 RootView 而不是终端页 —— 用户点主机时还没进终端。 + .sheet(isPresented: Binding( + get: { manager.sessionAwaitingChoice != nil }, + set: { if !$0 { manager.sessionAwaitingChoice?.useNativeTerminal() } } + )) { + if let s = manager.sessionAwaitingChoice, let choice = s.pendingSessionChoice { + SessionPickerSheet( + host: s.displayName, + choice: choice, + onAttach: { s.attachTmuxSession($0) }, + onCreate: { s.createTmuxSession($0) }, + onNative: { s.useNativeTerminal() }) + .environmentObject(theme) + .presentationDetents([.medium, .large]) + .presentationDragIndicator(.hidden) + } + } + // 会话就绪(选择完成 / 无 tmux / 失败)→ 进终端页。 + .onChange(of: manager.enterRequest) { _, id in + guard let id else { return } + manager.enterRequest = nil + router.enter(id) + } + // 兜底:路由指向的会话已不存在(被关闭/清理)→ 回首页,而不是停在空画布上黑屏。 + .onChange(of: manager.sessions.count) { _, _ in + if let id = router.route.sessionID, manager.session(id) == nil { router.goHome() } + } + } +} + +/// 所有会话的终端视图**常驻挂载**,只有当前路由指向的那条可见。 +/// +/// 这是"回首页/切会话内容不丢"的根:GhosttyKit 的 surface 一旦从视图树摘除就释放 grid, +/// 故沿用 tmux 切 tab 已验证的手法——ZStack 全挂载 + opacity 切换 + 关掉非活动者的命中测试。 +/// 画布尺寸恒为全屏,故切换路由**不会触发 PTY resize**。 +struct SessionCanvas: View { + @ObservedObject var manager: SessionManager + @ObservedObject var router: AppRouter + @ObservedObject var hostStore: HostStore + @ObservedObject var credStore: FileCredentialStore + @ObservedObject var tailscaleStore: TailscaleStore + + var body: some View { + ZStack { + ForEach(manager.sessions) { session in + let visible = router.route.sessionID == session.id + TerminalScreen(session: session, manager: manager, router: router, + tailscaleStore: tailscaleStore) + .opacity(visible ? 1 : 0) + // 离开终端时轻微缩小淡出,观感上"缩回首页卡片"。 + // `scaleEffect` 只是渲染变换、不改 frame → **不触发 PTY resize**, + // 故不能用 frame 动画替代(那会让 tmux 反复重排)。 + .scaleEffect(visible ? 1 : 0.97) + .animation(AppRouter.sidebarAnimation, value: visible) + .allowsHitTesting(visible) + } + } + } +} + +/// 终端主界面 · 沉浸轨道(设计稿 `1b` + `3a`)。 +/// +/// 布局:左 56pt 轨道(或 Pin 态的 288pt 面板)+ 右舞台。舞台顶部留 86pt 预留带给浮起胶囊, +/// 底部 34pt 提示条。遮罩态的面板是**浮层**,盖住轨道、不参与布局 → 终端不 reflow、不 resize; +/// 只有 Pin 切换才改变舞台宽度(各 resize 一次,符合设计约束)。 +struct TerminalScreen: View { + @ObservedObject var session: TerminalSession + @ObservedObject var manager: SessionManager + @ObservedObject var router: AppRouter + @ObservedObject var tailscaleStore: TailscaleStore + @EnvironmentObject var theme: TXThemeManager + @State private var sidebarSearch = "" + /// 「安装并启用 tmux」引导弹层。 + @State private var showTmuxInstall = false + private var p: TXPalette { theme.palette } + + var body: some View { + HStack(spacing: 0) { + if router.sidebar == .pinned { + sidebarPanel + } else { + RailView(manager: manager, router: router, session: session, + onNewHost: goHomeToAddHost, onSettings: goHomeToAddHost) + } + stage + } + .background(p.bgDeep.ignoresSafeArea()) + // 沉浸:内容顶到屏幕边缘(设计稿 1b/3a 顶部就是胶囊/logo)。状态栏的显隐归 RootView + // 统一控制 —— 本视图常驻挂载(SessionCanvas),在这里 hide 会连首页一起隐藏。 + .ignoresSafeArea(edges: .top) + // 遮罩态:面板浮在最上层盖住轨道,其余压 50% 遮罩,点终端任意处收起。 + // 动效:遮罩淡入 + 面板从左滑入(200–260ms easeOut,见 AppRouter 的动画包裹)。 + .overlay(alignment: .leading) { + if router.sidebar == .overlay { + ZStack(alignment: .leading) { + Color(hex: 0x0A0B12).opacity(0.5) + .ignoresSafeArea() + .contentShape(Rectangle()) + .onTapGesture { router.dismissOverlaySidebar() } + .transition(.opacity) + sidebarPanel + .transition(.move(edge: .leading)) + } + // 浮层要顶到屏幕边缘(面板是全高的),否则被 safe area 推低。 + .ignoresSafeArea() + } + } + // 关闭确认(3c)与分屏菜单(3b):自绘浮层,不用系统 alert/sheet —— 设计给的是精确尺寸的卡片。 + .overlay { + switch router.modal { + case .closeConfirm: + CloseConfirmDialog( + session: session, + onDetach: { router.dismissModal(); closeSession(kill: false) }, + onKill: { router.dismissModal(); closeSession(kill: true) }, + onCancel: { router.dismissModal() }) + case .splitMenu(let vertical): + SplitMenuView(session: session, vertical: vertical, + onPick: { target in + router.dismissModal() + performSplit(target, vertical: vertical) + }, + onCancel: { router.dismissModal() }) + case nil: + EmptyView() + } + if showTmuxInstall { + TmuxInstallSheet( + onRun: { cmd in + showTmuxInstall = false + session.runInTerminal(cmd) + }, + onCancel: { showTmuxInstall = false }) + } + } + // host key 告警挂在会话上(多会话各自一份):安全告警即使会话不在前台也应弹出。 .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() } + get: { session.hostKeyMismatch != nil }, + set: { if !$0 { session.dismissHostKeyMismatch() } } + ), presenting: session.hostKeyMismatch) { _ in + Button("信任新密钥", role: .destructive) { session.trustNewHostKey() } + Button("取消", role: .cancel) { session.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 sidebarPanel: SidebarPanel { + SidebarPanel(manager: manager, router: router, session: session, + tailscaleStore: tailscaleStore, + onNewHost: goHomeToAddHost, onSettings: goHomeToAddHost, + search: $sidebarSearch) + } - private var p: TXPalette { theme.palette } + /// 轨道 + 号 / 设置齿轮:首版回首页(那里有「新建连接」与设置入口)。 + /// 设计的「就地弹主机选择菜单」属 UI-4 的分屏/新建菜单一族,一起做。 + private func goHomeToAddHost() { + router.goHome() + } - var body: some View { - NavigationStack { - ZStack { - p.bg.ignoresSafeArea() + // MARK: - 舞台 - 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 留空 + private var stage: some View { + ZStack(alignment: .top) { + VStack(spacing: 0) { + // 无 tmux 时的行内引导横幅(设计 2c):占一行,把 pane 往下推,不遮内容。 + if session.tmuxUnavailable { + NoTmuxBanner(onInstall: { showTmuxInstall = true }, + onDismiss: { session.dismissTmuxBanner() }) + .padding(.top, TX.Layout.capsuleInset) + .transition(.opacity) } - - fab + // 沉浸:pane 容器**从舞台顶部就开始**(底色 + 边框铺满),胶囊悬浮其上; + // 只把 surface 内容下移 86pt 让出预留带 —— 预留带铺的是终端底色,故视觉连续。 + paneArea + // 横幅已占掉预留带时不再重复留白(设计 2c:多一条横幅时预留 122pt)。 + .padding(.top, session.tmuxUnavailable ? TX.Space.hairline : TX.Layout.capsuleInset) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(p.terminalBackground) + .clipShape(RoundedRectangle(cornerRadius: 6)) + .overlay( + RoundedRectangle(cornerRadius: 6).strokeBorder(p.border, lineWidth: 1) + ) + // 底部不留缝:pane 底边即与侧边栏脚同高的那条分割线。 + .padding([.leading, .trailing, .top], TX.Layout.paneGap) + TerminalHintBar(context: hintContext, hints: hints) } - .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) - } - } + capsules + } + .background(p.bgDeep) + .contentShape(Rectangle()) + // 点终端任意处收起遮罩态侧边栏(Pin 态不受影响)。 + .simultaneousGesture(TapGesture().onEnded { router.dismissOverlaySidebar() }) + } + + @ViewBuilder private var paneArea: some View { + ZStack { + if let tmux = session.tmuxController { + TmuxStage(controller: tmux) + } else { + RawTerminalView(model: session) } - .toolbarBackground(p.bg, for: .navigationBar) - .toolbarBackground(.visible, for: .navigationBar) - } - .tint(p.accent) - .sheet(isPresented: $showAdd) { - AddHostSheet { store.add($0) } - } - .sheet(isPresented: $showSettings) { - SettingsStubView() + // 连接中 / 失败 / 已关闭时给出解释与出路(已连接时此层不渲染,不遮挡终端)。 + TerminalStateOverlay(session: session, + onRetry: { session.retry() }, + onBack: { router.goHome() }, + onCancel: { + let id = session.id + router.goHome() + manager.close(id) + }) } } - private func sectionHeader(_ text: String) -> some View { - Text(text) - .font(.system(size: 13, weight: .medium)) - .foregroundStyle(p.textSecondary) - .padding(.leading, 4) - } - - private var emptyState: some View { - VStack(spacing: TX.Space.s) { - Image(systemName: "server.rack") - .font(.system(size: 34, weight: .light)) - .foregroundStyle(p.textTertiary) - Text("还没有主机") - .font(.system(size: 15)) - .foregroundStyle(p.textSecondary) - Text("点击右下角 + 添加一台服务器") - .font(.system(size: 13)) - .foregroundStyle(p.textTertiary) - } - .frame(maxWidth: .infinity) - .padding(.vertical, 48) - } - - /// 进行中(连接/认证/重连)——决定是否转圈。 - private var isBusy: Bool { - model.phaseText.hasSuffix("…") || model.phaseText.contains("重连") - } - - private var statusRow: some View { - HStack(spacing: TX.Space.s) { - if isBusy { ProgressView().controlSize(.small).tint(p.accent) } - Text(model.phaseText) - .font(.system(size: 13)) - .foregroundStyle(model.phaseText.hasPrefix("失败") ? .red : p.textSecondary) - } - .padding(.horizontal, 4) - } - - private var fab: some View { - VStack { - Spacer() - HStack { - Spacer() - Button { showAdd = true } label: { - Image(systemName: "plus") - .font(.system(size: 24, weight: .semibold)) - .foregroundStyle(p.bg) - .frame(width: 60, height: 60) - .background(p.accent, in: Circle()) - .shadow(color: p.accent.opacity(0.35), radius: 12, y: 4) - } - .padding(TX.Space.l) - } - } - } -} - -/// 主机卡片:服务器图标 + 状态点 / 名称 / user@ip:port(等宽)/ chevron。 -struct HostCard: View { - let host: SavedHost - let onTap: () -> Void - @EnvironmentObject var theme: TXThemeManager - private var p: TXPalette { theme.palette } - - var body: some View { - Button(action: onTap) { - HStack(spacing: TX.Space.m) { - ZStack(alignment: .topTrailing) { - Image(systemName: "server.rack") - .font(.system(size: 20, weight: .regular)) - .foregroundStyle(p.textSecondary) - .frame(width: 44, height: 44) - .background(p.surfaceElevated, in: RoundedRectangle(cornerRadius: TX.Radius.chip)) - if host.useMosh { - Circle().fill(p.mosh).frame(width: 9, height: 9) - .overlay(Circle().stroke(p.surface, lineWidth: 2)) - .offset(x: 3, y: -3) - } - } - VStack(alignment: .leading, spacing: 3) { - Text(host.name) - .font(.system(size: 16, weight: .semibold)) - .foregroundStyle(p.textPrimary) - Text(host.addressLine) - .font(TX.Font.mono(13)) - .foregroundStyle(p.textSecondary) - } - Spacer() - Image(systemName: "chevron.right") - .font(.system(size: 14, weight: .semibold)) - .foregroundStyle(p.textTertiary) - } - .padding(TX.Space.m) - .background(p.surface, in: RoundedRectangle(cornerRadius: TX.Radius.card)) - .overlay(RoundedRectangle(cornerRadius: TX.Radius.card).stroke(p.border, lineWidth: 1)) - } - .buttonStyle(.plain) - } -} - -/// 添加主机表单(深色样式)。 -struct AddHostSheet: View { - let onSave: (SavedHost) -> Void - @EnvironmentObject var theme: TXThemeManager - @Environment(\.dismiss) private var dismiss - @State private var name = "" - @State private var host = "" - @State private var port = "22" - @State private var username = "" - @State private var password = "" - @State private var useMosh = false - - private var p: TXPalette { theme.palette } - private var canSave: Bool { !host.isEmpty && !username.isEmpty } - - var body: some View { - NavigationStack { - ZStack { - p.bg.ignoresSafeArea() - Form { - Section { - 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) - } - } - .scrollContentBackground(.hidden) - } - .navigationTitle("添加主机") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("取消") { dismiss() }.tint(p.textSecondary) - } - ToolbarItem(placement: .confirmationAction) { - Button("保存") { - onSave(SavedHost( - name: name.isEmpty ? host : name, - host: host, port: Int(port) ?? 22, - username: username, password: password, useMosh: useMosh)) - dismiss() - } - .disabled(!canSave) - .tint(p.accent) - } - } - .toolbarBackground(p.bg, for: .navigationBar) - .toolbarBackground(.visible, for: .navigationBar) - } - .tint(p.accent) - } - - private func field(_ title: String, text: Binding, - 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 { - 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 + private var capsules: some View { + HStack(alignment: .top) { + TerminalTitleCapsule(session: session, onClose: requestClose, onMinimize: minimizeSession) + Spacer(minLength: TX.Space.m) + TerminalStatusCapsule(session: session) } .padding(.horizontal, TX.Space.m) - .padding(.vertical, TX.Space.s) - .background(p.bg) + .padding(.top, 14) } - 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) + /// 红点:**必须**先确认(设计要求)。确认后的动作分两套,见 `closeSession(kill:)`。 + private func requestClose() { + router.present(.closeConfirm) + } + + /// 执行关闭。tmux 会话默认只 `detach-client`(服务端继续跑);`kill=true` 才 `kill-session`。 + /// 原生窗口没有服务端兜底,直接 teardown。 + private func closeSession(kill: Bool) { + let id = session.id + if let tmux = session.tmuxController { + if kill { tmux.killSession() } else { tmux.detach() } + } + withAnimation(AppRouter.sidebarAnimation) { router.goHome() } + // 给 detach/kill 命令一点时间落地再拆传输层(tmux 会回 %exit 走既有 onExit 收尾)。 + Task { @MainActor in + try? await Task.sleep(nanoseconds: 250_000_000) + manager.close(id) + } + } + + private func minimizeSession() { + session.captureSnapshot() // 缩回首页前抓一次镜像,卡片立刻有内容 + manager.minimize(session.id) + withAnimation(AppRouter.sidebarAnimation) { router.goHome() } + } + + /// 分屏菜单选择后立即分屏(设计:选完不再问方向)。 + private func performSplit(_ target: SplitMenuView.SplitTarget, vertical: Bool) { + guard let tmux = session.tmuxController else { return } + switch target { + case .currentSession: + tmux.splitWindow(vertical: vertical) + case .otherSession(let name): + tmux.splitWindow(vertical: vertical, joinSession: name) + case .newSession: + // 名字用会话数递增,避免与既有会话撞名(tmux 撞名会报错)。 + tmux.splitIntoNewSession(vertical: vertical, name: "tx\(tmux.sessions.count + 1)") + case .nativeWindow: + break // 首版不可用(菜单里已灰显),见 HANDOFF §12.4 + } + } + + /// ⌘D / ⌘⇧D 的入口:先拉一次 list-sessions 再弹菜单(菜单要列 detached 会话)。 + private func requestSplit(vertical: Bool) { + session.tmuxController?.listSessions() + router.present(.splitMenu(vertical: vertical)) + } + + private var hintContext: String { + switch router.sidebar { + case .pinned: "Pin 态 · 终端变窄,仅在钉住/解除时 resize 一次" + case .overlay: "遮罩态 · 点终端任意处收起,终端不 reflow" + case .collapsed: session.isLive ? "已连接妙控键盘 · 附加行已收起" : session.phaseText + } + } + + private var hints: [(key: String, label: String)] { + switch router.sidebar { + case .pinned: [("Pin", "解除钉住"), ("⌘\\", "收起为轨道"), ("⌘⇧H", "回首页")] + case .overlay: [("⌘\\", "收起"), ("Pin", "钉在左侧"), ("⌘⇧H", "回首页")] + case .collapsed: [("⌘K", "命令面板"), ("⌘D", "竖分屏"), ("⌘T", "新 window"), ("⌘⇧H", "回首页")] } - .padding(.horizontal, 9).padding(.vertical, 5) - .background(color.opacity(0.13), in: Capsule()) } } /// 普通(非 tmux)单终端视图。 struct RawTerminalView: View { - @ObservedObject var model: SSHTerminalModel + @ObservedObject var model: TerminalSession @FocusState private var focused: Bool var body: some View { @@ -449,88 +407,52 @@ struct RawTerminalView: View { } } -/// tmux control-mode:window 映射为原生 tab 条 + 活动 window 终端。 -struct TmuxTabbedView: View { +/// tmux control-mode 舞台:所有 window 常驻挂载、只显活动 window。 +/// window 切换已上移到轨道数字 / 侧边栏 window 行(设计稿去掉了 tab 条)。 +struct TmuxStage: 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: TX.Space.xs) { + Group { + if controller.windows.isEmpty { + VStack { + Spacer() + Text("正在枚举 tmux window…") + .font(TX.Font.mono(12)) + .foregroundStyle(p.textWeak) + Spacer() + } + } else { + // 所有窗口常驻挂载、仅激活窗口可见:切 window 不销毁其它窗口的 pane 视图, + // 故 ghostty surface 不被释放、grid 内容保留(否则切回旧窗口会空白), + // 且后台窗口持续接收 %output 保持实时。非激活窗口关掉命中测试,避免吞触摸。 + ZStack { ForEach(controller.windows) { win in - TmuxTabChip(window: win, - isActive: win.id == controller.activeWindowID) { - controller.selectWindow(win.id) - } - } - Button { controller.newWindow() } label: { - Image(systemName: "plus") - .font(.system(size: 14, weight: .semibold)) - .foregroundStyle(p.textSecondary) - .padding(.horizontal, 8).padding(.vertical, 6) - } - } - .padding(.horizontal, TX.Space.s).padding(.vertical, TX.Space.xs) - } - .background(p.bg) - Divider().overlay(p.border) - Group { - if controller.windows.isEmpty { - Spacer(); Text("无 tmux 窗口").foregroundStyle(p.textSecondary); Spacer() - } else { - // 所有窗口常驻挂载、仅激活窗口可见:切 tab 不销毁其它窗口的 pane 视图, - // 故 ghostty surface 不被释放、grid 内容保留(否则切回旧 tab 会空白), - // 且后台窗口持续接收 %output 保持实时。非激活窗口关掉命中测试,避免吞触摸。 - ZStack { - ForEach(controller.windows) { win in - TmuxWindowView(window: win, controller: controller) - .opacity(win.id == controller.activeWindowID ? 1 : 0) - .allowsHitTesting(win.id == controller.activeWindowID) - } + 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) - } - }) } + .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) + } + }) } } -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(TX.Font.mono(13, isActive ? .semibold : .regular)).lineLimit(1) - .foregroundStyle(isActive ? p.accent : p.textSecondary) - .padding(.horizontal, 12).padding(.vertical, 6) - .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) - } -} - -/// 一个 window:按 tmux 可见布局树把多 pane 绝对定位为分屏(每 pane 一 surface)。 +/// 一个 window:按 tmux 可见布局树把多 pane 绝对定位为分屏(每 pane 一 surface)+ pane 间拖拽条。 struct TmuxWindowView: View { @ObservedObject var window: TmuxWindow let controller: TmuxController @@ -557,12 +479,16 @@ struct TmuxWindowView: View { y: (CGFloat(item.rect.y) + CGFloat(item.rect.height) / 2) * ch) } } + // pane 之间的拖拽条(在 pane 之上,热区 12pt)。 + ForEach(splitters(layout.root), id: \.id) { s in + PaneSplitter(splitter: s, cellW: cw, cellH: ch, controller: controller) + } } } else { Color.clear } } - // 容器尺寸测量已上移到 TmuxTabbedView(单一来源),此处不再驱动 containerResized。 + // 容器尺寸测量已上移到 TmuxStage(单一来源),此处不再驱动 containerResized。 } } @@ -573,22 +499,147 @@ struct TmuxWindowView: View { case .horizontal(let cs, _), .vertical(let cs, _): return cs.flatMap(leaves) } } + + /// 收集所有相邻子节点之间的分隔位置。tmux 的 `resize-pane -x/-y` 作用于**包含该 pane 的那层分割**, + /// 故 resize 目标取"前一个子节点里的任一 pane"即可。 + private func splitters(_ node: TmuxLayout.Node) -> [SplitterSpec] { + var out: [SplitterSpec] = [] + func walk(_ n: TmuxLayout.Node) { + switch n { + case .leaf: + break + case .horizontal(let children, _): + // 左右并排 → 竖直分隔条,拖动改前一个子节点的**宽度**。 + for (i, child) in children.enumerated() where i < children.count - 1 { + let r = child.rect + if let pane = firstPane(child) { + out.append(SplitterSpec( + id: "h-\(pane.raw)-\(i)", isVertical: true, pane: pane, + x: r.x + r.width, y: r.y, length: r.height, currentSize: r.width)) + } + } + children.forEach(walk) + case .vertical(let children, _): + // 上下堆叠 → 水平分隔条,拖动改前一个子节点的**高度**。 + for (i, child) in children.enumerated() where i < children.count - 1 { + let r = child.rect + if let pane = firstPane(child) { + out.append(SplitterSpec( + id: "v-\(pane.raw)-\(i)", isVertical: false, pane: pane, + x: r.x, y: r.y + r.height, length: r.width, currentSize: r.height)) + } + } + children.forEach(walk) + } + } + walk(node) + return out + } + + private func firstPane(_ node: TmuxLayout.Node) -> TmuxPaneID? { + switch node { + case .leaf(let p, _): return p + case .horizontal(let cs, _), .vertical(let cs, _): return cs.compactMap(firstPane).first + } + } } -/// 单个 pane 的终端面 + 焦点边框 + 点击选中。 +/// 一条分隔位置(格子坐标)。 +struct SplitterSpec { + let id: String + /// true = 竖直条(左右分,拖动改宽);false = 水平条(上下分,拖动改高) + let isVertical: Bool + /// resize 目标 pane(前一个子节点里的任一 pane) + let pane: TmuxPaneID + let x: Int, y: Int + /// 条的长度(格子) + let length: Int + /// 前一个子节点当前的 cols / rows + let currentSize: Int +} + +/// pane 拖拽条:热区 12pt、把手 44×3pt。 +/// +/// **拖动中只动视觉**(把手位移 + 高亮线),不改 pane frame、不发命令;**松手才发一次 `resize-pane`**, +/// 然后等 tmux 回 `%layout-change` 更新真实 frame —— 与"路线 b"一致(客户端不自行重排)。 +struct PaneSplitter: View { + let splitter: SplitterSpec + let cellW: CGFloat + let cellH: CGFloat + let controller: TmuxController + @State private var drag: CGFloat = 0 + @State private var dragging = false + @EnvironmentObject var theme: TXThemeManager + private var p: TXPalette { theme.palette } + + private var hit: CGFloat { TX.Layout.paneDragHit } + + var body: some View { + Group { + if splitter.isVertical { + bar(width: hit, height: CGFloat(splitter.length) * cellH) + .position(x: CGFloat(splitter.x) * cellW + drag, + y: (CGFloat(splitter.y) + CGFloat(splitter.length) / 2) * cellH) + } else { + bar(width: CGFloat(splitter.length) * cellW, height: hit) + .position(x: (CGFloat(splitter.x) + CGFloat(splitter.length) / 2) * cellW, + y: CGFloat(splitter.y) * cellH + drag) + } + } + .gesture( + DragGesture(minimumDistance: 2) + .onChanged { v in + dragging = true + drag = splitter.isVertical ? v.translation.width : v.translation.height + } + .onEnded { _ in + // 位移 → 格子增量;松手发一次命令,视觉位移立刻归零(等 tmux 回布局)。 + let cell = splitter.isVertical ? cellW : cellH + let delta = cell > 0 ? Int((drag / cell).rounded()) : 0 + let newSize = max(1, splitter.currentSize + delta) + if delta != 0 { + controller.resizePane(splitter.pane, + cols: splitter.isVertical ? newSize : nil, + rows: splitter.isVertical ? nil : newSize) + } + drag = 0 + dragging = false + } + ) + } + + /// 热区透明 + 居中 44×3pt 把手(拖动中把手转强调色)。 + private func bar(width: CGFloat, height: CGFloat) -> some View { + ZStack { + Color.clear.frame(width: width, height: height) + RoundedRectangle(cornerRadius: 2) + .fill(dragging ? TXAccent.base : p.borderStrong) + .frame(width: splitter.isVertical ? 3 : 44, + height: splitter.isVertical ? 44 : 3) + .opacity(dragging ? 1 : 0.55) + } + .contentShape(Rectangle()) + } +} + +/// 单个 pane 的终端面 + 焦点边框 + 点击选中。pane 之间留 2pt 缝(设计:舞台底色透出为缝)。 struct TmuxPaneView: View { @ObservedObject var surface: TmuxPaneSurface let isActive: Bool let onTap: () -> Void let onReady: () -> Void @FocusState private var focused: Bool + @EnvironmentObject var theme: TXThemeManager + private var p: TXPalette { theme.palette } var body: some View { TerminalSurfaceView(context: surface.state) .terminalFocusOnAppear($focused) + .clipShape(RoundedRectangle(cornerRadius: 6)) .overlay( - Rectangle().stroke(isActive ? Color.accentColor : Color.gray.opacity(0.35), - lineWidth: isActive ? 2 : 0.5) + RoundedRectangle(cornerRadius: 6) + .strokeBorder(isActive ? TXAccent.border : p.divider, + lineWidth: isActive ? 1 : 0.5) ) // 非活动 pane 叠透明 tap-catcher(TerminalSurfaceView 会吞触摸);活动 pane 直通终端。 .overlay { @@ -596,6 +647,7 @@ struct TmuxPaneView: View { Color.black.opacity(0.04).contentShape(Rectangle()).onTapGesture { onTap() } } } + .padding(TX.Layout.paneGap / 2) // 相邻 pane 各让 1pt → 缝 2pt .onChange(of: isActive) { _, active in focused = active } .task { for _ in 0 ..< 300 where surface.state.surfaceSize == nil { diff --git a/apps/TerminalX/iOS/Credential.swift b/apps/TerminalX/iOS/Credential.swift new file mode 100644 index 0000000..73d9c92 --- /dev/null +++ b/apps/TerminalX/iOS/Credential.swift @@ -0,0 +1,92 @@ +import Foundation + +/// 一条 Keychain 凭据(元数据部分,**不含机密**)。 +/// 机密(密码/私钥材料/passphrase)经 `CredentialStore.secret(for:)` 按需单条取, +/// 列表 UI 只碰这里的 meta,永不为渲染而加载机密(对齐签名后 Keychain 后端的天然分层)。 +struct Credential: Identifiable, Codable, Equatable { + var id: UUID = UUID() + var name: String + var username: String + var kind: Kind + var createdAt: Date = Date() + + enum Kind: Codable, Equatable { + /// 用户名 + 密码。 + case password + /// SSH 私钥。`publicKeyBlob` 冗余存 SSH 线格式公钥,供列表展示指纹/复制 authorized_keys 而不碰机密。 + case privateKey(keyType: KeyType, source: Source, hasPassphrase: Bool, publicKeyBlob: Data) + /// Secure Enclave(Face ID)密钥——机密不出 SE,只存 tag 引用。未签名构建下建不出,UI 占位。 + case secureEnclave(keyTag: String) + } + + /// 私钥来源。 + enum Source: String, Codable { case pasted, imported, generated } + + /// 私钥算法。libssh2 无 Ed25519、有 ECDSA;"生成新的"默认 P-256。 + enum KeyType: String, Codable { + case ecdsaP256, ecdsaP384, ecdsaP521, rsa, unknown + + var displayName: String { + switch self { + case .ecdsaP256: "ECDSA P-256" + case .ecdsaP384: "ECDSA P-384" + case .ecdsaP521: "ECDSA P-521" + case .rsa: "RSA" + case .unknown: "SSH 私钥" + } + } + } + + // MARK: - 展示辅助 + + var kindLabel: String { + switch kind { + case .password: "密码" + case .privateKey(let t, _, _, _): t.displayName + case .secureEnclave: "Face ID 密钥" + } + } + + var iconName: String { + switch kind { + case .password: "key.fill" + case .privateKey: "lock.doc.fill" + case .secureEnclave: "faceid" + } + } + + /// SSH 线格式公钥 blob(仅私钥类凭据有)。 + var publicKeyBlob: Data? { + if case .privateKey(_, _, _, let blob) = kind { return blob } + return nil + } + + /// authorized_keys 行(生成/导入的私钥可复制到服务器)。 + var authorizedKeysLine: String? { + guard let blob = publicKeyBlob, !blob.isEmpty else { return nil } + // 目前生成的都是 ecdsa-sha2-nistp256;导入的按 keyType 推断算法名。 + let algo: String + if case .privateKey(let t, _, _, _) = kind { + switch t { + case .ecdsaP256: algo = "ecdsa-sha2-nistp256" + case .ecdsaP384: algo = "ecdsa-sha2-nistp384" + case .ecdsaP521: algo = "ecdsa-sha2-nistp521" + case .rsa: algo = "ssh-rsa" + case .unknown: return nil + } + } else { return nil } + return "\(algo) \(blob.base64EncodedString()) \(username)@terminalx" + } +} + +/// 凭据机密(按需加载,自描述解释方式)。 +enum CredentialSecret: Codable, Equatable { + /// 密码 → `.password` 认证。 + case password(String) + /// PEM / PKCS#8 私钥文本(导入/粘贴)→ libssh2 `publickey_frommemory`(路径 B)。 + case pemPrivateKey(pem: Data, passphrase: String?) + /// SecKey 外部表示(生成的软件 P-256 私钥)→ 重建 SecKey → SSHSigner(路径 A)。 + case rawECPrivateKey(x963: Data) + /// 无机密(Secure Enclave:机密不出安全域)。 + case none +} diff --git a/apps/TerminalX/iOS/CredentialAuth.swift b/apps/TerminalX/iOS/CredentialAuth.swift new file mode 100644 index 0000000..85d04a8 --- /dev/null +++ b/apps/TerminalX/iOS/CredentialAuth.swift @@ -0,0 +1,84 @@ +import Foundation +import TXTransport + +/// 凭据 ↔ SSH 认证 的桥。三条各自很薄的路径: +/// - 密码 → `.password` +/// - 生成的软件 P-256 → 重建 SecKey → `SSHSigner` → `.publicKeyCallback`(路径 A) +/// - 粘贴/导入 的 PEM/PKCS#8 → `.privateKey`(libssh2 `publickey_frommemory`,路径 B) +/// +/// 不自造私钥格式解析器:导入的私钥直接透传 libssh2/mbedTLS。只做**轻量头部嗅探**推断类型/加密/警告。 +enum CredentialAuth { + + // MARK: - 生成 + + /// 生成一把软件 P-256 私钥 → 返回可存入 CredentialStore 的 (kind, secret)。 + static func generateECDSAP256() -> (kind: Credential.Kind, secret: CredentialSecret)? { + guard let (provider, x963) = SigningKeyProvider.generateSoftwareP256() else { return nil } + let kind = Credential.Kind.privateKey( + keyType: .ecdsaP256, source: .generated, hasPassphrase: false, + publicKeyBlob: Data(provider.publicKeyBlob)) + return (kind, .rawECPrivateKey(x963: x963)) + } + + // MARK: - 导入 / 粘贴 + + struct ParsedKey { + var kind: Credential.Kind + var secret: CredentialSecret + /// 人类可读的格式标签(UI 实时提示,如 "PEM · EC 私钥")。 + var formatLabel: String + /// 非致命警告(如 openssh-key-v1 mbedTLS 不认)。nil = 无。 + var warning: String? + } + + /// 嗅探私钥文本头部,产出凭据 kind + 机密(路径 B)。识别不了也照存(交由 libssh2 定夺)。 + static func parsePrivateKey(_ text: String, source: Credential.Source) -> ParsedKey { + let t = text.trimmingCharacters(in: .whitespacesAndNewlines) + let encrypted = t.contains("Proc-Type: 4,ENCRYPTED") + || t.contains("-----BEGIN ENCRYPTED PRIVATE KEY-----") + + var keyType: Credential.KeyType = .unknown + var label = "SSH 私钥" + var warning: String? + + if t.contains("-----BEGIN OPENSSH PRIVATE KEY-----") { + label = "OpenSSH 新格式" + warning = "mbedTLS 不识别 openssh-key-v1,可能认证失败。建议先 `ssh-keygen -p -m PEM` 转为 PEM。" + } else if t.contains("-----BEGIN RSA PRIVATE KEY-----") { + keyType = .rsa; label = "PEM · RSA (PKCS#1)" + } else if t.contains("-----BEGIN EC PRIVATE KEY-----") { + keyType = .ecdsaP256; label = "PEM · EC (SEC1)" + } else if t.contains("-----BEGIN ENCRYPTED PRIVATE KEY-----") { + label = "PKCS#8(加密)" + } else if t.contains("-----BEGIN PRIVATE KEY-----") { + label = "PKCS#8" + } else { + warning = "未识别到 PEM 私钥头,请确认粘贴的是私钥内容。" + } + + let kind = Credential.Kind.privateKey( + keyType: keyType, source: source, hasPassphrase: encrypted, publicKeyBlob: Data()) + let secret = CredentialSecret.pemPrivateKey(pem: Data(text.utf8), passphrase: nil) + return ParsedKey(kind: kind, secret: secret, formatLabel: label, warning: warning) + } + + // MARK: - 构造 Authentication + + /// 由凭据 meta + 机密构造 SSH 认证。返回 nil = 无法用于连接(如 SE 未签名建不出、机密缺失)。 + /// `passphraseOverride` 供连接时按需补输加密私钥口令。 + static func authentication(for cred: Credential, secret: CredentialSecret, + passphraseOverride: String? = nil) -> SSHConfig.Authentication? { + switch secret { + case .password(let pw): + return .password(pw) + case .pemPrivateKey(let pem, let storedPass): + let pem = String(decoding: pem, as: UTF8.self) + return .privateKey(pem: pem, passphrase: passphraseOverride ?? storedPass) + case .rawECPrivateKey(let x963): + guard let signer = SigningKeyProvider.from(x963: x963) else { return nil } + return .publicKeyCallback(signer) + case .none: + return nil // Secure Enclave 占位:未签名无法签名 + } + } +} diff --git a/apps/TerminalX/iOS/CredentialStore.swift b/apps/TerminalX/iOS/CredentialStore.swift new file mode 100644 index 0000000..1515625 --- /dev/null +++ b/apps/TerminalX/iOS/CredentialStore.swift @@ -0,0 +1,78 @@ +import Foundation + +/// 凭据存储抽象:meta 与机密分离。 +/// 让「未签名文件后端」与「签名后 Keychain 后端」可无缝替换——列表只用 `list()`(纯 meta), +/// 机密经 `secret(for:)` 按需单条取(Keychain 后端天然 meta 在 JSON、机密在 SecItem)。 +@MainActor +protocol CredentialStore: AnyObject { + func list() -> [Credential] + func secret(for id: UUID) -> CredentialSecret? + func upsert(_ cred: Credential, secret: CredentialSecret?) // secret == nil 时仅改 meta + func delete(_ id: UUID) +} + +/// 未签名构建的文件后端。 +/// +/// 为何不自造加密:无根信任(无 Keychain/SE 兜底存主密钥)则本地对称加密是伪安全—— +/// 能读机密文件者同样能读密钥文件。威胁模型为开发自用工具 / 模拟器 / 非越狱设备, +/// 沙盒明文与现有 host 明文密码一致即可。做三件几乎免费的加固: +/// 0600 权限、`isExcludedFromBackup`(防机密进 iCloud/iTunes 备份)、`FileProtectionType.complete`(锁屏加密)。 +/// 信封版本化(`scheme: "plaintext-v1"`)便于签名后按 scheme 分派迁移。 +@MainActor +final class FileCredentialStore: ObservableObject, CredentialStore { + @Published private(set) var credentials: [Credential] = [] + + private let url: URL + private var secrets: [UUID: CredentialSecret] = [:] + + private struct Entry: Codable { var meta: Credential; var secret: CredentialSecret } + private struct Envelope: Codable { var scheme: String; var entries: [Entry] } + private static let scheme = "plaintext-v1" + + init() { + let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + try? FileManager.default.createDirectory(at: base, withIntermediateDirectories: true) + url = base.appendingPathComponent("credentials.json") + load() + } + + func list() -> [Credential] { credentials } + + func secret(for id: UUID) -> CredentialSecret? { secrets[id] } + + func upsert(_ cred: Credential, secret: CredentialSecret?) { + if let i = credentials.firstIndex(where: { $0.id == cred.id }) { + credentials[i] = cred + } else { + credentials.append(cred) + } + if let secret { secrets[cred.id] = secret } + persist() + } + + func delete(_ id: UUID) { + credentials.removeAll { $0.id == id } + secrets[id] = nil + persist() + } + + // MARK: - 持久化 + + private func load() { + guard let data = try? Data(contentsOf: url), + let env = try? JSONDecoder().decode(Envelope.self, from: data) else { return } + credentials = env.entries.map(\.meta) + secrets = Dictionary(uniqueKeysWithValues: env.entries.map { ($0.meta.id, $0.secret) }) + } + + private func persist() { + let entries = credentials.map { Entry(meta: $0, secret: secrets[$0.id] ?? .none) } + let env = Envelope(scheme: Self.scheme, entries: entries) + guard let data = try? JSONEncoder().encode(env) else { return } + try? data.write(to: url, options: [.atomic, .completeFileProtection]) + try? FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path) + var u = url + var rv = URLResourceValues(); rv.isExcludedFromBackup = true + try? u.setResourceValues(rv) + } +} diff --git a/apps/TerminalX/iOS/HomeView.swift b/apps/TerminalX/iOS/HomeView.swift new file mode 100644 index 0000000..26ab866 --- /dev/null +++ b/apps/TerminalX/iOS/HomeView.swift @@ -0,0 +1,632 @@ +import SwiftUI + +/// 首页 · 主机管理(设计稿 `1e 主机-会话卡`)。 +/// +/// App 冷启动落点。首要动作是**「回到刚才那个会话」**,所以活动会话卡在最上面、占最大面积; +/// 「全部主机」在下面按内容高度铺 4 列。左右留白 40pt。 +struct HomeView: View { + @ObservedObject var manager: SessionManager + @ObservedObject var router: AppRouter + @ObservedObject var hostStore: HostStore + @ObservedObject var credStore: FileCredentialStore + @ObservedObject var tailscaleStore: TailscaleStore + @EnvironmentObject var theme: TXThemeManager + + @State private var search = "" + @State private var groupFilter: String? = nil + @State private var showEditor = false + @State private var editing: SavedHost? + @State private var showSettings = false + + private var p: TXPalette { theme.palette } + + /// 分组 chips:「全部」+ 主机里出现过的分组(按首次出现顺序,不排序以免抖动)。 + private var groups: [String] { + var seen: [String] = [] + for h in hostStore.hosts { + if let g = h.group, !seen.contains(g) { seen.append(g) } + } + return seen + } + + private var filteredHosts: [SavedHost] { + hostStore.hosts.filter { h in + let matchesGroup = groupFilter == nil || h.group == groupFilter + guard matchesGroup else { return false } + guard !search.isEmpty else { return true } + let q = search.lowercased() + return h.name.lowercased().contains(q) || h.host.lowercased().contains(q) + || (h.os?.lowercased().contains(q) ?? false) + } + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + header + if hostStore.hosts.isEmpty && manager.sessions.isEmpty { + firstRunEmptyState + } else { + ScrollView { + VStack(alignment: .leading, spacing: TX.Space.m) { + // 点主机后先留在首页探测 tmux → 这里给出进度,不让用户对着不动的界面等。 + ForEach(manager.connectingSessions) { s in + ConnectingRow(session: s) { manager.close(s.id) } + } + if liveSessions.isEmpty { + if manager.connectingSessions.isEmpty { noSessionHint } + } else { + TXSectionLabel(text: "活动会话") + .padding(.top, TX.Space.xs) + sessionCards + } + HStack(alignment: .center) { + TXSectionLabel(text: "全部主机") + Spacer() + if !groups.isEmpty { chips } + } + .padding(.top, manager.sessions.isEmpty ? TX.Space.xs : TX.Space.s) + if filteredHosts.isEmpty { + noMatchState + } else { + hostGrid + } + } + .padding(.horizontal, TX.Space.screenGutter) + .padding(.bottom, TX.Space.xxl) + } + } + } + .background(p.bg.ignoresSafeArea()) + // 首页可见时轮询会话内容做文本镜像(缩略卡「真实回滚」的落地方式)。 + .task(id: manager.sessions.count) { + while !Task.isCancelled { + for s in manager.sessions { s.captureSnapshot() } + try? await Task.sleep(nanoseconds: 1_500_000_000) + } + } + .sheet(isPresented: $showEditor) { + HostEditorView(existing: editing, credStore: credStore, tailscaleStore: tailscaleStore, + existingGroups: groups) { host in + if hostStore.hosts.contains(where: { $0.id == host.id }) { hostStore.update(host) } + else { hostStore.add(host) } + } + } + .sheet(isPresented: $showSettings) { + SettingsHost(credStore: credStore, tailscaleStore: tailscaleStore) + } + } + + // MARK: - 空状态 + + /// 首次运行:一台主机都没有。给一条明确的下一步,而不是空网格。 + private var firstRunEmptyState: some View { + VStack(spacing: TX.Space.m) { + Spacer() + Image(systemName: "terminal") + .font(.system(size: 40, weight: .ultraLight)) + .foregroundStyle(TXAccent.mid) + VStack(spacing: 6) { + Text("还没有主机") + .font(TX.Font.mono(15, .medium)) + .foregroundStyle(p.text) + Text("添加一台服务器就能开始:SSH 直连,或经 Tailscale 出口访问内网。\n开启 mosh 后断线自动接回。") + .font(TX.Font.mono(12)) + .foregroundStyle(p.textWeak) + .multilineTextAlignment(.center) + .lineSpacing(4) + } + HStack(spacing: TX.Space.s) { + TXButton(title: "新建连接", emphasized: true) { + editing = nil + showEditor = true + } + TXButton(title: "先配置 Tailscale") { showSettings = true } + } + .padding(.top, TX.Space.xs) + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + /// 有主机但没有会话:不留白,提示从下面的主机开始。 + private var noSessionHint: some View { + HStack(spacing: TX.Space.s) { + Image(systemName: "arrow.down") + .font(.system(size: 12)) + .foregroundStyle(p.textWeak) + Text("还没有在跑的会话 —— 点下面任意主机即可连接") + .font(TX.Font.mono(12)) + .foregroundStyle(p.textWeak) + } + .padding(.horizontal, TX.Space.s) + .frame(height: 40) + .frame(maxWidth: .infinity, alignment: .leading) + .background(p.surfaceDark, in: RoundedRectangle(cornerRadius: TX.Radius.card)) + .overlay( + RoundedRectangle(cornerRadius: TX.Radius.card) + .strokeBorder(style: StrokeStyle(lineWidth: 1, dash: [4, 4])) + .foregroundStyle(p.divider) + ) + .padding(.top, TX.Space.xs) + } + + /// 搜索/筛选无命中:说明命中为空的原因(是筛选还是搜索),并给一键清除。 + private var noMatchState: some View { + VStack(spacing: TX.Space.s) { + Text(search.isEmpty ? "该分组下没有主机" : "没有匹配「\(search)」的主机") + .font(TX.Font.mono(12.5, .medium)) + .foregroundStyle(p.text3) + TXButton(title: "清除筛选") { + search = "" + groupFilter = nil + } + } + .frame(maxWidth: .infinity) + .padding(.vertical, TX.Space.xl) + } + + // MARK: - 页头 + + private var header: some View { + HStack(alignment: .top) { + VStack(alignment: .leading, spacing: 4) { + Text("terminalX") + .font(TX.Font.mono(34, .medium)) + .tracking(-0.85) + .foregroundStyle(p.text) + Text(subtitle) + .font(TX.Font.mono(12)) + .foregroundStyle(p.textWeak) + } + Spacer() + HStack(spacing: TX.Space.s) { + TXSearchField(text: $search, placeholder: "搜索", shortcut: "⌘K") + .frame(width: 220) + TXButton(title: "新建连接", emphasized: true) { + editing = nil + showEditor = true + } + TXIconButton(icon: "gearshape", label: "设置") { showSettings = true } + } + } + .padding(.horizontal, TX.Space.screenGutter) + .padding(.top, TX.Space.l) + .padding(.bottom, TX.Space.m) + } + + private var subtitle: String { + var parts: [String] = [] + if !manager.sessions.isEmpty { parts.append("\(manager.sessions.count) 个会话在跑") } + parts.append("\(hostStore.hosts.count) 台主机") + if let tailnet = tailscaleStore.connections.first { + parts.append("Tailscale \(tailnet.name)") + } + return parts.joined(separator: " · ") + } + + private var chips: some View { + HStack(spacing: TX.Space.xs) { + TXChip(text: "全部", isSelected: groupFilter == nil) { groupFilter = nil } + ForEach(groups, id: \.self) { g in + TXChip(text: g, isSelected: groupFilter == g) { groupFilter = g } + } + } + } + + /// 首页的「正在连接」行:点主机后到进终端之间的反馈(探测 tmux、等选择、失败)。 +/// 独立视图以直接订阅该会话(`SessionManager` 的转发只保证 manager 自身刷新)。 +struct ConnectingRow: View { + @ObservedObject var session: TerminalSession + let onCancel: () -> Void + @EnvironmentObject var theme: TXThemeManager + private var p: TXPalette { theme.palette } + + private var failed: Bool { session.phaseText.hasPrefix("失败") } + + var body: some View { + HStack(spacing: TX.Space.s) { + if failed { + Image(systemName: "exclamationmark.triangle") + .font(.system(size: 13)) + .foregroundStyle(p.danger) + } else { + ProgressView().controlSize(.small).tint(TXAccent.base) + } + VStack(alignment: .leading, spacing: 2) { + Text(failed ? "连接 \(session.displayName) 失败" : "正在连接 \(session.displayName)") + .font(TX.Font.mono(13, .medium)) + .foregroundStyle(p.text) + Text(detail) + .font(TX.Font.mono(11)) + .foregroundStyle(failed ? p.danger : p.textWeak) + .lineLimit(1) + } + Spacer(minLength: TX.Space.s) + if failed { + TXButton(title: "重试") { session.retry() } + } + TXButton(title: failed ? "移除" : "取消", action: onCancel) + } + .padding(.horizontal, TX.Space.m) + .padding(.vertical, TX.Space.s) + .frame(maxWidth: .infinity, alignment: .leading) + .background(p.surfaceDark, in: RoundedRectangle(cornerRadius: TX.Radius.card)) + .overlay( + RoundedRectangle(cornerRadius: TX.Radius.card) + .strokeBorder(failed ? p.danger.opacity(0.4) : p.border, lineWidth: 1) + ) + .padding(.top, TX.Space.xs) + } + + private var detail: String { + if failed { return String(session.phaseText.dropFirst(3)) } + if session.pendingSessionChoice != nil { return "等待选择 tmux 会话" } + if !session.banner.isEmpty { return session.banner } + return session.phaseText + } +} + +// MARK: - 活动会话卡(3 列等宽) + + /// 已建立的会话。**排除正在连接的**(它们走 ConnectingRow)—— 否则同一会话既有进度行又有卡片, + /// 而且卡片里的 tmux 信息此刻还没定(会先显示成「原生窗口」再跳变)。 + private var liveSessions: [TerminalSession] { + let connecting = Set(manager.connectingSessions.map(\.id)) + return manager.activeSessions.filter { + ($0.isLive || $0.isMinimized) && !connecting.contains($0.id) + } + } + + private var sessionCards: some View { + HStack(alignment: .top, spacing: TX.Space.m) { + ForEach(liveSessions) { s in + SessionCard( + session: s, + shortcutIndex: (liveSessions.firstIndex { $0.id == s.id } ?? 0) + 1, + isFocused: manager.focusedID == s.id + ) { + manager.focus(s) + router.enter(s.id) + } + } + } + .frame(height: 560) + } + + // MARK: - 全部主机(4 列,按内容高度) + + private var hostGrid: some View { + LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: TX.Space.m), count: 4), + spacing: TX.Space.m) { + ForEach(filteredHosts) { host in + HostTile(host: host, + credName: credName(for: host), + hasTsnet: host.tailscaleID != nil, + liveSession: manager.sessions.first { $0.host?.id == host.id }) { + // 只发起连接:探测 tmux → 弹会话选择器 → 选完由 manager.enterRequest 进终端。 + manager.open(host: host) + } + .contextMenu { + Button { editing = host; showEditor = true } label: { + Label("编辑", systemImage: "pencil") + } + Button(role: .destructive) { hostStore.remove(host) } label: { + Label("删除", systemImage: "trash") + } + } + } + addHostTile + } + } + + private var addHostTile: some View { + Button { + editing = nil + showEditor = true + } label: { + VStack { + Text("添加主机") + .font(TX.Font.mono(12, .medium)) + .foregroundStyle(p.textWeak) + } + .frame(maxWidth: .infinity, minHeight: 96) + .background( + RoundedRectangle(cornerRadius: TX.Radius.card) + .strokeBorder(style: StrokeStyle(lineWidth: 1, dash: [4, 4])) + .foregroundStyle(p.border) + ) + } + .buttonStyle(.plain) + } + + private func credName(for host: SavedHost) -> String? { + if case .credential(let id) = host.auth { + return credStore.list().first { $0.id == id }?.name + } + return nil + } +} + +/// 首页的「正在连接」行:点主机后到进终端之间的反馈(探测 tmux、等选择、失败)。 +/// 独立视图以直接订阅该会话(`SessionManager` 的转发只保证 manager 自身刷新)。 +struct ConnectingRow: View { + @ObservedObject var session: TerminalSession + let onCancel: () -> Void + @EnvironmentObject var theme: TXThemeManager + private var p: TXPalette { theme.palette } + + private var failed: Bool { session.phaseText.hasPrefix("失败") } + + var body: some View { + HStack(spacing: TX.Space.s) { + if failed { + Image(systemName: "exclamationmark.triangle") + .font(.system(size: 13)) + .foregroundStyle(p.danger) + } else { + ProgressView().controlSize(.small).tint(TXAccent.base) + } + VStack(alignment: .leading, spacing: 2) { + Text(failed ? "连接 \(session.displayName) 失败" : "正在连接 \(session.displayName)") + .font(TX.Font.mono(13, .medium)) + .foregroundStyle(p.text) + Text(detail) + .font(TX.Font.mono(11)) + .foregroundStyle(failed ? p.danger : p.textWeak) + .lineLimit(1) + } + Spacer(minLength: TX.Space.s) + if failed { + TXButton(title: "重试") { session.retry() } + } + TXButton(title: failed ? "移除" : "取消", action: onCancel) + } + .padding(.horizontal, TX.Space.m) + .padding(.vertical, TX.Space.s) + .frame(maxWidth: .infinity, alignment: .leading) + .background(p.surfaceDark, in: RoundedRectangle(cornerRadius: TX.Radius.card)) + .overlay( + RoundedRectangle(cornerRadius: TX.Radius.card) + .strokeBorder(failed ? p.danger.opacity(0.4) : p.border, lineWidth: 1) + ) + .padding(.top, TX.Space.xs) + } + + private var detail: String { + if failed { return String(session.phaseText.dropFirst(3)) } + if session.pendingSessionChoice != nil { return "等待选择 tmux 会话" } + if !session.banner.isEmpty { return session.banner } + return session.phaseText + } +} + +// MARK: - 活动会话卡 + +/// 会话缩略卡:卡头(状态 + 主机名 + tmux + 链路)· 终端文本镜像(底对齐)· 卡脚(元信息 + 主动作)。 +/// 离线/重连中的卡片压一层遮罩 + 说明(设计要求遮罩是内容的最后一层,故用 overlay)。 +struct SessionCard: View { + @ObservedObject var session: TerminalSession + let shortcutIndex: Int + let isFocused: Bool + let onOpen: () -> Void + @EnvironmentObject var theme: TXThemeManager + private var p: TXPalette { theme.palette } + + private var statusColor: Color { + switch session.link { + case .direct, .relay: p.online + case .reconnecting: p.reconnecting + case .connecting: p.text4 + case .offline: p.offline + } + } + + private var isTroubled: Bool { + switch session.link { + case .reconnecting, .offline: true + default: false + } + } + + var body: some View { + VStack(spacing: 0) { + cardHead + Rectangle().fill(p.divider).frame(height: 1) + // 离线/重连时缓存内容轻微模糊 + 压遮罩(设计:blur(1.5px) + rgba(24,26,40,.72)), + // 说明「内容还在,只是链路断了」。 + TXTerminalMirror(lines: session.snapshotLines, background: p.terminalBackground) + .blur(radius: isTroubled ? 1.5 : 0) + .overlay { if isTroubled { troubleVeil } } + Rectangle().fill(p.divider).frame(height: 1) + cardFoot + } + .background(p.surfaceDark) + .clipShape(RoundedRectangle(cornerRadius: 13)) + .overlay( + RoundedRectangle(cornerRadius: 13) + .strokeBorder(isFocused ? TXAccent.selectedStroke : p.border, lineWidth: 1) + ) + .shadow(color: .black.opacity(isFocused ? 0.4 : 0.2), radius: isFocused ? 9 : 5, y: 6) + .contentShape(Rectangle()) + .onTapGesture(perform: onOpen) + } + + private var cardHead: some View { + HStack(spacing: TX.Space.xs) { + TXStatusDot(color: statusColor) + Text(session.displayName) + .font(TX.Font.mono(13, .semibold)) + .foregroundStyle(p.text) + .lineLimit(1) + if let t = session.tmuxSummary { + Text(t.name.isEmpty ? "tmux" : "tmux \(t.name)") + .font(TX.Font.mono(11)) + .foregroundStyle(p.textWeak) + .lineLimit(1) + } + Spacer(minLength: TX.Space.xs) + linkTag + } + .padding(.horizontal, TX.Space.s) + .frame(height: 36) + } + + /// 链路标:设计里这格是 RTT 数字,首版无 RTT 管道 → 显示链路类型/重连次数(见 HANDOFF §12.4)。 + @ViewBuilder private var linkTag: some View { + switch session.link { + case .direct: + tag("direct", p.online) + case .relay: + tag("tsnet", p.online) + case .connecting: + tag("连接中", p.text4) + case .reconnecting(let n): + tag("重连中 · 第 \(n) 次", p.reconnecting) + case .offline: + tag("离线", p.offline) + } + } + + private func tag(_ s: String, _ c: Color) -> some View { + Text(s).font(TX.Font.mono(11, .medium)).foregroundStyle(c).lineLimit(1) + } + + /// 遮罩:转圈 + 两行说明。压在终端镜像之上,说明「屏幕内容已缓存,恢复后原样接回」。 + private var troubleVeil: some View { + ZStack { + Color(hex: 0x181A28).opacity(0.72) + VStack(spacing: TX.Space.s) { + if case .reconnecting = session.link { + ProgressView().controlSize(.regular).tint(p.reconnecting) + } + Text(veilTitle) + .font(TX.Font.mono(12, .medium)) + .foregroundStyle(p.reconnecting) + Text("屏幕内容已缓存,恢复后原样接回") + .font(TX.Font.mono(10.5)) + .foregroundStyle(p.text4) + } + .multilineTextAlignment(.center) + .padding(TX.Space.m) + } + } + + private var veilTitle: String { + switch session.link { + case .reconnecting(let n): "\(session.moshEngaged ? "mosh " : "")会话保活中 · 第 \(n) 次重连" + default: "已断开" + } + } + + private var cardFoot: some View { + HStack(spacing: TX.Space.xs) { + Text(footMeta) + .font(TX.Font.mono(11)) + .foregroundStyle(p.textWeak) + .lineLimit(1) + Spacer(minLength: TX.Space.xs) + Text("⌘\(shortcutIndex)") + .font(TX.Font.mono(10.5)) + .foregroundStyle(p.textFaint) + Button(action: onOpen) { + Text(isTroubled ? "立即重试" : "回到会话 →") + .font(TX.Font.mono(11.5, .medium)) + .foregroundStyle(TXAccent.textBright) + } + .buttonStyle(.plain) + } + .padding(.horizontal, TX.Space.s) + .frame(height: 38) + } + + private var footMeta: String { + var parts: [String] = [] + if let t = session.tmuxSummary { + parts.append("\(t.windows) window\(t.windows == 1 ? "" : "s")") + if t.panes > 0 { parts.append("\(t.panes) panes") } + } else { + parts.append("原生窗口") + } + if session.isMinimized { + parts.append("最小化中") + } else { + parts.append(Self.relative(session.lastActiveAt)) + } + return parts.joined(separator: " · ") + } + + /// 「上次活跃」相对时间(秒/分/小时/天)。 + static func relative(_ date: Date) -> String { + let s = Int(Date().timeIntervalSince(date)) + if s < 60 { return "\(max(1, s)) 秒前" } + if s < 3600 { return "\(s / 60) 分钟前" } + if s < 86400 { return "\(s / 3600) 小时前" } + return "\(s / 86400) 天前" + } +} + +// MARK: - 主机卡 + +/// 主机卡(4 列网格):图标 + 名称 + os 副行 + 徽章行 + 右上状态点。 +struct HostTile: View { + let host: SavedHost + let credName: String? + let hasTsnet: Bool + /// 该主机是否已有活动会话(有则右上点亮,点击复用而非新建)。 + let liveSession: TerminalSession? + let onTap: () -> Void + @EnvironmentObject var theme: TXThemeManager + private var p: TXPalette { theme.palette } + + private var icon: String { + let os = (host.os ?? "").lowercased() + if os.contains("macos") { return "desktopcomputer" } + if os.contains("raspberry") { return "cpu" } + if os.contains("nas") || host.name.lowercased().contains("nas") { return "externaldrive" } + return "server.rack" + } + + var body: some View { + Button(action: onTap) { + VStack(alignment: .leading, spacing: TX.Space.s) { + HStack(alignment: .top, spacing: TX.Space.s) { + Image(systemName: icon) + .font(.system(size: 15, weight: .light)) + .foregroundStyle(p.text4) + .frame(width: 30, height: 30) + .background(p.surface, in: RoundedRectangle(cornerRadius: 8)) + VStack(alignment: .leading, spacing: 2) { + Text(host.name) + .font(TX.Font.mono(13, .semibold)) + .foregroundStyle(p.text) + .lineLimit(1) + Text(host.os ?? host.addressLine) + .font(TX.Font.mono(10.5)) + .foregroundStyle(p.textWeak) + .lineLimit(1) + } + Spacer(minLength: 0) + if let s = liveSession { + TXStatusDot(color: s.link == .offline ? p.offline + : (s.isMinimized ? TXAccent.mid : p.online)) + } else { + TXStatusDot(color: p.offline.opacity(0.5)) + } + } + HStack(spacing: 5) { + if host.useMosh { TXBadge(text: "mosh") } else { TXBadge(text: "SSH") } + if hasTsnet { TXBadge(text: "tsnet") } + if let credName { TXBadge(text: credName, emphasized: true) } + } + } + .padding(TX.Space.s) + .frame(maxWidth: .infinity, alignment: .leading) + .background(p.surfaceDark, in: RoundedRectangle(cornerRadius: TX.Radius.card)) + .overlay( + RoundedRectangle(cornerRadius: TX.Radius.card) + .strokeBorder(p.border, lineWidth: 1) + ) + } + .buttonStyle(.plain) + } +} diff --git a/apps/TerminalX/iOS/HostStore.swift b/apps/TerminalX/iOS/HostStore.swift index a13a2db..d7bfa6e 100644 --- a/apps/TerminalX/iOS/HostStore.swift +++ b/apps/TerminalX/iOS/HostStore.swift @@ -1,18 +1,103 @@ import Foundation -/// 已保存的主机(连接目标)。 -/// 注:`password` 暂存本地 JSON 仅供 demo;产品化后应移入 Keychain(未签名时 SecItem 不可用,见 HANDOFF)。 +/// 已保存的主机(连接目标)。认证经 `AuthRef` 引用凭据或内联密码;出口经可选 `tailscaleID`。 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 + /// 登录后自动进入 tmux control mode(`tmux -CC new -A`)。默认开——tmux 是本项目四大核心之一, + /// 会话复用/断线原样接回都靠它。服务器没装 tmux 时会自动降级为原生窗口并给安装引导。 + var useTmux: Bool = true + /// tmux 会话名(`-s`)。nil = 用默认 `main`。 + var tmuxSession: String? + /// 认证方式:内联用户名密码(快捷/旧数据归宿)或引用 Keychain 凭据。 + var auth: AuthRef + /// 出口:nil = Direct 直连;否则经该 Tailscale 连接(egress profile,架构决策 §2.6)。 + var tailscaleID: UUID? + /// 分组(首页「全部主机」的筛选 chips:生产 / 家里 / 云…)。nil = 未分组。 + var group: String? + /// 系统描述(主机卡副行,如「macOS · M4 Max」)。纯展示,不参与连接。 + var os: String? - var addressLine: String { "\(username)@\(host):\(port)" } + /// 认证引用。 + enum AuthRef: Codable, Equatable { + case inlinePassword(username: String, password: String) + case credential(id: UUID) + } + + init(id: UUID = UUID(), name: String, host: String, port: Int = 22, + useMosh: Bool = false, useTmux: Bool = true, tmuxSession: String? = nil, + auth: AuthRef, tailscaleID: UUID? = nil, + group: String? = nil, os: String? = nil) { + self.id = id + self.name = name + self.host = host + self.port = port + self.useMosh = useMosh + self.useTmux = useTmux + self.tmuxSession = tmuxSession + self.auth = auth + self.tailscaleID = tailscaleID + self.group = group + self.os = os + } + + /// 展示地址行;内联凭据带用户名,引用凭据时用户名在凭据里,故只显示 host:port。 + var addressLine: String { + if case .inlinePassword(let user, _) = auth, !user.isEmpty { + return "\(user)@\(host):\(port)" + } + return "\(host):\(port)" + } + + // MARK: - Codable(含旧 schema 迁移) + + enum CodingKeys: String, CodingKey { + case id, name, host, port, useMosh, useTmux, tmuxSession, auth, tailscaleID, group, os + // 旧字段 + case username, password + } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + id = try c.decodeIfPresent(UUID.self, forKey: .id) ?? UUID() + name = try c.decode(String.self, forKey: .name) + host = try c.decode(String.self, forKey: .host) + port = try c.decodeIfPresent(Int.self, forKey: .port) ?? 22 + useMosh = try c.decodeIfPresent(Bool.self, forKey: .useMosh) ?? false + // 旧数据没有这两个键:tmux 默认开(此前是"从不自动进 tmux",升级后按设计默认启用)。 + useTmux = try c.decodeIfPresent(Bool.self, forKey: .useTmux) ?? true + tmuxSession = try c.decodeIfPresent(String.self, forKey: .tmuxSession) + tailscaleID = try c.decodeIfPresent(UUID.self, forKey: .tailscaleID) + group = try c.decodeIfPresent(String.self, forKey: .group) + os = try c.decodeIfPresent(String.self, forKey: .os) + if let auth = try c.decodeIfPresent(AuthRef.self, forKey: .auth) { + self.auth = auth + } else { + // 旧 schema:inline username/password → inlinePassword(不自动造幽灵凭据)。 + let user = try c.decodeIfPresent(String.self, forKey: .username) ?? "" + let pw = try c.decodeIfPresent(String.self, forKey: .password) ?? "" + self.auth = .inlinePassword(username: user, password: pw) + } + } + + func encode(to encoder: Encoder) throws { + var c = encoder.container(keyedBy: CodingKeys.self) + try c.encode(id, forKey: .id) + try c.encode(name, forKey: .name) + try c.encode(host, forKey: .host) + try c.encode(port, forKey: .port) + try c.encode(useMosh, forKey: .useMosh) + try c.encode(useTmux, forKey: .useTmux) + try c.encodeIfPresent(tmuxSession, forKey: .tmuxSession) + try c.encode(auth, forKey: .auth) + try c.encodeIfPresent(tailscaleID, forKey: .tailscaleID) + try c.encodeIfPresent(group, forKey: .group) + try c.encodeIfPresent(os, forKey: .os) + } } /// 主机列表持久化(Application Support 下 JSON)。凭据不入仓库;此文件只在设备本地。 @@ -45,6 +130,13 @@ final class HostStore: ObservableObject { save() } + #if DEBUG + /// 无头 UI 验证:把示意主机塞进内存(**不写盘**,不污染真实 hosts.json)。 + func injectFixtureHosts(_ hosts: [SavedHost]) { + self.hosts = hosts + } + #endif + private func load() { guard let data = try? Data(contentsOf: url), let decoded = try? JSONDecoder().decode([SavedHost].self, from: data) else { return } diff --git a/apps/TerminalX/iOS/HostsView.swift b/apps/TerminalX/iOS/HostsView.swift new file mode 100644 index 0000000..1dec430 --- /dev/null +++ b/apps/TerminalX/iOS/HostsView.swift @@ -0,0 +1,350 @@ +import SwiftUI + +/// 主机列表页(split 详情)。点卡片连接;右上 + 新建;上下文菜单编辑/删除。 +struct HostsListView: View { + @ObservedObject var manager: SessionManager + @ObservedObject var router: AppRouter + @ObservedObject var hostStore: HostStore + @ObservedObject var credStore: FileCredentialStore + @ObservedObject var tailscaleStore: TailscaleStore + @EnvironmentObject var theme: TXThemeManager + @State private var editing: SavedHost? + @State private var showEditor = false + private var p: TXPalette { theme.palette } + + var body: some View { + ZStack { + p.bg.ignoresSafeArea() + ScrollView { + VStack(alignment: .leading, spacing: TX.Space.m) { + if hostStore.hosts.isEmpty { + TXEmptyState(icon: "server.rack", title: "还没有主机", + subtitle: "点击右上角 + 添加一台服务器") + } else { + ForEach(hostStore.hosts) { host in + HostCard(host: host, + credName: credName(for: host), + tailscaleName: tailscaleName(for: host)) { + // 设计定稿:建好即进终端(连接进度显示在终端页);已有该主机会话则复用。 + let s = manager.open(host: host) + router.enter(s.id) + } + .contextMenu { + Button { editing = host; showEditor = true } label: { + Label("编辑", systemImage: "pencil") + } + Button(role: .destructive) { hostStore.remove(host) } label: { + Label("删除", systemImage: "trash") + } + } + } + } + // 独立订阅焦点会话:manager 是嵌套 ObservableObject,会话内部变化不冒泡到这里。 + if let s = manager.focused { SessionStatusRow(session: s) } + } + .padding(TX.Space.m) + } + } + .navigationTitle("主机") + .navigationBarTitleDisplayMode(.inline) + .toolbarBackground(p.bg, for: .navigationBar) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { editing = nil; showEditor = true } label: { Image(systemName: "plus") } + } + } + .sheet(isPresented: $showEditor) { + HostEditorView(existing: editing, credStore: credStore, tailscaleStore: tailscaleStore) { host in + if hostStore.hosts.contains(where: { $0.id == host.id }) { hostStore.update(host) } + else { hostStore.add(host) } + } + } + } + + private func credName(for host: SavedHost) -> String? { + if case .credential(let id) = host.auth { + return credStore.list().first { $0.id == id }?.name ?? "(凭据已删除)" + } + return nil + } + + private func tailscaleName(for host: SavedHost) -> String? { + guard let id = host.tailscaleID else { return nil } + return tailscaleStore.connection(id: id)?.name ?? "(连接已删除)" + } + +} + +/// 焦点会话的连接进度/失败提示(连接中或失败时才出现)。 +/// 单独成视图以 `@ObservedObject` 直接订阅该会话——`SessionManager` 不会转发会话内部变化。 +struct SessionStatusRow: View { + @ObservedObject var session: TerminalSession + @EnvironmentObject var theme: TXThemeManager + private var p: TXPalette { theme.palette } + + private var isBusy: Bool { + session.phaseText.hasSuffix("…") || session.phaseText.contains("重连") + } + + var body: some View { + if isBusy || session.phaseText.hasPrefix("失败") { + HStack(spacing: TX.Space.s) { + if isBusy { ProgressView().controlSize(.small).tint(p.accent) } + Text(session.phaseText) + .font(.system(size: 13)) + .foregroundStyle(session.phaseText.hasPrefix("失败") ? p.danger : p.textSecondary) + } + .padding(.horizontal, 4) + } + } +} + +/// 主机卡片:图标 + mosh 点 / 名称 / 地址(等宽)/ 凭据·Tailscale 徽章 / chevron。 +struct HostCard: View { + let host: SavedHost + let credName: String? + let tailscaleName: String? + 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) + if credName != nil || tailscaleName != nil { + HStack(spacing: TX.Space.xs) { + if let c = credName { badge("key.fill", c, p.accent) } + if let t = tailscaleName { badge("network", t, p.mosh) } + } + } + } + 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) + } + + private func badge(_ icon: String, _ text: String, _ color: Color) -> some View { + HStack(spacing: 3) { + Image(systemName: icon).font(.system(size: 9)) + Text(text).font(.system(size: 11, weight: .medium)).lineLimit(1) + } + .foregroundStyle(color) + .padding(.horizontal, 7).padding(.vertical, 3) + .background(color.opacity(0.13), in: Capsule()) + } +} + +/// 主机编辑页:基本信息 + 认证(直接输入 / 选 Keychain)+ 网络(Direct / Tailscale)+ 选项。 +struct HostEditorView: View { + let existing: SavedHost? + @ObservedObject var credStore: FileCredentialStore + @ObservedObject var tailscaleStore: TailscaleStore + /// 已有分组(供一键选,避免录入同义不同名)。 + var existingGroups: [String] = [] + let onSave: (SavedHost) -> Void + + @EnvironmentObject var theme: TXThemeManager + @Environment(\.dismiss) private var dismiss + + private enum AuthMode: Hashable { case inline, credential } + + @State private var name = "" + @State private var host = "" + @State private var port = "22" + @State private var authMode: AuthMode = .inline + @State private var inlineUser = "" + @State private var inlinePassword = "" + @State private var selectedCredentialID: UUID? + @State private var tailscaleID: UUID? + @State private var useMosh = false + @State private var showNewCredential = false + /// 分组(首页筛选 chips)与系统描述(主机卡副行)——真实数据只能从这里录入。 + @State private var group = "" + @State private var os = "" + @State private var useTmux = true + @State private var tmuxSession = "" + + private var p: TXPalette { theme.palette } + private var canSave: Bool { + guard !host.isEmpty else { return false } + switch authMode { + case .inline: return !inlineUser.isEmpty + case .credential: return selectedCredentialID != nil + } + } + + var body: some View { + NavigationStack { + ZStack { + p.bg.ignoresSafeArea() + Form { + Section("基本") { + TXTextField(title: "名称", text: $name, placeholder: host.isEmpty ? "我的服务器" : host) + TXTextField(title: "主机 / IP", text: $host) + TXTextField(title: "端口", text: $port, keyboard: .numberPad) + } + Section { + TXTextField(title: "分组", text: $group, placeholder: "生产 / 家里 / 云(可空)") + if !existingGroups.isEmpty { + // 已有分组一键选,避免同义不同名把 chips 撑爆。 + HStack(spacing: TX.Space.xs) { + ForEach(existingGroups, id: \.self) { g in + TXChip(text: g, isSelected: group == g) { + group = (group == g) ? "" : g + } + } + } + .txRow(p) + } + TXTextField(title: "系统", text: $os, placeholder: "macOS · M4 Max(可空)") + } header: { + Text("归类(首页展示用)") + } footer: { + Text("分组决定首页「全部主机」的筛选 chips;系统只作卡片副行展示,不参与连接。") + } + authSection + networkSection + Section { + Toggle("自动进入 tmux", isOn: $useTmux).tint(p.accent).txRow(p) + if useTmux { + TXTextField(title: "tmux 会话名", text: $tmuxSession, placeholder: "main") + } + Toggle("使用 mosh", isOn: $useMosh).tint(p.accent).txRow(p) + } header: { + Text("会话") + } footer: { + Text(useTmux + ? "登录后执行 tmux -CC new -A -s <会话名>:同名会话存在就接回,否则新建。服务器没装 tmux 会自动降级为客户端窗口并给安装引导。" + : "不进 tmux:会话由 App 维持,关掉 App 后服务端不会继续跑(mosh 仍能兜断线)。") + } + } + .scrollContentBackground(.hidden) + } + .navigationTitle(existing == nil ? "添加主机" : "编辑主机") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("取消") { dismiss() }.tint(p.textSecondary) + } + ToolbarItem(placement: .confirmationAction) { + Button("保存") { save() }.disabled(!canSave).tint(p.accent) + } + } + .toolbarBackground(p.bg, for: .navigationBar) + .toolbarBackground(.visible, for: .navigationBar) + .sheet(isPresented: $showNewCredential) { + CredentialEditorView(existing: nil, store: credStore) { newCred in + selectedCredentialID = newCred.id + authMode = .credential + } + } + .onAppear(perform: populate) + } + .tint(p.accent) + } + + @ViewBuilder private var authSection: some View { + Section("认证") { + Picker("方式", selection: $authMode) { + Text("直接输入").tag(AuthMode.inline) + Text("从 Keychain 选择").tag(AuthMode.credential) + } + .pickerStyle(.segmented) + .txRow(p) + + if authMode == .inline { + TXTextField(title: "用户名", text: $inlineUser) + TXTextField(title: "密码", text: $inlinePassword, secure: true) + } else { + Picker("凭据", selection: $selectedCredentialID) { + Text("未选择").tag(UUID?.none) + ForEach(credStore.credentials) { c in + Text("\(c.name) · \(c.username)").tag(UUID?.some(c.id)) + } + } + .txRow(p) + Button { showNewCredential = true } label: { + Label("新建凭据", systemImage: "plus.circle") + } + .txRow(p) + } + } + } + + @ViewBuilder private var networkSection: some View { + Section("网络(出口)") { + Picker("经由", selection: $tailscaleID) { + Text("Direct(直连)").tag(UUID?.none) + ForEach(tailscaleStore.connections) { conn in + Text(conn.name).tag(UUID?.some(conn.id)) + } + } + .txRow(p) + if let id = tailscaleID, let conn = tailscaleStore.connection(id: id), conn.needsAuthKey { + Text("该 Tailscale 连接尚未认证,请先在 Tailscale 页完成。") + .font(.system(size: 12)).foregroundStyle(p.warning).txRow(p) + } + } + } + + private func populate() { + guard let h = existing else { return } + name = h.name; host = h.host; port = String(h.port) + useMosh = h.useMosh; tailscaleID = h.tailscaleID + group = h.group ?? ""; os = h.os ?? "" + useTmux = h.useTmux; tmuxSession = h.tmuxSession ?? "" + switch h.auth { + case .inlinePassword(let u, let pw): + authMode = .inline; inlineUser = u; inlinePassword = pw + case .credential(let id): + authMode = .credential; selectedCredentialID = id + } + } + + private func save() { + let auth: SavedHost.AuthRef + switch authMode { + case .inline: auth = .inlinePassword(username: inlineUser, password: inlinePassword) + case .credential: + guard let id = selectedCredentialID else { return } + auth = .credential(id: id) + } + let saved = SavedHost( + id: existing?.id ?? UUID(), + name: name.isEmpty ? host : name, + host: host, port: Int(port) ?? 22, + useMosh: useMosh, useTmux: useTmux, + tmuxSession: tmuxSession.isEmpty ? nil : tmuxSession.trimmingCharacters(in: .whitespaces), + auth: auth, tailscaleID: tailscaleID, + group: group.isEmpty ? nil : group.trimmingCharacters(in: .whitespaces), + os: os.isEmpty ? nil : os.trimmingCharacters(in: .whitespaces)) + onSave(saved) + dismiss() + } +} diff --git a/apps/TerminalX/iOS/KeychainKnownHosts.swift b/apps/TerminalX/iOS/KeychainKnownHosts.swift index baf9c92..015555f 100644 --- a/apps/TerminalX/iOS/KeychainKnownHosts.swift +++ b/apps/TerminalX/iOS/KeychainKnownHosts.swift @@ -1,6 +1,15 @@ import Foundation import TXCore +/// 已信任 host key 的展示条目(设置 → Known Hosts)。 +struct KnownHostEntry: Identifiable { + let id: String // accountKey "egress|host|port" + let egress: String + let host: String + let port: Int + let fingerprint: String +} + /// known_hosts pin 存储(文件后端)。 /// /// 为何用文件而非 Keychain:本 app 当前未签名(CODE_SIGNING_ALLOWED=NO),无 keychain-access-group @@ -47,6 +56,27 @@ final class KeychainKnownHostsStore: KnownHostsStore, @unchecked Sendable { persist() } + /// 已信任 host key 列表(供设置里查看/删除)。 + func entries() -> [KnownHostEntry] { + lock.lock(); defer { lock.unlock() } + return cache.compactMap { key, entry in + guard let b64 = entry["blob"] as? String, let blob = Data(base64Encoded: b64) else { return nil } + let parts = key.split(separator: "|", maxSplits: 2).map(String.init) + return KnownHostEntry( + id: key, + egress: parts.count > 0 ? parts[0] : "", + host: parts.count > 1 ? parts[1] : "", + port: parts.count > 2 ? (Int(parts[2]) ?? 0) : 0, + fingerprint: HostKey.opensshFingerprint(blob)) + }.sorted { $0.host < $1.host } + } + + func removeKey(_ id: String) { + lock.lock(); defer { lock.unlock() } + cache[id] = nil + persist() + } + private func persist() { guard let data = try? JSONSerialization.data(withJSONObject: cache) else { return } try? data.write(to: url, options: .atomic) diff --git a/apps/TerminalX/iOS/KeychainView.swift b/apps/TerminalX/iOS/KeychainView.swift new file mode 100644 index 0000000..67547da --- /dev/null +++ b/apps/TerminalX/iOS/KeychainView.swift @@ -0,0 +1,358 @@ +import SwiftUI +import UniformTypeIdentifiers +import TXCore + +/// Keychain 凭据列表页。 +struct KeychainListView: View { + @ObservedObject var store: FileCredentialStore + @EnvironmentObject var theme: TXThemeManager + @State private var editing: Credential? + @State private var showEditor = false + private var p: TXPalette { theme.palette } + + var body: some View { + ZStack { + p.bg.ignoresSafeArea() + ScrollView { + VStack(alignment: .leading, spacing: TX.Space.s) { + if store.credentials.isEmpty { + TXEmptyState(icon: "key.fill", title: "还没有凭据", + subtitle: "添加密码或 SSH 私钥,供主机复用") + } else { + ForEach(store.credentials) { cred in + credRow(cred) + .contextMenu { + Button { editing = cred; showEditor = true } label: { + Label("编辑", systemImage: "pencil") + } + Button(role: .destructive) { store.delete(cred.id) } label: { + Label("删除", systemImage: "trash") + } + } + } + } + } + .padding(TX.Space.m) + } + } + .navigationTitle("Keychain 凭据") + .navigationBarTitleDisplayMode(.inline) + .toolbarBackground(p.bg, for: .navigationBar) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { editing = nil; showEditor = true } label: { Image(systemName: "plus") } + } + } + .sheet(isPresented: $showEditor) { + CredentialEditorView(existing: editing, store: store) { _ in } + } + } + + private func credRow(_ cred: Credential) -> some View { + HStack(spacing: TX.Space.m) { + Image(systemName: cred.iconName) + .font(.system(size: 18)) + .foregroundStyle(p.accent) + .frame(width: 40, height: 40) + .background(p.surfaceElevated, in: RoundedRectangle(cornerRadius: TX.Radius.chip)) + VStack(alignment: .leading, spacing: 2) { + Text(cred.name).font(.system(size: 15, weight: .semibold)).foregroundStyle(p.textPrimary) + Text("\(cred.kindLabel) · \(cred.username)") + .font(TX.Font.mono(12)).foregroundStyle(p.textSecondary) + } + Spacer() + } + .padding(TX.Space.m) + .background(p.surface, in: RoundedRectangle(cornerRadius: TX.Radius.card)) + .overlay(RoundedRectangle(cornerRadius: TX.Radius.card).stroke(p.border, lineWidth: 1)) + } +} + +/// 凭据编辑页:类型(密码 / SSH 密钥 / Face ID);SSH 密钥支持 粘贴 / 导入 / 生成。 +struct CredentialEditorView: View { + let existing: Credential? + @ObservedObject var store: FileCredentialStore + let onSave: (Credential) -> Void + + @EnvironmentObject var theme: TXThemeManager + @Environment(\.dismiss) private var dismiss + + private enum Kind: Hashable { case password, sshKey, faceID } + private enum KeySource: Hashable { case generate, paste, importFile } + + @State private var name = "" + @State private var username = "" + @State private var kind: Kind = .password + @State private var password = "" + @State private var keySource: KeySource = .generate + @State private var keyText = "" + @State private var passphrase = "" + @State private var parsed: CredentialAuth.ParsedKey? + // 生成结果(暂存直到保存) + @State private var generatedKind: Credential.Kind? + @State private var generatedSecret: CredentialSecret? + @State private var generatedFingerprint = "" + @State private var generatedAuthLine = "" + @State private var showFileImporter = false + @State private var importError: String? + + private let seAvailable = SigningKeyProvider.secureEnclaveAvailable() + private let isEditing: Bool + private var p: TXPalette { theme.palette } + + init(existing: Credential?, store: FileCredentialStore, onSave: @escaping (Credential) -> Void) { + self.existing = existing + self.store = store + self.onSave = onSave + self.isEditing = existing != nil + } + + private var canSave: Bool { + guard !username.isEmpty else { return false } + switch kind { + case .password: return true + case .sshKey: + if isEditing { return true } // 编辑时允许只改名/用户名 + switch keySource { + case .generate: return generatedSecret != nil + case .paste, .importFile: return !keyText.isEmpty + } + case .faceID: return false + } + } + + var body: some View { + NavigationStack { + ZStack { + p.bg.ignoresSafeArea() + Form { + Section("基本") { + TXTextField(title: "名称", text: $name, placeholder: "我的密钥") + TXTextField(title: "用户名", text: $username, placeholder: "登录服务器的用户名") + } + Section("类型") { + Picker("类型", selection: $kind) { + Text("密码").tag(Kind.password) + Text("SSH 密钥").tag(Kind.sshKey) + Text("Face ID").tag(Kind.faceID) + } + .pickerStyle(.segmented) + .txRow(p) + } + switch kind { + case .password: passwordSection + case .sshKey: sshKeySection + case .faceID: faceIDSection + } + } + .scrollContentBackground(.hidden) + } + .navigationTitle(isEditing ? "编辑凭据" : "添加凭据") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("取消") { dismiss() }.tint(p.textSecondary) + } + ToolbarItem(placement: .confirmationAction) { + Button("保存") { save() }.disabled(!canSave).tint(p.accent) + } + } + .toolbarBackground(p.bg, for: .navigationBar) + .toolbarBackground(.visible, for: .navigationBar) + .fileImporter(isPresented: $showFileImporter, allowedContentTypes: [.data, .text]) { result in + handleImport(result) + } + .onAppear(perform: populate) + } + .tint(p.accent) + } + + // MARK: - 各类型区块 + + @ViewBuilder private var passwordSection: some View { + Section("密码") { + TXTextField(title: "密码", text: $password, secure: true) + } + } + + @ViewBuilder private var sshKeySection: some View { + Section("SSH 私钥") { + Picker("来源", selection: $keySource) { + Text("生成").tag(KeySource.generate) + Text("粘贴").tag(KeySource.paste) + Text("导入").tag(KeySource.importFile) + } + .pickerStyle(.segmented) + .txRow(p) + + switch keySource { + case .generate: generateContent + case .paste: pasteContent + case .importFile: importContent + } + } + } + + @ViewBuilder private var generateContent: some View { + if generatedSecret == nil { + Button { + generate() + } label: { + Label("生成 ECDSA P-256 密钥", systemImage: "sparkles") + } + .txRow(p) + } else { + VStack(alignment: .leading, spacing: 6) { + Text("已生成 ECDSA P-256").font(.system(size: 13, weight: .medium)).foregroundStyle(p.accent) + Text("公钥指纹:\(generatedFingerprint)").font(TX.Font.mono(11)).foregroundStyle(p.textSecondary) + Text("authorized_keys(复制到服务器):").font(.system(size: 12)).foregroundStyle(p.textSecondary) + Text(generatedAuthLine).font(TX.Font.mono(10)).foregroundStyle(p.textPrimary) + .textSelection(.enabled).lineLimit(3) + Button { UIPasteboard.general.string = generatedAuthLine } label: { + Label("复制 authorized_keys 行", systemImage: "doc.on.doc") + } + .font(.system(size: 13)).padding(.top, 4) + } + .txRow(p) + } + } + + @ViewBuilder private var pasteContent: some View { + VStack(alignment: .leading, spacing: 6) { + Text("粘贴 PEM / PKCS#8 私钥内容").font(.system(size: 12)).foregroundStyle(p.textSecondary) + TextEditor(text: $keyText) + .font(TX.Font.mono(11)).frame(minHeight: 120) + .foregroundStyle(p.textPrimary).scrollContentBackground(.hidden) + .background(p.bg, in: RoundedRectangle(cornerRadius: 8)) + .onChange(of: keyText) { _, t in parsed = CredentialAuth.parsePrivateKey(t, source: .pasted) } + detectionLabel + } + .txRow(p) + if parsedNeedsPassphrase { + TXTextField(title: "私钥口令(passphrase)", text: $passphrase, secure: true) + } + } + + @ViewBuilder private var importContent: some View { + VStack(alignment: .leading, spacing: 6) { + Button { showFileImporter = true } label: { + Label("从文件导入私钥", systemImage: "folder") + } + if let e = importError { + Text(e).font(.system(size: 12)).foregroundStyle(.red) + } + if !keyText.isEmpty { detectionLabel } + } + .txRow(p) + if parsedNeedsPassphrase { + TXTextField(title: "私钥口令(passphrase)", text: $passphrase, secure: true) + } + } + + @ViewBuilder private var faceIDSection: some View { + Section { + HStack { + Image(systemName: "faceid").foregroundStyle(seAvailable ? p.accent : p.textTertiary) + Text(seAvailable ? "Face ID 密钥(Secure Enclave)" : "Face ID 密钥(需签名构建)") + .foregroundStyle(seAvailable ? p.textPrimary : p.textTertiary) + } + .txRow(p) + } footer: { + Text(seAvailable + ? "私钥生成于 Secure Enclave,不可导出,签名时经 Face ID 授权。" + : "当前构建未签名,Secure Enclave 不可用。签名真机构建后此选项自动可用。") + } + } + + @ViewBuilder private var detectionLabel: some View { + if let parsed { + VStack(alignment: .leading, spacing: 3) { + Text("检测到:\(parsed.formatLabel)").font(.system(size: 12)).foregroundStyle(p.accent) + if let w = parsed.warning { + Text(w).font(.system(size: 11)).foregroundStyle(p.warning) + } + } + } + } + + private var parsedNeedsPassphrase: Bool { + if case .privateKey(_, _, let hasPass, _) = parsed?.kind { return hasPass } + return false + } + + // MARK: - 动作 + + private func generate() { + guard let (k, s) = CredentialAuth.generateECDSAP256() else { return } + generatedKind = k; generatedSecret = s + if case .privateKey(_, _, _, let blob) = k { + generatedAuthLine = "ecdsa-sha2-nistp256 \(blob.base64EncodedString()) \(username.isEmpty ? "terminalx" : username)@terminalx" + generatedFingerprint = HostKey.opensshFingerprint(blob) + } + } + + private func handleImport(_ result: Result) { + switch result { + case .failure(let e): importError = e.localizedDescription + case .success(let url): + let scoped = url.startAccessingSecurityScopedResource() + defer { if scoped { url.stopAccessingSecurityScopedResource() } } + guard let data = try? Data(contentsOf: url), let text = String(data: data, encoding: .utf8) else { + importError = "无法读取文件(需 UTF-8 文本私钥)"; return + } + importError = nil + keyText = text + parsed = CredentialAuth.parsePrivateKey(text, source: .imported) + } + } + + private func populate() { + guard let c = existing else { return } + name = c.name; username = c.username + switch c.kind { + case .password: + kind = .password + if case .password(let pw)? = store.secret(for: c.id) { password = pw } + case .privateKey: + kind = .sshKey; keySource = .generate // 编辑既有密钥仅改名/用户名 + case .secureEnclave: + kind = .faceID + } + } + + private func save() { + let credID = existing?.id ?? UUID() + var credKind: Credential.Kind + var secret: CredentialSecret? + + switch kind { + case .password: + credKind = .password + secret = .password(password) + case .sshKey: + if isEditing, let c = existing, case .privateKey = c.kind { + credKind = c.kind; secret = nil // 保留原机密,仅改 meta + } else if keySource == .generate, let gk = generatedKind, let gs = generatedSecret { + credKind = gk; secret = gs + } else { + let parsedKey = parsed ?? CredentialAuth.parsePrivateKey(keyText, source: keySource == .paste ? .pasted : .imported) + // 注入用户填写的 passphrase + if case .privateKey(let t, let src, _, let blob) = parsedKey.kind { + credKind = .privateKey(keyType: t, source: src, hasPassphrase: !passphrase.isEmpty, publicKeyBlob: blob) + } else { + credKind = parsedKey.kind + } + secret = .pemPrivateKey(pem: Data(keyText.utf8), passphrase: passphrase.isEmpty ? nil : passphrase) + } + case .faceID: + return + } + + let cred = Credential(id: credID, name: name.isEmpty ? username : name, + username: username, kind: credKind, + createdAt: existing?.createdAt ?? Date()) + store.upsert(cred, secret: secret) + onSave(cred) + dismiss() + } +} diff --git a/apps/TerminalX/iOS/RailSidebar.swift b/apps/TerminalX/iOS/RailSidebar.swift new file mode 100644 index 0000000..5bea47b --- /dev/null +++ b/apps/TerminalX/iOS/RailSidebar.swift @@ -0,0 +1,529 @@ +import SwiftUI + +// 设计稿 `1b 终端-沉浸轨道` 的左侧 56pt 轨道 + `3a 侧边栏-遮罩态/Pin 态` 的 288pt 面板。 +// +// **开合语义(唯一、不歧义)**:`›` 方块是 App logo,不可点。开合只有一枚按钮,两态方向相反—— +// 折叠态在轨道底部(»展开),展开态在标题右侧(«收起)。Pin 是独立按钮,只在展开态出现。 +// 三态元素不增不减,只是展示密度不同。 + +/// 折叠轨道(56pt,竖屏 48pt)。元素中心线统一在轨道正中。 +struct RailView: View { + @ObservedObject var manager: SessionManager + @ObservedObject var router: AppRouter + /// 当前会话(TMUX 段列它的 window)。 + @ObservedObject var session: TerminalSession + let onNewHost: () -> Void + let onSettings: () -> Void + @EnvironmentObject var theme: TXThemeManager + @Environment(\.horizontalSizeClass) private var hSize + private var p: TXPalette { theme.palette } + + private var width: CGFloat { + hSize == .compact ? TX.Layout.railWidthCompact : TX.Layout.railWidth + } + private var avatarSize: CGFloat { hSize == .compact ? 34 : 38 } + + // 纵向节奏取自设计稿 `1b` 的实测坐标(1366×1024 画板扫描): + // logo 28..62 · 搜索 75..109 · 首页 118..152 · 分隔 160 · 头像 174/220/266(间距 8) + // · 虚线+ 313..351 · 分隔 370 · TMUX 标签 382 · window 数字 408 起(间距 7) + // · 底部自下而上:齿轮 985..1019 · 展开键 932..967 · 状态点 914。 + var body: some View { + VStack(spacing: 0) { + logo + .padding(.top, 28) + .padding(.bottom, 13) + // 搜索 / 首页 / 设置在设计稿里是**裸图标**(无底无框),只有 logo 与展开键有框。 + TXIconButton(icon: "magnifyingglass", bare: true, hitSize: 34, label: "搜索") { + router.toggleSidebar() + } + .padding(.bottom, 9) + TXIconButton(icon: "house", bare: true, hitSize: 34, label: "首页") { router.goHome() } + .padding(.bottom, 8) + divider + .padding(.bottom, 13) + hostAvatars + newHostButton + divider + .padding(.top, 9) + .padding(.bottom, 12) + tmuxSection + Spacer(minLength: TX.Space.s) + TXStatusDot(color: linkColor, punchOut: p.surfaceDark) + .padding(.bottom, 11) + // 折叠态的开合键:方向朝右(展开)。设计稿里这一枚**有框**。 + TXIconButton(icon: "chevron.right.2", hitSize: 34, label: "展开侧边栏") { + router.toggleSidebar() + } + .padding(.bottom, 18) + TXIconButton(icon: "gearshape", bare: true, hitSize: 34, label: "设置", action: onSettings) + .padding(.bottom, 5) + } + .frame(width: width) + .background(p.surfaceDark) + } + + /// App logo(`›`)。**不可点** —— 开合是另一枚按钮的职责。 + private var logo: some View { + Text("›") + .font(TX.Font.mono(16, .bold)) + .foregroundStyle(TXAccent.text) + .frame(width: 34, height: 34) + .background(p.surface, in: RoundedRectangle(cornerRadius: 11)) + .overlay(RoundedRectangle(cornerRadius: 11).strokeBorder(p.border, lineWidth: 1)) + .accessibilityLabel("terminalX") + } + + private var divider: some View { + Rectangle() + .fill(p.divider) + .frame(width: 24, height: 1) + } + + private var hostAvatars: some View { + VStack(spacing: 8) { + ForEach(manager.activeSessions) { s in + Button { + manager.focus(s) + router.enter(s.id) + } label: { + TXInitialsAvatar(initials: s.initials, + size: avatarSize, + isSelected: router.route.sessionID == s.id, + status: RailSidebarStyle.statusColor(s, p), + punchOut: p.surfaceDark) + } + .buttonStyle(.plain) + } + } + } + + /// 虚线 + 号:在当前主机之外新开会话。 + private var newHostButton: some View { + Button(action: onNewHost) { + Image(systemName: "plus") + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(p.textWeak) + .frame(width: avatarSize, height: avatarSize) + .background( + RoundedRectangle(cornerRadius: 11) + .strokeBorder(style: StrokeStyle(lineWidth: 1, dash: [3, 3])) + .foregroundStyle(p.border) + ) + } + .buttonStyle(.plain) + .padding(.top, 9) + } + + /// TMUX 段:标签 + window 数字。无 tmux 的主机换成「窗口」段(客户端窗口)。 + private var tmuxSection: some View { + VStack(spacing: 7) { + Text(session.tmuxSummary == nil ? "窗口" : "TMUX") + .font(TX.Font.mono(9, .semibold)) + .tracking(0.9) + // 设计稿:轨道的 TMUX 段标签用 accent-500(弱化强调),不是中性灰。 + .foregroundStyle(TXAccent.mid) + .padding(.bottom, 9) + if let tmux = session.tmuxController { + ForEach(tmux.windows) { win in + Button { tmux.selectWindow(win.id) } label: { + windowNumber("\(win.index)", isActive: win.id == tmux.activeWindowID) + } + .buttonStyle(.plain) + } + } else { + // 还没 attach(或假会话)时按概览里的 window 数占位,保持轨道高度稳定。 + ForEach(0 ..< max(1, session.tmuxSummary?.windows ?? 1), id: \.self) { i in + windowNumber("\(i)", isActive: i == 0) + } + } + } + } + + /// window 数字格(34×30,选中态填控件底 + 中等描边)。 + private func windowNumber(_ text: String, isActive: Bool) -> some View { + Text(text) + .font(TX.Font.mono(12, isActive ? .semibold : .regular)) + .foregroundStyle(isActive ? p.text : p.text4) + .frame(width: 34, height: 30) + .background(isActive ? p.surface : .clear, in: RoundedRectangle(cornerRadius: 8)) + .overlay( + RoundedRectangle(cornerRadius: 8) + .strokeBorder(isActive ? p.borderDialog : .clear, lineWidth: 1) + ) + } + + private var linkColor: Color { RailSidebarStyle.statusColor(session, p) } +} + +/// 轨道与面板共用的状态色映射。 +@MainActor +enum RailSidebarStyle { + static func statusColor(_ s: TerminalSession, _ p: TXPalette) -> Color { + switch s.link { + case .direct, .relay: s.isMinimized ? TXAccent.mid : p.online + case .reconnecting: p.reconnecting + case .connecting: p.text4 + case .offline: p.offline + } + } +} + +// MARK: - 288pt 侧边栏面板 + +/// 展开态面板(遮罩 / Pin 共用同一套内容)。 +/// +/// 行规格:会话行 = 26pt 头像列 + 10pt gap;window 行 = 26×22pt 徽标列 + 10pt gap +/// → 两者标题左缘对齐。选中态用 **inset 描边**(`strokeBorder`)而非 border,避免内容位移 1pt。 +struct SidebarPanel: View { + @ObservedObject var manager: SessionManager + @ObservedObject var router: AppRouter + @ObservedObject var session: TerminalSession + @ObservedObject var tailscaleStore: TailscaleStore + let onNewHost: () -> Void + let onSettings: () -> Void + @Binding var search: String + @EnvironmentObject var theme: TXThemeManager + private var p: TXPalette { theme.palette } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + header + TXSearchField(text: $search, placeholder: "搜索主机 · 会话") + .padding(.horizontal, TX.Space.m) + .padding(.bottom, TX.Space.s) + homeRow + sessionsSection + // 设计:1pt **渐隐**分隔(两端淡出,不是通栏实线)。 + LinearGradient(colors: [p.divider.opacity(0), p.divider, p.divider.opacity(0)], + startPoint: .leading, endPoint: .trailing) + .frame(height: 1) + .padding(.horizontal, TX.Space.m) + .padding(.vertical, TX.Space.s) + tmuxSection + Spacer(minLength: 0) + tailscaleFoot + } + .frame(width: TX.Layout.sidebarWidth) + .background(p.surfaceDark) + } + + private var header: some View { + HStack(spacing: TX.Space.s) { + Text("›") + .font(TX.Font.mono(15, .bold)) + .foregroundStyle(TXAccent.text) + .frame(width: 30, height: 30) + .background(p.surface, in: RoundedRectangle(cornerRadius: 9)) + .overlay(RoundedRectangle(cornerRadius: 9).strokeBorder(p.border, lineWidth: 1)) + Text("terminalX") + .font(TX.Font.mono(14, .medium)) + .foregroundStyle(p.text) + Spacer() + // Pin 只在展开态出现;钉住时高亮。 + TXIconButton(icon: "pin", size: 28, isSelected: router.sidebar == .pinned, + label: "钉在左侧") { router.togglePin() } + // 展开态的开合键:方向朝左(收起)。 + TXIconButton(icon: "chevron.left.2", size: 28, label: "收起") { router.toggleSidebar() } + } + .padding(.horizontal, TX.Space.m) + .padding(.top, 11) + .padding(.bottom, 6) + } + + private var homeRow: some View { + Button { router.goHome() } label: { + HStack(spacing: TX.Space.s) { + Image(systemName: "house") + .font(.system(size: 13)) + .foregroundStyle(p.text2) + .frame(width: 26) + Text("首页") + .font(TX.Font.mono(13, .medium)) + .foregroundStyle(p.text) + Spacer() + Text("⌘⇧H") + .font(TX.Font.mono(10.5)) + .foregroundStyle(p.textFaint) + } + .padding(.horizontal, TX.Space.m) + .frame(height: 36) + } + .buttonStyle(.plain) + } + + private var sessionsSection: some View { + VStack(alignment: .leading, spacing: 2) { + TXSectionLabel(text: "活动会话", trailing: "\(manager.sessions.count)") + .padding(.horizontal, TX.Space.m) + .padding(.top, TX.Space.s) + .padding(.bottom, 2) + if manager.activeSessions.isEmpty { + Text("没有在跑的会话") + .font(TX.Font.mono(11.5)) + .foregroundStyle(p.textWeak) + .padding(.leading, 53) + .frame(height: 32, alignment: .leading) + } + ForEach(manager.activeSessions) { s in + SidebarSessionRow(session: s, isSelected: router.route.sessionID == s.id) { + manager.focus(s) + router.enter(s.id) + } + } + Button(action: onNewHost) { + Text("连接新主机…") + .font(TX.Font.mono(12)) + .foregroundStyle(p.text4) + .padding(.leading, 53) // 与会话行标题左缘对齐 + .frame(height: 32, alignment: .leading) + .frame(maxWidth: .infinity, alignment: .leading) + } + .buttonStyle(.plain) + } + } + + private var tmuxSection: some View { + VStack(alignment: .leading, spacing: 2) { + TXSectionLabel(text: tmuxHeader) + .padding(.horizontal, TX.Space.m) + .padding(.bottom, 2) + if let tmux = session.tmuxController { + ForEach(tmux.windows) { win in + SidebarWindowRow(window: win, + isSelected: win.id == tmux.activeWindowID) { + tmux.selectWindow(win.id) + } + } + newWindowRow { tmux.newWindow() } + } else if let summary = session.tmuxSummary { + // 已知是 tmux 会话但 controller 还没 attach:按概览占位,避免与分组标题矛盾。 + ForEach(0 ..< max(1, summary.windows), id: \.self) { i in + placeholderWindowRow( + index: i, + title: i == 0 && !summary.currentTitle.isEmpty ? summary.currentTitle : "window \(i)", + detail: i == 0 && summary.panes > 0 + ? "\(summary.panes) pane\(summary.panes == 1 ? "" : "s")" : "", + isSelected: i == 0) + } + newWindowRow {} + } else { + Text("无 tmux · 客户端窗口") + .font(TX.Font.mono(11.5)) + .foregroundStyle(p.textWeak) + .padding(.horizontal, TX.Space.m) + .padding(.vertical, TX.Space.xs) + } + } + } + + /// 「新建 window ⌘T」行(左缘与 window 行标题对齐 x=53)。 + private func newWindowRow(_ action: @escaping () -> Void) -> some View { + Button(action: action) { + HStack { + Text("新建 window") + .font(TX.Font.mono(12)) + .foregroundStyle(p.text4) + Spacer() + Text("⌘T") + .font(TX.Font.mono(10.5)) + .foregroundStyle(p.textFaint) + } + .padding(.leading, 53) + .padding(.trailing, TX.Space.m) + .frame(height: 32) + } + .buttonStyle(.plain) + } + + /// controller 未就绪时的 window 行占位(视觉与 SidebarWindowRow 一致)。 + private func placeholderWindowRow(index: Int, title: String, detail: String, + isSelected: Bool) -> some View { + HStack(spacing: TX.Space.s) { + Text("\(index)") + .font(TX.Font.mono(11, .semibold)) + .foregroundStyle(isSelected ? TXAccent.text : p.text4) + .frame(width: 26, height: 22) + .background(isSelected ? TXAccent.selectedBg : .clear, + in: RoundedRectangle(cornerRadius: 5)) + VStack(alignment: .leading, spacing: 1) { + Text(title) + .font(TX.Font.mono(13, isSelected ? .medium : .regular)) + .foregroundStyle(isSelected ? p.text : p.text2) + .lineLimit(1) + if !detail.isEmpty { + Text(detail) + .font(TX.Font.mono(10.5)) + .foregroundStyle(p.textWeak) + } + } + Spacer(minLength: 0) + if isSelected { TXStatusDot(color: p.online, size: 7) } + } + .padding(.horizontal, TX.Space.s) + .frame(height: 44) + .background(isSelected ? TXAccent.selectedBg.opacity(0.75) : .clear, + in: RoundedRectangle(cornerRadius: TX.Radius.control)) + .overlay( + RoundedRectangle(cornerRadius: TX.Radius.control) + .strokeBorder(isSelected ? TXAccent.selectedStroke : .clear, lineWidth: 1) + ) + .padding(.horizontal, TX.Space.s) + } + + private var tmuxHeader: String { + let host = session.displayName + if let t = session.tmuxSummary, !t.name.isEmpty { return "TMUX · \(host) · \(t.name)" } + return session.tmuxSummary == nil ? "窗口 · \(host)" : "TMUX · \(host)" + } + + /// 面板脚:tailnet 状态(绿点 + tailnet + 设备 IP)。 + /// + /// **与舞台底部提示条同高**(都是 `TX.Layout.footHeight`),故两侧的分割线落在同一条 y 上; + /// 分割线用 1pt divider(设计稿如此),不加底色块、不加阴影。 + private var tailscaleFoot: some View { + VStack(spacing: 0) { + Rectangle().fill(p.divider).frame(height: 1) + HStack(spacing: TX.Space.s) { + TXStatusDot(color: tailscaleStore.connections.isEmpty ? p.offline : p.online) + VStack(alignment: .leading, spacing: 1) { + Text(tailscaleStore.connections.isEmpty ? "Tailscale 未配置" : "Tailscale 已连接") + .font(TX.Font.mono(11.5, .medium)) + .foregroundStyle(p.text2) + if let c = tailscaleStore.connections.first { + // 设计:tailnet 内主机名 + 本机 tailnet IP(Up 后缓存的 SelfIP)。 + Text([c.hostname, c.lastSelfIP].compactMap { $0 }.joined(separator: " · ")) + .font(TX.Font.mono(10.5)) + .foregroundStyle(p.textWeak) + .lineLimit(1) + } + } + Spacer(minLength: 0) + } + .padding(.horizontal, TX.Space.m) + .frame(height: TX.Layout.footHeight) + } + } +} + +/// 侧边栏会话行:26pt 头像 + 名称·window title + 右侧链路/重连标。 +struct SidebarSessionRow: View { + @ObservedObject var session: TerminalSession + let isSelected: Bool + let onTap: () -> Void + @EnvironmentObject var theme: TXThemeManager + private var p: TXPalette { theme.palette } + + var body: some View { + Button(action: onTap) { + HStack(spacing: TX.Space.s) { + TXInitialsAvatar(initials: session.initials, size: 26, + isSelected: isSelected, + status: RailSidebarStyle.statusColor(session, p), + punchOut: isSelected ? TXAccent.selectedBg : p.surfaceDark) + VStack(alignment: .leading, spacing: 1) { + Text(title) + .font(TX.Font.mono(13, .medium)) + .foregroundStyle(p.text) + .lineLimit(1) + Text(subtitle) + .font(TX.Font.mono(10.5)) + .foregroundStyle(p.textWeak) + .lineLimit(1) + } + Spacer(minLength: TX.Space.xs) + trailingTag + } + .padding(.horizontal, TX.Space.s) + .frame(height: 48) + .background(isSelected ? TXAccent.selectedBg : .clear, + in: RoundedRectangle(cornerRadius: TX.Radius.control)) + // inset 描边而非 border:选中时内容不位移 1pt。 + .overlay( + RoundedRectangle(cornerRadius: TX.Radius.control) + .strokeBorder(isSelected ? TXAccent.selectedStroke : .clear, lineWidth: 1) + ) + .padding(.horizontal, TX.Space.s) + } + .buttonStyle(.plain) + } + + private var title: String { + if let t = session.tmuxSummary, !t.name.isEmpty { + return "\(session.displayName) · \(t.name)" + } + return session.displayName + } + + private var subtitle: String { + if session.isMinimized { return "最小化中" } + guard let t = session.tmuxSummary else { return "原生窗口" } + let title = t.currentTitle.isEmpty ? session.phaseText : t.currentTitle + return "\(title) · \(t.windows) window\(t.windows == 1 ? "" : "s")" + } + + @ViewBuilder private var trailingTag: some View { + switch session.link { + case .reconnecting: + Text("重连中").font(TX.Font.mono(10.5, .medium)).foregroundStyle(p.reconnecting) + case .offline: + Text("离线").font(TX.Font.mono(10.5)).foregroundStyle(p.offline) + default: + EmptyView() + } + } +} + +/// 侧边栏 window 行:26×22pt 编号徽标 + title + cwd/panes。 +struct SidebarWindowRow: View { + @ObservedObject var window: TmuxWindow + let isSelected: Bool + let onTap: () -> Void + @EnvironmentObject var theme: TXThemeManager + private var p: TXPalette { theme.palette } + + private var subtitle: String { + var parts: [String] = [] + if !window.cwd.isEmpty { parts.append(TerminalTitleCapsule.abbreviate(window.cwd)) } + let n = window.panes.count + if n > 1 { parts.append("\(n) panes") } + return parts.isEmpty ? "1 pane" : parts.joined(separator: " · ") + } + + var body: some View { + Button(action: onTap) { + HStack(spacing: TX.Space.s) { + Text("\(window.index)") + .font(TX.Font.mono(11, .semibold)) + .foregroundStyle(isSelected ? TXAccent.text : p.text4) + .frame(width: 26, height: 22) + .background(isSelected ? TXAccent.selectedBg : .clear, + in: RoundedRectangle(cornerRadius: 5)) + VStack(alignment: .leading, spacing: 1) { + Text(window.title) + .font(TX.Font.mono(13, isSelected ? .medium : .regular)) + .foregroundStyle(isSelected ? p.text : p.text2) + .lineLimit(1) + // 设计:cwd · n panes(cwd 来自 #{pane_current_path}) + Text(subtitle) + .font(TX.Font.mono(10.5)) + .foregroundStyle(p.textWeak) + .lineLimit(1) + } + Spacer(minLength: 0) + if isSelected { + TXStatusDot(color: p.online, size: 7) + } + } + .padding(.horizontal, TX.Space.s) + .frame(height: 44) + .background(isSelected ? TXAccent.selectedBg.opacity(0.75) : .clear, + in: RoundedRectangle(cornerRadius: TX.Radius.control)) + .overlay( + RoundedRectangle(cornerRadius: TX.Radius.control) + .strokeBorder(isSelected ? TXAccent.selectedStroke : .clear, lineWidth: 1) + ) + .padding(.horizontal, TX.Space.s) + } + .buttonStyle(.plain) + } +} diff --git a/apps/TerminalX/iOS/SSHTerminalModel.swift b/apps/TerminalX/iOS/SSHTerminalModel.swift deleted file mode 100644 index 4377b91..0000000 --- a/apps/TerminalX/iOS/SSHTerminalModel.swift +++ /dev/null @@ -1,655 +0,0 @@ -import Foundation -import GhosttyTerminal -import TXCore -import TXTransport -import TsnetBridge -#if canImport(UIKit) -import UIKit -#endif - -enum TsnetError: Error { case newNodeFailed, notUp } - -/// 主 tsnet 节点管理器(线程安全,供 SSHTerminalModel 在后台线程 up/dial)。 -/// up 后节点常驻,重连时复用(只重新 dial fd,不重新加入 tailnet)。 -final class TsnetManager: @unchecked Sendable { - private let lock = NSLock() - private var node: TsnetbridgeNode? - private let stateDir: String - - init() { - let base = NSSearchPathForDirectoriesInDomains(.applicationSupportDirectory, .userDomainMask, true)[0] - stateDir = base + "/tsnet-main" - try? FileManager.default.createDirectory(atPath: stateDir, withIntermediateDirectories: true) - } - - /// 确保节点已 up(幂等)。阻塞,需后台线程调用。 - func ensureUp(authKey: String, timeoutMs: Int) throws { - lock.lock(); let existing = node; lock.unlock() - if existing != nil { return } - guard let n = TsnetbridgeNewNode(stateDir, "terminalx-ipad") else { throw TsnetError.newNodeFailed } - try n.up(withAuthKey: authKey, timeoutMs: timeoutMs) - lock.lock(); node = n; lock.unlock() - } - - /// 经 tsnet dial tailnet 内 host:port,返回已连接 fd。阻塞,需后台线程调用。 - /// 带重试:tsnet 刚 Up 后到 peer 的路径(DERP/直连打洞)预热期间首次 dial 可能 i/o timeout, - /// 短退避重试若干次直到路径就绪。 - func dialFD(host: String, port: Int, timeoutMs: Int) throws -> Int32 { - lock.lock(); let n = node; lock.unlock() - guard let n else { throw TsnetError.notUp } - var lastError: Error? - for attempt in 0 ..< 6 { - do { - var fd: Int64 = -1 - try n.dialTCPFD(host, port: port, timeoutMs: 8000, ret0_: &fd) - return Int32(fd) - } catch { - lastError = error - if attempt < 5 { Thread.sleep(forTimeInterval: 2.0) } // 后台线程,路径预热 - } - } - throw lastError ?? TsnetError.notUp - } - - /// 为 mosh 建立 UDP relay:loopback ↔ tsnet(host:moshPort)。返回句柄(含 localPort)。 - /// 阻塞(tsnet UDP Dial),需后台线程调用。 - func startMoshRelay(host: String, moshPort: Int, timeoutMs: Int) throws -> TsnetbridgeMoshRelay { - lock.lock(); let n = node; lock.unlock() - guard let n else { throw TsnetError.notUp } - return try n.startMoshRelay(host, moshPort: moshPort, timeoutMs: timeoutMs) - } - - /// M3:前台恢复时唤醒 tsnet 网络层(注入链路事件 + Rebind/重新 STUN)。可能阻塞,后台线程调用。 - func wakeUp() { - lock.lock(); let n = node; lock.unlock() - n?.wakeUp() - } -} - -/// 线程安全地持有 transport 引用,供 libghostty 的输入/resize 回调(非主线程)安全取用。 -final class TransportHolder: @unchecked Sendable { - private let lock = NSLock() - private var value: Transport? - var transport: Transport? { - get { lock.lock(); defer { lock.unlock() }; return value } - set { lock.lock(); value = newValue; lock.unlock() } - } -} - -/// 线程安全地持有 mosh 会话引用;非 nil 时终端输入/resize 路由到 mosh 而非 SSH transport。 -final class MoshHolder: @unchecked Sendable { - private let lock = NSLock() - private var value: MoshSession? - var session: MoshSession? { - get { lock.lock(); defer { lock.unlock() }; return value } - set { lock.lock(); value = newValue; lock.unlock() } - } -} - -/// 输出闸门:surface 未 attach 时 receive 会丢弃写入,故未就绪先缓冲、就绪后按序 flush(修白屏)。 -final class OutputGate: @unchecked Sendable { - private let lock = NSLock() - private let session: InMemoryTerminalSession - private var ready = false - private var pending = Data() - - init(session: InMemoryTerminalSession) { self.session = session } - - func deliver(_ data: Data) { - lock.lock() - if ready { lock.unlock(); session.receive(data) } - else { pending.append(data); lock.unlock() } - } - - func markReady() { - lock.lock() - if ready { lock.unlock(); return } - ready = true - let buffered = pending; pending = Data() - lock.unlock() - if !buffered.isEmpty { session.receive(buffered) } - } -} - -/// tmux 网关字节的有序事件(enter 携初始字节 / bytes 后续)。经单一 AsyncStream 传到主线程, -/// 杜绝"每 chunk 起独立 Task"导致的乱序(多 pane 高吞吐会打碎 % 协议解析)。 -enum TmuxByteEvent: Sendable { case enter([UInt8]); case bytes([UInt8]) } - -/// 线程安全承载 tmux 字节通道的 continuation(传输后台线程 yield,主线程单消费者取用)。 -final class TmuxByteChannel: @unchecked Sendable { - let stream: AsyncStream - private let cont: AsyncStream.Continuation - init() { (stream, cont) = AsyncStream.makeStream() } - func enter(_ b: [UInt8]) { cont.yield(.enter(b)) } - func bytes(_ b: [UInt8]) { cont.yield(.bytes(b)) } -} - -/// 线程安全记录全屏终端格子数 + cell 像素尺寸(tmux attach/resize 时据此换算 `refresh-client -C`)。 -final class GridBox: @unchecked Sendable { - private let lock = NSLock() - private var cols = 80, rows = 24, cellW = 0, cellH = 0 - var value: (cols: Int, rows: Int, cellW: Int, cellH: Int) { - lock.lock(); defer { lock.unlock() }; return (cols, rows, cellW, cellH) - } - func set(cols: Int, rows: Int, cellW: Int, cellH: Int) { - lock.lock() - self.cols = max(1, cols); self.rows = max(1, rows) - if cellW > 0 { self.cellW = cellW } // cell 尺寸稳定,保留最后有效值 - if cellH > 0 { self.cellH = cellH } - lock.unlock() - } -} - -/// M4 host key 不符详情(供 SwiftUI alert 并排显示旧/新指纹 + 用户抉择)。 -struct HostKeyMismatchInfo: Identifiable { - let id = UUID() - let egress: String, host: String, port: Int - let keyType: Int32 - let presentedBlob: Data - let presentedFp: String - let storedFp: String -} - -/// M0 SSH 终端会话模型:SSHSession(libssh2) ⇄ GhosttyKit in-memory 终端, -/// 由 TXCore.SessionMachine 编排生命周期(断线指数退避自动重连、后台冻结/前台恢复)。 -@MainActor -final class SSHTerminalModel: ObservableObject { - let state: TerminalViewState - let session: InMemoryTerminalSession - private let holder: TransportHolder - private let gate: OutputGate - - /// 是否展示终端页(连接后保持,重连/后台期间不退回表单)。 - @Published var showsTerminal = false - @Published var banner: String = "" - @Published var phaseText: String = "空闲" - /// 非 nil 时进入 tmux control-mode(原生 tab)。 - @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() - - private var machine = SessionMachine() - private var config: SSHConfig? - private var reconnectTask: Task? - private var autoCommand: String? - - /// tsnet 模式:非 nil 时经 tsnet fd 桥连接(而非系统直连),重连复用同一节点。 - private var tsnetAuthKey: String? - private let tsnetMgr = TsnetManager() - - /// M2 mosh 模式:SSH 登录后跑 mosh-server,解析 MOSH CONNECT,改由 mosh(UDP over tsnet relay) 承载会话。 - private var moshMode = false - private var moshServerCmd = "mosh-server new -s -c 256 -l LANG=en_US.UTF-8 -l LC_ALL=en_US.UTF-8" - private let moshHolder: MoshHolder - private var moshSession: MoshSession? - private var moshRelay: TsnetbridgeMoshRelay? - private var moshScanner = MoshConnectScanner() - private var moshBootstrapSent = false - // M3 前台恢复:基线时刻 + healthy 轮询 + 恢复看门狗 + 后台任务。 - private var moshResumeBaseMs: UInt64 = 0 - private var moshHealthyPoll: Task? - private var resumeWatchdog: Task? - #if canImport(UIKit) - private var bgTask: UIBackgroundTaskIdentifier = .invalid - #endif - - private let tmuxChannel = TmuxByteChannel() - /// 全屏终端格子数(raw 模式 surface resize 时记录;tmux attach 用于 refresh-client -C)。 - let screenGrid = GridBox() - - init() { - let holder = TransportHolder() - let moshHolder = MoshHolder() - let grid = screenGrid - let session = InMemoryTerminalSession( - // 输入/resize:mosh 激活时路由到 mosh,否则走 SSH transport。 - write: { data in - if let m = moshHolder.session { m.send([UInt8](data)) } - else { holder.transport?.send(data) } - }, - resize: { vp in - // 记录全屏格子 + cell 像素(tmux resize 换算用)。 - grid.set(cols: Int(vp.columns), rows: Int(vp.rows), - cellW: Int(vp.cellWidthPixels), cellH: Int(vp.cellHeightPixels)) - if let m = moshHolder.session { m.resize(cols: Int(vp.columns), rows: Int(vp.rows)) } - else { holder.transport?.resize(cols: vp.columns, rows: vp.rows) } - } - ) - let state = TerminalViewState() - state.configuration = TerminalSurfaceOptions(backend: .inMemory(session)) - _ = state.controller.setTheme(.txDefault) // Catppuccin Mocha 终端配色 - self.holder = holder - self.moshHolder = moshHolder - self.session = session - self.state = state - self.gate = OutputGate(session: session) - - // tmux 网关字节:单消费者按序处理(enter 建 controller+喂初始,bytes 续喂)。 - let ch = tmuxChannel - Task { @MainActor [weak self] in - for await ev in ch.stream { - guard let self else { continue } - switch ev { - case .enter(let initial): self.enterTmux(initialBytes: initial) - case .bytes(let b): self.tmuxController?.feed(Data(b)) - } - } - } - } - - // MARK: - 公开入口 - - /// 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, - authentication: auth, - hostKeyVerifier: makeHostKeyVerifier(egress: egress, host: host, port: port)) - run(machine.reduce(.connectRequested)) - } - - /// M4:构造 host key 校验器(TOFU)。在 ssh 串行队列同步调用;firstUse 自动 pin + 横幅, - /// mismatch 冒泡到主线程触发 alert 并返回 false(SSHSession 会断开重试)。 - private func makeHostKeyVerifier(egress: String, host: String, port: Int) -> @Sendable (Data, Int32) -> Bool { - let store = knownHosts - let triple = HostTriple(egress: egress, host: host, port: port) - return { [weak self] blob, keyType in - switch HostKey.evaluate(stored: store.lookup(triple)?.blob, presented: blob) { - case .firstUse: - store.pin(triple, record: HostKeyRecord(blob: blob, keyType: keyType)) - let fp = HostKey.opensshFingerprint(blob) - Task { @MainActor in self?.banner = "已记住 host key \(fp)" } - return true - case .trusted: - return true - case .mismatch(let stored): - let info = HostKeyMismatchInfo( - egress: egress, host: host, port: port, keyType: keyType, - presentedBlob: blob, presentedFp: HostKey.opensshFingerprint(blob), - storedFp: HostKey.opensshFingerprint(stored)) - Task { @MainActor in self?.hostKeyMismatch = info } - return false - } - } - } - - /// 用户确认信任新 host key → 覆盖 pin 并重连。 - func trustNewHostKey() { - guard let m = hostKeyMismatch else { return } - knownHosts.pin(HostTriple(egress: m.egress, host: m.host, port: m.port), - record: HostKeyRecord(blob: m.presentedBlob, keyType: m.keyType)) - hostKeyMismatch = nil - run(machine.reduce(.retryRequested)) // failed → connecting(verifier 此时将判定 trusted) - } - - /// 用户取消 → 保持断开。 - 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 - run(machine.reduce(.enteredBackground)) - } - func enterForeground() { - endBgTask() - run(machine.reduce(.enteredForeground)) - } - - /// TerminalScreen 在 surface 首次布局后调用:放行缓冲输出(+ 无头自动命令)。 - func markSurfaceReady() { - gate.markReady() - if let cmd = autoCommand { - autoCommand = nil - let holder = self.holder - Task { - try? await Task.sleep(nanoseconds: 2_500_000_000) - holder.transport?.send(Data("\n".utf8)) - try? await Task.sleep(nanoseconds: 900_000_000) - holder.transport?.send(Data((cmd + "\n").utf8)) - } - } - } - - /// 无头验证:从启动参数自动连接(simctl launch --args -txHost … -txAutoCommand …)。 - func autoConnectIfConfigured() { - let d = UserDefaults.standard - guard let host = d.string(forKey: "txHost"), !host.isEmpty, - let user = d.string(forKey: "txUser") else { return } - let port = d.integer(forKey: "txPort") - autoCommand = d.string(forKey: "txAutoCommand") - if let key = d.string(forKey: "txTsnetKey"), !key.isEmpty { tsnetAuthKey = key } - if d.bool(forKey: "txMosh") { - moshMode = true - if let cmd = d.string(forKey: "txMoshServerCmd"), !cmd.isEmpty { moshServerCmd = cmd } - autoCommand = nil // mosh 模式用自己的引导流程,不用 txAutoCommand - } - let realPort = port == 0 ? 22 : port - // M4b:publickey 认证(SE/软件签名器)。日志打出 authorized_keys 行供加入服务器。 - if d.bool(forKey: "txPubkeyAuth") { - pubkeySigner = SigningKeyProvider.loadOrCreate() - if let s = pubkeySigner { - NSLog("M4DBG authorized_keys hw=\(s.isHardwareBacked): \(s.authorizedKeysLine)") - } - } - // 无头验证:播种一个假 pin → 真实 host key 将 mismatch,触发 alert 路径。 - if let b64 = d.string(forKey: "txHostKeyPinOverrideBase64"), let fake = Data(base64Encoded: b64) { - let egress = tsnetAuthKey != nil ? "tsnet" : "direct" - knownHosts.pin(HostTriple(egress: egress, host: host, port: realPort), - record: HostKeyRecord(blob: fake, keyType: 3)) - } - connect(host: host, port: realPort, - username: user, password: d.string(forKey: "txPass") ?? "") - } - - // MARK: - 状态机副作用执行 - - private func run(_ effects: [SessionMachine.Effect]) { - for effect in effects { - switch effect { - case .startConnect: startSession() - case .scheduleReconnect(_, let delayMS): scheduleReconnect(delayMS) - case .cancelReconnectTimer: reconnectTask?.cancel(); reconnectTask = nil - case .teardownTransport: holder.transport?.stop() - case .notify(let message): banner = message - case .nudgeResume: performWakePulse(); startHealthyPollIfNeeded() - case .scheduleResumeWatchdog(let ms): scheduleResumeWatchdog(ms) - case .cancelResumeWatchdog: cancelResumeWatchdog() - case .teardownMosh: teardownMosh() - } - } - syncUI() - } - - private func startSession() { - guard let config else { return } - if let key = tsnetAuthKey { - // tsnet 模式:后台 up 节点 + dial fd,再用外部 fd 建 SSHSession。 - let mgr = tsnetMgr - Task.detached { - do { - try mgr.ensureUp(authKey: key, timeoutMs: 45000) - let fd = try mgr.dialFD(host: config.host, port: config.port, timeoutMs: 15000) - await MainActor.run { self.wireAndStart(SSHSession(config: config, preconnectedFD: fd)) } - } catch { - await MainActor.run { - self.handleTransportState(.failed("tsnet: \(error.localizedDescription)")) - } - } - } - } else { - wireAndStart(SSHSession(config: config)) - } - } - - private func wireAndStart(_ ssh: SSHSession) { - exitTmux() // 新会话回原始模式(tmux 会话在服务端,重连后需重新 -CC attach) - let router = tmuxRouter - if moshMode { - // mosh 模式:SSH 字节喂给引导流程(既显示进度、又扫描 MOSH CONNECT)。 - ssh.onBytes = { [weak self] data in - Task { @MainActor in self?.moshBootstrapFeed(data) } - } - } else { - ssh.onBytes = { data in router.feed(data) } // 经路由器分流 raw / tmux 网关 - } - ssh.onState = { [weak self] newState in - Task { @MainActor in self?.handleTransportState(newState) } - } - holder.transport = ssh - ssh.start() - } - - private lazy var tmuxRouter = TmuxRouter( - rawGate: gate, - // 经有序通道传主线程(enter 先于后续 bytes,单消费者保序)。 - enterGateway: { [tmuxChannel] after in tmuxChannel.enter(after) }, - gatewayBytes: { [tmuxChannel] bytes in tmuxChannel.bytes(bytes) } - ) - - private func enterTmux(initialBytes: [UInt8]) { - let holder = self.holder - let controller = TmuxController(sendRaw: { data in holder.transport?.send(data) }) - controller.onExit = { [weak self] in Task { @MainActor in self?.exitTmux() } } - // 告知全屏格子数 + cell 像素 → attach 时 refresh-client -C、动态 resize 换算。 - let g = screenGrid.value - controller.setClientSize(cols: g.cols, rows: g.rows) - controller.setCellPixels(w: g.cellW, h: g.cellH) - tmuxController = controller - if !initialBytes.isEmpty { controller.feed(Data(initialBytes)) } - } - - private func exitTmux() { - if tmuxController != nil { tmuxController = nil } - tmuxRouter.reset() - } - - // MARK: - M2 mosh 编排 - - /// SSH 已连接后引导 mosh-server(延迟等 shell 提示符,只发一次)。 - private func scheduleMoshBootstrap() { - guard moshMode, !moshBootstrapSent, moshSession == nil else { return } - moshBootstrapSent = true - let holder = self.holder - let cmd = moshServerCmd - banner = "启动 mosh-server…" - Task { - try? await Task.sleep(nanoseconds: 2_500_000_000) // 等 shell 提示符就绪 - holder.transport?.send(Data("\n".utf8)) - try? await Task.sleep(nanoseconds: 800_000_000) - NSLog("MOSHDBG sent mosh-server cmd: \(cmd)") - holder.transport?.send(Data((cmd + "\n").utf8)) - } - } - - /// mosh 引导期消费 SSH 字节:显示进度 + 扫描 MOSH CONNECT,命中即切换到 mosh。 - /// mosh 接管后忽略 SSH 残余字节,避免污染 mosh 的屏幕状态。 - private func moshBootstrapFeed(_ data: Data) { - guard moshSession == nil else { return } - gate.deliver(data) // 让用户看到 mosh-server 引导输出 - let text = String(decoding: data, as: UTF8.self) - if let info = moshScanner.feed(text) { - NSLog("MOSHDBG parsed MOSH CONNECT port=\(info.port) keyLen=\(info.key.count)") - launchMosh(info) - } - } - - /// 拿到 MOSH CONNECT 后:tsnet 起 UDP relay(loopback:port) → 建 MoshSession → 接管会话。 - private func launchMosh(_ info: MoshConnectInfo) { - guard moshSession == nil, let config else { return } - banner = "mosh 连接中(server port \(info.port))…" - if tsnetAuthKey != nil { - let mgr = tsnetMgr - let host = config.host - Task.detached { - do { - NSLog("MOSHDBG startMoshRelay host=\(host) moshPort=\(info.port)") - let relay = try mgr.startMoshRelay(host: host, moshPort: info.port, timeoutMs: 15000) - let localPort = relay.localPort() - NSLog("MOSHDBG relay up localPort=\(localPort)") - await MainActor.run { self.activateMosh(ip: "127.0.0.1", port: localPort, key: info.key, relay: relay) } - } catch { - NSLog("MOSHDBG relay FAILED: \(error.localizedDescription)") - await MainActor.run { self.banner = "mosh relay 失败:\(error.localizedDescription)" } - } - } - } else { - // 直连模式:iOS 允许出站 UDP,直接连 host:port。 - activateMosh(ip: config.host, port: info.port, key: info.key, relay: nil) - } - } - - private func activateMosh(ip: String, port: Int, key: String, relay: TsnetbridgeMoshRelay?) { - guard moshSession == nil else { return } - let mosh = MoshSession(ip: ip, port: port, key: key, cols: 80, rows: 24) - mosh.onBytes = { [weak self] bytes in - guard let self else { return } - self.gate.deliver(Data(bytes)) - } - mosh.onClosed = { [weak self] rc in - Task { @MainActor in - guard let self else { return } - self.run(self.machine.reduce(.moshExited(rc: Int(rc)))) - } - } - moshRelay = relay - 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)) - } - - // MARK: - M3 前台快速恢复 - - /// 唤醒脉冲:tsnet WakeUp(后台线程,可能阻塞)+ relay 同端口 Rebind + mosh SIGCONT。 - private func performWakePulse() { - guard let mosh = moshSession else { return } - if moshResumeBaseMs == 0 { moshResumeBaseMs = mosh.lastHeardMs() } - if tsnetAuthKey != nil { - let mgr = tsnetMgr - Task.detached { mgr.wakeUp() } - } - do { try moshRelay?.rebind() } - catch { NSLog("MOSHDBG relay rebind failed: \(error.localizedDescription)") } - mosh.nudge() - NSLog("MOSHDBG wake pulse base=\(moshResumeBaseMs)") - } - - /// 轮询 mosh 探针:收到基线之后的新服务器状态即判定 SSP 已续(healthy)。 - private func startHealthyPollIfNeeded() { - guard moshHealthyPoll == nil, let mosh = moshSession else { return } - let base = moshResumeBaseMs - moshHealthyPoll = Task { [weak self] in - for _ in 0 ..< 100 { // ~10s 安全上限(看门狗更早兜底) - try? await Task.sleep(nanoseconds: 100_000_000) - if Task.isCancelled { return } - if mosh.lastHeardMs() > base { - guard let self else { return } - NSLog("TXM3 recovered base=\(base) now=\(mosh.lastHeardMs())") - self.moshHealthyPoll = nil - self.run(self.machine.reduce(.moshHealthy)) - return - } - } - } - } - - private func scheduleResumeWatchdog(_ ms: Int) { - resumeWatchdog?.cancel() - resumeWatchdog = Task { [weak self] in - try? await Task.sleep(nanoseconds: UInt64(ms) * 1_000_000) - guard !Task.isCancelled, let self else { return } - self.run(self.machine.reduce(.resumeWatchdogFired)) - } - } - - private func cancelResumeWatchdog() { - resumeWatchdog?.cancel(); resumeWatchdog = nil - moshHealthyPoll?.cancel(); moshHealthyPoll = nil - moshResumeBaseMs = 0 - } - - /// 拆除 mosh 会话(兜底全量重建 / 关闭时)。重置引导状态,允许重新引导 mosh-server。 - private func teardownMosh() { - cancelResumeWatchdog() - moshEngaged = false - moshHolder.session = nil - moshSession?.close(); moshSession = nil - try? moshRelay?.close(); moshRelay = nil - moshBootstrapSent = false - moshScanner = MoshConnectScanner() - } - - private func beginBgTask() { - #if canImport(UIKit) - guard bgTask == .invalid else { return } - bgTask = UIApplication.shared.beginBackgroundTask(withName: "mosh-keepalive") { [weak self] in - self?.endBgTask() - } - #endif - } - - private func endBgTask() { - #if canImport(UIKit) - if bgTask != .invalid { UIApplication.shared.endBackgroundTask(bgTask); bgTask = .invalid } - #endif - } - - private func handleTransportState(_ st: TransportState) { - switch st { - case .authenticating: run(machine.reduce(.authenticating)) - case .connected: - run(machine.reduce(.established)) - if moshMode { scheduleMoshBootstrap() } - case .disconnected(let reason): run(machine.reduce(.transportClosed(reason: reason))) - case .failed(let message): run(machine.reduce(.authFailed(reason: message))) - case .idle, .connecting: break - } - } - - private func scheduleReconnect(_ delayMS: Int) { - reconnectTask?.cancel() - reconnectTask = Task { [weak self] in - try? await Task.sleep(nanoseconds: UInt64(delayMS) * 1_000_000) - guard !Task.isCancelled else { return } - await MainActor.run { - guard let self else { return } - self.run(self.machine.reduce(.reconnectTimerFired)) - } - } - } - - private func syncUI() { - switch machine.phase { - case .connected, .reconnecting, .waitingToReconnect, .backgroundParked, - .moshActive, .moshParked, .moshResuming: - showsTerminal = true - case .idle, .connecting, .authenticating, .closed, .failed: - showsTerminal = false - } - phaseText = Self.describe(machine.phase) - } - - private static func describe(_ phase: SessionMachine.Phase) -> String { - switch phase { - case .idle: "空闲" - case .connecting: "连接中…" - case .authenticating: "认证中…" - case .connected: "已连接" - case .backgroundParked: "已挂起(后台)" - case .waitingToReconnect(let n): "等待重连(第 \(n) 次)" - case .reconnecting(let n): "重连中(第 \(n) 次)" - case .failed(let r): "失败:\(r)" - case .closed: "已关闭" - case .moshActive: "mosh 已连接" - case .moshParked: "已挂起(后台)" - case .moshResuming(let n): "mosh 恢复中(第 \(n) 次)" - } - } -} diff --git a/apps/TerminalX/iOS/SessionManager.swift b/apps/TerminalX/iOS/SessionManager.swift new file mode 100644 index 0000000..7555e9a --- /dev/null +++ b/apps/TerminalX/iOS/SessionManager.swift @@ -0,0 +1,193 @@ +import Combine +import Foundation +import GhosttyTerminal + +/// 多会话持有者:一台主机可以有多条会话,会话在后台继续跑(最小化 / 回首页都不断)。 +/// +/// 设计要点: +/// - **会话不因导航而销毁**:只有显式关闭(红点 / ⌘W)才 teardown。 +/// - 会话 surface 由 `SessionCanvas` 常驻挂载,故 `%output` 持续消费、内容不丢。 +/// - 生命周期广播(前后台)由本类扇出给每条会话——`TerminalSession` 自己不监听 scenePhase。 +@MainActor +final class SessionManager: ObservableObject { + @Published private(set) var sessions: [TerminalSession] = [] + /// 当前焦点会话(进终端时显示哪条)。 + @Published var focusedID: UUID? + + /// 「已就绪、可以进终端」的会话(视图层消费后清空)。 + /// + /// 为什么要这个信号:点主机后**先留在首页**探测 tmux、弹会话选择器,选完(或探测到没有 + /// tmux)才进终端页 —— 连接过程属于"准备连接",不该先把用户丢进空终端再弹框。 + @Published var enterRequest: UUID? + + /// 点了主机但还没进终端的会话 id(等探测/选择结果)。 + private var awaitingEnter: Set = [] + /// 转发每条会话的变化(嵌套 ObservableObject 不冒泡,见 CLAUDE.md)。 + private var sessionObservers: [UUID: AnyCancellable] = [:] + + private weak var credentialStore: (any CredentialStore)? + private weak var tailscaleStore: TailscaleStore? + + var focused: TerminalSession? { + guard let id = focusedID else { return nil } + return sessions.first { $0.id == id } + } + + func session(_ id: UUID) -> TerminalSession? { + sessions.first { $0.id == id } + } + + /// 正在建立中(还没进终端)的会话 —— 首页据此显示连接进度。 + var connectingSessions: [TerminalSession] { + sessions.filter { awaitingEnter.contains($0.id) } + } + + /// 等用户选 tmux 会话的那条(选择器数据源)。 + var sessionAwaitingChoice: TerminalSession? { + sessions.first { $0.pendingSessionChoice != nil } + } + + /// 首页「活动会话」区的排序:最小化的置顶(设计要求,否则用户不知道东西去哪了),其余按最近活跃。 + var activeSessions: [TerminalSession] { + sessions.sorted { + if $0.isMinimized != $1.isMinimized { return $0.isMinimized } + return $0.lastActiveAt > $1.lastActiveAt + } + } + + /// ⌘1…9 的会话顺序(与首页卡片顺序一致)。 + func session(atShortcutIndex i: Int) -> TerminalSession? { + let list = activeSessions + return i >= 0 && i < list.count ? list[i] : nil + } + + /// 注入解析凭据/出口所需的 Store(新建会话时转交)。 + func bind(credentials: any CredentialStore, tailscale: TailscaleStore) { + credentialStore = credentials + tailscaleStore = tailscale + for s in sessions { s.bind(credentials: credentials, tailscale: tailscale) } + } + + // MARK: - 开 / 关 + + /// 打开一台主机的会话:**已有该主机的活会话则复用**(对标设计「回到刚才那个会话」),否则新建并连接。 + /// 打开一台主机:**已有该主机的活会话则直接复用并进终端**;否则新建会话开始连接, + /// 但**先不进终端** —— 等 tmux 探测/用户选择完成后由 `enterRequest` 通知视图层。 + @discardableResult + func open(host: SavedHost) -> TerminalSession { + if let existing = sessions.first(where: { $0.host?.id == host.id }) { + focus(existing) + enterRequest = existing.id // 复用的会话没有连接过程,立刻进 + return existing + } + let s = makeSession() + sessions.append(s) + focus(s) + awaitingEnter.insert(s.id) + s.connect(to: host) + return s + } + + /// 会话状态变化后判断是否该进终端了。 + /// + /// 就绪 = 不需要用户选择,且(已建立 / 探测确认无 tmux / 用户选了原生 / 连接失败)。 + /// 失败也进终端:那里有「重试 / 返回首页」,比留在首页只显示一行错误更有出路。 + private func evaluateEnter(_ s: TerminalSession) { + guard awaitingEnter.contains(s.id) else { return } + if s.pendingSessionChoice != nil { return } // 等用户选会话 + if s.isProbingTmux { return } // 还在探测 → 留在首页,别先把人送进终端 + let failed = s.phaseText.hasPrefix("失败") + guard s.isLive || s.tmuxUnavailable || s.tmuxSkipped || failed else { return } + awaitingEnter.remove(s.id) + enterRequest = s.id + } + + /// 焦点切换:记录活跃时刻并解除最小化标记。 + func focus(_ s: TerminalSession) { + focusedID = s.id + s.isMinimized = false + s.lastActiveAt = Date() + } + + /// 最小化:**不确认**、不动传输,只标记 + 记时刻(回首页由调用方改路由)。 + func minimize(_ id: UUID) { + guard let s = session(id) else { return } + s.isMinimized = true + s.lastActiveAt = Date() + } + + /// 关闭并移除会话。tmux 的「仅断开 / 结束会话」分支在 UI-4 的确认对话框里决定, + /// 这里只负责最终 teardown + 摘除(会话对象随之释放,surface 一并回收)。 + func close(_ id: UUID) { + guard let s = session(id) else { return } + s.close() + sessions.removeAll { $0.id == id } + sessionObservers[id] = nil + awaitingEnter.remove(id) + if focusedID == id { focusedID = activeSessions.first?.id } + } + + // MARK: - 广播 + + // 假会话不参与:它没有传输层,广播会经 syncUI 把注入状态覆盖回 idle。 + func enterBackground() { for s in sessions where !s.isSyntheticFixture { s.enterBackground() } } + func enterForeground() { for s in sessions where !s.isSyntheticFixture { s.enterForeground() } } + + /// 主题切换:推给所有会话的 raw surface 与 tmux pane。 + func applyTerminalTheme(_ t: TerminalTheme) { + for s in sessions { s.applyTerminalTheme(t) } + } + + /// 任一会话的 host key 告警(多会话时取第一条,逐条处理)。 + var pendingHostKeyMismatch: TerminalSession? { + sessions.first { $0.hostKeyMismatch != nil } + } + + // MARK: - 无头验证 + + /// 启动参数自动连接(simctl launch --args -txHost …)。返回新建的会话供调用方切路由。 + @discardableResult + func autoConnectIfConfigured() -> TerminalSession? { + let d = UserDefaults.standard + guard let host = d.string(forKey: "txHost"), !host.isEmpty else { return nil } + let s = makeSession() + sessions.append(s) + focus(s) + s.autoConnectIfConfigured() + return s + } + + #if DEBUG + /// 无头 UI 验证:批量造假会话(`-txUIFixture 1`)。返回第一条供调用方进终端。 + @discardableResult + func spawnFixtureSessions() -> TerminalSession? { + for (i, spec) in UIPreviewFixture.specs.enumerated() { + // 复用 UIPreviewFixture.hosts 里的同名主机(同一 id),主机卡才能亮「已有会话」。 + guard let host = UIPreviewFixture.hosts.first(where: { $0.name == spec.name }) else { continue } + let s = makeSession() + s.injectFixture(host: host, lines: spec.lines, link: spec.link, + mosh: host.useMosh, tmux: spec.tmux, + tmuxSessions: spec.tmux == nil ? [] : UIPreviewFixture.tmuxSessions(current: spec.tmux!.name)) + // 活跃时刻错开,让首页卡片排序稳定可对拍。 + s.lastActiveAt = Date(timeIntervalSinceNow: Double(-i) * 60) + sessions.append(s) + } + focusedID = sessions.first?.id + return sessions.first + } + #endif + + private func makeSession() -> TerminalSession { + let s = TerminalSession() + if let c = credentialStore, let t = tailscaleStore { + s.bind(credentials: c, tailscale: t) + } + // 转发会话变化:① 让订阅 manager 的视图(首页/选择器)跟着刷新;② 驱动 enter 判定。 + sessionObservers[s.id] = s.objectWillChange.sink { [weak self, weak s] _ in + guard let self else { return } + self.objectWillChange.send() + if let s { Task { @MainActor in self.evaluateEnter(s) } } + } + return s + } +} diff --git a/apps/TerminalX/iOS/SessionPickerSheet.swift b/apps/TerminalX/iOS/SessionPickerSheet.swift new file mode 100644 index 0000000..d485931 --- /dev/null +++ b/apps/TerminalX/iOS/SessionPickerSheet.swift @@ -0,0 +1,206 @@ +import SwiftUI +import TXCore + +/// 连接前的会话选择器(连接流程的一部分):点主机 → 探测服务器 → 问「接回哪个会话」,选完才进终端。 +/// +/// 三种连接方式:**接回已有 tmux 会话** · **新建 tmux 会话** · **原生终端**(不经 tmux)。 +/// 服务器**没装 tmux 时不显示本 sheet** —— 直接连原生终端,不拿一个只有一个选项的框去打扰用户。 +/// +/// 措辞注意:右上不是「跳过」(那读起来像"跳过某个步骤",含义模糊)——它是一种**并列的连接方式**, +/// 所以直接叫「原生终端」。分段控件里 `Herdr` / `最近` 暂时置灰(后续支持),位置先占住避免重排。 +struct SessionPickerSheet: View { + /// 正在连接的主机名(标题里点明"连到哪台",避免多主机时看不出上下文)。 + let host: String + let choice: TmuxSessionChoice + let onAttach: (String) -> Void + let onCreate: (String) -> Void + let onNative: () -> Void + @EnvironmentObject var theme: TXThemeManager + @State private var newName = "" + @State private var creating = false + @FocusState private var nameFocused: Bool + private var p: TXPalette { theme.palette } + + var body: some View { + VStack(alignment: .leading, spacing: TX.Space.m) { + grabber + title + header + if choice.sessions.isEmpty { + emptyState + } else { + sessionList + } + newSessionRow + Spacer(minLength: 0) + } + .padding(.horizontal, TX.Space.l) + .padding(.bottom, TX.Space.l) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .background(p.bg.ignoresSafeArea()) + } + + private var title: some View { + VStack(alignment: .leading, spacing: 3) { + Text("连接到 \(host)") + .font(TX.Font.mono(17, .medium)) + .foregroundStyle(p.text) + Text("接回一个 tmux 会话,或直接用原生终端") + .font(TX.Font.mono(12)) + .foregroundStyle(p.textWeak) + } + } + + private var grabber: some View { + RoundedRectangle(cornerRadius: 2.5) + .fill(p.borderStrong) + .frame(width: 44, height: 5) + .frame(maxWidth: .infinity) + .padding(.top, TX.Space.s) + } + + // MARK: - 分段控件 + 跳过 + + private var header: some View { + HStack(spacing: TX.Space.s) { + HStack(spacing: 2) { + segment(icon: "chevron.left.forwardslash.chevron.right", title: "Tmux", enabled: true) + segment(icon: "shippingbox", title: "Herdr", enabled: false) + segment(icon: "clock.arrow.circlepath", title: "最近", enabled: false) + } + .padding(4) + .background(p.surface, in: Capsule()) + Spacer(minLength: TX.Space.s) + Button(action: onNative) { + HStack(spacing: TX.Space.xs) { + Image(systemName: "terminal") + .font(.system(size: 13, weight: .medium)) + Text("原生终端") + .font(TX.Font.mono(15, .medium)) + } + .foregroundStyle(p.text) + .padding(.horizontal, TX.Space.m) + .frame(height: 44) + .background(p.surface, in: Capsule()) + .overlay(Capsule().strokeBorder(p.border, lineWidth: 1)) + } + .buttonStyle(.plain) + .accessibilityLabel("直接连接原生终端,不经过 tmux") + } + } + + private func segment(icon: String, title: String, enabled: Bool) -> some View { + HStack(spacing: TX.Space.xs) { + Image(systemName: icon) + .font(.system(size: 13, weight: .medium)) + Text(title) + .font(TX.Font.mono(15, .medium)) + } + .foregroundStyle(enabled ? p.text : p.textWeak.opacity(0.7)) + .padding(.horizontal, TX.Space.m) + .frame(height: 36) + .background(enabled ? p.bgDeep : .clear, in: Capsule()) + .overlay { + if enabled { + Capsule().strokeBorder(p.border, lineWidth: 1) + } + } + } + + // MARK: - 会话列表 + + private var sessionList: some View { + VStack(spacing: 0) { + ForEach(Array(choice.sessions.enumerated()), id: \.element.name) { index, s in + Button { onAttach(s.name) } label: { + sessionRow(s) + } + .buttonStyle(.plain) + if index < choice.sessions.count - 1 { + Rectangle().fill(p.divider).frame(height: 1) + .padding(.horizontal, TX.Space.m) + } + } + } + .background(p.surfaceDark, in: RoundedRectangle(cornerRadius: TX.Radius.card)) + } + + private func sessionRow(_ s: TmuxSessionProbe.Session) -> some View { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: TX.Space.s) { + Text(s.name) + .font(TX.Font.mono(22, .medium)) + .foregroundStyle(p.text) + // tmux 会话本身就是活着的服务端进程 → 一律「活跃」;被别的客户端占用时另标。 + TXBadge(text: "活跃", emphasized: true) + if s.isAttached { + TXBadge(text: "已被接入") + } + Spacer(minLength: 0) + } + Text(subtitle(s)) + .font(TX.Font.mono(13)) + .foregroundStyle(p.textWeak) + } + .padding(.horizontal, TX.Space.m) + .padding(.vertical, 18) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + } + + private func subtitle(_ s: TmuxSessionProbe.Session) -> String { + var parts = ["\(s.windows) 个窗口"] + if let t = s.lastActivity { parts.append(SessionCard.relative(t)) } + return parts.joined(separator: " · ") + } + + private var emptyState: some View { + VStack(alignment: .leading, spacing: 6) { + Text("这台主机上还没有 tmux 会话") + .font(TX.Font.mono(15, .medium)) + .foregroundStyle(p.text) + Text("新建一个就能让会话留在服务端:关掉 App、换网络都能原样接回。也可以直接用原生终端。") + .font(TX.Font.mono(12)) + .foregroundStyle(p.textWeak) + .lineSpacing(3) + } + .padding(TX.Space.m) + .frame(maxWidth: .infinity, alignment: .leading) + .background(p.surfaceDark, in: RoundedRectangle(cornerRadius: TX.Radius.card)) + } + + // MARK: - 新建 + + @ViewBuilder private var newSessionRow: some View { + if creating { + HStack(spacing: TX.Space.s) { + TXSearchField(text: $newName, placeholder: choice.defaultNewName) + .focused($nameFocused) + TXButton(title: "创建", emphasized: true) { + let name = newName.trimmingCharacters(in: .whitespaces) + onCreate(name.isEmpty ? choice.defaultNewName : name) + } + TXButton(title: "取消") { creating = false } + } + } else { + Button { + creating = true + nameFocused = true + } label: { + HStack(spacing: TX.Space.s) { + Image(systemName: "plus") + .font(.system(size: 13, weight: .medium)) + Text("新建 tmux 会话…") + .font(TX.Font.mono(15, .medium)) + Spacer(minLength: 0) + } + .foregroundStyle(TXAccent.textBright) + .padding(.horizontal, TX.Space.m) + .frame(height: 56) + .background(p.surfaceDark, in: RoundedRectangle(cornerRadius: TX.Radius.card)) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + } +} diff --git a/apps/TerminalX/iOS/SettingsView.swift b/apps/TerminalX/iOS/SettingsView.swift new file mode 100644 index 0000000..82a10c5 --- /dev/null +++ b/apps/TerminalX/iOS/SettingsView.swift @@ -0,0 +1,173 @@ +import SwiftUI + +/// 设置容器(首页齿轮 → sheet)。过渡形态:`NavigationStack` + 分类跳转。 +/// UI-5 将按设计稿 `1g 设置-外观` 重写为「左 280pt 类别栏 + 右内容区」。 +struct SettingsHost: View { + @ObservedObject var credStore: FileCredentialStore + @ObservedObject var tailscaleStore: TailscaleStore + @Environment(\.dismiss) private var dismiss + @EnvironmentObject var theme: TXThemeManager + /// known hosts 是无状态的 keychain/文件访问层,容器自持一个即可。 + private let knownHosts = KeychainKnownHostsStore() + private var p: TXPalette { theme.palette } + + var body: some View { + NavigationStack { + ZStack { + p.bg.ignoresSafeArea() + List { + Section("外观") { + NavigationLink { SettingsView() } label: { row("paintbrush", "外观与主题") } + } + .listRowBackground(p.surface) + Section("保险库") { + NavigationLink { KeychainListView(store: credStore) } label: { + row("key.fill", "Keychain 凭据") + } + NavigationLink { KnownHostsView(store: knownHosts) } label: { + row("checkmark.shield", "Known Hosts") + } + } + .listRowBackground(p.surface) + Section("网络") { + NavigationLink { TailscaleListView(store: tailscaleStore) } label: { + row("network", "Tailscale") + } + } + .listRowBackground(p.surface) + } + .scrollContentBackground(.hidden) + .foregroundStyle(p.text) + } + .navigationTitle("设置") + .toolbarBackground(p.bg, for: .navigationBar) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button("完成") { dismiss() }.tint(TXAccent.base) + } + } + } + .tint(TXAccent.base) + } + + private func row(_ icon: String, _ title: String) -> some View { + Label(title, systemImage: icon) + .font(TX.Font.mono(13)) + .foregroundStyle(p.text) + } +} + +/// 设置页:主题(app 与终端统一)+ 终端 + 关于。 +struct SettingsView: View { + @EnvironmentObject var theme: TXThemeManager + private var p: TXPalette { theme.palette } + + var body: some View { + 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) + .toolbarBackground(p.bg, for: .navigationBar) + } + + /// 主题行:色板预览(底/面/强调)+ 名称 + 选中勾。 + 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)) + } + } +} + +/// 已信任 host key(TOFU pin)查看/删除页。 +struct KnownHostsView: View { + let store: KeychainKnownHostsStore + @EnvironmentObject var theme: TXThemeManager + @State private var entries: [KnownHostEntry] = [] + private var p: TXPalette { theme.palette } + + var body: some View { + ZStack { + p.bg.ignoresSafeArea() + ScrollView { + VStack(alignment: .leading, spacing: TX.Space.s) { + if entries.isEmpty { + TXEmptyState(icon: "checkmark.shield", title: "还没有已信任的主机密钥", + subtitle: "首次连接主机时会自动记住其 host key") + } else { + ForEach(entries) { e in + row(e) + .contextMenu { + Button(role: .destructive) { + store.removeKey(e.id); reload() + } label: { Label("移除信任", systemImage: "trash") } + } + } + } + } + .padding(TX.Space.m) + } + } + .navigationTitle("Known Hosts") + .navigationBarTitleDisplayMode(.inline) + .toolbarBackground(p.bg, for: .navigationBar) + .onAppear(perform: reload) + } + + private func row(_ e: KnownHostEntry) -> some View { + VStack(alignment: .leading, spacing: 3) { + Text("\(e.host):\(e.port)").font(.system(size: 15, weight: .semibold)).foregroundStyle(p.textPrimary) + Text("出口:\(e.egress)").font(TX.Font.mono(11)).foregroundStyle(p.textSecondary) + Text(e.fingerprint).font(TX.Font.mono(11)).foregroundStyle(p.textTertiary) + .textSelection(.enabled) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(TX.Space.m) + .background(p.surface, in: RoundedRectangle(cornerRadius: TX.Radius.card)) + .overlay(RoundedRectangle(cornerRadius: TX.Radius.card).stroke(p.border, lineWidth: 1)) + } + + private func reload() { entries = store.entries() } +} diff --git a/apps/TerminalX/iOS/SigningKeyProvider.swift b/apps/TerminalX/iOS/SigningKeyProvider.swift index e867885..6c39aac 100644 --- a/apps/TerminalX/iOS/SigningKeyProvider.swift +++ b/apps/TerminalX/iOS/SigningKeyProvider.swift @@ -43,6 +43,23 @@ final class SigningKeyProvider: SSHSigner, @unchecked Sendable { return URL(fileURLWithPath: base).appendingPathComponent("ssh-ecdsa-p256.key") } + /// 从 SecKey 外部表示(x9.63 私钥,生成时存入凭据机密)重建签名器(路径 A)。 + static func from(x963: Data) -> SigningKeyProvider? { + guard let key = SecKeyCreateWithData(x963 as CFData, [ + kSecAttrKeyType: kSecAttrKeyTypeECSECPrimeRandom, + kSecAttrKeyClass: kSecAttrKeyClassPrivate, + kSecAttrKeySizeInBits: 256, + ] as CFDictionary, nil) else { return nil } + return SigningKeyProvider(privateKey: key, hardware: false) + } + + /// 生成一把软件 P-256 私钥,返回签名器 + 其外部表示(供凭据机密持久化)。 + static func generateSoftwareP256() -> (provider: SigningKeyProvider, x963: Data)? { + guard let sw = createSoftwareKey(), + let ext = SecKeyCopyExternalRepresentation(sw, nil) as Data? else { return nil } + return (SigningKeyProvider(privateKey: sw, hardware: false), ext) + } + static func loadOrCreate() -> SigningKeyProvider? { // 1) 软件密钥文件(模拟器持久化,跨启动稳定)。 if let data = try? Data(contentsOf: keyFileURL), @@ -65,6 +82,23 @@ final class SigningKeyProvider: SSHSigner, @unchecked Sendable { return SigningKeyProvider(privateKey: sw, hardware: false) } + /// 运行时探测 Secure Enclave 是否可用(建一把临时非持久 SE key)。 + /// 未签名/模拟器返回 false(驱动 UI 灰化 Face ID 选项);签名真机自动点亮,无需改码。 + static func secureEnclaveAvailable() -> Bool { + guard let access = SecAccessControlCreateWithFlags( + nil, kSecAttrAccessibleWhenUnlockedThisDeviceOnly, .privateKeyUsage, nil) else { return false } + let attrs: [String: Any] = [ + kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom, + kSecAttrKeySizeInBits as String: 256, + kSecAttrTokenID as String: kSecAttrTokenIDSecureEnclave, + kSecPrivateKeyAttrs as String: [ + kSecAttrIsPermanent as String: false, + kSecAttrAccessControl as String: access, + ], + ] + return SecKeyCreateRandomKey(attrs as CFDictionary, nil) != nil + } + private static func createSecureEnclaveKey() -> SecKey? { guard let access = SecAccessControlCreateWithFlags( nil, kSecAttrAccessibleWhenUnlockedThisDeviceOnly, .privateKeyUsage, nil) else { return nil } diff --git a/apps/TerminalX/iOS/TXParts.swift b/apps/TerminalX/iOS/TXParts.swift new file mode 100644 index 0000000..f769be0 --- /dev/null +++ b/apps/TerminalX/iOS/TXParts.swift @@ -0,0 +1,301 @@ +import SwiftUI + +// 设计稿反复出现的小件(首页 / 轨道 / 侧边栏共用)。数值取自 docs/design/UI-DESIGN-HANDOFF.md。 + +/// 状态点:8pt 实心 + 2pt 挖底描边(`box-shadow: 0 0 0 2px <底色>`,让点浮在头像/卡片边缘上)。 +struct TXStatusDot: View { + let color: Color + var size: CGFloat = 8 + /// 挖底色(点所压的那层的底色);nil = 不挖底。 + var punchOut: Color? + + var body: some View { + Circle() + .fill(color) + .frame(width: size, height: size) + .overlay { + if let punchOut { + Circle().stroke(punchOut, lineWidth: 2) + } + } + } +} + +/// 两字缩写头像(轨道 38pt / 侧边栏 26pt / 首页卡 26pt)。选中态用 inset 描边,避免内容位移 1pt。 +struct TXInitialsAvatar: View { + let initials: String + var size: CGFloat = 26 + var isSelected: Bool = false + /// 右上状态点颜色(nil = 不显示)。 + var status: Color? + /// 状态点的挖底色(所在容器底色)。 + var punchOut: Color = .clear + @EnvironmentObject var theme: TXThemeManager + private var p: TXPalette { theme.palette } + + private var radius: CGFloat { size >= 34 ? 11 : TX.Radius.badge } + + var body: some View { + Text(initials) + .font(TX.Font.mono(size >= 34 ? 13 : 11, .semibold)) + .foregroundStyle(isSelected ? TXAccent.text : p.text3) + .frame(width: size, height: size) + .background(isSelected ? TXAccent.selectedBg : p.surface, + in: RoundedRectangle(cornerRadius: radius)) + .overlay( + RoundedRectangle(cornerRadius: radius) + .strokeBorder(isSelected ? TXAccent.dim : p.border, lineWidth: 1) + ) + .overlay(alignment: .topTrailing) { + if let status { + TXStatusDot(color: status, punchOut: punchOut) + .offset(x: 3, y: -3) + } + } + } +} + +/// 分组小标题(600 10px, ls .09em, uppercase)。 +struct TXSectionLabel: View { + let text: String + /// 右侧计数(如「活动会话 3」的 3 单独右对齐)。 + var trailing: String? + var color: Color? + @EnvironmentObject var theme: TXThemeManager + private var p: TXPalette { theme.palette } + + var body: some View { + HStack { + Text(text.uppercased()) + .font(TX.Font.mono(10, .semibold)) + .tracking(0.9) + .foregroundStyle(color ?? p.textWeak) + if let trailing { + Spacer() + Text(trailing) + .font(TX.Font.mono(10, .semibold)) + .foregroundStyle(color ?? p.textWeak) + } + } + } +} + +/// 徽标胶囊(主机卡的 mosh / tsnet / SSH / SE key)。 +struct TXBadge: View { + let text: String + var emphasized: Bool = false + @EnvironmentObject var theme: TXThemeManager + private var p: TXPalette { theme.palette } + + var body: some View { + Text(text) + .font(TX.Font.mono(11, .medium)) + .foregroundStyle(emphasized ? TXAccent.text : p.text4) + .padding(.horizontal, 7) + .padding(.vertical, 3) + .background(emphasized ? TXAccent.selectedBg : p.surface, + in: RoundedRectangle(cornerRadius: 5)) + .overlay( + RoundedRectangle(cornerRadius: 5) + .strokeBorder(emphasized ? TXAccent.selectedStroke : .clear, lineWidth: 1) + ) + } +} + +/// 筛选 chip(首页「全部 / 生产 / 家里 / 云」)。 +struct TXChip: View { + let text: String + let isSelected: Bool + let action: () -> Void + @EnvironmentObject var theme: TXThemeManager + private var p: TXPalette { theme.palette } + + var body: some View { + Button(action: action) { + Text(text) + .font(TX.Font.mono(11.5, .medium)) + .foregroundStyle(isSelected ? TXAccent.textBright : p.text4) + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background(isSelected ? TXAccent.selectedBg : p.surface, + in: RoundedRectangle(cornerRadius: TX.Radius.badge)) + .overlay( + RoundedRectangle(cornerRadius: TX.Radius.badge) + .strokeBorder(isSelected ? TXAccent.selectedStroke : p.divider, lineWidth: 1) + ) + } + .buttonStyle(.plain) + } +} + +/// 搜索框(32pt 高、圆角 8、控件底)。右侧可挂快捷键提示。 +struct TXSearchField: View { + @Binding var text: String + var placeholder: String + var shortcut: String? + @EnvironmentObject var theme: TXThemeManager + private var p: TXPalette { theme.palette } + + var body: some View { + HStack(spacing: TX.Space.xs) { + Image(systemName: "magnifyingglass") + .font(.system(size: 12)) + .foregroundStyle(p.textWeak) + TextField(placeholder, text: $text) + .font(TX.Font.mono(12)) + .foregroundStyle(p.text) + .textFieldStyle(.plain) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + if let shortcut, text.isEmpty { + Text(shortcut) + .font(TX.Font.mono(10.5)) + .foregroundStyle(p.textFaint) + } + } + .padding(.horizontal, TX.Space.s) + .frame(height: 32) + .background(p.surface, in: RoundedRectangle(cornerRadius: 8)) + .overlay( + RoundedRectangle(cornerRadius: 8).strokeBorder(p.divider, lineWidth: 1) + ) + } +} + +/// 次要按钮(描边款,如「新建连接」「安装并启用 tmux」)。 +struct TXButton: View { + let title: String + var icon: String? + /// 主动作用强调描边 + 强调文字(设计:强调色只做线和文字,不铺底)。 + var emphasized: Bool = false + let action: () -> Void + @EnvironmentObject var theme: TXThemeManager + private var p: TXPalette { theme.palette } + + var body: some View { + Button(action: action) { + HStack(spacing: TX.Space.xs) { + if let icon { + Image(systemName: icon).font(.system(size: 12, weight: .medium)) + } + Text(title).font(TX.Font.mono(12, .medium)) + } + .foregroundStyle(emphasized ? TXAccent.textBright : p.text2) + .padding(.horizontal, 14) + .frame(height: 32) + .background(p.surface, in: RoundedRectangle(cornerRadius: TX.Radius.control)) + .overlay( + RoundedRectangle(cornerRadius: TX.Radius.control) + .strokeBorder(emphasized ? TXAccent.border : p.border, lineWidth: 1) + ) + } + .buttonStyle(.plain) + } +} + +/// 图标按钮。两种形态(按设计稿区分,别混): +/// - **有框**(`bare: false`):控件底 + 常规描边,用于展开键、页头齿轮、Pin 等"控件"。 +/// - **裸图标**(`bare: true`):无底无框,用于轨道里的搜索 / 首页 / 设置——设计稿这三个只有图标。 +struct TXIconButton: View { + let icon: String + var size: CGFloat = 34 + var isSelected: Bool = false + var bare: Bool = false + /// 命中区(默认补到 44pt 触控下限;轨道里按设计坐标排布时传入实际间距)。 + var hitSize: CGFloat? + var label: String? + let action: () -> Void + @EnvironmentObject var theme: TXThemeManager + private var p: TXPalette { theme.palette } + + var body: some View { + Button(action: action) { + Image(systemName: icon) + .font(.system(size: bare ? 17 : 14, weight: .regular)) + .foregroundStyle(isSelected ? TXAccent.text : p.text3) + .frame(width: size, height: size) + .background { + if !bare { + RoundedRectangle(cornerRadius: TX.Radius.control) + .fill(isSelected ? TXAccent.selectedBg : p.surface) + .overlay( + RoundedRectangle(cornerRadius: TX.Radius.control) + .strokeBorder(isSelected ? TXAccent.selectedStroke : p.border, + lineWidth: 1) + ) + } + } + .frame(width: hitSize ?? max(size, TX.Layout.minTouch), + height: hitSize ?? max(size, TX.Layout.minTouch)) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel(label ?? icon) + } +} + +/// 终端内容的**文本镜像**(首页会话缩略卡 / 侧边栏预览)。 +/// +/// 为什么不渲染真 surface:一个 ghostty surface 只有一个 IOSurfaceLayer,不能同处两地; +/// 离屏 Metal 渲染是已修过的泄漏雷区。故用 `readViewportText()` 抓文本,按终端底色排版, +/// **内容底对齐**(新输出贴着卡片下缘,和真终端的视觉重心一致)。 +struct TXTerminalMirror: View { + let lines: [String] + var fontSize: CGFloat = 11 + /// 终端底色(Catppuccin base,跟随所选 flavor 的终端配色而非 chrome)。 + var background: Color + @EnvironmentObject var theme: TXThemeManager + private var p: TXPalette { theme.palette } + + /// 无内容时的占位文案(真实会话刚建立、surface 还没 attach 或还没有输出)。 + var placeholder: String = "等待输出…" + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + Spacer(minLength: 0) + if lines.isEmpty { + Text(placeholder) + .font(TX.Font.mono(fontSize)) + .foregroundStyle(p.text4) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) + } + ForEach(Array(lines.enumerated()), id: \.offset) { _, line in + Text(line.isEmpty ? " " : line) + .font(TX.Font.mono(fontSize)) + // 设计:终端次要文本 11px / 行高 1.5 → 行距 ≈ 字号 × 0.6。 + .lineSpacing(fontSize * 0.6) + .foregroundStyle(color(for: line)) + .lineLimit(1) + .truncationMode(.tail) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + .padding(.horizontal, 14) + .padding(.vertical, TX.Space.xs) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomLeading) + .background(background) + .clipped() + } + + /// 启发式着色:`readViewportText()` 只给纯文本(ANSI 属性已丢),故按行首符号/常见片段上色, + /// 逼近真实终端的信息密度。不追求语法正确——错判只影响一行的颜色,不影响可读性。 + private func color(for line: String) -> Color { + let t = line.trimmingCharacters(in: .whitespaces) + if t.isEmpty { return p.text4 } + if t.hasPrefix("✓") || t.hasPrefix("✔") || t.hasPrefix("✅") || t.hasPrefix("**") { + return p.online + } + if t.hasPrefix("✗") || t.hasPrefix("❌") { return p.danger } + let lower = t.lowercased() + if lower.hasPrefix("error") || lower.contains(" error:") { return p.danger } + if lower.hasPrefix("warning") || lower.contains(" warning:") { return p.warning } + if t.hasPrefix("◇") || t.hasPrefix("▸") || t.hasPrefix("↳") || t.hasPrefix("*") { + return p.mosh + } + if t.hasPrefix("●") { return p.online } + // 提示符行(→ / ➜ / $ 结尾的命令行)用主文字色,命令输出用次文字色 —— 与真实终端观感一致。 + if t.hasPrefix("→") || t.hasPrefix("➜") || t.contains("$ ") { return p.text } + if t.hasPrefix("[") { return p.offline } + return p.text3 + } +} diff --git a/apps/TerminalX/iOS/TailscaleStore.swift b/apps/TerminalX/iOS/TailscaleStore.swift new file mode 100644 index 0000000..60c3c83 --- /dev/null +++ b/apps/TerminalX/iOS/TailscaleStore.swift @@ -0,0 +1,80 @@ +import Foundation + +/// 一个 Tailscale 连接(= 一个常驻 tsnet 节点)。可添加多个;host 可绑定其一作为出口。 +/// 每连接独立 stateDir(`tsnet-`,创建即固化、终身不变——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) + } +} diff --git a/apps/TerminalX/iOS/TailscaleView.swift b/apps/TerminalX/iOS/TailscaleView.swift new file mode 100644 index 0000000..21485e7 --- /dev/null +++ b/apps/TerminalX/iOS/TailscaleView.swift @@ -0,0 +1,206 @@ +import SwiftUI + +/// Tailscale 连接列表页。可添加多个;每个是一个常驻 tsnet 节点。 +struct TailscaleListView: View { + @ObservedObject var store: TailscaleStore + @EnvironmentObject var theme: TXThemeManager + @State private var editing: TailscaleConnection? + @State private var showEditor = false + private var p: TXPalette { theme.palette } + + var body: some View { + ZStack { + p.bg.ignoresSafeArea() + ScrollView { + VStack(alignment: .leading, spacing: TX.Space.s) { + if store.connections.isEmpty { + TXEmptyState(icon: "network", title: "还没有 Tailscale 连接", + subtitle: "添加一个 tailnet,主机可选择经它连接") + } else { + ForEach(store.connections) { conn in + connRow(conn) + .contextMenu { + Button { editing = conn; showEditor = true } label: { + Label("编辑", systemImage: "pencil") + } + Button(role: .destructive) { store.remove(conn) } label: { + Label("删除", systemImage: "trash") + } + } + } + } + } + .padding(TX.Space.m) + } + } + .navigationTitle("Tailscale") + .navigationBarTitleDisplayMode(.inline) + .toolbarBackground(p.bg, for: .navigationBar) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { editing = nil; showEditor = true } label: { Image(systemName: "plus") } + } + } + .sheet(isPresented: $showEditor) { + TailscaleEditorView(existing: editing, store: store) + } + } + + private func connRow(_ conn: TailscaleConnection) -> some View { + HStack(spacing: TX.Space.m) { + Image(systemName: "network") + .font(.system(size: 18)) + .foregroundStyle(conn.needsAuthKey ? p.textTertiary : p.mosh) + .frame(width: 40, height: 40) + .background(p.surfaceElevated, in: RoundedRectangle(cornerRadius: TX.Radius.chip)) + VStack(alignment: .leading, spacing: 2) { + Text(conn.name).font(.system(size: 15, weight: .semibold)).foregroundStyle(p.textPrimary) + Text(conn.needsAuthKey ? "未认证" : (conn.lastSelfIP.map { "已注册 · \($0)" } ?? "已注册")) + .font(TX.Font.mono(12)) + .foregroundStyle(conn.needsAuthKey ? p.warning : p.textSecondary) + } + Spacer() + Circle().fill(conn.needsAuthKey ? p.offline : p.mosh).frame(width: 8, height: 8) + } + .padding(TX.Space.m) + .background(p.surface, in: RoundedRectangle(cornerRadius: TX.Radius.card)) + .overlay(RoundedRectangle(cornerRadius: TX.Radius.card).stroke(p.border, lineWidth: 1)) + } +} + +/// Tailscale 连接编辑页:填 name/hostname/authKey → 注册(Up tsnet 节点)→ 显示 SelfIP。 +struct TailscaleEditorView: View { + let existing: TailscaleConnection? + @ObservedObject var store: TailscaleStore + + @EnvironmentObject var theme: TXThemeManager + @Environment(\.dismiss) private var dismiss + + @State private var name = "" + @State private var hostname = "terminalx-ipad" + @State private var authKey = "" + @State private var registering = false + @State private var statusText = "" + @State private var registered = false + @State private var draft: TailscaleConnection? + + private var p: TXPalette { theme.palette } + private var canRegister: Bool { !name.isEmpty && (!authKey.isEmpty || registered) } + + var body: some View { + NavigationStack { + ZStack { + p.bg.ignoresSafeArea() + Form { + Section("连接") { + TXTextField(title: "名称", text: $name, placeholder: "公司 tailnet") + TXTextField(title: "本节点主机名", text: $hostname) + } + Section { + TXTextField(title: "Auth Key(tskey-…)", text: $authKey, secure: true) + Button { + register() + } label: { + HStack { + if registering { ProgressView().controlSize(.small) } + Text(registering ? "注册中…" : "注册 / 连接测试") + } + } + .disabled(!canRegister || registering) + .txRow(p) + } header: { + Text("认证") + } footer: { + Text("Auth key 仅用于首次注册,注册成功后不保存(节点身份持久化在设备本地)。") + } + if !statusText.isEmpty { + Section { + Text(statusText) + .font(.system(size: 13)) + .foregroundStyle(registered ? p.accent : p.warning) + .txRow(p) + } + } + } + .scrollContentBackground(.hidden) + } + .navigationTitle(existing == nil ? "添加 Tailscale" : "编辑 Tailscale") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("取消") { dismiss() }.tint(p.textSecondary) + } + ToolbarItem(placement: .confirmationAction) { + Button("完成") { finish() }.tint(p.accent) + } + } + .toolbarBackground(p.bg, for: .navigationBar) + .toolbarBackground(.visible, for: .navigationBar) + .onAppear(perform: populate) + } + .tint(p.accent) + } + + private func populate() { + guard let c = existing else { return } + name = c.name; hostname = c.hostname + registered = !c.needsAuthKey + draft = c + if registered { statusText = c.lastSelfIP.map { "已注册 · \($0)" } ?? "已注册" } + } + + /// 注册:经 TsnetRegistry Up 该连接的节点(后台阻塞),成功后 SelfIP 落库、needsAuthKey=false。 + private func register() { + // 确定连接身份(新建则造一个,固化 stateDir)。 + let conn = draft ?? existing ?? { + let c = TailscaleConnection(name: name, hostname: hostname) + return c + }() + draft = conn + registering = true; statusText = "" + let connID = conn.id, dir = conn.stateDirName, host = hostname, key = authKey + Task.detached { + do { + let node = try TsnetRegistry.shared.node( + connID: connID, stateDirName: dir, hostname: host, authKey: key, timeoutMs: 45000) + let ip = node.selfIP() + await MainActor.run { + registering = false; registered = true + statusText = ip.isEmpty ? "已注册(IP 获取中)" : "已注册 · \(ip)" + // 落库(保留同一 id/stateDir)。 + let updated = TailscaleConnection(id: connID, name: name.isEmpty ? "tailnet" : name, + hostname: host, needsAuthKey: false, + lastSelfIP: ip.isEmpty ? nil : ip) + persist(updated) + } + } catch { + await MainActor.run { + registering = false + statusText = "注册失败:\(error.localizedDescription)" + } + } + } + } + + /// 完成:若已注册则确保落库;未注册也保存 meta(needsAuthKey=true,供之后再认证)。 + private func finish() { + let base = draft ?? existing + let conn: TailscaleConnection + if let base { + conn = TailscaleConnection(id: base.id, name: name.isEmpty ? "tailnet" : name, + hostname: hostname, needsAuthKey: !registered, + lastSelfIP: registered ? base.lastSelfIP : nil) + } else { + conn = TailscaleConnection(name: name.isEmpty ? "tailnet" : name, + hostname: hostname, needsAuthKey: !registered) + } + persist(conn) + dismiss() + } + + private func persist(_ conn: TailscaleConnection) { + if store.connections.contains(where: { $0.id == conn.id }) { store.update(conn) } + else { store.add(conn) } + draft = conn + } +} diff --git a/apps/TerminalX/iOS/TerminalChrome.swift b/apps/TerminalX/iOS/TerminalChrome.swift new file mode 100644 index 0000000..fde2c63 --- /dev/null +++ b/apps/TerminalX/iOS/TerminalChrome.swift @@ -0,0 +1,406 @@ +import SwiftUI + +// 设计稿 `1b 终端-沉浸轨道` 的浮起 chrome:左上标题胶囊 + 右上状态胶囊 + 底部提示条。 +// +// 浮起(absolute + blur)而非占一行布局:终端面积最大化是这一屏的唯一目标。 +// 终端内容容器顶部留 86pt 预留带给胶囊,预留带用 padding 实现(容器 clipped 防向上溢出)。 + +/// 浮起胶囊的共用外观:blur 材质 + 半透底 + 常规描边 + 圆角 11。 +private struct FloatingCapsule: View { + @EnvironmentObject var theme: TXThemeManager + @ViewBuilder let content: Content + private var p: TXPalette { theme.palette } + + var body: some View { + content + .padding(.horizontal, TX.Space.s) + .frame(height: 34) + .background { + RoundedRectangle(cornerRadius: TX.Radius.capsule) + .fill(p.surfaceDark.opacity(0.88)) + .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: TX.Radius.capsule)) + } + .overlay( + RoundedRectangle(cornerRadius: TX.Radius.capsule) + .strokeBorder(p.border, lineWidth: 1) + ) + .shadow(color: .black.opacity(0.55), radius: 15, y: 10) + } +} + +/// 左上标题胶囊:红/黄圆点 → 竖分隔 → 状态点 → 主机名 → 「window title · cwd」。 +struct TerminalTitleCapsule: View { + @ObservedObject var session: TerminalSession + let onClose: () -> Void + let onMinimize: () -> Void + @EnvironmentObject var theme: TXThemeManager + private var p: TXPalette { theme.palette } + + var body: some View { + FloatingCapsule { + HStack(spacing: TX.Space.xs) { + windowDot(TXWindowDot.close, "关闭会话", action: onClose) + windowDot(TXWindowDot.minimize, "最小化", action: onMinimize) + Rectangle().fill(p.border).frame(width: 1, height: 14) + .padding(.horizontal, 2) + TXStatusDot(color: RailSidebarStyle.statusColor(session, p), size: 6) + Text(session.displayName) + .font(TX.Font.mono(12.5, .medium)) + .foregroundStyle(p.text) + .lineLimit(1) + if !contextLine.isEmpty { + Text(contextLine) + .font(TX.Font.mono(11.5)) + .foregroundStyle(p.textWeak) + .lineLimit(1) + } + // 终端格子数(设计稿 08 的「· 168×44」)。首次布局后稳定,故不必 @Published。 + Text(gridLabel) + .font(TX.Font.mono(11.5)) + .foregroundStyle(p.textFaint) + } + } + } + + /// 「当前 window title · cwd」(设计稿 3a)。无 tmux 时退回连接状态文案。 + private var contextLine: String { + guard let t = session.tmuxSummary else { + return session.isLive ? "" : session.phaseText + } + var parts: [String] = [] + if !t.currentTitle.isEmpty { parts.append(t.currentTitle) } + if !t.cwd.isEmpty { parts.append(Self.abbreviate(t.cwd)) } + return parts.joined(separator: " · ") + } + + /// `/Users/yz/src/x` → `~/src/x`(终端习惯写法,设计稿即此形态)。 + static func abbreviate(_ path: String) -> String { + let home = NSHomeDirectory() + if !home.isEmpty, path.hasPrefix(home) { return "~" + path.dropFirst(home.count) } + // 远端 home 与本机不同,按常见前缀兜底。 + for prefix in ["/home/", "/Users/"] where path.hasPrefix(prefix) { + let rest = path.dropFirst(prefix.count) + if let slash = rest.firstIndex(of: "/") { return "~" + rest[slash...] } + return "~" + } + return path + } + + private var gridLabel: String { + let g = session.screenGrid.value + return "\(g.cols)×\(g.rows)" + } + + private func windowDot(_ color: Color, _ label: String, action: @escaping () -> Void) -> some View { + Button(action: action) { + Circle() + .fill(color) + .frame(width: 13, height: 13) + .overlay(Circle().strokeBorder(Color.black.opacity(0.25), lineWidth: 0.5)) + .frame(width: 22, height: 30) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel(label) + } +} + +/// 右上状态胶囊:三格分层徽标 `tsnet | mosh | tmux`,格间 1×16pt 竖线。 +/// +/// **只染出问题的那层**:网络层不可达 → 第一格转琥珀;重连中 → 第二格转琥珀;tmux 缺失 → 第三格 +/// 写「无 tmux · 客户端窗口」而非报错。首版第二格不显示 RTT 数字(见 HANDOFF §12.4)。 +struct TerminalStatusCapsule: View { + @ObservedObject var session: TerminalSession + @EnvironmentObject var theme: TXThemeManager + private var p: TXPalette { theme.palette } + + var body: some View { + FloatingCapsule { + HStack(spacing: 0) { + cell(egressText, color: egressColor, dot: true) + separator + cell(transportText, color: transportColor, dot: false) + separator + cell(tmuxText, color: p.text2, dot: false) + } + } + } + + private var separator: some View { + Rectangle().fill(p.border).frame(width: 1, height: 16) + .padding(.horizontal, TX.Space.s) + } + + private func cell(_ text: String, color: Color, dot: Bool) -> some View { + HStack(spacing: 5) { + if dot { TXStatusDot(color: color, size: 6) } + Text(text) + .font(TX.Font.mono(12, .medium)) + .foregroundStyle(color) + .lineLimit(1) + } + } + + // 第一格:网络可达性(tsnet 出口 / 直连) + private var egressText: String { + switch session.link { + case .relay: "tsnet" + case .direct: "direct" + case .connecting: "连接中" + case .reconnecting: "tsnet" + case .offline: "离线" + } + } + + private var egressColor: Color { + switch session.link { + case .direct, .relay: p.online + case .connecting: p.text4 + case .reconnecting: p.reconnecting + case .offline: p.offline + } + } + + // 第二格:传输层(mosh / SSH) + private var transportText: String { + if case .reconnecting(let n) = session.link { + return "\(session.moshEngaged ? "mosh" : "ssh") 重连 \(n)" + } + return session.moshEngaged ? "mosh" : "ssh" + } + + private var transportColor: Color { + if case .reconnecting = session.link { return p.reconnecting } + // 设计:这一格的文字是中性色,只有 RTT 数字才染绿/琥珀(首版无 RTT → 全中性)。 + return p.text2 + } + + // 第三格:会话复用层 + private var tmuxText: String { + if let t = session.tmuxSummary { return t.name.isEmpty ? "tmux" : "tmux \(t.name)" } + // 还在探测 / 等用户选会话时别谎报「无 tmux」——那是探测确定不可用后才成立的结论。 + if session.pendingSessionChoice != nil { return "选择 tmux 会话…" } + if session.tmuxSkipped { return "原生窗口 · 不经 tmux" } + if session.tmuxUnavailable { return "无 tmux · 客户端窗口" } + return session.isLive ? "检测 tmux…" : "—" + } +} + +/// 终端区的**非内容态**覆盖层:连接中 / 失败 / 已关闭。 +/// +/// 真实连接不是瞬间的:解析凭据 → tsnet Up → dial → 认证 → (mosh 引导)→ tmux attach, +/// 期间终端一片空白。这层给出「现在在干什么、失败了能怎么办」,避免用户对着黑屏猜。 +/// 已连接(含重连中,屏幕内容还在)时**不显示**,不遮挡终端。 +struct TerminalStateOverlay: View { + @ObservedObject var session: TerminalSession + let onRetry: () -> Void + let onBack: () -> Void + /// 取消正在进行的连接(用户不想再等)。 + let onCancel: () -> Void + @EnvironmentObject var theme: TXThemeManager + /// 连接已等待秒数 —— 真实连接可能要走 tsnet Up / mosh 引导,等待要可见、可取消。 + @State private var waited = 0 + private var p: TXPalette { theme.palette } + + /// 失败态:phaseText 由 SessionMachine 给出(`失败:…`)。 + private var failure: String? { + session.phaseText.hasPrefix("失败") ? String(session.phaseText.dropFirst(3)) : nil + } + + private var isConnecting: Bool { + !session.isLive && failure == nil && session.phaseText != "已关闭" + } + + var body: some View { + if let failure { + state(icon: "exclamationmark.triangle", tint: p.danger, title: "连接失败", + detail: failure) { + HStack(spacing: TX.Space.s) { + TXButton(title: "返回首页", action: onBack) + TXButton(title: "重试", emphasized: true, action: onRetry) + } + } + } else if session.phaseText == "已关闭" { + state(icon: "power", tint: p.textWeak, title: "会话已关闭", + detail: "服务端连接已断开。") { + HStack(spacing: TX.Space.s) { + TXButton(title: "返回首页", action: onBack) + TXButton(title: "重新连接", emphasized: true, action: onRetry) + } + } + } else if isConnecting { + state(icon: nil, tint: p.text4, title: session.phaseText, + detail: session.banner.isEmpty ? connectHint : session.banner) { + VStack(spacing: TX.Space.xs) { + if waited >= 3 { + Text("已等待 \(waited)s") + .font(TX.Font.mono(11)) + .foregroundStyle(p.textFaint) + } + if waited >= 5 { + // 等久了才给取消:避免正常的 1–2s 连接闪现一个按钮。 + TXButton(title: "取消连接", action: onCancel) + } + } + } + .task(id: session.phaseText) { + waited = 0 + while !Task.isCancelled { + try? await Task.sleep(nanoseconds: 1_000_000_000) + waited += 1 + } + } + } + } + + /// 分阶段提示:让等待有解释(尤其 tsnet 首次 Up 与 mosh 引导都要数秒)。 + private var connectHint: String { + switch session.link { + case .relay: "正在经 Tailscale 出口建立连接…" + case .connecting, .direct: session.moshEngaged ? "正在引导 mosh…" : "正在建立 SSH 连接…" + case .reconnecting(let n): "第 \(n) 次重连中…" + case .offline: "等待连接…" + } + } + + private func state(icon: String?, tint: Color, title: String, detail: String, + @ViewBuilder actions: () -> Actions) -> some View { + VStack(spacing: TX.Space.s) { + if let icon { + Image(systemName: icon) + .font(.system(size: 26, weight: .light)) + .foregroundStyle(tint) + } else { + ProgressView().controlSize(.regular).tint(TXAccent.base) + } + Text(title) + .font(TX.Font.mono(13, .medium)) + .foregroundStyle(p.text) + Text(detail) + .font(TX.Font.mono(11.5)) + .foregroundStyle(p.textWeak) + .multilineTextAlignment(.center) + .lineLimit(3) + .frame(maxWidth: 420) + actions() + .padding(.top, TX.Space.xs) + } + .padding(TX.Space.l) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} + +/// 无 tmux 主机的行内横幅(设计稿 `2c`):说明窗口由 App 维持、mosh 兜底,但**关掉 App 不会在 +/// 服务端继续跑**;右侧给「安装并启用 tmux」次要按钮。**不弹窗、不强推**,可关闭。 +struct NoTmuxBanner: View { + let onInstall: () -> Void + let onDismiss: () -> Void + @EnvironmentObject var theme: TXThemeManager + private var p: TXPalette { theme.palette } + + var body: some View { + HStack(spacing: TX.Space.s) { + Image(systemName: "info.circle") + .font(.system(size: 12)) + .foregroundStyle(p.warning) + Text("未检测到 tmux · 当前是客户端窗口:mosh 能兜断线,但**关掉 App 后服务端不会继续跑**") + .font(TX.Font.mono(11.5)) + .foregroundStyle(p.text3) + .lineLimit(1) + Spacer(minLength: TX.Space.s) + TXButton(title: "安装并启用 tmux", action: onInstall) + Button(action: onDismiss) { + Image(systemName: "xmark") + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(p.textWeak) + .frame(width: 28, height: 28) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + .padding(.horizontal, TX.Space.s) + .frame(height: 40) + .background(p.surfaceDark.opacity(0.92)) + .overlay(alignment: .bottom) { Rectangle().fill(p.divider).frame(height: 1) } + } +} + +/// 「安装并启用 tmux」的引导(点横幅按钮后):给出各发行版命令,用户可一键塞进终端执行。 +struct TmuxInstallSheet: View { + let onRun: (String) -> Void + let onCancel: () -> Void + @EnvironmentObject var theme: TXThemeManager + private var p: TXPalette { theme.palette } + + /// 常见包管理器。跨发行版探测一条命令搞定:有哪个用哪个。 + private let combined = "command -v apt-get >/dev/null && sudo apt-get install -y tmux || " + + "command -v dnf >/dev/null && sudo dnf install -y tmux || " + + "command -v pacman >/dev/null && sudo pacman -S --noconfirm tmux || " + + "command -v apk >/dev/null && sudo apk add tmux || " + + "command -v brew >/dev/null && brew install tmux" + + var body: some View { + TXModalScrim(onDismiss: onCancel) { + VStack(alignment: .leading, spacing: TX.Space.s) { + Text("安装 tmux") + .font(TX.Font.mono(15, .medium)) + .foregroundStyle(p.text) + Text("tmux 让会话留在服务端:关掉 App、换网络、睡一觉回来都能原样接回。\n下面这条会按服务器的包管理器自动选择(需要 sudo 权限)。") + .font(TX.Font.mono(12)) + .foregroundStyle(p.text3) + .lineSpacing(4) + Text(combined) + .font(TX.Font.mono(10.5)) + .foregroundStyle(p.text4) + .padding(TX.Space.s) + .frame(maxWidth: .infinity, alignment: .leading) + .background(p.bgDeep, in: RoundedRectangle(cornerRadius: 8)) + HStack(spacing: TX.Space.s) { + Spacer() + TXButton(title: "取消", action: onCancel) + TXButton(title: "在终端执行", emphasized: true) { onRun(combined) } + } + .padding(.top, TX.Space.xs) + } + .padding(TX.Space.l) + .frame(width: 520) + .background(p.surfaceDark, in: RoundedRectangle(cornerRadius: TX.Radius.dialog)) + .overlay( + RoundedRectangle(cornerRadius: TX.Radius.dialog) + .strokeBorder(p.borderStrong, lineWidth: 1) + ) + .shadow(color: .black.opacity(0.7), radius: 40, y: 30) + } + } +} + +/// 底部提示条(34pt):接硬件键盘时只留这条快捷键清单;触控时由键盘附加行替换(UI-5)。 +struct TerminalHintBar: View { + /// 左侧上下文说明(如「Pin 态 · 终端变窄,仅在钉住/解除时 resize 一次」)。 + let context: String + let hints: [(key: String, label: String)] + @EnvironmentObject var theme: TXThemeManager + private var p: TXPalette { theme.palette } + + var body: some View { + HStack(spacing: TX.Space.s) { + Text(context) + .font(TX.Font.mono(11.5)) + .foregroundStyle(p.textWeak) + .lineLimit(1) + Spacer(minLength: TX.Space.m) + ForEach(hints, id: \.key) { hint in + HStack(spacing: 5) { + Text(hint.key) + .font(TX.Font.mono(11.5, .medium)) + .foregroundStyle(p.text4) + Text(hint.label) + .font(TX.Font.mono(11.5)) + .foregroundStyle(p.textWeak) + } + } + } + .padding(.horizontal, TX.Space.m) + .frame(height: TX.Layout.footHeight) + } +} diff --git a/apps/TerminalX/iOS/TerminalDialogs.swift b/apps/TerminalX/iOS/TerminalDialogs.swift new file mode 100644 index 0000000..b948a81 --- /dev/null +++ b/apps/TerminalX/iOS/TerminalDialogs.swift @@ -0,0 +1,279 @@ +import SwiftUI + +// 设计稿 `3c 关闭确认` 与 `3b 新建分屏菜单`。 +// +// 语义分工(务必别混): +// - **黄点 / ⌘M / 下滑 = 最小化**:不确认,直接回首页,会话继续跑。 +// - **红点 / ⌘W = 关闭**:弹一次确认,按主机能力两套文案;`esc` 取消、`⏎` 选推荐项。 + +/// 浮层容器:遮罩 + 居中卡片(对话框 / 菜单共用)。 +struct TXModalScrim: View { + let onDismiss: () -> Void + @ViewBuilder let content: Content + + var body: some View { + ZStack { + Color(hex: 0x0A0B12).opacity(0.42) + .ignoresSafeArea() + .contentShape(Rectangle()) + .onTapGesture(perform: onDismiss) + content + } + .transition(.opacity) + } +} + +// MARK: - 关闭确认(3c) + +/// 关闭确认。两套文案由会话能力决定: +/// - **tmux 会话**:推荐「仅断开」(`detach-client`,服务端继续跑);左侧次要文字按钮「结束会话」 +/// (`kill-session`,**需再确认一次**)。 +/// - **原生窗口**:琥珀警告「没有 tmux 兜底,前台进程会随之结束」,推荐「取消」,危险按钮「关闭窗口」。 +struct CloseConfirmDialog: View { + @ObservedObject var session: TerminalSession + /// 仅断开(tmux)/ 直接关闭(原生窗口) + let onDetach: () -> Void + /// 结束 tmux 会话(kill-session) + let onKill: () -> Void + let onCancel: () -> Void + @EnvironmentObject var theme: TXThemeManager + @State private var confirmingKill = false + private var p: TXPalette { theme.palette } + + private var isTmux: Bool { session.tmuxSummary != nil } + + var body: some View { + TXModalScrim(onDismiss: onCancel) { + VStack(alignment: .leading, spacing: 0) { + Text(title) + .font(TX.Font.mono(15, .medium)) + .foregroundStyle(p.text) + .padding(.bottom, 8) + message + .padding(.bottom, TX.Space.l) + buttons + } + .padding(TX.Space.l) + .frame(width: isTmux ? 436 : 404) + .background(p.surfaceDark, in: RoundedRectangle(cornerRadius: TX.Radius.dialog)) + .overlay( + RoundedRectangle(cornerRadius: TX.Radius.dialog) + .strokeBorder(p.borderStrong, lineWidth: 1) + ) + .shadow(color: .black.opacity(0.7), radius: 40, y: 30) + } + } + + private var title: String { + if let t = session.tmuxSummary, !t.name.isEmpty { + return "关闭 \(session.displayName) · \(t.name)?" + } + // 原生窗口带窗口编号(首版每主机单窗口,恒为 1;多窗口是二期能力)。 + return "关闭 \(session.displayName) · 窗口 1?" + } + + @ViewBuilder private var message: some View { + if let t = session.tmuxSummary { + Text("tmux 会话 (\(t.windows) windows / \(t.panes) panes)。仅断开后服务端继续跑,下次原样接回。") + .font(TX.Font.mono(12)) + .foregroundStyle(p.text3) + .lineSpacing(4) + } else { + Text("没有 tmux 兜底,") + .font(TX.Font.mono(12)) + .foregroundStyle(p.text3) + + Text("前台进程会随之结束") + .font(TX.Font.mono(12, .medium)) + .foregroundStyle(p.warning) + + Text("。想留着就点黄点最小化。") + .font(TX.Font.mono(12)) + .foregroundStyle(p.text3) + } + } + + @ViewBuilder private var buttons: some View { + HStack(spacing: TX.Space.s) { + if isTmux { + // 次要文字按钮(危险):kill-session 需再点一次确认。 + Button { + if confirmingKill { onKill() } else { confirmingKill = true } + } label: { + Text(confirmingKill ? "确认结束会话?" : "结束会话") + .font(TX.Font.mono(12, confirmingKill ? .semibold : .regular)) + .foregroundStyle(p.danger) + } + .buttonStyle(.plain) + } + Spacer() + TXButton(title: "取消", action: onCancel) + if isTmux { + TXButton(title: "仅断开 ⏎", emphasized: true, action: onDetach) + } else { + dangerButton + } + } + } + + /// 危险按钮(设计给的精确色:描边 #8d5560 / 底 rgba(92,58,63,.35) / 字 #f5a0ac)。 + private var dangerButton: some View { + Button(action: onDetach) { + Text("关闭窗口") + .font(TX.Font.mono(12, .medium)) + .foregroundStyle(Color(hex: 0xF5A0AC)) + .padding(.horizontal, 14) + .frame(height: 32) + .background(Color(hex: 0x5C3A3F).opacity(0.35), + in: RoundedRectangle(cornerRadius: TX.Radius.control)) + .overlay( + RoundedRectangle(cornerRadius: TX.Radius.control) + .strokeBorder(Color(hex: 0x8D5560), lineWidth: 1) + ) + } + .buttonStyle(.plain) + } +} + +// MARK: - 新建分屏菜单(3b) + +/// 分屏菜单。**不问方向**(竖分 ⌘D / 横分 ⌘⇧D 本就是两个入口),只问「分到哪里」: +/// 当前会话 → 直接 `split-window`;其余 detached 会话 → `join-pane -s`;也可新建一个 tmux 会话。 +/// +/// 模型约束:分屏只发生在**同一台主机内**(跨主机分屏设计已取消)。 +struct SplitMenuView: View { + @ObservedObject var session: TerminalSession + /// true = 竖分(左右并排,⌘D);false = 横分(上下堆叠,⌘⇧D) + let vertical: Bool + let onPick: (SplitTarget) -> Void + let onCancel: () -> Void + @EnvironmentObject var theme: TXThemeManager + private var p: TXPalette { theme.palette } + + enum SplitTarget { + case currentSession + case otherSession(String) + case newSession + /// 原生窗口(不经 tmux)。首版不可用:需要一台主机多条 SSH channel(见 HANDOFF §12.4)。 + case nativeWindow + } + + /// 其余会话 = 除了本客户端所在的那个。(不能按 `isAttached` 过滤——服务器上别的客户端 + /// attach 着其它会话时会把它们错误地排除掉。) + private var detached: [TmuxSessionInfo] { + let current = session.tmuxSummary?.name ?? "" + return session.tmuxSessionList.filter { $0.name != current } + } + + var body: some View { + TXModalScrim(onDismiss: onCancel) { + VStack(alignment: .leading, spacing: 0) { + header + Rectangle().fill(p.divider).frame(height: 1) + if session.tmuxSummary != nil { + TXSectionLabel(text: "tmux 会话") + .padding(.horizontal, TX.Space.m) + .padding(.top, TX.Space.s) + .padding(.bottom, 2) + row(title: currentName, detail: currentDetail, dot: p.online, + tag: "当前", selected: true) { onPick(.currentSession) } + ForEach(detached) { s in + // detached 会话的进程仍在服务端跑 → 状态点同样是绿的(设计稿如此)。 + row(title: s.name, + detail: "\(s.windows) window\(s.windows == 1 ? "" : "s") · detached", + dot: p.online, + tag: s.lastActivity.map { SessionCard.relative($0) }) { + onPick(.otherSession(s.name)) + } + } + row(title: "新建 tmux 会话…", detail: nil, dot: nil, tag: nil) { + onPick(.newSession) + } + Rectangle().fill(p.divider).frame(height: 1) + .padding(.vertical, TX.Space.xs) + } + // 原生窗口:设计里是并列选项;首版能力未就绪 → 灰显并注明,不装作能用。 + row(title: "原生窗口", detail: "不经 tmux · 关 App 即结束(二期)", + dot: nil, tag: nil, disabled: true) {} + } + .padding(.bottom, TX.Space.xs) + .frame(width: 360) + .background(p.surfaceDark, in: RoundedRectangle(cornerRadius: TX.Radius.dialog - 1)) + .overlay( + RoundedRectangle(cornerRadius: TX.Radius.dialog - 1) + .strokeBorder(p.borderStrong, lineWidth: 1) + ) + .shadow(color: .black.opacity(0.7), radius: 40, y: 30) + } + } + + private var header: some View { + HStack { + Text(vertical ? "竖分屏到…" : "横分屏到…") + .font(TX.Font.mono(13, .medium)) + .foregroundStyle(p.text) + Spacer() + Text(session.displayName) + .font(TX.Font.mono(11)) + .foregroundStyle(TXAccent.mid) + } + .padding(.horizontal, TX.Space.m) + .frame(height: 44) + } + + private var currentName: String { + guard let t = session.tmuxSummary else { return session.displayName } + return t.name.isEmpty ? "当前会话" : t.name + } + + private var currentDetail: String { + guard let t = session.tmuxSummary else { return "" } + var parts: [String] = [] + if !t.currentTitle.isEmpty { parts.append(t.currentTitle) } + parts.append("\(t.windows) window\(t.windows == 1 ? "" : "s")") + if t.panes > 0 { parts.append("\(t.panes) panes") } + return parts.joined(separator: " · ") + } + + /// 菜单行:**前导列统一 20pt**(状态点也包在 20pt 居中列里),保证所有行文字左缘一致。 + private func row(title: String, detail: String?, dot: Color?, tag: String?, + selected: Bool = false, disabled: Bool = false, + action: @escaping () -> Void) -> some View { + Button(action: action) { + HStack(spacing: TX.Space.s) { + ZStack { + if let dot { TXStatusDot(color: dot, size: 7) } + } + .frame(width: 20) + VStack(alignment: .leading, spacing: 1) { + Text(title) + .font(TX.Font.mono(13, .medium)) + .foregroundStyle(disabled ? p.textWeak : p.text) + if let detail, !detail.isEmpty { + Text(detail) + .font(TX.Font.mono(10.5)) + .foregroundStyle(p.textWeak) + .lineLimit(1) + } + } + Spacer(minLength: TX.Space.xs) + if let tag { + Text(tag) + .font(TX.Font.mono(10.5, .medium)) + .foregroundStyle(selected ? TXAccent.text : TXAccent.mid) + } + } + .padding(.horizontal, TX.Space.s) + .frame(height: 40) + .frame(maxWidth: .infinity, alignment: .leading) + .background(selected ? TXAccent.selectedBg : .clear, + in: RoundedRectangle(cornerRadius: TX.Radius.control)) + .overlay( + RoundedRectangle(cornerRadius: TX.Radius.control) + .strokeBorder(selected ? TXAccent.selectedStroke : .clear, lineWidth: 1) + ) + .padding(.horizontal, TX.Space.xs) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .disabled(disabled) + } +} diff --git a/apps/TerminalX/iOS/TerminalSession.swift b/apps/TerminalX/iOS/TerminalSession.swift new file mode 100644 index 0000000..4018d99 --- /dev/null +++ b/apps/TerminalX/iOS/TerminalSession.swift @@ -0,0 +1,1097 @@ +import Combine +import Foundation +import GhosttyTerminal +import TXCore +import TXTransport +import TsnetBridge +#if canImport(UIKit) +import UIKit +#endif + +/// 出口计划(connect 时按 host 解析,贯穿 startSession/launchMosh/唤醒脉冲)。 +enum EgressPlan { + case direct + /// 经指定 Tailscale 连接的 tsnet 节点。authKey 仅首次注册需要(已存 state 用 nil)。 + case tsnet(connID: UUID, stateDirName: String, hostname: String, authKey: String?) + + /// host key pin 的命名空间(按出口隔离,架构决策 §2.6)。 + var egressKey: String { + switch self { + case .direct: "direct" + case .tsnet(let id, _, _, _): "tsnet-\(id.uuidString)" + } + } +} + +/// 经 tsnet 节点 dial,带退避重试(tsnet 刚 Up 后到 peer 的路径预热期间首次 dial 可能超时)。 +private func dialFDWithRetry(_ node: TsnetbridgeNode, host: String, port: Int) throws -> Int32 { + var lastError: Error? + for attempt in 0 ..< 6 { + do { + var fd: Int64 = -1 + try node.dialTCPFD(host, port: port, timeoutMs: 8000, ret0_: &fd) + return Int32(fd) + } catch { + lastError = error + if attempt < 5 { Thread.sleep(forTimeInterval: 2.0) } + } + } + throw lastError ?? TsnetError.notUp +} + +/// 线程安全地持有 transport 引用,供 libghostty 的输入/resize 回调(非主线程)安全取用。 +final class TransportHolder: @unchecked Sendable { + private let lock = NSLock() + private var value: Transport? + var transport: Transport? { + get { lock.lock(); defer { lock.unlock() }; return value } + set { lock.lock(); value = newValue; lock.unlock() } + } +} + +/// 线程安全地持有 mosh 会话引用;非 nil 时终端输入/resize 路由到 mosh 而非 SSH transport。 +final class MoshHolder: @unchecked Sendable { + private let lock = NSLock() + private var value: MoshSession? + var session: MoshSession? { + get { lock.lock(); defer { lock.unlock() }; return value } + set { lock.lock(); value = newValue; lock.unlock() } + } +} + +/// raw 输出的接收端(`OutputGate` 或它的旁路 `OutputTap`)。 +/// 抽这个协议只为让 `TmuxRouter` 能同时接受两者,别在这上面加职责。 +protocol RawOutputSink: Sendable { + func deliver(_ data: Data) +} + +/// 输出闸门:surface 未 attach 时 receive 会丢弃写入,故未就绪先缓冲、就绪后按序 flush(修白屏)。 +final class OutputGate: RawOutputSink, @unchecked Sendable { + private let lock = NSLock() + private let session: InMemoryTerminalSession + private var ready = false + private var pending = Data() + + init(session: InMemoryTerminalSession) { self.session = session } + + func deliver(_ data: Data) { + lock.lock() + if ready { lock.unlock(); session.receive(data) } + else { pending.append(data); lock.unlock() } + } + + func markReady() { + lock.lock() + if ready { lock.unlock(); return } + ready = true + let buffered = pending; pending = Data() + lock.unlock() + if !buffered.isEmpty { session.receive(buffered) } + } +} + +/// 输出旁路:把 raw 字节**同时**送给下游(终端渲染)和一个观察闭包(会话探测扫描)。 +/// +/// 为什么需要:连接后的 tmux 会话探测要读 shell 的输出,而那条路原本直通 `OutputGate`。 +/// 旁路而非改 gate:gate 的缓冲/flush 语义(修白屏的关键)不能动。 +final class OutputTap: RawOutputSink, @unchecked Sendable { + private let downstream: OutputGate + private let observe: @Sendable (Data) -> Void + + init(downstream: OutputGate, observe: @escaping @Sendable (Data) -> Void) { + self.downstream = downstream + self.observe = observe + } + + func deliver(_ data: Data) { + downstream.deliver(data) + observe(data) + } + + func markReady() { downstream.markReady() } +} + +/// tmux 网关字节的有序事件(enter 携初始字节 / bytes 后续)。经单一 AsyncStream 传到主线程, +/// 杜绝"每 chunk 起独立 Task"导致的乱序(多 pane 高吞吐会打碎 % 协议解析)。 +enum TmuxByteEvent: Sendable { case enter([UInt8]); case bytes([UInt8]) } + +/// 线程安全承载 tmux 字节通道的 continuation(传输后台线程 yield,主线程单消费者取用)。 +final class TmuxByteChannel: @unchecked Sendable { + let stream: AsyncStream + private let cont: AsyncStream.Continuation + init() { (stream, cont) = AsyncStream.makeStream() } + func enter(_ b: [UInt8]) { cont.yield(.enter(b)) } + func bytes(_ b: [UInt8]) { cont.yield(.bytes(b)) } +} + +/// 线程安全记录全屏终端格子数 + cell 像素尺寸(tmux attach/resize 时据此换算 `refresh-client -C`)。 +final class GridBox: @unchecked Sendable { + private let lock = NSLock() + private var cols = 80, rows = 24, cellW = 0, cellH = 0 + var value: (cols: Int, rows: Int, cellW: Int, cellH: Int) { + lock.lock(); defer { lock.unlock() }; return (cols, rows, cellW, cellH) + } + func set(cols: Int, rows: Int, cellW: Int, cellH: Int) { + lock.lock() + self.cols = max(1, cols); self.rows = max(1, rows) + if cellW > 0 { self.cellW = cellW } // cell 尺寸稳定,保留最后有效值 + if cellH > 0 { self.cellH = cellH } + lock.unlock() + } +} + +/// M4 host key 不符详情(供 SwiftUI alert 并排显示旧/新指纹 + 用户抉择)。 +struct HostKeyMismatchInfo: Identifiable { + let id = UUID() + let egress: String, host: String, port: Int + let keyType: Int32 + let presentedBlob: Data + let presentedFp: String + let storedFp: String +} + +/// 链路状态(设计稿右上状态胶囊「只染出问题的那层」用)。 +/// +/// 首版**不含 RTT 数字**:mosh 侧只有 `lastHeardMs()`(时刻,非 SRTT),要真实 RTT 需改 +/// vendor/mosh 的 iosclient 导出 SRTT 再桥到 Swift —— 属传输层改动,二期做。 +enum LinkState: Equatable { + case connecting + /// 直连(无 tsnet 出口) + case direct + /// 经 tsnet(首版不细分 DERP relay / 直连 WG) + case relay + case reconnecting(attempt: Int) + case offline +} + +/// 连接后待用户选择的 tmux 会话(选择器 sheet 的数据源)。 +struct TmuxSessionChoice: Equatable { + let version: String + let sessions: [TmuxSessionProbe.Session] + /// 「新建会话」的默认名(取主机配置里的 tmux 会话名,默认 main)。 + let defaultNewName: String +} + +/// tmux 概览(侧边栏分组标题、会话卡副行)。真实会话从 `TmuxController` 派生,假会话直接注入。 +struct TmuxSummary: Equatable { + /// tmux 会话名。首版拿不到(需 `list-sessions`,二期做)→ 空串时 UI 只显示「tmux」。 + var name: String = "" + var windows: Int = 0 + var panes: Int = 0 + /// 当前 window title。 + var currentTitle: String = "" + /// 当前 window 的工作目录(`#{pane_current_path}`)。标题胶囊显示「title · cwd」。 + var cwd: String = "" +} + +/// 一条会话 = 一个 TerminalSession 实例:SSHSession(libssh2) ⇄ GhosttyKit in-memory 终端, +/// 由 TXCore.SessionMachine 编排生命周期(断线指数退避自动重连、后台冻结/前台恢复)。 +/// +/// **多会话并存**由 `SessionManager` 持有多个实例实现;本类内无全局单例依赖 +/// (tsnet 经 `TsnetRegistry.shared` 按 connID 操作,天然多节点)。 +@MainActor +final class TerminalSession: ObservableObject, Identifiable { + /// 会话身份(路由 `.terminal(id)`、⌘1…9 索引、首页卡与轨道头像都用它)。 + let id = UUID() + + let state: TerminalViewState + let session: InMemoryTerminalSession + private let holder: TransportHolder + private let gate: OutputGate + + /// 归属主机(从主机列表连接时非 nil;无头直连为 nil,标题回退 `connectionTitle`)。 + @Published var host: SavedHost? + /// 会话已建立(重连/后台期间保持 true)。**不再驱动导航** —— 路由归 AppRouter。 + @Published var isLive = false + /// 最小化中:会话继续跑,只是不在前台。首页卡片据此置顶并标「最小化中」。 + @Published var isMinimized = false + /// 最后一次成为焦点的时刻(首页「上次活跃」+ 会话卡排序)。 + @Published var lastActiveAt = Date() + /// 链路状态(状态胶囊/状态点)。由 `syncUI()` 从 phase + 出口计划推导。 + @Published var link: LinkState = .offline + + /// 无头 UI 验证的假会话:不建连接、**不参与生命周期广播**(否则 scenePhase 广播会 + /// 经 syncUI 把注入的状态覆盖回 idle)。Release 构建恒为 false。 + private(set) var isSyntheticFixture = false + + /// 首页会话缩略卡的文本镜像(viewport 尾部若干行,剔掉尾部空行)。 + /// 不渲染第二个 surface(一 surface 一 IOSurfaceLayer,离屏渲染是已修过的泄漏雷区)。 + @Published private(set) var snapshotLines: [String] = [] + + /// 假会话注入的 tmux 概览(真实会话走 `tmuxSummary` 的派生分支)。 + private var injectedTmuxSummary: TmuxSummary? + /// 假会话注入的 tmux 会话列表(分屏菜单的 detached 行;真实会话走 controller.sessions)。 + private(set) var injectedTmuxSessions: [TmuxSessionInfo] = [] + + /// 分屏菜单的候选会话:真实会话用 controller 的 list-sessions 结果,假会话用注入值。 + var tmuxSessionList: [TmuxSessionInfo] { + if let c = tmuxController, !c.sessions.isEmpty { return c.sessions } + return injectedTmuxSessions + } + + /// tmux 概览:假会话用注入值,真实会话从 controller 现算。 + var tmuxSummary: TmuxSummary? { + if let injectedTmuxSummary { return injectedTmuxSummary } + guard let t = tmuxController else { return nil } + let active = t.windows.first { $0.id == t.activeWindowID } ?? t.windows.first + return TmuxSummary( + name: t.currentSessionName, + windows: t.windows.count, + panes: t.windows.reduce(0) { $0 + $1.panes.count }, + currentTitle: active?.title ?? "", + cwd: active?.cwd ?? "") + } + + /// 抓一次终端内容快照。tmux 模式取活动 pane 的 surface,否则取自己的。 + /// surface 未 attach 时 `readViewportText()` 返回 nil → 保留上一次快照(不清空,避免卡片闪空)。 + func captureSnapshot(maxLines: Int = 32) { + guard let text = snapshotSource?.readViewportText() else { return } + var lines = text.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) + while let last = lines.last, last.trimmingCharacters(in: .whitespaces).isEmpty { + lines.removeLast() + } + let tail = Array(lines.suffix(maxLines)) + if tail != snapshotLines { snapshotLines = tail } + } + + private var snapshotSource: InMemoryTerminalSession? { + if let t = tmuxController { + let win = t.windows.first { $0.id == t.activeWindowID } ?? t.windows.first + if let win, let paneID = win.activePane ?? win.panes.keys.first, + let surface = win.panes[paneID] { + return surface.session + } + } + return session + } + + @Published var banner: String = "" + @Published var phaseText: String = "空闲" + /// 非 nil 时进入 tmux control-mode(原生 tab)。 + @Published var tmuxController: TmuxController? + /// 把 controller 的变化**转发**成本对象的变化。 + /// + /// 必须有:`tmuxSummary` 等是读 controller 的 computed property,而轨道/胶囊/侧边栏只 + /// `@ObservedObject` 订阅了 session —— 嵌套 ObservableObject 不冒泡(见 CLAUDE.md 坑), + /// 没有这条转发,window 列表/会话名/cwd 解析对了 UI 也不刷新(真机上表现为轨道空、第三格只有「tmux」)。 + private var tmuxObserver: AnyCancellable? + /// M4:host key 与 pin 不符 → 非 nil 触发 SwiftUI alert(信任新密钥 / 取消)。 + @Published var hostKeyMismatch: HostKeyMismatchInfo? + /// 终端顶部状态行:当前连接标题(主机名/地址)。 + @Published var connectionTitle: String = "" + /// mosh 是否已接管(传输徽章 SSH/mosh)。 + @Published var moshEngaged: Bool = false + /// 连接后探测到的 tmux 会话(非 nil = 等用户选「attach 哪个 / 新建 / 跳过」)。 + /// 设计:点主机连接后先让用户挑,而不是替他决定 attach 哪个会话。 + @Published var pendingSessionChoice: TmuxSessionChoice? + + /// 用户在选择器里选了「原生终端」→ 本次会话不经 tmux,也不显示安装引导横幅(他是主动选的)。 + @Published private(set) var tmuxSkipped = false + + /// 正在探测服务器的 tmux 能力/会话。 + /// + /// **必须在 `.connected` 的那一刻就置位**(不能等 2.5s 后的 bootstrap):否则连接建立与探测开始 + /// 之间有个空窗,`SessionManager.evaluateEnter` 会看到 `isLive == true` 就把用户送进终端页, + /// 于是选择器弹在终端页上而不是首页 —— 那正是这次要改掉的错误时序。 + @Published private(set) var isProbingTmux = false + + /// 已发出 `tmux -CC` 但迟迟没进 control mode → 服务器很可能没装 tmux。 + /// 终端顶部据此显示可关闭的安装引导横幅(设计稿 2c),**不弹窗、不强推**。 + @Published var tmuxUnavailable = false + + let knownHosts = KeychainKnownHostsStore() + + private var machine = SessionMachine() + private var config: SSHConfig? + private var reconnectTask: Task? + private var autoCommand: String? + + /// 出口计划(direct / 经某 Tailscale 连接)。connect 时按 host 解析。 + private var egressPlan: EgressPlan = .direct + /// 当前会话的 Tailscale 连接 id(tsnet 出口时非 nil):mosh relay / 唤醒脉冲 / 会话计数按此 connID 操作。 + private var activeConnID: UUID? + + /// 解析 host 认证/出口所需的两个 Store(由 ContentView 注入)。 + private weak var credentialStore: (any CredentialStore)? + private weak var tailscaleStore: TailscaleStore? + + /// tmux 模式:登录后自动 `tmux -CC new -A -s ` 进 control mode(默认开)。 + private var tmuxMode = true + private var tmuxSessionName = "main" + private var tmuxBootstrapSent = false + private var tmuxProbeTask: Task? + /// 连接后的会话探测(普通 shell 阶段,进 control mode 之前)。 + private var sessionProbe: TmuxSessionProbe? + private var sessionProbeSent = false + + /// M2 mosh 模式:SSH 登录后跑 mosh-server,解析 MOSH CONNECT,改由 mosh(UDP over tsnet relay) 承载会话。 + private var moshMode = false + private var moshServerCmd = "mosh-server new -s -c 256 -l LANG=en_US.UTF-8 -l LC_ALL=en_US.UTF-8" + private let moshHolder: MoshHolder + private var moshSession: MoshSession? + private var moshRelay: TsnetbridgeMoshRelay? + private var moshScanner = MoshConnectScanner() + private var moshBootstrapSent = false + // M3 前台恢复:基线时刻 + healthy 轮询 + 恢复看门狗 + 后台任务。 + private var moshResumeBaseMs: UInt64 = 0 + private var moshHealthyPoll: Task? + private var resumeWatchdog: Task? + #if canImport(UIKit) + private var bgTask: UIBackgroundTaskIdentifier = .invalid + #endif + + private let tmuxChannel = TmuxByteChannel() + /// 全屏终端格子数(raw 模式 surface resize 时记录;tmux attach 用于 refresh-client -C)。 + let screenGrid = GridBox() + + init() { + let holder = TransportHolder() + let moshHolder = MoshHolder() + let grid = screenGrid + let session = InMemoryTerminalSession( + // 输入/resize:mosh 激活时路由到 mosh,否则走 SSH transport。 + write: { data in + if let m = moshHolder.session { m.send([UInt8](data)) } + else { holder.transport?.send(data) } + }, + resize: { vp in + // 记录全屏格子 + cell 像素(tmux resize 换算用)。 + grid.set(cols: Int(vp.columns), rows: Int(vp.rows), + cellW: Int(vp.cellWidthPixels), cellH: Int(vp.cellHeightPixels)) + if let m = moshHolder.session { m.resize(cols: Int(vp.columns), rows: Int(vp.rows)) } + else { holder.transport?.resize(cols: vp.columns, rows: vp.rows) } + } + ) + let state = TerminalViewState() + state.configuration = TerminalSurfaceOptions(backend: .inMemory(session)) + _ = state.controller.setTheme(.txDefault) // Catppuccin Mocha 终端配色 + self.holder = holder + self.moshHolder = moshHolder + self.session = session + self.state = state + self.gate = OutputGate(session: session) + + // tmux 网关字节:单消费者按序处理(enter 建 controller+喂初始,bytes 续喂)。 + let ch = tmuxChannel + Task { @MainActor [weak self] in + for await ev in ch.stream { + guard let self else { continue } + switch ev { + case .enter(let initial): self.enterTmux(initialBytes: initial) + case .bytes(let b): self.tmuxController?.feed(Data(b)) + } + } + } + } + + // MARK: - 公开入口 + + /// M4b:非 nil 时用 publickey(SE/软件签名器) 认证而非密码(launch-arg 无头验证路径)。 + private var pubkeySigner: SigningKeyProvider? + + /// 无头验证合成的临时 Tailscale 连接 id(固定,复用 "tsnet-main" 模拟器身份)。 + private static let headlessTsnetConnID = UUID(uuidString: "00000000-0000-0000-0000-0000000000FF")! + + /// 注入解析所需的 Store(ContentView.onAppear 调用)。 + func bind(credentials: any CredentialStore, tailscale: TailscaleStore) { + credentialStore = credentials + tailscaleStore = tailscale + } + + /// 会话显示名(首页卡、标题胶囊、关闭确认文案)。 + var displayName: String { + if let h = host, !h.name.isEmpty { return h.name } + return connectionTitle.isEmpty ? "终端" : connectionTitle + } + + /// 两字缩写(轨道 / 侧边栏 / 会话卡的主机头像)。 + /// 规则同设计稿:首段首字母 + 末段(末段以数字结尾取该数字,否则取首字母)—— + /// `mac-studio → ms`、`ubuntu-cn2 → u2`、`nas-home → nh`。 + var initials: String { + let name = displayName + // 纯 IP(只有数字和点):取首段前两位,"10.255.255.1" → "10"(按字母规则会得到无意义的 "11")。 + if name.allSatisfy({ $0.isNumber || $0 == "." }), let head = name.split(separator: ".").first { + return String(head.prefix(2)) + } + let parts = name.split(whereSeparator: { !$0.isLetter && !$0.isNumber }) + guard let first = parts.first, let last = parts.last, parts.count >= 2 else { + return String(name.prefix(2)).lowercased() + } + let tail = last.last.map { $0.isNumber ? String($0) : String(last.prefix(1)) } ?? "" + return (String(first.prefix(1)) + tail).lowercased() + } + + /// 是否 tmux 会话(决定关闭确认走「仅断开 / 结束会话」还是「进程随之结束」文案)。 + var isTmuxSession: Bool { tmuxController != nil } + + /// 从主机列表连接:解析凭据 + 出口,再驱动标准连接流程。 + func connect(to h: SavedHost) { + moshMode = h.useMosh + tmuxMode = h.useTmux + if let name = h.tmuxSession, !name.isEmpty { tmuxSessionName = name } + host = h + connectionTitle = h.name + + // 1) 解析认证 + let username: String + let auth: SSHConfig.Authentication + switch h.auth { + case .inlinePassword(let user, let pw): + username = user; auth = .password(pw) + case .credential(let id): + guard let store = credentialStore, + let cred = store.list().first(where: { $0.id == id }), + let secret = store.secret(for: id), + let resolved = CredentialAuth.authentication(for: cred, secret: secret) else { + phaseText = "失败:凭据不可用"; banner = "凭据已删除或需签名构建(Face ID 密钥)" + return + } + username = cred.username; auth = resolved + } + + // 2) 解析出口 + if let tid = h.tailscaleID { + guard let conn = tailscaleStore?.connection(id: tid) else { + phaseText = "失败:Tailscale 连接不存在"; return + } + if conn.needsAuthKey { + phaseText = "失败:Tailscale 未认证"; banner = "请先在 Tailscale 页完成认证"; return + } + egressPlan = .tsnet(connID: conn.id, stateDirName: conn.stateDirName, + hostname: conn.hostname, authKey: nil) + } else { + egressPlan = .direct + } + + startConnect(host: h.host, port: h.port, username: username, authentication: auth) + } + + /// 密码/签名器直连(无头验证 / 简单入口)。 + func connect(host: String, port: Int, username: String, password: String) { + let auth: SSHConfig.Authentication = pubkeySigner.map { .publicKeyCallback($0) } ?? .password(password) + startConnect(host: host, port: port, username: username, authentication: auth) + } + + /// 统一起连:构造 config(含按出口命名空间的 host key 校验)并驱动状态机。 + private func startConnect(host: String, port: Int, username: String, + authentication: SSHConfig.Authentication) { + if connectionTitle.isEmpty { connectionTitle = host } + moshEngaged = false + config = SSHConfig(host: host, port: port, username: username, + authentication: authentication, + hostKeyVerifier: makeHostKeyVerifier(egress: egressPlan.egressKey, host: host, port: port)) + run(machine.reduce(.connectRequested)) + } + + /// M4:构造 host key 校验器(TOFU)。在 ssh 串行队列同步调用;firstUse 自动 pin + 横幅, + /// mismatch 冒泡到主线程触发 alert 并返回 false(SSHSession 会断开重试)。 + private func makeHostKeyVerifier(egress: String, host: String, port: Int) -> @Sendable (Data, Int32) -> Bool { + let store = knownHosts + let triple = HostTriple(egress: egress, host: host, port: port) + return { [weak self] blob, keyType in + switch HostKey.evaluate(stored: store.lookup(triple)?.blob, presented: blob) { + case .firstUse: + store.pin(triple, record: HostKeyRecord(blob: blob, keyType: keyType)) + let fp = HostKey.opensshFingerprint(blob) + Task { @MainActor in self?.banner = "已记住 host key \(fp)" } + return true + case .trusted: + return true + case .mismatch(let stored): + let info = HostKeyMismatchInfo( + egress: egress, host: host, port: port, keyType: keyType, + presentedBlob: blob, presentedFp: HostKey.opensshFingerprint(blob), + storedFp: HostKey.opensshFingerprint(stored)) + Task { @MainActor in self?.hostKeyMismatch = info } + return false + } + } + } + + /// 用户确认信任新 host key → 覆盖 pin 并重连。 + func trustNewHostKey() { + guard let m = hostKeyMismatch else { return } + knownHosts.pin(HostTriple(egress: m.egress, host: m.host, port: m.port), + record: HostKeyRecord(blob: m.presentedBlob, keyType: m.keyType)) + hostKeyMismatch = nil + run(machine.reduce(.retryRequested)) // failed → connecting(verifier 此时将判定 trusted) + } + + /// 用户取消 → 保持断开。 + func dismissHostKeyMismatch() { hostKeyMismatch = nil } + + /// 失败 / 已关闭后重试:有 config 就走状态机重连;否则(凭据解析失败等)重跑一次 host 解析。 + func retry() { + if config != nil { + run(machine.reduce(.retryRequested)) + } else if let h = host { + connect(to: h) + } + } + + /// 切换主题时推送新终端配色到当前 raw surface 与所有 tmux pane(新建面板另经 TerminalTheme.txDefault 读取)。 + func applyTerminalTheme(_ t: TerminalTheme) { + _ = state.controller.setTheme(t) + tmuxController?.applyTerminalTheme(t) + } + + func close() { + if let id = activeConnID { TsnetRegistry.shared.markSessionActive(id, false) } + activeConnID = nil + run(machine.reduce(.closeRequested)) + } + func enterBackground() { + if moshMode { beginBgTask() } // 争取 ~30s 后台执行,mosh 每 3s ack 保活 NAT/WG/DERP + run(machine.reduce(.enteredBackground)) + } + func enterForeground() { + endBgTask() + run(machine.reduce(.enteredForeground)) + } + + /// TerminalScreen 在 surface 首次布局后调用:放行缓冲输出(+ 无头自动命令)。 + func markSurfaceReady() { + gate.markReady() + if let cmd = autoCommand { + autoCommand = nil + let holder = self.holder + Task { + try? await Task.sleep(nanoseconds: 2_500_000_000) + holder.transport?.send(Data("\n".utf8)) + try? await Task.sleep(nanoseconds: 900_000_000) + holder.transport?.send(Data((cmd + "\n").utf8)) + } + } + } + + #if DEBUG + /// 无头 UI 验证:把示意输出灌进 in-memory 终端。**不建传输连接、不驱动状态机**, + /// 只为让首页会话卡/轨道头像/状态徽标有内容可截图对拍(见 UIPreviewFixture)。 + /// 走 `gate` 而非 `session.receive`,故 surface 未 attach 时会先缓冲、就绪后 flush。 + func injectFixture(host: SavedHost, lines: [String], link: LinkState, mosh: Bool, + tmux: TmuxSummary? = nil, tmuxSessions: [TmuxSessionInfo] = []) { + isSyntheticFixture = true + injectedTmuxSummary = tmux + injectedTmuxSessions = tmuxSessions + self.host = host + connectionTitle = host.name + isLive = true + self.link = link + moshEngaged = mosh + phaseText = mosh ? "mosh 已连接" : "已连接" + gate.deliver(Data(lines.joined(separator: "\r\n").utf8)) + } + #endif + + /// 无头验证:从启动参数自动连接(simctl launch --args -txHost … -txAutoCommand …)。 + func autoConnectIfConfigured() { + let d = UserDefaults.standard + guard let host = d.string(forKey: "txHost"), !host.isEmpty, + let user = d.string(forKey: "txUser") else { return } + let port = d.integer(forKey: "txPort") + autoCommand = d.string(forKey: "txAutoCommand") + if let key = d.string(forKey: "txTsnetKey"), !key.isEmpty { + // 无头验证:合成临时 Tailscale 出口(stateDir 沿用 "tsnet-main" 复用既有模拟器身份)。 + egressPlan = .tsnet(connID: Self.headlessTsnetConnID, + stateDirName: "tsnet-main", hostname: "terminalx-ipad", authKey: key) + } + // tmux 自动进入:默认开;`-txTmux 0` 关(回归 raw 终端路径),`-txTmuxSession` 指定会话名。 + if d.object(forKey: "txTmux") != nil { tmuxMode = d.bool(forKey: "txTmux") } + if let n = d.string(forKey: "txTmuxSession"), !n.isEmpty { tmuxSessionName = n } + if d.bool(forKey: "txMosh") { + moshMode = true + if let cmd = d.string(forKey: "txMoshServerCmd"), !cmd.isEmpty { moshServerCmd = cmd } + autoCommand = nil // mosh 模式用自己的引导流程,不用 txAutoCommand + } + let realPort = port == 0 ? 22 : port + // M4b:publickey 认证(SE/软件签名器)。日志打出 authorized_keys 行供加入服务器。 + if d.bool(forKey: "txPubkeyAuth") { + pubkeySigner = SigningKeyProvider.loadOrCreate() + if let s = pubkeySigner { + NSLog("M4DBG authorized_keys hw=\(s.isHardwareBacked): \(s.authorizedKeysLine)") + } + } + // 无头验证:播种一个假 pin → 真实 host key 将 mismatch,触发 alert 路径。 + if let b64 = d.string(forKey: "txHostKeyPinOverrideBase64"), let fake = Data(base64Encoded: b64) { + knownHosts.pin(HostTriple(egress: egressPlan.egressKey, host: host, port: realPort), + record: HostKeyRecord(blob: fake, keyType: 3)) + } + connect(host: host, port: realPort, + username: user, password: d.string(forKey: "txPass") ?? "") + } + + // MARK: - 状态机副作用执行 + + private func run(_ effects: [SessionMachine.Effect]) { + for effect in effects { + switch effect { + case .startConnect: startSession() + case .scheduleReconnect(_, let delayMS): scheduleReconnect(delayMS) + case .cancelReconnectTimer: reconnectTask?.cancel(); reconnectTask = nil + case .teardownTransport: holder.transport?.stop() + case .notify(let message): banner = message + case .nudgeResume: performWakePulse(); startHealthyPollIfNeeded() + case .scheduleResumeWatchdog(let ms): scheduleResumeWatchdog(ms) + case .cancelResumeWatchdog: cancelResumeWatchdog() + case .teardownMosh: teardownMosh() + } + } + syncUI() + } + + private func startSession() { + guard let config else { return } + switch egressPlan { + case .direct: + wireAndStart(SSHSession(config: config)) + case .tsnet(let connID, let dir, let hostname, let key): + // tsnet 模式:后台经注册表 up 该连接的节点 + dial fd,再用外部 fd 建 SSHSession。 + Task.detached { + do { + let node = try TsnetRegistry.shared.node( + connID: connID, stateDirName: dir, hostname: hostname, + authKey: key, timeoutMs: 45000) + let fd = try dialFDWithRetry(node, host: config.host, port: config.port) + await MainActor.run { + self.activeConnID = connID + TsnetRegistry.shared.markSessionActive(connID, true) + self.wireAndStart(SSHSession(config: config, preconnectedFD: fd)) + } + } catch { + await MainActor.run { + self.handleTransportState(.failed("tsnet: \(error.localizedDescription)")) + } + } + } + } + } + + private func wireAndStart(_ ssh: SSHSession) { + exitTmux() // 新会话回原始模式(tmux 会话在服务端,重连后需重新 -CC attach) + let router = tmuxRouter + if moshMode { + // mosh 模式:SSH 字节喂给引导流程(既显示进度、又扫描 MOSH CONNECT)。 + ssh.onBytes = { [weak self] data in + Task { @MainActor in self?.moshBootstrapFeed(data) } + } + } else { + ssh.onBytes = { data in router.feed(data) } // 经路由器分流 raw / tmux 网关 + } + ssh.onState = { [weak self] newState in + Task { @MainActor in self?.handleTransportState(newState) } + } + holder.transport = ssh + ssh.start() + } + + /// raw 阶段的输出旁路:既喂终端渲染,也喂会话探测扫描器。 + private lazy var probeTapGate = OutputTap(downstream: gate) { [weak self] data in + Task { @MainActor in + guard let self, self.sessionProbe != nil else { return } + self.feedSessionProbe(String(decoding: data, as: UTF8.self)) + } + } + + private lazy var tmuxRouter = TmuxRouter( + rawGate: probeTapGate, + // 经有序通道传主线程(enter 先于后续 bytes,单消费者保序)。 + enterGateway: { [tmuxChannel] after in tmuxChannel.enter(after) }, + gatewayBytes: { [tmuxChannel] bytes in tmuxChannel.bytes(bytes) } + ) + + private func enterTmux(initialBytes: [UInt8]) { + tmuxProbeTask?.cancel(); tmuxProbeTask = nil + tmuxUnavailable = false + let holder = self.holder + let controller = TmuxController(sendRaw: { data in holder.transport?.send(data) }) + controller.onExit = { [weak self] in Task { @MainActor in self?.exitTmux() } } + // 告知全屏格子数 + cell 像素 → attach 时 refresh-client -C、动态 resize 换算。 + let g = screenGrid.value + controller.setClientSize(cols: g.cols, rows: g.rows) + controller.setCellPixels(w: g.cellW, h: g.cellH) + tmuxController = controller + tmuxObserver = controller.objectWillChange.sink { [weak self] _ in + self?.objectWillChange.send() + } + if !initialBytes.isEmpty { controller.feed(Data(initialBytes)) } + } + + private func exitTmux() { + tmuxObserver = nil + if tmuxController != nil { tmuxController = nil } + tmuxRouter.reset() + tmuxProbeTask?.cancel(); tmuxProbeTask = nil + tmuxBootstrapSent = false // 重连后需要重新 -CC attach + sessionProbeSent = false + sessionProbe = nil + pendingSessionChoice = nil + tmuxSkipped = false + isProbingTmux = false + } + + // MARK: - tmux 自动进入 + + /// 登录后自动进入 tmux control mode。 + /// + /// `tmux -CC new -A -s `:`-A` = 同名会话存在就 attach(断线原样接回的关键)、否则新建; + /// `-CC` = control mode(我们的 `TmuxRouter` 检测其 DCS 前导后接管字节流)。 + /// 服务器没装 tmux → shell 回 command not found,探测超时后降级为原生窗口 + 安装引导横幅。 + /// 连接建立后**先探测**服务器的 tmux 会话,等用户选择再 attach。 + /// + /// 为什么不直接 `tmux -CC new -A -s main`:那是替用户决定了 attach 哪个会话。真实运维里 + /// 服务器上常有多个会话(各跑不同任务),必须让用户挑;没装 tmux 则**不打扰**,直接原生终端。 + private func scheduleTmuxBootstrap() { + guard tmuxMode, !sessionProbeSent, tmuxController == nil else { return } + sessionProbeSent = true + banner = "检测 tmux 会话…" + Task { [weak self] in + // 等 shell 提示符就绪(登录横幅 / motd 可能还在刷)。2.5s 沿用 mosh 引导已验证的值—— + // 太短会把命令喂给还没起来的 shell,字符被丢。 + try? await Task.sleep(nanoseconds: 2_500_000_000) + guard let self else { return } + self.sessionProbe = TmuxSessionProbe() + NSLog("TMUXDBG probing tmux sessions") + self.sendToTerminal(TmuxSessionProbe.command) + self.startProbeTimeout() + } + } + + /// 探测输出的消费入口(raw 阶段的字节都会流经这里)。 + private func feedSessionProbe(_ text: String) { + guard var probe = sessionProbe else { return } + guard let result = probe.feed(text) else { + sessionProbe = probe // struct:写回累积状态 + return + } + sessionProbe = nil + tmuxProbeTask?.cancel(); tmuxProbeTask = nil + isProbingTmux = false + banner = "" + switch result { + case .unavailable: + // 没装 tmux → **不弹框**,直接用原生终端 + 行内安装引导横幅(用户明确要求)。 + NSLog("TMUXDBG probe: tmux 不可用 → 原生终端") + tmuxUnavailable = true + case .available(let version, let sessions): + NSLog("TMUXDBG probe: \(version) sessions=\(sessions.map(\.name))") + pendingSessionChoice = TmuxSessionChoice( + version: version, sessions: sessions, + defaultNewName: tmuxSessionName) + } + } + + /// 探测超时兜底(服务器不回、shell 卡住等):按无 tmux 处理,不把用户永远挂在等待里。 + private func startProbeTimeout() { + tmuxProbeTask?.cancel() + tmuxProbeTask = Task { [weak self] in + try? await Task.sleep(nanoseconds: 6_000_000_000) + guard !Task.isCancelled, let self, self.sessionProbe != nil else { return } + self.sessionProbe = nil + self.isProbingTmux = false + self.banner = "" + self.tmuxUnavailable = true + NSLog("TMUXDBG probe timeout → 按无 tmux 处理") + } + } + + // MARK: - 用户的会话选择 + + /// attach 已有会话。 + func attachTmuxSession(_ name: String) { + pendingSessionChoice = nil + isProbingTmux = false + guard tmuxController == nil else { return } + tmuxSessionName = name + tmuxBootstrapSent = true + banner = "接回 tmux 会话 \(name)…" + NSLog("TMUXDBG attach -t \(name)") + sendToTerminal("tmux -CC attach -t \(name)\n") + startTmuxProbe() + } + + /// 新建会话(`-A` 兜底:同名已存在就接回,避免报错)。 + func createTmuxSession(_ name: String) { + pendingSessionChoice = nil + isProbingTmux = false + guard tmuxController == nil else { return } + tmuxSessionName = name + tmuxBootstrapSent = true + banner = "新建 tmux 会话 \(name)…" + NSLog("TMUXDBG new -A -s \(name)") + sendToTerminal("tmux -CC new -A -s \(name)\n") + startTmuxProbe() + } + + /// 选择原生终端:不经 tmux(本次会话不再提示,也不显示安装横幅——用户是主动选的)。 + func useNativeTerminal() { + pendingSessionChoice = nil + isProbingTmux = false + tmuxUnavailable = false + tmuxSkipped = true + banner = "" + NSLog("TMUXDBG user chose native terminal") + } + + /// 探测:给足时间进 control mode,超时则判定不可用(服务器无 tmux / 命令失败)。 + private func startTmuxProbe() { + tmuxProbeTask?.cancel() + tmuxProbeTask = Task { [weak self] in + try? await Task.sleep(nanoseconds: 4_000_000_000) + guard !Task.isCancelled, let self, self.tmuxController == nil else { return } + self.tmuxUnavailable = true + self.banner = "" + NSLog("TMUXDBG probe timeout → 判定无 tmux(服务器未安装或命令失败)") + } + } + + /// 用户在横幅里点「安装并启用 tmux」后:装完再试一次。 + func retryTmux() { + guard tmuxController == nil else { return } + tmuxUnavailable = false + tmuxBootstrapSent = false + scheduleTmuxBootstrap() + } + + /// 用户关掉横幅(这台主机本次会话不再提示)。 + func dismissTmuxBanner() { tmuxUnavailable = false } + + /// 把一条命令塞进当前终端执行(安装引导用)。执行后给足时间装包,再自动重试进 tmux。 + func runInTerminal(_ command: String) { + sendToTerminal(command + "\n") + tmuxUnavailable = false + Task { [weak self] in + try? await Task.sleep(nanoseconds: 8_000_000_000) + guard let self, self.tmuxController == nil else { return } + self.retryTmux() + } + } + + /// 往当前承载通道写入(mosh 接管后走 mosh,否则走 SSH)——与终端输入同一条路径。 + private func sendToTerminal(_ text: String) { + let data = Data(text.utf8) + if let m = moshSession { m.send([UInt8](data)) } else { holder.transport?.send(data) } + } + + // MARK: - M2 mosh 编排 + + /// SSH 已连接后引导 mosh-server(延迟等 shell 提示符,只发一次)。 + private func scheduleMoshBootstrap() { + guard moshMode, !moshBootstrapSent, moshSession == nil else { return } + moshBootstrapSent = true + let holder = self.holder + let cmd = moshServerCmd + banner = "启动 mosh-server…" + Task { + try? await Task.sleep(nanoseconds: 2_500_000_000) // 等 shell 提示符就绪 + holder.transport?.send(Data("\n".utf8)) + try? await Task.sleep(nanoseconds: 800_000_000) + NSLog("MOSHDBG sent mosh-server cmd: \(cmd)") + holder.transport?.send(Data((cmd + "\n").utf8)) + } + } + + /// mosh 引导期消费 SSH 字节:显示进度 + 扫描 MOSH CONNECT,命中即切换到 mosh。 + /// mosh 接管后忽略 SSH 残余字节,避免污染 mosh 的屏幕状态。 + private func moshBootstrapFeed(_ data: Data) { + guard moshSession == nil else { return } + gate.deliver(data) // 让用户看到 mosh-server 引导输出 + let text = String(decoding: data, as: UTF8.self) + if let info = moshScanner.feed(text) { + NSLog("MOSHDBG parsed MOSH CONNECT port=\(info.port) keyLen=\(info.key.count)") + launchMosh(info) + } + } + + /// 拿到 MOSH CONNECT 后:tsnet 起 UDP relay(loopback:port) → 建 MoshSession → 接管会话。 + private func launchMosh(_ info: MoshConnectInfo) { + guard moshSession == nil, let config else { return } + banner = "mosh 连接中(server port \(info.port))…" + if let connID = activeConnID { + let host = config.host + Task.detached { + do { + NSLog("MOSHDBG startMoshRelay host=\(host) moshPort=\(info.port)") + let relay = try TsnetRegistry.shared.startMoshRelay( + connID: connID, host: host, moshPort: info.port, timeoutMs: 15000) + let localPort = relay.localPort() + NSLog("MOSHDBG relay up localPort=\(localPort)") + await MainActor.run { self.activateMosh(ip: "127.0.0.1", port: localPort, key: info.key, relay: relay) } + } catch { + NSLog("MOSHDBG relay FAILED: \(error.localizedDescription)") + await MainActor.run { self.banner = "mosh relay 失败:\(error.localizedDescription)" } + } + } + } else { + // 直连模式:iOS 允许出站 UDP,直接连 host:port。 + activateMosh(ip: config.host, port: info.port, key: info.key, relay: nil) + } + } + + private func activateMosh(ip: String, port: Int, key: String, relay: TsnetbridgeMoshRelay?) { + guard moshSession == nil else { return } + let mosh = MoshSession(ip: ip, port: port, key: key, cols: 80, rows: 24) + mosh.onBytes = { [weak self] bytes in + guard let self else { return } + self.gate.deliver(Data(bytes)) + } + mosh.onClosed = { [weak self] rc in + Task { @MainActor in + guard let self else { return } + self.run(self.machine.reduce(.moshExited(rc: Int(rc)))) + } + } + moshRelay = relay + moshSession = mosh + moshHolder.session = mosh // 输入/resize 从此路由到 mosh + mosh.start() + moshEngaged = true + NSLog("MOSHDBG activateMosh ip=\(ip) port=\(port) started") + scheduleTmuxBootstrap() // mosh 已承载会话 → 在 mosh 里进 tmux + // mosh 接管成功 → 状态机拆掉底层 idle SSH(moshActive 之后忽略其 transportClosed)。 + run(machine.reduce(.moshEstablished)) + } + + // MARK: - M3 前台快速恢复 + + /// 唤醒脉冲:tsnet WakeUp(后台线程,可能阻塞)+ relay 同端口 Rebind + mosh SIGCONT。 + private func performWakePulse() { + guard let mosh = moshSession else { return } + if moshResumeBaseMs == 0 { moshResumeBaseMs = mosh.lastHeardMs() } + if let connID = activeConnID { + Task.detached { TsnetRegistry.shared.wakeUp(connID: connID) } + } + do { try moshRelay?.rebind() } + catch { NSLog("MOSHDBG relay rebind failed: \(error.localizedDescription)") } + mosh.nudge() + NSLog("MOSHDBG wake pulse base=\(moshResumeBaseMs)") + } + + /// 轮询 mosh 探针:收到基线之后的新服务器状态即判定 SSP 已续(healthy)。 + private func startHealthyPollIfNeeded() { + guard moshHealthyPoll == nil, let mosh = moshSession else { return } + let base = moshResumeBaseMs + moshHealthyPoll = Task { [weak self] in + for _ in 0 ..< 100 { // ~10s 安全上限(看门狗更早兜底) + try? await Task.sleep(nanoseconds: 100_000_000) + if Task.isCancelled { return } + if mosh.lastHeardMs() > base { + guard let self else { return } + NSLog("TXM3 recovered base=\(base) now=\(mosh.lastHeardMs())") + self.moshHealthyPoll = nil + self.run(self.machine.reduce(.moshHealthy)) + return + } + } + } + } + + private func scheduleResumeWatchdog(_ ms: Int) { + resumeWatchdog?.cancel() + resumeWatchdog = Task { [weak self] in + try? await Task.sleep(nanoseconds: UInt64(ms) * 1_000_000) + guard !Task.isCancelled, let self else { return } + self.run(self.machine.reduce(.resumeWatchdogFired)) + } + } + + private func cancelResumeWatchdog() { + resumeWatchdog?.cancel(); resumeWatchdog = nil + moshHealthyPoll?.cancel(); moshHealthyPoll = nil + moshResumeBaseMs = 0 + } + + /// 拆除 mosh 会话(兜底全量重建 / 关闭时)。重置引导状态,允许重新引导 mosh-server。 + private func teardownMosh() { + cancelResumeWatchdog() + moshEngaged = false + moshHolder.session = nil + moshSession?.close(); moshSession = nil + try? moshRelay?.close(); moshRelay = nil + moshBootstrapSent = false + moshScanner = MoshConnectScanner() + } + + private func beginBgTask() { + #if canImport(UIKit) + guard bgTask == .invalid else { return } + bgTask = UIApplication.shared.beginBackgroundTask(withName: "mosh-keepalive") { [weak self] in + self?.endBgTask() + } + #endif + } + + private func endBgTask() { + #if canImport(UIKit) + if bgTask != .invalid { UIApplication.shared.endBackgroundTask(bgTask); bgTask = .invalid } + #endif + } + + private func handleTransportState(_ st: TransportState) { + switch st { + case .authenticating: run(machine.reduce(.authenticating)) + case .connected: + // 先置探测中,再驱动状态机 —— 顺序反了就会漏进上面注释说的空窗。 + if tmuxMode, tmuxController == nil, !sessionProbeSent { isProbingTmux = true } + run(machine.reduce(.established)) + if moshMode { scheduleMoshBootstrap() } + else { scheduleTmuxBootstrap() } // mosh 模式等 mosh 接管后再进 tmux + case .disconnected(let reason): run(machine.reduce(.transportClosed(reason: reason))) + case .failed(let message): run(machine.reduce(.authFailed(reason: message))) + case .idle, .connecting: break + } + } + + private func scheduleReconnect(_ delayMS: Int) { + reconnectTask?.cancel() + reconnectTask = Task { [weak self] in + try? await Task.sleep(nanoseconds: UInt64(delayMS) * 1_000_000) + guard !Task.isCancelled else { return } + await MainActor.run { + guard let self else { return } + self.run(self.machine.reduce(.reconnectTimerFired)) + } + } + } + + private func syncUI() { + switch machine.phase { + case .connected, .reconnecting, .waitingToReconnect, .backgroundParked, + .moshActive, .moshParked, .moshResuming: + isLive = true + case .idle, .connecting, .authenticating, .closed, .failed: + isLive = false + } + link = Self.linkState(machine.phase, egress: egressPlan) + phaseText = Self.describe(machine.phase) + } + + /// 链路状态推导:出口决定 direct/relay,重连/失败覆盖之。 + private static func linkState(_ phase: SessionMachine.Phase, egress: EgressPlan) -> LinkState { + let up: LinkState = { if case .tsnet = egress { return .relay } else { return .direct } }() + switch phase { + case .connecting, .authenticating: return .connecting + // 挂起(后台)时链路并未断,仍报上行状态;恢复中归入重连语义。 + case .connected, .moshActive, .backgroundParked, .moshParked: return up + case .waitingToReconnect(let n), .reconnecting(let n), .moshResuming(let n): + return .reconnecting(attempt: n) + case .idle, .closed, .failed: return .offline + } + } + + private static func describe(_ phase: SessionMachine.Phase) -> String { + switch phase { + case .idle: "空闲" + case .connecting: "连接中…" + case .authenticating: "认证中…" + case .connected: "已连接" + case .backgroundParked: "已挂起(后台)" + case .waitingToReconnect(let n): "等待重连(第 \(n) 次)" + case .reconnecting(let n): "重连中(第 \(n) 次)" + case .failed(let r): "失败:\(r)" + case .closed: "已关闭" + case .moshActive: "mosh 已连接" + case .moshParked: "已挂起(后台)" + case .moshResuming(let n): "mosh 恢复中(第 \(n) 次)" + } + } +} diff --git a/apps/TerminalX/iOS/Theme.swift b/apps/TerminalX/iOS/Theme.swift index d73f35d..f6433cb 100644 --- a/apps/TerminalX/iOS/Theme.swift +++ b/apps/TerminalX/iOS/Theme.swift @@ -2,86 +2,210 @@ import SwiftUI import GhosttyTerminal import GhosttyTheme -/// 统一主题:一套配色同时驱动 **app UI**(下面的颜色角色)和 **终端**(`ghosttyName` → GhosttyTheme)。 -/// 在设置里选主题后 app 与终端一起变色。新增主题 = 再写一个 `static let`,视图零改动。 -struct TXPalette: Equatable, Identifiable { +// MARK: - 设计令牌(对齐 docs/design/UI-DESIGN-HANDOFF.md) +// +// 三层分工,别混: +// TXAccent —— 唯一强调色(Nocturne blurple),**恒定不随主题变**。只做线、点、选中底和文字,绝不大面积铺色。 +// TXChrome —— app chrome 的中性阶。切 Catppuccin flavor 时这一层随之推导(4 张预生成表,见下)。 +// TXFlavor —— Catppuccin flavor 本体:驱动**终端内容区**(libghostty 渲染)+ 提供状态语义色。 +// TXPalette 是三者的门面,视图只吃 palette。 + +/// 唯一强调色:Nocturne blurple 阶。恒定 —— 换主题只换中性阶与终端配色,强调色不动。 +enum TXAccent { + /// `--color-accent`:主强调(线、点、选中描边的源色) + static let base = Color(hex: 0x9184D9) + /// accent-600:强调描边(如主题卡选中框) + static let border = Color(hex: 0x796CBF) + /// accent-900:选中底(列表行、轨道头像) + static let selectedBg = Color(hex: 0x2B2741) + /// accent-800:选中描边(配 inset stroke 用,避免选中时内容位移 1pt) + static let selectedStroke = Color(hex: 0x423A6A) + /// accent-400:强调文字 + static let text = Color(hex: 0xB5ABFC) + /// accent-300:强调文字(更亮,用于 hover/主按钮标题) + static let textBright = Color(hex: 0xD2CEFD) + /// accent-500:弱化强调文字(分组小标题) + static let mid = Color(hex: 0x968AE0) + /// accent-700:极弱强调(轨道头像 inset 描边) + static let dim = Color(hex: 0x5D5294) +} + +/// macOS 窗口按钮沿用系统色(关闭/最小化圆点),不进主题体系。 +enum TXWindowDot { + static let close = Color(hex: 0xFF5F57) + static let minimize = Color(hex: 0xFEBC2E) +} + +/// app chrome 的中性阶。**不含强调色**(那是 TXAccent 的事)。 +struct TXChrome: Equatable { + /// 舞台底 / pane 之间的缝(最深) + let bgDeep: UInt32 + /// 画布底 / 屏幕底 + let bg: UInt32 + /// 侧边栏 / 轨道 / 卡片底 + let surfaceDark: UInt32 + /// 控件底(输入框、次要按钮、徽标) + let surface: UInt32 + /// 分隔线(弱) + let divider: UInt32 + /// 描边(常规) + let border: UInt32 + /// 描边(强调 / 对话框) + let borderStrong: UInt32 + /// 描边(对话框内层 / 中等) + let borderDialog: UInt32 + /// 主文字 + let text: UInt32 + /// 次文字(neutral-300) + let text2: UInt32 + /// 次文字(neutral-400) + let text3: UInt32 + /// 三级文字(neutral-500) + let text4: UInt32 + /// 弱文字 / 占位(neutral-600) + let textWeak: UInt32 + /// 极弱(提示、注脚,neutral-700) + let textFaint: UInt32 +} + +extension TXChrome { + /// Mocha 表 = 设计交付的**精确锚点值**,其余 flavor 由此按 HSL 平移推导(见 docs/design/render-screens.py 同目录笔记)。 + static let mocha = TXChrome( + bgDeep: 0x11121C, bg: 0x161826, surfaceDark: 0x1B1D2B, surface: 0x232532, + divider: 0x2C2F3D, border: 0x3F424D, borderStrong: 0x595D6C, borderDialog: 0x4A4D5E, + text: 0xE9E9ED, text2: 0xCFD3E5, text3: 0xB2B6CA, text4: 0x9397AB, + textWeak: 0x75798C, textFaint: 0x4A4D5E) + + /// Macchiato:按 flavor base 相对 Mocha 的色相/明度差平移(文字阶抬升幅度收一半,保住对比度)。 + static let macchiato = TXChrome( + bgDeep: 0x171B28, bg: 0x1B2133, surfaceDark: 0x212737, surface: 0x292F3E, + divider: 0x323949, border: 0x464C58, borderStrong: 0x606877, borderDialog: 0x515869, + text: 0xEDEEF1, text2: 0xD3DAE9, text3: 0xB6BDCF, text4: 0x969EB0, + textWeak: 0x788091, textFaint: 0x515869) + + static let frappe = TXChrome( + bgDeep: 0x222735, bg: 0x272E3F, surfaceDark: 0x2D3443, surface: 0x363C49, + divider: 0x3F4754, border: 0x535963, borderStrong: 0x6D7582, borderDialog: 0x5E6574, + text: 0xF4F4F5, text2: 0xDCE1EB, text3: 0xBEC5D1, text4: 0x9FA6B2, + textWeak: 0x818793, textFaint: 0x5E6574) + + /// Latte 是唯一浅色 flavor:中性阶整体反转(底浅字深),不走平移。仍遵守"不用纯黑纯白"。 + static let latte = TXChrome( + bgDeep: 0xE4E6ED, bg: 0xF3F4F8, surfaceDark: 0xFAFBFD, surface: 0xEBEDF3, + divider: 0xDCDEE7, border: 0xC7CAD6, borderStrong: 0xA9ADBD, borderDialog: 0xB8BCCA, + text: 0x2A2C3A, text2: 0x43465A, text3: 0x5A5E73, text4: 0x757994, + textWeak: 0x8E92A6, textFaint: 0xB0B4C4) +} + +/// Catppuccin flavor:驱动终端内容区配色 + 提供状态语义色(徽标、状态点)。 +struct TXFlavor: Equatable { let id: String let name: String let isDark: Bool - /// 对应 GhosttyTheme 目录名,驱动终端配色(与 app 配色取自同一 Catppuccin flavor,视觉统一)。 + /// 对应 GhosttyTheme 目录名,驱动终端配色。 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 + // 语义色(Catppuccin 命名) + let green: UInt32 // 成功、提示符、在线 + let red: UInt32 // error + let yellow: UInt32 // warning、git 标记 + let blue: UInt32 // 路径、字段 + let teal: UInt32 // 勾选、mosh + let peach: UInt32 // 重连中 + let overlay: UInt32 // 次要输出、离线 + /// 终端内容区底色(Catppuccin base)。沉浸态胶囊下的预留带、首页缩略卡都铺这个色, + /// 与 libghostty 实际渲染的 pane 底色一致,视觉才连续。 + let terminalBg: UInt32 +} + +// MARK: - Palette(视图唯一入口) + +/// 统一主题门面:一套选择同时驱动 **app chrome**(中性阶)与 **终端**(`ghosttyName` → GhosttyTheme), +/// 强调色恒为 Nocturne blurple。新增主题 = 再写一个 `static let`,视图零改动。 +struct TXPalette: Equatable, Identifiable { + let flavor: TXFlavor + let chrome: TXChrome + + var id: String { flavor.id } + var name: String { flavor.name } + var isDark: Bool { flavor.isDark } + var ghosttyName: String { flavor.ghosttyName } /// 该主题的终端配色(GhosttyTheme → TerminalTheme)。找不到回退系统默认。 var terminalTheme: TerminalTheme { - GhosttyThemeCatalog.theme(named: ghosttyName)?.toTerminalTheme() ?? .default + GhosttyThemeCatalog.theme(named: flavor.ghosttyName)?.toTerminalTheme() ?? .default } + + // MARK: chrome 取色(设计 token 直通) + var bgDeep: Color { Color(hex: chrome.bgDeep) } + var bg: Color { Color(hex: chrome.bg) } + var surfaceDark: Color { Color(hex: chrome.surfaceDark) } + var surface: Color { Color(hex: chrome.surface) } + var divider: Color { Color(hex: chrome.divider) } + var border: Color { Color(hex: chrome.border) } + var borderStrong: Color { Color(hex: chrome.borderStrong) } + var borderDialog: Color { Color(hex: chrome.borderDialog) } + var text: Color { Color(hex: chrome.text) } + var text2: Color { Color(hex: chrome.text2) } + var text3: Color { Color(hex: chrome.text3) } + var text4: Color { Color(hex: chrome.text4) } + var textWeak: Color { Color(hex: chrome.textWeak) } + var textFaint: Color { Color(hex: chrome.textFaint) } + + // MARK: 状态语义色(随 flavor) + /// 在线 / 成功 / 运行中 + var online: Color { Color(hex: flavor.green) } + /// error + var danger: Color { Color(hex: flavor.red) } + /// 重连中 + var reconnecting: Color { Color(hex: flavor.peach) } + var linkGood: Color { Color(hex: flavor.green) } + var linkPath: Color { Color(hex: flavor.blue) } + /// 终端内容区底色(沉浸预留带 / 缩略卡背景)。 + var terminalBackground: Color { Color(hex: flavor.terminalBg) } + + // MARK: 兼容旧取色点 + // 各屏按设计稿重写时逐步改用上面的 chrome/TXAccent 直通名,重写完这一段就删。 + var surfaceElevated: Color { divider } + var accent: Color { TXAccent.base } + var accentSoft: Color { TXAccent.selectedBg } + var textPrimary: Color { text } + var textSecondary: Color { text3 } + var textTertiary: Color { textWeak } + var running: Color { online } + var mosh: Color { Color(hex: flavor.teal) } + var warning: Color { Color(hex: flavor.yellow) } + var offline: Color { Color(hex: flavor.overlay) } } 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 = TXPalette( + flavor: TXFlavor( + id: "catppuccin-mocha", name: "Mocha", isDark: true, ghosttyName: "Catppuccin Mocha", + green: 0xA6E3A1, red: 0xF38BA8, yellow: 0xF9E2AF, blue: 0x89B4FA, + teal: 0x94E2D5, peach: 0xFAB387, overlay: 0x6C7086, terminalBg: 0x1E1E2E), + chrome: .mocha) - 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 = TXPalette( + flavor: TXFlavor( + id: "catppuccin-macchiato", name: "Macchiato", isDark: true, ghosttyName: "Catppuccin Macchiato", + green: 0xA6DA95, red: 0xED8796, yellow: 0xEED49F, blue: 0x8AADF4, + teal: 0x8BD5CA, peach: 0xF5A97F, overlay: 0x6E738D, terminalBg: 0x24273A), + chrome: .macchiato) - 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 = TXPalette( + flavor: TXFlavor( + id: "catppuccin-frappe", name: "Frappé", isDark: true, ghosttyName: "Catppuccin Frappe", + green: 0xA6D189, red: 0xE78284, yellow: 0xE5C890, blue: 0x8CAAEE, + teal: 0x81C8BE, peach: 0xEF9F76, overlay: 0x737994, terminalBg: 0x303446), + chrome: .frappe) - 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 catppuccinLatte = TXPalette( + flavor: TXFlavor( + id: "catppuccin-latte", name: "Latte", isDark: false, ghosttyName: "Catppuccin Latte", + green: 0x40A02B, red: 0xD20F39, yellow: 0xDF8E1D, blue: 0x1E66F5, + teal: 0x179299, peach: 0xFE640B, overlay: 0x9CA0B0, terminalBg: 0xEFF1F5), + chrome: .latte) /// 可选主题(设置里列出)。默认第一个。 static let all: [TXPalette] = [catppuccinMocha, catppuccinMacchiato, catppuccinFrappe, catppuccinLatte] @@ -118,24 +242,91 @@ extension TerminalTheme { } } -/// 结构性设计令牌(圆角/间距/字体)——不随主题变。 +// MARK: - 结构性令牌(不随主题变) + +/// 圆角 / 间距 / 字体 / 布局基准,数值取自设计交付文档。 enum TX { enum Radius { - static let card: CGFloat = 16 - static let chip: CGFloat = 10 - static let field: CGFloat = 12 + /// 徽标 / 胶囊小件 + static let badge: CGFloat = 6 + /// 控件(输入框、按钮) + static let control: CGFloat = 9 + /// 卡片 + static let card: CGFloat = 12 + /// 对话框 + static let dialog: CGFloat = 15 + /// 浮起 chrome(标题/状态胶囊) + static let capsule: CGFloat = 11 + + // 兼容旧名 + static let chip: CGFloat = 9 + static let field: CGFloat = 9 } + + /// 设计间距阶:2 / 6 / 8 / 10 / 12 / 14 / 16 / 20 / 26 / 34 / 40 enum Space { + static let hairline: CGFloat = 2 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 + static let l: CGFloat = 20 + static let xl: CGFloat = 26 + static let xxl: CGFloat = 34 + /// 屏幕左右留白(首页 / 设置内容区) + static let screenGutter: CGFloat = 40 } + + /// 布局基准(pt)。竖屏值见 `.compact` 变体。 + enum Layout { + /// 折叠轨道宽(竖屏 48) + static let railWidth: CGFloat = 56 + static let railWidthCompact: CGFloat = 48 + /// 展开侧边栏宽 + static let sidebarWidth: CGFloat = 288 + /// pane 之间的缝 + static let paneGap: CGFloat = 2 + /// 拖拽热区 + static let paneDragHit: CGFloat = 12 + /// 触控命中区下限 + static let minTouch: CGFloat = 44 + /// 终端主 pane 顶部给浮起胶囊预留 + static let capsuleInset: CGFloat = 86 + /// 底部区高度:**舞台提示条与侧边栏脚共用**,故两侧的分割线落在同一条 y 上。 + static let footHeight: CGFloat = 34 + } + enum Font { + /// JetBrains Mono(chrome 与终端同源)。字体缺失时 SwiftUI 自动回退系统字体,中文走 PingFang SC。 static func mono(_ size: CGFloat, _ weight: SwiftUI.Font.Weight = .regular) -> SwiftUI.Font { - .system(size: size, weight: weight, design: .monospaced) + .custom(psName(weight), fixedSize: size) } + + /// 随动态字体缩放的变体(正文型文案用这个,终端内容不用)。 + static func monoScaled(_ size: CGFloat, _ weight: SwiftUI.Font.Weight = .regular) -> SwiftUI.Font { + .custom(psName(weight), size: size) + } + + private static func psName(_ weight: SwiftUI.Font.Weight) -> String { + switch weight { + case .bold, .heavy, .black: return "JetBrainsMono-Bold" + case .semibold: return "JetBrainsMono-SemiBold" + case .medium: return "JetBrainsMono-Medium" + default: return "JetBrainsMono-Regular" + } + } + + /// 分组小标题(600 10px, ls .09em, uppercase) + static let sectionLabel = mono(10, .semibold) + /// 屏幕主标题(500 34px) + static let screenTitle = mono(34, .medium) + /// 页面标题(500 24px) + static let pageTitle = mono(24, .medium) + /// 列表主行(500 13px) + static let listRow = mono(13, .medium) + /// 列表副行 / 元信息(400 11px) + static let listMeta = mono(11) + /// 徽标 / 胶囊(500 12px) + static let badge = mono(12, .medium) } } diff --git a/apps/TerminalX/iOS/TmuxController.swift b/apps/TerminalX/iOS/TmuxController.swift index 0d3173c..a1ec044 100644 --- a/apps/TerminalX/iOS/TmuxController.swift +++ b/apps/TerminalX/iOS/TmuxController.swift @@ -37,7 +37,11 @@ final class TmuxPaneSurface: ObservableObject, Identifiable { @MainActor final class TmuxWindow: ObservableObject, Identifiable { let id: TmuxWindowID + /// tmux `#{window_index}`(用户可见编号,轨道/侧边栏显示这个;`id` 是内部 `@N`)。 + @Published var index: Int @Published var title: String + /// 活动 pane 的工作目录(`#{pane_current_path}`)。title 是 shell 默认值时 UI 回退显示它。 + @Published var cwd: String = "" @Published var panes: [TmuxPaneID: TmuxPaneSurface] = [:] /// 渲染用:zoom 时只含被 zoom 的 pane。 @Published var visibleLayout: TmuxLayout? @@ -45,7 +49,19 @@ final class TmuxWindow: ObservableObject, Identifiable { var fullLayout: TmuxLayout? @Published var activePane: TmuxPaneID? - init(id: TmuxWindowID, title: String) { self.id = id; self.title = title } + init(id: TmuxWindowID, index: Int, title: String) { + self.id = id; self.index = index; self.title = title + } +} + +/// 一个 tmux 会话的概要(`list-sessions` 结果)。分屏菜单列「分到哪里」用。 +struct TmuxSessionInfo: Identifiable, Equatable { + var id: String { name } + let name: String + let windows: Int + /// 是否已被某客户端 attach(当前会话即 attached 的那个)。 + let isAttached: Bool + let lastActivity: Date? } /// tmux control-mode 网关:消费 `%`-协议流,把 window 映射为原生 tab、window 内多 pane 映射为分屏。 @@ -55,6 +71,8 @@ final class TmuxWindow: ObservableObject, Identifiable { final class TmuxController: ObservableObject { @Published private(set) var windows: [TmuxWindow] = [] @Published var activeWindowID: TmuxWindowID? + /// 本 tmux server 上的会话(`listSessions()` 填充,分屏菜单用)。 + @Published private(set) var sessions: [TmuxSessionInfo] = [] private let parser = TmuxControlParser() private var windowByID: [TmuxWindowID: TmuxWindow] = [:] @@ -70,7 +88,18 @@ final class TmuxController: ObservableObject { // control-mode 命令应答 FIFO 匹配。 private var attachAcked = false - private enum PendingKind { case ignore, listWindows, capturePane(TmuxPaneID) } + private enum PendingKind: CustomStringConvertible { + case ignore, listWindows, listSessions, currentSession, capturePane(TmuxPaneID) + var description: String { + switch self { + case .ignore: "ignore" + case .listWindows: "listWindows" + case .listSessions: "listSessions" + case .currentSession: "currentSession" + case .capturePane(let p): "capturePane(%\(p.raw))" + } + } + } private var pending: [PendingKind] = [] // attach kickoff(refresh-client + list-windows + 各 pane capture)必须等"容器真实几何已知"再发: @@ -80,6 +109,11 @@ final class TmuxController: ObservableObject { private var containerKnown = false private var kickoffDone = false + /// format 字段分隔符。**不要用 tab**:真机实测 tab 经 PTY 到 tmux 后变成下划线, + /// 导致 list-windows/list-sessions 整行分不开(title/cwd/会话名全丢,UI 显示兜底名)。 + /// 也不能用 `#`(tmux format 里 `#{}`/`##` 有特殊含义)。 + static let fieldSep = "|:|" + init(sendRaw: @escaping @Sendable (Data) -> Void) { self.sendRaw = sendRaw } @@ -91,8 +125,14 @@ final class TmuxController: ObservableObject { 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}\"") + let sep = Self.fieldSep + sendCommand("list-windows -F \"#{window_id}\(sep)#{window_name}\(sep)#{window_active}\(sep)#{window_layout}\(sep)#{window_visible_layout}\(sep)#{pane_current_path}\(sep)#{window_index}\"") pending.append(.listWindows) + // 本客户端所在的会话名。**不能**用 list-sessions 的 `session_attached` 反推—— +// 服务器上别的客户端 attach 着其它会话时会猜错(真机实测把 `demo` 当成了当前会话)。 + sendCommand("display-message -p \"#{session_name}\"") + pending.append(.currentSession) + listSessions() // 分屏菜单要列全部会话 } /// 由 model 在 attach 前告知全屏格子(tmux 据此 refresh-client -C 布局)。 @@ -125,6 +165,10 @@ final class TmuxController: ObservableObject { } } + /// 本客户端所在的 tmux 会话名(`display-message -p "#{session_name}"` 的权威答案)。 + /// 拿不到时为空串 —— UI 只显示「tmux」而不编造名字。 + @Published private(set) var currentSessionName = "" + var activeWindow: TmuxWindow? { guard let id = activeWindowID else { return windows.first } return windowByID[id] @@ -231,18 +275,45 @@ final class TmuxController: ObservableObject { return } guard !pending.isEmpty else { return } - switch pending.removeFirst() { + let kind = pending.removeFirst() + switch kind { case .ignore: break case .listWindows: for line in r.lines { - let parts = line.split(separator: "\t", omittingEmptySubsequences: false) - guard parts.count >= 5, let w = TmuxWindowID.parse(parts[0]) else { continue } + let parts = line.components(separatedBy: Self.fieldSep) + guard parts.count >= 5, let w = TmuxWindowID.parse(parts[0]) else { + NSLog("TMUXDBG winparse FAILED count=\(parts.count) line=[\(line.prefix(160))]") + continue + } let win = ensureWindow(w) win.title = String(parts[1]) + if parts.count >= 6 { win.cwd = String(parts[5]) } + if parts.count >= 7, let idx = Int(parts[6]) { win.index = idx } if parts[2] == "1" { activeWindowID = w } applyLayout(window: w, full: String(parts[3]), visible: String(parts[4])) } + case .listSessions: + sessions = r.lines.compactMap { line in + let f = line.components(separatedBy: Self.fieldSep) + guard f.count >= 3 else { return nil } + let activity = f.count >= 4 ? Double(f[3]).map { Date(timeIntervalSince1970: $0) } : nil + return TmuxSessionInfo(name: f[0], + windows: Int(f[1]) ?? 1, + isAttached: f[2] == "1", + lastActivity: activity) + } + NSLog("TMUXDBG sessions=\(sessions.map { "\($0.name)/w\($0.windows)/a\($0.isAttached)" })") + case .currentSession: + if let name = r.lines.first?.trimmingCharacters(in: .whitespacesAndNewlines), !name.isEmpty { + currentSessionName = name + NSLog("TMUXDBG currentSession=\(name)") + } + case .currentSession: + if let name = r.lines.first?.trimmingCharacters(in: .whitespacesAndNewlines), !name.isEmpty { + currentSessionName = name + NSLog("TMUXDBG currentSession=\(name)") + } case .capturePane(let p): // 剔除尾部空行:capture-pane 常返回补满 pane 高度的尾随空行,若行数 > pane grid 行数, // 铺快照(ESC[H + N-1 个 \r\n)会溢出上滚、把顶部内容顶出屏幕(提示符被拆/内容钉底)。 @@ -263,7 +334,8 @@ final class TmuxController: ObservableObject { @discardableResult private func ensureWindow(_ w: TmuxWindowID) -> TmuxWindow { if let existing = windowByID[w] { return existing } - let win = TmuxWindow(id: w, title: "窗口 \(w.raw)") + // index 先用 id 占位,list-windows 应答到达后校正为真实 window_index。 + let win = TmuxWindow(id: w, index: w.raw, title: "窗口 \(w.raw)") windowByID[w] = win windows.append(win) windows.sort { $0.id.raw < $1.id.raw } @@ -307,6 +379,47 @@ final class TmuxController: ObservableObject { func newWindow() { sendCommand("new-window") } func killWindow(_ w: TmuxWindowID) { sendCommand("kill-window -t @\(w.raw)") } + // MARK: - 关闭 / 分屏 / resize(UI-4) + + /// 断开 tmux 客户端:**服务端会话继续跑**,下次原样接回。红点确认里的推荐动作。 + /// tmux 会回 `%exit` → 既有 `onExit` 链路收尾。 + func detach() { sendCommand("detach-client") } + + /// 结束当前 tmux 会话(服务端进程一并结束)。红点确认里的危险次要动作,需再确认一次。 + func killSession() { sendCommand("kill-session") } + + /// 分屏。tmux 语义:`-h` 左右并排(竖直分隔线)、`-v` 上下堆叠。 + /// 只发命令即可 —— tmux 回 `%layout-change`,既有 reconcile 会为新 pane 建 surface + /// 并走 capture-pane 快照链路(见 `applyLayout` / `ensurePane`)。 + func splitWindow(vertical: Bool, joinSession: String? = nil) { + if let s = joinSession { + // 把另一个会话的当前 window 拉进本 window 当 pane(tmux 原生支持,不嵌套 tmux)。 + sendCommand("join-pane \(vertical ? "-h" : "-v") -s \(s):") + } else { + sendCommand("split-window \(vertical ? "-h" : "-v")") + } + } + + /// 新建 tmux 会话并把它的 window 拉进当前 window 分屏(detached 建,避免抢走客户端)。 + func splitIntoNewSession(vertical: Bool, name: String) { + sendCommand("new-session -d -s \(name)") + sendCommand("join-pane \(vertical ? "-h" : "-v") -s \(name):") + } + + /// 拖 pane 分隔条**松手后**调整尺寸(拖动中只动视觉、不发命令,见设计约束)。 + /// 发完等 `%layout-change` 回来再更新真实 frame —— 与"路线 b"一致,客户端不自行重排。 + func resizePane(_ p: TmuxPaneID, cols: Int? = nil, rows: Int? = nil) { + if let cols, cols > 0 { sendCommand("resize-pane -t %\(p.raw) -x \(cols)") } + if let rows, rows > 0 { sendCommand("resize-pane -t %\(p.raw) -y \(rows)") } + } + + /// 枚举本 tmux server 上的会话(分屏菜单要列 detached 会话)。结果进 `sessions`。 + func listSessions() { + let sep = Self.fieldSep + sendCommand("list-sessions -F \"#{session_name}\(sep)#{session_windows}\(sep)#{session_attached}\(sep)#{session_activity}\"") + pending.append(.listSessions) + } + /// 某 pane 的 surface 就绪 → 放行其缓冲输出。 func markPaneReady(_ p: TmuxPaneID) { surfaceByPane[p]?.gate.markReady() } @@ -320,13 +433,13 @@ final class TmuxController: ObservableObject { /// 在传输后台线程调用;原始字节走线程安全的 rawGate,网关字节经闭包 hop 到主线程。 final class TmuxRouter: @unchecked Sendable { private let lock = NSLock() - private let rawGate: OutputGate + private let rawGate: any RawOutputSink private let enterGateway: @Sendable ([UInt8]) -> Void private let gatewayBytes: @Sendable ([UInt8]) -> Void private var carry: [UInt8] = [] private var inGateway = false - init(rawGate: OutputGate, + init(rawGate: any RawOutputSink, enterGateway: @escaping @Sendable ([UInt8]) -> Void, gatewayBytes: @escaping @Sendable ([UInt8]) -> Void) { self.rawGate = rawGate diff --git a/apps/TerminalX/iOS/TsnetRegistry.swift b/apps/TerminalX/iOS/TsnetRegistry.swift new file mode 100644 index 0000000..2114b3d --- /dev/null +++ b/apps/TerminalX/iOS/TsnetRegistry.swift @@ -0,0 +1,103 @@ +import Foundation +import TsnetBridge + +enum TsnetError: Error { case newNodeFailed, notUp, needsAuthKey } + +/// 多 tsnet 节点注册表:按 Tailscale 连接 id 复用节点。 +/// +/// tsnet v1.102.0 官方支持一进程多 `tsnet.Server`(各自独立 netstack;dial 走具体 node 句柄、 +/// 无全局路由,故重叠 100.x 不会误连——只要每个 host 始终经其绑定的那个 Node dial)。铁律不破: +/// 仍是一个 Go runtime、一次 gomobile bind(全在 tsnetbridge 包)。 +/// +/// 策略:**懒启动**(用到才 Up)·**Up 后常驻**(不空闲 Down,M3 mosh 恢复依赖 netstack 存活)· +/// **软上限 3**(超限 LRU 关闭无活跃会话的最久未用节点)·**in-flight 去重**(同连接并发 connect +/// 绝不 double-Up 同一 stateDir——tsnet 无文件锁,同 Dir 双 Up 会损坏状态)。 +/// +/// 线程安全(`@unchecked Sendable` + 锁):Up/dial 阻塞,由 `TerminalSession` 在后台线程调用。 +final class TsnetRegistry: @unchecked Sendable { + static let shared = TsnetRegistry() + + private let lock = NSLock() + private var nodes: [UUID: TsnetbridgeNode] = [:] + private var upLocks: [UUID: NSLock] = [:] + private var lastUsed: [UUID: Date] = [:] + private var activeSessions: Set = [] + private let maxLive = 3 + + private let stateBase: String + + init() { + stateBase = NSSearchPathForDirectoriesInDomains(.applicationSupportDirectory, .userDomainMask, true)[0] + } + + /// 取(必要时懒启动)某连接的 tsnet 节点。阻塞,需后台线程调用。 + /// `authKey` 仅在节点尚未注册(首次 Up)时需要;已持久化 state 的节点用 nil(走已存 node key)。 + func node(connID: UUID, stateDirName: String, hostname: String, + authKey: String?, timeoutMs: Int) throws -> TsnetbridgeNode { + lock.lock() + if let n = nodes[connID] { lastUsed[connID] = Date(); lock.unlock(); return n } + let upLock = upLocks[connID] ?? { let l = NSLock(); upLocks[connID] = l; return l }() + lock.unlock() + + // 同连接串行 Up(in-flight 去重);不同连接可并发。 + upLock.lock() + defer { upLock.unlock() } + + lock.lock() + if let n = nodes[connID] { lastUsed[connID] = Date(); lock.unlock(); return n } + lock.unlock() + + let dir = stateBase + "/" + stateDirName + try? FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true) + guard let n = TsnetbridgeNewNode(dir, hostname) else { throw TsnetError.newNodeFailed } + try n.up(withAuthKey: authKey ?? "", timeoutMs: timeoutMs) + + lock.lock() + nodes[connID] = n + lastUsed[connID] = Date() + evictIfNeededLocked(keeping: connID) + lock.unlock() + return n + } + + /// 为 mosh 建立 UDP relay(阻塞,后台线程调用)。节点必须已 Up。 + func startMoshRelay(connID: UUID, host: String, moshPort: Int, timeoutMs: Int) throws -> TsnetbridgeMoshRelay { + lock.lock(); let n = nodes[connID]; lock.unlock() + guard let n else { throw TsnetError.notUp } + return try n.startMoshRelay(host, moshPort: moshPort, timeoutMs: timeoutMs) + } + + /// M3:唤醒某连接的 tsnet 网络层(前台恢复,可能阻塞)。 + func wakeUp(connID: UUID) { + lock.lock(); let n = nodes[connID]; lock.unlock() + n?.wakeUp() + } + + /// 标记某连接是否有活跃会话(供 LRU 不驱逐在用节点)。 + func markSessionActive(_ connID: UUID, _ active: Bool) { + lock.lock() + if active { activeSessions.insert(connID) } else { activeSessions.remove(connID) } + lock.unlock() + } + + /// 关闭并销毁某连接的节点(删连接 / LRU 驱逐)。 + func close(_ connID: UUID) { + lock.lock() + let n = nodes[connID] + nodes[connID] = nil; lastUsed[connID] = nil; activeSessions.remove(connID) + lock.unlock() + try? n?.close() + } + + /// 超出软上限时,关闭无活跃会话、最久未用的节点(保护刚取到的 keeping)。 + private func evictIfNeededLocked(keeping: UUID) { + while nodes.count > maxLive { + let candidates = nodes.keys + .filter { $0 != keeping && !activeSessions.contains($0) } + .sorted { (lastUsed[$0] ?? .distantPast) < (lastUsed[$1] ?? .distantPast) } + guard let victim = candidates.first, let n = nodes[victim] else { break } + nodes[victim] = nil; lastUsed[victim] = nil + try? n.close() + } + } +} diff --git a/apps/TerminalX/iOS/UIComponents.swift b/apps/TerminalX/iOS/UIComponents.swift new file mode 100644 index 0000000..ffd28c2 --- /dev/null +++ b/apps/TerminalX/iOS/UIComponents.swift @@ -0,0 +1,56 @@ +import SwiftUI + +/// 编辑页共享样式:深色 Form 行背景 + 主文字色。 +extension View { + func txRow(_ p: TXPalette) -> some View { + self.listRowBackground(p.surface).foregroundStyle(p.textPrimary) + } +} + +/// 深色主题的表单文本字段。 +struct TXTextField: View { + let title: String + @Binding var text: String + var placeholder: String = "" + var keyboard: UIKeyboardType = .default + var secure: Bool = false + @EnvironmentObject var theme: TXThemeManager + private var p: TXPalette { theme.palette } + + var body: some View { + Group { + if secure { + SecureField(placeholder.isEmpty ? title : placeholder, text: $text) + } else { + TextField(placeholder.isEmpty ? title : placeholder, text: $text) + .keyboardType(keyboard) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + } + } + .foregroundStyle(p.textPrimary) + .txRow(p) + } +} + +/// 空状态占位(列表为空时)。 +struct TXEmptyState: View { + let icon: String + let title: String + let subtitle: String + @EnvironmentObject var theme: TXThemeManager + private var p: TXPalette { theme.palette } + + var body: some View { + VStack(spacing: TX.Space.s) { + Image(systemName: icon) + .font(.system(size: 34, weight: .light)) + .foregroundStyle(p.textTertiary) + Text(title).font(.system(size: 15)).foregroundStyle(p.textSecondary) + Text(subtitle).font(.system(size: 13)).foregroundStyle(p.textTertiary) + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 48) + } +} diff --git a/apps/TerminalX/iOS/UIPreviewFixture.swift b/apps/TerminalX/iOS/UIPreviewFixture.swift new file mode 100644 index 0000000..909cbde --- /dev/null +++ b/apps/TerminalX/iOS/UIPreviewFixture.swift @@ -0,0 +1,267 @@ +#if DEBUG +import Foundation +import UIKit + +/// 无头 UI 验证脚手架(仅 DEBUG)。 +/// +/// 本项目的构建机没有 GUI,每屏都靠 `simctl io screenshot` 取证对拍设计稿;而设计稿里的 +/// 首页会话卡、轨道多主机头像、状态徽标都需要**多条已连接会话**才有内容。用真实主机验证既慢 +/// 又依赖即时凭据,故提供这条假会话通道:只灌示意输出到 in-memory 终端,**不建任何传输连接**。 +/// +/// 用法:`xcrun simctl launch ai.athom.terminalx --args -txUIFixture 1` +/// 真实端到端回归仍走 `-txHost/-txUser/-txPass`(见 CLAUDE.md 启动参数表),二者互斥。 +enum UIPreviewFixture { + /// 三条会话对应设计稿 `1e 主机-会话卡` 的三张卡(直连 / 经 tsnet / 重连中)。 + /// `name` 与 `hosts` 里的主机同名 → 会话复用同一 `SavedHost.id`,主机卡才能亮「已有会话」。 + struct Spec { + let name: String + let link: LinkState + let tmux: TmuxSummary? + let lines: [String] + } + + /// 假 Tailscale 出口 id(只为让主机卡亮 tsnet 徽章;fixture 不建连接)。 + static let fixtureTailnetID = UUID(uuidString: "00000000-0000-0000-0000-0000000000FE")! + + /// 示意 tailnet(首页副行 + 侧边栏脚的状态)。只进内存,不写盘。 + static let tailnets: [TailscaleConnection] = [ + TailscaleConnection(id: fixtureTailnetID, name: "athom.ts.net", + hostname: "ipad-pro", needsAuthKey: false, lastSelfIP: "100.92.14.7"), + ] + + /// 首页「全部主机」的示意主机(对拍设计稿 `1e` 的 7 台 + 添加卡)。只进内存,不写盘。 + static let hosts: [SavedHost] = [ + SavedHost(name: "mac-studio", host: "100.71.203.4", useMosh: true, + auth: .inlinePassword(username: "yz", password: ""), + tailscaleID: fixtureTailnetID, group: "生产", os: "macOS · M4 Max"), + SavedHost(name: "ubuntu-cn2", host: "100.88.9.61", useMosh: true, + auth: .inlinePassword(username: "yz", password: ""), + tailscaleID: fixtureTailnetID, group: "云", os: "Ubuntu 26.04"), + SavedHost(name: "edge-sg1", host: "10.1.2.11", useMosh: false, + auth: .inlinePassword(username: "root", password: ""), + tailscaleID: fixtureTailnetID, group: "云", os: "Debian 13"), + SavedHost(name: "edge-sg2", host: "10.1.2.12", useMosh: false, + auth: .inlinePassword(username: "root", password: ""), + group: "云", os: "Debian 13"), + SavedHost(name: "nas-home", host: "100.104.2.18", useMosh: true, + auth: .inlinePassword(username: "admin", password: ""), + group: "家里", os: "TrueNAS"), + SavedHost(name: "pi-lab", host: "192.168.1.31", useMosh: false, + auth: .inlinePassword(username: "pi", password: ""), + group: "家里", os: "Raspberry Pi OS"), + SavedHost(name: "mosh-lab", host: "192.168.1.44", useMosh: true, + auth: .inlinePassword(username: "yz", password: ""), + group: "家里", os: "Fedora 44"), + ] + + static let specs: [Spec] = [ + Spec(name: "mac-studio", link: .direct, + tmux: TmuxSummary(name: "dev", windows: 3, panes: 4, currentTitle: "运行测试"), lines: [ + "→ terminalx git:(main) git fetch --all --prune", + "Fetching origin … done", + "→ terminalx git:(main) swiftformat --lint Sources", + "✓ 0 issues in 84 files", + "→ terminalx git:(main) swift build -c release --arch arm64", + "[142/142] Compiling TXCore", + "✓ build complete (14.2s)", + "→ terminalx git:(main) swift test --package-path packages/TXCore", + "◇ Test run started.", + " ✔ parses %output escaped octal", + " ✔ layout diff keeps pane identity", + " ✔ host-key pin survives wipe", + "◇ 50 tests passed after 0.412s", + "→ terminalx git:(main) xcodegen generate --spec project.yml", + "✅ Created TerminalX.xcodeproj", + "→ terminalx git:(main) xcodebuild -scheme TerminalX build", + "** BUILD SUCCEEDED **", + "→ terminalx git:(main) git status --short", + " M apps/TerminalX/iOS/RailSidebar.swift", + " M apps/TerminalX/iOS/HomeView.swift", + "?? docs/design/UI-DESIGN-HANDOFF.md", + "→ terminalx git:(main) scripts/tx-shot.sh /tmp/v3.png", + " pixelWidth: 2752", + " pixelHeight: 2064", + "→ terminalx git:(main) swift test --package-path packages/TXCore", + "◇ 50 tests passed after 0.398s", + "→ terminalx git:(main) tmux ls", + "dev: 3 windows (created Fri 09:12:03)", + "scratch: 1 windows (created Tue)", + "→ terminalx git:(main) tailscale status | head -3", + "100.71.203.4 mac-studio direct", + "100.88.9.61 ubuntu-cn2 relay hkg", + "→ terminalx git:(main) ", + ]), + Spec(name: "ubuntu-cn2", link: .relay, + tmux: TmuxSummary(name: "build", windows: 1, panes: 1, currentTitle: "集成测试"), lines: [ + "→ cn2 ~ journalctl -u build-runner -n 3 --no-pager", + "12:05:02 Started build-runner@42", + "12:06:02 build-runner@42 succeeded", + "→ cn2 ~ systemctl status build-runner --no-pager", + "● build-runner.service — active (running)", + " since Fri 09:12:03 CST; 2h 54min ago", + "→ cn2 ~ ninja -C build -j 8", + "[148/148] Linking libmosh-core.a", + "✓ ninja: no work to do.", + "→ cn2 ~ tailscale netcheck | head -5", + " * UDP: true", + " * IPv4: yes", + " * UPnP: false", + "→ cn2 ~ ./run-integration.sh --tsnet", + "▸ joining tailnet as ci-runner…", + "▸ peer enumeration: 7 nodes", + "▸ mosh relay loopback:60122 ⇄ tsnet", + "▸ handshake ok, AES-OCB session up", + " roaming: peer addr switched", + " resume ok in 1.8s, 0 bytes lost", + " suspend 90s → wake", + " resume ok in 2.4s", + "▸ 14/14 passed", + "→ cn2 ~ df -h /tank | tail -1", + "tank 187T 217T 7.87 42%", + "→ cn2 ~ ", + ]), + Spec(name: "nas-home", link: .reconnecting(attempt: 3), tmux: nil, lines: [ + "nas ~ $ zfs get compressratio tank", + "tank compressratio 1.34x", + "nas ~ $ docker ps --format 'table {{.Names}}'", + "NAMES STATUS", + "plex Up 6 days", + "syncthing Up 6 days", + "nas ~ $ uname -sr", + "Linux 6.12.9-truenas", + "nas ~ $ zpool status -x", + " all pools are healthy", + "nas ~ $ df -h /tank", + "Filesystem Size Used Avail Use%", + "tank 187T 217T 7.87 42%", + "nas ~ $ zfs list -t snapshot | tail -3", + "tank/media@auto-2026-07-24 1.2G", + "tank/docs@auto-2026-07-24 318M", + "tank/vm@auto-2026-07-24 2.4G", + "nas ~ $ smartctl -H /dev/sda | tail -2", + "SMART overall-health: PASSED", + "nas ~ $ systemctl is-active smbd nfs-server", + "active", + "active", + "nas ~ $ tail -2 /var/log/messages", + "12:04:31 nas kernel: wlan0 rssi -74", + "12:06:20 nas tailscaled: peer offline", + "nas ~ $ ", + ]), + ] + + /// 分屏菜单要列的会话(当前 attached + 两个 detached)。对拍设计稿 `3b`。 + static func tmuxSessions(current: String) -> [TmuxSessionInfo] { + // 相对时刻用 timeIntervalSinceNow 造(脚本里不能用固定日期,会随时间漂移)。 + [ + TmuxSessionInfo(name: current, windows: 3, isAttached: true, lastActivity: nil), + TmuxSessionInfo(name: "scratch", windows: 1, isAttached: false, + lastActivity: Date(timeIntervalSinceNow: -3 * 86400)), + TmuxSessionInfo(name: "deploy", windows: 2, isAttached: false, + lastActivity: Date(timeIntervalSinceNow: -86400)), + ] + } + + /// 把启动参数拼成一台主机(供 `-txOpenAsHost 1` 走真实的「点主机连接」路径)。 + static func hostFromLaunchArgs() -> SavedHost? { + let d = UserDefaults.standard + guard let host = d.string(forKey: "txHost"), !host.isEmpty, + let user = d.string(forKey: "txUser") else { return nil } + let port = d.integer(forKey: "txPort") + return SavedHost( + name: host, host: host, port: port == 0 ? 22 : port, + useMosh: d.bool(forKey: "txMosh"), + useTmux: d.object(forKey: "txTmux") == nil ? true : d.bool(forKey: "txTmux"), + tmuxSession: d.string(forKey: "txTmuxSession"), + auth: .inlinePassword(username: user, password: d.string(forKey: "txPass") ?? "")) + } + + /// 是否启用(启动参数 `-txUIFixture 1`)。 + static var isEnabled: Bool { + UserDefaults.standard.bool(forKey: "txUIFixture") + } + + /// 无头导航驱动:`xcrun simctl openurl "terminalx://ui/<动作>"`。 + /// + /// 为什么不用坐标点击:本环境没有 GUI,idb companion 不可用;而坐标点击还会随布局改动失效。 + /// 走 URL 直接驱动 `AppRouter`/`SessionManager` 的公开语义,既稳定又正是要验的那条路径。 + /// + /// 支持:`home` · `minimize` · `close`(弹确认) · `close-now`(直接关) · `split/v|h` + /// · `split-now/v|h`(直接分屏) · `resize/`(resize-pane) + /// · `pick/<会话名>` · `pick-new/<名>` · `pick-native`(连接前的会话选择器) + /// · `dismiss` · `session/<序号>`(1 起,按首页卡片顺序)· `sidebar/toggle|pin` + @MainActor + static func handle(uiCommand path: [String], manager: SessionManager, router: AppRouter) -> Bool { + switch path.first { + case "home": + router.goHome() + case "minimize": + if let id = router.route.sessionID { manager.minimize(id); router.goHome() } + case "close": + // 与红点一致:弹确认对话框(真正关闭要在对话框里选)。 + guard router.route.sessionID != nil else { return false } + router.present(.closeConfirm) + case "close-now": + // 跳过确认直接关(回归脚本用)。 + if let id = router.route.sessionID { router.goHome(); manager.close(id) } + case "split": + guard router.route.sessionID != nil else { return false } + router.present(.splitMenu(vertical: path.dropFirst().first != "h")) + case "dismiss": + router.dismissModal() + case "split-now": + // 跳过菜单直接对当前会话分屏(验证 split-window 链路)。 + guard let id = router.route.sessionID, let s = manager.session(id), + let tmux = s.tmuxController else { return false } + tmux.splitWindow(vertical: path.dropFirst().first != "h") + case "pick": + // 会话选择器:attach 指定会话(`ui/pick/`)。此时通常还在首页,故优先取待选择的会话。 + guard let s = manager.sessionAwaitingChoice ?? router.route.sessionID.flatMap(manager.session), + let name = path.dropFirst().first else { return false } + s.attachTmuxSession(name) + case "pick-new": + guard let s = manager.sessionAwaitingChoice ?? router.route.sessionID.flatMap(manager.session) + else { return false } + s.createTmuxSession(path.dropFirst().first ?? "main") + case "pick-native": + // 选择器里的「原生终端」。会话可能还没进终端页,故不依赖 route。 + guard let s = manager.sessionAwaitingChoice ?? router.route.sessionID.flatMap(manager.session) + else { return false } + s.useNativeTerminal() + case "resize": + // 验证 resize-pane 链路(拖拽手势本身是标准 DragGesture):`ui/resize/`。 + guard let id = router.route.sessionID, let s = manager.session(id), + let tmux = s.tmuxController, + let win = tmux.windows.first(where: { $0.id == tmux.activeWindowID }), + let pane = win.activePane, + let cols = path.dropFirst().first.flatMap(Int.init) else { return false } + tmux.resizePane(pane, cols: cols) + case "session": + guard let n = path.dropFirst().first.flatMap(Int.init), + let s = manager.session(atShortcutIndex: n - 1) else { return false } + manager.focus(s) + router.enter(s.id) + case "sidebar": + if path.dropFirst().first == "pin" { router.togglePin() } else { router.toggleSidebar() } + default: + return false + } + NSLog("TXUI cmd=\(path.joined(separator: "/")) route=\(router.route) sidebar=\(router.sidebar)") + return true + } + + /// 无头验证强制横屏(`-txLandscape 1`)。 + /// + /// 没有 GUI 时模拟器起来是竖屏,而设计基准是 iPad Pro 13″ 横屏 1366×1024;`simctl` 没有旋转 + /// 能力,故用 iOS 16+ 的 `requestGeometryUpdate` 由 app 侧请求方向,让 `simctl io screenshot` + /// 取到横屏帧。仅 DEBUG,生产路径不碰方向(用户可自由旋转)。 + @MainActor + static func forceLandscapeIfRequested() { + guard UserDefaults.standard.bool(forKey: "txLandscape") else { return } + guard let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene else { return } + scene.requestGeometryUpdate(.iOS(interfaceOrientations: .landscapeRight)) { error in + NSLog("TXUI landscape request failed: \(error.localizedDescription)") + } + } +} +#endif diff --git a/apps/TerminalX/project.yml b/apps/TerminalX/project.yml index dc430db..9256c91 100644 --- a/apps/TerminalX/project.yml +++ b/apps/TerminalX/project.yml @@ -21,6 +21,13 @@ targets: deploymentTarget: "17.0" sources: - path: iOS + # 打包字体(JetBrains Mono 四权重,UIAppFonts 在 TerminalX-Info.plist 声明)。 + # 必须平铺进 bundle 根(UIAppFonts 的值不带目录),故用 group 而非 folder reference。 + # OFL.txt 一并打包(OFL-1.1 要求许可随分发)。 + - path: Resources/Fonts + buildPhase: resources + excludes: + - "README.md" dependencies: - package: GhosttyKit product: GhosttyTerminal @@ -45,5 +52,7 @@ targets: TARGETED_DEVICE_FAMILY: "1,2" INFOPLIST_KEY_UILaunchScreen_Generation: YES INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents: YES - # M0 渲染自检无需签名(仅模拟器);真机/上架在 M4/M5 配置。 - CODE_SIGNING_ALLOWED: NO + # 真机自动签名(公司团队 Nanjing ShenRuan)。tsnet 用户态无需 NE entitlement。 + # 签名后 Keychain(SecItem)/Secure Enclave 可用(未签名会 -34018 回退文件)。 + CODE_SIGN_STYLE: Automatic + DEVELOPMENT_TEAM: J75A6S5938 diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index c950c8e..dce1a31 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -115,4 +115,177 @@ ### 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)。 + +## 11. Hosts / Keychain / Tailscale 三分离管理(2026-07-24 本轮,对标 Termius) + +把连接拆成 **Hosts / Keychain(凭据) / Tailscale 连接** 三者分别管理,iPad 优先。设计阶段咨询 Fable 顾问(Q1–Q5 裁决)。**BUILD SUCCEEDED + 模拟器 idb 截图全流程验证 + TXCore 40 tests 绿。** + +### 11.1 数据模型(app 层新文件) +- `Credential.swift`:`Credential`(凭据 meta,无机密) + `CredentialSecret`(自描述机密)。Kind: password / privateKey(source: pasted/imported/generated, publicKeyBlob) / secureEnclave(占位)。 +- `CredentialStore.swift`:`@MainActor protocol CredentialStore`(list/secret(for:)/upsert/delete,meta 与机密分离) + `FileCredentialStore`(沙盒 `credentials.json`,版本化信封 `{"scheme":"plaintext-v1"}`,0600+`isExcludedFromBackup`+`completeFileProtection`)。**不自造加密**:无根信任下伪安全;签名后换 Keychain 后端即可(协议已抽象)。 +- `TailscaleStore.swift`:`TailscaleConnection`(每个=一个常驻 tsnet 节点;`stateDirName="tsnet-"` 创建即固化终身不变——tsnet 无文件锁,同 Dir 双 Up 损坏状态) + `TailscaleStore`(tailnets.json,无机密;authKey 注册后即弃)。 +- `HostStore.swift`:`SavedHost` 改为 `auth: AuthRef`(.inlinePassword / .credential(id)) + `tailscaleID: UUID?`;自定义 decoder 迁移旧 inline username/password 为 `.inlinePassword`(不造幽灵凭据)。 + +### 11.2 签名映射 `CredentialAuth.swift`(复用既有三认证路径,传输层零改动) +- password → `.password`;生成软件 P-256 → `SigningKeyProvider.from(x963:)` → `.publicKeyCallback`(路径A);粘贴/导入 PEM → `.privateKey`(libssh2 `publickey_frommemory` 路径B)。 +- **关键:`SSHSession.authenticate()` 三分支(.password/.privateKey/.publicKeyCallback)早已全部实现**,本次无需动 TXTransport。 +- `SigningKeyProvider` 加 `from(x963:)`/`generateSoftwareP256()`/`secureEnclaveAvailable()`(非持久探测,签名真机自动点亮 Face ID UI)。openssh-key-v1 mbedTLS 不认 → UI 警告提示 `ssh-keygen -p -m PEM` 转换。 + +### 11.3 多 tsnet 节点 `TsnetRegistry.swift`(替换旧单例 `TsnetManager`) +- 单例 `@unchecked Sendable`+锁,按 connID **懒启动/Up 后常驻/LRU 限 3/in-flight 去重**。Fable 经 tsnet v1.102.0 源码确认一进程多 `tsnet.Server` 官方支持(dial 走具体 node 句柄无全局路由→重叠 100.x 不误连;红线仅 stateDir 唯一)。 +- **坑:`TsnetbridgeNode`/`MoshRelay` 非 Sendable**,不能在 `Task.detached` 捕获或跨 actor 传 → mosh relay/wakeUp 收进 registry 按 connID 操作,model 只留 `activeConnID`(不再持 node)。 + +### 11.4 连接编排(`SSHTerminalModel`) +- `connect(to:)` 解析 AuthRef+tailscaleID → `EgressPlan`(.direct/.tsnet(connID,stateDir,hostname,authKey));`startConnect` 统一构造 config,host key pin 命名空间=`egressPlan.egressKey`(`direct`/`tsnet-`)。删实例级 `tsnetAuthKey`/`TsnetManager` 单例。 +- 无头验证:`-txTsnetKey` 合成临时连接(固定 connID + stateDir `tsnet-main` 复用模拟器身份)→ 启动参数矩阵不回归(已验证直连 SSH 到 127.0.0.1:22 走通 host key 校验+密码认证)。 +- `model.bind(credentials:tailscale:)` 由 ContentView 注入两 Store。 + +### 11.5 UI(`ContentView` + 4 个 View 文件 + `UIComponents.swift`) +- `RootSplitView`=`NavigationSplitView` 侧栏(§连接:主机 / §保险库:Keychain·Known Hosts / §网络:Tailscale / 设置)+详情;iPhone 紧凑自动坍缩。终端改 `.fullScreenCover($model.showsTerminal)` 全屏盖(退出回列表,状态保留)。 +- `HostsView.swift`(列表+HostCard+HostEditorView:认证 segmented[直接输入/从 Keychain 选]+出口 Picker[Direct/各 Tailscale])、`KeychainView.swift`(列表+CredentialEditorView:密码/SSH密钥[生成·粘贴·导入]/Face ID 占位)、`TailscaleView.swift`(列表+编辑器 name/hostname/authKey+注册按钮经 registry Up+SelfIP)、`SettingsView.swift`(主题+关于 + `KnownHostsView` pin 查看/删除)。 + +### 11.6 未验证(需用户实时资源) +真 tsnet 多节点 Up(需 authkey)、密钥/凭据成功登录(需服务器信任 key)、mosh over 新 egress plan。这些在有凭据时按 §8 流程补测。 + +--- + +## 12. Demo → 商业化 UI/UE 改造(2026-07-25 起,按 Claude Design 定稿) + +**设计定稿**(hifi,含精确 token):`docs/design/UI-DESIGN-HANDOFF.md` + `terminalX-iPadOS.dc.html`(13 屏画板)。 +渲染对拍:`python3 docs/design/render-screens.py` 拆屏 → headless Chrome 逐屏截 PNG。规划级裁决咨询 Fable 顾问。 + +**总判断**:差距不在样式而在结构。三处必须先拆的地基——① chrome 从 Catppuccin 派生改为 Nocturne 中性阶 + 恒定 blurple;② `NavigationSplitView`+`fullScreenCover` 改路由 + 会话常驻画布;③ 单会话模型改多会话并存。 + +### 12.1 路线图(6 阶段,UI-1/UI-2 已完成) +| 阶段 | 内容 | 状态 | +|---|---|---| +| UI-1 | 设计令牌地基(Nocturne chrome + blurple + JetBrains Mono) | ✅ 完成 | +| UI-2 | 导航与多会话状态模型(AppRouter/SessionManager/SessionCanvas) | ✅ 完成 | +| UI-3 | 终端主界面:56pt 轨道 + 浮起双胶囊 + 侧边栏三态 | ✅ 完成(pane 缝拖拽待做) | +| UI-4 | 首页会话卡 + 关闭确认 + 分屏菜单 + pane 拖拽 | ✅ 完成(pane 拖拽待真机 tmux 验证) | +| UI-5 | 设置·外观 + 键盘体系(GCKeyboard 检测 + 全套 ⌘ 快捷键) | 待做 | +| UI-6 | 竖屏参数化 + iPhone 首版 | 待做 | + +### 12.2 UI-1 设计令牌(`Theme.swift` 重写) +- 三层分工:`TXAccent`(Nocturne blurple 8 档,**恒定不随主题变**,只做线/点/选中底/文字)· `TXChrome`(chrome 中性阶 14 档,**4 张预生成表**)· `TXFlavor`(Catppuccin flavor,驱动终端 + 状态语义色)。`TXPalette` 是门面,旧取色名(`p.accent`/`p.textPrimary`…)保留为 computed,逐屏重写时再换直通名。 +- chrome 表推导:**Mocha 用设计交付的精确锚点值**;Macchiato/Frappé 按 flavor base 相对 Mocha 的 HSL 差平移(文字阶抬升幅度收一半,保对比度);Latte 是唯一浅色,整体反转手工写(仍不用纯黑纯白)。切 flavor → chrome 变阶、accent 不变。 +- JetBrains Mono 四权重 vendor 进 `apps/TerminalX/Resources/Fonts/`(附 OFL.txt,OFL-1.1 要求随分发)。**必须平铺 bundle 根**(`UIAppFonts` 值不带目录)→ project.yml 用 group 而非 folder reference。`TX.Font.mono` 按 weight 映射 PostScript 名,缺失时 SwiftUI 自动回退。 + +### 12.3 UI-2 导航与多会话(新 `AppRouter.swift`/`SessionManager.swift`,`SSHTerminalModel`→`TerminalSession`) +- `AppRoute = .home | .terminal(UUID)`;`SidebarState = .collapsed|.overlay|.pinned`(overlay 不占位、不 resize;pinned 钉住各 resize 一次)。 +- `TerminalSession` 加身份:`id`/`host`/`isLive`(原 showsTerminal,**不再驱动导航**)/`isMinimized`/`lastActiveAt`/`link`(LinkState 五态,由 phase+egress 推导)/`displayName`/`initials`/`isTmuxSession`。**TXCore、传输层零改动**(50 tests 保持绿)。 +- `SessionManager`:多实例持有、`open(host:)` 复用同主机会话、`focus`/`minimize`/`close`、前后台广播、⌘1…9 索引、首页排序(最小化置顶 + 最近活跃)。 +- `ContentView` → `RootView(ZStack{SessionCanvas, HomeShell})`。**SessionCanvas 常驻挂载所有会话**(详见 CLAUDE.md 坑:摘除即丢内容 + matchedGeometry 要求同层级)。host key alert 下移到 `TerminalScreen`(每会话一份,后台会话也能弹安全告警)。 +- 无头验证脚手架(DEBUG):`UIPreviewFixture` 假会话 + `terminalx://ui/*` 导航驱动 + `scripts/tx-shot.sh` 截图定向。**坑**:假会话必须 `isSyntheticFixture=true` 从生命周期广播里排除,否则 scenePhase→active 广播经 `syncUI()` 把注入状态覆盖回 idle。 +- **验收**(四步截图取证,全通过):A 终端会话1 → B `ui/minimize` 回首页(3 会话并存 + mac-studio 标「最小化中」置顶)→ C `ui/session/2` 切会话2(独立内容 + tsnet 徽标)→ D `ui/session/1` 回会话1 **内容原样**。 + +### 12.3b UI-3/UI-4 两屏还原(新文件 `TXParts` / `RailSidebar` / `TerminalChrome` / `HomeView`) +- **`TXParts.swift`** — 设计稿反复出现的小件:`TXStatusDot`(8pt + 2pt 挖底描边) · `TXInitialsAvatar`(26/38pt 两字缩写 + 右上状态点) · `TXSectionLabel`(600 10px ls .09em uppercase) · `TXBadge` · `TXChip` · `TXSearchField`(32pt) · `TXButton` · `TXIconButton`(视觉 34、命中 44) · **`TXTerminalMirror`**(终端文本镜像,底对齐)。 +- **`RailSidebar.swift`** — `RailView`(56pt/竖屏 48pt 轨道:logo 不可点 → 搜索 → 首页 → 主机头像+状态点 → 虚线+ → TMUX 段 window 数字 → 状态点 → »展开 → 齿轮) + `SidebarPanel`(288pt,遮罩/Pin 共用同一套内容) + `SidebarSessionRow`/`SidebarWindowRow`。**开合语义唯一**:logo 不可点,折叠态»展开在轨道底、展开态«收起在标题右,Pin 是独立按钮只在展开态出现。 +- **`TerminalChrome.swift`** — 浮起胶囊(`.ultraThinMaterial` + 半透底 + 描边 + 圆角 11):左上标题胶囊(红黄点/竖分隔/状态点/主机名/window title)+ 右上三格状态胶囊(`tsnet | mosh | tmux`,**只染出问题的那层**)+ 底部 34pt 提示条(文案随侧边栏三态切换)。 +- **`HomeView.swift`** — 页头(34px 标题 + 副行 + 220pt 搜索 + 新建连接 + 齿轮)→ 活动会话 3 列 `SessionCard`(卡头状态/主机名/tmux/链路 · 文本镜像 · 卡脚 windows·panes·上次活跃 + ⌘n + 回到会话)→ 全部主机 4 列 `HostTile` + 筛选 chips + 添加卡。重连/离线卡:镜像 `blur(1.5)` + `rgba(24,26,40,.72)` 遮罩 + 转圈 + 「屏幕内容已缓存,恢复后原样接回」。 +- 舞台:pane 区顶部留 `TX.Layout.capsuleInset`(86pt) 预留带 + `clipped()` 防溢出;pane 之间 2pt 缝(各让 1pt);`TmuxStage` 去掉 tab 条(window 切换移到轨道/侧边栏),保留「全 window 常驻挂载 + opacity」与容器几何单一来源。 +- **缩略卡的「真实回滚」**:`TerminalSession.captureSnapshot()` 用 `readViewportText()` 抓尾部 32 行(剔尾部空行),首页可见时每 1.5s 轮询 + 最小化前抓一次。**没有第二个 surface**。 +- fixture 扩展:`UIPreviewFixture.hosts`(7 台 + 分组/os)、`tailnets`、每条会话的 `TmuxSummary` 注入;`HostStore.injectFixtureHosts`/`TailscaleStore.injectFixtureConnections` 只进内存**不写盘**。 +- **验收**:四态截图对拍设计稿 —— 折叠轨道(08) · 遮罩态(00) · Pin 态(01,终端真的变窄) · 首页(09)。TXCore 50 tests 保持绿。 + +### 12.3c 像素级走查修正(用户走查 + 逐屏对拍,2026-07-25 二轮) +走查方法(**后续每屏都照这个来**):`scripts/tx-shot.sh` 截图 → `python3` 把设计稿 PNG 与截图统一缩放到 1366×1024 → 裁同一区域上下/左右并排 → 再对元素列做**垂直投影扫描**(逐行找与底色不同的段),把元素 y 坐标量化对比。目测靠不住(缩略图里 `#1E1E2E` 与 `#11121C` 分不出)。 + +修正项: +1. **沉浸不彻底(最严重)**:原来把 86pt 预留带做成"pane 容器整体下移",胶囊浮在舞台底色上。改为 **pane 容器从舞台顶部就开始**(终端底色 + 边框铺满),只把 surface 内容 `padding(.top, 86)` —— 预留带铺的是 `flavor.terminalBg`,故与终端内容视觉连续,胶囊真正"悬浮在终端之上"。像素采样验证:预留带与内容区同为 `#1E1E2E`。 +2. **终端页隐藏系统状态栏**(设计稿 `1b`/`3a` 顶部就是胶囊/logo,`1e`/`1g` 才有状态栏)+ `ignoresSafeArea(edges: .top)`。**坑**:`statusBarHidden` 不能挂在 `TerminalScreen` 上——它常驻挂载(SessionCanvas),会连首页一起隐藏;要挂在 `RootView` 上按 `router.route` 判断。 +3. **侧边栏动画**:三态切换统一走 `AppRouter.sidebarAnimation`(`.easeOut(0.24)`,设计要求 200–260ms),面板 `.move(edge: .leading)` 滑入 + 遮罩 `.opacity` 淡入。 +4. **底部两区同高**:侧边栏脚与舞台提示条共用 `TX.Layout.footHeight`(34pt),故两侧分割线落在同一 y;侧边栏脚原来的深色块 + 齿轮**去掉**,改 1pt `divider`(设计稿如此)。 +5. 轨道结构:**搜索/首页/设置是裸图标**(无底无框),只有 logo 与展开键有框(`TXIconButton(bare:)`);纵向节奏按扫描到的设计坐标写死(logo 28 · 头像 174/220/266 · TMUX 标签 388 · 数字 408 起);TMUX 段标签用 accent-500 而非中性灰。 +6. 侧边栏面板:logo 30pt、会话行 48pt、window 行 44pt(实测值);分组分隔线改**渐隐** LinearGradient;占位分支补回漏掉的「新建 window ⌘T」行。对齐后与设计稿偏差 ≤9pt(window 区完全重合)。 +7. 缩略卡:卡头/卡脚分隔线用 1pt `Rectangle`(`Divider().overlay()` 压不住默认色);行距 `fontSize×0.6`(设计 11px/1.5);**启发式着色** —— `readViewportText()` 只给纯文本,故按行首符号/关键字上色(`✓`绿 `error`红 `◇▸`青 `[..]`灰 提示符行亮),逼近真实终端密度。 + +### 12.3d UI-4 交互闭环(关闭确认 / 分屏菜单 / pane 拖拽) +- **`TmuxController` 新增命令**(纯追加,未动既有链路):`detach()`(`detach-client`) · `killSession()` · `splitWindow(vertical:joinSession:)`(`split-window` / 跨会话用 `join-pane -s`,**不嵌套 tmux**)· `splitIntoNewSession()`(`new-session -d` + `join-pane`)· `resizePane(cols:rows:)` · `listSessions()`(新 `PendingKind.listSessions`,结果进 `@Published sessions: [TmuxSessionInfo]`)。 +- **`AppModal` 提到 `AppRouter`**(不是视图 `@State`):⌘W/⌘D 快捷键与无头 URL 驱动都要能打开浮层,视图内部状态触达不到。 +- **关闭确认 `CloseConfirmDialog`**(3c,436/404pt):tmux → 推荐「仅断开 ⏎」(`detach-client`,服务端继续跑) + 次要红字「结束会话」(`kill-session`,**再点一次才执行**);原生窗口 → 琥珀警告 + 危险按钮(设计精确色 `#8d5560` / `rgba(92,58,63,.35)` / `#f5a0ac`)。`closeSession(kill:)` 先发 tmux 命令、延迟 250ms 再 teardown 传输层(让 `%exit` 走既有 `onExit`)。 +- **分屏菜单 `SplitMenuView`**(3b,360pt):**不问方向**(⌘D/⌘⇧D 本就是两个入口),只列「分到哪里」——当前会话(选中底 + 「当前」)· detached 会话(绿点,因为进程仍在服务端跑 + 上次活跃)· 新建 tmux 会话。前导列统一 20pt 使各行文字左缘一致。**原生窗口项灰显标「二期」**(需要一台主机多条 SSH channel,见 §12.4),不装作能用。 +- **pane 拖拽 `PaneSplitter`**:从布局树内部节点算出相邻子节点边界 → 热区 12pt + 把手 44×3pt。**拖动中只动视觉**(把手位移 + 转强调色),不改 pane frame、不发命令;**松手才发一次 `resize-pane`**,等 `%layout-change` 回来更新真实 frame(与"路线 b"一致:客户端不自行重排)。 +- **最小化过渡**:离开终端时 `opacity + scaleEffect(0.97)`(240ms easeOut)。用 `scaleEffect` 而非 frame 动画 —— 前者只是渲染变换**不触发 PTY resize**,后者会让 tmux 反复重排。完整 matchedGeometry 缩回卡片未做(fable 裁决:不对 `TerminalSurfaceView` 本体做 matchedGeometry)。 +- **验收**:三个浮层逐个对拍设计稿(3c 两套文案 + 3b 菜单);最小化往返内容原样、日志无 crash;TXCore 50 tests 绿。**pane 拖拽只做了静态编译与逻辑走查,没有真实 tmux 多 pane 会话验证过**(fixture 没有 controller)——有测试主机时必须补。 + +### 12.3e 接真实数据 + 空状态(UI-5 之前的必要一步) +之前几轮都靠 `-txUIFixture` 假会话对拍设计稿,真实路径下有数据缺口和裸露的空白屏。本轮补齐: + +**真实数据打通** +- **tmux 会话名**:kickoff 时顺带 `listSessions()`,`currentSessionName` = list-sessions 里 attached 的那个 → 状态胶囊/侧边栏标题显示真实 `tmux <会话名>`(拿不到就只显示 `tmux`,不编造)。 +- **cwd**:`list-windows` format 追加 `#{pane_current_path}` → `TmuxWindow.cwd`;标题胶囊显示「window title · ~/src/x」,侧边栏 window 行副行显示「cwd · n panes」。`abbreviate()` 把 `/home/u/x`、`/Users/u/x` 收成 `~/x`。 +- **主机的分组 / 系统**:`HostEditorView` 新增「归类」区(分组输入 + 已有分组一键选 chips + 系统描述)。此前 `SavedHost.group/os` 只有 fixture 在写,真实主机永远没有分组 → 首页 chips 恒只有「全部」。 +- IP 主机名的缩写:`10.255.255.1` 按字母规则会得到无意义的 `11` → 纯 IP 取首段前两位(`10`)。 + +**空状态(真实路径必经)** +- 首页首次运行(0 主机 0 会话):终端图标 + 「还没有主机」+ 能力说明 + 「新建连接 / 先配置 Tailscale」。 +- 有主机无会话:虚线提示条「还没有在跑的会话 —— 点下面任意主机即可连接」。 +- 搜索/筛选无命中:说明是分组还是搜索造成的 + 「清除筛选」。 +- 缩略卡无输出:「等待输出…」占位(真实会话刚连上时 `readViewportText()` 还是空的)。 +- 侧边栏无会话:「没有在跑的会话」。 +- **终端区 `TerminalStateOverlay`**:连接中(转圈 + 分阶段文案 + **已等待 Ns** + ≥5s 后出「取消连接」)/ 连接失败(错误详情 + 返回首页 + 重试)/ 已关闭(重新连接)。已连接与重连中**不渲染此层**,不遮挡终端内容。 +- 路由兜底:`route` 指向的会话已被关闭 → 自动回首页,不停在空画布上黑屏。 + +**传输层修一处真实可用性问题(`TXTransport`)** +- `SSHSession.connect()` 原来用裸 `Darwin.connect`,**阻塞到系统 TCP 超时(75s+)**,连不上的主机会让用户对着「连接中」干等。改为 `connectWithTimeout()`:置非阻塞 → connect → `poll(POLLOUT)` 等超时 → **查 `SO_ERROR`**(可写≠成功,拒绝连接也会变可写)→ 恢复阻塞(libssh2 用阻塞模式)。超时值走新增的 `SSHConfig.connectTimeoutSec`(默认 12s)。tsnet 路径(`preconnectedFD`)不经此处,不受影响。 +- 验证:不可达地址 12s 后报「失败:socket("连接失败(12s 超时)…")」,失败态覆盖层给出重试/返回。 + +### 12.3f tmux 自动进入(真机反馈修复:连上永远显示「无 tmux」) +**根因**:全代码库没有任何地方发起 `tmux -CC attach`。`TmuxRouter` 只被动检测传输字节流里的 DCS 前导,之前所有 tmux 端到端验证都靠 `-txAutoCommand "tmux -CC …"` 手动触发 → 真实点击连接只有 raw shell,状态胶囊第三格恒为「无 tmux · 客户端窗口」。不是检测坏了,是从来没人发起。 + +**修复** +- `SavedHost` 加 `useTmux: Bool = true`(旧数据迁移也默认 true)+ `tmuxSession: String?`(默认 `main`);主机编辑器新增「会话」区(tmux 开关 + 会话名 + mosh 开关,带说明)。 +- `TerminalSession.scheduleTmuxBootstrap()`:连接建立后(mosh 模式则等 mosh 接管后)延迟 **2.5s** 等 shell 提示符 → 发 `tmux -CC new -A -s <名>`。`-A` = 同名会话存在就接回(断线原样恢复的关键)。 +- **探测降级**:4s 内没进 control mode → `tmuxUnavailable = true` → 终端顶部行内横幅(设计 2c:说明「关掉 App 后服务端不会继续跑」+「安装并启用 tmux」+ 可关闭)。装完自动重试一次。 +- `exitTmux()` 重置引导标记 → 重连后重新 attach。 +- 无头参数:`-txTmux 0|1`、`-txTmuxSession <名>`。 + +**真机端到端验证通过**(192.168.99.83,macOS + fish + tmux 3.7b)。过程中查出 3 个真实 bug: + +1. **tab 分隔符被 PTY 吃掉**:`list-windows -F "…\t…"` 的 tab 经 PTY 到 tmux 后变成**下划线**,整行 `components(separatedBy:)` 分不开(`count=1`)→ title/cwd/会话名全丢、UI 显示兜底名「窗口 N」。改用 `TmuxController.fieldSep = "|:|"`。**排查坑**:`os_log` 把控制字符显示成 `_`,光看日志无法区分「真下划线」与「被显示替换的 tab」,我因此先误判为 os_log 显示问题——最后靠打印 `parts.count` 才确诊。 +2. **UI 不刷新**(正是 CLAUDE.md 里「嵌套 ObservableObject 不冒泡」那条坑的另一面):`tmuxSummary`/window 列表是读 controller 的 computed property,而轨道/胶囊/侧边栏只订阅 session → 数据解析对了 UI 也不动。修法:`TerminalSession` 用 Combine 订阅 `controller.objectWillChange` 并转发自己的 `objectWillChange`(一处解决所有读 controller 的视图)。 +3. **会话名猜错**:`currentSessionName` 原来取 `list-sessions` 里 `session_attached==1` 的那个,结果把用户自己终端 attach 的 `demo` 当成了当前会话。改用 `display-message -p "#{session_name}"`(权威:本客户端所在会话)。分屏菜单的「当前」判定也随之改为按会话名比较。 +4. 顺带修正:轨道/侧边栏的 window 编号用 `#{window_index}`(用户可见的 0/1/2…)而非内部 `window_id`(@2/@3…)。 + +**验证结果**(服务器侧交叉验证): +- tmux 自动进入 ✓ 状态胶囊 `direct | ssh | tmux main`、标题胶囊 `fish · ~ 163×50`、轨道 window `0/1/2/3/4`(与服务器 `idx=0..4 active=@3/idx=1` 完全吻合) +- **⌘D 分屏 ✓**:`split-window -h` 后服务器 `list-panes` 从 `%7 164x50` 变为 `%7 82x50 + %8 81x50`;app 端双 pane 左右并排渲染、2pt 缝、拖拽把手、活动 pane blurple 边框 +- **`resize-pane` ✓**:`ui/resize/110` 后服务器 pane 宽度变为 `110`(另一侧自动收缩到 53)——拖拽条的命令链路已验证(手势本身是标准 `DragGesture`) +- **会话服务端持久 ✓**:app 退出后 `tmux ls` 里测试会话仍在(验证完已 kill 清理) +- 新增无头验证命令:`ui/split-now/v|h`(跳过菜单直接分屏)、`ui/resize/` + +### 12.3g 连接后的会话选择器(用户定稿交互,替换掉「自动 attach 固定会话」) +**为什么改**:之前实现是连上就 `tmux -CC new -A -s main` —— 替用户决定了 attach 哪个会话。真实运维里服务器上常有多个会话各跑不同任务,必须让用户挑。 + +**新流程**:SSH 连上 → 普通 shell 里跑探测 → 弹选择器 sheet → 按选择 attach。**没装 tmux 时不弹框**(直接原生终端 + 安装引导横幅,不打扰)。 + +- **`TXCore/Tmux/TmuxSessionProbe.swift`**(纯逻辑 + 5 个单测):流式扫描 `BEGIN…END` 标记块,产出 `.available(version:sessions:)` / `.unavailable`。三个坑都在注释里: + 1. **PTY 回显**:命令本身会被回显,标记若字面出现在命令里,回显行会被当成结果起点 → 写成相邻字符串拼接 `"__TX""_SESSIONS_BEGIN"`(shell 输出完整标记,回显的是拼接前的样子)。 + 2. **三态要分开**:「没装 tmux」和「装了但没会话」的 stderr 都被 `2>/dev/null` 吞掉、标记之间同样是空的,只能靠 `tmux -V` 的版本行区分 —— 两者的正确 UI 完全不同(安装引导 vs 让用户新建)。 + 3. **别脏屏**:探测命令与输出会打在用户屏幕上 → 命令末尾接 `printf '\033[H\033[2J\033[3J'`(归位+清屏+清 scrollback,不依赖 terminfo)。刚连上时清掉的只有 motd/shell 欢迎语,首屏反而更干净。 +- **`OutputTap`**:raw 字节的旁路(同时喂终端渲染与探测扫描器)。抽 `RawOutputSink` 协议让 `TmuxRouter` 能接受 gate 或 tap —— **不动 `OutputGate` 的缓冲/flush 语义**(那是修白屏的关键)。 +- **`SessionPickerSheet.swift`**(按用户给的设计图):分段控件 `>_ Tmux` | `Herdr`(置灰) | `最近`(置灰) + 右侧「跳过 ▷|」;会话行 = 大字会话名 + 「活跃」徽标(被别的客户端占用时另标「已被接入」)+ 副行「N 个窗口 · M 分钟前」;底部「+ 新建 tmux 会话…」(点开就地输入名字)。`.presentationDetents([.medium, .large])`。 +- `TerminalSession`:`pendingSessionChoice` / `attachTmuxSession` / `createTmuxSession` / `skipTmux`(`tmuxSkipped` 让状态胶囊显示「原生窗口 · 不经 tmux」而不是谎报「无 tmux」);探测 6s 超时兜底按无 tmux 处理。 +- 无头命令:`ui/pick/<会话名>` · `ui/pick-new/<名>` · `ui/pick-skip`。 + +**时机修正(用户第二轮反馈)**:选择器属于**连接流程的一部分** —— 点主机后**留在首页**探测、在首页弹选择器、选完才进终端页;不是先把用户丢进空终端再弹框。 +- `SessionManager`:`open(host:)` 不再立刻 `router.enter`;改为 `awaitingEnter` 集合 + `@Published enterRequest`(视图层消费后进终端)。`evaluateEnter` 的就绪条件:不等选择 ∧ 不在探测 ∧(已建立 / 无 tmux / 选了原生 / 失败)。失败也进终端 —— 那里有「重试 / 返回首页」,比留在首页只有一行错误更有出路。 +- **坑**:`isProbingTmux` 必须在 `.connected` 那一刻就置位。否则「连接建立」与「探测开始(延迟 2.5s)」之间有空窗,`evaluateEnter` 看到 `isLive == true` 就把人送进终端页 —— 第一次改完就是这个症状(选择器弹在终端页上)。 +- `SessionManager` 也要转发每条会话的 `objectWillChange`(同 `TerminalSession` 转发 controller 那条坑),否则首页/选择器不会因会话内部变化刷新。 +- 首页新增 `ConnectingRow`(正在连接 / 等待选择 / 失败+重试+取消);`liveSessions` 排除正在连接的会话,避免同一会话既有进度行又有卡片(且卡片里的 tmux 信息此刻还没定,会先显示成「原生窗口」再跳变)。 +- 无头验证真实路径:`-txOpenAsHost 1`(把启动参数造成一台内存主机后走 `open(host:)`,即探测→选择→进终端;与老的 `-txHost` 直连路径互斥)。 + +**措辞修正**:右上不再是「跳过」(读起来像"跳过某个步骤"、含义模糊),改为**「原生终端」**+ terminal 图标 —— 它是与 tmux 并列的一种连接方式。`skipTmux()` → `useNativeTerminal()`,状态胶囊第三格显示「原生窗口 · 不经 tmux」。首版只有 Tmux 与原生两种,所以不做别的出口。 + +**真机验证(192.168.99.83)三条出路全过**: +- 探测 → `probe: tmux 3.7b sessions=["demo", "main"]`,选择器如设计图渲染,终端首屏干净(清屏生效) +- **attach 已有** → `attach -t demo` → `currentSession=demo` +- **新建** → `new -A -s uitest` → `currentSession=uitest`,服务器 `tmux ls` 出现 `uitest: 1 windows (attached)`(验完已 kill) +- **原生终端** → 状态胶囊 `direct | ssh | 原生窗口 · 不经 tmux` +- **完整真实路径**(`-txOpenAsHost`)→ 首页「正在连接 192.168.99.83」→ 首页上弹选择器(标题「连接到 …」)→ 选 `main` → 进终端页 `tmux main` + 轨道 window `0/1/2/3/4` + +### 12.4 首版明确推迟(Fable 裁决,勿在 UI-3~6 里偷偷做) +`mosh ` 实时数字(需改 vendor/mosh iosclient 导出 SRTT,属传输层)· ⌘K 命令面板 · 无 tmux 主机的多客户端窗口(保留横幅引导)· iPad 竖屏 pane 强制上下堆叠(会推翻「路线 b」,交给 tmux relayout)· 缩略卡 ANSI 彩色(用 `readViewportText()` 文本镜像,**绝不做离屏 Metal/双 surface**——IOSurfaceLayer 泄漏史)· 设置页连字/CJK 开关(ghostty 无运行时 mutation,灰显占位)。 diff --git a/docs/design/UI-DESIGN-HANDOFF.md b/docs/design/UI-DESIGN-HANDOFF.md new file mode 100644 index 0000000..efbfeb4 --- /dev/null +++ b/docs/design/UI-DESIGN-HANDOFF.md @@ -0,0 +1,241 @@ +# Handoff: terminalX — iPadOS 终端 App(沉浸轨道方案) + +## Overview + +terminalX 是面向 iPadOS / iOS 的原生终端运维工具,解决 iPad 远程运维 macOS / Linux 的痛点。技术选型:**libghostty**(终端引擎)· **mosh**(抗断线传输)· **Tailscale tsnet**(用户态入网)· **tmux control mode**(会话复用)。平台优先级 **iPadOS > iOS > macOS(Apple Silicon)**。 + +本包描述的是**终端主界面(沉浸轨道)+ 首页(主机管理)+ 设置**这一套已定稿的 UI,含侧边栏三态、分屏模型、关闭/最小化语义、竖屏与 iPhone 降级规则。 + +## About the Design Files + +`terminalX iPadOS.dc.html` 是**设计稿**,不是生产代码。它用 HTML/CSS 表达最终视觉与交互意图(1:1 像素尺寸),目的是让你在**目标工程里用原生技术重建**——本项目的目标环境是 **SwiftUI(iPadOS 26 / iOS 26)**,请使用工程已有的架构与组件模式实现,不要移植 HTML/CSS。 + +打开方式:浏览器直接打开该文件即可,画板可自由缩放平移。每块屏幕都带 `data-screen-label`,便于对照本文档。 + +## Fidelity + +**High-fidelity(hifi)**。颜色、字号、圆角、间距、行高均为最终值,可按本文档的精确数值实现。文案(简体中文,技术术语保留英文)也是最终稿。 + +唯一例外:终端内容区的命令输出是**示意数据**,用于验证排版密度与配色,不必照搬。 + +--- + +## Design Tokens + +### 颜色 — App chrome(Nocturne 设计系统) + +| 用途 | Hex | Nocturne token | +| --- | --- | --- | +| 画布底 / 屏幕底 | `#161826` | `--color-bg` | +| 侧边栏 / 轨道 / 卡片底 | `#1b1d2b` | surface-dark | +| 控件底(输入框、次要按钮、徽标) | `#232532` | `--color-surface` | +| 舞台底(pane 之间的缝) | `#11121c` | bg-deep | +| 分隔线(弱) | `#2c2f3d` | neutral-800 | +| 描边(常规) | `#3f424d` | neutral-700 | +| 描边(强调 / 对话框) | `#595d6c`、`#4a4d5e` | neutral-600 | +| 强调色(唯一) | `#9184d9` | `--color-accent` | +| 强调 - 边框 | `#796cbf` | accent-600 | +| 强调 - 选中底 | `#2b2741` | accent-900 | +| 强调 - 选中描边 | `#423a6a` | accent-800 | +| 强调 - 文字 | `#b5abfc` / `#d2cefd` | accent-400 / -300 | +| 强调 - 弱化文字 | `#5d5294`、`#968ae0` | accent-700 / -500 | +| 主文字 | `#e9e9ed` | `--color-text` | +| 次文字 | `#cfd3e5` / `#b2b6ca` | neutral-300 / -400 | +| 三级文字 | `#9397ab` | neutral-500 | +| 弱文字 / 占位 | `#75798c` | neutral-600 | +| 极弱(提示、注脚) | `#4a4d5e` | neutral-700 | + +**规则**:强调色只做线、点、选中底和文字,**绝不大面积铺色**;不使用纯黑纯白。 + +### 颜色 — 终端内容区(Catppuccin Mocha,libghostty 渲染) + +`base #1e1e2e`(pane 底)· `mantle #181825` · `surface0 #313244`(vim 状态栏)· `text #cdd6f4` · `subtext #a6adc8` · `overlay #6c7086`(次要输出)· `surface2 #585b70`(禁用/缓存态) +语义色:`green #a6e3a1`(成功、提示符)· `red #f38ba8`(error)· `yellow #f9e2af`(warning、git 标记)· `blue #89b4fa`(路径、字段)· `mauve #cba6f7`(关键字)· `teal #94e2d5`(勾选)· `peach #fab387`(重连中)· `pink #f5c2e7` + +设置页可切换 Mocha / Macchiato / Frappé / Latte,**chrome 的中性阶随之推导,强调色恒为 Nocturne blurple `#9184d9`**。 + +macOS 窗口按钮沿用系统色:关闭 `#ff5f57`,最小化 `#febc2e`(13pt 圆点,`inset 0 0 0 .5px rgba(0,0,0,.25)`,仅悬停/触摸时显出符号)。 + +### 字体 + +全局 **JetBrains Mono**(400 / 500 / 600 / 700),chrome 与终端同源。中文走系统回退(PingFang SC)。 + +| 场景 | 规格 | +| --- | --- | +| 屏幕主标题(首页 terminalX) | 500 34px, letter-spacing -.025em | +| 页面标题(设置、总览) | 500 24–28px, ls -.02em | +| 分区标题 | 500 22px, ls -.02em | +| 分组小标题(活动会话 / TMUX) | 600 10px, ls .09em, uppercase, `#75798c` | +| 列表主行 | 400–500 13px | +| 列表副行 / 元信息 | 400 10.5–11px, `#75798c` | +| 徽标 / 胶囊 | 500 11.5–12px | +| 终端正文(主 pane) | 400 13.5px / 1.55 | +| 终端正文(次 pane / 缩略) | 400 11–12px / 1.5 | +| 终端 pane 头 | 500 11px, `#6c7086` | +| 底部提示条 | 400 11.5px, `#75798c` | + +### 间距 / 圆角 / 阴影 + +- 间距:2 / 6 / 8 / 9 / 10 / 12 / 14 / 16 / 18 / 20 / 26 / 34 / 40(px) +- 圆角:徽标 5–7 · 控件 8–9 · 卡片 11–13 · 对话框 14–15 · 屏幕卡 18 · 手机卡 38 +- 阴影:浮起胶囊 `0 10px 30px rgba(0,0,0,.55)`;侧边栏浮层 `24px 0 60px rgba(0,0,0,.55)`;对话框 `0 30px 80px rgba(0,0,0,.7)`;卡片 `0 6px 18px rgba(0,0,0,.4)` +- 遮罩:`rgba(10,11,18,.42–.5)`,浮层用 `backdrop-filter: blur(20px)` + +### 图标 + +**Phosphor Icons**(regular)。用到:`magnifying-glass` `house` `plus` `gear-six` `caret-double-left` `caret-double-right` `caret-down` `caret-left` `push-pin` `columns` `terminal-window` `hard-drives` `desktop-tower` `cpu` `key` `shield-check` `lightning` `paint-brush` `text-aa` `keyboard` `export` `info` `warning` `x` `minus` `arrow-right` `arrow-left` `lightbulb` `moon` `check-circle` `wifi-high` `battery-high` `browsers` `rows` `git-branch` `plugs` `trash` `arrows-in-simple` `textbox` `sidebar-simple` `globe-hemisphere-west`。 + +SwiftUI 实现请换成等义 **SF Symbols**(例如 `house` / `magnifying-glass` → `house`、`magnifyingglass`;`caret-double-left` → `chevron.compact.left` 或 `sidebar.left`;`push-pin` → `pin`)。 + +--- + +## Layout 基准 + +- 画板尺寸:iPad Pro 13″ 横屏 **1366×1024pt**;竖屏 **1024×1366pt**;iPhone **393×852pt** +- 折叠轨道宽 **56pt**(竖屏 48pt),元素中心线统一在 28pt(竖屏 24pt) +- 展开侧边栏宽 **288pt** +- pane 之间的缝 **2pt**(拖拽热区 **12pt**,把手 44×3pt 圆角 2pt) +- 触控命中区不低于 **44pt**(键盘附加行按键高 38–40pt + 6pt 间距 = 44pt 节距) +- 终端主 pane 顶部预留 **86pt** 给浮起胶囊(2c 因多一条横幅为 122pt);预留带用 padding-top 实现,内容容器 `min-height:0; overflow:hidden` 防止向上溢出 +- Home indicator 条:200×4pt(iPhone 140×5pt) + +--- + +## Screens / Views + +### 1) 终端主界面 · 沉浸轨道 — `1b 终端-沉浸轨道` + +**Purpose**:真正干活的界面,终端面积最大化。 + +**Layout**:`HStack`:左 56pt 轨道 + 右舞台。舞台是 `VStack`:pane 区(flex)+ 硬件键盘提示条 34pt + home 区 14pt。pane 区 padding 2pt,上下两个 pane 比例 1.7 : 1,中间 12pt 拖拽条。 + +**轨道自上而下**:Logo 方块(34×34,圆角 11,底 `#232532`,inset 描边 `#3f424d`,字形 `›` 700 16px `#b5abfc`,**不可点**)→ 搜索图标 → 首页图标 → 1pt 分隔 → 主机头像(38×38 圆角 11,两字缩写;选中底 `#2b2741` + inset 描边 `#5d5294`;右上角 8pt 状态点,`box-shadow: 0 0 0 2px #1b1d2b` 挖底)→ 虚线 + 号 → 分隔 → `TMUX` 标签(600 9px ls .1em)+ window 数字(34×30,选中底 `#232532` + inset 描边 `#4a4d5e`)→ `Spacer` → 连接状态点 8pt → 展开键(`caret-double-right`,34×34 底 `#232532` 描边 `#3f424d`)→ 设置齿轮。 + +**浮起 chrome**(`position: absolute`,`backdrop-filter: blur(20px)`,底 `rgba(27,29,43,.86–.9)`,描边 `#3f424d`,圆角 11): +- 左上标题胶囊 `top 14, left 16`:红/黄圆点 → 1pt 竖分隔 → 6pt 绿点 → 主机名(500 12.5px)→ 「当前 window title · cwd」(400 11.5px `#75798c`) +- 右上状态胶囊 `top 14, right 16`:三格分层徽标 `tsnet` | `mosh 41ms` | `tmux dev`,格间 1×16pt 竖线 + +**底部提示条**:接硬件键盘时只留 34pt 提示条(快捷键清单);触控时替换为键盘附加行(两行,见 iPhone / 竖屏)。 + +### 2) 侧边栏三态 — `3a 侧边栏-遮罩态`、`3a 侧边栏-Pin 态` + +同一条栏的三个状态,**元素不增不减**,只是展示密度不同: + +| 状态 | 宽度 | 行为 | +| --- | --- | --- | +| 折叠 | 56pt | 只显图标与两字缩写 | +| 遮罩(默认展开) | 288pt | 面板**盖住**轨道(`left: 0`,不并排,避免两套入口),右侧终端压 `rgba(10,11,18,.5)` 遮罩,**点终端任意处收起**;终端不 reflow,不触发 PTY resize | +| Pin | 288pt | 点面板右上 `push-pin` 后钉住:遮罩消失,终端变窄,**只在钉住/解除时各 resize 一次**;再点解除回遮罩态 | + +**开合语义(务必唯一、不歧义)**:`›` 方块是 App logo,不可点。开合只有一枚按钮,两态方向相反——折叠态在轨道底部 `caret-double-right`(展开),展开态在标题右侧 `caret-double-left`(收起)。Pin 是独立按钮,只在展开态出现。快捷键 `⌘\` 开合。 + +**面板内容自上而下**:Logo + `terminalX`(500 14px)+ Pin + 收起 → 搜索框(32pt 高,圆角 8)→ **首页**行(`house` + 「首页」+ `⌘⇧H`)→ 分组「活动会话 n」→ 会话行 → 1pt 渐隐分隔 → 分组「TMUX · <主机> · <会话>」→ window 行 + 「新建 window ⌘T」→ `Spacer` → Tailscale 状态脚(绿点 + tailnet + 设备 IP + 齿轮)。 + +**行规格(左缘必须对齐)**:会话行 = 26pt 头像列 + 10pt gap;window 行 = 26×22pt 徽标列 + 10pt gap;两者标题左缘同为 **x=53**。选中态用 `box-shadow: inset 0 0 0 1px #423a6a` 而非 `border`,避免选中时内容位移 1pt。 + +### 3) 首页 · 主机管理(落地页)— `1e 主机-会话卡` + +**Purpose**:App 冷启动落点;「回到刚才那个会话」是首要动作。 + +**Layout**:状态栏 24pt → 页头(terminalX 500 34px + 副行)+ 右侧搜索 220pt / 新建连接 / 设置 → 分组「活动会话」→ **3 列会话缩略卡(flex:1)** → 分组「全部主机」+ 筛选 chips → **4 列主机卡(按内容高度)** → home 区。左右留白 40pt。 + +**会话缩略卡**(圆角 13,选中卡描边 `#423a6a` + `0 6px 18px rgba(0,0,0,.4)`):卡头 36pt(状态点 + 主机名 + tmux 会话 + RTT)→ 终端缩略(`#1e1e2e`,11px/1.5,**内容底对齐**,真实回滚)→ 卡脚 38pt(「n windows · 上次活跃」+ `⌘1/2/3` + 主动作「回到会话 →」)。离线卡加 `rgba(24,26,40,.72)` + `blur(1.5px)` 遮罩,内含 26pt 转圈 + 两行说明;**遮罩必须是 pane 的最后一个子节点**(pane 为 `position: relative`)。 + +**最小化回来的卡**:置顶并标「最小化中」,转场是从终端缩回该卡的动画。 + +### 4) 新建分屏 — `3b 新建分屏菜单` + +**不问方向**(竖分 `⌘D` / 横分 `⌘⇧D` 本来就是两个入口)。菜单只问「分到哪里」: + +- 分组「tmux 会话」:当前会话(带「当前」标签)、其余 detached 会话(title · n windows · 上次活跃)、「新建 tmux 会话…」 +- 分隔线 +- 「原生窗口 / 不经 tmux · 关 App 即结束」 + +选完**立即分屏**。菜单 360pt 宽,圆角 14,`#1b1d2b` + 描边 `#595d6c`;行高 ~40pt,前导列统一 20pt(状态点也要包在 20pt 居中列里,保证五行文字左缘一致 x=50)。 + +**模型约束**:分屏只发生在**同一台主机内**。卡片内部的分割 = 真 tmux pane(`split-window`,服务端持有,断线原样恢复);跨主机分屏**已取消**(层级过深),要同时看两台机器就切会话或 Pin 侧边栏。 + +### 5) 关闭 / 最小化 — `3c 关闭确认` + +标题胶囊左侧 macOS 红黄圆点: + +- **黄点(最小化)**:**不确认**,直接回首页,会话继续跑;卡片置顶标「最小化中」。手势:终端下滑。快捷键 `⌘M`。 +- **红点(关闭)**:**弹一次确认**,按主机能力分两套文案: + - tmux 会话 → 主按钮「仅断开 ⏎」(`detach`,服务端继续跑);左侧次要文字按钮「结束会话」(`kill-session`,需再确认) + - 原生窗口 → 琥珀警告「没有 tmux 兜底,前台进程会随之结束」;推荐「取消」,危险按钮「关闭窗口」(描边 `#8d5560`,底 `rgba(92,58,63,.35)`,字 `#f5a0ac`) + - 快捷键 `⌘W`;`esc` 取消;`⏎` 选推荐项 + +对话框 436pt / 404pt 宽,圆角 15。 + +### 6) 首页 ⇄ 终端 跳转关系 — `3d 页面跳转关系` + +**进入终端(2 条)**:点活动会话卡 / 会话行 → 恢复到**上一个焦点 pane**;新建连接或主机 + → 建好即进终端,侧边栏同步出现该会话。 +**离开终端(3 条)**:侧边栏首页行(`⌘⇧H`,会话完全不动)· 黄点最小化(同上但语义是"待会儿还回来")· 红点关闭(二次确认,tmux detach / 原生窗口结束)。 +换会话不必回首页——侧边栏直接切;回首页表示"换任务"。会话总览层(原 `⌘O` 缩放)**已取消**。 + +### 7) 无 tmux 主机 — `2c 无 tmux 主机` + +轨道下半段的 `TMUX` 段换成「窗口」段(客户端窗口,每个 = 一条独立 SSH channel / mosh 会话,可随意新建)。状态胶囊第三格写「无 tmux · 客户端窗口」而非报错。终端顶部一条可关闭的行内横幅:说明窗口由 App 维持、mosh 兜底,但**关掉 App 不会在服务端继续跑**,右侧给「安装并启用 tmux」次要按钮。不弹窗、不强推。 + +### 8) 竖屏与 iPhone — `2d iPad 竖屏`、`2d iPhone 竖屏`、`1h iPhone-终端`、`1h iPhone-主机` + +- **iPad 竖屏(1024×1366)**:轨道降到 48pt(元素中心 24pt,同样含搜索/首页/展开键);pane 由左右平铺改为**上下堆叠,最多两层**,多余 pane 折成顶部段控;状态胶囊压缩为「主机名 + RTT」两枚;底部常驻单行键盘附加行。 +- **iPhone(393×852)**:轨道下沉为**底部主机条**(ms / u2 / nh 胶囊);单 pane 全屏,顶部 3 段进度式 pane 指示 +「左右轻扫切 pane」;下拉标题打开会话列表;键盘附加行两行(esc/ctrl/tab/~///| 与 ↑↓←→/⌘K);主机页用底部 tab(主机 / 会话 / 密钥 / 设置)。 + +### 9) 设置 · 外观 — `1g 设置-外观` + +左 280pt 类别栏(外观与主题 / 终端与字体 / Tailscale / 密钥与安全 / mosh 与重连 / tmux 集成 / 键盘与手势 / 诊断日志,脚注版本行),右内容区 padding 28/40。内容:标题 + 说明 → 4 张 Catppuccin 主题卡(88pt 预览 + 5 色点 + 名称,选中描边 `#796cbf` + `check-circle`)→ 两栏(左「实时预览」终端框,右「排版」面板:字体、字号 13.5pt、行高 1.50、连字、CJK 等宽补偿、跟随系统深浅色)。 + +--- + +## Interactions & Behavior + +| 交互 | 行为 | +| --- | --- | +| 侧边栏开合 | `⌘\` 或开合键;遮罩态点终端收起;Pin 切换推挤/浮层 | +| 切会话 | 轨道主机头像 / 侧边栏会话行 / `⌘1…9`;恢复到该会话上一个焦点 pane | +| 切 window | 轨道数字 / 侧边栏 window 行 / `⌃⇥` | +| 分屏 | `⌘D` 竖分、`⌘⇧D` 横分 → 弹会话选择菜单 → 立即分屏 | +| 新建 | `⌘T` 新 tmux window;`⌘⌥T` 原生窗口 | +| 调整 pane 比例 | 拖 12pt 分隔条(把手 44×3pt);松手才向 tmux 发 `resize-pane` | +| 回首页 | `⌘⇧H` / 侧边栏首页行 | +| 最小化 | 黄点 / `⌘M` / 终端下滑;无确认 | +| 关闭 | 红点 / `⌘W`;二次确认,`⏎` = 推荐项,`esc` 取消 | +| 命令面板 | `⌘K`(模糊匹配主机、会话、动作) | +| 硬件键盘 | 检测到即收起附加行,只留 34pt 提示条;拔掉恢复附加行 | +| 长按 `⌘` | 显示全部快捷键面板(iPadOS 系统行为) | + +**动效**:状态切换 200–260ms `easeOut`;侧边栏浮层从左滑入 + 遮罩淡入;最小化 = 终端缩回首页卡片(matched geometry);重连转圈 1s 线性循环。 + +**状态徽标规则(分层,只染出问题的那层)**: +1. `tsnet` — 网络可达;断则本格转灰/琥珀 +2. `mosh ` — 数字实时跳动;`≤120ms` 绿 `#a6e3a1`,`120–200ms` 中性,`>200ms` 琥珀 `#fab387` +3. `tmux <会话名>` / 「无 tmux · 客户端窗口」 + +**重连**:指数退避,上限 30s;卡片/胶囊显示「重连中 · 第 n 次 · ms 后」;屏幕内容本地缓存,恢复后原样接回(目标 <3s)。 + +**安全**:host key TOFU 固定在钥匙串;不匹配时**拦截连接**并告警;私钥存 Secure Enclave(不可导出),密码回退默认关闭。 + +## State Management + +``` +AppRoute = .home | .terminal(sessionID) +SidebarState = .collapsed | .overlay | .pinned +Session { id, host, kind: .tmux(name) | .nativeWindow, windows[], focusedPaneID, + transport: .ssh | .sshOverTsnet | .mosh | .moshOverTsnet, + link: .direct(rtt) | .relay(derp, rtt) | .reconnecting(attempt, nextIn) | .offline, + lastActiveAt, isMinimized } +TmuxWindow { index, title, cwd, panes[] } // title 为 shell 默认值时 UI 回退显示 cwd +Host { id, name, group, user, addr, port, os, tsnetIP, hasTmux, hostKeyPinnedAt } +``` + +关键转换:`选择会话 → .terminal`;`首页/最小化 → .home`(会话保留);`关闭 → detach 或 kill → .home`;`Pin → .pinned`(触发一次 PTY resize)。 + +## Files + +- `terminalX iPadOS.dc.html` — 全部 13 块屏幕的设计稿(画板,可缩放平移) +- `CLAUDE.md` / `README.md`(源项目文档,若已在仓库中则以仓库版本为准) + +## Assets + +无位图资产。图标全部来自 Phosphor(实现时换 SF Symbols),字体 JetBrains Mono(Google Fonts),终端配色 Catppuccin。 diff --git a/docs/design/render-screens.py b/docs/design/render-screens.py new file mode 100644 index 0000000..511fc39 --- /dev/null +++ b/docs/design/render-screens.py @@ -0,0 +1,80 @@ +#!/usr/bin/env python3 +"""把设计稿画板拆成每屏一个独立 HTML,便于 headless chrome 逐屏截图。""" +import re, os, sys +from html.parser import HTMLParser + +# python3 docs/design/render-screens.py && \ +# for f in /tmp/tx-screens/*.html; do "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \ +# --headless=new --window-size=1366,1024 --screenshot="${f%.html}.png" "file://$f"; done +SRC = os.path.join(os.path.dirname(os.path.abspath(__file__)), "terminalX-iPadOS.dc.html") +OUT = "/tmp/tx-screens" +os.makedirs(OUT, exist_ok=True) +html = open(SRC, encoding="utf-8").read() + +# 提取 (helmet 内的全局样式) +style = "\n".join(re.findall(r"", html, re.S)) + +VOID = {"br", "img", "hr", "input", "meta", "link", "source", "area", "base", "col", "embed", "param", "track", "wbr"} + +class Finder(HTMLParser): + def __init__(self): + super().__init__(convert_charrefs=False) + self.stack = [] # 当前打开的 tag 名 + self.capture = None # (label, depth, start_offset) + self.results = [] # (label, start, end) + + def cur_off(self): + line, col = self.getpos() + return self.line_starts[line - 1] + col + + def handle_starttag(self, tag, attrs): + if tag in VOID: + return + d = dict(attrs) + if self.capture is None and "data-screen-label" in d: + self.capture = (d["data-screen-label"], len(self.stack), self.cur_off()) + self.stack.append(tag) + + def handle_startendtag(self, tag, attrs): + pass + + def handle_endtag(self, tag): + if tag in VOID: + return + while self.stack and self.stack[-1] != tag: + self.stack.pop() + if self.stack: + self.stack.pop() + if self.capture and len(self.stack) == self.capture[1]: + label, _, start = self.capture + end = self.cur_off() + len(f"") + self.results.append((label, start, end)) + self.capture = None + +f = Finder() +f.line_starts = [0] +for line in html.splitlines(keepends=True): + f.line_starts.append(f.line_starts[-1] + len(line)) +f.feed(html) + +HEAD = """ + + +""" + +manifest = [] +for i, (label, s, e) in enumerate(f.results): + frag = html[s:e] + m = re.search(r"width:(\d+)px;height:(\d+)px", frag) + w, h = (m.group(1), m.group(2)) if m else ("1366", "1024") + slug = re.sub(r"[^\w一-鿿]+", "_", label).strip("_") + path = f"{OUT}/{i:02d}_{slug}.html" + open(path, "w", encoding="utf-8").write(HEAD % style + frag + "") + manifest.append((path, w, h, label)) + print(f"{i:02d}\t{label}\t{w}x{h}\t{path}") + +with open(f"{OUT}/manifest.tsv", "w") as fh: + for p, w, h, l in manifest: + fh.write(f"{p}\t{w}\t{h}\t{l}\n") +print(f"total {len(manifest)}") diff --git a/docs/design/support.js b/docs/design/support.js new file mode 100644 index 0000000..cb009b6 --- /dev/null +++ b/docs/design/support.js @@ -0,0 +1,1911 @@ +// GENERATED from dc-runtime/src/*.ts — do not edit. Rebuild with `cd dc-runtime && bun run build`. +"use strict"; +(() => { + var __defProp = Object.defineProperty; + var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value; + var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value); + + // src/react.ts + function getReact() { + const R = window.React; + if (!R) throw new Error("dc-runtime: window.React is not available yet"); + return R; + } + function getReactDOM() { + const RD = window.ReactDOM; + if (!RD) throw new Error("dc-runtime: window.ReactDOM is not available yet"); + return RD; + } + var h = ((...args) => getReact().createElement( + ...args + )); + + // src/parse.ts + function parseDcDocument(doc) { + const dc = doc.querySelector("x-dc"); + if (!dc) return null; + const scriptEl = doc.querySelector("script[data-dc-script]"); + const { props, preview } = parseDataProps( + scriptEl?.getAttribute("data-props") ?? null + ); + return { + template: dc.innerHTML, + js: scriptEl ? scriptEl.textContent || "" : "", + props, + preview + }; + } + function parseDcText(src) { + const openMatch = /]*)?>/.exec(src); + if (!openMatch) return null; + const close = src.lastIndexOf(""); + if (close === -1 || close < openMatch.index) return null; + const template = src.slice(openMatch.index + openMatch[0].length, close); + const doc = new DOMParser().parseFromString(src, "text/html"); + const scriptEl = doc.querySelector("script[data-dc-script]"); + const { props, preview } = parseDataProps( + scriptEl?.getAttribute("data-props") ?? null + ); + return { + template, + js: scriptEl ? scriptEl.textContent || "" : "", + props, + preview + }; + } + function parseDataProps(raw) { + if (!raw) return { props: null, preview: null }; + let parsed; + try { + parsed = JSON.parse(raw); + } catch { + return { props: null, preview: null }; + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return { props: null, preview: null }; + } + const obj = parsed; + const preview = obj.$preview && typeof obj.$preview === "object" ? obj.$preview : null; + const rest = {}; + for (const k of Object.keys(obj)) { + if (k[0] !== "$") rest[k] = obj[k]; + } + return { props: Object.keys(rest).length ? rest : null, preview }; + } + function dcNameFromPath(pathname) { + let p = pathname || ""; + try { + p = decodeURIComponent(p); + } catch { + } + const base = p.split("/").pop() || "Root"; + return base.replace(/\.dc\.html$/, "").replace(/\.html?$/, "") || "Root"; + } + + // src/boot.ts + var BASE_CSS = ` + .sc-placeholder{background:color-mix(in srgb,currentColor 8%,transparent); + border:1px solid color-mix(in srgb,currentColor 50%,transparent); + border-radius:2px;box-sizing:border-box;overflow:hidden} + @keyframes sc-shine{0%{background-position:100% 50%}100%{background-position:0% 50%}} + html.sc-dc-streaming .sc-placeholder, + html.sc-dc-streaming .sc-interp.sc-missing{position:relative; + background:color-mix(in srgb,currentColor 5%,transparent); + border-color:transparent} + html.sc-dc-streaming .sc-placeholder::before, + html.sc-dc-streaming .sc-interp.sc-missing::before{content:''; + position:absolute;inset:0;pointer-events:none; + background:linear-gradient(90deg,rgba(217,119,87,0) 25%,rgba(247,225,211,.95) 37%,rgba(217,119,87,0) 63%); + background-size:400% 100%;animation:sc-shine 1.4s ease infinite} + html.sc-dc-streaming .sc-placeholder:nth-child(n+9 of .sc-placeholder)::before, + html.sc-dc-streaming .sc-interp.sc-missing:nth-child(n+9 of .sc-interp.sc-missing)::before{animation:none; + background:color-mix(in srgb,currentColor 8%,transparent)} + .sc-placeholder-error{padding:4px 8px;font:11px/1.4 ui-monospace,monospace; + color:color-mix(in srgb,currentColor 70%,transparent);word-break:break-word} + .sc-interp.sc-missing{display:inline-block;width:2em;height:1em;overflow:hidden; + vertical-align:text-bottom;background:rgba(255,255,255,.3);border:1px solid rgba(0,0,0,.5); + border-radius:2px;box-sizing:border-box;color:transparent; + user-select:none} + .sc-interp.sc-unresolved{font-family:ui-monospace,monospace;font-size:.85em; + color:color-mix(in srgb,currentColor 50%,transparent); + background:color-mix(in srgb,currentColor 10%,transparent);border-radius:3px; + padding:0 3px} + .sc-host.sc-has-error{position:relative} + .sc-logic-error{position:absolute;top:8px;left:8px;z-index:2147483647;max-width:60ch; + padding:6px 10px;background:#b00020;color:#fff;font:12px/1.4 ui-monospace,monospace; + border-radius:4px;white-space:pre-wrap;pointer-events:none} + /* Mirrors PRINT_BASELINE_CSS in apps/web deck-stage-export.ts \u2014 keep both + in sync until dc-runtime regains a build step. */ + @media print { + @page { margin: 0.5cm; } + figure, table { break-inside: avoid; } + #dc-root, #dc-root > .sc-host { height: auto; } + *, *::before, *::after { + print-color-adjust: exact; -webkit-print-color-adjust: exact; + backdrop-filter: none !important; -webkit-backdrop-filter: none !important; + animation-delay: -99s !important; animation-duration: .001s !important; + animation-iteration-count: 1 !important; animation-fill-mode: both !important; + animation-play-state: running !important; transition-duration: 0s !important; + } + } + `; + var FULL_PAGE_CSS = "html,body{height:100%;margin:0}#dc-root,#dc-root>.sc-host{height:100%}"; + function rootNameForDocument(doc, loc) { + let bootPath = loc.pathname || ""; + if (!/\.dc\.html?$/i.test(safeDecode(bootPath))) { + try { + bootPath = new URL(doc.baseURI || "/").pathname; + } catch { + } + } + return dcNameFromPath(bootPath); + } + function safeDecode(s) { + try { + return decodeURIComponent(s); + } catch { + return s; + } + } + function boot(runtime, doc = document) { + const parsed = parseDcDocument(doc); + if (!parsed) return null; + const React = getReact(); + const rootName = rootNameForDocument(doc, location); + runtime.markFetched(rootName); + runtime.setRootName(rootName); + runtime.adoptParsed(rootName, parsed); + if (!window.__resources) { + fetch(location.href).then((res) => res.ok ? res.text() : "").then((t) => { + const raw = t ? parseDcText(t) : null; + if (raw?.template) runtime.updateHtml(rootName, raw.template); + }).catch(() => { + }); + } + const dc = doc.querySelector("x-dc"); + const hostEl = doc.createElement("div"); + hostEl.id = "dc-root"; + dc.replaceWith(hostEl); + if (!parsed.preview) { + const s = doc.createElement("style"); + s.textContent = FULL_PAGE_CSS; + doc.head.appendChild(s); + } + const Root = runtime.getDC(rootName); + const entry = runtime.registry.get(rootName); + function StandaloneRoot() { + const [, setTick] = React.useState(0); + React.useEffect(() => { + const sub = () => setTick((n) => n + 1); + entry.subs.add(sub); + return () => { + entry.subs.delete(sub); + }; + }, []); + const defaults = React.useMemo(() => { + const d = {}; + for (const k in entry.propsMeta || {}) { + const v = entry.propsMeta?.[k]?.default; + if (v !== void 0) d[k] = v; + } + return d; + }, [entry.propsMeta]); + return h(Root, { ...defaults, ...entry.propOverrides || {} }); + } + const ReactDOM = getReactDOM(); + if (ReactDOM.createRoot) + ReactDOM.createRoot(hostEl).render(h(StandaloneRoot)); + else ReactDOM.render(h(StandaloneRoot), hostEl); + return rootName; + } + + // src/expr.ts + var IDENT_RE = /^[A-Za-z_$][A-Za-z0-9_$]*/; + var NUMBER_RE = /^-?\d+(\.\d+)?$/; + function resolve(vals, src) { + const expr = String(src).trim(); + if (!expr) return void 0; + if (expr[0] === "(" && expr[expr.length - 1] === ")" && parensWrapWhole(expr)) { + return resolve(vals, expr.slice(1, -1)); + } + const eq = findTopLevelEquality(expr); + if (eq) { + const lv = resolve(vals, expr.slice(0, eq.index)); + const rv = resolve(vals, expr.slice(eq.index + eq.op.length)); + switch (eq.op) { + case "===": + return lv === rv; + case "!==": + return lv !== rv; + case "==": + return lv == rv; + default: + return lv != rv; + } + } + if (expr[0] === "!") return !resolve(vals, expr.slice(1)); + if (expr === "true") return true; + if (expr === "false") return false; + if (expr === "null") return null; + if (expr === "undefined") return void 0; + if (NUMBER_RE.test(expr)) return Number(expr); + if (expr.length >= 2 && (expr[0] === '"' || expr[0] === "'") && expr[expr.length - 1] === expr[0]) { + return expr.slice(1, -1); + } + return resolvePath(vals, expr); + } + function parensWrapWhole(expr) { + let depth = 0; + for (let i = 0; i < expr.length - 1; i++) { + if (expr[i] === "(") depth++; + else if (expr[i] === ")") { + depth--; + if (depth === 0) return false; + } + } + return true; + } + function findTopLevelEquality(expr) { + let depth = 0; + for (let i = 0; i < expr.length; i++) { + const c = expr[i]; + if (c === "[" || c === "(") depth++; + else if (c === "]" || c === ")") depth--; + else if (depth === 0 && (c === "=" || c === "!") && expr[i + 1] === "=") { + if (i > 0 && (expr[i - 1] === "=" || expr[i - 1] === "!")) continue; + if (!expr.slice(0, i).trim()) continue; + const op = expr[i + 2] === "=" ? c + "==" : c + "="; + return { index: i, op }; + } + } + return null; + } + function resolvePath(vals, expr) { + const head = expr.match(IDENT_RE); + if (!head) return void 0; + let cur = vals == null ? void 0 : vals[head[0]]; + let i = head[0].length; + while (i < expr.length) { + if (expr[i] === ".") { + const m = expr.slice(i + 1).match(IDENT_RE) || expr.slice(i + 1).match(/^\d+/); + if (!m) return void 0; + cur = cur == null ? void 0 : cur[m[0]]; + i += 1 + m[0].length; + } else if (expr[i] === "[") { + let depth = 1; + let j = i + 1; + while (j < expr.length && depth > 0) { + if (expr[j] === "[") depth++; + else if (expr[j] === "]") { + depth--; + if (depth === 0) break; + } + j++; + } + if (depth !== 0) return void 0; + const key = resolve(vals, expr.slice(i + 1, j)); + cur = cur == null ? void 0 : cur[key]; + i = j + 1; + } else { + return void 0; + } + } + return cur; + } + + // src/encode.ts + var CAMEL_ATTR = "sc-camel-"; + var INLINE_TEXT_TAGS = new Set( + "a abbr b bdi bdo br cite code del dfn em i ins kbd mark q s samp small span strike strong sub sup u var wbr".split( + " " + ) + ); + var RAW_WRAP = { + select: "sc-raw-select", + table: "sc-raw-table", + tbody: "sc-raw-tbody", + thead: "sc-raw-thead", + tfoot: "sc-raw-tfoot", + tr: "sc-raw-tr", + td: "sc-raw-td", + th: "sc-raw-th", + caption: "sc-raw-caption" + }; + var RAW_UNWRAP = Object.fromEntries( + Object.entries(RAW_WRAP).map(([k, v]) => [v, k]) + ); + var EVENT_MAP = { + onclick: "onClick", + onchange: "onChange", + oninput: "onInput", + onsubmit: "onSubmit", + onkeydown: "onKeyDown", + onkeyup: "onKeyUp", + onkeypress: "onKeyPress", + onmousedown: "onMouseDown", + onmouseup: "onMouseUp", + onmouseenter: "onMouseEnter", + onmouseleave: "onMouseLeave", + onfocus: "onFocus", + onblur: "onBlur", + ondoubleclick: "onDoubleClick", + oncontextmenu: "onContextMenu", + onmousemove: "onMouseMove", + onmouseover: "onMouseOver", + onmouseout: "onMouseOut", + onpointerdown: "onPointerDown", + onpointerup: "onPointerUp", + onpointermove: "onPointerMove", + onpointerenter: "onPointerEnter", + onpointerleave: "onPointerLeave", + onpointercancel: "onPointerCancel", + onpointerover: "onPointerOver", + onpointerout: "onPointerOut", + ongotpointercapture: "onGotPointerCapture", + onlostpointercapture: "onLostPointerCapture", + ontouchstart: "onTouchStart", + ontouchend: "onTouchEnd", + ontouchmove: "onTouchMove", + ontouchcancel: "onTouchCancel", + ondragstart: "onDragStart", + ondragend: "onDragEnd", + ondragenter: "onDragEnter", + ondragleave: "onDragLeave", + ondragover: "onDragOver", + onanimationstart: "onAnimationStart", + onanimationend: "onAnimationEnd", + onanimationiteration: "onAnimationIteration", + ontransitionend: "onTransitionEnd" + }; + var ATTRS = `(?:[^>"']|"[^"]*"|'[^']*')*`; + var IMPORT_SELF_CLOSE_RE = new RegExp( + "<(x-import|dc-import)(" + ATTRS + ")/>", + "gi" + ); + var CAMEL_ATTR_RE = /(\s)([a-z]+[A-Z][A-Za-z0-9]*)(\s*=)/g; + function encodeCamelAttrs(html) { + return html.replace( + CAMEL_ATTR_RE, + (_, sp, name, eq) => sp + CAMEL_ATTR + name.replace(/[A-Z]/g, (c) => "-" + c.toLowerCase()) + eq + ); + } + function encodeCase(html) { + html = html.replace( + IMPORT_SELF_CLOSE_RE, + (_, t, a) => "<" + t + a + ">" + ); + html = html.replace(/)/gi, "/gi, ""); + html = encodeCamelAttrs(html); + for (const [real, alias] of Object.entries(RAW_WRAP)) { + html = html.replace( + new RegExp("(])", "gi"), + "$1" + alias + ); + } + return html; + } + function kebabToCamel(s) { + return s.replace(/-([a-z])/g, (_, c) => c.toUpperCase()); + } + function cssToObj(css) { + const o = {}; + for (const decl of css.split(";")) { + const i = decl.indexOf(":"); + if (i < 0) continue; + const prop = decl.slice(0, i).trim(); + o[prop.startsWith("--") ? prop : kebabToCamel(prop)] = decl.slice(i + 1).trim(); + } + return o; + } + function compileAttr(raw) { + const whole = raw.match(/^\s*\{\{([\s\S]+?)\}\}\s*$/); + if (whole) { + const path = whole[1]; + return (vals) => resolve(vals, path); + } + if (raw.includes("{{")) { + const parts = raw.split(/\{\{([\s\S]+?)\}\}/g); + return (vals) => parts.map((s, i) => i & 1 ? resolve(vals, s) ?? "" : s).join(""); + } + return () => raw; + } + + // src/compile.ts + function collectProps(node, kind, host) { + const propGetters = []; + const pseudoClasses = []; + let hintSize = null; + for (const { name, value } of [...node.attributes]) { + if (name === "sc-name" || name === "data-dc-tpl") continue; + let key = name; + if (key.startsWith(CAMEL_ATTR)) + key = kebabToCamel(key.slice(CAMEL_ATTR.length)); + if (key === "hint-size") { + hintSize = value; + continue; + } + if (key.startsWith("style-")) { + pseudoClasses.push(host.pseudoClass(key.slice(6), value)); + continue; + } + if (kind !== "dom") { + if (key.includes("-") && !(kind === "x-import" && (key.startsWith("aria-") || key.startsWith("data-")))) + key = kebabToCamel(key); + } else { + if (key === "class") key = "className"; + else if (key === "for") key = "htmlFor"; + else if (key.startsWith("on")) + key = EVENT_MAP[key] || "on" + key[2].toUpperCase() + key.slice(3); + } + propGetters.push([key, compileAttr(value)]); + } + return { propGetters, pseudoClasses, hintSize }; + } + var HOST_STYLE_PROPS = /* @__PURE__ */ new Set([ + "position", + "left", + "right", + "top", + "bottom", + "inset", + "width", + "height", + "z-index", + "transform" + ]); + function hostPositionStyle(style) { + const all = typeof style === "string" ? cssToObj(style) : style != null && typeof style === "object" ? style : null; + if (!all) return void 0; + const out = {}; + for (const [k, v] of Object.entries(all)) { + const kebab = k.replace(/[A-Z]/g, (c) => "-" + c.toLowerCase()); + if (HOST_STYLE_PROPS.has(kebab)) out[k] = v; + } + return Object.keys(out).length ? out : void 0; + } + function compileTemplate(html, host) { + const tpl = document.createElement("template"); + //! nosemgrep: direct-inner-html-assignment + tpl.innerHTML = encodeCase(html); + let tplN = 0; + (function stamp(node) { + if (node.nodeType === Node.ELEMENT_NODE) { + node.setAttribute("data-dc-tpl", String(tplN++)); + } + for (const c of node.childNodes) stamp(c); + })(tpl.content); + const builders = walkChildren(tpl.content, host); + const render = ((vals, ctx) => builders.map((b, i) => b(vals || {}, ctx, i))); + render.__annotated = tpl.innerHTML; + return render; + } + function walkChildren(node, host) { + return [...node.childNodes].map((c) => walk(c, host)).filter((b) => b != null); + } + var SLIDE_ID_VALUE_RE = /^[0-9a-f]{8}$/; + var DECK_CONTROL_FLOW_RE = /^(sc-if|sc-for|sc-else|dc-import|x-import)$/; + var DECK_AUX_RE = /^(template|script|style|sc-helmet|helmet)$/; + function isDeckMountTag(el) { + if (el.localName === "deck-stage") return true; + return el.localName === "x-import" && (el.getAttribute("component-from-global-scope") || "") === "deck-stage"; + } + function walkDeckChildren(el, host) { + const pairs = [...el.childNodes].map((c) => ({ c, b: walk(c, host) })).filter((p) => p.b !== null); + const kids = pairs.map((p) => p.b); + const seen = /* @__PURE__ */ new Set(); + const wsSeen = /* @__PURE__ */ new Map(); + const keys = []; + const nextSlideId = new Array(pairs.length); + { + let upcoming = null; + for (let j = pairs.length - 1; j >= 0; j--) { + const n = pairs[j].c; + if (n.nodeType === Node.ELEMENT_NODE) { + const t = n.localName; + upcoming = !DECK_AUX_RE.test(t) && !DECK_CONTROL_FLOW_RE.test(t) ? n.getAttribute("data-om-slide-id") : null; + } + nextSlideId[j] = upcoming; + } + } + for (let j = 0; j < pairs.length; j++) { + const { c } = pairs[j]; + if (c.nodeType === Node.TEXT_NODE) { + if ((c.nodeValue ?? "").trim() === "") { + const base = nextSlideId[j] ? "omid-ws:" + nextSlideId[j] : "omid-ws:aux"; + const n = wsSeen.get(base) ?? 0; + wsSeen.set(base, n + 1); + keys.push(n === 0 ? base : base + ":" + n); + continue; + } + return { kids, keys: null }; + } + if (c.nodeType !== Node.ELEMENT_NODE) { + keys.push(j); + continue; + } + const child = c; + const tag = child.localName; + if (DECK_AUX_RE.test(tag)) { + keys.push(j); + continue; + } + if (DECK_CONTROL_FLOW_RE.test(tag)) return { kids, keys: null }; + const v = child.getAttribute("data-om-slide-id"); + if (!v || !SLIDE_ID_VALUE_RE.test(v) || seen.has(v)) { + return { kids, keys: null }; + } + seen.add(v); + keys.push("omid:" + v); + } + return { kids, keys }; + } + function renderDeckKids(kids, kidKeys, vals, ctx) { + return kids.map((b, j) => { + const k = kidKeys ? kidKeys[j] : j; + const out = b(vals, ctx, k); + return kidKeys != null && typeof out === "string" ? h(getReact().Fragment, { key: k }, out) : out; + }); + } + function walk(node, host) { + if (node.nodeType === Node.TEXT_NODE) return walkText(node); + if (node.nodeType !== Node.ELEMENT_NODE) return null; + const el = node; + const tag = el.tagName.toLowerCase(); + if (tag === "sc-for") return walkFor(el, host); + if (tag === "sc-if") return walkIf(el, host); + if (tag === "x-import") return walkXImport(el, host); + if (tag === "sc-helmet") return host.helmet(el); + if (tag === "dc-import") return walkComponent(el, host); + return walkElement(el, host); + } + var warnedHoles = /* @__PURE__ */ new Set(); + function warnUnresolved(ctx, what) { + const key = (ctx?.__name || "?") + "\0" + what; + if (warnedHoles.has(key)) return; + warnedHoles.add(key); + console.warn("[dc-runtime] " + (ctx?.__name || "template") + ": " + what); + } + function walkText(node) { + const txt = node.nodeValue ?? ""; + if (!txt.includes("{{")) { + if (!txt.trim() && !txt.includes(" ")) return null; + return () => txt; + } + const parts = txt.split(/\{\{([\s\S]+?)\}\}/g); + return (vals, ctx, key) => h( + getReact().Fragment, + { key }, + ...parts.map((p, i) => { + if (!(i & 1)) return p; + const v = resolve(vals, p); + if (v === void 0) { + if (!ctx?.__streamingNow) { + if (document.body?.hasAttribute("data-dc-editor-on")) { + return h( + "span", + { key: i, className: "sc-interp sc-unresolved" }, + "{{ " + p.trim() + " }}" + ); + } + warnUnresolved( + ctx, + "{{ " + p.trim() + " }} never resolved \u2014 rendered as empty" + ); + return null; + } + return h( + "span", + { key: i, className: "sc-interp sc-missing" }, + p.trim() + ); + } + if (getReact().isValidElement(v) || Array.isArray(v)) { + return h(getReact().Fragment, { key: i }, v); + } + if (v === null || typeof v === "boolean") return null; + return h("span", { key: i, className: "sc-interp" }, String(v)); + }) + ); + } + function walkFor(el, host) { + const listGet = compileAttr(el.getAttribute("list") || ""); + const asName = el.getAttribute("as") || "item"; + const hintN = parseInt(el.getAttribute("hint-placeholder-count") || "0", 10); + const kids = walkChildren(el, host); + const listSrc = el.getAttribute("list") || ""; + return (vals, ctx, key) => { + let list = listGet(vals); + if (!Array.isArray(list)) { + if (!ctx?.__streamingNow) { + if (list !== void 0 && list !== null) { + warnUnresolved( + ctx, + 'sc-for list="' + listSrc + '" is not an array (' + typeof list + ")" + ); + } + list = []; + } else { + list = hintN > 0 ? Array(hintN).fill(void 0) : []; + } + } + return h( + getReact().Fragment, + { key }, + list.map((item, i) => { + const sub = { ...vals, [asName]: item, $index: i }; + return h( + getReact().Fragment, + { key: i }, + kids.map((b, j) => b(sub, ctx, j)) + ); + }) + ); + }; + } + function walkIf(el, host) { + const valGet = compileAttr(el.getAttribute("value") || ""); + const hintRaw = el.getAttribute("hint-placeholder-val"); + const hintGet = hintRaw != null ? compileAttr(hintRaw) : null; + const kids = walkChildren(el, host); + return (vals, ctx, key) => { + let v = valGet(vals); + if (v === void 0 && hintGet && ctx?.__streamingNow) v = hintGet(vals); + return v ? h( + getReact().Fragment, + { key }, + kids.map((b, j) => b(vals, ctx, j)) + ) : null; + }; + } + function walkComponent(el, host) { + const name = el.getAttribute("name") || el.getAttribute("component") || ""; + el.removeAttribute("name"); + el.removeAttribute("component"); + const tplId = el.getAttribute("data-dc-tpl"); + const styleRaw = el.getAttribute("style"); + el.removeAttribute("style"); + const styleGet = styleRaw != null ? compileAttr(styleRaw) : null; + const { propGetters, hintSize } = collectProps(el, "dc-import", host); + const kids = walkChildren(el, host); + return (vals, ctx, key) => { + const props = { + key, + __hintSize: hintSize, + __tplId: tplId, + __hostStyle: styleGet ? hostPositionStyle(styleGet(vals)) : void 0 + }; + for (const [k, g] of propGetters) { + const v = g(vals); + if (k === "dcProps") { + if (v && typeof v === "object") Object.assign(props, v); + continue; + } + props[k] = v; + } + if (kids.length) props.children = kids.map((b, j) => b(vals, ctx, j)); + return h(host.component(name), props); + }; + } + function walkXImport(el, host) { + const globalNameGet = compileAttr( + el.getAttribute("component-from-global-scope") || "" + ); + const exportNameGet = compileAttr( + el.getAttribute("component") || el.getAttribute("name") || "" + ); + const fromRaw = el.getAttribute("from") || (el.getAttribute("component-from-global-scope") ? "" : el.getAttribute("src") || el.getAttribute("import") || ""); + const urls = fromRaw.trim() ? fromRaw.trim().split(/\s+/) : []; + const url = urls.length ? urls[urls.length - 1] : ""; + const kindOf = (u) => /\.(jsx|tsx)(\?|#|$)/i.test(u) ? "jsx" : "js"; + const tplId = el.getAttribute("data-dc-tpl"); + const styleRaw = el.getAttribute("style"); + el.removeAttribute("style"); + const styleGet = styleRaw != null ? compileAttr(styleRaw) : null; + const wrap = tplId != null || styleGet != null; + const { propGetters, hintSize } = collectProps(el, "x-import", host); + const hasContent = el.children.length > 0 || !!(el.textContent || "").trim(); + const deckKeyed = hasContent && isDeckMountTag(el) ? walkDeckChildren(el, host) : null; + const kids = deckKeyed ? deckKeyed.kids : hasContent ? walkChildren(el, host) : []; + const kidKeys = deckKeyed?.keys ?? null; + const urlBindable = fromRaw.includes("{{"); + if (urls.length && !urlBindable) { + let prev; + for (const u of urls) prev = host.loadExternal(kindOf(u), u, prev); + } + const evalName = (g, vals) => { + const v = g(vals); + const s = v == null ? "" : String(v); + return s.includes("{{") ? "" : s; + }; + return (vals, ctx, key) => { + const globalName = evalName(globalNameGet, vals); + const name = globalName || evalName(exportNameGet, vals); + const C = !name || urlBindable ? null : globalName ? host.resolveExternalGlobal(url, globalName) : host.resolveExternal(url, name); + const hostStyle = styleGet ? hostPositionStyle(styleGet(vals)) : void 0; + const wrapper = wrap ? { + key, + className: "sc-host-x", + "data-dc-tpl": tplId, + style: hostStyle || { display: "contents" } + } : null; + if (!C) { + const error = urlBindable ? "x-import `from` cannot contain {{ \u2026 }} \u2014 module URLs are resolved at parse time; use a literal URL" : host.resolveExternalError(url, name); + const ph = host.placeholder({ + key: wrapper ? void 0 : key, + name, + hintSize, + error + }); + return wrapper ? h("div", wrapper, ph) : ph; + } + const props = wrapper ? {} : { key }; + let unresolvedHole = false; + for (const [k, g] of propGetters) { + if (k === "component" || k === "componentFromGlobalScope" || k === "from") { + continue; + } + const v = g(vals); + if (v === void 0) unresolvedHole = true; + if (k === "dcProps") { + if (v && typeof v === "object") Object.assign(props, v); + continue; + } + props[k] = v; + } + if (unresolvedHole && ctx?.__htmlStreamingNow) { + const ph = host.placeholder({ + key: wrapper ? void 0 : key, + name, + hintSize, + error: null + }); + return wrapper ? h("div", wrapper, ph) : ph; + } + if (kids.length) { + props.children = renderDeckKids(kids, kidKeys, vals, ctx); + } + return wrapper ? h("div", wrapper, h(C, props)) : h(C, props); + }; + } + function contentKey(el) { + const clone = el.cloneNode(true); + for (const d of clone.querySelectorAll("*")) { + while (d.attributes.length) d.removeAttribute(d.attributes[0].name); + } + const s = clone.innerHTML; + let h2 = 5381; + for (let i = 0; i < s.length; i++) h2 = (h2 << 5) + h2 + s.charCodeAt(i) | 0; + return s.length + "." + (h2 >>> 0).toString(36); + } + var NEVER_CONTENT_KEYED = new Set( + "script style textarea option title select canvas iframe video audio".split( + " " + ) + ); + var NOT_INLINE_SELECTOR = ":not(" + [...INLINE_TEXT_TAGS].join(",") + ")"; + function walkElement(el, host) { + const realTag = RAW_UNWRAP[el.localName] || el.localName; + const tplId = el.getAttribute("data-dc-tpl"); + const inlineOnly = el.childNodes.length > 0 && !NEVER_CONTENT_KEYED.has(realTag) && el.querySelector(NOT_INLINE_SELECTOR) === null; + const keySuffix = inlineOnly ? "|" + contentKey(el) : ""; + const { propGetters, pseudoClasses } = collectProps(el, "dom", host); + const deckKeyed = isDeckMountTag(el) ? walkDeckChildren(el, host) : null; + const kids = deckKeyed ? deckKeyed.kids : walkChildren(el, host); + const kidKeys = deckKeyed?.keys ?? null; + return (vals, ctx, key) => { + const props = { + key: key + keySuffix, + "data-dc-tpl": tplId + }; + for (const [k, g] of propGetters) { + let v = g(vals); + if (k === "style" && typeof v === "string") v = cssToObj(v); + if ((k === "value" || k === "checked") && v === void 0) { + v = k === "checked" ? false : ""; + } + props[k] = v; + } + if (pseudoClasses.length) { + props.className = [props.className, ...pseudoClasses].filter(Boolean).join(" "); + } + return h(realTag, props, ...renderDeckKids(kids, kidKeys, vals, ctx)); + }; + } + + // src/logic.ts + var StreamableLogic = class { + constructor(props) { + __publicField(this, "props"); + __publicField(this, "state", {}); + /** Back-pointer to the wrapper component, installed after construction. */ + __publicField(this, "__host"); + this.props = props || {}; + } + setState(update, cb) { + this.__host && this.__host.__setLogicState(update, cb); + } + forceUpdate() { + this.__host && this.__host.forceUpdate(); + } + componentDidMount() { + } + componentDidUpdate(_prevProps) { + } + componentWillUnmount() { + } + /** The flat object the template renders against (merged over props). */ + renderVals() { + return {}; + } + }; + function evalDcLogic(src) { + //! nosemgrep: eval-and-function-constructor + const fn = new Function( + "DCLogic", + "StreamableLogic", + "React", + src + '\n;return (typeof Component!=="undefined"&&Component)||undefined;' + ); + return fn(StreamableLogic, StreamableLogic, getReact()); + } + + // src/component.ts + function shallowEqual(a, b) { + if (!b) return false; + const ak = Object.keys(a).filter((k) => k !== "children"); + const bk = Object.keys(b).filter((k) => k !== "children"); + if (ak.length !== bk.length) return false; + for (const k of ak) if (a[k] !== b[k]) return false; + return true; + } + function Placeholder({ + name, + hintSize, + streaming, + error + }) { + const [w, hgt] = (hintSize || "100%,60px").split(","); + return h( + "div", + { + className: "sc-placeholder" + (streaming ? " sc-streaming" : ""), + style: { width: w.trim(), height: hgt && hgt.trim() }, + title: name + }, + error ? h( + "div", + { className: "sc-placeholder-error" }, + (name ? name + ": " : "") + error + ) : null + ); + } + function hintToMin(hint) { + if (!hint) return void 0; + const [w, hgt] = hint.split(","); + return { minWidth: w.trim(), minHeight: hgt && hgt.trim() }; + } + function createComponentFactory(registry, ensureFetched) { + const React = getReact(); + const AncestorContext = React.createContext([]); + class StreamableComponent extends React.Component { + constructor(props) { + super(props); + __publicField(this, "__name"); + __publicField(this, "__sub"); + __publicField(this, "__needsDidMount", false); + /** Snapshot of the registry's streaming flags taken at render time — + * builders read it off the RenderCtx (this) to pick placeholder vs + * render-nothing for unresolved values. */ + __publicField(this, "__streamingNow", false); + __publicField(this, "__htmlStreamingNow", false); + /** When a construct throws, remember the (class, registry.ver, props) + * triple so render-time reconcile doesn't re-attempt it on every parent + * re-render. A registry bump (new class, template, external module + * resolving via bumpAll) changes `ver` and breaks the memo so an + * env-dependent constructor can self-heal. */ + __publicField(this, "__failedLogic", null); + __publicField(this, "__failedUserProps", null); + __publicField(this, "__failedVer", -1); + /** Per-instance constructor error — kept here (not on the registry entry) + * so one instance's successful construct can't hide a sibling's failure, + * and a construct can never wipe an eval error `updateJs` recorded on + * `r.logicError`. */ + __publicField(this, "__ctorError", null); + __publicField(this, "logic"); + this.__name = props.__name; + this.state = { __v: 0, __err: null }; + this.__sub = () => { + if (this.state.__err) this.setState({ __err: null }); + this.forceUpdate(); + }; + this.__makeLogic(registry.get(this.__name).Logic, null); + ensureFetched(this.__name); + } + /** Error-boundary hook: a render crash anywhere in this DC's subtree + * (its own template, an x-import'd component, a child DC without its + * own deeper boundary) lands here instead of unmounting the page. */ + static getDerivedStateFromError(e) { + return { __err: e instanceof Error && e.message ? e.message : String(e) }; + } + componentDidCatch(e, info) { + console.error( + "[dc-runtime] render error in <" + this.__name + ">:", + e, + info?.componentStack || "" + ); + } + /** Instantiate the logic class (or the no-op base) and adopt `prevState` + * over its initial state — used both at mount and on hot-swap. */ + __makeLogic(Logic, prevState) { + const L = Logic || StreamableLogic; + try { + this.logic = new L(this.__userProps()); + this.__failedLogic = null; + this.__failedUserProps = null; + this.__ctorError = null; + } catch (e) { + console.error(e); + this.__failedLogic = Logic; + this.__failedUserProps = this.__userProps(); + this.__failedVer = registry.get(this.__name).ver; + this.__ctorError = this.__name + ": " + (e instanceof Error && e.message ? e.message : String(e)); + this.logic = new StreamableLogic( + this.__userProps() + ); + } + this.logic.__host = this; + if (prevState) + this.logic.state = { ...this.logic.state || {}, ...prevState }; + } + /** The props the author's logic + template see — internal __-prefixed + * wiring stripped. */ + __userProps() { + const { __name, __hintSize, __tplId, __hostStyle, ...rest } = this.props; + return rest; + } + __setLogicState(update, cb) { + const prev = this.logic.state; + const patch = typeof update === "function" ? update(prev) : update; + this.logic.state = { ...prev, ...patch }; + this.setState((s) => ({ __v: s.__v + 1 }), cb); + } + /** Swap the logic instance when the registry's Logic class changed + * (streaming completion, hot reload). State carries over; didMount + * re-fires after the swap commits so refs exist. */ + __reconcileLogic() { + const r = registry.get(this.__name); + const Next = r.Logic; + const Cur = this.logic.constructor; + if (Next === Cur || !Next && Cur === StreamableLogic || Next === this.__failedLogic && r.ver === this.__failedVer && shallowEqual(this.__userProps(), this.__failedUserProps)) { + return; + } + if (!this.__needsDidMount) { + try { + this.logic.componentWillUnmount(); + } catch (e) { + console.error(e); + } + } + this.__makeLogic(Next, this.logic.state); + this.__needsDidMount = true; + } + componentDidMount() { + registry.get(this.__name).subs.add(this.__sub); + try { + this.logic.componentDidMount(); + } catch (e) { + console.error(e); + } + } + componentDidUpdate(prevProps) { + this.logic.props = this.__userProps(); + if (this.__needsDidMount) { + if (this.state.__err || !registry.get(this.__name).tpl) return; + this.__needsDidMount = false; + try { + this.logic.componentDidMount(); + } catch (e) { + console.error(e); + } + } else { + try { + this.logic.componentDidUpdate(prevProps); + } catch (e) { + console.error(e); + } + } + } + componentWillUnmount() { + registry.get(this.__name).subs.delete(this.__sub); + if (!this.__needsDidMount) { + try { + this.logic.componentWillUnmount(); + } catch (e) { + console.error(e); + } + } + } + render() { + const r = registry.get(this.__name); + const cls = "sc-host" + (r.htmlStreaming ? " sc-streaming-html" : "") + (r.jsStreaming ? " sc-streaming-js" : ""); + const hintStyle = r.htmlStreaming ? hintToMin(this.props.__hintSize) : void 0; + const hostStyle = this.props.__hostStyle || hintStyle ? { ...hintStyle || {}, ...this.props.__hostStyle || {} } : void 0; + const hostBase = { + className: cls, + style: hostStyle, + "data-sc-name": this.__name, + "data-dc-tpl": this.props.__tplId + }; + const chain = Array.isArray(this.context) ? this.context : []; + if (chain.includes(this.__name)) { + const cycle = [ + ...chain.slice(chain.indexOf(this.__name)), + this.__name + ].join(" \u2192 "); + return h( + "div", + { ...hostBase, className: cls + " sc-has-error" }, + h(Placeholder, { + name: this.__name, + hintSize: this.props.__hintSize, + error: "circular import: " + cycle + }) + ); + } + if (this.state.__err) { + return h( + "div", + { ...hostBase, className: cls + " sc-has-error" }, + h( + "div", + { className: "sc-logic-error", "data-omelette-chrome": "" }, + this.__name + ": " + this.state.__err + ), + h(Placeholder, { + name: this.__name, + hintSize: this.props.__hintSize, + error: this.state.__err + }) + ); + } + this.__reconcileLogic(); + if (!r.tpl) { + return h( + "div", + hostBase, + h(Placeholder, { name: this.__name, hintSize: this.props.__hintSize }) + ); + } + const userProps = this.__userProps(); + this.logic.props = userProps; + let vals = userProps; + let renderErr = r.logicError || this.__ctorError; + try { + vals = { ...userProps, ...this.logic.renderVals() || {} }; + } catch (e) { + console.error(e); + renderErr = this.__name + ".renderVals(): " + (e instanceof Error && e.message ? e.message : String(e)); + } + this.__streamingNow = !!(r.htmlStreaming || r.jsStreaming); + this.__htmlStreamingNow = !!r.htmlStreaming; + return h( + "div", + { ...hostBase, className: cls + (renderErr ? " sc-has-error" : "") }, + renderErr && h( + "div", + { className: "sc-logic-error", "data-omelette-chrome": "" }, + renderErr + ), + h( + AncestorContext.Provider, + { value: [...chain, this.__name] }, + r.tpl(vals, this) + ) + ); + } + } + __publicField(StreamableComponent, "contextType", AncestorContext); + const named = /* @__PURE__ */ new Map(); + function getDC(name) { + const hit = named.get(name); + if (hit) return hit; + function Dispatcher(p) { + const [, setTick] = React.useState(0); + React.useEffect(() => { + const sub = () => setTick((n) => n + 1); + registry.get(name).subs.add(sub); + return () => { + registry.get(name).subs.delete(sub); + }; + }, []); + ensureFetched(name); + return h(StreamableComponent, { ...p, __name: name }); + } + Dispatcher.displayName = name; + named.set(name, Dispatcher); + return Dispatcher; + } + return { + getDC, + StreamableComponent + }; + } + + // src/bundled.ts + function bundledBlob(url) { + const blobs = window.__resourceBlobs; + const b = blobs ? blobs[url.split("#")[0]] : void 0; + return b instanceof Blob ? b : null; + } + + // src/cdn.ts + var REACT_URL = "https://unpkg.com/react@18.3.1/umd/react.production.min.js"; + var REACT_SRI = "sha384-DGyLxAyjq0f9SPpVevD6IgztCFlnMF6oW/XQGmfe+IsZ8TqEiDrcHkMLKI6fiB/Z"; + var REACT_DOM_URL = "https://unpkg.com/react-dom@18.3.1/umd/react-dom.production.min.js"; + var REACT_DOM_SRI = "sha384-gTGxhz21lVGYNMcdJOyq01Edg0jhn/c22nsx0kyqP0TxaV5WVdsSH1fSDUf5YJj1"; + var BABEL_URL = "https://unpkg.com/@babel/standalone@7.29.0/babel.min.js"; + var BABEL_SRI = "sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y"; + function cdnScriptFor(url, sri) { + const res = window.__resources; + const v = res ? res[url] : void 0; + return typeof v === "string" && v ? { src: v } : { src: url, integrity: sri }; + } + + // src/external.ts + var isCustomElementName = (n) => !n.includes(".") && n.includes("-"); + function isRenderableType(g) { + if (typeof g === "function") return !isElementClass(g); + return typeof g === "object" && g !== null && typeof g.$$typeof === "symbol"; + } + function resolveDottedPath(root, name) { + let cur = root; + for (const seg of name.split(".")) { + if (cur == null) return void 0; + cur = cur[seg]; + } + return cur; + } + var GLOBAL_POLL_INTERVAL_MS = 50; + var GLOBAL_POLL_TIMEOUT_MS = 3e4; + function createExternalModules(onResolved) { + const cache = /* @__PURE__ */ new Map(); + let babelLoading = null; + const reportedMissing = /* @__PURE__ */ new Map(); + const polling = /* @__PURE__ */ new Set(); + function ensureBabel() { + if (window.Babel) return Promise.resolve(); + if (babelLoading) return babelLoading; + const babel = cdnScriptFor(BABEL_URL, BABEL_SRI); + babelLoading = new Promise((res, rej) => { + const s = document.createElement("script"); + s.src = babel.src; + if (babel.integrity) { + s.integrity = babel.integrity; + s.crossOrigin = "anonymous"; + } + s.onload = () => res(); + s.onerror = rej; + document.head.appendChild(s); + }); + return babelLoading; + } + const pending = /* @__PURE__ */ new Map(); + function load(kind, url, after) { + const existing = pending.get(url); + if (existing) return existing; + cache.set(url, null); + console.info("[dc-runtime] x-import: loading", url, "(" + kind + ")"); + const ready = Promise.all([ + kind === "jsx" ? ensureBabel() : Promise.resolve(), + after ?? Promise.resolve() + ]); + const p = ready.then(() => { + const pre = bundledBlob(url); + if (pre) return pre.text(); + return fetch(url).then((r) => { + if (!r.ok) throw new Error("HTTP " + r.status); + return r.text(); + }); + }).then((src) => { + const code = kind === "jsx" ? window.Babel.transform(src, { + filename: url, + presets: ["react", "typescript"] + }).code : src; + const module = { exports: {} }; + const before = new Set(Object.keys(window)); + //! nosemgrep: eval-and-function-constructor + new Function("React", "module", "exports", "require", code)( + getReact(), + module, + module.exports, + () => ({}) + ); + const globals = {}; + for (const k of Object.keys(window)) { + if (!before.has(k) && typeof window[k] === "function") { + globals[k] = window[k]; + } + } + cache.set(url, { mod: module.exports, globals }); + console.info( + "[dc-runtime] x-import: loaded", + url, + "\u2014 exports:", + Object.keys(module.exports), + "window globals:", + Object.keys(globals) + ); + onResolved(); + }).catch((e) => { + cache.set(url, { + mod: {}, + globals: {}, + error: "failed to load: " + (e instanceof Error && e.message ? e.message : String(e)) + }); + console.error( + "[dc-runtime] x-import: FAILED to load", + url, + "(" + kind + ")", + e + ); + onResolved(); + }); + pending.set(url, p); + return p; + } + function resolve2(url, name) { + const entry = cache.get(url); + if (!entry) return null; + const { mod, globals } = entry; + const C = mod && mod[name] || globals && globals[name] || typeof window !== "undefined" && window[name] || mod && mod.default; + if (typeof C === "function") return C; + const key = url + "\0" + name; + if (!reportedMissing.has(key)) { + reportedMissing.set( + key, + entry.error || 'no export named "' + name + '" (has: ' + Object.keys(mod).join(", ") + ")" + ); + console.error( + "[dc-runtime] x-import: module", + url, + "loaded but has no component named", + JSON.stringify(name), + "\u2014 available exports:", + Object.keys(mod), + "window globals:", + Object.keys(globals), + ". The module must `module.exports = {" + name + "}` or set `window." + name + "`." + ); + } + return null; + } + function waitForGlobal(name) { + if (polling.has(name)) return; + polling.add(name); + const started = Date.now(); + const isCE = isCustomElementName(name); + const tick = () => { + const found = isCE ? customElements.get(name) : isRenderableType(resolveDottedPath(window, name)); + if (found) { + polling.delete(name); + onResolved(); + return; + } + if (Date.now() - started >= GLOBAL_POLL_TIMEOUT_MS) { + console.warn( + "[dc-runtime] x-import: global", + JSON.stringify(name), + "never appeared on window after " + GLOBAL_POLL_TIMEOUT_MS + "ms" + ); + return; + } + setTimeout(tick, GLOBAL_POLL_INTERVAL_MS); + }; + setTimeout(tick, GLOBAL_POLL_INTERVAL_MS); + } + function resolveGlobal(url, name) { + const isCE = isCustomElementName(name); + if (!url) { + if (isCE) { + if (customElements.get(name)) return name; + waitForGlobal(name); + return null; + } + const g2 = resolveDottedPath(window, name); + if (isRenderableType(g2)) return g2; + waitForGlobal(name); + return null; + } + const entry = cache.get(url); + if (!entry) return null; + if (isCE && customElements.get(name)) return name; + const g = entry.globals[name] ?? resolveDottedPath(window, name); + if (isRenderableType(g)) return g; + if (name.includes(".")) return null; + const key = url + "\0global\0" + name; + if (!reportedMissing.has(key)) { + reportedMissing.set(key, null); + if (isCE && !customElements.get(name)) { + console.warn( + "[dc-runtime] x-import:", + url, + "loaded but no custom element", + JSON.stringify(name), + "is registered and window." + name + " is not a function \u2014 rendering <" + name + "> as an unknown element." + ); + } + } + return name; + } + function getError(url, name) { + const entry = cache.get(url); + if (entry?.error) return entry.error; + return reportedMissing.get(url + "\0" + name) || null; + } + return { load, resolve: resolve2, resolveGlobal, getError }; + } + function isElementClass(g) { + try { + return typeof g === "function" && typeof HTMLElement !== "undefined" && g.prototype instanceof HTMLElement; + } catch { + return false; + } + } + + // src/atomics.ts + var ATOMIC_CSS = ( + // layout + ".fx{display:flex}.col{display:flex;flex-direction:column}.grid{display:grid}.ac{align-items:center}.jc{justify-content:center}.jb{justify-content:space-between}.f1{flex:1}.noshrink{flex-shrink:0}.wrap{flex-wrap:wrap}.fw5{font-weight:500}.fw6{font-weight:600}.fw7{font-weight:700}.fw8{font-weight:800}.fs11{font-size:11px}.fs12{font-size:12px}.fs13{font-size:13px}.fs14{font-size:14px}.fs15{font-size:15px}.fs16{font-size:16px}.fs20{font-size:20px}.fs22{font-size:22px}.upper{text-transform:uppercase}.tc{text-align:center}.nowrap{white-space:nowrap}.gap8{gap:8px}.gap10{gap:10px}.gap12{gap:12px}.gap16{gap:16px}.gap24{gap:24px}.m0{margin:0}.mt8{margin-top:8px}.mt12{margin-top:12px}.mt16{margin-top:16px}.mb8{margin-bottom:8px}.mb12{margin-bottom:12px}.mb16{margin-bottom:16px}.posrel{position:relative}.posabs{position:absolute}.round{border-radius:50%}.ohide{overflow:hidden}.bbox{box-sizing:border-box}.pointer{cursor:pointer}.w100{width:100%}.b0{border:none}" + ); + + // src/helmet.ts + var DESIGN_DOC_MODE_RE = /]*\bname\s*=\s*["']design_doc_mode["'][^>]*\b(?:content|value)\s*=\s*["'](\w+)["']/i; + var CANVAS_BG_LIGHT = "#f0eee6"; + var CANVAS_BG_DARK = "#2e2c26"; + function createHelmetManager(doc, isStreaming) { + const mounted = /* @__PURE__ */ new Set(); + const live = /* @__PURE__ */ new Map(); + let designDocMode = null; + let canvasStyleEl = null; + let appTheme = "light"; + try { + const ds = doc.documentElement.dataset.theme; + appTheme = ds === "dark" || ds === "light" ? ds : new URLSearchParams(doc.defaultView?.location.search ?? "").get( + "theme" + ) === "dark" ? "dark" : "light"; + } catch { + } + function applyCanvasBg() { + if (!canvasStyleEl) return; + const bg = appTheme === "dark" ? CANVAS_BG_DARK : CANVAS_BG_LIGHT; + canvasStyleEl.textContent = `html,body{background:${bg}}#dc-root>.sc-host{position:relative}`; + } + function postDesignMode(mode) { + if (window.parent === window) return; + try { + window.parent.postMessage({ type: "__dc_design_mode", mode }, "*"); + } catch { + } + } + function setDesignDocMode(mode) { + if (mode === designDocMode) return; + designDocMode = mode; + postDesignMode(mode); + if (mode === "canvas") { + doc.documentElement.setAttribute("data-dc-canvas", ""); + canvasStyleEl = doc.createElement("style"); + canvasStyleEl.setAttribute("data-dc-canvas", ""); + applyCanvasBg(); + doc.head.appendChild(canvasStyleEl); + } else { + doc.documentElement.removeAttribute("data-dc-canvas"); + canvasStyleEl?.remove(); + canvasStyleEl = null; + } + } + window.addEventListener("message", (e) => { + const type = e.data && e.data.type; + if (type === "__dc_theme") { + const t = e.data.theme; + if (t === "light" || t === "dark") { + appTheme = t; + applyCanvasBg(); + } + return; + } + if (!designDocMode || type !== "__dc_probe") return; + postDesignMode(designDocMode); + }); + function compile(node) { + const raw = [...node.children]; + const helmetClosed = node.nextSibling != null || node.parentNode?.nextSibling != null; + if (node.hasAttribute("data-dc-atomics") && !mounted.has("__dc-atomics")) { + mounted.add("__dc-atomics"); + const el = doc.createElement("style"); + el.id = "__dc-atomics"; + el.textContent = ATOMIC_CSS; + doc.head.appendChild(el); + } + return (_vals, ctx) => { + const name = ctx && ctx.__name || ""; + const streaming = !!(name && isStreaming(name)); + for (let i = 0; i < raw.length; i++) { + const child = raw[i]; + const tag = child.tagName; + const mayBePartial = streaming && !helmetClosed && i === raw.length - 1; + if (tag === "SCRIPT") { + if (mayBePartial) continue; + const key = "SCRIPT|" + (child.getAttribute("src") || child.textContent || ""); + if (mounted.has(key)) continue; + mounted.add(key); + const el = doc.createElement("script"); + for (const { name: an, value } of [...child.attributes]) + el.setAttribute(an, value); + if (child.textContent) el.textContent = child.textContent; + doc.head.appendChild(el); + } else if (tag === "LINK" || tag === "META") { + if (mayBePartial) continue; + const key = tag + "|" + (child.getAttribute("href") || child.getAttribute("src") || child.outerHTML); + if (mounted.has(key)) continue; + mounted.add(key); + if (tag === "LINK") { + const rel = (child.getAttribute("rel") || "").toLowerCase().split(/\s+/); + const href = (child.getAttribute("href") || "").trim(); + const res = window.__resources; + const pre = res && rel.includes("stylesheet") && !rel.includes("alternate") ? res[href] : void 0; + const blob = typeof pre === "string" && pre ? bundledBlob(pre) : null; + if (blob) { + const el = doc.createElement("style"); + if (child.hasAttribute("disabled")) { + el.setAttribute("media", "not all"); + } else if (child.getAttribute("media")) { + el.setAttribute("media", child.getAttribute("media")); + } + if (child.getAttribute("title")) + el.setAttribute("title", child.getAttribute("title")); + void blob.text().then((css) => { + el.textContent = css; + }); + doc.head.appendChild(el); + continue; + } + } + doc.head.appendChild(child.cloneNode(true)); + } else { + const key = name + "|" + i; + let el = live.get(key); + if (!el || el.tagName !== tag) { + if (el) el.remove(); + el = doc.createElement(tag.toLowerCase()); + live.set(key, el); + doc.head.appendChild(el); + } + for (const { name: an, value } of [...child.attributes]) { + if (el.getAttribute(an) !== value) el.setAttribute(an, value); + } + if (el.textContent !== child.textContent) + el.textContent = child.textContent; + } + } + return null; + }; + } + return { compile, setDesignDocMode }; + } + + // src/pseudo.ts + function scanUnquotedUrl(css, i) { + if (css[i] !== "u" && css[i] !== "U" || css.slice(i, i + 4).toLowerCase() !== "url(" || /[a-z0-9_-]/i.test(css[i - 1] ?? "")) { + return -1; + } + let j = i + 4; + while (j < css.length && /\s/.test(css[j])) j++; + if (css[j] === '"' || css[j] === "'") return -1; + while (j < css.length && css[j] !== ")") { + if (css[j] === "\\") j++; + j++; + } + return j < css.length ? j + 1 : css.length; + } + function stripComments(css) { + let out = ""; + let quote = ""; + for (let i = 0; i < css.length; i++) { + const c = css[i]; + if (quote) { + if (c === "\\") { + out += c + (css[i + 1] ?? ""); + i++; + continue; + } + if (c === quote) quote = ""; + out += c; + } else if (c === "'" || c === '"') { + quote = c; + out += c; + } else if (c === "/" && css[i + 1] === "*") { + const end = css.indexOf("*/", i + 2); + i = end === -1 ? css.length : end + 1; + out += " "; + } else { + const end = scanUnquotedUrl(css, i); + if (end === -1) out += c; + else { + out += css.slice(i, end); + i = end - 1; + } + } + } + return out; + } + function importantify(css) { + css = stripComments(css); + const decls = []; + let start = 0; + let depth = 0; + let quote = ""; + for (let i = 0; i < css.length; i++) { + const c = css[i]; + if (quote) { + if (c === "\\") i++; + else if (c === quote) quote = ""; + } else if (c === "'" || c === '"') quote = c; + else if (c === "(") depth++; + else if (c === ")") depth = Math.max(0, depth - 1); + else if (c === ";" && depth === 0) { + decls.push(css.slice(start, i)); + start = i + 1; + } else { + const end = scanUnquotedUrl(css, i); + if (end !== -1) i = end - 1; + } + } + decls.push(css.slice(start)); + return decls.map((d) => d.trim()).filter(Boolean).map((d) => /!\s*important$/i.test(d) ? d : d + " !important").join(";"); + } + function createPseudoSheet(doc) { + let el = null; + const cache = /* @__PURE__ */ new Map(); + let n = 0; + return (pseudo, css) => { + const k = pseudo + "|" + css; + const hit = cache.get(k); + if (hit) return hit; + if (!el) { + el = doc.createElement("style"); + doc.head.appendChild(el); + } + const cls = "scp" + (n++).toString(36); + const isPseudoElement = pseudo === "before" || pseudo === "after"; + const sel = isPseudoElement ? "." + cls + "::" + pseudo : "." + cls + ":" + pseudo; + el.sheet.insertRule( + sel + "{" + (isPseudoElement ? css : importantify(css)) + "}", + el.sheet.cssRules.length + ); + cache.set(k, cls); + return cls; + }; + } + + // src/registry.ts + function createRegistry() { + const entries = /* @__PURE__ */ Object.create(null); + function get(name) { + return entries[name] || (entries[name] = { + html: "", + tpl: null, + Logic: null, + jsStreaming: false, + htmlStreaming: false, + ver: 0, + subs: /* @__PURE__ */ new Set(), + fetched: false + }); + } + function bump(name) { + const r = get(name); + r.ver++; + for (const fn of r.subs) fn(); + } + return { + entries, + get, + bump, + bumpAll() { + for (const n in entries) bump(n); + } + }; + } + + // src/runtime.ts + var COMPONENT_DIR = "."; + function createRuntime(doc = document) { + const registry = createRegistry(); + const pseudoClass = createPseudoSheet(doc); + const helmet = createHelmetManager( + doc, + (name) => registry.get(name).htmlStreaming + ); + const external = createExternalModules(() => registry.bumpAll()); + const factory = createComponentFactory(registry, ensureFetched); + const host = { + component: (name) => factory.getDC(name), + placeholder: (props) => h(Placeholder, props), + helmet: (node) => helmet.compile(node), + loadExternal: (kind, url, after) => external.load(kind, url, after), + resolveExternal: (url, name) => external.resolve(url, name), + resolveExternalGlobal: (url, name) => external.resolveGlobal(url, name), + resolveExternalError: (url, name) => external.getError(url, name), + pseudoClass + }; + function ensureFetched(name) { + const r = registry.get(name); + if (r.fetched) return; + r.fetched = true; + const url = COMPONENT_DIR + "/" + encodeURIComponent(name) + ".dc.html"; + const res = window.__resources; + const pre = res ? res[url] : void 0; + const target = typeof pre === "string" && pre ? pre : url; + const blob = bundledBlob(target); + (blob ? blob.text() : fetch(target).then((res2) => { + if (!res2.ok) { + console.error( + '[dc-runtime] sibling fetch for "' + name + '" failed:', + url, + "returned", + res2.status, + "\u2014 the reference renders as an empty placeholder." + ); + return ""; + } + return res2.text(); + })).then((t) => { + if (!t) return; + const parsed = parseDcText(t); + if (!parsed) { + console.error( + '[dc-runtime] sibling fetch for "' + name + '":', + url, + "has no block \u2014 not a Design Component." + ); + return; + } + if (parsed.props) r.propsMeta = parsed.props; + if (parsed.preview) r.preview = parsed.preview; + if (parsed.template && !r.html) updateHtml(name, parsed.template); + if (parsed.js && !r.Logic) updateJs(name, parsed.js); + }).catch( + (e) => console.error( + '[dc-runtime] sibling fetch for "' + name + '" threw:', + url, + e + ) + ); + } + let rootName = null; + function updateHtml(name, html) { + const r = registry.get(name); + r.html = html; + if (name === rootName) { + const mode = DESIGN_DOC_MODE_RE.exec(html)?.[1] ?? null; + if (mode || !r.htmlStreaming) helmet.setDesignDocMode(mode); + } + try { + r.tpl = compileTemplate(html, host); + } catch (e) { + console.error("[dc-runtime] template compile FAILED for", name, e); + } + registry.bump(name); + } + function updateJs(name, src) { + const r = registry.get(name); + const seq = r.jsSeq = (r.jsSeq || 0) + 1; + try { + const Cls = evalDcLogic(src); + if (r.jsSeq !== seq) return; + if (typeof Cls !== "function") { + r.logicError = name + ".dc.html: + + + + + + + + + + +
+
3去掉跨主机分屏 · 新建分屏交互 · 会话标题 · 首页与关闭/最小化
+
+
跨主机分屏已移除
分屏只发生在同一台主机内,层级回到「主机 → 会话 → window → pane」四层。要同时看两台机器,用轨道或侧边栏一键切换。第 2 轮的 2b 稿已作废删除。
+
新建分屏
不问方向(竖分/横分本就是两个按钮),只列这台主机的 tmux 会话,末尾追加「原生窗口」,选完立即分屏。见 3b
+
tmux 会话标题
会话行第二行显示当前 window title,window 行显示各自 title(仍是默认 zsh 时退回 cwd),不新增层级。见 3a
+
首页入口
默认遮罩态:288pt 面板盖住轨道(非并排,故无两套入口),点终端即收起;点 Pin 才钉在左侧、终端变窄。栏内仍是上「活动会话」下「TMUX」两段。见 3a
+
关闭 / 最小化
沿用 macOS 红黄圆点:黄点最小化不确认,红点关闭才确认;tmux 默认仅断开,原生窗口明说进程会结束。见 3c
+
对你方案的两点修改
① 最小化后落地页卡片置顶并标「最小化中」,配缩回动画,否则用户不知道东西去哪了;② 关闭的确认按主机能力分成两套文案,tmux 不默认 kill(只有次要按钮才是 kill)。见 3d
+
+
+
+
3a侧边栏:遮罩态与 Pin 态 折叠与展开只是同一条栏的两个状态,元素不增不减:折叠 56pt 收成图标(搜索 / 首页 / 主机 / TMUX / 设置 / 展开键),展开 288pt 显示完整标题。左上角 是 App logo,不可点;开合只有一枚按钮——折叠态是轨道底部的 »,展开态是标题右侧的 «,方向相反不歧义。左=默认遮罩态:288pt 面板盖住 56pt 轨道(不是并排,因此没有两套入口),右侧压一层遮罩,点终端任意处即收起。右=点 Pin 之后:面板钉在左侧、遮罩消失、终端变窄并只 resize 一次;再点一次 Pin 回到遮罩态。栏内布局沿用轨道的分区:上半「活动会话」,下半「TMUX(当前主机的 window 列表)」。
+
+
+
+
+
+
+
+
+
ms
u2
nh
+
+
+
TMUX
+
0
1
2
+
+
+
+
+
+
+
+
+
+
terminalx git:(main) git fetch --all --prune
Fetching origin … done
terminalx git:(main) swift build -c release --arch arm64
[142/142] Compiling TXCore
[ 8/8 ] Linking TerminalX
✓ build complete (14.2s)
 
terminalx git:(main) swift test --package-path packages/TXCore
◇ Test run started.
parses %output escaped octal
layout diff keeps pane identity
DCS passthrough detected
backoff caps at 30s
mosh three-phase resume
◇ 50 tests passed after 0.412s
 
terminalx git:(main) xcodegen generate --spec project.yml
✅ Created TerminalX.xcodeproj
 
terminalx git:(main) git status --short
M apps/TerminalX/iOS/RailSidebar.swift
?? docs/ui/sidebar-states.md
 
terminalx git:(main) git fetch --all --prune
 
terminalx git:(main)
+
+
+
+
0.1 · 日志 · log stream --style compact168×17
+
12:03:12 terminalx: ghostty frame 16.7ms
12:03:41 terminalx: tmux %layout-change
12:04:02 terminalx: session dev attached
12:04:31 terminalx: link degraded, rtt 210ms
12:04:33 terminalx: mosh resume ok, 0 lost
12:05:02 terminalx: scrollback trimmed 4k
12:05:44 terminalx: pasteboard bridge ok
12:06:20 terminalx: window title → "运行测试"
12:03:12 terminalx: ghostty frame 16.7ms
12:03:41 terminalx: tmux %layout-change
12:04:02 terminalx: session dev attached
12:04:31 terminalx: link degraded, rtt 210ms
12:04:33 terminalx: mosh resume ok, 0 lost
12:05:02 terminalx: scrollback trimmed 4k
12:05:44 terminalx: pasteboard bridge ok
12:06:20 terminalx: window title → "运行测试"
12:03:12 terminalx: ghostty frame 16.7ms
+
+
+
+
tsnet
+
+
mosh41ms
+
+
tmuxdev
+
+
+
+
+ + mac-studio + 运行测试 · ~/src/terminalx +
+
遮罩态 · 点终端任意处收起⌘\ 收起Pin 钉在左侧⌘⇧H 回首页 切换会话
+
+
+
+
+
+
+ terminalX +
+
+
+
+
+
搜索主机 · 会话
+
首页⌘⇧H
+
活动会话3
+
+
ms
mac-studio · dev
运行测试 · 3 windows
41ms
+
u2
ubuntu-cn2 · build
集成测试 · 1 window
186ms
+
nh
nas-home
原生窗口 · ~
重连中
+
连接新主机…
+
+
+
TMUX · mac-studio · dev
+
+
0
运行测试
~/src/terminalx · 2 panes
+
1
日志
~/src/terminalx
+
2
nvim Theme.swift
packages/TXCore
+
新建 window ⌘T
+
+ +
Tailscale 已连接
ipad-pro · 100.92.14.7
+
+
+
+
+
+
+
+
+ terminalX +
+
+
+
+
+
搜索主机 · 会话
+
首页⌘⇧H
+
活动会话3
+
+
ms
mac-studio · dev
运行测试 · 3 windows
41ms
+
u2
ubuntu-cn2 · build
集成测试 · 1 window
186ms
+
nh
nas-home
原生窗口 · ~
重连中
+
连接新主机…
+
+
+
TMUX · mac-studio · dev
+
+
0
运行测试
~/src/terminalx · 2 panes
+
1
日志
~/src/terminalx
+
2
nvim Theme.swift
packages/TXCore
+
新建 window ⌘T
+
+ +
Tailscale 已连接
ipad-pro · 100.92.14.7
+
+
+
+
+
terminalx git:(main) git fetch --all --prune
Fetching origin … done
terminalx git:(main) swift build -c release --arch arm64
[142/142] Compiling TXCore
[ 8/8 ] Linking TerminalX
✓ build complete (14.2s)
 
terminalx git:(main) swift test --package-path packages/TXCore
◇ Test run started.
parses %output escaped octal
layout diff keeps pane identity
DCS passthrough detected
backoff caps at 30s
mosh three-phase resume
◇ 50 tests passed after 0.412s
 
terminalx git:(main) xcodegen generate --spec project.yml
✅ Created TerminalX.xcodeproj
 
terminalx git:(main) git status --short
M apps/TerminalX/iOS/RailSidebar.swift
?? docs/ui/sidebar-states.md
 
terminalx git:(main) git fetch --all --prune
 
terminalx git:(main)
+
+
+
+
0.1 · 日志 · log stream --style compact132×17
+
12:03:12 terminalx: ghostty frame 16.7ms
12:03:41 terminalx: tmux %layout-change
12:04:02 terminalx: session dev attached
12:04:31 terminalx: link degraded, rtt 210ms
12:04:33 terminalx: mosh resume ok, 0 lost
12:05:02 terminalx: scrollback trimmed 4k
12:05:44 terminalx: pasteboard bridge ok
12:06:20 terminalx: window title → "运行测试"
12:03:12 terminalx: ghostty frame 16.7ms
12:03:41 terminalx: tmux %layout-change
12:04:02 terminalx: session dev attached
12:04:31 terminalx: link degraded, rtt 210ms
12:04:33 terminalx: mosh resume ok, 0 lost
12:05:02 terminalx: scrollback trimmed 4k
12:05:44 terminalx: pasteboard bridge ok
12:06:20 terminalx: window title → "运行测试"
12:03:12 terminalx: ghostty frame 16.7ms
+
+
+
+
tsnet
+
+
mosh41ms
+
+
tmuxdev
+
+
+
+
+ + mac-studio + 运行测试 · ~/src/terminalx +
+
Pin 态 · 终端变窄,仅在钉住/解除时各 resize 一次Pin 解除钉住⌘\ 收起为轨道⌘⇧H 回首页
+
+
+
+
+
+
+
+
3b新建分屏:只问"分到哪个会话" 竖分 / 横分本来就是两个不同的按钮(⌘D / ⌘⇧D,触控是轨道 + 的两枚小图标),所以菜单不再问方向。菜单只列出这台主机上的 tmux 会话(含当前会话、可接管的 detached 会话、新建会话),最后追加一项原生窗口。选完立即分屏——此稿右侧就是选中「dev(当前会话)」后竖分的结果。
+
+
+
+
+
+
+
+
ms
u2
nh
+
+
+
TMUX
+
0
1
2
+
+
+
+
+
+
+
+
+
+
terminalx git:(main) swift package resolve
Fetching TXCore … resolved
Fetching swift-log 1.6.1 … resolved
 
terminalx git:(main) xcodebuild -showsdks | head -2
iOS SDKs: iOS 26.2 (iphoneos26.2)
macOS SDKs: macOS 26.2 (macosx26.2)
 
terminalx git:(main) git fetch --all --prune
Fetching origin … done
terminalx git:(main) swiftformat --lint Sources
✓ 0 issues in 84 files
 
terminalx git:(main) xcrun simctl list devices booted
-- iOS 26.2 --
iPad Pro 13-inch (M4) (Booted)
 
terminalx git:(main) swift package resolve
Fetching TXCore … resolved
Fetching swift-log 1.6.1 … resolved
 
terminalx git:(main) xcodebuild -showsdks | head -2
iOS SDKs: iOS 26.2 (iphoneos26.2)
macOS SDKs: macOS 26.2 (macosx26.2)
terminalx git:(main) git fetch --all --prune
Fetching origin … done
terminalx git:(main) swift build -c release --arch arm64
[142/142] Compiling TXCore
[ 8/8 ] Linking TerminalX
✓ build complete (14.2s)
 
terminalx git:(main) swift test --package-path packages/TXCore
◇ Test run started.
parses %output escaped octal
layout diff keeps pane identity
DCS passthrough detected
backoff caps at 30s
mosh three-phase resume
◇ 50 tests passed after 0.412s
 
terminalx git:(main) xcodegen generate --spec project.yml
✅ Created TerminalX.xcodeproj
 
terminalx git:(main) git status --short
M apps/TerminalX/iOS/RailSidebar.swift
M apps/TerminalX/iOS/SessionRouter.swift
?? docs/ui/home-and-close.md
 
 
terminalx git:(main)
+
+
+
+
0.1 · 日志 · log stream --style compact
+
12:03:12 terminalx: ghostty frame 16.7ms
12:03:41 terminalx: tmux %layout-change
12:04:02 terminalx: session dev attached
12:04:31 terminalx: link degraded, rtt 210ms
12:04:33 terminalx: mosh resume ok, 0 lost
12:05:02 terminalx: scrollback trimmed 4k
12:05:44 terminalx: pasteboard bridge ok
12:06:20 terminalx: window title → "运行测试"
12:06:41 terminalx: tmux %window-renamed 0
12:03:12 terminalx: ghostty frame 16.7ms
12:03:41 terminalx: tmux %layout-change
12:04:02 terminalx: session dev attached
12:04:31 terminalx: link degraded, rtt 210ms
12:04:33 terminalx: mosh resume ok, 0 lost
12:05:02 terminalx: scrollback trimmed 4k
12:05:44 terminalx: pasteboard bridge ok
12:06:20 terminalx: window title → "运行测试"
12:06:41 terminalx: tmux %window-renamed 0
12:03:12 terminalx: ghostty frame 16.7ms
12:03:41 terminalx: tmux %layout-change
12:04:02 terminalx: session dev attached
12:04:31 terminalx: link degraded, rtt 210ms
12:04:33 terminalx: mosh resume ok, 0 lost
12:05:02 terminalx: scrollback trimmed 4k
12:05:44 terminalx: pasteboard bridge ok
12:06:20 terminalx: window title → "运行测试"
12:06:41 terminalx: tmux %window-renamed 0
12:03:12 terminalx: ghostty frame 16.7ms
12:03:41 terminalx: tmux %layout-change
12:04:02 terminalx: session dev attached
12:04:31 terminalx: link degraded, rtt 210ms
12:04:33 terminalx: mosh resume ok, 0 lost
12:05:02 terminalx: scrollback trimmed 4k
12:05:44 terminalx: pasteboard bridge ok
12:06:20 terminalx: window title → "运行测试"
12:06:41 terminalx: tmux %window-renamed 0
12:03:12 terminalx: ghostty frame 16.7ms
12:03:41 terminalx: tmux %layout-change
12:04:02 terminalx: session dev attached
12:04:31 terminalx: link degraded, rtt 210ms
12:04:33 terminalx: mosh resume ok, 0 lost
12:05:02 terminalx: scrollback trimmed 4k
12:05:44 terminalx: pasteboard bridge ok
12:06:20 terminalx: window title → "运行测试"
+
+
+
+
tsnet
+
+
mosh41ms
+
+
tmuxdev
+
+
+
+
+ + mac-studio + 运行测试 · ~/src/terminalx +
+
竖分屏 · 选择分到哪个会话⌘D 竖分⌘⇧D 横分↑↓ 移动 立即分屏
+
+
+
+
竖分屏到…mac-studio
+
+
tmux 会话
+
+ +
dev
运行测试 · 3 windows · 4 panes
+ + 当前 +
+
+ +
scratch
zsh · 1 window · detached
+ + 3 天前 +
+
+ +
deploy
部署脚本 · 2 windows · detached
+ + 昨天 +
+
新建 tmux 会话…
+
+
原生窗口
不经 tmux · 关 App 即结束
+
+
+
+
+
+
+
+
3c关闭 / 最小化:红黄两枚圆点 标题胶囊左侧沿用 macOS 语汇: = 关闭, = 最小化,悬停/触摸才显出符号。黄点不确认,直接回首页、会话继续跑(卡片置顶标「最小化中」)。红点弹一次确认:tmux 默认仅断开,结束会话是次要按钮;原生窗口的文案换成"前台进程会结束"。
+
+
+
+
+
+
+
+
ms
u2
nh
+
+
+
TMUX
+
0
1
2
+
+
+
+
+
+
+
+
+
+
terminalx git:(main) git fetch --all --prune
Fetching origin … done
terminalx git:(main) swift build -c release --arch arm64
[142/142] Compiling TXCore
[ 8/8 ] Linking TerminalX
✓ build complete (14.2s)
 
terminalx git:(main) swift test --package-path packages/TXCore
◇ Test run started.
parses %output escaped octal
layout diff keeps pane identity
DCS passthrough detected
backoff caps at 30s
mosh three-phase resume
◇ 50 tests passed after 0.412s
 
terminalx git:(main) xcodegen generate --spec project.yml
✅ Created TerminalX.xcodeproj
 
terminalx git:(main) git status --short
M apps/TerminalX/iOS/RailSidebar.swift
M apps/TerminalX/iOS/SessionRouter.swift
?? docs/ui/home-and-close.md
 
 
terminalx git:(main)
+
+
+
+
0.1 · 日志 · log stream --style compact80×17
+
12:03:12 terminalx: ghostty frame 16.7ms
12:03:41 terminalx: tmux %layout-change
12:04:02 terminalx: session dev attached
12:04:31 terminalx: link degraded, rtt 210ms
12:04:33 terminalx: mosh resume ok, 0 lost
12:05:02 terminalx: scrollback trimmed 4k
12:05:44 terminalx: pasteboard bridge ok
12:06:20 terminalx: window title → "运行测试"
12:06:41 terminalx: tmux %window-renamed 0
12:03:12 terminalx: ghostty frame 16.7ms
12:03:41 terminalx: tmux %layout-change
12:04:02 terminalx: session dev attached
12:04:31 terminalx: link degraded, rtt 210ms
12:04:33 terminalx: mosh resume ok, 0 lost
12:05:02 terminalx: scrollback trimmed 4k
12:05:44 terminalx: pasteboard bridge ok
12:06:20 terminalx: window title → "运行测试"
+
+
+
+
tsnet
+
+
mosh41ms
+
+
tmuxdev
+
+
+
+
+ + mac-studio + 运行测试 · ~/src/terminalx +
+
关闭当前会话 · 等待确认⌘W 关闭⌘M 最小化 仅断开esc 取消
+
+
+
+
+
关闭 mac-studio · dev?
+
tmux 会话(3 windows / 4 panes)。仅断开后服务端继续跑,下次原样接回。
+
+
+ 结束会话 +
+ 取消 + 仅断开 ⏎ +
+
+
+
+
对照原生窗口(无 tmux)
+
+
关闭 nas-home · 窗口 1?
+
没有 tmux 兜底,前台进程会随之结束。想留着就点黄点最小化。
+
+
+ 取消 + 关闭窗口 +
+
+
+
+
+
+
+
3d首页 ⇄ 终端:四条路径与返回规则 把两屏之间所有出入口画在一起。进入只有两种(恢复已有会话 / 新建会话),离开有三种(首页键、最小化、关闭),三者都落到同一张落地页——差别只在会话的去向:继续跑 / 后台 detach / 结束。换会话不必回首页,侧边栏里点一下即可。
+
+
+
+

首页(主机管理) ⇄ 终端主界面

+
App 冷启动永远落在首页;终端是"进入某个会话"的结果,不是独立 tab。
+
+
+
+
首页 · 主机管理(落地页)
+
活动会话(最上排)
三张缩略卡:前台运行 / 后台 detach / 重连中,点卡片即进终端
全部主机(下排)
点主机 → 若有会话则列出可接管的,否则直接新建
从最小化回来
卡片右上角标「最小化中」,点一下瞬间恢复,光标位置不变
冷启动 / ⌘⇧H
都停在这里,不自动进任何会话
+
+
+
进入终端
+
+ +
恢复已有会话
点活动会话卡 / 主机下的会话行 → 直接进终端,恢复到上一个焦点 pane
+
+
+ +
新建会话
点「新建连接」或主机上的 + → 建好即进终端,侧边栏同步出现该会话
+
+
离开终端
+
+ +
首页键(侧边栏第一行)
会话完全不动,随时切回;用于"去看看别的主机"
+
+
+ +
最小化(黄点)
等同首页键,但明确表达"我待会儿还回来",卡片置顶并标记
+
+
+ +
关闭(红点)· 二次确认
tmux 默认 detach(后台跑);原生窗口会结束进程,文案单独写
+
+
+
+
终端主界面 · 沉浸轨道
+
侧边栏第一行:首页
折叠态显示为图标,展开态显示完整标题;同一条栏、同一个位置,⌘⇧H 随时可用
标题胶囊左侧:红点 / 黄点
红点关闭需二次确认后回首页;黄点最小化不确认,直接回首页、会话继续
活动会话就在侧边栏
切会话不必回首页,回首页是"换任务"而不是"切窗口"
Pin 后常驻
侧边栏钉在左侧时,切会话与看终端可同时进行,无需任何中间层
+
+
+
+
+ +
建议一:"最小化"在 iPad 上没有 Dock 承接,容易被理解成"藏起来找不到了"。仍保留这枚按钮,但把落点讲清楚——回到首页后该卡片置顶并标记「最小化中」,动画从终端缩回卡片,用户就知道东西去哪了。
+
+
+ +
建议二:"关闭"对 tmux 与原生窗口的后果完全不同,别用同一句确认。tmux 默认 detach 而非 kill,把 kill 放在第二项;原生窗口则明说"进程会结束"。另外给最小化配下滑手势、关闭配 ⌘W,二次确认支持 ⏎ 直接选推荐项。
+
+
+
+
+
+
+
+ +
+
2沉浸轨道深化 · 无 tmux 主机 / 竖屏降级(跨主机分屏、浮层侧边栏、会话总览三稿已作废)
+
+
展开策略(已更新)
原「浮层压在轨道旁」的方案已作废(会出现两套入口)。现行方案:遮罩态面板盖住轨道,Pin 后才推挤终端。见第 3 轮 3a
+
分屏模型(已更新)
跨主机分屏已取消,分屏只在同一台主机内:tmux pane 由服务端持有、断线保持;原生窗口不经 tmux。新建交互见第 3 轮 3b
+
无 tmux 主机
轨道隐藏 TMUX 段,换成客户端「窗口」段;胶囊第三格写"无 tmux"。可随时新建与 tmux 无关的窗口。见 2c
+
竖屏降级
iPad 竖屏:轨道 48pt,pane 改上下堆叠,最多两层,多余折成段控。iPhone:轨道下沉为底部主机条,单 pane + 轻扫。见 2d
+
会话总览(已取消)
「⌘O 缩放成卡片」的总览层已去掉,轨道底部相应按钮也移除。切会话直接用侧边栏,换任务用「首页」。跳转关系见第 3 轮 3d
+
保持不变
状态徽标顺序(tsnet → mosh RTT → tmux/窗口)、Nocturne chrome + Catppuccin 终端、硬件键盘时附加行自动收起——五张新稿全部沿用。
+
+
+
+
2c主机没有 tmux:轨道收起 TMUX 段 轨道下半段不再显示 tmux window 数字条,改为客户端「窗口」段(每个窗口 = 一条独立 SSH channel / mosh 会话),可随意新建。状态胶囊第三格显示「无 tmux」而不是报错。若探测到装了 tmux 但没会话,才在终端顶部给一条可忽略的行内提示——不弹窗、不强推。
+
+
+
+
+
+
+
+
ms
u2
nh
+
+
+
窗口
1
2
+
+
+
+
+
+
+
+
+
+
nas:~$ zfs get compressratio tank
tank compressratio 1.34x
 
nas:~$ docker stats --no-stream | head -3
NAME CPU % MEM USAGE
plex 4.10% 1.21GiB
syncthing 0.80% 312MiB
 
nas:~$ zfs get compressratio tank
tank compressratio 1.34x
nas:~$ uname -sr
Linux 6.12.9-truenas
 
nas:~$ docker ps --format 'table {{.Names}}'
NAMES STATUS
plex Up 6 days
syncthing Up 6 days
caddy Up 12 hours
postgres Up 6 days (healthy)
 
nas:~$ zpool status -x
all pools are healthy
 
nas:~$ df -h /tank
Filesystem Size Used Avail Use%
tank 18T 11T 7.0T 62%
 
nas:~$ zfs list -t snapshot | tail -3
tank/photos@auto-2026-07-24 1.2G
tank/docs@auto-2026-07-24 84M
tank/vm@auto-2026-07-24 3.4G
 
nas:~$ tail -4 /var/log/syncthing.log
folder "photos" sync complete
folder "docs" idle
puller: 0 conflicts
scanner: 12,418 files ok
 
nas:~$ smbstatus --shares | head -3
Service pid Machine
media 4412 100.92.14.7
 
nas:~$ uptime
12:08:03 up 41 days, 3:22, 1 user
 
nas:~$ uname -sr
Linux 6.12.9-truenas
 
 
nas:~$
+
+
+
+ +
这台主机上没有 tmux。窗口由 terminalX 客户端维持,mosh 负责断线续传;但关掉 App 后不会像 tmux 那样在服务端继续跑
+
安装并启用 tmux
+ +
+
+
tsnet
+
+
mosh88ms
+
+
无 tmux · 客户端窗口
+
+
nas-home~ · 窗口 1 / 2 · 132×46
+
客户端窗口模式 · 每个窗口一条独立通道⌘T 新建窗口⌘1…9 切窗口⌘W 关闭窗口⌘K 命令面板
+
+
+
+
+
+
+
2d竖屏 iPad 与 iPhone 竖屏宽度不够并排,规则改为:轨道保留但降到 48pt(拇指可达的左缘),pane 由左右平铺改为上下堆叠,最多两层,第三个 pane 折成顶部段控。状态胶囊压缩成一枚。iPhone 再降一级:轨道变成底部主机条,单 pane 全屏,pane 之间左右轻扫,下拉标题打开会话列表。
+
+
+
+
+
+
+
+
+
ms
+
u2
+
nh
+
+
0
+
1
+
+
+
+
+
+
terminalx git:(main) git fetch --all --prune
Fetching origin … done
terminalx git:(main) swiftformat --lint Sources
✓ 0 issues in 84 files
 
terminalx git:(main) xcodebuild -showsdks | head -2
terminalx git:(main) swift build -c release --arch arm64
[142/142] Compiling TXCore
[ 8/8 ] Linking TerminalX
✓ build complete (14.2s)
 
terminalx git:(main) swift test --package-path packages/TXCore
◇ Test run started.
↳ Suite "tmux control mode"
parses %output escaped octal
layout diff keeps pane identity
DCS passthrough detected
%window-add spawns pane tree
↳ Suite "SessionMachine"
backoff caps at 30s
mosh three-phase resume
host-key pin survives wipe
◇ 50 tests passed after 0.412s
 
terminalx git:(main) xcodegen generate --spec project.yml
✅ Created TerminalX.xcodeproj
 
terminalx git:(main) xcrun simctl list devices booted
-- iOS 26.2 --
iPad Pro 13-inch (M4) (Booted)
 
terminalx git:(main) git pull --rebase
From tsnet:athom/terminalx main
Successfully rebased onto main.
 
terminalx git:(main) git status --short
M apps/TerminalX/iOS/ContentView.swift
M apps/TerminalX/iOS/Theme.swift
 
terminalx git:(main)
+
+
+
+
0.1 · log stream --style compact2/3 pane · 第 3 个已折叠
+
12:02:31 terminalx: tsnet peer refresh (7)
12:02:58 terminalx: mosh heartbeat 38ms
12:03:12 terminalx: ghostty frame 16.7ms
12:03:41 terminalx: tmux %layout-change
12:04:02 terminalx: session dev attached
12:04:31 terminalx: link degraded, rtt 210ms
12:04:33 terminalx: mosh resume ok, 0 lost
12:05:02 terminalx: scrollback trimmed 4k
12:05:44 terminalx: pasteboard bridge ok
12:06:02 terminalx: ghostty frame 16.4ms
12:06:20 terminalx: tsnet peer nas-home down
12:06:41 terminalx: retry scheduled in 8s
12:07:03 terminalx: tmux %window-add 3
12:01:02 terminalx: tmux %output window 0
12:01:19 terminalx: mosh heartbeat 41ms
12:01:44 terminalx: pane 0.1 resized 60x18
12:02:03 terminalx: keychain read host-key
12:02:31 terminalx: tsnet peer refresh (7)
12:02:58 terminalx: mosh heartbeat 38ms
12:03:12 terminalx: ghostty frame 16.7ms
12:03:41 terminalx: tmux %layout-change
12:04:02 terminalx: session dev attached
12:04:31 terminalx: link degraded, rtt 210ms
12:04:33 terminalx: mosh resume ok, 0 lost
12:05:02 terminalx: scrollback trimmed 4k
12:05:44 terminalx: pasteboard bridge ok
12:06:02 terminalx: ghostty frame 16.4ms
12:06:20 terminalx: tsnet peer nas-home down
12:06:41 terminalx: retry scheduled in 8s
12:07:03 terminalx: tmux %window-add 3
+
+
+
+
mac-studiodev
+
41ms
+
+
+
esc
ctrl
tab
~
/
|
+
+
+
+
+
+
+
+
9:41 78%
+
+ +
mac-studio · dev
+
41ms
+
+
左右轻扫切 pane
+
tx git:(main) git fetch --all --prune
Fetching origin … done
tx git:(main) swiftformat --lint Sources
✓ 0 issues in 84 files
 
tx git:(main) xcodebuild -showsdks | head -2
tx git:(main) swift build -c release --arch arm64
[142/142] Compiling TXCore
[ 8/8 ] Linking TerminalX
✓ build complete (14.2s)
 
tx git:(main) swift test --package-path packages/TXCore
◇ Test run started.
↳ Suite "tmux control mode"
parses %output escaped octal
layout diff keeps pane identity
DCS passthrough detected
%window-add spawns pane tree
↳ Suite "SessionMachine"
backoff caps at 30s
mosh three-phase resume
host-key pin survives wipe
◇ 50 tests passed after 0.412s
 
tx git:(main) xcodegen generate --spec project.yml
✅ Created TerminalX.xcodeproj
 
tx git:(main) xcrun simctl list devices booted
-- iOS 26.2 --
iPad Pro 13-inch (M4) (Booted)
 
tx git:(main) git pull --rebase
From tsnet:athom/terminalx main
Successfully rebased onto main.
 
 
tx git:(main)
+
+
esc
ctrl
tab
~
/
|
+
⌘K
+
+
+
ms
+
u2
+
nh
+ +
+
+
+
+
+
+
+
+ +
+
1terminalX · iPadOS 优先 · 选定方案:1b 沉浸轨道终端 + 1e 会话缩略卡
+ +
+
+
+
设计前提
+
画布尺寸 iPad Pro 13″ 横屏 1366×1024pt(1:1)。App chrome 全部走 Nocturne 令牌;终端内容区保留 Catppuccin Mocha,与 chrome 通过同一深靛蓝底相接。文案简体中文,技术术语(mosh / tmux / tsnet)保留英文。
+
+
+
分层状态徽标
+
三层链路各占一枚徽标,从下到上:tsnet(网络可达)→ mosh RTT(传输健康,数字实时跳动,>200ms 转琥珀)→ tmux 会话名(复用层)。断哪层哪层变色,其余保持中性——避免"全红恐慌"。
+
+
+
iPad 输入取舍
+
触控时底部常驻 44pt 键盘附加行(Esc/Ctrl/方向/PgUp);检测到硬件键盘则整行收起、只留一条 4pt 提示带,把面积还给终端。pane 分隔线为 12pt 拖拽热区。
+
+
+
+ +
+
选定终端主界面 · 沉浸轨道1b — chrome 全部浮起,终端面积最大
+
+
1b终端 · 沉浸轨道 边栏压成 56pt 图标轨道(搜索 · 首页 · 主机 · TMUX window · 设置 · 展开键一应俱全,只是收成图标),chrome 全部浮起为胶囊,终端面积最大。点轨道底部的展开键或 ⌘\ 变成 288pt 完整侧边栏,见 3a
+
+
+
+
+
+
+
+
ms
u2
nh
+
+
+
TMUX
+
0
1
2
+
+
+
+
+
+
+
+
+
+
terminalx git:(main) git fetch --all --prune
Fetching origin … done
terminalx git:(main) swiftformat --lint Sources
✓ 0 issues in 84 files
terminalx git:(main) swift build -c release --arch arm64
[142/142] Compiling TXCore
[ 8/8 ] Linking TerminalX
✓ build complete (14.2s)
 
terminalx git:(main) swift test --package-path packages/TXCore
◇ Test run started.
↳ Suite "tmux control mode"
parses %output escaped octal
layout diff keeps pane identity
DCS passthrough detected
%window-add spawns pane tree
↳ Suite "SessionMachine"
backoff caps at 30s
mosh three-phase resume
host-key pin survives wipe
◇ 50 tests passed after 0.412s
 
terminalx git:(main) xcodegen generate --spec project.yml
✅ Created TerminalX.xcodeproj
 
terminalx git:(main) xcrun simctl list devices booted
 
terminalx git:(main)
+
+
+
+
0.1 · ubuntu-cn2 · journalctl -f80×14
+
12:01:19 cn2 mosh-server[891]: heartbeat 41ms
12:01:44 cn2 build-runner: job 39 finished
12:02:03 cn2 sshd[9931]: session opened for ci
12:02:31 cn2 tailscaled: derp hkg 24ms
12:02:58 cn2 mosh-server[891]: heartbeat 38ms
12:03:12 cn2 systemd: rotated build.log
12:03:41 cn2 ninja: 148/148 objects
12:04:12 cn2 tailscaled: endpoint DIRECT (udp)
12:04:31 cn2 kernel: wlan0 rssi -74
12:04:33 cn2 mosh-server: roaming → :51004
12:04:33 cn2 mosh-server: resume ok, 0 lost
12:05:02 cn2 systemd: Started build-runner@42
12:05:44 cn2 sshd: Accepted publickey for ci
12:06:02 cn2 runner: artifact 18.4 MB uploaded
12:06:20 cn2 tailscaled: peer nas-home offline
12:06:41 cn2 cron: rotated /var/log/build.log
12:07:03 cn2 systemd: build-runner@42 ok
+
+
+
+
tsnet
+
+
mosh41ms
+
+
tmuxdev
+
+
+ mac-studio~/src/terminalx · 168×44 +
+
+ 已连接妙控键盘 · 附加行已收起 + ⌘K 命令面板⌘D 竖分屏⌘⇧D 横分屏⌃⇥ 切 window长按 ⌘ 全部快捷键 +
+
+
+
+
+
+
+
选定主机管理 · 会话缩略卡1e — 活动会话优先,回到刚才那个会话是首要动作
+
+
1e主机 · 会话缩略卡 把「回到刚才那个会话」当作首要动作:上排是活着的会话(真实终端缩略图 + 最后一行输出),下排才是主机网格。适合 2–4 个并行会话来回跳。
+
+
+
9:41 周五 Wi-Fi · 78%
+
+
+

terminalX

+
3 个会话在跑 · 7 台主机 · Tailscale athom.ts.net
+
+
+
搜索⌘K
+
新建连接
+
+
+
+
活动会话
+
+
+
mac-studiotmux dev41ms
+
tx git:(main) git fetch --all --prune
Fetching origin … done
tx git:(main) swiftformat --lint Sources
✓ 0 issues in 84 files
 
tx git:(main) swift build -c release --arch arm64
[142/142] Compiling TXCore
[ 8/8 ] Linking TerminalX
✓ build complete (14.2s)
 
tx git:(main) swift test --package-path packages/TXCore
◇ Test run started.
↳ Suite "tmux control mode"
parses %output escaped octal
layout diff keeps pane identity
DCS passthrough detected
%window-add spawns pane tree
↳ Suite "SessionMachine"
backoff caps at 30s
mosh three-phase resume
host-key pin survives wipe
◇ 50 tests passed after 0.412s
 
tx git:(main) xcodegen generate --spec project.yml
✅ Created TerminalX.xcodeproj
 
tx git:(main) xcrun simctl list devices booted
-- iOS 26.2 --
iPad Pro 13-inch (M4) (Booted)
 
 
tx git:(main)
+
3 windows · 4 panes · 12 秒前⌘1回到会话
+
+
+
ubuntu-cn2tmux build186ms
+
cn2 ~ journalctl -u build-runner -n 3 --no-pager
12:05:02 Started build-runner@42
12:06:02 build-runner@42 succeeded
 
cn2 ~ systemctl status build-runner --no-pager
● build-runner.service — active (running)
since Fri 09:12:03 CST; 2h 54min ago
 
cn2 ~ ninja -C build -j 8
[148/148] Linking libmosh-core.a
✓ ninja: no work to do.
 
cn2 ~ ./run-integration.sh --tsnet
▸ joining tailnet as ci-runner…
▸ peer enumeration: 7 nodes
▸ mosh relay loopback:60122 ↔ tsnet
▸ handshake ok, AES-OCB session up
roaming: peer addr switched
resume ok in 1.8s, 0 bytes lost
suspend 90s → wake
resume ok in 2.4s
▸ 12/14 passed, 2 flaky (retrying)
↻ tmux/resize-under-loss
↻ tsnet/derp-failover
 
cn2 ~ tailscale netcheck | head -5
* UDP: true
* IPv4: yes, 203.0.113.9:41641
* Nearest DERP: Hong Kong
 
cn2 ~
+
1 window · relay hkg · 4 分钟前⌘2回到会话
+
+
+
nas-home重连中
+
nas:~$ zfs get compressratio tank
tank compressratio 1.34x
 
nas:~$ docker stats --no-stream | head -3
NAME CPU % MEM USAGE
plex 4.10% 1.21GiB
nas:~$ uname -sr
Linux 6.12.9-truenas
 
nas:~$ docker ps --format 'table {{.Names}}'
NAMES STATUS
plex Up 6 days
syncthing Up 6 days
caddy Up 12 hours
postgres Up 6 days (healthy)
 
nas:~$ zpool status -x
all pools are healthy
 
nas:~$ df -h /tank
Filesystem Size Used Avail Use%
tank 18T 11T 7.0T 62%
 
nas:~$ zfs list -t snapshot | tail -3
tank/photos@auto-2026-07-24 1.2G
tank/docs@auto-2026-07-24 84M
tank/vm@auto-2026-07-24 3.4G
 
nas:~$ tail -4 /var/log/syncthing.log
folder "photos" sync complete
folder "docs" idle
 
nas:~$
mosh 会话保活中,8s 后第 3 次重连
屏幕内容已缓存,恢复后原样接回
+
tsnet 节点离线 · 2 分钟前⌘3立即重试
+
+
+
全部主机
全部生产家里
+
+
+
mac-studio
macOS · M4 Max
+
moshtsnetSE key
+
+
+
ubuntu-cn2
Ubuntu 26.04
+
moshtsnet
+
+
+
edge-sg1
Debian 13
+
SSHtsnet
+
+
+
edge-sg2
离线 · 4 小时
+
SSH
+
+
+
nas-home
重连中
+
mosh
+
+
+
pi-lab
Raspberry Pi OS
+
SSH
+
+
+
mosh-lab
Fedora 44
+
mosh
+
+
添加主机
+
+
+
+
+
+
+
共用屏设置 · iPhone 对照跟随选定方案
+ +
+
1g设置 · 外观 左类别、右内容。核心是「app chrome 与终端同源换肤」:选一套 Catppuccin,chrome 的中性阶跟着走,Nocturne 的 blurple 始终作为唯一强调色不被覆盖。
+
+
+
9:41 周五 Wi-Fi · 78%
+
+
+

设置

+
外观与主题
终端与字体
Tailscale
密钥与安全
mosh 与重连
tmux 集成
键盘与手势
诊断日志
+
terminalX 0.6 (M4) · libghostty · mosh 1.4 · tsnet
+
+
+
+

外观与主题

+
App chrome 与终端配色同源:切一次,两边一起变。强调色恒为 Nocturne blurple。
+
+
+
Catppuccin 主题
+
+
+
+
~ git status
M Theme.swift
+
+
+
Mocha
+
+
+
+
~ git status
M Theme.swift
+
+
+
Macchiato
+
+
+
+
~ git status
M Theme.swift
+
+
+
Frappé
+
+
+
+
~ git status
M Theme.swift
+
+
+
Latte浅色
+
+
+
+
+
+
实时预览
+
terminalx git:(main) ls -l packages
drwxr-xr-x yz staff 192B TXCore
drwxr-xr-x yz staff 256B TXTransport
-rw-r--r-- yz staff 4.1K Package.swift
 
terminalx git:(main) git diff --stat
SessionMachine.swift | ++++++++---
TmuxController.swift | +++++-
2 files changed, 13(+), 4(-)
 
terminalx git:(main) 中文等宽对齐测试:终端渲染 CJK
宽字符占两格 emoji 归一
制表符对齐 连字 -> ≠
└─ 行高 1.50 · 字号 13.5pt
 
terminalx git:(main) swift build 2>&1 | tail -4
error: cannot find 'foo' in scope
warning: unused variable 'gate'
note: add explicit _ = to silence
✓ 编译完成 · 14.2s
 
terminalx git:(main) tailscale status | head -3
100.71.203.4 mac-studio direct
100.88.9.61 ubuntu-cn2 relay hkg
100.104.2.18 nas-home offline
 
terminalx git:(main) tmux list-panes
0.0 [124x38] zsh (active)
0.1 [ 60x18] btop
0.2 [ 60x19] tail -f build.log
 
terminalx git:(main) echo $TERM
xterm-256color · truecolor
 
terminalx git:(main)
+
+
+
排版
+
+
终端字体
JetBrains Mono
+
字号13.5pt
+
行高1.50
+
连字(ligatures)
-> ≠ 合并显示
+
CJK 等宽补偿
宽字符严格占两格
+
+
+
跟随系统浅/深色
深色 Mocha ⇄ 浅色 Latte
+
+
+ +
终端配色由 libghostty 渲染;chrome 的中性阶与所选主题同源推导,强调色始终取 Nocturne blurple。
+
+
+
+
+
+
+
+
+
+ + +
+
1hiPhone 紧凑对照 同一套令牌降级:边栏→底部 tab,pane 平铺→单 pane + 顶部 pane 段控,附加行两行滚动。
+
+
+
+
9:41 78%
+
+ +
mac-studio
tmux dev · 0 build
+
41ms
+ +
+
+
0.0 zsh
+
0.1 btop
+
0.2 log
+ 80×34 +
+
tx git:(main) git fetch --all --prune
Fetching origin … done
tx git:(main) swiftformat --lint Sources
✓ 0 issues in 84 files
 
tx git:(main) xcodebuild -showsdks | head -2
iOS SDKs: iOS 26.2 (iphoneos26.2)
tx git:(main) swift build -c release --arch arm64
[142/142] Compiling TXCore
[ 8/8 ] Linking TerminalX
✓ build complete (14.2s)
 
tx git:(main) swift test --package-path packages/TXCore
◇ Test run started.
↳ Suite "tmux control mode"
parses %output escaped octal
layout diff keeps pane identity
DCS passthrough detected
%window-add spawns pane tree
↳ Suite "SessionMachine"
backoff caps at 30s
mosh three-phase resume
host-key pin survives wipe
◇ 50 tests passed after 0.412s
 
tx git:(main) xcodegen generate --spec project.yml
✅ Created TerminalX.xcodeproj
 
tx git:(main) xcrun simctl list devices booted
-- iOS 26.2 --
iPad Pro 13-inch (M4) (Booted)
 
tx git:(main) git pull --rebase
From tsnet:athom/terminalx main
Successfully rebased onto main.
 
tx git:(main) git status --short
M apps/TerminalX/iOS/ContentView.swift
M apps/TerminalX/iOS/Theme.swift
?? docs/ui/ipad-rail.md
 
 
tx git:(main)
+
+
esc
ctrl
tab
~
/
|
+
⌘K
+
+
+
+
+
+
+
9:41 78%
+

主机

+
搜索
+
活动会话
+
+
+
mac-studio
tmux dev · 3 windows
41ms
+
swift test --package-path TXCore
◇ 50 tests passed after 0.412s
+
+
nas-home
重连中 · 8s 后第 3 次
重试
+
+
全部主机
+
+
ubuntu-cn2
ci@100.88.9.61 · mosh
+
edge-sg1
root@100.77.4.2 · SSH
+
nas-home
yz@100.104.2.18
+
pi-lab
pi@100.104.2.9 · SSH
+
edge-sg2
离线 4 小时
+
+
Tailscale 已连接
iphone · 100.92.14.11
+
主机
会话
密钥
设置
+
+
+
+
+
+
+
+

下一步:细化 1b 的 pane 拖拽 / 缩放态,或把 1e1b 串成可点原型。

+
+
+ + diff --git a/packages/TXCore/Sources/TXCore/Tmux/TmuxSessionProbe.swift b/packages/TXCore/Sources/TXCore/Tmux/TmuxSessionProbe.swift new file mode 100644 index 0000000..7b00f94 --- /dev/null +++ b/packages/TXCore/Sources/TXCore/Tmux/TmuxSessionProbe.swift @@ -0,0 +1,102 @@ +import Foundation + +/// 在**普通 shell**(还没进 control mode)里探测服务器的 tmux 能力与会话列表。 +/// +/// 为什么需要它:点主机连接时要先让用户挑「attach 哪个会话 / 新建 / 跳过用原生终端」, +/// 而这个选择必须在进 control mode **之前**做出 —— 一旦发了 `tmux -CC new` 就已经建了会话。 +/// +/// **PTY 回显坑**:命令本身会被 PTY 回显。若标记字面量出现在命令里,回显行会被误当成结果起点。 +/// 故命令里把标记写成相邻字符串拼接(`"__TX""_SESSIONS_BEGIN"`):shell 输出完整标记, +/// 回显的却是拼接前的样子,扫描器只认完整标记,天然不会命中回显。 +/// +/// **为什么要 `tmux -V`**:`tmux list-sessions` 在「没装 tmux」和「装了但没有会话」两种情况下 +/// 都只往 stderr 写东西(被 `2>/dev/null` 吞掉),标记之间同样是空的 —— 只靠会话行分不开这两态, +/// 而它们的正确 UI 完全不同(装安装引导 vs 让用户新建会话)。版本行用来定这一刀。 +public struct TmuxSessionProbe: Sendable { + public static let beginMark = "__TX_SESSIONS_BEGIN" + public static let endMark = "__TX_SESSIONS_END" + /// 会话行前缀(与版本行区分)。 + public static let sessionPrefix = "S" + /// 字段分隔符:与 `TmuxController.fieldSep` 同源理由(tab 经 PTY 会被替换成下划线)。 + public static let fieldSep = "|:|" + + /// 要发进 shell 的探测命令(自带换行)。用 `printf` 而非 `echo`:跨 shell(fish/bash/zsh)一致。 + /// + /// **末尾清屏**:命令本身会被 PTY 回显、结果也会打在屏幕上,用户不该看见这些内部细节。 + /// 探测发生在刚连上、用户还没输入任何东西时,清掉的只有 motd/shell 欢迎语,换来干净的首屏。 + /// 用裸 ANSI(`[H` 归位 + `[2J` 清屏 + `[3J` 清 scrollback)而非 `clear`:不依赖 terminfo。 + public static var command: String { + let begin = #"printf '%s\n' "__TX""_SESSIONS_BEGIN""# + let end = #"printf '%s\n' "__TX""_SESSIONS_END""# + let version = "tmux -V 2>/dev/null" + let list = "tmux list-sessions -F '\(sessionPrefix)\(fieldSep)#{session_name}\(fieldSep)#{session_windows}\(fieldSep)#{session_attached}\(fieldSep)#{session_activity}' 2>/dev/null" + let clear = #"printf '\033[H\033[2J\033[3J'"# + return "\(begin); \(version); \(list); \(end); \(clear)\n" + } + + /// 一条会话记录(探测阶段拿到的最小信息)。 + public struct Session: Equatable, Sendable { + public let name: String + public let windows: Int + public let isAttached: Bool + /// `#{session_activity}`(Unix 秒)→ 选择器副行的「N 分钟前」。 + public let lastActivity: Date? + + public init(name: String, windows: Int, isAttached: Bool, lastActivity: Date? = nil) { + self.name = name + self.windows = windows + self.isAttached = isAttached + self.lastActivity = lastActivity + } + } + + public enum Result: Equatable, Sendable { + /// tmux 可用。`sessions` 可能为空 —— 装了但还没有任何会话,UI 只给「新建 / 跳过」。 + case available(version: String, sessions: [Session]) + /// 没有 tmux(`tmux -V` 没输出)→ 走原生终端 + 安装引导横幅。 + case unavailable + } + + private var buffer = "" + private var inBlock = false + private var version: String? + private var sessions: [Session] = [] + private var done = false + + public init() {} + + /// 流式喂入终端输出。命中 END 标记时返回结果;之后恒返回 nil(一次性)。 + public mutating func feed(_ text: String) -> Result? { + guard !done else { return nil } + buffer += text + // 按行切:PTY 行尾是 CRLF,而 Swift 视 "\r\n" 为单个 Character,故用 isNewline 判定。 + while let idx = buffer.firstIndex(where: { $0.isNewline }) { + let raw = String(buffer[buffer.startIndex ..< idx]) + buffer.removeSubrange(buffer.startIndex ... idx) + let line = raw.trimmingCharacters(in: .whitespacesAndNewlines) + if !inBlock { + if line == Self.beginMark { inBlock = true } + continue + } + if line == Self.endMark { + done = true + guard let version else { return .unavailable } + return .available(version: version, sessions: sessions) + } + if line.hasPrefix(Self.sessionPrefix + Self.fieldSep) { + if let s = Self.parseSession(line) { sessions.append(s) } + } else if line.hasPrefix("tmux ") { + version = line // 例:tmux 3.7b + } + } + return nil + } + + /// `S|:|name|:|windows|:|attached|:|activity` → Session + static func parseSession(_ line: String) -> Session? { + let f = line.components(separatedBy: fieldSep) + guard f.count >= 4, f[0] == sessionPrefix, !f[1].isEmpty, let w = Int(f[2]) else { return nil } + let activity = f.count >= 5 ? Double(f[4]).map { Date(timeIntervalSince1970: $0) } : nil + return Session(name: f[1], windows: w, isAttached: f[3] == "1", lastActivity: activity) + } +} diff --git a/packages/TXCore/Tests/TXCoreTests/TmuxSessionProbeTests.swift b/packages/TXCore/Tests/TXCoreTests/TmuxSessionProbeTests.swift new file mode 100644 index 0000000..859f5de --- /dev/null +++ b/packages/TXCore/Tests/TXCoreTests/TmuxSessionProbeTests.swift @@ -0,0 +1,69 @@ +import Foundation +import Testing +@testable import TXCore + +@Suite("tmux 会话探测(连接前选择用)") +struct TmuxSessionProbeTests { + /// 命令里的标记必须是**拼接写法** —— 否则 PTY 回显会被误当成结果起点。 + @Test("命令不含完整标记字面量,避免 PTY 回显误命中") + func commandAvoidsEchoSelfMatch() { + let cmd = TmuxSessionProbe.command + #expect(!cmd.contains(TmuxSessionProbe.beginMark)) + #expect(!cmd.contains(TmuxSessionProbe.endMark)) + #expect(cmd.contains("tmux -V")) + #expect(cmd.contains("list-sessions")) + } + + @Test("解析会话列表(CRLF 行尾 + 版本行)") + func parsesSessions() { + var p = TmuxSessionProbe() + // 先喂一段回显噪声(含拼接前的命令文本),不应触发。 + #expect(p.feed("kid@Air ~> printf '%s\\n' \"__TX\"\"_SESSIONS_BEGIN\"\r\n") == nil) + #expect(p.feed("\(TmuxSessionProbe.beginMark)\r\n") == nil) + #expect(p.feed("tmux 3.7b\r\n") == nil) + #expect(p.feed("S|:|main|:|5|:|0|:|1784988802\r\n") == nil) + #expect(p.feed("S|:|demo|:|2|:|1|:|1784901778\r\n") == nil) + let result = p.feed("\(TmuxSessionProbe.endMark)\r\n") + guard case .available(let version, let sessions) = result else { + Issue.record("期望 available,实际 \(String(describing: result))") + return + } + #expect(version == "tmux 3.7b") + #expect(sessions.count == 2) + #expect(sessions[0] == TmuxSessionProbe.Session( + name: "main", windows: 5, isAttached: false, + lastActivity: Date(timeIntervalSince1970: 1784988802))) + #expect(sessions[1].name == "demo") + #expect(sessions[1].isAttached) + // 一次性:命中后不再返回结果。 + #expect(p.feed("S|:|late|:|1|:|0|:|1\r\n") == nil) + } + + @Test("装了 tmux 但没有会话 → available 且列表为空(UI 只给新建/跳过)") + func availableWithNoSessions() { + var p = TmuxSessionProbe() + _ = p.feed("\(TmuxSessionProbe.beginMark)\n") + _ = p.feed("tmux 3.5a\n") + let r = p.feed("\(TmuxSessionProbe.endMark)\n") + #expect(r == .available(version: "tmux 3.5a", sessions: [])) + } + + @Test("没装 tmux(无版本行)→ unavailable,直接走原生终端") + func unavailableWithoutVersion() { + var p = TmuxSessionProbe() + _ = p.feed("\(TmuxSessionProbe.beginMark)\n") + let r = p.feed("\(TmuxSessionProbe.endMark)\n") + #expect(r == .unavailable) + } + + @Test("畸形会话行被跳过而非崩溃") + func skipsMalformedLines() { + var p = TmuxSessionProbe() + _ = p.feed("\(TmuxSessionProbe.beginMark)\ntmux 3.7\n") + _ = p.feed("S|:|\n") // 字段不足 + _ = p.feed("S|:||:|3|:|0\n") // 空名字 + _ = p.feed("S|:|ok|:|x|:|0\n") // windows 非数字 + let r = p.feed("\(TmuxSessionProbe.endMark)\n") + #expect(r == .available(version: "tmux 3.7", sessions: [])) + } +} diff --git a/packages/TXTransport/Sources/TXTransport/SSHSession.swift b/packages/TXTransport/Sources/TXTransport/SSHSession.swift index 0f4b9c5..9799c22 100644 --- a/packages/TXTransport/Sources/TXTransport/SSHSession.swift +++ b/packages/TXTransport/Sources/TXTransport/SSHSession.swift @@ -102,6 +102,30 @@ public final class SSHSession: Transport, @unchecked Sendable { // MARK: - Connect / Handshake + /// 带超时的 TCP connect:临时置非阻塞 → connect → `poll` 等可写 → 校验 SO_ERROR → 恢复阻塞。 + /// 不用裸 `Darwin.connect`:那会阻塞到系统 TCP 超时(75s+),UI 上就是长时间「连接中」。 + private static func connectWithTimeout(_ fd: Int32, _ addr: UnsafeMutablePointer, + _ len: socklen_t, seconds: Int) -> Bool { + let flags = fcntl(fd, F_GETFL, 0) + guard flags >= 0, fcntl(fd, F_SETFL, flags | O_NONBLOCK) >= 0 else { + return Darwin.connect(fd, addr, len) == 0 // 拿不到 flags 时退回阻塞语义 + } + defer { _ = fcntl(fd, F_SETFL, flags) } // 恢复阻塞(libssh2 用阻塞模式) + + if Darwin.connect(fd, addr, len) == 0 { return true } + guard errno == EINPROGRESS else { return false } + + var pfd = pollfd(fd: fd, events: Int16(POLLOUT), revents: 0) + let ready = withUnsafeMutablePointer(to: &pfd) { poll($0, 1, Int32(seconds * 1000)) } + guard ready == 1, pfd.revents & Int16(POLLOUT) != 0 else { return false } + + // 可写不代表成功:必须查 SO_ERROR(拒绝连接也会让 fd 变可写)。 + var soErr: Int32 = 0 + var soLen = socklen_t(MemoryLayout.size) + guard getsockopt(fd, SOL_SOCKET, SO_ERROR, &soErr, &soLen) == 0, soErr == 0 else { return false } + return true + } + private func connect() throws { let fd: Int32 if preconnectedFD >= 0 { @@ -124,12 +148,16 @@ public final class SSHSession: Transport, @unchecked Sendable { while let addr = node { dialed = socket(addr.pointee.ai_family, addr.pointee.ai_socktype, addr.pointee.ai_protocol) if dialed >= 0 { - if Darwin.connect(dialed, addr.pointee.ai_addr, addr.pointee.ai_addrlen) == 0 { break } + if Self.connectWithTimeout(dialed, addr.pointee.ai_addr, + addr.pointee.ai_addrlen, + seconds: config.connectTimeoutSec) { break } close(dialed); dialed = -1 } node = addr.pointee.ai_next } - guard dialed >= 0 else { throw SSHError.socket("连接失败: \(config.host):\(config.port)") } + guard dialed >= 0 else { + throw SSHError.socket("连接失败(\(config.connectTimeoutSec)s 超时): \(config.host):\(config.port)") + } fd = dialed } diff --git a/packages/TXTransport/Sources/TXTransport/Transport.swift b/packages/TXTransport/Sources/TXTransport/Transport.swift index 354a4f8..3b9ac0b 100644 --- a/packages/TXTransport/Sources/TXTransport/Transport.swift +++ b/packages/TXTransport/Sources/TXTransport/Transport.swift @@ -35,6 +35,9 @@ public struct SSHConfig: Sendable { /// (trusted 或 firstUse 已 pin),false=拒绝(mismatch)→ SSHSession 断开并抛 hostKeyMismatch。 /// nil 时不校验(历史行为)。校验/pin 的存储与 UI 由 app 层实现。 public var hostKeyVerifier: (@Sendable (Data, Int32) -> Bool)? + /// TCP connect 超时(秒)。**必须有**:`Darwin.connect` 默认阻塞到系统 TCP 超时(75s+), + /// 连不上的主机会让用户对着「连接中」干等。tsnet 路径(preconnectedFD)不经此处。 + public var connectTimeoutSec: Int public enum Authentication: Sendable { case password(String) @@ -51,9 +54,11 @@ public struct SSHConfig: Sendable { terminalType: String = "xterm-256color", initialCols: UInt16 = 80, initialRows: UInt16 = 24, - hostKeyVerifier: (@Sendable (Data, Int32) -> Bool)? = nil + hostKeyVerifier: (@Sendable (Data, Int32) -> Bool)? = nil, + connectTimeoutSec: Int = 12 ) { self.host = host + self.connectTimeoutSec = connectTimeoutSec self.port = port self.username = username self.authentication = authentication diff --git a/scripts/tx-shot.sh b/scripts/tx-shot.sh new file mode 100755 index 0000000..64a8de2 --- /dev/null +++ b/scripts/tx-shot.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +# 无头截图取证:装 → 启(可带启动参数)→ 截图 → 修正方向。 +# +# 本环境没有 GUI,模拟器帧缓冲恒为竖屏;带 -txLandscape 1 时 app 会经 +# requestGeometryUpdate 把「内容」转成横屏(设计基准 iPad Pro 13″ 1366×1024), +# 但帧缓冲仍是竖的,故截完用 sips 转回来,Read 出来才是设计稿的方向。 +# +# 用法: +# scripts/tx-shot.sh /tmp/shot.png # 只截当前画面 +# scripts/tx-shot.sh /tmp/shot.png -- -txUIFixture 1 -txLandscape 1 # 重启并带参数 +set -euo pipefail + +UDID="${TX_UDID:-2EA9055E-4557-4B20-82AF-56A68AC1D454}" +BUNDLE=ai.athom.terminalx +OUT="${1:?usage: tx-shot.sh [-- ]}" +shift || true + +if [ "${1:-}" = "--" ]; then + shift + APP=$(find ~/Library/Developer/Xcode/DerivedData/TerminalX-*/Build/Products/Debug-iphonesimulator \ + -maxdepth 1 -name TerminalX.app | head -1) + [ -n "$APP" ] || { echo "TerminalX.app not found — build first" >&2; exit 1; } + xcrun simctl terminate "$UDID" "$BUNDLE" 2>/dev/null || true + xcrun simctl install "$UDID" "$APP" + xcrun simctl launch "$UDID" "$BUNDLE" --args "$@" >/dev/null + # 等 surface attach + OutputGate flush(tmux/多 pane 场景需要更久) + sleep "${TX_SETTLE:-6}" + LANDSCAPE=0 + for a in "$@"; do [ "$a" = "-txLandscape" ] && LANDSCAPE=1; done +else + LANDSCAPE="${TX_LANDSCAPE:-1}" +fi + +xcrun simctl io "$UDID" screenshot "$OUT" >/dev/null 2>&1 +if [ "$LANDSCAPE" = "1" ]; then + sips -r 90 "$OUT" >/dev/null # landscapeRight 内容 → 正向 +fi +sips -g pixelWidth -g pixelHeight "$OUT" | tail -2 +echo "wrote $OUT"