feat: UI 商业化改造 + 连接前 tmux 会话选择(真机验证)
按 Claude Design 定稿(归档在 docs/design/)重做 UI,并把 tmux 从 「从不自动进」修成「连接前探测 → 让用户选会话」。 设计令牌与导航地基 - Theme 拆三层:TXAccent(恒定 blurple) / TXChrome(中性阶×4 表) / TXFlavor(Catppuccin 终端) - vendor JetBrains Mono 四权重(附 OFL 许可) - AppRouter + SessionManager:多会话并存,路由与会话生命周期解耦 - SessionCanvas 常驻挂载会话 surface(摘除即丢内容,也是缩回动画的前提) 按设计稿落地的屏 - 沉浸轨道页:56pt 轨道 + 浮起标题/状态胶囊 + 侧边栏三态(遮罩不 resize、Pin 各一次) - 首页:活动会话卡(readViewportText 文本镜像)+ 主机网格 + 筛选 chips - 关闭二次确认(tmux 仅断开 / 原生窗口两套文案)、分屏菜单、pane 拖拽条 - 空状态:首次运行 / 无会话 / 搜索无命中 / 连接中·失败·已关闭 tmux 真实链路(真机查出并修掉 4 个 bug) - 全代码库从来没人发起 attach → 连接前探测 + 会话选择器(接回 / 新建 / 原生终端) - format 分隔符 tab 经 PTY 变成下划线 → 改用 |:| - controller 变化不冒泡到 session → Combine 转发(否则数据解析对了 UI 不刷新) - 当前会话名不能靠 session_attached 反推 → 改用 display-message 传输层:SSH connect 加超时(原来阻塞到系统 TCP 超时 75s+) 无头验证设施:假会话 fixture · terminalx://ui/* 驱动 · 横屏截图脚本 · 对拍走查法 TXCore 45 tests 绿(新增 5 个会话探测单测) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
BIN
apps/TerminalX/Resources/Fonts/JetBrainsMono-Bold.ttf
Normal file
BIN
apps/TerminalX/Resources/Fonts/JetBrainsMono-Bold.ttf
Normal file
Binary file not shown.
BIN
apps/TerminalX/Resources/Fonts/JetBrainsMono-Medium.ttf
Normal file
BIN
apps/TerminalX/Resources/Fonts/JetBrainsMono-Medium.ttf
Normal file
Binary file not shown.
BIN
apps/TerminalX/Resources/Fonts/JetBrainsMono-Regular.ttf
Normal file
BIN
apps/TerminalX/Resources/Fonts/JetBrainsMono-Regular.ttf
Normal file
Binary file not shown.
BIN
apps/TerminalX/Resources/Fonts/JetBrainsMono-SemiBold.ttf
Normal file
BIN
apps/TerminalX/Resources/Fonts/JetBrainsMono-SemiBold.ttf
Normal file
Binary file not shown.
93
apps/TerminalX/Resources/Fonts/OFL.txt
Normal file
93
apps/TerminalX/Resources/Fonts/OFL.txt
Normal file
@@ -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.
|
||||
2
apps/TerminalX/Resources/Fonts/README.md
Normal file
2
apps/TerminalX/Resources/Fonts/README.md
Normal file
@@ -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。
|
||||
@@ -2,8 +2,16 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<!-- 部分 Info.plist 基底:仅声明 URL scheme(供无头验证前台唤醒 terminalx://resume)。
|
||||
<!-- 部分 Info.plist 基底:URL scheme(供无头验证前台唤醒 terminalx://resume)+ 打包字体。
|
||||
其余键由 GENERATE_INFOPLIST_FILE=YES 自动生成并合并到本基底之上。 -->
|
||||
<!-- JetBrains Mono:chrome 与终端同源字体(设计交付要求)。中文自动回退 PingFang SC。 -->
|
||||
<key>UIAppFonts</key>
|
||||
<array>
|
||||
<string>JetBrainsMono-Regular.ttf</string>
|
||||
<string>JetBrainsMono-Medium.ttf</string>
|
||||
<string>JetBrainsMono-SemiBold.ttf</string>
|
||||
<string>JetBrainsMono-Bold.ttf</string>
|
||||
</array>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
|
||||
97
apps/TerminalX/iOS/AppRouter.swift
Normal file
97
apps/TerminalX/iOS/AppRouter.swift
Normal file
@@ -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 } }
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
92
apps/TerminalX/iOS/Credential.swift
Normal file
92
apps/TerminalX/iOS/Credential.swift
Normal file
@@ -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
|
||||
}
|
||||
84
apps/TerminalX/iOS/CredentialAuth.swift
Normal file
84
apps/TerminalX/iOS/CredentialAuth.swift
Normal file
@@ -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 占位:未签名无法签名
|
||||
}
|
||||
}
|
||||
}
|
||||
78
apps/TerminalX/iOS/CredentialStore.swift
Normal file
78
apps/TerminalX/iOS/CredentialStore.swift
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
632
apps/TerminalX/iOS/HomeView.swift
Normal file
632
apps/TerminalX/iOS/HomeView.swift
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
350
apps/TerminalX/iOS/HostsView.swift
Normal file
350
apps/TerminalX/iOS/HostsView.swift
Normal file
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
358
apps/TerminalX/iOS/KeychainView.swift
Normal file
358
apps/TerminalX/iOS/KeychainView.swift
Normal file
@@ -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<URL, Error>) {
|
||||
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()
|
||||
}
|
||||
}
|
||||
529
apps/TerminalX/iOS/RailSidebar.swift
Normal file
529
apps/TerminalX/iOS/RailSidebar.swift
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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<TmuxByteEvent>
|
||||
private let cont: AsyncStream<TmuxByteEvent>.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<Void, Never>?
|
||||
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<Void, Never>?
|
||||
private var resumeWatchdog: Task<Void, Never>?
|
||||
#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) 次)"
|
||||
}
|
||||
}
|
||||
}
|
||||
193
apps/TerminalX/iOS/SessionManager.swift
Normal file
193
apps/TerminalX/iOS/SessionManager.swift
Normal file
@@ -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<UUID> = []
|
||||
/// 转发每条会话的变化(嵌套 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
|
||||
}
|
||||
}
|
||||
206
apps/TerminalX/iOS/SessionPickerSheet.swift
Normal file
206
apps/TerminalX/iOS/SessionPickerSheet.swift
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
173
apps/TerminalX/iOS/SettingsView.swift
Normal file
173
apps/TerminalX/iOS/SettingsView.swift
Normal file
@@ -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() }
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
301
apps/TerminalX/iOS/TXParts.swift
Normal file
301
apps/TerminalX/iOS/TXParts.swift
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
80
apps/TerminalX/iOS/TailscaleStore.swift
Normal file
80
apps/TerminalX/iOS/TailscaleStore.swift
Normal file
@@ -0,0 +1,80 @@
|
||||
import Foundation
|
||||
|
||||
/// 一个 Tailscale 连接(= 一个常驻 tsnet 节点)。可添加多个;host 可绑定其一作为出口。
|
||||
/// 每连接独立 stateDir(`tsnet-<uuid>`,创建即固化、终身不变——tsnet 无文件锁,同 Dir 双 Up 会损坏状态)。
|
||||
struct TailscaleConnection: Identifiable, Codable, Equatable {
|
||||
let id: UUID
|
||||
var name: String
|
||||
/// tailnet 内展示的主机名。
|
||||
var hostname: String
|
||||
/// tsnet 状态目录名(Application Support 下),创建即固化。
|
||||
let stateDirName: String
|
||||
/// true = 从未注册 或 被 tailnet 清理 → 需重填 authKey 才能 Up。
|
||||
var needsAuthKey: Bool
|
||||
/// 展示用缓存:上次 Up 后的本节点 tailnet IP。
|
||||
var lastSelfIP: String?
|
||||
|
||||
init(id: UUID = UUID(), name: String, hostname: String = "terminalx-ipad",
|
||||
needsAuthKey: Bool = true, lastSelfIP: String? = nil) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.hostname = hostname
|
||||
self.stateDirName = "tsnet-\(id.uuidString)"
|
||||
self.needsAuthKey = needsAuthKey
|
||||
self.lastSelfIP = lastSelfIP
|
||||
}
|
||||
}
|
||||
|
||||
/// Tailscale 连接列表持久化(tailnets.json)。本体无机密(authKey 默认注册后即丢弃)。
|
||||
@MainActor
|
||||
final class TailscaleStore: ObservableObject {
|
||||
@Published private(set) var connections: [TailscaleConnection] = []
|
||||
|
||||
private let url: URL
|
||||
|
||||
init() {
|
||||
let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
|
||||
try? FileManager.default.createDirectory(at: base, withIntermediateDirectories: true)
|
||||
url = base.appendingPathComponent("tailnets.json")
|
||||
load()
|
||||
}
|
||||
|
||||
func add(_ conn: TailscaleConnection) { connections.append(conn); save() }
|
||||
|
||||
func update(_ conn: TailscaleConnection) {
|
||||
guard let i = connections.firstIndex(where: { $0.id == conn.id }) else { return }
|
||||
connections[i] = conn
|
||||
save()
|
||||
}
|
||||
|
||||
func remove(_ conn: TailscaleConnection) {
|
||||
connections.removeAll { $0.id == conn.id }
|
||||
save()
|
||||
// 删连接 → 删其 tsnet 状态目录,避免残留身份。
|
||||
let base = NSSearchPathForDirectoriesInDomains(.applicationSupportDirectory, .userDomainMask, true)[0]
|
||||
try? FileManager.default.removeItem(atPath: base + "/" + conn.stateDirName)
|
||||
}
|
||||
|
||||
func connection(id: UUID?) -> TailscaleConnection? {
|
||||
guard let id else { return nil }
|
||||
return connections.first { $0.id == id }
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
/// 无头 UI 验证:注入示意 tailnet(**不写盘**,不污染真实 tailnets.json)。
|
||||
func injectFixtureConnections(_ list: [TailscaleConnection]) {
|
||||
connections = list
|
||||
}
|
||||
#endif
|
||||
|
||||
private func load() {
|
||||
guard let data = try? Data(contentsOf: url),
|
||||
let decoded = try? JSONDecoder().decode([TailscaleConnection].self, from: data) else { return }
|
||||
connections = decoded
|
||||
}
|
||||
|
||||
private func save() {
|
||||
guard let data = try? JSONEncoder().encode(connections) else { return }
|
||||
try? data.write(to: url, options: .atomic)
|
||||
}
|
||||
}
|
||||
206
apps/TerminalX/iOS/TailscaleView.swift
Normal file
206
apps/TerminalX/iOS/TailscaleView.swift
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
406
apps/TerminalX/iOS/TerminalChrome.swift
Normal file
406
apps/TerminalX/iOS/TerminalChrome.swift
Normal file
@@ -0,0 +1,406 @@
|
||||
import SwiftUI
|
||||
|
||||
// 设计稿 `1b 终端-沉浸轨道` 的浮起 chrome:左上标题胶囊 + 右上状态胶囊 + 底部提示条。
|
||||
//
|
||||
// 浮起(absolute + blur)而非占一行布局:终端面积最大化是这一屏的唯一目标。
|
||||
// 终端内容容器顶部留 86pt 预留带给胶囊,预留带用 padding 实现(容器 clipped 防向上溢出)。
|
||||
|
||||
/// 浮起胶囊的共用外观:blur 材质 + 半透底 + 常规描边 + 圆角 11。
|
||||
private struct FloatingCapsule<Content: View>: 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<Actions: View>(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)
|
||||
}
|
||||
}
|
||||
279
apps/TerminalX/iOS/TerminalDialogs.swift
Normal file
279
apps/TerminalX/iOS/TerminalDialogs.swift
Normal file
@@ -0,0 +1,279 @@
|
||||
import SwiftUI
|
||||
|
||||
// 设计稿 `3c 关闭确认` 与 `3b 新建分屏菜单`。
|
||||
//
|
||||
// 语义分工(务必别混):
|
||||
// - **黄点 / ⌘M / 下滑 = 最小化**:不确认,直接回首页,会话继续跑。
|
||||
// - **红点 / ⌘W = 关闭**:弹一次确认,按主机能力两套文案;`esc` 取消、`⏎` 选推荐项。
|
||||
|
||||
/// 浮层容器:遮罩 + 居中卡片(对话框 / 菜单共用)。
|
||||
struct TXModalScrim<Content: View>: 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)
|
||||
}
|
||||
}
|
||||
1097
apps/TerminalX/iOS/TerminalSession.swift
Normal file
1097
apps/TerminalX/iOS/TerminalSession.swift
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
103
apps/TerminalX/iOS/TsnetRegistry.swift
Normal file
103
apps/TerminalX/iOS/TsnetRegistry.swift
Normal file
@@ -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<UUID> = []
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
56
apps/TerminalX/iOS/UIComponents.swift
Normal file
56
apps/TerminalX/iOS/UIComponents.swift
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
267
apps/TerminalX/iOS/UIPreviewFixture.swift
Normal file
267
apps/TerminalX/iOS/UIPreviewFixture.swift
Normal file
@@ -0,0 +1,267 @@
|
||||
#if DEBUG
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
/// 无头 UI 验证脚手架(仅 DEBUG)。
|
||||
///
|
||||
/// 本项目的构建机没有 GUI,每屏都靠 `simctl io screenshot` 取证对拍设计稿;而设计稿里的
|
||||
/// 首页会话卡、轨道多主机头像、状态徽标都需要**多条已连接会话**才有内容。用真实主机验证既慢
|
||||
/// 又依赖即时凭据,故提供这条假会话通道:只灌示意输出到 in-memory 终端,**不建任何传输连接**。
|
||||
///
|
||||
/// 用法:`xcrun simctl launch <UDID> 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 <UDID> "terminalx://ui/<动作>"`。
|
||||
///
|
||||
/// 为什么不用坐标点击:本环境没有 GUI,idb companion 不可用;而坐标点击还会随布局改动失效。
|
||||
/// 走 URL 直接驱动 `AppRouter`/`SessionManager` 的公开语义,既稳定又正是要验的那条路径。
|
||||
///
|
||||
/// 支持:`home` · `minimize` · `close`(弹确认) · `close-now`(直接关) · `split/v|h`
|
||||
/// · `split-now/v|h`(直接分屏) · `resize/<cols>`(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/<name>`)。此时通常还在首页,故优先取待选择的会话。
|
||||
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/<cols>`。
|
||||
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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user