初始提交:terminalX 可运行态(M0/M1/M1.5 已真机验证)
- M0: libghostty SSH 终端(渲染/输入/连接)+ 白屏修复(OutputGate) + 会话状态机/自动重连 - M1: tsnet 用户态组网 + SSH-over-tsnet(fd 桥),shell 级真机验证;R5(Go+gvisor+C+++Swift 同进程) retire - M1.5: tmux -CC 原生 tab(MVP) - 结构: packages/(TXCore·TXTransport), apps/TerminalX, vendor/(libghostty-spm/libssh2/mbedtls/tsnet-bridge), artifacts/ - 文档: CLAUDE.md + docs/HANDOFF.md(新会话入口) - 环境: 认证代理→依赖 vendor 本地化;Go 在 ~/.local/go;仅模拟器/未签名 - 待续: M2 mosh, tmux 多 pane, M4 安全(host key/SE) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
18
.gitignore
vendored
Normal file
18
.gitignore
vendored
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# macOS / logs / user data
|
||||||
|
.DS_Store
|
||||||
|
*.log
|
||||||
|
**/xcuserdata/
|
||||||
|
.swiftpm/
|
||||||
|
|
||||||
|
# SPM build caches(可重建)
|
||||||
|
.build/
|
||||||
|
DerivedData/
|
||||||
|
|
||||||
|
# XcodeGen 生成的工程(由 project.yml 重生)
|
||||||
|
apps/**/*.xcodeproj
|
||||||
|
|
||||||
|
# CMake 构建中间产物(由 vendor/build/build-cssh.sh 重建;build-cssh.sh 本身保留)
|
||||||
|
vendor/build/cssh/
|
||||||
|
|
||||||
|
# 说明:vendored 第三方源/预编译 xcframework(vendor/*、artifacts/*)**有意提交**,
|
||||||
|
# 以在受限网络环境下保持仓库自包含、克隆即可构建运行。
|
||||||
68
CLAUDE.md
Normal file
68
CLAUDE.md
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
# CLAUDE.md — terminalX
|
||||||
|
|
||||||
|
原生苹果生态终端工具,解决 iPad/iPhone 远程运维 macOS/Linux + vibe coding 痛点。
|
||||||
|
平台优先级 **iPadOS > iOS > macOS(Apple Silicon)**。四大核心:**libghostty** 引擎 · **mosh** · **Tailscale(tsnet 用户态)** · **tmux control-mode hook**。
|
||||||
|
iOS/iPadOS 只做远程(沙盒禁 fork/exec);macOS 支持本地 PTY。
|
||||||
|
|
||||||
|
> **新会话必读**:先读 `docs/HANDOFF.md`(项目全貌 + 里程碑状态 + 如何继续)。本文件只讲操作规范。
|
||||||
|
|
||||||
|
## ⚠️ 铁律:绝不臆想工具结果
|
||||||
|
调用任何工具后,在**真实结果返回前**绝不预测/编造/续写其输出(不写假想的 `BUILD SUCCEEDED`、日志、`ls`、截图内容)。一次调用→停→读真实返回→再推理。想说预期只在调用**之前**用将来时。发现自己编造要**硬停**重新核实。(曾因此误诊,教训见 git 历史 / HANDOFF。)
|
||||||
|
|
||||||
|
## 环境约束(关键,决定一切构建方式)
|
||||||
|
- 构建机走**带认证的 HTTPS 代理**(疑似国内网)。规律:`github.com` release blob 可经 `curl` 下;**`go.dev`/`ghcr.io`(brew bottle) 被代理 abort**;**SwiftPM 不支持带认证代理** → 所有 SPM 依赖必须 **vendor 本地化**(见 `vendor/`)。Go 模块走 `goproxy.cn`。
|
||||||
|
- **Go 1.26.5 在 `~/.local/go`**(不在默认 PATH!经 aliyun 镜像 curl 装);**gomobile/gobind 在 `~/go/bin`**。`go env` 已固化 `GOPROXY=https://goproxy.cn GOSUMDB=off`。
|
||||||
|
- 工具链:Xcode 26.6 / Swift 6.3 / xcodegen 2.46 / zig 0.15.2 / cmake 4.4 / ninja 1.13。
|
||||||
|
|
||||||
|
## 构建 & 验证命令
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# 纯逻辑单测(无需 Xcode/模拟器)
|
||||||
|
swift test --package-path packages/TXCore # 35 tests
|
||||||
|
|
||||||
|
# 重建 SSH 库 xcframework(libssh2+mbedTLS,改 vendor/libssh2|mbedtls 后)
|
||||||
|
bash vendor/build/build-cssh.sh all # 产出 artifacts/CSSHCore.xcframework (library-only!)
|
||||||
|
|
||||||
|
# 重建 tsnet 桥(改 vendor/tsnet-bridge/tsnetbridge/bridge.go 后)
|
||||||
|
export GOROOT=$HOME/.local/go; export PATH=$GOROOT/bin:$HOME/go/bin:$PATH
|
||||||
|
cd vendor/tsnet-bridge && gomobile bind -target=ios,iossimulator \
|
||||||
|
-o ../../artifacts/TsnetBridge.xcframework ./tsnetbridge
|
||||||
|
|
||||||
|
# 构建 app(改 project.yml 后先 xcodegen generate)
|
||||||
|
cd apps/TerminalX && xcodegen generate --spec project.yml
|
||||||
|
xcodebuild -project TerminalX.xcodeproj -scheme TerminalX \
|
||||||
|
-destination 'platform=iOS Simulator,id=<UDID>' \
|
||||||
|
-clonedSourcePackagesDirPath /tmp/tx-spm build
|
||||||
|
```
|
||||||
|
|
||||||
|
## 无头验证(本环境无 GUI,用 simctl 截图取证)
|
||||||
|
iPad Pro 13(M5) 模拟器 UDID:`2EA9055E-4557-4B20-82AF-56A68AC1D454`(变了用 `xcrun simctl list devices`)。
|
||||||
|
```sh
|
||||||
|
UDID=2EA9055E-4557-4B20-82AF-56A68AC1D454
|
||||||
|
APP=$(find ~/Library/Developer/Xcode/DerivedData/TerminalX-*/Build/Products/Debug-iphonesimulator -maxdepth 1 -name TerminalX.app | head -1)
|
||||||
|
xcrun simctl boot $UDID; xcrun simctl install $UDID "$APP"
|
||||||
|
xcrun simctl launch $UDID ai.athom.terminalx --args <见下>
|
||||||
|
sleep N; xcrun simctl io $UDID screenshot /tmp/x.png # 然后用 Read 工具看 /tmp/x.png
|
||||||
|
```
|
||||||
|
**启动参数(NSUserDefaults 参数域,自动连接,供无头验证)**:
|
||||||
|
- 直连 SSH:`-txHost H -txPort 22 -txUser U -txPass PW -txAutoCommand "echo hi"`
|
||||||
|
- tsnet SSH:额外加 `-txTsnetKey <authkey>`(-txHost 填 tailnet IP)
|
||||||
|
- 纯 tsnet 自检(加入+对端枚举):只给 `-txTsnetKey <authkey>`(不给 -txHost)
|
||||||
|
|
||||||
|
## 仓库结构
|
||||||
|
- `packages/TXCore` — 零依赖纯逻辑:tmux 解析器/layout/`SessionMachine`(会话状态机+退避重连)/`TmuxControlSequence`(DCS 检测)。
|
||||||
|
- `packages/TXTransport` — `SSHSession`(libssh2, 支持外部 fd)/`Transport` 协议;`CSSH`(C shim: 头+modulemap)/`CSSHCore`(binaryTarget)。
|
||||||
|
- `apps/TerminalX/iOS` — `SSHTerminalModel`(连接编排+tsnet+tmux 路由)/`ContentView`/`TmuxController`(tmux 网关)/`TsnetProbe`(M1 自检)。
|
||||||
|
- `vendor/` — 所有非 Swift 依赖本地化:`libghostty-spm`(GhosttyKit)/`MSDisplayLink`/`libssh2`/`mbedtls`/`tsnet-bridge`(Go)。
|
||||||
|
- `artifacts/` — 预编译 xcframework(gitignore):`CSSHCore`/`TsnetBridge`。
|
||||||
|
|
||||||
|
## 约定 & 坑
|
||||||
|
- **CSSHCore 必须 library-only**(无 Headers/modulemap);libssh2 的 Swift module 由包内 `CSSH` C target 提供。否则与 GhosttyKit 撞 `include/module.modulemap`。
|
||||||
|
- **一进程一个 Go runtime、只一次 gomobile bind**(全在 `tsnetbridge` 包)。
|
||||||
|
- GhosttyKit 的 `InMemoryTerminalSession.receive` 在 surface 未 attach 时**丢弃**写入 → 用 `OutputGate` 缓冲到 `surfaceSize != nil` 再 flush。
|
||||||
|
- tmux `-CC attach` **不推 %window-add** → 必须主动 `list-windows` 枚举。
|
||||||
|
- SourceKit "No such module" 是索引噪声,**以 `swift build`/`xcodebuild` 为准**。
|
||||||
|
- 密钥/密码**绝不写进仓库**;测试主机凭据由用户即时提供。
|
||||||
|
- 提交/推送 git 只在用户明确要求时(当前已 `git init`,尚无 commit)。
|
||||||
|
- 与用户始终用**简体中文**交流。
|
||||||
|
</content>
|
||||||
36
README.md
Normal file
36
README.md
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
# terminalX
|
||||||
|
|
||||||
|
原生苹果生态终端运维 / vibe coding 工具。平台优先级:**iPadOS > iOS > macOS(Apple Silicon)**。
|
||||||
|
|
||||||
|
核心选型:**libghostty**(终端引擎) · **mosh** · **Tailscale (tsnet 用户态)** · **tmux control mode hook**。
|
||||||
|
|
||||||
|
> 设计与决策见 `docs/`(若缺失,参见会话记忆 / 架构蓝图)。当前处于 **M0**(骨架 + 引擎 + libssh2 直连),走 SSH-first 路线。
|
||||||
|
|
||||||
|
## 仓库结构
|
||||||
|
|
||||||
|
```
|
||||||
|
packages/ 纯 Swift SPM 包(源码构建,依赖只向下)
|
||||||
|
TXCore 零依赖:模型 / 状态机 / 协议 / 纯算法(含 tmux control-mode 解析器)
|
||||||
|
TXTransport SSH(libssh2) 引导 · mosh 驱动 · UDP relay · 连接编排 [M1+]
|
||||||
|
TXTailnet tsnet 多节点 · DialerRouter · fd 桥 [M1+]
|
||||||
|
TXTerminal GhosttyKit 包装:Metal 渲染 · 键盘 · IME [M0]
|
||||||
|
TXSecurity Keychain / Secure Enclave / known_hosts [M4]
|
||||||
|
TXPTY 本地 PTY(仅 macOS) [M4]
|
||||||
|
TXUI SwiftUI 界面层 [M0+]
|
||||||
|
apps/ Xcode app targets(XcodeGen 生成,iOS/iPadOS 合一 + macOS 独立)
|
||||||
|
vendor/ 非 Swift 语言源(zig/go/c++),CI 产出 xcframework [M1+]
|
||||||
|
artifacts/ 预编译 xcframework 落地(gitignore)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 开发
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# M0:纯逻辑包,命令行即可测,无需 Xcode 工程
|
||||||
|
cd packages/TXCore && swift test
|
||||||
|
```
|
||||||
|
|
||||||
|
## 工具链要求
|
||||||
|
|
||||||
|
- Xcode 26+ / Swift 6.x(M0)
|
||||||
|
- Go + gomobile(M1,tsnet)
|
||||||
|
- CMake + Ninja + zig(M1/M2,vendor 构建)
|
||||||
180
apps/TerminalX/iOS/ContentView.swift
Normal file
180
apps/TerminalX/iOS/ContentView.swift
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import GhosttyTerminal
|
||||||
|
|
||||||
|
struct ContentView: View {
|
||||||
|
@StateObject private var model = SSHTerminalModel()
|
||||||
|
@Environment(\.scenePhase) private var scenePhase
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
// M1 纯自检分支:仅 -txTsnetKey(无 -txHost)时跑 tsnet 加入+对端枚举自检。
|
||||||
|
// 若同时有 -txHost,则走正常终端流程(model 经 tsnet fd 桥连接)。
|
||||||
|
if let tsnetKey = UserDefaults.standard.string(forKey: "txTsnetKey"), !tsnetKey.isEmpty,
|
||||||
|
(UserDefaults.standard.string(forKey: "txHost") ?? "").isEmpty {
|
||||||
|
TsnetProbeView(authKey: tsnetKey)
|
||||||
|
} else {
|
||||||
|
Group {
|
||||||
|
if model.showsTerminal {
|
||||||
|
TerminalScreen(model: model)
|
||||||
|
} else {
|
||||||
|
ConnectionForm(model: model)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear { model.autoConnectIfConfigured() }
|
||||||
|
.onChange(of: scenePhase) { _, phase in
|
||||||
|
switch phase {
|
||||||
|
case .background: model.enterBackground()
|
||||||
|
case .active: model.enterForeground()
|
||||||
|
default: break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// M0 连接表单:SSH 直连(不过 tsnet)。真实运维即输入自己的服务器。
|
||||||
|
struct ConnectionForm: View {
|
||||||
|
@ObservedObject var model: SSHTerminalModel
|
||||||
|
@State private var host = ""
|
||||||
|
@State private var port = "22"
|
||||||
|
@State private var username = ""
|
||||||
|
@State private var password = ""
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
Form {
|
||||||
|
Section("SSH 直连 (M0)") {
|
||||||
|
TextField("主机 / IP", text: $host)
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
.textInputAutocapitalization(.never)
|
||||||
|
TextField("端口", text: $port)
|
||||||
|
.keyboardType(.numberPad)
|
||||||
|
TextField("用户名", text: $username)
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
.textInputAutocapitalization(.never)
|
||||||
|
SecureField("密码", text: $password)
|
||||||
|
}
|
||||||
|
Section {
|
||||||
|
Button {
|
||||||
|
model.connect(host: host, port: Int(port) ?? 22,
|
||||||
|
username: username, password: password)
|
||||||
|
} label: {
|
||||||
|
Text("连接").frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
.disabled(host.isEmpty || username.isEmpty)
|
||||||
|
}
|
||||||
|
Section("状态") {
|
||||||
|
Text(model.phaseText)
|
||||||
|
.foregroundStyle(model.phaseText.hasPrefix("失败") ? .red : .secondary)
|
||||||
|
if !model.banner.isEmpty {
|
||||||
|
Text(model.banner).font(.caption).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("terminalX")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TerminalScreen: View {
|
||||||
|
@ObservedObject var model: SSHTerminalModel
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if let tmux = model.tmuxController {
|
||||||
|
TmuxTabbedView(controller: tmux) // tmux -CC → 原生 tab
|
||||||
|
} else {
|
||||||
|
RawTerminalView(model: model) // 普通单终端
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 普通(非 tmux)单终端视图。
|
||||||
|
struct RawTerminalView: View {
|
||||||
|
@ObservedObject var model: SSHTerminalModel
|
||||||
|
@FocusState private var focused: Bool
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
TerminalSurfaceView(context: model.state)
|
||||||
|
.terminalFocusOnAppear($focused)
|
||||||
|
.overlay(alignment: .top) {
|
||||||
|
if model.phaseText != "已连接", !model.banner.isEmpty {
|
||||||
|
Text(model.banner)
|
||||||
|
.font(.caption)
|
||||||
|
.padding(.horizontal, 10).padding(.vertical, 6)
|
||||||
|
.background(.thinMaterial, in: Capsule())
|
||||||
|
.padding(.top, 6)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.task {
|
||||||
|
for _ in 0 ..< 300 where model.state.surfaceSize == nil {
|
||||||
|
try? await Task.sleep(nanoseconds: 30_000_000)
|
||||||
|
}
|
||||||
|
model.markSurfaceReady()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// tmux control-mode:window 映射为原生 tab 条 + 活动 window 终端。
|
||||||
|
struct TmuxTabbedView: View {
|
||||||
|
@ObservedObject var controller: TmuxController
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
ForEach(controller.windows) { win in
|
||||||
|
TmuxTabChip(window: win,
|
||||||
|
isActive: win.id == controller.activeWindowID) {
|
||||||
|
controller.selectWindow(win.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Button { controller.newWindow() } label: {
|
||||||
|
Image(systemName: "plus").padding(.horizontal, 6)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 8).padding(.vertical, 6)
|
||||||
|
}
|
||||||
|
.background(.thinMaterial)
|
||||||
|
Divider()
|
||||||
|
if let win = controller.activeWindow {
|
||||||
|
TmuxWindowView(window: win, controller: controller)
|
||||||
|
.id(win.id.raw)
|
||||||
|
} else {
|
||||||
|
Spacer(); Text("无 tmux 窗口").foregroundStyle(.secondary); Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TmuxTabChip: View {
|
||||||
|
@ObservedObject var window: TmuxWindow
|
||||||
|
let isActive: Bool
|
||||||
|
let onTap: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button(action: onTap) {
|
||||||
|
Text(window.title)
|
||||||
|
.font(.caption).lineLimit(1)
|
||||||
|
.padding(.horizontal, 12).padding(.vertical, 6)
|
||||||
|
.background(isActive ? Color.accentColor.opacity(0.25) : Color.gray.opacity(0.12),
|
||||||
|
in: RoundedRectangle(cornerRadius: 8))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TmuxWindowView: View {
|
||||||
|
@ObservedObject var window: TmuxWindow
|
||||||
|
let controller: TmuxController
|
||||||
|
@FocusState private var focused: Bool
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
TerminalSurfaceView(context: window.state)
|
||||||
|
.terminalFocusOnAppear($focused)
|
||||||
|
.task {
|
||||||
|
for _ in 0 ..< 300 where window.state.surfaceSize == nil {
|
||||||
|
try? await Task.sleep(nanoseconds: 30_000_000)
|
||||||
|
}
|
||||||
|
controller.markWindowReady(window.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
280
apps/TerminalX/iOS/SSHTerminalModel.swift
Normal file
280
apps/TerminalX/iOS/SSHTerminalModel.swift
Normal file
@@ -0,0 +1,280 @@
|
|||||||
|
import Foundation
|
||||||
|
import GhosttyTerminal
|
||||||
|
import TXCore
|
||||||
|
import TXTransport
|
||||||
|
import TsnetBridge
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 线程安全地持有 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() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 输出闸门: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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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?
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
|
init() {
|
||||||
|
let holder = TransportHolder()
|
||||||
|
let session = InMemoryTerminalSession(
|
||||||
|
write: { data in holder.transport?.send(data) },
|
||||||
|
resize: { vp in holder.transport?.resize(cols: vp.columns, rows: vp.rows) }
|
||||||
|
)
|
||||||
|
let state = TerminalViewState()
|
||||||
|
state.configuration = TerminalSurfaceOptions(backend: .inMemory(session))
|
||||||
|
self.holder = holder
|
||||||
|
self.session = session
|
||||||
|
self.state = state
|
||||||
|
self.gate = OutputGate(session: session)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 公开入口
|
||||||
|
|
||||||
|
func connect(host: String, port: Int, username: String, password: String) {
|
||||||
|
config = SSHConfig(host: host, port: port, username: username,
|
||||||
|
authentication: .password(password))
|
||||||
|
run(machine.reduce(.connectRequested))
|
||||||
|
}
|
||||||
|
|
||||||
|
func close() { run(machine.reduce(.closeRequested)) }
|
||||||
|
func enterBackground() { run(machine.reduce(.enteredBackground)) }
|
||||||
|
func enterForeground() { 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 }
|
||||||
|
connect(host: host, port: port == 0 ? 22 : port,
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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
|
||||||
|
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,
|
||||||
|
enterGateway: { [weak self] after in
|
||||||
|
Task { @MainActor in self?.enterTmux(initialBytes: after) }
|
||||||
|
},
|
||||||
|
gatewayBytes: { [weak self] bytes in
|
||||||
|
Task { @MainActor in self?.tmuxController?.feed(Data(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() } }
|
||||||
|
tmuxController = controller
|
||||||
|
if !initialBytes.isEmpty { controller.feed(Data(initialBytes)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func exitTmux() {
|
||||||
|
if tmuxController != nil { tmuxController = nil }
|
||||||
|
tmuxRouter.reset()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleTransportState(_ st: TransportState) {
|
||||||
|
switch st {
|
||||||
|
case .authenticating: run(machine.reduce(.authenticating))
|
||||||
|
case .connected: run(machine.reduce(.established))
|
||||||
|
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:
|
||||||
|
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: "已关闭"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
10
apps/TerminalX/iOS/TerminalXApp.swift
Normal file
10
apps/TerminalX/iOS/TerminalXApp.swift
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@main
|
||||||
|
struct TerminalXApp: App {
|
||||||
|
var body: some Scene {
|
||||||
|
WindowGroup {
|
||||||
|
ContentView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
242
apps/TerminalX/iOS/TmuxController.swift
Normal file
242
apps/TerminalX/iOS/TmuxController.swift
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
import Foundation
|
||||||
|
import GhosttyTerminal
|
||||||
|
import TXCore
|
||||||
|
|
||||||
|
/// 一个 tmux window(MVP:显示其活动 pane)。每 window 独立终端会话 + surface。
|
||||||
|
@MainActor
|
||||||
|
final class TmuxWindow: ObservableObject, Identifiable {
|
||||||
|
let id: TmuxWindowID
|
||||||
|
@Published var title: String
|
||||||
|
let state: TerminalViewState
|
||||||
|
let session: InMemoryTerminalSession
|
||||||
|
let gate: OutputGate
|
||||||
|
var activePane: TmuxPaneID?
|
||||||
|
|
||||||
|
init(id: TmuxWindowID, title: String, onInput: @escaping @Sendable (Data) -> Void) {
|
||||||
|
self.id = id
|
||||||
|
self.title = title
|
||||||
|
let session = InMemoryTerminalSession(
|
||||||
|
write: { data in onInput(data) }, // 用户输入 → send-keys(由 controller 处理)
|
||||||
|
resize: { _ in }
|
||||||
|
)
|
||||||
|
let state = TerminalViewState()
|
||||||
|
state.configuration = TerminalSurfaceOptions(backend: .inMemory(session))
|
||||||
|
self.session = session
|
||||||
|
self.state = state
|
||||||
|
self.gate = OutputGate(session: session)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// tmux control-mode 网关:消费 `%`-协议流,把 tmux windows 映射为原生 tab。
|
||||||
|
/// MVP:每 window 显示活动 pane;输入经 `send-keys -H`;切 tab 经 `select-window`。
|
||||||
|
/// 已简化:多 pane 分屏只显示活动 pane;历史依赖 attach 重绘;命令不做严格应答匹配。
|
||||||
|
@MainActor
|
||||||
|
final class TmuxController: ObservableObject {
|
||||||
|
@Published private(set) var windows: [TmuxWindow] = []
|
||||||
|
@Published var activeWindowID: TmuxWindowID?
|
||||||
|
|
||||||
|
private let parser = TmuxControlParser()
|
||||||
|
private var windowByID: [TmuxWindowID: TmuxWindow] = [:]
|
||||||
|
private var paneToWindow: [TmuxPaneID: TmuxWindowID] = [:]
|
||||||
|
private let sendRaw: @Sendable (Data) -> Void
|
||||||
|
var onExit: (() -> Void)?
|
||||||
|
|
||||||
|
// control-mode 命令应答 FIFO 匹配(attach 场景需主动 list-windows 枚举)。
|
||||||
|
private var attachAcked = false
|
||||||
|
private enum PendingKind { case ignore, listWindows, listPanes(TmuxWindowID), capturePane(TmuxWindowID) }
|
||||||
|
private var pending: [PendingKind] = []
|
||||||
|
|
||||||
|
init(sendRaw: @escaping @Sendable (Data) -> Void) {
|
||||||
|
self.sendRaw = sendRaw
|
||||||
|
}
|
||||||
|
|
||||||
|
var activeWindow: TmuxWindow? {
|
||||||
|
guard let id = activeWindowID else { return windows.first }
|
||||||
|
return windowByID[id]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 送入 control-mode 字节流。
|
||||||
|
func feed(_ data: Data) {
|
||||||
|
for event in parser.feed(data) { apply(event) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 事件处理
|
||||||
|
|
||||||
|
private func apply(_ event: TmuxEvent) {
|
||||||
|
switch event {
|
||||||
|
case .windowAdd(let w):
|
||||||
|
_ = ensureWindow(w)
|
||||||
|
case .windowClose(let w), .unlinkedWindowClose(let w):
|
||||||
|
removeWindow(w)
|
||||||
|
case .windowRenamed(let w, let name):
|
||||||
|
windowByID[w]?.title = name
|
||||||
|
case .layoutChange(let w, let layout, _):
|
||||||
|
let win = ensureWindow(w)
|
||||||
|
if let parsed = try? TmuxLayout.parse(layout) {
|
||||||
|
let panes = parsed.paneIDs
|
||||||
|
for p in panes { paneToWindow[p] = w }
|
||||||
|
if win.activePane == nil { win.activePane = panes.first }
|
||||||
|
}
|
||||||
|
case .windowPaneChanged(let w, let pane):
|
||||||
|
paneToWindow[pane] = w
|
||||||
|
windowByID[w]?.activePane = pane
|
||||||
|
case .output(let pane, let data):
|
||||||
|
if let wid = paneToWindow[pane] {
|
||||||
|
windowByID[wid]?.gate.deliver(data)
|
||||||
|
}
|
||||||
|
case .extendedOutput(let pane, _, let data):
|
||||||
|
if let wid = paneToWindow[pane] {
|
||||||
|
windowByID[wid]?.gate.deliver(data)
|
||||||
|
}
|
||||||
|
case .sessionWindowChanged(_, let w):
|
||||||
|
if windowByID[w] != nil { activeWindowID = w }
|
||||||
|
case .commandResponse(let response):
|
||||||
|
handleCommandResponse(response)
|
||||||
|
case .exit:
|
||||||
|
onExit?()
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// control-mode 命令应答。第一个应答是 attach 的隐式块(无对应命令)→ 触发 list-windows 枚举;
|
||||||
|
/// 之后按 FIFO 匹配我方发出的命令。
|
||||||
|
private func handleCommandResponse(_ r: TmuxCommandResponse) {
|
||||||
|
if !attachAcked {
|
||||||
|
attachAcked = true
|
||||||
|
pending.append(.listWindows)
|
||||||
|
sendCommand("list-windows -F \"#{window_id} #{window_name}\"")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard !pending.isEmpty else { return }
|
||||||
|
switch pending.removeFirst() {
|
||||||
|
case .ignore:
|
||||||
|
break
|
||||||
|
case .listWindows:
|
||||||
|
for line in r.lines {
|
||||||
|
let parts = line.split(separator: " ", maxSplits: 1)
|
||||||
|
guard let first = parts.first, let w = TmuxWindowID.parse(first) else { continue }
|
||||||
|
let win = ensureWindow(w)
|
||||||
|
if parts.count > 1 { win.title = String(parts[1]) }
|
||||||
|
pending.append(.listPanes(w))
|
||||||
|
sendCommand("list-panes -t @\(w.raw) -F \"#{pane_id} #{pane_active}\"")
|
||||||
|
}
|
||||||
|
sendCommand("refresh-client") // 强制重绘 → 当前屏幕以 %output 下发
|
||||||
|
pending.append(.ignore)
|
||||||
|
case .listPanes(let w):
|
||||||
|
for line in r.lines {
|
||||||
|
let parts = line.split(separator: " ")
|
||||||
|
guard let first = parts.first, let p = TmuxPaneID.parse(first) else { continue }
|
||||||
|
paneToWindow[p] = w
|
||||||
|
let active = parts.count > 1 && parts[1] == "1"
|
||||||
|
if active || windowByID[w]?.activePane == nil {
|
||||||
|
windowByID[w]?.activePane = p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 抓当前屏幕内容作初始渲染(初始 %output 可能在映射建立前已到达被丢)。
|
||||||
|
if let pane = windowByID[w]?.activePane {
|
||||||
|
pending.append(.capturePane(w))
|
||||||
|
sendCommand("capture-pane -t %\(pane.raw) -p -e -J")
|
||||||
|
}
|
||||||
|
case .capturePane(let w):
|
||||||
|
let screen = r.lines.joined(separator: "\r\n")
|
||||||
|
if !screen.isEmpty {
|
||||||
|
windowByID[w]?.gate.deliver(Data((screen + "\r\n").utf8))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
private func ensureWindow(_ w: TmuxWindowID) -> TmuxWindow {
|
||||||
|
if let existing = windowByID[w] { return existing }
|
||||||
|
let win = TmuxWindow(id: w, title: "窗口 \(w.raw)") { [weak self] data in
|
||||||
|
self?.enqueueInput(window: w, data: data)
|
||||||
|
}
|
||||||
|
windowByID[w] = win
|
||||||
|
windows.append(win)
|
||||||
|
windows.sort { $0.id.raw < $1.id.raw }
|
||||||
|
if activeWindowID == nil { activeWindowID = w }
|
||||||
|
return win
|
||||||
|
}
|
||||||
|
|
||||||
|
private func removeWindow(_ w: TmuxWindowID) {
|
||||||
|
windowByID[w] = nil
|
||||||
|
windows.removeAll { $0.id == w }
|
||||||
|
paneToWindow = paneToWindow.filter { $0.value != w }
|
||||||
|
if activeWindowID == w { activeWindowID = windows.first?.id }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 命令 / 输入
|
||||||
|
|
||||||
|
func sendCommand(_ command: String) {
|
||||||
|
sendRaw(Data((command + "\n").utf8))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 用户在某 window 输入 → send-keys -H 到其活动 pane(非主线程安全:只在主线程调)。
|
||||||
|
private nonisolated func enqueueInput(window w: TmuxWindowID, data: Data) {
|
||||||
|
Task { @MainActor in
|
||||||
|
guard let pane = self.windowByID[w]?.activePane else { return }
|
||||||
|
let hex = data.map { String(format: "%02x", $0) }.joined(separator: " ")
|
||||||
|
self.sendCommand("send-keys -t %\(pane.raw) -H \(hex)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func selectWindow(_ w: TmuxWindowID) {
|
||||||
|
activeWindowID = w
|
||||||
|
sendCommand("select-window -t @\(w.raw)")
|
||||||
|
}
|
||||||
|
|
||||||
|
func newWindow() { sendCommand("new-window") }
|
||||||
|
func killWindow(_ w: TmuxWindowID) { sendCommand("kill-window -t @\(w.raw)") }
|
||||||
|
|
||||||
|
/// 某 window 的 surface 就绪 → 放行其缓冲输出。
|
||||||
|
func markWindowReady(_ w: TmuxWindowID) { windowByID[w]?.gate.markReady() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 传输字节分流器:检测 `tmux -CC` 进入 DCS 前 → 原始终端;进入后 → tmux 网关。
|
||||||
|
/// 在传输后台线程调用;原始字节走线程安全的 rawGate,网关字节经闭包 hop 到主线程。
|
||||||
|
final class TmuxRouter: @unchecked Sendable {
|
||||||
|
private let lock = NSLock()
|
||||||
|
private let rawGate: OutputGate
|
||||||
|
private let enterGateway: @Sendable ([UInt8]) -> Void
|
||||||
|
private let gatewayBytes: @Sendable ([UInt8]) -> Void
|
||||||
|
private var carry: [UInt8] = []
|
||||||
|
private var inGateway = false
|
||||||
|
|
||||||
|
init(rawGate: OutputGate,
|
||||||
|
enterGateway: @escaping @Sendable ([UInt8]) -> Void,
|
||||||
|
gatewayBytes: @escaping @Sendable ([UInt8]) -> Void) {
|
||||||
|
self.rawGate = rawGate
|
||||||
|
self.enterGateway = enterGateway
|
||||||
|
self.gatewayBytes = gatewayBytes
|
||||||
|
}
|
||||||
|
|
||||||
|
func feed(_ data: Data) {
|
||||||
|
lock.lock(); defer { lock.unlock() }
|
||||||
|
if inGateway {
|
||||||
|
gatewayBytes(Array(data))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let buf = carry + Array(data)
|
||||||
|
if let idx = TmuxControlSequence.find(TmuxControlSequence.enter, in: buf) {
|
||||||
|
let before = Array(buf[0 ..< idx])
|
||||||
|
let after = Array(buf[(idx + TmuxControlSequence.enter.count)...])
|
||||||
|
if !before.isEmpty { rawGate.deliver(Data(before)) }
|
||||||
|
inGateway = true
|
||||||
|
carry = []
|
||||||
|
enterGateway(after)
|
||||||
|
} else {
|
||||||
|
// 仅当结尾是哨兵部分前缀时滞留,避免延迟正常输出。
|
||||||
|
let k = TmuxControlSequence.partialTrailingMatchLength(TmuxControlSequence.enter, in: buf)
|
||||||
|
if buf.count > k {
|
||||||
|
rawGate.deliver(Data(buf[0 ..< (buf.count - k)]))
|
||||||
|
}
|
||||||
|
carry = k > 0 ? Array(buf.suffix(k)) : []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// tmux 退出 → 回原始模式。
|
||||||
|
func reset() {
|
||||||
|
lock.lock(); inGateway = false; carry = []; lock.unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
89
apps/TerminalX/iOS/TsnetProbe.swift
Normal file
89
apps/TerminalX/iOS/TsnetProbe.swift
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import Foundation
|
||||||
|
import SwiftUI
|
||||||
|
import TsnetBridge
|
||||||
|
|
||||||
|
struct TsnetPeer: Codable, Identifiable {
|
||||||
|
let name: String
|
||||||
|
let ip: String
|
||||||
|
let online: Bool
|
||||||
|
let os: String
|
||||||
|
var id: String { name + ip }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// M1 自检:app 进程内用 tsnet 加入 tailnet,显示分配 IP + 枚举 tailnet 对端(发现可连主机)。
|
||||||
|
@MainActor
|
||||||
|
final class TsnetProbe: ObservableObject {
|
||||||
|
@Published var status = "准备中…"
|
||||||
|
@Published var selfIP = ""
|
||||||
|
@Published var peers: [TsnetPeer] = []
|
||||||
|
@Published var done = false
|
||||||
|
|
||||||
|
func run(authKey: String) {
|
||||||
|
let base = NSSearchPathForDirectoriesInDomains(.applicationSupportDirectory, .userDomainMask, true)[0]
|
||||||
|
let dir = base + "/tsnet-probe"
|
||||||
|
try? FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true)
|
||||||
|
status = "加入 tailnet 中…(up 阻塞等待 Running)"
|
||||||
|
|
||||||
|
Task.detached {
|
||||||
|
guard let node = TsnetbridgeNewNode(dir, "terminalx-ipad") else {
|
||||||
|
await MainActor.run { self.status = "NewNode 失败"; self.done = true }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
try node.up(withAuthKey: authKey, timeoutMs: 45000)
|
||||||
|
let ip = node.selfIP()
|
||||||
|
try? await Task.sleep(nanoseconds: 5_000_000_000) // 等 netmap 同步
|
||||||
|
let peersJSON = node.peersJSON()
|
||||||
|
let decoded = (try? JSONDecoder().decode([TsnetPeer].self,
|
||||||
|
from: Data(peersJSON.utf8))) ?? []
|
||||||
|
await MainActor.run {
|
||||||
|
self.selfIP = ip
|
||||||
|
self.peers = decoded.sorted { $0.online && !$1.online }
|
||||||
|
self.status = ip.isEmpty ? "已 Up 但未取到 IP" : "已加入 tailnet ✓"
|
||||||
|
self.done = true
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
await MainActor.run {
|
||||||
|
self.status = "失败:\(error.localizedDescription)"
|
||||||
|
self.done = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TsnetProbeView: View {
|
||||||
|
let authKey: String
|
||||||
|
@StateObject private var probe = TsnetProbe()
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
Text("terminalX · tsnet 自检 (M1)").font(.headline)
|
||||||
|
ProgressView().opacity(probe.done ? 0 : 1)
|
||||||
|
Text(probe.status).foregroundStyle(probe.selfIP.isEmpty ? Color.secondary : Color.green)
|
||||||
|
if !probe.selfIP.isEmpty {
|
||||||
|
Text("本机 Tailscale IP:\(probe.selfIP)")
|
||||||
|
.font(.system(.body, design: .monospaced)).bold().foregroundStyle(.green)
|
||||||
|
}
|
||||||
|
if probe.done {
|
||||||
|
Text("tailnet 对端:\(probe.peers.count)").font(.subheadline).padding(.top, 8)
|
||||||
|
}
|
||||||
|
if !probe.peers.isEmpty {
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
ForEach(probe.peers) { p in
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Circle().fill(p.online ? .green : .gray).frame(width: 8, height: 8)
|
||||||
|
Text(p.ip).font(.system(.caption, design: .monospaced))
|
||||||
|
Text(p.name).font(.caption).foregroundStyle(.secondary).lineLimit(1)
|
||||||
|
Text(p.os).font(.caption2).foregroundStyle(.tertiary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}.frame(maxHeight: 500)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(28)
|
||||||
|
.task { probe.run(authKey: authKey) }
|
||||||
|
}
|
||||||
|
}
|
||||||
47
apps/TerminalX/project.yml
Normal file
47
apps/TerminalX/project.yml
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
name: TerminalX
|
||||||
|
options:
|
||||||
|
bundleIdPrefix: ai.athom.terminalx
|
||||||
|
deploymentTarget:
|
||||||
|
iOS: "17.0"
|
||||||
|
createIntermediateGroups: true
|
||||||
|
|
||||||
|
packages:
|
||||||
|
# 本地 vendored(构建环境的认证代理不被 SwiftPM 支持,改用 local-path 零网络)。
|
||||||
|
GhosttyKit:
|
||||||
|
path: ../../vendor/libghostty-spm
|
||||||
|
TXTransport:
|
||||||
|
path: ../../packages/TXTransport
|
||||||
|
TXCore:
|
||||||
|
path: ../../packages/TXCore
|
||||||
|
|
||||||
|
targets:
|
||||||
|
TerminalX:
|
||||||
|
type: application
|
||||||
|
platform: iOS
|
||||||
|
deploymentTarget: "17.0"
|
||||||
|
sources:
|
||||||
|
- path: iOS
|
||||||
|
dependencies:
|
||||||
|
- package: GhosttyKit
|
||||||
|
product: GhosttyTerminal
|
||||||
|
- package: GhosttyKit
|
||||||
|
product: GhosttyTheme
|
||||||
|
- package: TXTransport
|
||||||
|
product: TXTransport
|
||||||
|
- package: TXCore
|
||||||
|
product: TXCore
|
||||||
|
# gomobile bind 产出的 tsnet 桥接(动态 framework,需嵌入+签名拷贝)。
|
||||||
|
- framework: ../../artifacts/TsnetBridge.xcframework
|
||||||
|
embed: true
|
||||||
|
settings:
|
||||||
|
base:
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER: ai.athom.terminalx
|
||||||
|
MARKETING_VERSION: "0.0.1"
|
||||||
|
CURRENT_PROJECT_VERSION: "1"
|
||||||
|
SWIFT_VERSION: "6.0"
|
||||||
|
GENERATE_INFOPLIST_FILE: YES
|
||||||
|
TARGETED_DEVICE_FAMILY: "1,2"
|
||||||
|
INFOPLIST_KEY_UILaunchScreen_Generation: YES
|
||||||
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents: YES
|
||||||
|
# M0 渲染自检无需签名(仅模拟器);真机/上架在 M4/M5 配置。
|
||||||
|
CODE_SIGNING_ALLOWED: NO
|
||||||
58
artifacts/CSSHCore.xcframework/Info.plist
Normal file
58
artifacts/CSSHCore.xcframework/Info.plist
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>AvailableLibraries</key>
|
||||||
|
<array>
|
||||||
|
<dict>
|
||||||
|
<key>BinaryPath</key>
|
||||||
|
<string>libCSSHCore.a</string>
|
||||||
|
<key>LibraryIdentifier</key>
|
||||||
|
<string>macos-arm64</string>
|
||||||
|
<key>LibraryPath</key>
|
||||||
|
<string>libCSSHCore.a</string>
|
||||||
|
<key>SupportedArchitectures</key>
|
||||||
|
<array>
|
||||||
|
<string>arm64</string>
|
||||||
|
</array>
|
||||||
|
<key>SupportedPlatform</key>
|
||||||
|
<string>macos</string>
|
||||||
|
</dict>
|
||||||
|
<dict>
|
||||||
|
<key>BinaryPath</key>
|
||||||
|
<string>libCSSHCore.a</string>
|
||||||
|
<key>LibraryIdentifier</key>
|
||||||
|
<string>ios-arm64_x86_64-simulator</string>
|
||||||
|
<key>LibraryPath</key>
|
||||||
|
<string>libCSSHCore.a</string>
|
||||||
|
<key>SupportedArchitectures</key>
|
||||||
|
<array>
|
||||||
|
<string>arm64</string>
|
||||||
|
<string>x86_64</string>
|
||||||
|
</array>
|
||||||
|
<key>SupportedPlatform</key>
|
||||||
|
<string>ios</string>
|
||||||
|
<key>SupportedPlatformVariant</key>
|
||||||
|
<string>simulator</string>
|
||||||
|
</dict>
|
||||||
|
<dict>
|
||||||
|
<key>BinaryPath</key>
|
||||||
|
<string>libCSSHCore.a</string>
|
||||||
|
<key>LibraryIdentifier</key>
|
||||||
|
<string>ios-arm64</string>
|
||||||
|
<key>LibraryPath</key>
|
||||||
|
<string>libCSSHCore.a</string>
|
||||||
|
<key>SupportedArchitectures</key>
|
||||||
|
<array>
|
||||||
|
<string>arm64</string>
|
||||||
|
</array>
|
||||||
|
<key>SupportedPlatform</key>
|
||||||
|
<string>ios</string>
|
||||||
|
</dict>
|
||||||
|
</array>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>XFWK</string>
|
||||||
|
<key>XCFrameworkFormatVersion</key>
|
||||||
|
<string>1.0</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
BIN
artifacts/CSSHCore.xcframework/ios-arm64/libCSSHCore.a
Normal file
BIN
artifacts/CSSHCore.xcframework/ios-arm64/libCSSHCore.a
Normal file
Binary file not shown.
Binary file not shown.
BIN
artifacts/CSSHCore.xcframework/macos-arm64/libCSSHCore.a
Normal file
BIN
artifacts/CSSHCore.xcframework/macos-arm64/libCSSHCore.a
Normal file
Binary file not shown.
44
artifacts/TsnetBridge.xcframework/Info.plist
Normal file
44
artifacts/TsnetBridge.xcframework/Info.plist
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>AvailableLibraries</key>
|
||||||
|
<array>
|
||||||
|
<dict>
|
||||||
|
<key>BinaryPath</key>
|
||||||
|
<string>TsnetBridge.framework/TsnetBridge</string>
|
||||||
|
<key>LibraryIdentifier</key>
|
||||||
|
<string>ios-arm64</string>
|
||||||
|
<key>LibraryPath</key>
|
||||||
|
<string>TsnetBridge.framework</string>
|
||||||
|
<key>SupportedArchitectures</key>
|
||||||
|
<array>
|
||||||
|
<string>arm64</string>
|
||||||
|
</array>
|
||||||
|
<key>SupportedPlatform</key>
|
||||||
|
<string>ios</string>
|
||||||
|
</dict>
|
||||||
|
<dict>
|
||||||
|
<key>BinaryPath</key>
|
||||||
|
<string>TsnetBridge.framework/TsnetBridge</string>
|
||||||
|
<key>LibraryIdentifier</key>
|
||||||
|
<string>ios-arm64_x86_64-simulator</string>
|
||||||
|
<key>LibraryPath</key>
|
||||||
|
<string>TsnetBridge.framework</string>
|
||||||
|
<key>SupportedArchitectures</key>
|
||||||
|
<array>
|
||||||
|
<string>arm64</string>
|
||||||
|
<string>x86_64</string>
|
||||||
|
</array>
|
||||||
|
<key>SupportedPlatform</key>
|
||||||
|
<string>ios</string>
|
||||||
|
<key>SupportedPlatformVariant</key>
|
||||||
|
<string>simulator</string>
|
||||||
|
</dict>
|
||||||
|
</array>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>XFWK</string>
|
||||||
|
<key>XCFrameworkFormatVersion</key>
|
||||||
|
<string>1.0</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
|
||||||
|
// Objective-C API for talking to the following Go packages
|
||||||
|
//
|
||||||
|
// terminalx/tsnetbridge/tsnetbridge
|
||||||
|
//
|
||||||
|
// File is generated by gomobile bind. Do not edit.
|
||||||
|
#ifndef __TsnetBridge_FRAMEWORK_H__
|
||||||
|
#define __TsnetBridge_FRAMEWORK_H__
|
||||||
|
|
||||||
|
#include "Tsnetbridge.objc.h"
|
||||||
|
#include "Universe.objc.h"
|
||||||
|
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
// Objective-C API for talking to terminalx/tsnetbridge/tsnetbridge Go package.
|
||||||
|
// gobind -lang=objc terminalx/tsnetbridge/tsnetbridge
|
||||||
|
//
|
||||||
|
// File is generated by gobind. Do not edit.
|
||||||
|
|
||||||
|
#ifndef __Tsnetbridge_H__
|
||||||
|
#define __Tsnetbridge_H__
|
||||||
|
|
||||||
|
@import Foundation;
|
||||||
|
#include "ref.h"
|
||||||
|
#include "Universe.objc.h"
|
||||||
|
|
||||||
|
|
||||||
|
@class TsnetbridgeNode;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Node 包装一个嵌入式 tsnet 节点。gomobile 导出为引用型对象。
|
||||||
|
*/
|
||||||
|
@interface TsnetbridgeNode : NSObject <goSeqRefInterface> {
|
||||||
|
}
|
||||||
|
@property(strong, readonly) _Nonnull id _ref;
|
||||||
|
|
||||||
|
- (nonnull instancetype)initWithRef:(_Nonnull id)ref;
|
||||||
|
/**
|
||||||
|
* NewNode 创建(不启动)一个 tsnet 节点。stateDir 唯一、hostname 展示在管理台。
|
||||||
|
*/
|
||||||
|
- (nullable instancetype)init:(NSString* _Nullable)stateDir hostname:(NSString* _Nullable)hostname;
|
||||||
|
/**
|
||||||
|
* Close 释放节点。
|
||||||
|
*/
|
||||||
|
- (BOOL)close:(NSError* _Nullable* _Nullable)error;
|
||||||
|
/**
|
||||||
|
* DialTCPFD 经 tsnet 连接 tailnet 内 host:port,返回一个已连接的 socket fd 供 libssh2 使用。
|
||||||
|
实现:tsnet.Dial 得到 net.Conn(netstack 虚拟连接,无真实 fd)→ socketpair(AF_UNIX) →
|
||||||
|
两条 goroutine 双向 io.Copy 泵 → 把 socketpair 的另一端 fd 返回给 Swift。
|
||||||
|
Swift 侧 close(fd) 会触发泵结束并关闭 tsConn。返回 -1 + error 表示失败。
|
||||||
|
*/
|
||||||
|
- (BOOL)dialTCPFD:(NSString* _Nullable)hostOrIP port:(long)port timeoutMs:(long)timeoutMs ret0_:(int64_t* _Nullable)ret0_ error:(NSError* _Nullable* _Nullable)error;
|
||||||
|
/**
|
||||||
|
* PeersJSON 返回 tailnet 内其它节点列表(name/ip/online/os)的 JSON,用于选择连接目标。
|
||||||
|
*/
|
||||||
|
- (NSString* _Nonnull)peersJSON;
|
||||||
|
/**
|
||||||
|
* SelfIP 返回本节点的 Tailscale IPv4(经 LocalClient().Status() 取;未就绪返回空串)。
|
||||||
|
*/
|
||||||
|
- (NSString* _Nonnull)selfIP;
|
||||||
|
/**
|
||||||
|
* UpWithAuthKey 用 auth key 启动并等待 Running(timeoutMs 到则返回 error)。
|
||||||
|
gomobile 友好签名:基本类型入参、error 出参。
|
||||||
|
*/
|
||||||
|
- (BOOL)upWithAuthKey:(NSString* _Nullable)authKey timeoutMs:(long)timeoutMs error:(NSError* _Nullable* _Nullable)error;
|
||||||
|
@end
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NewNode 创建(不启动)一个 tsnet 节点。stateDir 唯一、hostname 展示在管理台。
|
||||||
|
*/
|
||||||
|
FOUNDATION_EXPORT TsnetbridgeNode* _Nullable TsnetbridgeNewNode(NSString* _Nullable stateDir, NSString* _Nullable hostname);
|
||||||
|
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
// Objective-C API for talking to Go package.
|
||||||
|
// gobind -lang=objc
|
||||||
|
//
|
||||||
|
// File is generated by gobind. Do not edit.
|
||||||
|
|
||||||
|
#ifndef __Universe_H__
|
||||||
|
#define __Universe_H__
|
||||||
|
|
||||||
|
@import Foundation;
|
||||||
|
#include "ref.h"
|
||||||
|
|
||||||
|
@protocol Universeerror;
|
||||||
|
@class Universeerror;
|
||||||
|
|
||||||
|
@protocol Universeerror <NSObject>
|
||||||
|
- (NSString* _Nonnull)error;
|
||||||
|
@end
|
||||||
|
|
||||||
|
@class Universeerror;
|
||||||
|
|
||||||
|
@interface Universeerror : NSError <goSeqRefInterface, Universeerror> {
|
||||||
|
}
|
||||||
|
@property(strong, readonly) _Nonnull id _ref;
|
||||||
|
|
||||||
|
- (nonnull instancetype)initWithRef:(_Nonnull id)ref;
|
||||||
|
- (NSString* _Nonnull)error;
|
||||||
|
@end
|
||||||
|
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
// Copyright 2015 The Go Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a BSD-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
#ifndef __GO_REF_HDR__
|
||||||
|
#define __GO_REF_HDR__
|
||||||
|
|
||||||
|
#include <Foundation/Foundation.h>
|
||||||
|
|
||||||
|
// GoSeqRef is an object tagged with an integer for passing back and
|
||||||
|
// forth across the language boundary. A GoSeqRef may represent either
|
||||||
|
// an instance of a Go object, or an Objective-C object passed to Go.
|
||||||
|
// The explicit allocation of a GoSeqRef is used to pin a Go object
|
||||||
|
// when it is passed to Objective-C. The Go seq package maintains a
|
||||||
|
// reference to the Go object in a map keyed by the refnum along with
|
||||||
|
// a reference count. When the reference count reaches zero, the Go
|
||||||
|
// seq package will clear the corresponding entry in the map.
|
||||||
|
@interface GoSeqRef : NSObject {
|
||||||
|
}
|
||||||
|
@property(readonly) int32_t refnum;
|
||||||
|
@property(strong) id obj; // NULL when representing a Go object.
|
||||||
|
|
||||||
|
// new GoSeqRef object to proxy a Go object. The refnum must be
|
||||||
|
// provided from Go side.
|
||||||
|
- (instancetype)initWithRefnum:(int32_t)refnum obj:(id)obj;
|
||||||
|
|
||||||
|
- (int32_t)incNum;
|
||||||
|
|
||||||
|
@end
|
||||||
|
|
||||||
|
@protocol goSeqRefInterface
|
||||||
|
-(GoSeqRef*) _ref;
|
||||||
|
@end
|
||||||
|
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>TsnetBridge</string>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>TsnetBridge</string>
|
||||||
|
<key>MinimumOSVersion</key>
|
||||||
|
<string>100.0</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>0.0.1784856790</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>0.0.1784856790</string>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>FMWK</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
framework module "TsnetBridge" {
|
||||||
|
header "ref.h"
|
||||||
|
header "Tsnetbridge.objc.h"
|
||||||
|
header "Universe.objc.h"
|
||||||
|
header "TsnetBridge.h"
|
||||||
|
|
||||||
|
export *
|
||||||
|
}
|
||||||
Binary file not shown.
@@ -0,0 +1,13 @@
|
|||||||
|
|
||||||
|
// Objective-C API for talking to the following Go packages
|
||||||
|
//
|
||||||
|
// terminalx/tsnetbridge/tsnetbridge
|
||||||
|
//
|
||||||
|
// File is generated by gomobile bind. Do not edit.
|
||||||
|
#ifndef __TsnetBridge_FRAMEWORK_H__
|
||||||
|
#define __TsnetBridge_FRAMEWORK_H__
|
||||||
|
|
||||||
|
#include "Tsnetbridge.objc.h"
|
||||||
|
#include "Universe.objc.h"
|
||||||
|
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
// Objective-C API for talking to terminalx/tsnetbridge/tsnetbridge Go package.
|
||||||
|
// gobind -lang=objc terminalx/tsnetbridge/tsnetbridge
|
||||||
|
//
|
||||||
|
// File is generated by gobind. Do not edit.
|
||||||
|
|
||||||
|
#ifndef __Tsnetbridge_H__
|
||||||
|
#define __Tsnetbridge_H__
|
||||||
|
|
||||||
|
@import Foundation;
|
||||||
|
#include "ref.h"
|
||||||
|
#include "Universe.objc.h"
|
||||||
|
|
||||||
|
|
||||||
|
@class TsnetbridgeNode;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Node 包装一个嵌入式 tsnet 节点。gomobile 导出为引用型对象。
|
||||||
|
*/
|
||||||
|
@interface TsnetbridgeNode : NSObject <goSeqRefInterface> {
|
||||||
|
}
|
||||||
|
@property(strong, readonly) _Nonnull id _ref;
|
||||||
|
|
||||||
|
- (nonnull instancetype)initWithRef:(_Nonnull id)ref;
|
||||||
|
/**
|
||||||
|
* NewNode 创建(不启动)一个 tsnet 节点。stateDir 唯一、hostname 展示在管理台。
|
||||||
|
*/
|
||||||
|
- (nullable instancetype)init:(NSString* _Nullable)stateDir hostname:(NSString* _Nullable)hostname;
|
||||||
|
/**
|
||||||
|
* Close 释放节点。
|
||||||
|
*/
|
||||||
|
- (BOOL)close:(NSError* _Nullable* _Nullable)error;
|
||||||
|
/**
|
||||||
|
* DialTCPFD 经 tsnet 连接 tailnet 内 host:port,返回一个已连接的 socket fd 供 libssh2 使用。
|
||||||
|
实现:tsnet.Dial 得到 net.Conn(netstack 虚拟连接,无真实 fd)→ socketpair(AF_UNIX) →
|
||||||
|
两条 goroutine 双向 io.Copy 泵 → 把 socketpair 的另一端 fd 返回给 Swift。
|
||||||
|
Swift 侧 close(fd) 会触发泵结束并关闭 tsConn。返回 -1 + error 表示失败。
|
||||||
|
*/
|
||||||
|
- (BOOL)dialTCPFD:(NSString* _Nullable)hostOrIP port:(long)port timeoutMs:(long)timeoutMs ret0_:(int64_t* _Nullable)ret0_ error:(NSError* _Nullable* _Nullable)error;
|
||||||
|
/**
|
||||||
|
* PeersJSON 返回 tailnet 内其它节点列表(name/ip/online/os)的 JSON,用于选择连接目标。
|
||||||
|
*/
|
||||||
|
- (NSString* _Nonnull)peersJSON;
|
||||||
|
/**
|
||||||
|
* SelfIP 返回本节点的 Tailscale IPv4(经 LocalClient().Status() 取;未就绪返回空串)。
|
||||||
|
*/
|
||||||
|
- (NSString* _Nonnull)selfIP;
|
||||||
|
/**
|
||||||
|
* UpWithAuthKey 用 auth key 启动并等待 Running(timeoutMs 到则返回 error)。
|
||||||
|
gomobile 友好签名:基本类型入参、error 出参。
|
||||||
|
*/
|
||||||
|
- (BOOL)upWithAuthKey:(NSString* _Nullable)authKey timeoutMs:(long)timeoutMs error:(NSError* _Nullable* _Nullable)error;
|
||||||
|
@end
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NewNode 创建(不启动)一个 tsnet 节点。stateDir 唯一、hostname 展示在管理台。
|
||||||
|
*/
|
||||||
|
FOUNDATION_EXPORT TsnetbridgeNode* _Nullable TsnetbridgeNewNode(NSString* _Nullable stateDir, NSString* _Nullable hostname);
|
||||||
|
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
// Objective-C API for talking to Go package.
|
||||||
|
// gobind -lang=objc
|
||||||
|
//
|
||||||
|
// File is generated by gobind. Do not edit.
|
||||||
|
|
||||||
|
#ifndef __Universe_H__
|
||||||
|
#define __Universe_H__
|
||||||
|
|
||||||
|
@import Foundation;
|
||||||
|
#include "ref.h"
|
||||||
|
|
||||||
|
@protocol Universeerror;
|
||||||
|
@class Universeerror;
|
||||||
|
|
||||||
|
@protocol Universeerror <NSObject>
|
||||||
|
- (NSString* _Nonnull)error;
|
||||||
|
@end
|
||||||
|
|
||||||
|
@class Universeerror;
|
||||||
|
|
||||||
|
@interface Universeerror : NSError <goSeqRefInterface, Universeerror> {
|
||||||
|
}
|
||||||
|
@property(strong, readonly) _Nonnull id _ref;
|
||||||
|
|
||||||
|
- (nonnull instancetype)initWithRef:(_Nonnull id)ref;
|
||||||
|
- (NSString* _Nonnull)error;
|
||||||
|
@end
|
||||||
|
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
// Copyright 2015 The Go Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a BSD-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
#ifndef __GO_REF_HDR__
|
||||||
|
#define __GO_REF_HDR__
|
||||||
|
|
||||||
|
#include <Foundation/Foundation.h>
|
||||||
|
|
||||||
|
// GoSeqRef is an object tagged with an integer for passing back and
|
||||||
|
// forth across the language boundary. A GoSeqRef may represent either
|
||||||
|
// an instance of a Go object, or an Objective-C object passed to Go.
|
||||||
|
// The explicit allocation of a GoSeqRef is used to pin a Go object
|
||||||
|
// when it is passed to Objective-C. The Go seq package maintains a
|
||||||
|
// reference to the Go object in a map keyed by the refnum along with
|
||||||
|
// a reference count. When the reference count reaches zero, the Go
|
||||||
|
// seq package will clear the corresponding entry in the map.
|
||||||
|
@interface GoSeqRef : NSObject {
|
||||||
|
}
|
||||||
|
@property(readonly) int32_t refnum;
|
||||||
|
@property(strong) id obj; // NULL when representing a Go object.
|
||||||
|
|
||||||
|
// new GoSeqRef object to proxy a Go object. The refnum must be
|
||||||
|
// provided from Go side.
|
||||||
|
- (instancetype)initWithRefnum:(int32_t)refnum obj:(id)obj;
|
||||||
|
|
||||||
|
- (int32_t)incNum;
|
||||||
|
|
||||||
|
@end
|
||||||
|
|
||||||
|
@protocol goSeqRefInterface
|
||||||
|
-(GoSeqRef*) _ref;
|
||||||
|
@end
|
||||||
|
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>TsnetBridge</string>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>TsnetBridge</string>
|
||||||
|
<key>MinimumOSVersion</key>
|
||||||
|
<string>100.0</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>0.0.1784856790</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>0.0.1784856790</string>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>FMWK</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
framework module "TsnetBridge" {
|
||||||
|
header "ref.h"
|
||||||
|
header "Tsnetbridge.objc.h"
|
||||||
|
header "Universe.objc.h"
|
||||||
|
header "TsnetBridge.h"
|
||||||
|
|
||||||
|
export *
|
||||||
|
}
|
||||||
Binary file not shown.
82
docs/HANDOFF.md
Normal file
82
docs/HANDOFF.md
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
# terminalX 交接文档(HANDOFF)
|
||||||
|
|
||||||
|
> 目的:让**没有上下文的新会话**读完即可掌握项目全貌并继续工作。配合 `../CLAUDE.md`(操作规范/命令)与项目记忆(`~/.claude/projects/-Users-kid-...-terminalX/memory/`,本项目会话会自动注入)一起看。
|
||||||
|
> 最后更新:2026-07-24。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. 一句话现状
|
||||||
|
一个**原生 iPad SSH 终端**已可用并真机验证:libghostty 渲染 + 键盘输入 + SSH 直连 + **SSH-over-tsnet(Tailscale 用户态)** + **tmux -CC 原生 tab** + 断线自动重连。四大核心需求(libghostty/tailscale/tmux 已验证,**mosh 未做**)。仅在 iOS 模拟器验证(未上真机/未签名/未提交 git)。
|
||||||
|
|
||||||
|
## 1. 项目愿景与硬约束
|
||||||
|
- 目标:iPad/iPhone 远程轻量运维 macOS/Linux + vibe coding。平台优先级 **iPadOS > iOS > macOS(Apple Silicon)**。
|
||||||
|
- iOS/iPadOS **只远程**(沙盒禁 fork/exec,不能跑本地 shell);macOS 才支持本地 PTY。
|
||||||
|
- 分发目标 App Store(M5,已暂缓)。
|
||||||
|
- 四大核心:**libghostty**(终端引擎) · **mosh** · **Tailscale tsnet(用户态,非 NetworkExtension)** · **tmux control-mode(-CC) hook**。
|
||||||
|
|
||||||
|
## 2. 关键架构决策(已定,勿推翻)
|
||||||
|
1. 终端引擎用 **libghostty**,经社区 **GhosttyKit**(`Lakr233/libghostty-spm`,已 vendor 到 `vendor/libghostty-spm`)。`TerminalViewState` 是 `ObservableObject`;喂字节入口 `InMemoryTerminalSession.receive(Data)`;SwiftUI 视图 `TerminalSurfaceView(context:)`。
|
||||||
|
2. Tailscale 走 **tsnet 用户态**(Go,gomobile bind → `TsnetBridge.xcframework`),**不用 NetworkExtension**(避开 VPN entitlement + 50MB 内存墙)。
|
||||||
|
3. SSH 用 **libssh2 + mbedTLS**(`CSSHCore.xcframework`),支持传入外部 fd。
|
||||||
|
4. **SSH-over-tsnet = fd 桥**:`tsnet.Dial()` 得 net.Conn(无真实 fd)→ `socketpair(AF_UNIX)` + 双向 io.Copy 泵 → 把另一端 fd 交给 libssh2。见 `vendor/tsnet-bridge/tsnetbridge/bridge.go: DialTCPFD`。
|
||||||
|
5. 一进程只一个 Go runtime、只一次 gomobile bind;C/C++ 库符号隐藏只导出 C API。
|
||||||
|
6. 多账号(未实现):多 tsnet 节点并存;每 host 绑 egress profile(Direct/tailnet);**禁止多 tailnet 并发盲探**(重叠 100.x/同名会误连)。
|
||||||
|
|
||||||
|
## 3. 环境(决定构建方式,极重要)
|
||||||
|
- **带认证的 HTTPS 代理**(`HTTPS_PROXY=...@...:38128`)。`github.com` release blob 能 curl;**go.dev / ghcr.io 被代理 abort**;**SwiftPM 不支持带认证代理** → 所有 SPM 依赖 vendor 本地化。Go 模块用 `goproxy.cn`。
|
||||||
|
- **Go 1.26.5 在 `~/.local/go`**(不在默认 PATH;经 aliyun 镜像 curl 装),**gomobile/gobind 在 `~/go/bin`**,`go env -w GOPROXY=https://goproxy.cn GOSUMDB=off` 已固化。
|
||||||
|
- 工具链:Xcode 26.6 / Swift 6.3 / xcodegen 2.46 / zig 0.15.2 / cmake 4.4 / ninja 1.13 / git。
|
||||||
|
|
||||||
|
## 4. 里程碑状态
|
||||||
|
|
||||||
|
| 里程碑 | 状态 | 真机实证 |
|
||||||
|
|---|---|---|
|
||||||
|
| **M0** SSH 直连终端 | ✅ 完成 | libghostty 渲染(SGR/CJK)、键盘输入、连接;**白屏 bug 修复**(OutputGate);会话状态机+自动重连(31→含后加共 35 单测) |
|
||||||
|
| **M1** tsnet + SSH-over-tsnet | ✅ 完成 | app 内 tsnet 加入 tailnet(IP 100.74.60.108);对端枚举;**SSH-over-tsnet 登录 Ubuntu shell**(服务端 `Last login from 100.x` 证明走 tailnet) |
|
||||||
|
| **M1.5** tmux -CC 原生 tab | ✅ MVP | 直连跑 `tmux -CC attach` → 渲染 fish/tmux 两原生 tab + 活动窗口内容 |
|
||||||
|
| dial 重试打磨 | ✅ | `TsnetManager.dialFD` 6 次退避重试,解决 tsnet 首连路径预热超时 |
|
||||||
|
| **R5** Go+gvisor+C+++Swift 同进程 | ✅ retire | 全程真机无崩溃 |
|
||||||
|
| **M2** mosh | ◻️ 未开始 | libmoshios(C++) + 本地 UDP relay over tsnet |
|
||||||
|
| **M3** 生命周期深化 | ◻️ 部分 | 前后台 FSM 已做;mosh 快速恢复 + tmux 兜底 待做 |
|
||||||
|
| **M4** 安全 | ◻️ 未做 | **当前 SSH 接受任意 host key**(见 SSHSession TODO);known_hosts 固定 + Secure Enclave 密钥 待做 |
|
||||||
|
| **M5** 合规提审 | ⏸️ 暂缓 | 用户指示暂缓 |
|
||||||
|
|
||||||
|
## 5. 代码地图与数据流
|
||||||
|
- `packages/TXCore/Sources/TXCore/`
|
||||||
|
- `Tmux/TmuxControlParser.swift` `TmuxEvent.swift` `TmuxIDs.swift` `TmuxOutputDecoder.swift` `TmuxLayout.swift` `TmuxControlSequence.swift`(DCS 检测)—— 纯解析,35 单测在 `Tests/`。
|
||||||
|
- `Session/SessionMachine.swift` —— `reduce(event)->[Effect]` 会话状态机(指数退避重连/后台冻结/前台恢复)。
|
||||||
|
- `packages/TXTransport/Sources/`
|
||||||
|
- `TXTransport/Transport.swift`(协议+SSHConfig+SSHError)、`SSHSession.swift`(libssh2;`preconnectedFD` 支持外部 fd;事件循环 poll+非阻塞)。
|
||||||
|
- `CSSH/`(C target:libssh2 头 + `include/module.modulemap`,供 `import CSSH`)、`CSSHCore`(binaryTarget,library-only)。
|
||||||
|
- `apps/TerminalX/iOS/`
|
||||||
|
- `SSHTerminalModel.swift` —— 核心编排:`OutputGate`(surface-ready 缓冲)、`TransportHolder`、`TsnetManager`(tsnet 节点 up/dial+重试)、`TsnetError`、`TmuxRouter`(raw/gateway 字节分流)、状态机驱动 startSession(直连/tsnet 分支)、`autoConnectIfConfigured`(启动参数)。
|
||||||
|
- `ContentView.swift` —— 分支:`tmuxController != nil`→`TmuxTabbedView`(tab 条+活动窗口);否则 `RawTerminalView`;`TsnetProbeView`(纯自检);`ConnectionForm`。
|
||||||
|
- `TmuxController.swift` —— tmux 网关:消费解析事件、window↔`TmuxWindow`(各自终端会话+OutputGate)、`list-windows`/`list-panes`/`capture-pane` 握手、`send-keys`/`select-window`/`new-window`。
|
||||||
|
- `TsnetProbe.swift` —— M1 自检视图(加入 tailnet + 对端列表)。
|
||||||
|
- `TerminalXApp.swift` —— @main。
|
||||||
|
- **数据流(直连)**:transport.onBytes → `TmuxRouter.feed` → (raw) `OutputGate.deliver`→`session.receive`→libghostty;(检测到 DCS) → `TmuxController.feed`→解析→各 window。输入:libghostty→session write 闭包→transport.send(tmux 模式→send-keys)。
|
||||||
|
- **数据流(tsnet)**:`TsnetManager.ensureUp`(auth key)→`dialFD`(socketpair fd)→`SSHSession(preconnectedFD:)`。
|
||||||
|
|
||||||
|
## 6. 已知问题 / TODO(继续工作的入口)
|
||||||
|
- **M4 安全**:`SSHSession.connect()` 有 `TODO(M4)`:不校验 host key(接受任意)。需接 known_hosts 三元组 pin(egress,host,port) + Secure Enclave 私钥(`libssh2_userauth_publickey_frommemory`+`SecKeyCreateSignature`)。
|
||||||
|
- **tmux MVP 简化**:每 window 只显示活动 pane(**多 pane 分屏未做**);历史靠 `capture-pane` 抓当前屏(非完整 scrollback);命令应答 FIFO 简化匹配;resize 未回传(默认 80x24,应发 `refresh-client -C`)。
|
||||||
|
- **tsnet 首连**:已加 dial 重试;更优是等 peer online 再 dial(读 PeersJSON online 状态)。
|
||||||
|
- **签名/真机**:`CODE_SIGNING_ALLOWED=NO`,仅模拟器;上真机/TestFlight 需配置签名 + entitlements(tsnet 用户态**无需** NE entitlement,利好审核)。
|
||||||
|
- **git**:已 `git init`,**尚无 commit**;`artifacts/*.xcframework` 已 gitignore(需 `make`/脚本重建或从 Release 拉)。
|
||||||
|
- **多平台**:iOS target 已跑;macOS target(本地 PTY, `TXPTY`)未建。
|
||||||
|
|
||||||
|
## 7. 下一步建议(按价值)
|
||||||
|
1. **M2 mosh over tsnet**:接 `blinksh/mosh`(libmoshios C++,依赖 protobuf) 交叉编译成 xcframework(参考 `build-cssh.sh` 模式) → 本地 UDP relay(mosh 连 127.0.0.1:N,Go 侧 `tsnet` UDP `ListenPacket`/`Dial` 转发;bridge 加 UDP relay 方法,类比 `DialTCPFD`)。SSH 先起 `mosh-server` 拿 key+port。**需用户提供装了 mosh-server 的 tailnet 主机**做端到端。
|
||||||
|
2. **tmux 多 pane 分屏**:`TmuxLayout` 已能解析布局树 → 把 window 内多 pane 渲染成 SwiftUI 分屏(每 pane 一 surface)。
|
||||||
|
3. **M4 安全**:known_hosts 固定 + SE 密钥。
|
||||||
|
4. **产品化**:主机列表持久化(Keychain)、多标签(非 tmux)、软键盘运维工具栏(GhosttyKit 内建,接线即可)。
|
||||||
|
|
||||||
|
## 8. 测试资源(用户提供,**凭据勿写入仓库/勿硬编码**,每次向用户索取)
|
||||||
|
- 一台局域网 macOS(有 tmux)、一台 tailnet 内 online 的 Ubuntu VM(vohive-vm)、一个 tailnet auth key(ephemeral,可能过期,用户会给新的)。用于直连 SSH / SSH-over-tsnet / tmux / M2 mosh 端到端验证。
|
||||||
|
- tailnet 是 `tail3b5057.ts.net`,已见节点:vohive-vm(linux)、iphone-air、ipad-pro、macbook-air-m1、macbook-air。
|
||||||
|
|
||||||
|
## 9. 工作纪律(务必遵守)
|
||||||
|
- **绝不臆想工具结果**:调用后等真实返回再推理,绝不编造 BUILD/日志/截图。曾因连续编造工具输出(含假 BUILD SUCCEEDED、错误归因 GhosttyKit)而误诊——真相全靠老实 `Read` 完整 log。此规则已写入全局 `~/.claude/CLAUDE.md`。
|
||||||
|
- 每步用真实证据验证:`swift test` 看真实计数、`xcodebuild` 看真实 BUILD 结果、模拟器 `screenshot` + `Read` 看真实画面。
|
||||||
|
- 与用户用**简体中文**;commit/push 只在明确要求时。
|
||||||
|
</content>
|
||||||
25
packages/TXCore/Package.swift
Normal file
25
packages/TXCore/Package.swift
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
// swift-tools-version: 6.0
|
||||||
|
import PackageDescription
|
||||||
|
|
||||||
|
let package = Package(
|
||||||
|
name: "TXCore",
|
||||||
|
platforms: [
|
||||||
|
.macOS(.v14),
|
||||||
|
.iOS(.v17),
|
||||||
|
],
|
||||||
|
products: [
|
||||||
|
.library(name: "TXCore", targets: ["TXCore"]),
|
||||||
|
],
|
||||||
|
targets: [
|
||||||
|
// 零依赖:领域模型、状态机、协议、纯算法。可在 macOS 命令行 `swift test`。
|
||||||
|
.target(
|
||||||
|
name: "TXCore",
|
||||||
|
swiftSettings: [.swiftLanguageMode(.v6)]
|
||||||
|
),
|
||||||
|
.testTarget(
|
||||||
|
name: "TXCoreTests",
|
||||||
|
dependencies: ["TXCore"],
|
||||||
|
swiftSettings: [.swiftLanguageMode(.v6)]
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
164
packages/TXCore/Sources/TXCore/Session/SessionMachine.swift
Normal file
164
packages/TXCore/Sources/TXCore/Session/SessionMachine.swift
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// 会话生命周期状态机(切片② + M3 地基)。纯逻辑、零依赖、可单测。
|
||||||
|
///
|
||||||
|
/// 设计要点:`reduce(event)` 是纯函数式转移——输入事件、更新 `phase`、返回一组
|
||||||
|
/// `Effect`(副作用意图)由外层执行(发起连接、排程重连、更新 UI 等)。
|
||||||
|
/// 移动端核心诉求:断线/失败后按指数退避自动重连,前台恢复即重试。
|
||||||
|
public struct SessionMachine: Sendable, Equatable {
|
||||||
|
|
||||||
|
public enum Phase: Sendable, Equatable {
|
||||||
|
case idle
|
||||||
|
case connecting
|
||||||
|
case authenticating
|
||||||
|
case connected
|
||||||
|
/// 进入后台,连接冻结(保留最后画面),不主动断开。
|
||||||
|
case backgroundParked
|
||||||
|
/// 已断开/失败,等待下一次自动重连(记录第几次尝试)。
|
||||||
|
case waitingToReconnect(attempt: Int)
|
||||||
|
/// 正在执行自动重连。
|
||||||
|
case reconnecting(attempt: Int)
|
||||||
|
/// 终态失败:需要用户介入(认证失效等),不再自动重连。
|
||||||
|
case failed(reason: String)
|
||||||
|
/// 用户主动关闭。
|
||||||
|
case closed
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum Event: Sendable, Equatable {
|
||||||
|
case connectRequested
|
||||||
|
case authenticating
|
||||||
|
case established // 传输层报告已连接
|
||||||
|
case transportClosed(reason: String?) // 网络断开(可重连)
|
||||||
|
case authFailed(reason: String) // 认证失败(终态,不重连)
|
||||||
|
case reconnectTimerFired
|
||||||
|
case enteredBackground
|
||||||
|
case enteredForeground
|
||||||
|
case closeRequested
|
||||||
|
case retryRequested // 用户手动重试(从 failed 复位)
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum Effect: Sendable, Equatable {
|
||||||
|
case startConnect
|
||||||
|
case scheduleReconnect(attempt: Int, delayMS: Int)
|
||||||
|
case cancelReconnectTimer
|
||||||
|
case teardownTransport
|
||||||
|
case notify(String) // UI 提示
|
||||||
|
}
|
||||||
|
|
||||||
|
public private(set) var phase: Phase = .idle
|
||||||
|
|
||||||
|
/// 最大自动重连次数(超过则转 failed)。
|
||||||
|
public let maxReconnectAttempts: Int
|
||||||
|
/// 退避基数与上限(毫秒)。
|
||||||
|
private let baseDelayMS: Int
|
||||||
|
private let maxDelayMS: Int
|
||||||
|
|
||||||
|
public init(maxReconnectAttempts: Int = 6, baseDelayMS: Int = 500, maxDelayMS: Int = 16000) {
|
||||||
|
self.maxReconnectAttempts = maxReconnectAttempts
|
||||||
|
self.baseDelayMS = baseDelayMS
|
||||||
|
self.maxDelayMS = maxDelayMS
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 指数退避:base * 2^(attempt-1),封顶 maxDelayMS。
|
||||||
|
public func backoffDelayMS(attempt: Int) -> Int {
|
||||||
|
guard attempt >= 1 else { return baseDelayMS }
|
||||||
|
let shift = min(attempt - 1, 30)
|
||||||
|
let raw = baseDelayMS * (1 << shift)
|
||||||
|
return min(raw, maxDelayMS)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 处理一个事件,就地更新 phase,返回需执行的副作用。
|
||||||
|
public mutating func reduce(_ event: Event) -> [Effect] {
|
||||||
|
switch (phase, event) {
|
||||||
|
|
||||||
|
// —— 发起连接 ——
|
||||||
|
case (.idle, .connectRequested),
|
||||||
|
(.closed, .connectRequested),
|
||||||
|
(.failed, .retryRequested):
|
||||||
|
phase = .connecting
|
||||||
|
return [.startConnect]
|
||||||
|
|
||||||
|
case (.connecting, .authenticating):
|
||||||
|
phase = .authenticating
|
||||||
|
return []
|
||||||
|
|
||||||
|
case (.connecting, .established), (.authenticating, .established):
|
||||||
|
phase = .connected
|
||||||
|
return [.notify("已连接")]
|
||||||
|
|
||||||
|
// —— 认证失败:终态,不自动重连 ——
|
||||||
|
case (.connecting, .authFailed(let r)), (.authenticating, .authFailed(let r)):
|
||||||
|
phase = .failed(reason: r)
|
||||||
|
return [.teardownTransport, .notify("认证失败:\(r)")]
|
||||||
|
|
||||||
|
// —— 网络断开:进入自动重连(从已连接或连接中)——
|
||||||
|
case (.connected, .transportClosed(let r)),
|
||||||
|
(.connecting, .transportClosed(let r)),
|
||||||
|
(.authenticating, .transportClosed(let r)):
|
||||||
|
return enterReconnect(fromAttempt: 0, reason: r)
|
||||||
|
|
||||||
|
// —— 重连计时到点:执行重连 ——
|
||||||
|
case (.waitingToReconnect(let attempt), .reconnectTimerFired):
|
||||||
|
phase = .reconnecting(attempt: attempt)
|
||||||
|
return [.startConnect]
|
||||||
|
|
||||||
|
// 重连成功
|
||||||
|
case (.reconnecting, .established):
|
||||||
|
phase = .connected
|
||||||
|
return [.notify("已重连")]
|
||||||
|
|
||||||
|
// 重连中的过渡态
|
||||||
|
case (.reconnecting, .authenticating):
|
||||||
|
return []
|
||||||
|
case (.reconnecting, .authFailed(let r)):
|
||||||
|
phase = .failed(reason: r)
|
||||||
|
return [.teardownTransport, .notify("认证失败:\(r)")]
|
||||||
|
|
||||||
|
// 重连又断:下一次退避(attempt 递增)
|
||||||
|
case (.reconnecting(let attempt), .transportClosed(let r)):
|
||||||
|
return enterReconnect(fromAttempt: attempt, reason: r)
|
||||||
|
|
||||||
|
// —— 后台/前台 ——
|
||||||
|
case (.connected, .enteredBackground):
|
||||||
|
phase = .backgroundParked
|
||||||
|
return []
|
||||||
|
case (.backgroundParked, .enteredForeground):
|
||||||
|
// 恢复:主动触发一次重连探测(attempt 从 1 起)
|
||||||
|
phase = .reconnecting(attempt: 1)
|
||||||
|
return [.startConnect, .notify("恢复中…")]
|
||||||
|
case (.backgroundParked, .transportClosed):
|
||||||
|
// 后台期间底层已断,回前台会重连;保持 parked
|
||||||
|
return []
|
||||||
|
|
||||||
|
// 等待重连期间进入后台:取消计时,parked
|
||||||
|
case (.waitingToReconnect, .enteredBackground):
|
||||||
|
phase = .backgroundParked
|
||||||
|
return [.cancelReconnectTimer]
|
||||||
|
|
||||||
|
// —— 关闭 ——
|
||||||
|
case (_, .closeRequested):
|
||||||
|
phase = .closed
|
||||||
|
return [.cancelReconnectTimer, .teardownTransport]
|
||||||
|
|
||||||
|
// 其它组合不改变状态
|
||||||
|
default:
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private mutating func enterReconnect(fromAttempt: Int, reason: String?) -> [Effect] {
|
||||||
|
let next = fromAttempt + 1
|
||||||
|
if next > maxReconnectAttempts {
|
||||||
|
phase = .failed(reason: reason ?? "连接中断,重连已达上限")
|
||||||
|
return [.teardownTransport, .notify("重连失败,请手动重试")]
|
||||||
|
}
|
||||||
|
phase = .waitingToReconnect(attempt: next)
|
||||||
|
let delay = backoffDelayMS(attempt: next)
|
||||||
|
let reasonText = reason.map { ":\($0)" } ?? ""
|
||||||
|
return [
|
||||||
|
.teardownTransport,
|
||||||
|
.scheduleReconnect(attempt: next, delayMS: delay),
|
||||||
|
.notify("连接中断\(reasonText),\(delay / 1000)s 后重连(第 \(next) 次)"),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
203
packages/TXCore/Sources/TXCore/Tmux/TmuxControlParser.swift
Normal file
203
packages/TXCore/Sources/TXCore/Tmux/TmuxControlParser.swift
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// tmux control-mode 行协议解析器。
|
||||||
|
///
|
||||||
|
/// 输入任意切片的字节流(`feed`),输出完整语义事件。**不涉及 UI、非 `@MainActor`**,
|
||||||
|
/// 设计为在 transport 的串行队列上运行。
|
||||||
|
///
|
||||||
|
/// 关键不变量(依据协议):
|
||||||
|
/// - 输出严格按行(`\n`),残行缓冲到下次 `feed`;容忍 `\r\n`。
|
||||||
|
/// - `%begin`…`%end`/`%error` 守卫块之间的所有行都是命令输出,**即使以 `%` 开头**也不解释为通知。
|
||||||
|
/// - `%output` payload 按字节八进制解码,绝不经 `String`(UTF-8 多字节字符可能跨多条 `%output`)。
|
||||||
|
/// - 未知 `%`-行降级为 `.unknown`,保证前向兼容。
|
||||||
|
public final class TmuxControlParser {
|
||||||
|
|
||||||
|
private enum State {
|
||||||
|
case idle
|
||||||
|
case inBlock(number: Int, lines: [String])
|
||||||
|
}
|
||||||
|
|
||||||
|
private var buffer: [UInt8] = []
|
||||||
|
private var state: State = .idle
|
||||||
|
|
||||||
|
public init() {}
|
||||||
|
|
||||||
|
/// 送入一段原始字节,返回本次可解析出的完整事件(可能为空)。
|
||||||
|
public func feed(_ chunk: Data) -> [TmuxEvent] {
|
||||||
|
buffer.append(contentsOf: chunk)
|
||||||
|
var events: [TmuxEvent] = []
|
||||||
|
while let nl = buffer.firstIndex(of: 0x0A) {
|
||||||
|
var line = Array(buffer[buffer.startIndex..<nl])
|
||||||
|
buffer.removeSubrange(buffer.startIndex...nl)
|
||||||
|
if line.last == 0x0D { line.removeLast() } // 容忍 CRLF
|
||||||
|
handle(line: line, into: &events)
|
||||||
|
}
|
||||||
|
return events
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 字符串便捷入口(测试用)。
|
||||||
|
public func feed(_ text: String) -> [TmuxEvent] {
|
||||||
|
feed(Data(text.utf8))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 行处理
|
||||||
|
|
||||||
|
private func handle(line: [UInt8], into events: inout [TmuxEvent]) {
|
||||||
|
switch state {
|
||||||
|
case .inBlock(let number, var lines):
|
||||||
|
if let guardLine = parseBlockGuard(line), guardLine.number == number {
|
||||||
|
events.append(.commandResponse(
|
||||||
|
TmuxCommandResponse(number: number, isError: guardLine.isError, lines: lines)
|
||||||
|
))
|
||||||
|
state = .idle
|
||||||
|
} else {
|
||||||
|
lines.append(String(decoding: line, as: UTF8.self))
|
||||||
|
state = .inBlock(number: number, lines: lines)
|
||||||
|
}
|
||||||
|
|
||||||
|
case .idle:
|
||||||
|
if line.first == 0x25 /* % */ {
|
||||||
|
if let begin = parseBeginGuard(line) {
|
||||||
|
state = .inBlock(number: begin, lines: [])
|
||||||
|
} else {
|
||||||
|
parseNotification(line: line, into: &events)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// idle 态下的非 %-行属于协议噪声(如登录横幅)。
|
||||||
|
events.append(.unknown(line: String(decoding: line, as: UTF8.self)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 守卫行
|
||||||
|
|
||||||
|
/// `%begin <time> <number> <flags>` → number
|
||||||
|
private func parseBeginGuard(_ line: [UInt8]) -> Int? {
|
||||||
|
let tokens = tokenize(line)
|
||||||
|
guard tokens.first == "%begin", tokens.count >= 3 else { return nil }
|
||||||
|
return Int(tokens[2])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `%end|%error <time> <number> <flags>`
|
||||||
|
private func parseBlockGuard(_ line: [UInt8]) -> (number: Int, isError: Bool)? {
|
||||||
|
let tokens = tokenize(line)
|
||||||
|
guard let head = tokens.first, head == "%end" || head == "%error",
|
||||||
|
tokens.count >= 3, let n = Int(tokens[2]) else { return nil }
|
||||||
|
return (n, head == "%error")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 通知
|
||||||
|
|
||||||
|
private func parseNotification(line: [UInt8], into events: inout [TmuxEvent]) {
|
||||||
|
let tokens = tokenize(line)
|
||||||
|
guard let head = tokens.first else {
|
||||||
|
events.append(.unknown(line: String(decoding: line, as: UTF8.self)))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch head {
|
||||||
|
case "%output":
|
||||||
|
// %output %<pane> <data>;data 从第 2 个空格后取原始字节(含 raw space)。
|
||||||
|
guard tokens.count >= 2, let pane = TmuxPaneID.parse(tokens[1]) else { break }
|
||||||
|
let payload = TmuxOutputDecoder.decode(bytesAfterTokens(2, in: line))
|
||||||
|
events.append(.output(pane: pane, data: payload))
|
||||||
|
return
|
||||||
|
|
||||||
|
case "%extended-output":
|
||||||
|
// %extended-output %<pane> <age> : <data>
|
||||||
|
guard tokens.count >= 4, let pane = TmuxPaneID.parse(tokens[1]),
|
||||||
|
let age = Int(tokens[2]) else { break }
|
||||||
|
// tokens[3] 为 ":";data 从第 4 个空格后取。
|
||||||
|
let payload = TmuxOutputDecoder.decode(bytesAfterTokens(4, in: line))
|
||||||
|
events.append(.extendedOutput(pane: pane, ageMS: age, data: payload))
|
||||||
|
return
|
||||||
|
|
||||||
|
case "%window-add":
|
||||||
|
if tokens.count >= 2, let w = TmuxWindowID.parse(tokens[1]) {
|
||||||
|
events.append(.windowAdd(w)); return
|
||||||
|
}
|
||||||
|
case "%window-close":
|
||||||
|
if tokens.count >= 2, let w = TmuxWindowID.parse(tokens[1]) {
|
||||||
|
events.append(.windowClose(w)); return
|
||||||
|
}
|
||||||
|
case "%unlinked-window-close":
|
||||||
|
if tokens.count >= 2, let w = TmuxWindowID.parse(tokens[1]) {
|
||||||
|
events.append(.unlinkedWindowClose(w)); return
|
||||||
|
}
|
||||||
|
case "%window-renamed":
|
||||||
|
if tokens.count >= 3, let w = TmuxWindowID.parse(tokens[1]) {
|
||||||
|
events.append(.windowRenamed(w, name: tokens[2...].joined(separator: " "))); return
|
||||||
|
}
|
||||||
|
case "%window-pane-changed":
|
||||||
|
if tokens.count >= 3, let w = TmuxWindowID.parse(tokens[1]),
|
||||||
|
let p = TmuxPaneID.parse(tokens[2]) {
|
||||||
|
events.append(.windowPaneChanged(window: w, pane: p)); return
|
||||||
|
}
|
||||||
|
case "%layout-change":
|
||||||
|
// %layout-change @<w> <layout> [<visible-layout>] [<flags>]
|
||||||
|
if tokens.count >= 3, let w = TmuxWindowID.parse(tokens[1]) {
|
||||||
|
let visible = tokens.count >= 4 ? tokens[3] : nil
|
||||||
|
events.append(.layoutChange(window: w, layout: tokens[2], visibleLayout: visible)); return
|
||||||
|
}
|
||||||
|
case "%session-changed":
|
||||||
|
if tokens.count >= 3, let s = TmuxSessionID.parse(tokens[1]) {
|
||||||
|
events.append(.sessionChanged(s, name: tokens[2...].joined(separator: " "))); return
|
||||||
|
}
|
||||||
|
case "%session-renamed":
|
||||||
|
// 部分版本为 `%session-renamed <name>`(当前会话),也有带 $id 的形式;两者都容忍。
|
||||||
|
if tokens.count >= 3, let s = TmuxSessionID.parse(tokens[1]) {
|
||||||
|
events.append(.sessionRenamed(s, name: tokens[2...].joined(separator: " "))); return
|
||||||
|
}
|
||||||
|
case "%sessions-changed":
|
||||||
|
events.append(.sessionsChanged); return
|
||||||
|
case "%session-window-changed":
|
||||||
|
if tokens.count >= 3, let s = TmuxSessionID.parse(tokens[1]),
|
||||||
|
let w = TmuxWindowID.parse(tokens[2]) {
|
||||||
|
events.append(.sessionWindowChanged(session: s, window: w)); return
|
||||||
|
}
|
||||||
|
case "%pane-mode-changed":
|
||||||
|
if tokens.count >= 2, let p = TmuxPaneID.parse(tokens[1]) {
|
||||||
|
events.append(.paneModeChanged(p)); return
|
||||||
|
}
|
||||||
|
case "%pause":
|
||||||
|
if tokens.count >= 2, let p = TmuxPaneID.parse(tokens[1]) {
|
||||||
|
events.append(.pause(p)); return
|
||||||
|
}
|
||||||
|
case "%continue":
|
||||||
|
if tokens.count >= 2, let p = TmuxPaneID.parse(tokens[1]) {
|
||||||
|
events.append(.unpause(p)); return
|
||||||
|
}
|
||||||
|
case "%exit":
|
||||||
|
let reason = tokens.count >= 2 ? tokens[1...].joined(separator: " ") : nil
|
||||||
|
events.append(.exit(reason: reason)); return
|
||||||
|
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
events.append(.unknown(line: String(decoding: line, as: UTF8.self)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 字节级辅助
|
||||||
|
|
||||||
|
/// 按 ASCII 空格切分为 UTF-8 字符串 token(用于守卫行/通知头,非 payload)。
|
||||||
|
private func tokenize(_ line: [UInt8]) -> [String] {
|
||||||
|
line.split(separator: 0x20, omittingEmptySubsequences: true)
|
||||||
|
.map { String(decoding: $0, as: UTF8.self) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 返回跳过前 `n` 个以空格分隔的 token 之后的原始字节切片(保留 payload 内的 raw space)。
|
||||||
|
private func bytesAfterTokens(_ n: Int, in line: [UInt8]) -> ArraySlice<UInt8> {
|
||||||
|
var idx = line.startIndex
|
||||||
|
var spacesSeen = 0
|
||||||
|
// 逐字节推进,跳过 n 段非空格 + 其后的单个分隔空格。
|
||||||
|
while idx < line.endIndex && spacesSeen < n {
|
||||||
|
// 跳过一个 token(非空格)
|
||||||
|
while idx < line.endIndex && line[idx] != 0x20 { idx = line.index(after: idx) }
|
||||||
|
// 跳过分隔空格
|
||||||
|
if idx < line.endIndex { idx = line.index(after: idx) }
|
||||||
|
spacesSeen += 1
|
||||||
|
}
|
||||||
|
return line[idx..<line.endIndex]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// tmux `-CC`(iTerm2 风格)控制模式的进入/退出哨兵序列检测。
|
||||||
|
/// 进入:tmux 打印 DCS `ESC P 1000 p`;退出:`%exit` 后打印 ST `ESC \`。
|
||||||
|
/// 用于在原始字节流中把「普通 shell 输出」与「control-mode 协议流」分界。
|
||||||
|
public enum TmuxControlSequence {
|
||||||
|
/// 进入控制模式的 DCS:ESC P 1000 p
|
||||||
|
public static let enter: [UInt8] = [0x1B, 0x50, 0x31, 0x30, 0x30, 0x30, 0x70]
|
||||||
|
/// 退出控制模式的 ST:ESC \
|
||||||
|
public static let exit: [UInt8] = [0x1B, 0x5C]
|
||||||
|
|
||||||
|
/// 在 bytes 中查找 needle,返回其**起始**下标;未找到返回 nil。
|
||||||
|
public static func find(_ needle: [UInt8], in bytes: [UInt8]) -> Int? {
|
||||||
|
guard !needle.isEmpty, bytes.count >= needle.count else { return nil }
|
||||||
|
let last = bytes.count - needle.count
|
||||||
|
var i = 0
|
||||||
|
while i <= last {
|
||||||
|
var j = 0
|
||||||
|
while j < needle.count && bytes[i + j] == needle[j] { j += 1 }
|
||||||
|
if j == needle.count { return i }
|
||||||
|
i += 1
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 为「跨 feed 边界」的哨兵匹配保留的尾部长度(needle 最长 - 1)。
|
||||||
|
public static var maxSentinelTail: Int { max(enter.count, exit.count) - 1 }
|
||||||
|
|
||||||
|
/// 返回 bytes 尾部与 needle 前缀相同的最大长度(1..<needle.count);无则 0。
|
||||||
|
/// 用于仅在「chunk 结尾恰是哨兵的部分前缀」时才滞留字节,避免延迟正常输出。
|
||||||
|
public static func partialTrailingMatchLength(_ needle: [UInt8], in bytes: [UInt8]) -> Int {
|
||||||
|
var k = min(needle.count - 1, bytes.count)
|
||||||
|
while k > 0 {
|
||||||
|
var match = true
|
||||||
|
for j in 0 ..< k where bytes[bytes.count - k + j] != needle[j] { match = false; break }
|
||||||
|
if match { return k }
|
||||||
|
k -= 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
60
packages/TXCore/Sources/TXCore/Tmux/TmuxEvent.swift
Normal file
60
packages/TXCore/Sources/TXCore/Tmux/TmuxEvent.swift
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// control-mode 解析器输出的语义事件。
|
||||||
|
///
|
||||||
|
/// 协议参考:https://github.com/tmux/tmux/wiki/Control-Mode
|
||||||
|
/// - `%output`/`%extended-output` 承载 pane 的原始 PTY 字节(已从八进制转义还原为 `Data`)。
|
||||||
|
/// - 命令应答由 `%begin`/`%end`/`%error` 守卫块包裹,按命令发送顺序返回。
|
||||||
|
/// - 其余 `%`-前缀行是异步通知。
|
||||||
|
public enum TmuxEvent: Sendable, Equatable {
|
||||||
|
/// `%output %<pane> <data>` — pane 的 PTY 输出(已解码为原始字节)。
|
||||||
|
case output(pane: TmuxPaneID, data: Data)
|
||||||
|
/// `%extended-output %<pane> <age-ms> : <data>` — 带滞后毫秒数的输出(流控场景)。
|
||||||
|
case extendedOutput(pane: TmuxPaneID, ageMS: Int, data: Data)
|
||||||
|
|
||||||
|
/// 一条命令的完整应答(`%begin`…`%end`/`%error` 之间)。
|
||||||
|
case commandResponse(TmuxCommandResponse)
|
||||||
|
|
||||||
|
case windowAdd(TmuxWindowID)
|
||||||
|
case windowClose(TmuxWindowID)
|
||||||
|
case unlinkedWindowClose(TmuxWindowID)
|
||||||
|
case windowRenamed(TmuxWindowID, name: String)
|
||||||
|
case windowPaneChanged(window: TmuxWindowID, pane: TmuxPaneID)
|
||||||
|
|
||||||
|
case layoutChange(window: TmuxWindowID, layout: String, visibleLayout: String?)
|
||||||
|
|
||||||
|
case sessionChanged(TmuxSessionID, name: String)
|
||||||
|
case sessionRenamed(TmuxSessionID, name: String)
|
||||||
|
case sessionsChanged
|
||||||
|
case sessionWindowChanged(session: TmuxSessionID, window: TmuxWindowID)
|
||||||
|
|
||||||
|
case paneModeChanged(TmuxPaneID)
|
||||||
|
|
||||||
|
/// 流控:某 pane 输出被暂停 / 恢复。
|
||||||
|
case pause(TmuxPaneID)
|
||||||
|
case unpause(TmuxPaneID)
|
||||||
|
|
||||||
|
case exit(reason: String?)
|
||||||
|
|
||||||
|
/// 前向兼容:未识别的行(未知通知或协议噪声)。上层应容忍并记录。
|
||||||
|
case unknown(line: String)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `%begin <t> <num> <flags>` … `%end|%error <t> <num> <flags>` 之间的原始输出。
|
||||||
|
public struct TmuxCommandResponse: Sendable, Equatable {
|
||||||
|
/// 命令序号(守卫行的第 2 个字段),用于与发送队列 FIFO 关联。
|
||||||
|
public let number: Int
|
||||||
|
/// 是否以 `%error` 结束。
|
||||||
|
public let isError: Bool
|
||||||
|
/// 守卫块内的原始行(不含守卫行本身)。
|
||||||
|
public let lines: [String]
|
||||||
|
|
||||||
|
public init(number: Int, isError: Bool, lines: [String]) {
|
||||||
|
self.number = number
|
||||||
|
self.isError = isError
|
||||||
|
self.lines = lines
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 便捷:整块合并为字符串。
|
||||||
|
public var text: String { lines.joined(separator: "\n") }
|
||||||
|
}
|
||||||
42
packages/TXCore/Sources/TXCore/Tmux/TmuxIDs.swift
Normal file
42
packages/TXCore/Sources/TXCore/Tmux/TmuxIDs.swift
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// tmux 对象标识。control mode 里以带前缀的十进制文本出现:
|
||||||
|
/// pane `%3`、window `@2`、session `$1`。
|
||||||
|
public struct TmuxPaneID: Hashable, Sendable, CustomStringConvertible {
|
||||||
|
public let raw: Int
|
||||||
|
public init(_ raw: Int) { self.raw = raw }
|
||||||
|
public var description: String { "%\(raw)" }
|
||||||
|
|
||||||
|
/// 解析 `%3` → TmuxPaneID(3)。前缀不符或非数字返回 nil。
|
||||||
|
public static func parse(_ token: some StringProtocol) -> TmuxPaneID? {
|
||||||
|
TmuxID.parse(token, prefix: "%").map(TmuxPaneID.init)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct TmuxWindowID: Hashable, Sendable, CustomStringConvertible {
|
||||||
|
public let raw: Int
|
||||||
|
public init(_ raw: Int) { self.raw = raw }
|
||||||
|
public var description: String { "@\(raw)" }
|
||||||
|
|
||||||
|
public static func parse(_ token: some StringProtocol) -> TmuxWindowID? {
|
||||||
|
TmuxID.parse(token, prefix: "@").map(TmuxWindowID.init)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct TmuxSessionID: Hashable, Sendable, CustomStringConvertible {
|
||||||
|
public let raw: Int
|
||||||
|
public init(_ raw: Int) { self.raw = raw }
|
||||||
|
public var description: String { "$\(raw)" }
|
||||||
|
|
||||||
|
public static func parse(_ token: some StringProtocol) -> TmuxSessionID? {
|
||||||
|
TmuxID.parse(token, prefix: "$").map(TmuxSessionID.init)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum TmuxID {
|
||||||
|
/// 校验前缀并解析其后的十进制整数。
|
||||||
|
static func parse(_ token: some StringProtocol, prefix: Character) -> Int? {
|
||||||
|
guard let first = token.first, first == prefix else { return nil }
|
||||||
|
return Int(token.dropFirst())
|
||||||
|
}
|
||||||
|
}
|
||||||
127
packages/TXCore/Sources/TXCore/Tmux/TmuxLayout.swift
Normal file
127
packages/TXCore/Sources/TXCore/Tmux/TmuxLayout.swift
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// tmux window layout 字符串的解析结果。
|
||||||
|
///
|
||||||
|
/// 格式(递归):`<csum>,<cell>`,其中
|
||||||
|
/// - `cell` = `WxH,X,Y` 后接三选一:`,<paneId>`(叶子)、`{c,c,…}`(左右并列)、`[c,c,…]`(上下并列)。
|
||||||
|
/// - 前导 `<csum>` 是 4 位十六进制校验和,解析时忽略。
|
||||||
|
///
|
||||||
|
/// 例:`bc62,80x24,0,0,0`(单 pane)、
|
||||||
|
/// `a1b2,80x24,0,0{40x24,0,0,1,39x24,41,0,2}`(两 pane 左右分屏)。
|
||||||
|
public struct TmuxLayout: Sendable, Equatable {
|
||||||
|
public let root: Node
|
||||||
|
|
||||||
|
public indirect enum Node: Sendable, Equatable {
|
||||||
|
case leaf(pane: TmuxPaneID, rect: Rect)
|
||||||
|
/// 子节点沿水平方向并排(`{}`)。
|
||||||
|
case horizontal([Node], rect: Rect)
|
||||||
|
/// 子节点沿垂直方向堆叠(`[]`)。
|
||||||
|
case vertical([Node], rect: Rect)
|
||||||
|
|
||||||
|
public var rect: Rect {
|
||||||
|
switch self {
|
||||||
|
case .leaf(_, let r), .horizontal(_, let r), .vertical(_, let r): return r
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct Rect: Sendable, Equatable {
|
||||||
|
public let width: Int, height: Int, x: Int, y: Int
|
||||||
|
public init(width: Int, height: Int, x: Int, y: Int) {
|
||||||
|
self.width = width; self.height = height; self.x = x; self.y = y
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum ParseError: Error, Equatable {
|
||||||
|
case empty
|
||||||
|
case malformed(at: Int, reason: String)
|
||||||
|
case trailingGarbage(at: Int)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 收集 layout 中出现的全部 pane(用于与现有 pane 集合做 diff)。
|
||||||
|
public var paneIDs: [TmuxPaneID] {
|
||||||
|
var acc: [TmuxPaneID] = []
|
||||||
|
func walk(_ n: Node) {
|
||||||
|
switch n {
|
||||||
|
case .leaf(let p, _): acc.append(p)
|
||||||
|
case .horizontal(let cs, _), .vertical(let cs, _): cs.forEach(walk)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
walk(root)
|
||||||
|
return acc
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func parse(_ s: String) throws -> TmuxLayout {
|
||||||
|
guard let comma = s.firstIndex(of: ",") else { throw ParseError.empty }
|
||||||
|
let body = s[s.index(after: comma)...]
|
||||||
|
var cur = Cursor(Array(body))
|
||||||
|
let root = try cur.parseCell()
|
||||||
|
guard cur.isAtEnd else { throw ParseError.trailingGarbage(at: cur.offset) }
|
||||||
|
return TmuxLayout(root: root)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct Cursor {
|
||||||
|
let chars: [Character]
|
||||||
|
var i = 0
|
||||||
|
init(_ chars: [Character]) { self.chars = chars }
|
||||||
|
|
||||||
|
var isAtEnd: Bool { i >= chars.count }
|
||||||
|
var offset: Int { i }
|
||||||
|
private func peek() -> Character? { i < chars.count ? chars[i] : nil }
|
||||||
|
|
||||||
|
mutating func parseCell() throws -> TmuxLayout.Node {
|
||||||
|
let start = i
|
||||||
|
let w = try parseInt()
|
||||||
|
try expect("x")
|
||||||
|
let h = try parseInt()
|
||||||
|
try expect(",")
|
||||||
|
let x = try parseInt()
|
||||||
|
try expect(",")
|
||||||
|
let y = try parseInt()
|
||||||
|
let rect = TmuxLayout.Rect(width: w, height: h, x: x, y: y)
|
||||||
|
|
||||||
|
switch peek() {
|
||||||
|
case "{":
|
||||||
|
let children = try parseChildren(open: "{", close: "}")
|
||||||
|
return .horizontal(children, rect: rect)
|
||||||
|
case "[":
|
||||||
|
let children = try parseChildren(open: "[", close: "]")
|
||||||
|
return .vertical(children, rect: rect)
|
||||||
|
case ",":
|
||||||
|
i += 1
|
||||||
|
let paneRaw = try parseInt()
|
||||||
|
return .leaf(pane: TmuxPaneID(paneRaw), rect: rect)
|
||||||
|
default:
|
||||||
|
throw TmuxLayout.ParseError.malformed(at: start, reason: "cell 缺少 pane id 或子节点")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private mutating func parseChildren(open: Character, close: Character) throws -> [TmuxLayout.Node] {
|
||||||
|
try expect(open)
|
||||||
|
var nodes: [TmuxLayout.Node] = []
|
||||||
|
while true {
|
||||||
|
nodes.append(try parseCell())
|
||||||
|
if peek() == "," { i += 1; continue }
|
||||||
|
break
|
||||||
|
}
|
||||||
|
try expect(close)
|
||||||
|
return nodes
|
||||||
|
}
|
||||||
|
|
||||||
|
private mutating func parseInt() throws -> Int {
|
||||||
|
let start = i
|
||||||
|
while let c = peek(), c.isNumber { i += 1 }
|
||||||
|
guard i > start, let v = Int(String(chars[start..<i])) else {
|
||||||
|
throw TmuxLayout.ParseError.malformed(at: start, reason: "期望数字")
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
private mutating func expect(_ c: Character) throws {
|
||||||
|
guard peek() == c else {
|
||||||
|
throw TmuxLayout.ParseError.malformed(at: i, reason: "期望 '\(c)'")
|
||||||
|
}
|
||||||
|
i += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
46
packages/TXCore/Sources/TXCore/Tmux/TmuxOutputDecoder.swift
Normal file
46
packages/TXCore/Sources/TXCore/Tmux/TmuxOutputDecoder.swift
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// `%output` 的 payload 转义还原。
|
||||||
|
///
|
||||||
|
/// tmux 在 control mode 下对 pane 输出中 **ASCII < 32 的字节与反斜杠本身**
|
||||||
|
/// 用三位八进制转义(`\ooo`),其余字节(含空格 0x20)原样传输。
|
||||||
|
/// 例:换行 `\n`(0x0A) → `\012`,反斜杠 `\`(0x5C) → `\134`。
|
||||||
|
enum TmuxOutputDecoder {
|
||||||
|
|
||||||
|
/// 将转义态字节还原为原始字节。非法/不完整的 `\` 序列按原样保留(稳健优先)。
|
||||||
|
static func decode(_ bytes: ArraySlice<UInt8>) -> Data {
|
||||||
|
var out = [UInt8]()
|
||||||
|
out.reserveCapacity(bytes.count)
|
||||||
|
|
||||||
|
var i = bytes.startIndex
|
||||||
|
let end = bytes.endIndex
|
||||||
|
while i < end {
|
||||||
|
let b = bytes[i]
|
||||||
|
guard b == 0x5C /* backslash */ else {
|
||||||
|
out.append(b)
|
||||||
|
i = bytes.index(after: i)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// 需要其后正好三位八进制数字(0-7)。
|
||||||
|
let d0 = bytes.index(i, offsetBy: 1, limitedBy: end) ?? end
|
||||||
|
let d1 = bytes.index(i, offsetBy: 2, limitedBy: end) ?? end
|
||||||
|
let d2 = bytes.index(i, offsetBy: 3, limitedBy: end) ?? end
|
||||||
|
if d2 < end,
|
||||||
|
let v0 = octalValue(bytes[d0]),
|
||||||
|
let v1 = octalValue(bytes[d1]),
|
||||||
|
let v2 = octalValue(bytes[d2]) {
|
||||||
|
out.append(UInt8(v0 * 64 + v1 * 8 + v2))
|
||||||
|
i = bytes.index(i, offsetBy: 4)
|
||||||
|
} else {
|
||||||
|
// 不是合法八进制转义,原样保留反斜杠。
|
||||||
|
out.append(b)
|
||||||
|
i = bytes.index(after: i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Data(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func octalValue(_ b: UInt8) -> Int? {
|
||||||
|
(0x30...0x37).contains(b) ? Int(b - 0x30) : nil
|
||||||
|
}
|
||||||
|
}
|
||||||
128
packages/TXCore/Tests/TXCoreTests/SessionMachineTests.swift
Normal file
128
packages/TXCore/Tests/TXCoreTests/SessionMachineTests.swift
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
import Testing
|
||||||
|
@testable import TXCore
|
||||||
|
|
||||||
|
@Suite("会话生命周期状态机")
|
||||||
|
struct SessionMachineTests {
|
||||||
|
|
||||||
|
@Test("正常连接流程")
|
||||||
|
func happyPath() {
|
||||||
|
var m = SessionMachine()
|
||||||
|
#expect(m.reduce(.connectRequested) == [.startConnect])
|
||||||
|
#expect(m.phase == .connecting)
|
||||||
|
#expect(m.reduce(.authenticating) == [])
|
||||||
|
#expect(m.phase == .authenticating)
|
||||||
|
#expect(m.reduce(.established) == [.notify("已连接")])
|
||||||
|
#expect(m.phase == .connected)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("认证失败为终态,不自动重连")
|
||||||
|
func authFailedTerminal() {
|
||||||
|
var m = SessionMachine()
|
||||||
|
_ = m.reduce(.connectRequested)
|
||||||
|
let effects = m.reduce(.authFailed(reason: "密码错误"))
|
||||||
|
#expect(m.phase == .failed(reason: "密码错误"))
|
||||||
|
#expect(effects.contains(.teardownTransport))
|
||||||
|
// failed 态收到网络事件不动
|
||||||
|
#expect(m.reduce(.transportClosed(reason: nil)) == [])
|
||||||
|
#expect(m.phase == .failed(reason: "密码错误"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("断线进入退避重连")
|
||||||
|
func disconnectSchedulesReconnect() {
|
||||||
|
var m = SessionMachine()
|
||||||
|
_ = m.reduce(.connectRequested)
|
||||||
|
_ = m.reduce(.established)
|
||||||
|
let effects = m.reduce(.transportClosed(reason: "eof"))
|
||||||
|
#expect(m.phase == .waitingToReconnect(attempt: 1))
|
||||||
|
#expect(effects.contains(.teardownTransport))
|
||||||
|
#expect(effects.contains(.scheduleReconnect(attempt: 1, delayMS: 500)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("重连计时→重连→成功")
|
||||||
|
func reconnectSuccess() {
|
||||||
|
var m = SessionMachine()
|
||||||
|
_ = m.reduce(.connectRequested)
|
||||||
|
_ = m.reduce(.established)
|
||||||
|
_ = m.reduce(.transportClosed(reason: nil))
|
||||||
|
#expect(m.reduce(.reconnectTimerFired) == [.startConnect])
|
||||||
|
#expect(m.phase == .reconnecting(attempt: 1))
|
||||||
|
#expect(m.reduce(.established) == [.notify("已重连")])
|
||||||
|
#expect(m.phase == .connected)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("重连反复失败,退避递增,达上限转 failed")
|
||||||
|
func reconnectExhaustion() {
|
||||||
|
var m = SessionMachine(maxReconnectAttempts: 3, baseDelayMS: 500, maxDelayMS: 16000)
|
||||||
|
_ = m.reduce(.connectRequested)
|
||||||
|
_ = m.reduce(.established)
|
||||||
|
|
||||||
|
// 第1次断
|
||||||
|
_ = m.reduce(.transportClosed(reason: nil))
|
||||||
|
#expect(m.phase == .waitingToReconnect(attempt: 1))
|
||||||
|
_ = m.reduce(.reconnectTimerFired) // reconnecting(1)
|
||||||
|
// 第2次断(attempt 2, delay 1000)
|
||||||
|
let e2 = m.reduce(.transportClosed(reason: nil))
|
||||||
|
#expect(m.phase == .waitingToReconnect(attempt: 2))
|
||||||
|
#expect(e2.contains(.scheduleReconnect(attempt: 2, delayMS: 1000)))
|
||||||
|
_ = m.reduce(.reconnectTimerFired) // reconnecting(2)
|
||||||
|
// 第3次断(attempt 3, delay 2000)
|
||||||
|
let e3 = m.reduce(.transportClosed(reason: nil))
|
||||||
|
#expect(m.phase == .waitingToReconnect(attempt: 3))
|
||||||
|
#expect(e3.contains(.scheduleReconnect(attempt: 3, delayMS: 2000)))
|
||||||
|
_ = m.reduce(.reconnectTimerFired) // reconnecting(3)
|
||||||
|
// 第4次断 → 超过 maxReconnectAttempts(3) → failed
|
||||||
|
let e4 = m.reduce(.transportClosed(reason: "gone"))
|
||||||
|
if case .failed = m.phase {} else { Issue.record("期望 failed,实际 \(m.phase)") }
|
||||||
|
#expect(e4.contains(.notify("重连失败,请手动重试")))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("退避封顶")
|
||||||
|
func backoffCap() {
|
||||||
|
let m = SessionMachine(baseDelayMS: 500, maxDelayMS: 16000)
|
||||||
|
#expect(m.backoffDelayMS(attempt: 1) == 500)
|
||||||
|
#expect(m.backoffDelayMS(attempt: 2) == 1000)
|
||||||
|
#expect(m.backoffDelayMS(attempt: 5) == 8000)
|
||||||
|
#expect(m.backoffDelayMS(attempt: 6) == 16000)
|
||||||
|
#expect(m.backoffDelayMS(attempt: 10) == 16000) // 封顶
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("后台冻结与前台恢复")
|
||||||
|
func backgroundResume() {
|
||||||
|
var m = SessionMachine()
|
||||||
|
_ = m.reduce(.connectRequested)
|
||||||
|
_ = m.reduce(.established)
|
||||||
|
#expect(m.reduce(.enteredBackground) == [])
|
||||||
|
#expect(m.phase == .backgroundParked)
|
||||||
|
let effects = m.reduce(.enteredForeground)
|
||||||
|
#expect(m.phase == .reconnecting(attempt: 1))
|
||||||
|
#expect(effects.contains(.startConnect))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("等待重连时进入后台取消计时")
|
||||||
|
func backgroundDuringWait() {
|
||||||
|
var m = SessionMachine()
|
||||||
|
_ = m.reduce(.connectRequested)
|
||||||
|
_ = m.reduce(.established)
|
||||||
|
_ = m.reduce(.transportClosed(reason: nil))
|
||||||
|
#expect(m.phase == .waitingToReconnect(attempt: 1))
|
||||||
|
#expect(m.reduce(.enteredBackground) == [.cancelReconnectTimer])
|
||||||
|
#expect(m.phase == .backgroundParked)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("主动关闭与手动重试复位")
|
||||||
|
func closeAndRetry() {
|
||||||
|
var m = SessionMachine()
|
||||||
|
_ = m.reduce(.connectRequested)
|
||||||
|
_ = m.reduce(.established)
|
||||||
|
let closeEffects = m.reduce(.closeRequested)
|
||||||
|
#expect(m.phase == .closed)
|
||||||
|
#expect(closeEffects.contains(.teardownTransport))
|
||||||
|
|
||||||
|
// failed 后手动重试
|
||||||
|
var m2 = SessionMachine()
|
||||||
|
_ = m2.reduce(.connectRequested)
|
||||||
|
_ = m2.reduce(.authFailed(reason: "x"))
|
||||||
|
#expect(m2.reduce(.retryRequested) == [.startConnect])
|
||||||
|
#expect(m2.phase == .connecting)
|
||||||
|
}
|
||||||
|
}
|
||||||
118
packages/TXCore/Tests/TXCoreTests/TmuxControlParserTests.swift
Normal file
118
packages/TXCore/Tests/TXCoreTests/TmuxControlParserTests.swift
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
import Testing
|
||||||
|
import Foundation
|
||||||
|
@testable import TXCore
|
||||||
|
|
||||||
|
@Suite("tmux control-mode 解析器")
|
||||||
|
struct TmuxControlParserTests {
|
||||||
|
|
||||||
|
@Test("命令应答块按序号关联")
|
||||||
|
func commandBlock() {
|
||||||
|
let p = TmuxControlParser()
|
||||||
|
let events = p.feed("%begin 1678 7 1\nhello\nworld\n%end 1678 7 1\n")
|
||||||
|
#expect(events.count == 1)
|
||||||
|
guard case .commandResponse(let r) = events[0] else {
|
||||||
|
Issue.record("期望 commandResponse"); return
|
||||||
|
}
|
||||||
|
#expect(r.number == 7)
|
||||||
|
#expect(r.isError == false)
|
||||||
|
#expect(r.lines == ["hello", "world"])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("%error 块标记为错误")
|
||||||
|
func errorBlock() {
|
||||||
|
let p = TmuxControlParser()
|
||||||
|
let events = p.feed("%begin 1 3 1\nno server\n%error 1 3 1\n")
|
||||||
|
guard case .commandResponse(let r) = events.first else {
|
||||||
|
Issue.record("期望 commandResponse"); return
|
||||||
|
}
|
||||||
|
#expect(r.isError)
|
||||||
|
#expect(r.number == 3)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("块内以 % 开头的行不解释为通知")
|
||||||
|
func percentInsideBlock() {
|
||||||
|
let p = TmuxControlParser()
|
||||||
|
let events = p.feed("%begin 1 1 1\n%output not-a-notification\n%end 1 1 1\n")
|
||||||
|
#expect(events.count == 1)
|
||||||
|
guard case .commandResponse(let r) = events.first else {
|
||||||
|
Issue.record("期望 commandResponse"); return
|
||||||
|
}
|
||||||
|
#expect(r.lines == ["%output not-a-notification"])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("%output 解析出 pane 与字节")
|
||||||
|
func output() {
|
||||||
|
let p = TmuxControlParser()
|
||||||
|
let events = p.feed("%output %2 ready\\012\n")
|
||||||
|
guard case .output(let pane, let data) = events.first else {
|
||||||
|
Issue.record("期望 output"); return
|
||||||
|
}
|
||||||
|
#expect(pane == TmuxPaneID(2))
|
||||||
|
#expect(data == Data("ready\n".utf8))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("跨 feed 的残行被正确缓冲")
|
||||||
|
func splitAcrossFeeds() {
|
||||||
|
let p = TmuxControlParser()
|
||||||
|
#expect(p.feed("%output %0 par").isEmpty) // 无换行 → 无事件
|
||||||
|
let events = p.feed("tial\n")
|
||||||
|
guard case .output(_, let data) = events.first else {
|
||||||
|
Issue.record("期望 output"); return
|
||||||
|
}
|
||||||
|
#expect(data == Data("partial".utf8))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("CRLF 行尾被容忍")
|
||||||
|
func crlf() {
|
||||||
|
let p = TmuxControlParser()
|
||||||
|
let events = p.feed("%window-add @5\r\n")
|
||||||
|
#expect(events == [.windowAdd(TmuxWindowID(5))])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("窗口生命周期通知")
|
||||||
|
func windowNotifications() {
|
||||||
|
let p = TmuxControlParser()
|
||||||
|
#expect(p.feed("%window-add @1\n") == [.windowAdd(TmuxWindowID(1))])
|
||||||
|
#expect(p.feed("%window-close @1\n") == [.windowClose(TmuxWindowID(1))])
|
||||||
|
#expect(p.feed("%window-renamed @2 my shell\n")
|
||||||
|
== [.windowRenamed(TmuxWindowID(2), name: "my shell")])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("会话与布局通知")
|
||||||
|
func sessionAndLayout() {
|
||||||
|
let p = TmuxControlParser()
|
||||||
|
#expect(p.feed("%session-changed $1 main\n")
|
||||||
|
== [.sessionChanged(TmuxSessionID(1), name: "main")])
|
||||||
|
let layout = p.feed("%layout-change @3 bc62,80x24,0,0,0\n")
|
||||||
|
#expect(layout == [.layoutChange(window: TmuxWindowID(3),
|
||||||
|
layout: "bc62,80x24,0,0,0",
|
||||||
|
visibleLayout: nil)])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("流控与退出")
|
||||||
|
func flowControlAndExit() {
|
||||||
|
let p = TmuxControlParser()
|
||||||
|
#expect(p.feed("%pause %4\n") == [.pause(TmuxPaneID(4))])
|
||||||
|
#expect(p.feed("%continue %4\n") == [.unpause(TmuxPaneID(4))])
|
||||||
|
#expect(p.feed("%exit\n") == [.exit(reason: nil)])
|
||||||
|
#expect(p.feed("%exit server exited\n") == [.exit(reason: "server exited")])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("未知通知降级为 .unknown 而非崩溃")
|
||||||
|
func unknownNotification() {
|
||||||
|
let p = TmuxControlParser()
|
||||||
|
let events = p.feed("%some-future-notification foo bar\n")
|
||||||
|
#expect(events == [.unknown(line: "%some-future-notification foo bar")])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("attach 后的隐式空块")
|
||||||
|
func implicitEmptyBlock() {
|
||||||
|
let p = TmuxControlParser()
|
||||||
|
let events = p.feed("%begin 100 0 0\n%end 100 0 0\n")
|
||||||
|
guard case .commandResponse(let r) = events.first else {
|
||||||
|
Issue.record("期望空 commandResponse"); return
|
||||||
|
}
|
||||||
|
#expect(r.lines.isEmpty)
|
||||||
|
#expect(r.number == 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import Testing
|
||||||
|
@testable import TXCore
|
||||||
|
|
||||||
|
@Suite("tmux -CC 哨兵序列检测")
|
||||||
|
struct TmuxControlSequenceTests {
|
||||||
|
|
||||||
|
@Test("查找进入 DCS")
|
||||||
|
func findEnter() {
|
||||||
|
let stream = Array("shell output\r\n".utf8) + TmuxControlSequence.enter + Array("%begin".utf8)
|
||||||
|
let idx = TmuxControlSequence.find(TmuxControlSequence.enter, in: stream)
|
||||||
|
#expect(idx == Array("shell output\r\n".utf8).count)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("查找退出 ST")
|
||||||
|
func findExit() {
|
||||||
|
let stream = Array("%exit\n".utf8) + TmuxControlSequence.exit
|
||||||
|
#expect(TmuxControlSequence.find(TmuxControlSequence.exit, in: stream) == Array("%exit\n".utf8).count)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("未出现返回 nil")
|
||||||
|
func notFound() {
|
||||||
|
#expect(TmuxControlSequence.find(TmuxControlSequence.enter, in: Array("normal text".utf8)) == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("enter 序列即 ESC P 1000 p")
|
||||||
|
func enterBytes() {
|
||||||
|
#expect(TmuxControlSequence.enter == [0x1B, 0x50, 0x31, 0x30, 0x30, 0x30, 0x70])
|
||||||
|
}
|
||||||
|
}
|
||||||
59
packages/TXCore/Tests/TXCoreTests/TmuxLayoutTests.swift
Normal file
59
packages/TXCore/Tests/TXCoreTests/TmuxLayoutTests.swift
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import Testing
|
||||||
|
@testable import TXCore
|
||||||
|
|
||||||
|
@Suite("tmux layout 字符串解析")
|
||||||
|
struct TmuxLayoutTests {
|
||||||
|
|
||||||
|
@Test("单 pane")
|
||||||
|
func single() throws {
|
||||||
|
let l = try TmuxLayout.parse("bc62,80x24,0,0,0")
|
||||||
|
#expect(l.root == .leaf(pane: TmuxPaneID(0),
|
||||||
|
rect: .init(width: 80, height: 24, x: 0, y: 0)))
|
||||||
|
#expect(l.paneIDs == [TmuxPaneID(0)])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("左右分屏 {} → horizontal")
|
||||||
|
func horizontalSplit() throws {
|
||||||
|
let l = try TmuxLayout.parse("a1b2,80x24,0,0{40x24,0,0,1,39x24,41,0,2}")
|
||||||
|
guard case .horizontal(let children, let rect) = l.root else {
|
||||||
|
Issue.record("期望 horizontal"); return
|
||||||
|
}
|
||||||
|
#expect(rect == .init(width: 80, height: 24, x: 0, y: 0))
|
||||||
|
#expect(children.count == 2)
|
||||||
|
#expect(children[0] == .leaf(pane: TmuxPaneID(1), rect: .init(width: 40, height: 24, x: 0, y: 0)))
|
||||||
|
#expect(children[1] == .leaf(pane: TmuxPaneID(2), rect: .init(width: 39, height: 24, x: 41, y: 0)))
|
||||||
|
#expect(l.paneIDs == [TmuxPaneID(1), TmuxPaneID(2)])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("上下分屏 [] → vertical")
|
||||||
|
func verticalSplit() throws {
|
||||||
|
let l = try TmuxLayout.parse("ffff,80x24,0,0[80x12,0,0,1,80x11,0,13,2]")
|
||||||
|
guard case .vertical(let children, _) = l.root else {
|
||||||
|
Issue.record("期望 vertical"); return
|
||||||
|
}
|
||||||
|
#expect(children.count == 2)
|
||||||
|
#expect(l.paneIDs == [TmuxPaneID(1), TmuxPaneID(2)])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("嵌套:左右分屏,右侧再上下分屏")
|
||||||
|
func nested() throws {
|
||||||
|
let l = try TmuxLayout.parse("0000,100x40,0,0{50x40,0,0,1,49x40,51,0[49x20,51,0,2,49x19,51,21,3]}")
|
||||||
|
guard case .horizontal(let top, _) = l.root else {
|
||||||
|
Issue.record("期望 horizontal"); return
|
||||||
|
}
|
||||||
|
#expect(top.count == 2)
|
||||||
|
#expect(top[0] == .leaf(pane: TmuxPaneID(1), rect: .init(width: 50, height: 40, x: 0, y: 0)))
|
||||||
|
guard case .vertical(let inner, _) = top[1] else {
|
||||||
|
Issue.record("期望嵌套 vertical"); return
|
||||||
|
}
|
||||||
|
#expect(inner.count == 2)
|
||||||
|
#expect(l.paneIDs == [TmuxPaneID(1), TmuxPaneID(2), TmuxPaneID(3)])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("畸形字符串抛错而非崩溃")
|
||||||
|
func malformed() {
|
||||||
|
#expect(throws: TmuxLayout.ParseError.self) { try TmuxLayout.parse("garbage") }
|
||||||
|
#expect(throws: TmuxLayout.ParseError.self) { try TmuxLayout.parse("abcd,80x24,0,0{") }
|
||||||
|
#expect(throws: TmuxLayout.ParseError.self) { try TmuxLayout.parse("abcd,80x,0,0,0") }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import Testing
|
||||||
|
import Foundation
|
||||||
|
@testable import TXCore
|
||||||
|
|
||||||
|
@Suite("tmux %output 八进制解码")
|
||||||
|
struct TmuxOutputDecoderTests {
|
||||||
|
|
||||||
|
// 通过公开 API 间接验证 decode(decoder 为 internal)。
|
||||||
|
private func decode(_ escaped: String) -> Data {
|
||||||
|
let parser = TmuxControlParser()
|
||||||
|
let events = parser.feed("%output %0 \(escaped)\n")
|
||||||
|
guard case .output(_, let data) = events.first else {
|
||||||
|
Issue.record("期望 .output,得到 \(events)")
|
||||||
|
return Data()
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("普通字符原样通过")
|
||||||
|
func plain() {
|
||||||
|
#expect(decode("hello") == Data("hello".utf8))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("空格不转义,原样保留")
|
||||||
|
func rawSpace() {
|
||||||
|
#expect(decode("hello\\040world") == Data("hello world".utf8))
|
||||||
|
// payload 中的真实空格也应保留
|
||||||
|
#expect(decode("a b") == Data("a b".utf8))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("控制字符八进制还原")
|
||||||
|
func controlBytes() {
|
||||||
|
#expect(decode("line\\015\\012") == Data([0x6C, 0x69, 0x6E, 0x65, 0x0D, 0x0A]))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("反斜杠自身 \\134")
|
||||||
|
func backslash() {
|
||||||
|
#expect(decode("a\\134b") == Data([0x61, 0x5C, 0x62]))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("不完整转义序列原样保留")
|
||||||
|
func malformedEscape() {
|
||||||
|
// 末尾孤立反斜杠 + 不足三位
|
||||||
|
#expect(decode("x\\12") == Data("x\\12".utf8))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("非八进制数字(8/9)不视为转义")
|
||||||
|
func nonOctalDigits() {
|
||||||
|
#expect(decode("\\189") == Data("\\189".utf8))
|
||||||
|
}
|
||||||
|
}
|
||||||
31
packages/TXTransport/Package.swift
Normal file
31
packages/TXTransport/Package.swift
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
// swift-tools-version: 6.0
|
||||||
|
import PackageDescription
|
||||||
|
|
||||||
|
let package = Package(
|
||||||
|
name: "TXTransport",
|
||||||
|
platforms: [
|
||||||
|
.macOS(.v14),
|
||||||
|
.iOS(.v17),
|
||||||
|
],
|
||||||
|
products: [
|
||||||
|
.library(name: "TXTransport", targets: ["TXTransport"]),
|
||||||
|
],
|
||||||
|
dependencies: [
|
||||||
|
.package(path: "../TXCore"),
|
||||||
|
],
|
||||||
|
targets: [
|
||||||
|
// libssh2 + mbedTLS 静态库切片(vendor/build/build-cssh.sh,library-only)。
|
||||||
|
.binaryTarget(name: "CSSHCore", path: "../../artifacts/CSSHCore.xcframework"),
|
||||||
|
// 头文件 + modulemap 载体(供 `import CSSH`);库符号来自 CSSHCore。
|
||||||
|
.target(name: "CSSH", publicHeadersPath: "include"),
|
||||||
|
.target(
|
||||||
|
name: "TXTransport",
|
||||||
|
dependencies: [
|
||||||
|
"CSSH",
|
||||||
|
"CSSHCore",
|
||||||
|
.product(name: "TXCore", package: "TXCore"),
|
||||||
|
],
|
||||||
|
swiftSettings: [.swiftLanguageMode(.v6)]
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
1516
packages/TXTransport/Sources/CSSH/include/libssh2.h
Normal file
1516
packages/TXTransport/Sources/CSSH/include/libssh2.h
Normal file
File diff suppressed because it is too large
Load Diff
128
packages/TXTransport/Sources/CSSH/include/libssh2_publickey.h
Normal file
128
packages/TXTransport/Sources/CSSH/include/libssh2_publickey.h
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
/* Copyright (C) Sara Golemon <sarag@libssh2.org>
|
||||||
|
* All rights reserved.
|
||||||
|
*
|
||||||
|
* Redistribution and use in source and binary forms,
|
||||||
|
* with or without modification, are permitted provided
|
||||||
|
* that the following conditions are met:
|
||||||
|
*
|
||||||
|
* Redistributions of source code must retain the above
|
||||||
|
* copyright notice, this list of conditions and the
|
||||||
|
* following disclaimer.
|
||||||
|
*
|
||||||
|
* Redistributions in binary form must reproduce the above
|
||||||
|
* copyright notice, this list of conditions and the following
|
||||||
|
* disclaimer in the documentation and/or other materials
|
||||||
|
* provided with the distribution.
|
||||||
|
*
|
||||||
|
* Neither the name of the copyright holder nor the names
|
||||||
|
* of any other contributors may be used to endorse or
|
||||||
|
* promote products derived from this software without
|
||||||
|
* specific prior written permission.
|
||||||
|
*
|
||||||
|
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND
|
||||||
|
* CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES,
|
||||||
|
* INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
|
||||||
|
* OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
||||||
|
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
|
||||||
|
* CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
|
||||||
|
* SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
|
||||||
|
* BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||||
|
* SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
||||||
|
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,
|
||||||
|
* WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
|
||||||
|
* NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE
|
||||||
|
* USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY
|
||||||
|
* OF SUCH DAMAGE.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* Note: This include file is only needed for using the
|
||||||
|
* publickey SUBSYSTEM which is not the same as publickey
|
||||||
|
* authentication. For authentication you only need libssh2.h
|
||||||
|
*
|
||||||
|
* For more information on the publickey subsystem,
|
||||||
|
* refer to IETF draft: secsh-publickey
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
*/
|
||||||
|
|
||||||
|
#ifndef LIBSSH2_PUBLICKEY_H
|
||||||
|
#define LIBSSH2_PUBLICKEY_H 1
|
||||||
|
|
||||||
|
#include "libssh2.h"
|
||||||
|
|
||||||
|
typedef struct _LIBSSH2_PUBLICKEY LIBSSH2_PUBLICKEY;
|
||||||
|
|
||||||
|
typedef struct _libssh2_publickey_attribute {
|
||||||
|
const char *name;
|
||||||
|
unsigned long name_len;
|
||||||
|
const char *value;
|
||||||
|
unsigned long value_len;
|
||||||
|
char mandatory;
|
||||||
|
} libssh2_publickey_attribute;
|
||||||
|
|
||||||
|
typedef struct _libssh2_publickey_list {
|
||||||
|
unsigned char *packet; /* For freeing */
|
||||||
|
|
||||||
|
const unsigned char *name;
|
||||||
|
unsigned long name_len;
|
||||||
|
const unsigned char *blob;
|
||||||
|
unsigned long blob_len;
|
||||||
|
unsigned long num_attrs;
|
||||||
|
libssh2_publickey_attribute *attrs; /* free me */
|
||||||
|
} libssh2_publickey_list;
|
||||||
|
|
||||||
|
/* Generally use the first macro here, but if both name and value are string
|
||||||
|
literals, you can use _fast() to take advantage of preprocessing */
|
||||||
|
#define libssh2_publickey_attribute(name, value, mandatory) \
|
||||||
|
{ (name), strlen(name), (value), strlen(value), (mandatory) },
|
||||||
|
#define libssh2_publickey_attribute_fast(name, value, mandatory) \
|
||||||
|
{ (name), sizeof(name) - 1, (value), sizeof(value) - 1, (mandatory) },
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
extern "C" {
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/* Publickey Subsystem */
|
||||||
|
LIBSSH2_API LIBSSH2_PUBLICKEY *
|
||||||
|
libssh2_publickey_init(LIBSSH2_SESSION *session);
|
||||||
|
|
||||||
|
LIBSSH2_API int
|
||||||
|
libssh2_publickey_add_ex(LIBSSH2_PUBLICKEY *pkey,
|
||||||
|
const unsigned char *name,
|
||||||
|
unsigned long name_len,
|
||||||
|
const unsigned char *blob,
|
||||||
|
unsigned long blob_len, char overwrite,
|
||||||
|
unsigned long num_attrs,
|
||||||
|
const libssh2_publickey_attribute attrs[]);
|
||||||
|
#define libssh2_publickey_add(pkey, name, blob, blob_len, overwrite, \
|
||||||
|
num_attrs, attrs) \
|
||||||
|
libssh2_publickey_add_ex((pkey), \
|
||||||
|
(name), strlen(name), \
|
||||||
|
(blob), (blob_len), \
|
||||||
|
(overwrite), (num_attrs), (attrs))
|
||||||
|
|
||||||
|
LIBSSH2_API int libssh2_publickey_remove_ex(LIBSSH2_PUBLICKEY *pkey,
|
||||||
|
const unsigned char *name,
|
||||||
|
unsigned long name_len,
|
||||||
|
const unsigned char *blob,
|
||||||
|
unsigned long blob_len);
|
||||||
|
#define libssh2_publickey_remove(pkey, name, blob, blob_len) \
|
||||||
|
libssh2_publickey_remove_ex((pkey), \
|
||||||
|
(name), strlen(name), \
|
||||||
|
(blob), (blob_len))
|
||||||
|
|
||||||
|
LIBSSH2_API int
|
||||||
|
libssh2_publickey_list_fetch(LIBSSH2_PUBLICKEY *pkey,
|
||||||
|
unsigned long *num_keys,
|
||||||
|
libssh2_publickey_list **pkey_list);
|
||||||
|
LIBSSH2_API void
|
||||||
|
libssh2_publickey_list_free(LIBSSH2_PUBLICKEY *pkey,
|
||||||
|
libssh2_publickey_list *pkey_list);
|
||||||
|
|
||||||
|
LIBSSH2_API int libssh2_publickey_shutdown(LIBSSH2_PUBLICKEY *pkey);
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
} /* extern "C" */
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#endif /* LIBSSH2_PUBLICKEY_H */
|
||||||
382
packages/TXTransport/Sources/CSSH/include/libssh2_sftp.h
Normal file
382
packages/TXTransport/Sources/CSSH/include/libssh2_sftp.h
Normal file
@@ -0,0 +1,382 @@
|
|||||||
|
/* Copyright (C) Sara Golemon <sarag@libssh2.org>
|
||||||
|
* All rights reserved.
|
||||||
|
*
|
||||||
|
* Redistribution and use in source and binary forms,
|
||||||
|
* with or without modification, are permitted provided
|
||||||
|
* that the following conditions are met:
|
||||||
|
*
|
||||||
|
* Redistributions of source code must retain the above
|
||||||
|
* copyright notice, this list of conditions and the
|
||||||
|
* following disclaimer.
|
||||||
|
*
|
||||||
|
* Redistributions in binary form must reproduce the above
|
||||||
|
* copyright notice, this list of conditions and the following
|
||||||
|
* disclaimer in the documentation and/or other materials
|
||||||
|
* provided with the distribution.
|
||||||
|
*
|
||||||
|
* Neither the name of the copyright holder nor the names
|
||||||
|
* of any other contributors may be used to endorse or
|
||||||
|
* promote products derived from this software without
|
||||||
|
* specific prior written permission.
|
||||||
|
*
|
||||||
|
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND
|
||||||
|
* CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES,
|
||||||
|
* INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
|
||||||
|
* OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
||||||
|
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
|
||||||
|
* CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
|
||||||
|
* SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
|
||||||
|
* BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||||
|
* SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
||||||
|
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,
|
||||||
|
* WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
|
||||||
|
* NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE
|
||||||
|
* USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY
|
||||||
|
* OF SUCH DAMAGE.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
*/
|
||||||
|
|
||||||
|
#ifndef LIBSSH2_SFTP_H
|
||||||
|
#define LIBSSH2_SFTP_H 1
|
||||||
|
|
||||||
|
#include "libssh2.h"
|
||||||
|
|
||||||
|
#ifndef _WIN32
|
||||||
|
#include <unistd.h>
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
extern "C" {
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/* Note: Version 6 was documented at the time of writing
|
||||||
|
* However it was marked as "DO NOT IMPLEMENT" due to pending changes
|
||||||
|
*
|
||||||
|
* Let's start with Version 3 (The version found in OpenSSH) and go from there
|
||||||
|
*/
|
||||||
|
#define LIBSSH2_SFTP_VERSION 3
|
||||||
|
|
||||||
|
typedef struct _LIBSSH2_SFTP LIBSSH2_SFTP;
|
||||||
|
typedef struct _LIBSSH2_SFTP_HANDLE LIBSSH2_SFTP_HANDLE;
|
||||||
|
typedef struct _LIBSSH2_SFTP_ATTRIBUTES LIBSSH2_SFTP_ATTRIBUTES;
|
||||||
|
typedef struct _LIBSSH2_SFTP_STATVFS LIBSSH2_SFTP_STATVFS;
|
||||||
|
|
||||||
|
/* Flags for open_ex() */
|
||||||
|
#define LIBSSH2_SFTP_OPENFILE 0
|
||||||
|
#define LIBSSH2_SFTP_OPENDIR 1
|
||||||
|
|
||||||
|
/* Flags for rename_ex() */
|
||||||
|
#define LIBSSH2_SFTP_RENAME_OVERWRITE 0x00000001
|
||||||
|
#define LIBSSH2_SFTP_RENAME_ATOMIC 0x00000002
|
||||||
|
#define LIBSSH2_SFTP_RENAME_NATIVE 0x00000004
|
||||||
|
|
||||||
|
/* Flags for stat_ex() */
|
||||||
|
#define LIBSSH2_SFTP_STAT 0
|
||||||
|
#define LIBSSH2_SFTP_LSTAT 1
|
||||||
|
#define LIBSSH2_SFTP_SETSTAT 2
|
||||||
|
|
||||||
|
/* Flags for symlink_ex() */
|
||||||
|
#define LIBSSH2_SFTP_SYMLINK 0
|
||||||
|
#define LIBSSH2_SFTP_READLINK 1
|
||||||
|
#define LIBSSH2_SFTP_REALPATH 2
|
||||||
|
|
||||||
|
/* Flags for sftp_mkdir() */
|
||||||
|
#define LIBSSH2_SFTP_DEFAULT_MODE -1
|
||||||
|
|
||||||
|
/* SFTP attribute flag bits */
|
||||||
|
#define LIBSSH2_SFTP_ATTR_SIZE 0x00000001
|
||||||
|
#define LIBSSH2_SFTP_ATTR_UIDGID 0x00000002
|
||||||
|
#define LIBSSH2_SFTP_ATTR_PERMISSIONS 0x00000004
|
||||||
|
#define LIBSSH2_SFTP_ATTR_ACMODTIME 0x00000008
|
||||||
|
#define LIBSSH2_SFTP_ATTR_EXTENDED 0x80000000
|
||||||
|
|
||||||
|
/* SFTP statvfs flag bits */
|
||||||
|
#define LIBSSH2_SFTP_ST_RDONLY 0x00000001
|
||||||
|
#define LIBSSH2_SFTP_ST_NOSUID 0x00000002
|
||||||
|
|
||||||
|
struct _LIBSSH2_SFTP_ATTRIBUTES {
|
||||||
|
/* If flags & ATTR_* bit is set, then the value in this struct will be
|
||||||
|
* meaningful Otherwise it should be ignored
|
||||||
|
*/
|
||||||
|
unsigned long flags;
|
||||||
|
|
||||||
|
libssh2_uint64_t filesize;
|
||||||
|
unsigned long uid, gid;
|
||||||
|
unsigned long permissions;
|
||||||
|
unsigned long atime, mtime;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct _LIBSSH2_SFTP_STATVFS {
|
||||||
|
libssh2_uint64_t f_bsize; /* file system block size */
|
||||||
|
libssh2_uint64_t f_frsize; /* fragment size */
|
||||||
|
libssh2_uint64_t f_blocks; /* size of fs in f_frsize units */
|
||||||
|
libssh2_uint64_t f_bfree; /* # free blocks */
|
||||||
|
libssh2_uint64_t f_bavail; /* # free blocks for non-root */
|
||||||
|
libssh2_uint64_t f_files; /* # inodes */
|
||||||
|
libssh2_uint64_t f_ffree; /* # free inodes */
|
||||||
|
libssh2_uint64_t f_favail; /* # free inodes for non-root */
|
||||||
|
libssh2_uint64_t f_fsid; /* file system ID */
|
||||||
|
libssh2_uint64_t f_flag; /* mount flags */
|
||||||
|
libssh2_uint64_t f_namemax; /* maximum filename length */
|
||||||
|
};
|
||||||
|
|
||||||
|
/* SFTP filetypes */
|
||||||
|
#define LIBSSH2_SFTP_TYPE_REGULAR 1
|
||||||
|
#define LIBSSH2_SFTP_TYPE_DIRECTORY 2
|
||||||
|
#define LIBSSH2_SFTP_TYPE_SYMLINK 3
|
||||||
|
#define LIBSSH2_SFTP_TYPE_SPECIAL 4
|
||||||
|
#define LIBSSH2_SFTP_TYPE_UNKNOWN 5
|
||||||
|
#define LIBSSH2_SFTP_TYPE_SOCKET 6
|
||||||
|
#define LIBSSH2_SFTP_TYPE_CHAR_DEVICE 7
|
||||||
|
#define LIBSSH2_SFTP_TYPE_BLOCK_DEVICE 8
|
||||||
|
#define LIBSSH2_SFTP_TYPE_FIFO 9
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Reproduce the POSIX file modes here for systems that are not POSIX
|
||||||
|
* compliant.
|
||||||
|
*
|
||||||
|
* These is used in "permissions" of "struct _LIBSSH2_SFTP_ATTRIBUTES"
|
||||||
|
*/
|
||||||
|
/* File type */
|
||||||
|
#define LIBSSH2_SFTP_S_IFMT 0170000 /* type of file mask */
|
||||||
|
#define LIBSSH2_SFTP_S_IFIFO 0010000 /* named pipe (fifo) */
|
||||||
|
#define LIBSSH2_SFTP_S_IFCHR 0020000 /* character special */
|
||||||
|
#define LIBSSH2_SFTP_S_IFDIR 0040000 /* directory */
|
||||||
|
#define LIBSSH2_SFTP_S_IFBLK 0060000 /* block special */
|
||||||
|
#define LIBSSH2_SFTP_S_IFREG 0100000 /* regular */
|
||||||
|
#define LIBSSH2_SFTP_S_IFLNK 0120000 /* symbolic link */
|
||||||
|
#define LIBSSH2_SFTP_S_IFSOCK 0140000 /* socket */
|
||||||
|
|
||||||
|
/* File mode */
|
||||||
|
/* Read, write, execute/search by owner */
|
||||||
|
#define LIBSSH2_SFTP_S_IRWXU 0000700 /* RWX mask for owner */
|
||||||
|
#define LIBSSH2_SFTP_S_IRUSR 0000400 /* R for owner */
|
||||||
|
#define LIBSSH2_SFTP_S_IWUSR 0000200 /* W for owner */
|
||||||
|
#define LIBSSH2_SFTP_S_IXUSR 0000100 /* X for owner */
|
||||||
|
/* Read, write, execute/search by group */
|
||||||
|
#define LIBSSH2_SFTP_S_IRWXG 0000070 /* RWX mask for group */
|
||||||
|
#define LIBSSH2_SFTP_S_IRGRP 0000040 /* R for group */
|
||||||
|
#define LIBSSH2_SFTP_S_IWGRP 0000020 /* W for group */
|
||||||
|
#define LIBSSH2_SFTP_S_IXGRP 0000010 /* X for group */
|
||||||
|
/* Read, write, execute/search by others */
|
||||||
|
#define LIBSSH2_SFTP_S_IRWXO 0000007 /* RWX mask for other */
|
||||||
|
#define LIBSSH2_SFTP_S_IROTH 0000004 /* R for other */
|
||||||
|
#define LIBSSH2_SFTP_S_IWOTH 0000002 /* W for other */
|
||||||
|
#define LIBSSH2_SFTP_S_IXOTH 0000001 /* X for other */
|
||||||
|
|
||||||
|
/* macros to check for specific file types, added in 1.2.5 */
|
||||||
|
#define LIBSSH2_SFTP_S_ISLNK(m) \
|
||||||
|
(((m) & LIBSSH2_SFTP_S_IFMT) == LIBSSH2_SFTP_S_IFLNK)
|
||||||
|
#define LIBSSH2_SFTP_S_ISREG(m) \
|
||||||
|
(((m) & LIBSSH2_SFTP_S_IFMT) == LIBSSH2_SFTP_S_IFREG)
|
||||||
|
#define LIBSSH2_SFTP_S_ISDIR(m) \
|
||||||
|
(((m) & LIBSSH2_SFTP_S_IFMT) == LIBSSH2_SFTP_S_IFDIR)
|
||||||
|
#define LIBSSH2_SFTP_S_ISCHR(m) \
|
||||||
|
(((m) & LIBSSH2_SFTP_S_IFMT) == LIBSSH2_SFTP_S_IFCHR)
|
||||||
|
#define LIBSSH2_SFTP_S_ISBLK(m) \
|
||||||
|
(((m) & LIBSSH2_SFTP_S_IFMT) == LIBSSH2_SFTP_S_IFBLK)
|
||||||
|
#define LIBSSH2_SFTP_S_ISFIFO(m) \
|
||||||
|
(((m) & LIBSSH2_SFTP_S_IFMT) == LIBSSH2_SFTP_S_IFIFO)
|
||||||
|
#define LIBSSH2_SFTP_S_ISSOCK(m) \
|
||||||
|
(((m) & LIBSSH2_SFTP_S_IFMT) == LIBSSH2_SFTP_S_IFSOCK)
|
||||||
|
|
||||||
|
/* SFTP File Transfer Flags -- (e.g. flags parameter to sftp_open())
|
||||||
|
* Danger will robinson... APPEND doesn't have any effect on OpenSSH servers */
|
||||||
|
#define LIBSSH2_FXF_READ 0x00000001
|
||||||
|
#define LIBSSH2_FXF_WRITE 0x00000002
|
||||||
|
#define LIBSSH2_FXF_APPEND 0x00000004
|
||||||
|
#define LIBSSH2_FXF_CREAT 0x00000008
|
||||||
|
#define LIBSSH2_FXF_TRUNC 0x00000010
|
||||||
|
#define LIBSSH2_FXF_EXCL 0x00000020
|
||||||
|
|
||||||
|
/* SFTP Status Codes (returned by libssh2_sftp_last_error() ) */
|
||||||
|
#define LIBSSH2_FX_OK 0UL
|
||||||
|
#define LIBSSH2_FX_EOF 1UL
|
||||||
|
#define LIBSSH2_FX_NO_SUCH_FILE 2UL
|
||||||
|
#define LIBSSH2_FX_PERMISSION_DENIED 3UL
|
||||||
|
#define LIBSSH2_FX_FAILURE 4UL
|
||||||
|
#define LIBSSH2_FX_BAD_MESSAGE 5UL
|
||||||
|
#define LIBSSH2_FX_NO_CONNECTION 6UL
|
||||||
|
#define LIBSSH2_FX_CONNECTION_LOST 7UL
|
||||||
|
#define LIBSSH2_FX_OP_UNSUPPORTED 8UL
|
||||||
|
#define LIBSSH2_FX_INVALID_HANDLE 9UL
|
||||||
|
#define LIBSSH2_FX_NO_SUCH_PATH 10UL
|
||||||
|
#define LIBSSH2_FX_FILE_ALREADY_EXISTS 11UL
|
||||||
|
#define LIBSSH2_FX_WRITE_PROTECT 12UL
|
||||||
|
#define LIBSSH2_FX_NO_MEDIA 13UL
|
||||||
|
#define LIBSSH2_FX_NO_SPACE_ON_FILESYSTEM 14UL
|
||||||
|
#define LIBSSH2_FX_QUOTA_EXCEEDED 15UL
|
||||||
|
#define LIBSSH2_FX_UNKNOWN_PRINCIPLE 16UL /* Initial mis-spelling */
|
||||||
|
#define LIBSSH2_FX_UNKNOWN_PRINCIPAL 16UL
|
||||||
|
#define LIBSSH2_FX_LOCK_CONFlICT 17UL /* Initial mis-spelling */
|
||||||
|
#define LIBSSH2_FX_LOCK_CONFLICT 17UL
|
||||||
|
#define LIBSSH2_FX_DIR_NOT_EMPTY 18UL
|
||||||
|
#define LIBSSH2_FX_NOT_A_DIRECTORY 19UL
|
||||||
|
#define LIBSSH2_FX_INVALID_FILENAME 20UL
|
||||||
|
#define LIBSSH2_FX_LINK_LOOP 21UL
|
||||||
|
|
||||||
|
/* Returned by any function that would block during a read/write operation */
|
||||||
|
#define LIBSSH2SFTP_EAGAIN LIBSSH2_ERROR_EAGAIN
|
||||||
|
|
||||||
|
/* SFTP API */
|
||||||
|
LIBSSH2_API LIBSSH2_SFTP *libssh2_sftp_init(LIBSSH2_SESSION *session);
|
||||||
|
LIBSSH2_API int libssh2_sftp_shutdown(LIBSSH2_SFTP *sftp);
|
||||||
|
LIBSSH2_API unsigned long libssh2_sftp_last_error(LIBSSH2_SFTP *sftp);
|
||||||
|
LIBSSH2_API LIBSSH2_CHANNEL *libssh2_sftp_get_channel(LIBSSH2_SFTP *sftp);
|
||||||
|
|
||||||
|
/* File / Directory Ops */
|
||||||
|
LIBSSH2_API LIBSSH2_SFTP_HANDLE *
|
||||||
|
libssh2_sftp_open_ex(LIBSSH2_SFTP *sftp,
|
||||||
|
const char *filename,
|
||||||
|
unsigned int filename_len,
|
||||||
|
unsigned long flags,
|
||||||
|
long mode, int open_type);
|
||||||
|
#define libssh2_sftp_open(sftp, filename, flags, mode) \
|
||||||
|
libssh2_sftp_open_ex((sftp), \
|
||||||
|
(filename), (unsigned int)strlen(filename), \
|
||||||
|
(flags), (mode), LIBSSH2_SFTP_OPENFILE)
|
||||||
|
#define libssh2_sftp_opendir(sftp, path) \
|
||||||
|
libssh2_sftp_open_ex((sftp), \
|
||||||
|
(path), (unsigned int)strlen(path), \
|
||||||
|
0, 0, LIBSSH2_SFTP_OPENDIR)
|
||||||
|
LIBSSH2_API LIBSSH2_SFTP_HANDLE *
|
||||||
|
libssh2_sftp_open_ex_r(LIBSSH2_SFTP *sftp,
|
||||||
|
const char *filename,
|
||||||
|
size_t filename_len,
|
||||||
|
unsigned long flags,
|
||||||
|
long mode, int open_type,
|
||||||
|
LIBSSH2_SFTP_ATTRIBUTES *attrs);
|
||||||
|
#define libssh2_sftp_open_r(sftp, filename, flags, mode, attrs) \
|
||||||
|
libssh2_sftp_open_ex_r((sftp), (filename), strlen(filename), \
|
||||||
|
(flags), (mode), LIBSSH2_SFTP_OPENFILE, \
|
||||||
|
(attrs))
|
||||||
|
|
||||||
|
LIBSSH2_API ssize_t libssh2_sftp_read(LIBSSH2_SFTP_HANDLE *handle,
|
||||||
|
char *buffer, size_t buffer_maxlen);
|
||||||
|
|
||||||
|
LIBSSH2_API int libssh2_sftp_readdir_ex(LIBSSH2_SFTP_HANDLE *handle, \
|
||||||
|
char *buffer, size_t buffer_maxlen,
|
||||||
|
char *longentry,
|
||||||
|
size_t longentry_maxlen,
|
||||||
|
LIBSSH2_SFTP_ATTRIBUTES *attrs);
|
||||||
|
#define libssh2_sftp_readdir(handle, buffer, buffer_maxlen, attrs) \
|
||||||
|
libssh2_sftp_readdir_ex((handle), (buffer), (buffer_maxlen), NULL, 0, \
|
||||||
|
(attrs))
|
||||||
|
|
||||||
|
LIBSSH2_API ssize_t libssh2_sftp_write(LIBSSH2_SFTP_HANDLE *handle,
|
||||||
|
const char *buffer, size_t count);
|
||||||
|
LIBSSH2_API int libssh2_sftp_fsync(LIBSSH2_SFTP_HANDLE *handle);
|
||||||
|
|
||||||
|
LIBSSH2_API int libssh2_sftp_close_handle(LIBSSH2_SFTP_HANDLE *handle);
|
||||||
|
#define libssh2_sftp_close(handle) libssh2_sftp_close_handle(handle)
|
||||||
|
#define libssh2_sftp_closedir(handle) libssh2_sftp_close_handle(handle)
|
||||||
|
|
||||||
|
LIBSSH2_API void libssh2_sftp_seek(LIBSSH2_SFTP_HANDLE *handle, size_t offset);
|
||||||
|
LIBSSH2_API void libssh2_sftp_seek64(LIBSSH2_SFTP_HANDLE *handle,
|
||||||
|
libssh2_uint64_t offset);
|
||||||
|
#define libssh2_sftp_rewind(handle) libssh2_sftp_seek64((handle), 0)
|
||||||
|
|
||||||
|
LIBSSH2_API size_t libssh2_sftp_tell(LIBSSH2_SFTP_HANDLE *handle);
|
||||||
|
LIBSSH2_API libssh2_uint64_t libssh2_sftp_tell64(LIBSSH2_SFTP_HANDLE *handle);
|
||||||
|
|
||||||
|
LIBSSH2_API int libssh2_sftp_fstat_ex(LIBSSH2_SFTP_HANDLE *handle,
|
||||||
|
LIBSSH2_SFTP_ATTRIBUTES *attrs,
|
||||||
|
int setstat);
|
||||||
|
#define libssh2_sftp_fstat(handle, attrs) \
|
||||||
|
libssh2_sftp_fstat_ex((handle), (attrs), 0)
|
||||||
|
#define libssh2_sftp_fsetstat(handle, attrs) \
|
||||||
|
libssh2_sftp_fstat_ex((handle), (attrs), 1)
|
||||||
|
|
||||||
|
/* Miscellaneous Ops */
|
||||||
|
LIBSSH2_API int libssh2_sftp_rename_ex(LIBSSH2_SFTP *sftp,
|
||||||
|
const char *source_filename,
|
||||||
|
unsigned int srouce_filename_len,
|
||||||
|
const char *dest_filename,
|
||||||
|
unsigned int dest_filename_len,
|
||||||
|
long flags);
|
||||||
|
#define libssh2_sftp_rename(sftp, sourcefile, destfile) \
|
||||||
|
libssh2_sftp_rename_ex((sftp), \
|
||||||
|
(sourcefile), (unsigned int)strlen(sourcefile), \
|
||||||
|
(destfile), (unsigned int)strlen(destfile), \
|
||||||
|
LIBSSH2_SFTP_RENAME_OVERWRITE | \
|
||||||
|
LIBSSH2_SFTP_RENAME_ATOMIC | \
|
||||||
|
LIBSSH2_SFTP_RENAME_NATIVE)
|
||||||
|
|
||||||
|
LIBSSH2_API int libssh2_sftp_posix_rename_ex(LIBSSH2_SFTP *sftp,
|
||||||
|
const char *source_filename,
|
||||||
|
size_t srouce_filename_len,
|
||||||
|
const char *dest_filename,
|
||||||
|
size_t dest_filename_len);
|
||||||
|
#define libssh2_sftp_posix_rename(sftp, sourcefile, destfile) \
|
||||||
|
libssh2_sftp_posix_rename_ex((sftp), (sourcefile), strlen(sourcefile), \
|
||||||
|
(destfile), strlen(destfile))
|
||||||
|
|
||||||
|
LIBSSH2_API int libssh2_sftp_unlink_ex(LIBSSH2_SFTP *sftp,
|
||||||
|
const char *filename,
|
||||||
|
unsigned int filename_len);
|
||||||
|
#define libssh2_sftp_unlink(sftp, filename) \
|
||||||
|
libssh2_sftp_unlink_ex((sftp), (filename), (unsigned int)strlen(filename))
|
||||||
|
|
||||||
|
LIBSSH2_API int libssh2_sftp_fstatvfs(LIBSSH2_SFTP_HANDLE *handle,
|
||||||
|
LIBSSH2_SFTP_STATVFS *st);
|
||||||
|
|
||||||
|
LIBSSH2_API int libssh2_sftp_statvfs(LIBSSH2_SFTP *sftp,
|
||||||
|
const char *path,
|
||||||
|
size_t path_len,
|
||||||
|
LIBSSH2_SFTP_STATVFS *st);
|
||||||
|
|
||||||
|
LIBSSH2_API int libssh2_sftp_mkdir_ex(LIBSSH2_SFTP *sftp,
|
||||||
|
const char *path,
|
||||||
|
unsigned int path_len, long mode);
|
||||||
|
#define libssh2_sftp_mkdir(sftp, path, mode) \
|
||||||
|
libssh2_sftp_mkdir_ex((sftp), (path), (unsigned int)strlen(path), (mode))
|
||||||
|
|
||||||
|
LIBSSH2_API int libssh2_sftp_rmdir_ex(LIBSSH2_SFTP *sftp,
|
||||||
|
const char *path,
|
||||||
|
unsigned int path_len);
|
||||||
|
#define libssh2_sftp_rmdir(sftp, path) \
|
||||||
|
libssh2_sftp_rmdir_ex((sftp), (path), (unsigned int)strlen(path))
|
||||||
|
|
||||||
|
LIBSSH2_API int libssh2_sftp_stat_ex(LIBSSH2_SFTP *sftp,
|
||||||
|
const char *path,
|
||||||
|
unsigned int path_len,
|
||||||
|
int stat_type,
|
||||||
|
LIBSSH2_SFTP_ATTRIBUTES *attrs);
|
||||||
|
#define libssh2_sftp_stat(sftp, path, attrs) \
|
||||||
|
libssh2_sftp_stat_ex((sftp), (path), (unsigned int)strlen(path), \
|
||||||
|
LIBSSH2_SFTP_STAT, (attrs))
|
||||||
|
#define libssh2_sftp_lstat(sftp, path, attrs) \
|
||||||
|
libssh2_sftp_stat_ex((sftp), (path), (unsigned int)strlen(path), \
|
||||||
|
LIBSSH2_SFTP_LSTAT, (attrs))
|
||||||
|
#define libssh2_sftp_setstat(sftp, path, attrs) \
|
||||||
|
libssh2_sftp_stat_ex((sftp), (path), (unsigned int)strlen(path), \
|
||||||
|
LIBSSH2_SFTP_SETSTAT, (attrs))
|
||||||
|
|
||||||
|
LIBSSH2_API int libssh2_sftp_symlink_ex(LIBSSH2_SFTP *sftp,
|
||||||
|
const char *path,
|
||||||
|
unsigned int path_len,
|
||||||
|
char *target,
|
||||||
|
unsigned int target_len,
|
||||||
|
int link_type);
|
||||||
|
#define libssh2_sftp_symlink(sftp, orig, linkpath) \
|
||||||
|
libssh2_sftp_symlink_ex((sftp), \
|
||||||
|
(orig), (unsigned int)strlen(orig), \
|
||||||
|
(linkpath), (unsigned int)strlen(linkpath), \
|
||||||
|
LIBSSH2_SFTP_SYMLINK)
|
||||||
|
#define libssh2_sftp_readlink(sftp, path, target, maxlen) \
|
||||||
|
libssh2_sftp_symlink_ex((sftp), \
|
||||||
|
(path), (unsigned int)strlen(path), \
|
||||||
|
(target), (maxlen), \
|
||||||
|
LIBSSH2_SFTP_READLINK)
|
||||||
|
#define libssh2_sftp_realpath(sftp, path, target, maxlen) \
|
||||||
|
libssh2_sftp_symlink_ex((sftp), \
|
||||||
|
(path), (unsigned int)strlen(path), \
|
||||||
|
(target), (maxlen), \
|
||||||
|
LIBSSH2_SFTP_REALPATH)
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
} /* extern "C" */
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#endif /* LIBSSH2_SFTP_H */
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
module CSSH {
|
||||||
|
header "libssh2.h"
|
||||||
|
header "libssh2_sftp.h"
|
||||||
|
header "libssh2_publickey.h"
|
||||||
|
export *
|
||||||
|
}
|
||||||
3
packages/TXTransport/Sources/CSSH/shim.c
Normal file
3
packages/TXTransport/Sources/CSSH/shim.c
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
// CSSH 是 libssh2 头文件 + modulemap 的载体 target(供 `import CSSH`);
|
||||||
|
// 实际库符号来自 CSSHCore.xcframework(静态库)。此处仅需一个非空翻译单元。
|
||||||
|
typedef int cssh_shim_placeholder;
|
||||||
282
packages/TXTransport/Sources/TXTransport/SSHSession.swift
Normal file
282
packages/TXTransport/Sources/TXTransport/SSHSession.swift
Normal file
@@ -0,0 +1,282 @@
|
|||||||
|
import Foundation
|
||||||
|
import Darwin
|
||||||
|
import CSSH
|
||||||
|
|
||||||
|
/// 基于 libssh2 的 SSH 传输。M0:系统 socket 直连(不过 tsnet),密码 / 私钥认证,
|
||||||
|
/// 打开 PTY + shell,非阻塞事件循环把 PTY 输出投递给 `onBytes`。
|
||||||
|
///
|
||||||
|
/// 线程模型:所有 libssh2 调用都在单条串行队列上执行(libssh2 会话非线程安全)。
|
||||||
|
/// `send`/`resize` 只把数据放进加锁缓冲,由事件循环 drain。
|
||||||
|
///
|
||||||
|
/// ⚠️ M0 暂不校验 known_hosts host key(接受任意)。真正的指纹固定放到 TXSecurity(M4)。
|
||||||
|
public final class SSHSession: Transport, @unchecked Sendable {
|
||||||
|
public var onBytes: (@Sendable (Data) -> Void)?
|
||||||
|
public var onState: (@Sendable (TransportState) -> Void)?
|
||||||
|
|
||||||
|
private let config: SSHConfig
|
||||||
|
private let queue = DispatchQueue(label: "ai.athom.terminalx.ssh")
|
||||||
|
|
||||||
|
private var session: OpaquePointer?
|
||||||
|
private var channel: OpaquePointer?
|
||||||
|
private var sock: Int32 = -1
|
||||||
|
private var stopped = false
|
||||||
|
/// >=0 时使用外部已连接 fd(tsnet fd 桥),跳过自身 DNS/connect。
|
||||||
|
private let preconnectedFD: Int32
|
||||||
|
|
||||||
|
private let lock = NSLock()
|
||||||
|
private var pendingWrite = Data()
|
||||||
|
private var pendingResize: (cols: UInt16, rows: UInt16)?
|
||||||
|
|
||||||
|
private static let eagain = Int(LIBSSH2_ERROR_EAGAIN)
|
||||||
|
private static let initOnce: Int32 = libssh2_init(0)
|
||||||
|
|
||||||
|
public init(config: SSHConfig, preconnectedFD: Int32 = -1) {
|
||||||
|
self.config = config
|
||||||
|
self.preconnectedFD = preconnectedFD
|
||||||
|
_ = SSHSession.initOnce
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Transport
|
||||||
|
|
||||||
|
public func start() {
|
||||||
|
queue.async { [self] in
|
||||||
|
do {
|
||||||
|
setState(.connecting)
|
||||||
|
try connect()
|
||||||
|
setState(.authenticating)
|
||||||
|
try authenticate()
|
||||||
|
try openShell()
|
||||||
|
setState(.connected)
|
||||||
|
libssh2_session_set_blocking(session, 0)
|
||||||
|
runLoop()
|
||||||
|
} catch let error as SSHError {
|
||||||
|
setState(.failed(String(describing: error)))
|
||||||
|
cleanup()
|
||||||
|
} catch {
|
||||||
|
setState(.failed("\(error)"))
|
||||||
|
cleanup()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func send(_ data: Data) {
|
||||||
|
lock.lock(); pendingWrite.append(data); lock.unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
public func resize(cols: UInt16, rows: UInt16) {
|
||||||
|
lock.lock(); pendingResize = (cols, rows); lock.unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
public func stop() {
|
||||||
|
queue.async { [self] in stopped = true }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Connect / Handshake
|
||||||
|
|
||||||
|
private func connect() throws {
|
||||||
|
let fd: Int32
|
||||||
|
if preconnectedFD >= 0 {
|
||||||
|
fd = preconnectedFD // tsnet fd 桥:使用已连接 socket,跳过 DNS/connect
|
||||||
|
} else {
|
||||||
|
var hints = addrinfo()
|
||||||
|
hints.ai_family = AF_UNSPEC
|
||||||
|
hints.ai_socktype = SOCK_STREAM
|
||||||
|
hints.ai_protocol = IPPROTO_TCP
|
||||||
|
|
||||||
|
var res: UnsafeMutablePointer<addrinfo>?
|
||||||
|
guard getaddrinfo(config.host, String(config.port), &hints, &res) == 0,
|
||||||
|
let list = res else {
|
||||||
|
throw SSHError.socket("DNS 解析失败: \(config.host)")
|
||||||
|
}
|
||||||
|
defer { freeaddrinfo(list) }
|
||||||
|
|
||||||
|
var dialed: Int32 = -1
|
||||||
|
var node: UnsafeMutablePointer<addrinfo>? = list
|
||||||
|
while let addr = node {
|
||||||
|
dialed = socket(addr.pointee.ai_family, addr.pointee.ai_socktype, addr.pointee.ai_protocol)
|
||||||
|
if dialed >= 0 {
|
||||||
|
if Darwin.connect(dialed, addr.pointee.ai_addr, addr.pointee.ai_addrlen) == 0 { break }
|
||||||
|
close(dialed); dialed = -1
|
||||||
|
}
|
||||||
|
node = addr.pointee.ai_next
|
||||||
|
}
|
||||||
|
guard dialed >= 0 else { throw SSHError.socket("连接失败: \(config.host):\(config.port)") }
|
||||||
|
fd = dialed
|
||||||
|
}
|
||||||
|
|
||||||
|
var on: Int32 = 1
|
||||||
|
setsockopt(fd, SOL_SOCKET, SO_NOSIGPIPE, &on, socklen_t(MemoryLayout<Int32>.size))
|
||||||
|
sock = fd
|
||||||
|
|
||||||
|
guard let s = libssh2_session_init_ex(nil, nil, nil, nil) else {
|
||||||
|
throw SSHError.handshake(-1)
|
||||||
|
}
|
||||||
|
session = s
|
||||||
|
libssh2_session_set_blocking(s, 1)
|
||||||
|
let rc = libssh2_session_handshake(s, sock)
|
||||||
|
guard rc == 0 else { throw SSHError.handshake(Int(rc)) }
|
||||||
|
// TODO(M4/TXSecurity): 校验 host key 指纹(known_hosts 三元组)。M0 暂接受任意。
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Auth
|
||||||
|
|
||||||
|
private func authenticate() throws {
|
||||||
|
guard let session else { throw SSHError.authentication(-1) }
|
||||||
|
let user = config.username
|
||||||
|
let rc: Int32
|
||||||
|
|
||||||
|
switch config.authentication {
|
||||||
|
case .password(let password):
|
||||||
|
rc = user.withCString { u in
|
||||||
|
password.withCString { p in
|
||||||
|
libssh2_userauth_password_ex(
|
||||||
|
session, u, UInt32(strlen(u)), p, UInt32(strlen(p)), nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case .privateKey(let pem, let passphrase):
|
||||||
|
rc = user.withCString { u in
|
||||||
|
pem.withCString { priv in
|
||||||
|
let auth: (UnsafePointer<CChar>?) -> Int32 = { pass in
|
||||||
|
libssh2_userauth_publickey_frommemory(
|
||||||
|
session,
|
||||||
|
u, strlen(u),
|
||||||
|
nil, 0,
|
||||||
|
priv, strlen(priv),
|
||||||
|
pass
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if let passphrase {
|
||||||
|
return passphrase.withCString { auth($0) }
|
||||||
|
} else {
|
||||||
|
return auth(nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
guard rc == 0 else { throw SSHError.authentication(Int(rc)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Shell
|
||||||
|
|
||||||
|
private func openShell() throws {
|
||||||
|
guard let session else { throw SSHError.channel("无会话") }
|
||||||
|
guard let ch = libssh2_channel_open_ex(
|
||||||
|
session, "session", 7, 2 * 1024 * 1024, 32768, nil, 0
|
||||||
|
) else {
|
||||||
|
throw SSHError.channel("打开 channel 失败")
|
||||||
|
}
|
||||||
|
channel = ch
|
||||||
|
|
||||||
|
let ptyRC = config.terminalType.withCString {
|
||||||
|
libssh2_channel_request_pty_ex(
|
||||||
|
ch, $0, UInt32(strlen($0)), nil, 0,
|
||||||
|
Int32(config.initialCols), Int32(config.initialRows), 0, 0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
guard ptyRC == 0 else { throw SSHError.channel("请求 PTY 失败 (\(ptyRC))") }
|
||||||
|
|
||||||
|
let shellRC = "shell".withCString {
|
||||||
|
libssh2_channel_process_startup(ch, $0, 5, nil, 0)
|
||||||
|
}
|
||||||
|
guard shellRC == 0 else { throw SSHError.channel("启动 shell 失败 (\(shellRC))") }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Event Loop
|
||||||
|
|
||||||
|
private func runLoop() {
|
||||||
|
guard let channel else { return }
|
||||||
|
var buffer = [Int8](repeating: 0, count: 32768)
|
||||||
|
|
||||||
|
while !stopped {
|
||||||
|
var pfd = pollfd(fd: sock, events: Int16(POLLIN), revents: 0)
|
||||||
|
_ = poll(&pfd, 1, 100)
|
||||||
|
|
||||||
|
var closed = false
|
||||||
|
readLoop: while true {
|
||||||
|
let n = buffer.withUnsafeMutableBufferPointer { ptr in
|
||||||
|
libssh2_channel_read_ex(channel, 0, ptr.baseAddress, ptr.count)
|
||||||
|
}
|
||||||
|
if n > 0 {
|
||||||
|
let data = buffer.withUnsafeBytes { raw in
|
||||||
|
Data(bytes: raw.baseAddress!, count: Int(n))
|
||||||
|
}
|
||||||
|
onBytes?(data)
|
||||||
|
} else if n == Self.eagain {
|
||||||
|
break readLoop
|
||||||
|
} else if n == 0 {
|
||||||
|
break readLoop
|
||||||
|
} else {
|
||||||
|
setState(.disconnected(reason: "读取错误 (\(n))"))
|
||||||
|
closed = true
|
||||||
|
break readLoop
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if closed { break }
|
||||||
|
|
||||||
|
if libssh2_channel_eof(channel) == 1 {
|
||||||
|
setState(.disconnected(reason: nil))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
flushWrites()
|
||||||
|
applyResizeIfNeeded()
|
||||||
|
}
|
||||||
|
cleanup()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func flushWrites() {
|
||||||
|
guard let channel else { return }
|
||||||
|
lock.lock(); let data = pendingWrite; pendingWrite = Data(); lock.unlock()
|
||||||
|
guard !data.isEmpty else { return }
|
||||||
|
|
||||||
|
data.withUnsafeBytes { (raw: UnsafeRawBufferPointer) in
|
||||||
|
let base = raw.bindMemory(to: CChar.self).baseAddress!
|
||||||
|
var offset = 0
|
||||||
|
while offset < data.count {
|
||||||
|
let n = libssh2_channel_write_ex(channel, 0, base + offset, data.count - offset)
|
||||||
|
if n == Self.eagain {
|
||||||
|
let remainder = data.subdata(in: (data.startIndex + offset)..<data.endIndex)
|
||||||
|
lock.lock(); pendingWrite = remainder + pendingWrite; lock.unlock()
|
||||||
|
return
|
||||||
|
} else if n < 0 {
|
||||||
|
return // 写错误:丢弃本批,下轮循环会发现连接问题
|
||||||
|
} else {
|
||||||
|
offset += Int(n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyResizeIfNeeded() {
|
||||||
|
lock.lock(); let r = pendingResize; pendingResize = nil; lock.unlock()
|
||||||
|
guard let r, let channel else { return }
|
||||||
|
_ = libssh2_channel_request_pty_size_ex(channel, Int32(r.cols), Int32(r.rows), 0, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Cleanup
|
||||||
|
|
||||||
|
private func cleanup() {
|
||||||
|
if let channel {
|
||||||
|
libssh2_channel_free(channel)
|
||||||
|
self.channel = nil
|
||||||
|
}
|
||||||
|
if let session {
|
||||||
|
"bye".withCString { reason in
|
||||||
|
"".withCString { lang in
|
||||||
|
_ = libssh2_session_disconnect_ex(session, 11 /* BY_APPLICATION */, reason, lang)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
libssh2_session_free(session)
|
||||||
|
self.session = nil
|
||||||
|
}
|
||||||
|
if sock >= 0 {
|
||||||
|
close(sock)
|
||||||
|
sock = -1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setState(_ state: TransportState) {
|
||||||
|
onState?(state)
|
||||||
|
}
|
||||||
|
}
|
||||||
64
packages/TXTransport/Sources/TXTransport/Transport.swift
Normal file
64
packages/TXTransport/Sources/TXTransport/Transport.swift
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// 终端字节传输的抽象。SSH / mosh / 本地 PTY 各实现之;上层 PaneEngine 只依赖此协议,
|
||||||
|
/// 不关心底层是系统直连还是经 tsnet(M1 起 DialerRouter 在此协议之下注入出口)。
|
||||||
|
public protocol Transport: AnyObject {
|
||||||
|
/// 远端 → 终端 的字节(PTY 输出)。在传输内部队列上回调,消费方自行 hop 线程。
|
||||||
|
var onBytes: (@Sendable (Data) -> Void)? { get set }
|
||||||
|
/// 连接状态变化。
|
||||||
|
var onState: (@Sendable (TransportState) -> Void)? { get set }
|
||||||
|
func start()
|
||||||
|
/// 终端 → 远端 的字节(用户输入)。
|
||||||
|
func send(_ data: Data)
|
||||||
|
func resize(cols: UInt16, rows: UInt16)
|
||||||
|
func stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum TransportState: Sendable, Equatable {
|
||||||
|
case idle
|
||||||
|
case connecting
|
||||||
|
case authenticating
|
||||||
|
case connected
|
||||||
|
case disconnected(reason: String?)
|
||||||
|
case failed(String)
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct SSHConfig: Sendable {
|
||||||
|
public var host: String
|
||||||
|
public var port: Int
|
||||||
|
public var username: String
|
||||||
|
public var authentication: Authentication
|
||||||
|
public var terminalType: String
|
||||||
|
public var initialCols: UInt16
|
||||||
|
public var initialRows: UInt16
|
||||||
|
|
||||||
|
public enum Authentication: Sendable {
|
||||||
|
case password(String)
|
||||||
|
case privateKey(pem: String, passphrase: String?)
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(
|
||||||
|
host: String,
|
||||||
|
port: Int = 22,
|
||||||
|
username: String,
|
||||||
|
authentication: Authentication,
|
||||||
|
terminalType: String = "xterm-256color",
|
||||||
|
initialCols: UInt16 = 80,
|
||||||
|
initialRows: UInt16 = 24
|
||||||
|
) {
|
||||||
|
self.host = host
|
||||||
|
self.port = port
|
||||||
|
self.username = username
|
||||||
|
self.authentication = authentication
|
||||||
|
self.terminalType = terminalType
|
||||||
|
self.initialCols = initialCols
|
||||||
|
self.initialRows = initialRows
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum SSHError: Error, Sendable, Equatable {
|
||||||
|
case socket(String)
|
||||||
|
case handshake(Int)
|
||||||
|
case authentication(Int)
|
||||||
|
case channel(String)
|
||||||
|
}
|
||||||
8
vendor/MSDisplayLink/.gitignore
vendored
Normal file
8
vendor/MSDisplayLink/.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
.DS_Store
|
||||||
|
/.build
|
||||||
|
/Packages
|
||||||
|
xcuserdata/
|
||||||
|
DerivedData/
|
||||||
|
.swiftpm/configuration/registries.json
|
||||||
|
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
|
||||||
|
.netrc
|
||||||
378
vendor/MSDisplayLink/Example/DLExample.xcodeproj/project.pbxproj
vendored
Normal file
378
vendor/MSDisplayLink/Example/DLExample.xcodeproj/project.pbxproj
vendored
Normal file
@@ -0,0 +1,378 @@
|
|||||||
|
// !$*UTF8*$!
|
||||||
|
{
|
||||||
|
archiveVersion = 1;
|
||||||
|
classes = {
|
||||||
|
};
|
||||||
|
objectVersion = 56;
|
||||||
|
objects = {
|
||||||
|
|
||||||
|
/* Begin PBXBuildFile section */
|
||||||
|
506240E32C6CBFFA00605212 /* App.swift in Sources */ = {isa = PBXBuildFile; fileRef = 506240E22C6CBFFA00605212 /* App.swift */; };
|
||||||
|
506240E52C6CBFFA00605212 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 506240E42C6CBFFA00605212 /* ContentView.swift */; };
|
||||||
|
506240E72C6CBFFB00605212 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 506240E62C6CBFFB00605212 /* Assets.xcassets */; };
|
||||||
|
506240F32C6CC02C00605212 /* MSDisplayLink in Frameworks */ = {isa = PBXBuildFile; productRef = 506240F22C6CC02C00605212 /* MSDisplayLink */; };
|
||||||
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
|
/* Begin PBXFileReference section */
|
||||||
|
506240DF2C6CBFFA00605212 /* DLExample.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = DLExample.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
506240E22C6CBFFA00605212 /* App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App.swift; sourceTree = "<group>"; };
|
||||||
|
506240E42C6CBFFA00605212 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
|
||||||
|
506240E62C6CBFFB00605212 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||||
|
506240E82C6CBFFB00605212 /* Entitlement.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Entitlement.entitlements; sourceTree = "<group>"; };
|
||||||
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
|
506240DC2C6CBFFA00605212 /* Frameworks */ = {
|
||||||
|
isa = PBXFrameworksBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
506240F32C6CC02C00605212 /* MSDisplayLink in Frameworks */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
/* End PBXFrameworksBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXGroup section */
|
||||||
|
506240D62C6CBFFA00605212 = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
506240E12C6CBFFA00605212 /* DLExample */,
|
||||||
|
506240E02C6CBFFA00605212 /* Products */,
|
||||||
|
506240F12C6CC02C00605212 /* Frameworks */,
|
||||||
|
);
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
506240E02C6CBFFA00605212 /* Products */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
506240DF2C6CBFFA00605212 /* DLExample.app */,
|
||||||
|
);
|
||||||
|
name = Products;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
506240E12C6CBFFA00605212 /* DLExample */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
506240E62C6CBFFB00605212 /* Assets.xcassets */,
|
||||||
|
506240E82C6CBFFB00605212 /* Entitlement.entitlements */,
|
||||||
|
506240E22C6CBFFA00605212 /* App.swift */,
|
||||||
|
506240E42C6CBFFA00605212 /* ContentView.swift */,
|
||||||
|
);
|
||||||
|
path = DLExample;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
506240F12C6CC02C00605212 /* Frameworks */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
);
|
||||||
|
name = Frameworks;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
/* End PBXGroup section */
|
||||||
|
|
||||||
|
/* Begin PBXNativeTarget section */
|
||||||
|
506240DE2C6CBFFA00605212 /* DLExample */ = {
|
||||||
|
isa = PBXNativeTarget;
|
||||||
|
buildConfigurationList = 506240EE2C6CBFFB00605212 /* Build configuration list for PBXNativeTarget "DLExample" */;
|
||||||
|
buildPhases = (
|
||||||
|
506240DB2C6CBFFA00605212 /* Sources */,
|
||||||
|
506240DC2C6CBFFA00605212 /* Frameworks */,
|
||||||
|
506240DD2C6CBFFA00605212 /* Resources */,
|
||||||
|
);
|
||||||
|
buildRules = (
|
||||||
|
);
|
||||||
|
dependencies = (
|
||||||
|
);
|
||||||
|
name = DLExample;
|
||||||
|
packageProductDependencies = (
|
||||||
|
506240F22C6CC02C00605212 /* MSDisplayLink */,
|
||||||
|
);
|
||||||
|
productName = DLExample;
|
||||||
|
productReference = 506240DF2C6CBFFA00605212 /* DLExample.app */;
|
||||||
|
productType = "com.apple.product-type.application";
|
||||||
|
};
|
||||||
|
/* End PBXNativeTarget section */
|
||||||
|
|
||||||
|
/* Begin PBXProject section */
|
||||||
|
506240D72C6CBFFA00605212 /* Project object */ = {
|
||||||
|
isa = PBXProject;
|
||||||
|
attributes = {
|
||||||
|
BuildIndependentTargetsInParallel = 1;
|
||||||
|
LastSwiftUpdateCheck = 1540;
|
||||||
|
LastUpgradeCheck = 1540;
|
||||||
|
TargetAttributes = {
|
||||||
|
506240DE2C6CBFFA00605212 = {
|
||||||
|
CreatedOnToolsVersion = 15.4;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
buildConfigurationList = 506240DA2C6CBFFA00605212 /* Build configuration list for PBXProject "DLExample" */;
|
||||||
|
compatibilityVersion = "Xcode 14.0";
|
||||||
|
developmentRegion = en;
|
||||||
|
hasScannedForEncodings = 0;
|
||||||
|
knownRegions = (
|
||||||
|
en,
|
||||||
|
Base,
|
||||||
|
);
|
||||||
|
mainGroup = 506240D62C6CBFFA00605212;
|
||||||
|
productRefGroup = 506240E02C6CBFFA00605212 /* Products */;
|
||||||
|
projectDirPath = "";
|
||||||
|
projectRoot = "";
|
||||||
|
targets = (
|
||||||
|
506240DE2C6CBFFA00605212 /* DLExample */,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
/* End PBXProject section */
|
||||||
|
|
||||||
|
/* Begin PBXResourcesBuildPhase section */
|
||||||
|
506240DD2C6CBFFA00605212 /* Resources */ = {
|
||||||
|
isa = PBXResourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
506240E72C6CBFFB00605212 /* Assets.xcassets in Resources */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
/* End PBXResourcesBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXSourcesBuildPhase section */
|
||||||
|
506240DB2C6CBFFA00605212 /* Sources */ = {
|
||||||
|
isa = PBXSourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
506240E52C6CBFFA00605212 /* ContentView.swift in Sources */,
|
||||||
|
506240E32C6CBFFA00605212 /* App.swift in Sources */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
/* End PBXSourcesBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin XCBuildConfiguration section */
|
||||||
|
506240EC2C6CBFFB00605212 /* Debug */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
|
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||||
|
CLANG_ANALYZER_NONNULL = YES;
|
||||||
|
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||||
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||||
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CLANG_ENABLE_OBJC_ARC = YES;
|
||||||
|
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||||
|
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||||
|
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_COMMA = YES;
|
||||||
|
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||||
|
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||||
|
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||||
|
CLANG_WARN_EMPTY_BODY = YES;
|
||||||
|
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||||
|
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||||
|
CLANG_WARN_INT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||||
|
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||||
|
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||||
|
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||||
|
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||||
|
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||||
|
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||||
|
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||||
|
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||||
|
COPY_PHASE_STRIP = NO;
|
||||||
|
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||||
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
|
ENABLE_TESTABILITY = YES;
|
||||||
|
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||||
|
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||||
|
GCC_DYNAMIC_NO_PIC = NO;
|
||||||
|
GCC_NO_COMMON_BLOCKS = YES;
|
||||||
|
GCC_OPTIMIZATION_LEVEL = 0;
|
||||||
|
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||||
|
"DEBUG=1",
|
||||||
|
"$(inherited)",
|
||||||
|
);
|
||||||
|
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||||
|
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||||
|
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||||
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
|
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||||
|
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||||
|
MTL_FAST_MATH = YES;
|
||||||
|
ONLY_ACTIVE_ARCH = YES;
|
||||||
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
|
||||||
|
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||||
|
};
|
||||||
|
name = Debug;
|
||||||
|
};
|
||||||
|
506240ED2C6CBFFB00605212 /* Release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
|
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||||
|
CLANG_ANALYZER_NONNULL = YES;
|
||||||
|
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||||
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||||
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CLANG_ENABLE_OBJC_ARC = YES;
|
||||||
|
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||||
|
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||||
|
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_COMMA = YES;
|
||||||
|
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||||
|
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||||
|
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||||
|
CLANG_WARN_EMPTY_BODY = YES;
|
||||||
|
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||||
|
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||||
|
CLANG_WARN_INT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||||
|
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||||
|
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||||
|
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||||
|
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||||
|
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||||
|
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||||
|
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||||
|
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||||
|
COPY_PHASE_STRIP = NO;
|
||||||
|
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||||
|
ENABLE_NS_ASSERTIONS = NO;
|
||||||
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
|
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||||
|
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||||
|
GCC_NO_COMMON_BLOCKS = YES;
|
||||||
|
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||||
|
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||||
|
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||||
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
|
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||||
|
MTL_ENABLE_DEBUG_INFO = NO;
|
||||||
|
MTL_FAST_MATH = YES;
|
||||||
|
SWIFT_COMPILATION_MODE = wholemodule;
|
||||||
|
};
|
||||||
|
name = Release;
|
||||||
|
};
|
||||||
|
506240EF2C6CBFFB00605212 /* Debug */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = DLExample/Entitlement.entitlements;
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
DEVELOPMENT_ASSET_PATHS = "";
|
||||||
|
DEVELOPMENT_TEAM = 964G86XT2P;
|
||||||
|
ENABLE_HARDENED_RUNTIME = YES;
|
||||||
|
ENABLE_PREVIEWS = NO;
|
||||||
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
|
||||||
|
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
|
||||||
|
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
|
||||||
|
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphonesimulator*]" = YES;
|
||||||
|
"INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphoneos*]" = YES;
|
||||||
|
"INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphonesimulator*]" = YES;
|
||||||
|
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]" = UIStatusBarStyleDefault;
|
||||||
|
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault;
|
||||||
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
||||||
|
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
||||||
|
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||||
|
MARKETING_VERSION = 1.0;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = wiki.qaq.DLExample;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SDKROOT = auto;
|
||||||
|
SUPPORTED_PLATFORMS = "appletvos appletvsimulator iphoneos iphonesimulator macosx xros xrsimulator";
|
||||||
|
SUPPORTS_MACCATALYST = YES;
|
||||||
|
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES;
|
||||||
|
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = YES;
|
||||||
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2,3,7";
|
||||||
|
};
|
||||||
|
name = Debug;
|
||||||
|
};
|
||||||
|
506240F02C6CBFFB00605212 /* Release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = DLExample/Entitlement.entitlements;
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
DEVELOPMENT_ASSET_PATHS = "";
|
||||||
|
DEVELOPMENT_TEAM = 964G86XT2P;
|
||||||
|
ENABLE_HARDENED_RUNTIME = YES;
|
||||||
|
ENABLE_PREVIEWS = NO;
|
||||||
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
|
||||||
|
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
|
||||||
|
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
|
||||||
|
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphonesimulator*]" = YES;
|
||||||
|
"INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphoneos*]" = YES;
|
||||||
|
"INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphonesimulator*]" = YES;
|
||||||
|
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]" = UIStatusBarStyleDefault;
|
||||||
|
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault;
|
||||||
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
||||||
|
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
||||||
|
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||||
|
MARKETING_VERSION = 1.0;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = wiki.qaq.DLExample;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SDKROOT = auto;
|
||||||
|
SUPPORTED_PLATFORMS = "appletvos appletvsimulator iphoneos iphonesimulator macosx xros xrsimulator";
|
||||||
|
SUPPORTS_MACCATALYST = YES;
|
||||||
|
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES;
|
||||||
|
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = YES;
|
||||||
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2,3,7";
|
||||||
|
};
|
||||||
|
name = Release;
|
||||||
|
};
|
||||||
|
/* End XCBuildConfiguration section */
|
||||||
|
|
||||||
|
/* Begin XCConfigurationList section */
|
||||||
|
506240DA2C6CBFFA00605212 /* Build configuration list for PBXProject "DLExample" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
506240EC2C6CBFFB00605212 /* Debug */,
|
||||||
|
506240ED2C6CBFFB00605212 /* Release */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Release;
|
||||||
|
};
|
||||||
|
506240EE2C6CBFFB00605212 /* Build configuration list for PBXNativeTarget "DLExample" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
506240EF2C6CBFFB00605212 /* Debug */,
|
||||||
|
506240F02C6CBFFB00605212 /* Release */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Release;
|
||||||
|
};
|
||||||
|
/* End XCConfigurationList section */
|
||||||
|
|
||||||
|
/* Begin XCSwiftPackageProductDependency section */
|
||||||
|
506240F22C6CC02C00605212 /* MSDisplayLink */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
productName = MSDisplayLink;
|
||||||
|
};
|
||||||
|
/* End XCSwiftPackageProductDependency section */
|
||||||
|
};
|
||||||
|
rootObject = 506240D72C6CBFFA00605212 /* Project object */;
|
||||||
|
}
|
||||||
7
vendor/MSDisplayLink/Example/DLExample.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
vendored
Normal file
7
vendor/MSDisplayLink/Example/DLExample.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Workspace
|
||||||
|
version = "1.0">
|
||||||
|
<FileRef
|
||||||
|
location = "self:">
|
||||||
|
</FileRef>
|
||||||
|
</Workspace>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>IDEDidComputeMac32BitWarning</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
10
vendor/MSDisplayLink/Example/DLExample.xcworkspace/contents.xcworkspacedata
generated
vendored
Normal file
10
vendor/MSDisplayLink/Example/DLExample.xcworkspace/contents.xcworkspacedata
generated
vendored
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Workspace
|
||||||
|
version = "1.0">
|
||||||
|
<FileRef
|
||||||
|
location = "group:DLExample.xcodeproj">
|
||||||
|
</FileRef>
|
||||||
|
<FileRef
|
||||||
|
location = "group:..">
|
||||||
|
</FileRef>
|
||||||
|
</Workspace>
|
||||||
8
vendor/MSDisplayLink/Example/DLExample.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist
vendored
Normal file
8
vendor/MSDisplayLink/Example/DLExample.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>IDEDidComputeMac32BitWarning</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
37
vendor/MSDisplayLink/Example/DLExample/App.swift
vendored
Normal file
37
vendor/MSDisplayLink/Example/DLExample/App.swift
vendored
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
//
|
||||||
|
// App.swift
|
||||||
|
// DLExample
|
||||||
|
//
|
||||||
|
// Created by 秋星桥 on 2024/8/14.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@main
|
||||||
|
struct App: SwiftUI.App {
|
||||||
|
var body: some Scene {
|
||||||
|
WindowGroup {
|
||||||
|
ContentView()
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
#if os(macOS)
|
||||||
|
.background(VisualEffectView().ignoresSafeArea())
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
#if os(macOS)
|
||||||
|
.windowStyle(.hiddenTitleBar)
|
||||||
|
.windowResizability(.contentMinSize)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#if os(macOS)
|
||||||
|
struct VisualEffectView: NSViewRepresentable {
|
||||||
|
func makeNSView(context _: Context) -> NSVisualEffectView {
|
||||||
|
let effectView = NSVisualEffectView()
|
||||||
|
effectView.state = .active
|
||||||
|
return effectView
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateNSView(_: NSVisualEffectView, context _: Context) {}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
11
vendor/MSDisplayLink/Example/DLExample/Assets.xcassets/AccentColor.colorset/Contents.json
vendored
Normal file
11
vendor/MSDisplayLink/Example/DLExample/Assets.xcassets/AccentColor.colorset/Contents.json
vendored
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"colors" : [
|
||||||
|
{
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
63
vendor/MSDisplayLink/Example/DLExample/Assets.xcassets/AppIcon.appiconset/Contents.json
vendored
Normal file
63
vendor/MSDisplayLink/Example/DLExample/Assets.xcassets/AppIcon.appiconset/Contents.json
vendored
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"platform" : "ios",
|
||||||
|
"size" : "1024x1024"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "mac",
|
||||||
|
"scale" : "1x",
|
||||||
|
"size" : "16x16"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "mac",
|
||||||
|
"scale" : "2x",
|
||||||
|
"size" : "16x16"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "mac",
|
||||||
|
"scale" : "1x",
|
||||||
|
"size" : "32x32"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "mac",
|
||||||
|
"scale" : "2x",
|
||||||
|
"size" : "32x32"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "mac",
|
||||||
|
"scale" : "1x",
|
||||||
|
"size" : "128x128"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "mac",
|
||||||
|
"scale" : "2x",
|
||||||
|
"size" : "128x128"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "mac",
|
||||||
|
"scale" : "1x",
|
||||||
|
"size" : "256x256"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "mac",
|
||||||
|
"scale" : "2x",
|
||||||
|
"size" : "256x256"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "mac",
|
||||||
|
"scale" : "1x",
|
||||||
|
"size" : "512x512"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "mac",
|
||||||
|
"scale" : "2x",
|
||||||
|
"size" : "512x512"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
6
vendor/MSDisplayLink/Example/DLExample/Assets.xcassets/Contents.json
vendored
Normal file
6
vendor/MSDisplayLink/Example/DLExample/Assets.xcassets/Contents.json
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
25
vendor/MSDisplayLink/Example/DLExample/ContentView.swift
vendored
Normal file
25
vendor/MSDisplayLink/Example/DLExample/ContentView.swift
vendored
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
//
|
||||||
|
// ContentView.swift
|
||||||
|
// DLExample
|
||||||
|
//
|
||||||
|
// Created by 秋星桥 on 2024/8/14.
|
||||||
|
//
|
||||||
|
|
||||||
|
import MSDisplayLink
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct ContentView: View {
|
||||||
|
@State var frame: Int = 0
|
||||||
|
var body: some View {
|
||||||
|
VStack {
|
||||||
|
Text("MSDisplayLink Trigger: \(frame)")
|
||||||
|
.monospacedDigit()
|
||||||
|
.contentTransition(.numericText())
|
||||||
|
}
|
||||||
|
.animation(.interactiveSpring, value: frame)
|
||||||
|
.modifier(DisplayLinkModifier(scheduleToMainThread: true) {
|
||||||
|
frame += 1
|
||||||
|
})
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
}
|
||||||
10
vendor/MSDisplayLink/Example/DLExample/Entitlement.entitlements
vendored
Normal file
10
vendor/MSDisplayLink/Example/DLExample/Entitlement.entitlements
vendored
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>com.apple.security.app-sandbox</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.security.files.user-selected.read-only</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
21
vendor/MSDisplayLink/LICENSE
vendored
Normal file
21
vendor/MSDisplayLink/LICENSE
vendored
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2024 Lakr Aream
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
20
vendor/MSDisplayLink/Package.swift
vendored
Normal file
20
vendor/MSDisplayLink/Package.swift
vendored
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
// swift-tools-version: 6.0
|
||||||
|
|
||||||
|
import PackageDescription
|
||||||
|
|
||||||
|
let package = Package(
|
||||||
|
name: "MSDisplayLink",
|
||||||
|
platforms: [
|
||||||
|
.iOS(.v13),
|
||||||
|
.macOS(.v11),
|
||||||
|
.macCatalyst(.v13),
|
||||||
|
.tvOS(.v13),
|
||||||
|
.visionOS(.v1),
|
||||||
|
],
|
||||||
|
products: [
|
||||||
|
.library(name: "MSDisplayLink", targets: ["MSDisplayLink"]),
|
||||||
|
],
|
||||||
|
targets: [
|
||||||
|
.target(name: "MSDisplayLink"),
|
||||||
|
]
|
||||||
|
)
|
||||||
73
vendor/MSDisplayLink/README.md
vendored
Normal file
73
vendor/MSDisplayLink/README.md
vendored
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
# MSDisplayLink
|
||||||
|
|
||||||
|
The missing DisplayLink driver for Apple platforms. Use CADisplayLink on UIKit and CVDisplayLink on AppKit.
|
||||||
|
|
||||||
|
Battle tested in [ColorfulX](https://github.com/Lakr233/ColorfulX)
|
||||||
|
|
||||||
|
Called MS but it has nothing to do with Microsoft. :P
|
||||||
|
|
||||||
|
## Preview
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
### Swift Package Manager
|
||||||
|
|
||||||
|
```swift
|
||||||
|
.package(url: "https://github.com/Lakr233/MSDisplayLink.git", from: "2.0.8")
|
||||||
|
```
|
||||||
|
|
||||||
|
### CocoaPods
|
||||||
|
|
||||||
|
**Never thought about it.**
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### UIKit/AppKit
|
||||||
|
|
||||||
|
Follow the steps to get called. The `DisplayLinkDelegate` is friendly to confirm for a UIView or NSView.
|
||||||
|
|
||||||
|
- Hold reference to `DisplayLink` object.
|
||||||
|
- Confirm to `DisplayLinkDelegate` protocol to get called within `synchronization()`.
|
||||||
|
- Call `displayLink.delegatingObject` with a `DisplayLinkDelegate` object.
|
||||||
|
|
||||||
|
Tech Tips:
|
||||||
|
|
||||||
|
- Call to `synchronization()` is performed at a background thread.
|
||||||
|
- Scheduler is a serial queue unique to each `DisplayLinkDriver` holds by `DisplayLink`.
|
||||||
|
- Queue inherits the quality of service from the where you created the `DisplayLink`.
|
||||||
|
- Does not make scene to use concurrent queue.
|
||||||
|
- Does not share the queue across different `DisplayLink` objects.
|
||||||
|
- Queue names are same.
|
||||||
|
|
||||||
|
### SwiftUI
|
||||||
|
|
||||||
|
```
|
||||||
|
import MSDisplayLink
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct ContentView: View {
|
||||||
|
@State var frame: Int = 0
|
||||||
|
var body: some View {
|
||||||
|
VStack {
|
||||||
|
Text("MSDisplayLink Trigger: \(frame)")
|
||||||
|
.monospacedDigit()
|
||||||
|
.contentTransition(.numericText())
|
||||||
|
}
|
||||||
|
.animation(.interactiveSpring, value: frame)
|
||||||
|
.modifier(DisplayLinkModifier(scheduleToMainThread: true) {
|
||||||
|
frame += 1
|
||||||
|
})
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
[MIT License](LICENSE)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
2024.08.14
|
||||||
BIN
vendor/MSDisplayLink/Resources/SCR-20240814-qqri.jpeg
vendored
Normal file
BIN
vendor/MSDisplayLink/Resources/SCR-20240814-qqri.jpeg
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 110 KiB |
36
vendor/MSDisplayLink/Scripts/test.build.sh
vendored
Executable file
36
vendor/MSDisplayLink/Scripts/test.build.sh
vendored
Executable file
@@ -0,0 +1,36 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
cd ..
|
||||||
|
|
||||||
|
WORKSPACE="./Example/DLExample.xcworkspace"
|
||||||
|
SCEHEME="DLExample"
|
||||||
|
|
||||||
|
function test_build() {
|
||||||
|
DESTINATION=$1
|
||||||
|
echo "[*] test build for $DESTINATION"
|
||||||
|
xcodebuild \
|
||||||
|
-workspace "$WORKSPACE" \
|
||||||
|
-scheme "$SCEHEME" \
|
||||||
|
-destination "$DESTINATION" \
|
||||||
|
CODE_SIGN_IDENTITY="" \
|
||||||
|
CODE_SIGNING_ALLOWED=NO \
|
||||||
|
| xcbeautify
|
||||||
|
EXIT_CODE=${PIPESTATUS[0]}
|
||||||
|
echo "[*] finished with exit code $EXIT_CODE"
|
||||||
|
if [ $EXIT_CODE -ne 0 ]; then
|
||||||
|
echo "[!] failed to build for $DESTINATION"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
test_build "generic/platform=macOS"
|
||||||
|
test_build "generic/platform=macOS,variant=Mac Catalyst"
|
||||||
|
test_build "generic/platform=iOS"
|
||||||
|
test_build "generic/platform=iOS Simulator"
|
||||||
|
test_build "generic/platform=tvOS"
|
||||||
|
test_build "generic/platform=tvOS Simulator"
|
||||||
|
test_build "generic/platform=xrOS"
|
||||||
|
test_build "generic/platform=xrOS Simulator"
|
||||||
|
|
||||||
|
echo "[*] all builds succeeded"
|
||||||
56
vendor/MSDisplayLink/Sources/MSDisplayLink/DisplayLink+SwiftUI.swift
vendored
Normal file
56
vendor/MSDisplayLink/Sources/MSDisplayLink/DisplayLink+SwiftUI.swift
vendored
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
//
|
||||||
|
// DisplayLink+SwiftUI.swift
|
||||||
|
// MSDisplayLink
|
||||||
|
//
|
||||||
|
// Created by 秋星桥 on 2024/8/14.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
public struct DisplayLinkModifier: ViewModifier {
|
||||||
|
let link: DisplayLink
|
||||||
|
let context: DisplayLinkModifierContext
|
||||||
|
|
||||||
|
public init(scheduleToMainThread: Bool = true, _ callback: @escaping @Sendable (DisplayLinkCallbackContext) -> Void) {
|
||||||
|
link = .init()
|
||||||
|
context = .init(scheduleToMainThread: scheduleToMainThread, callback: callback)
|
||||||
|
link.delegatingObject(context)
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(scheduleToMainThread: Bool = true, _ callback: @escaping @Sendable () -> Void) {
|
||||||
|
self.init(scheduleToMainThread: scheduleToMainThread) { _ in callback() }
|
||||||
|
}
|
||||||
|
|
||||||
|
public func body(content: Content) -> some View {
|
||||||
|
content
|
||||||
|
.onAppear { link.delegatingObject(context) }
|
||||||
|
.onDisappear { link.delegatingObject(nil) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class DisplayLinkModifierContext: ObservableObject, DisplayLinkDelegate, @unchecked Sendable {
|
||||||
|
let scheduleToMainThread: Bool
|
||||||
|
var callback: @Sendable (DisplayLinkCallbackContext) -> Void
|
||||||
|
|
||||||
|
init(scheduleToMainThread: Bool, callback: @escaping @Sendable (DisplayLinkCallbackContext) -> Void) {
|
||||||
|
self.scheduleToMainThread = scheduleToMainThread
|
||||||
|
self.callback = callback
|
||||||
|
}
|
||||||
|
|
||||||
|
func synchronization(context: DisplayLinkCallbackContext) {
|
||||||
|
if scheduleToMainThread {
|
||||||
|
if Thread.isMainThread {
|
||||||
|
callback(context)
|
||||||
|
} else {
|
||||||
|
DispatchQueue.main.async { self.callback(context) }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if Thread.isMainThread {
|
||||||
|
DispatchQueue.global().async { self.callback(context) }
|
||||||
|
} else {
|
||||||
|
callback(context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
40
vendor/MSDisplayLink/Sources/MSDisplayLink/DisplayLink.swift
vendored
Normal file
40
vendor/MSDisplayLink/Sources/MSDisplayLink/DisplayLink.swift
vendored
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
//
|
||||||
|
// DisplayLink.swift
|
||||||
|
// MSDisplayLink
|
||||||
|
//
|
||||||
|
// Created by 秋星桥 on 2024/8/13.
|
||||||
|
//
|
||||||
|
|
||||||
|
@preconcurrency import Combine
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
public protocol DisplayLinkDelegate: AnyObject {
|
||||||
|
func synchronization(context: DisplayLinkCallbackContext)
|
||||||
|
}
|
||||||
|
|
||||||
|
public class DisplayLink: @unchecked Sendable {
|
||||||
|
private weak var delegatingObject: DisplayLinkDelegate?
|
||||||
|
|
||||||
|
private var driver: DisplayLinkDriver?
|
||||||
|
private var driverSubscription: Set<AnyCancellable> = .init()
|
||||||
|
|
||||||
|
public init() {
|
||||||
|
let driver = DisplayLinkDriver()
|
||||||
|
driver.synchronizationPublisher
|
||||||
|
.sink { [weak self] output in self?.delegatingObject?.synchronization(context: output) }
|
||||||
|
.store(in: &driverSubscription)
|
||||||
|
self.driver = driver
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit { teardown() }
|
||||||
|
|
||||||
|
func teardown() {
|
||||||
|
driverSubscription.forEach { $0.cancel() }
|
||||||
|
driverSubscription.removeAll()
|
||||||
|
driver = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
public func delegatingObject(_ object: DisplayLinkDelegate?) {
|
||||||
|
delegatingObject = object
|
||||||
|
}
|
||||||
|
}
|
||||||
23
vendor/MSDisplayLink/Sources/MSDisplayLink/DisplayLinkCallbackContext.swift
vendored
Normal file
23
vendor/MSDisplayLink/Sources/MSDisplayLink/DisplayLinkCallbackContext.swift
vendored
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
//
|
||||||
|
// DisplayLinkCallbackContext.swift
|
||||||
|
// MSDisplayLink
|
||||||
|
//
|
||||||
|
// Created by 秋星桥 on 2025/1/6.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
public struct DisplayLinkCallbackContext: Sendable {
|
||||||
|
/// The time interval between screen refresh updates.
|
||||||
|
public let duration: TimeInterval
|
||||||
|
/// The time interval that represents when the last frame displayed.
|
||||||
|
public let timestamp: TimeInterval
|
||||||
|
/// The time interval that represents when the next frame displays.
|
||||||
|
public let targetTimestamp: TimeInterval
|
||||||
|
|
||||||
|
public init(duration: TimeInterval, timestamp: TimeInterval, targetTimestamp: TimeInterval) {
|
||||||
|
self.duration = duration
|
||||||
|
self.timestamp = timestamp
|
||||||
|
self.targetTimestamp = targetTimestamp
|
||||||
|
}
|
||||||
|
}
|
||||||
64
vendor/MSDisplayLink/Sources/MSDisplayLink/DisplayLinkDriver+CA.swift
vendored
Normal file
64
vendor/MSDisplayLink/Sources/MSDisplayLink/DisplayLinkDriver+CA.swift
vendored
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
#if canImport(UIKit)
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
typealias DisplayLinkDriverHelper = CADisplayLinkDriverHelper
|
||||||
|
|
||||||
|
class CADisplayLinkDriverHelper: DisplayLinkDriverHelperBase {
|
||||||
|
nonisolated(unsafe) static let shared = CADisplayLinkDriverHelper()
|
||||||
|
|
||||||
|
private var displayLink: CADisplayLink?
|
||||||
|
|
||||||
|
override private init() {
|
||||||
|
super.init()
|
||||||
|
NotificationCenter.default.addObserver(
|
||||||
|
self,
|
||||||
|
selector: #selector(applicationDidEnterBackground(_:)),
|
||||||
|
name: UIApplication.didEnterBackgroundNotification,
|
||||||
|
object: nil
|
||||||
|
)
|
||||||
|
|
||||||
|
NotificationCenter.default.addObserver(
|
||||||
|
self,
|
||||||
|
selector: #selector(applicationWillEnterForeground(_:)),
|
||||||
|
name: UIApplication.willEnterForegroundNotification,
|
||||||
|
object: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
NotificationCenter.default.removeObserver(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func startDisplayLink() {
|
||||||
|
assert(Thread.isMainThread)
|
||||||
|
guard displayLink == nil else { return }
|
||||||
|
displayLink = CADisplayLink(target: self, selector: #selector(displayLinkCallback(_:)))
|
||||||
|
displayLink?.add(to: .main, forMode: .common)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func stopDisplayLink() {
|
||||||
|
assert(Thread.isMainThread)
|
||||||
|
displayLink?.invalidate()
|
||||||
|
displayLink = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func displayLinkCallback(_ displayLink: CADisplayLink) {
|
||||||
|
let context = DisplayLinkCallbackContext(
|
||||||
|
duration: displayLink.duration,
|
||||||
|
timestamp: displayLink.timestamp,
|
||||||
|
targetTimestamp: displayLink.targetTimestamp
|
||||||
|
)
|
||||||
|
autoreleasepool { dispatchUpdate(context: context) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func applicationDidEnterBackground(_: Notification) {
|
||||||
|
stopDisplayLink()
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func applicationWillEnterForeground(_: Notification) {
|
||||||
|
startDisplayLink()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
43
vendor/MSDisplayLink/Sources/MSDisplayLink/DisplayLinkDriver+CV.swift
vendored
Normal file
43
vendor/MSDisplayLink/Sources/MSDisplayLink/DisplayLinkDriver+CV.swift
vendored
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
#if !canImport(UIKit) && canImport(AppKit)
|
||||||
|
import AppKit
|
||||||
|
|
||||||
|
typealias DisplayLinkDriverHelper = CVDisplayLinkDriverHelper
|
||||||
|
|
||||||
|
class CVDisplayLinkDriverHelper: DisplayLinkDriverHelperBase {
|
||||||
|
nonisolated(unsafe) static let shared = CVDisplayLinkDriverHelper()
|
||||||
|
|
||||||
|
private var displayLink: CVDisplayLink?
|
||||||
|
|
||||||
|
override func startDisplayLink() {
|
||||||
|
assert(Thread.isMainThread)
|
||||||
|
guard displayLink == nil else { return }
|
||||||
|
CVDisplayLinkCreateWithActiveCGDisplays(&displayLink)
|
||||||
|
guard let displayLink else { return }
|
||||||
|
|
||||||
|
CVDisplayLinkSetOutputCallback(displayLink, { _, inNow, inOutputTime, _, _, _ in
|
||||||
|
let clockFrequency = CVGetHostClockFrequency()
|
||||||
|
let context = DisplayLinkCallbackContext(
|
||||||
|
duration: TimeInterval(inNow.pointee.videoRefreshPeriod) / TimeInterval(inNow.pointee.videoTimeScale),
|
||||||
|
timestamp: TimeInterval(inNow.pointee.hostTime) / clockFrequency,
|
||||||
|
targetTimestamp: TimeInterval(inOutputTime.pointee.hostTime) / clockFrequency
|
||||||
|
)
|
||||||
|
assert(!Thread.isMainThread)
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
autoreleasepool {
|
||||||
|
CVDisplayLinkDriverHelper.shared.dispatchUpdate(context: context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return kCVReturnSuccess
|
||||||
|
}, nil)
|
||||||
|
CVDisplayLinkStart(displayLink)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func stopDisplayLink() {
|
||||||
|
assert(Thread.isMainThread)
|
||||||
|
if let displayLink { CVDisplayLinkStop(displayLink) }
|
||||||
|
displayLink = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
59
vendor/MSDisplayLink/Sources/MSDisplayLink/DisplayLinkDriver+Helper.swift
vendored
Normal file
59
vendor/MSDisplayLink/Sources/MSDisplayLink/DisplayLinkDriver+Helper.swift
vendored
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
//
|
||||||
|
// DisplayLinkDriver+Helper.swift
|
||||||
|
//
|
||||||
|
//
|
||||||
|
// Created by 秋星桥 on 2024/8/29.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
class DisplayLinkDriverHelperBase: Identifiable {
|
||||||
|
final let id: UUID = .init()
|
||||||
|
|
||||||
|
private(set) var referenceHolder: [WeakBox] = []
|
||||||
|
|
||||||
|
struct WeakBox { weak var object: DisplayLinkDriver? }
|
||||||
|
|
||||||
|
final func delegate(_ object: DisplayLinkDriver) {
|
||||||
|
assert(Thread.isMainThread)
|
||||||
|
var shouldStartDisplayLink = false
|
||||||
|
defer { if shouldStartDisplayLink { startDisplayLink() } }
|
||||||
|
|
||||||
|
referenceHolder = referenceHolder
|
||||||
|
.filter { $0.object != nil }
|
||||||
|
.filter { $0.object?.id != object.id }
|
||||||
|
+ [.init(object: object)]
|
||||||
|
|
||||||
|
shouldStartDisplayLink = !referenceHolder.isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
final func remove(_ object: DisplayLinkDriver) {
|
||||||
|
assert(Thread.isMainThread)
|
||||||
|
|
||||||
|
referenceHolder = referenceHolder.filter { $0.object?.id != object.id }
|
||||||
|
}
|
||||||
|
|
||||||
|
final func reclaimComputeResourceIfPossible() {
|
||||||
|
assert(Thread.isMainThread)
|
||||||
|
var shouldStop = false
|
||||||
|
defer { if shouldStop { self.stopDisplayLink() } }
|
||||||
|
referenceHolder = referenceHolder.filter { $0.object != nil }
|
||||||
|
shouldStop = referenceHolder.isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
final func dispatchUpdate(context: DisplayLinkCallbackContext) {
|
||||||
|
defer { self.reclaimComputeResourceIfPossible() }
|
||||||
|
|
||||||
|
for box in referenceHolder {
|
||||||
|
box.object?.synchronize(context: context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func startDisplayLink() {
|
||||||
|
fatalError("Subclasses need to implement the `startDisplayLink()` method.")
|
||||||
|
}
|
||||||
|
|
||||||
|
func stopDisplayLink() {
|
||||||
|
fatalError("Subclasses need to implement the `stopDisplayLink()` method.")
|
||||||
|
}
|
||||||
|
}
|
||||||
32
vendor/MSDisplayLink/Sources/MSDisplayLink/DisplayLinkDriver.swift
vendored
Normal file
32
vendor/MSDisplayLink/Sources/MSDisplayLink/DisplayLinkDriver.swift
vendored
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
//
|
||||||
|
// DisplayLinkDriver.swift
|
||||||
|
// MSDisplayLink
|
||||||
|
//
|
||||||
|
// Created by 秋星桥 on 2024/8/13.
|
||||||
|
//
|
||||||
|
|
||||||
|
@preconcurrency import Combine
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
class DisplayLinkDriver: Identifiable {
|
||||||
|
let id: UUID = .init()
|
||||||
|
|
||||||
|
typealias SynchornizationPublisher = PassthroughSubject<
|
||||||
|
DisplayLinkCallbackContext,
|
||||||
|
Never
|
||||||
|
>
|
||||||
|
let synchronizationPublisher: SynchornizationPublisher
|
||||||
|
|
||||||
|
init() {
|
||||||
|
synchronizationPublisher = .init()
|
||||||
|
DisplayLinkDriverHelper.shared.delegate(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
DisplayLinkDriverHelper.shared.remove(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
func synchronize(context: DisplayLinkCallbackContext) {
|
||||||
|
synchronizationPublisher.send(context)
|
||||||
|
}
|
||||||
|
}
|
||||||
87
vendor/build/build-cssh.sh
vendored
Executable file
87
vendor/build/build-cssh.sh
vendored
Executable file
@@ -0,0 +1,87 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# 交叉编译 libssh2 + mbedTLS → CSSHCore.xcframework(iOS device / simulator / macOS)。
|
||||||
|
# mbedTLS 作为 libssh2 的 crypto backend(M0 选型;后续可换 OpenSSL,TXTransport 接口不变)。
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
VENDOR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
MBEDTLS="$VENDOR/mbedtls"
|
||||||
|
LIBSSH2="$VENDOR/libssh2"
|
||||||
|
OUT="$VENDOR/build/cssh"
|
||||||
|
ARTIFACTS="$VENDOR/../artifacts"
|
||||||
|
IOS_DEPLOY=17.0
|
||||||
|
MAC_DEPLOY=14.0
|
||||||
|
|
||||||
|
build_slice() {
|
||||||
|
local name="$1" sysname="$2" sysroot="$3" archs="$4" deploy="$5"
|
||||||
|
local prefix="$OUT/$name/prefix"
|
||||||
|
echo "==================== slice: $name ($archs @ $sysroot) ===================="
|
||||||
|
rm -rf "$OUT/$name"; mkdir -p "$prefix"
|
||||||
|
|
||||||
|
local common=(
|
||||||
|
-G Ninja
|
||||||
|
-DCMAKE_BUILD_TYPE=Release
|
||||||
|
-DCMAKE_SYSTEM_NAME="$sysname"
|
||||||
|
-DCMAKE_OSX_SYSROOT="$sysroot"
|
||||||
|
-DCMAKE_OSX_ARCHITECTURES="$archs"
|
||||||
|
-DCMAKE_OSX_DEPLOYMENT_TARGET="$deploy"
|
||||||
|
-DCMAKE_INSTALL_PREFIX="$prefix"
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- mbedTLS ---
|
||||||
|
cmake -S "$MBEDTLS" -B "$OUT/$name/mbedtls" "${common[@]}" \
|
||||||
|
-DENABLE_TESTING=OFF -DENABLE_PROGRAMS=OFF \
|
||||||
|
-DUSE_STATIC_MBEDTLS_LIBRARY=ON -DUSE_SHARED_MBEDTLS_LIBRARY=OFF
|
||||||
|
cmake --build "$OUT/$name/mbedtls" --target install
|
||||||
|
|
||||||
|
# --- libssh2 (mbedTLS backend) ---
|
||||||
|
cmake -S "$LIBSSH2" -B "$OUT/$name/libssh2" "${common[@]}" \
|
||||||
|
-DCRYPTO_BACKEND=mbedTLS \
|
||||||
|
-DCMAKE_PREFIX_PATH="$prefix" \
|
||||||
|
-DMBEDTLS_INCLUDE_DIR="$prefix/include" \
|
||||||
|
-DMBEDCRYPTO_LIBRARY="$prefix/lib/libmbedcrypto.a" \
|
||||||
|
-DBUILD_SHARED_LIBS=OFF -DBUILD_EXAMPLES=OFF -DBUILD_TESTING=OFF \
|
||||||
|
-DENABLE_ZLIB_COMPRESSION=OFF
|
||||||
|
cmake --build "$OUT/$name/libssh2" --target install
|
||||||
|
|
||||||
|
# --- 合并静态库:libssh2 + mbedtls* → 单一 libCSSHCore.a ---
|
||||||
|
libtool -static -o "$OUT/$name/libCSSHCore.a" \
|
||||||
|
"$prefix/lib/libssh2.a" \
|
||||||
|
"$prefix/lib/libmbedtls.a" \
|
||||||
|
"$prefix/lib/libmbedx509.a" \
|
||||||
|
"$prefix/lib/libmbedcrypto.a"
|
||||||
|
echo "[+] $name → $(lipo -archs "$OUT/$name/libCSSHCore.a")"
|
||||||
|
}
|
||||||
|
|
||||||
|
pack_xcframework() {
|
||||||
|
echo "==================== packing CSSHCore.xcframework (library-only) ===================="
|
||||||
|
# 只打包静态库切片;头文件与 modulemap 由 TXTransport 包内的 CSSH(C) target 提供,
|
||||||
|
# 避免 SwiftPM binaryTarget 自带 modulemap 复制到 include/ 时的冲突。
|
||||||
|
rm -rf "$ARTIFACTS/CSSHCore.xcframework"; mkdir -p "$ARTIFACTS"
|
||||||
|
local args=()
|
||||||
|
for s in "$@"; do
|
||||||
|
args+=(-library "$OUT/$s/libCSSHCore.a")
|
||||||
|
done
|
||||||
|
xcodebuild -create-xcframework "${args[@]}" -output "$ARTIFACTS/CSSHCore.xcframework"
|
||||||
|
echo "[+] CSSHCore.xcframework:"; ls "$ARTIFACTS/CSSHCore.xcframework"
|
||||||
|
|
||||||
|
# 同步 libssh2 公共头到 CSSH C target(供 import CSSH)。
|
||||||
|
local csshinc="$VENDOR/../packages/TXTransport/Sources/CSSH/include"
|
||||||
|
mkdir -p "$csshinc"
|
||||||
|
cp "$OUT/iossim/prefix/include/libssh2.h" \
|
||||||
|
"$OUT/iossim/prefix/include/libssh2_publickey.h" \
|
||||||
|
"$OUT/iossim/prefix/include/libssh2_sftp.h" "$csshinc/"
|
||||||
|
echo "[+] synced libssh2 headers → $csshinc"
|
||||||
|
}
|
||||||
|
|
||||||
|
case "${1:-all}" in
|
||||||
|
iossim) build_slice iossim iOS iphonesimulator "arm64;x86_64" "$IOS_DEPLOY" ;;
|
||||||
|
device) build_slice ios-arm64 iOS iphoneos "arm64" "$IOS_DEPLOY" ;;
|
||||||
|
macos) build_slice macos Darwin macosx "arm64" "$MAC_DEPLOY" ;;
|
||||||
|
all)
|
||||||
|
build_slice iossim iOS iphonesimulator "arm64;x86_64" "$IOS_DEPLOY"
|
||||||
|
build_slice ios-arm64 iOS iphoneos "arm64" "$IOS_DEPLOY"
|
||||||
|
build_slice macos Darwin macosx "arm64" "$MAC_DEPLOY"
|
||||||
|
pack_xcframework iossim ios-arm64 macos
|
||||||
|
;;
|
||||||
|
pack) shift; pack_xcframework "$@" ;;
|
||||||
|
esac
|
||||||
1
vendor/libghostty-spm/.gitattributes
vendored
Normal file
1
vendor/libghostty-spm/.gitattributes
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
*.patch whitespace=-trailing-space
|
||||||
306
vendor/libghostty-spm/.github/workflows/build.yml
vendored
Normal file
306
vendor/libghostty-spm/.github/workflows/build.yml
vendored
Normal file
@@ -0,0 +1,306 @@
|
|||||||
|
name: Build GhosttyKit
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
package_version:
|
||||||
|
description: "GhosttyKit package version to publish, for example 1.3.1."
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
schedule:
|
||||||
|
- cron: "23 6 * * 1"
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: build-${{ github.ref_name || 'scheduled' }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
prepare:
|
||||||
|
runs-on: macos-15
|
||||||
|
outputs:
|
||||||
|
build_needed: ${{ steps.release.outputs.build_needed }}
|
||||||
|
ghostty_ref: ${{ steps.release.outputs.ghostty_ref }}
|
||||||
|
resolved_sha: ${{ steps.release.outputs.resolved_sha }}
|
||||||
|
package_version: ${{ steps.release.outputs.package_version }}
|
||||||
|
storage_tag: ${{ steps.release.outputs.storage_tag }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Resolve Release
|
||||||
|
id: release
|
||||||
|
run: |
|
||||||
|
EVENT="${{ github.event_name }}"
|
||||||
|
INPUT_PACKAGE_VERSION="${{ github.event.inputs.package_version }}"
|
||||||
|
GHOSTTY_REF=$(tr -d '[:space:]' < Ghostty.ref)
|
||||||
|
|
||||||
|
if ! echo "$GHOSTTY_REF" | grep -Eq '^[0-9a-f]{40}$'; then
|
||||||
|
echo "[!] Ghostty.ref must contain one full lowercase commit sha"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
git fetch --tags origin
|
||||||
|
LATEST_PACKAGE_VERSION=$(
|
||||||
|
git tag --list |
|
||||||
|
python3 -c 'import re, sys; versions = [tuple(int(part) for part in tag.split(".")) for tag in (line.strip() for line in sys.stdin) if re.fullmatch(r"\d+\.\d+\.\d+", tag)]; print(".".join(map(str, max(versions))), end="") if versions else None'
|
||||||
|
)
|
||||||
|
|
||||||
|
case "$EVENT" in
|
||||||
|
workflow_dispatch)
|
||||||
|
PACKAGE_VERSION="$INPUT_PACKAGE_VERSION"
|
||||||
|
;;
|
||||||
|
schedule)
|
||||||
|
PACKAGE_VERSION=$(
|
||||||
|
python3 -c 'import sys; version = sys.argv[1]; major, minor, patch = map(int, version.split(".")) if version else (1, 0, -1); print(f"{major}.{minor}.{patch + 1}", end="")' "$LATEST_PACKAGE_VERSION"
|
||||||
|
)
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "[!] unsupported event: $EVENT"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
if ! echo "$PACKAGE_VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then
|
||||||
|
echo "[!] package_version must be semantic, for example 1.3.1: $PACKAGE_VERSION"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "$LATEST_PACKAGE_VERSION" ] && ! python3 -c 'import sys; latest = tuple(map(int, sys.argv[1].split("."))); requested = tuple(map(int, sys.argv[2].split("."))); raise SystemExit(0 if requested > latest else 1)' "$LATEST_PACKAGE_VERSION" "$PACKAGE_VERSION"; then
|
||||||
|
echo "[!] package_version must be newer than $LATEST_PACKAGE_VERSION: $PACKAGE_VERSION"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
STORAGE_TAG="storage.$PACKAGE_VERSION"
|
||||||
|
if git rev-parse "refs/tags/$PACKAGE_VERSION" >/dev/null 2>&1; then
|
||||||
|
echo "[!] package tag already exists: $PACKAGE_VERSION"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if git rev-parse "refs/tags/$STORAGE_TAG" >/dev/null 2>&1; then
|
||||||
|
echo "[!] storage tag already exists: $STORAGE_TAG"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$EVENT" = "schedule" ] && [ -n "$LATEST_PACKAGE_VERSION" ] && [ "$(git rev-list -n 1 "$LATEST_PACKAGE_VERSION")" = "$(git rev-parse HEAD)" ]; then
|
||||||
|
echo "[*] main matches package tag $LATEST_PACKAGE_VERSION, skipping scheduled release"
|
||||||
|
echo "build_needed=false" >> "$GITHUB_OUTPUT"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
RESOLVED_SHA=$(gh api "repos/ghostty-org/ghostty/commits/$GHOSTTY_REF" --jq '.sha')
|
||||||
|
if [ "$RESOLVED_SHA" != "$GHOSTTY_REF" ]; then
|
||||||
|
echo "[!] pinned Ghostty commit did not resolve exactly: $GHOSTTY_REF"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[*] package_version=$PACKAGE_VERSION ghostty_ref=$GHOSTTY_REF"
|
||||||
|
echo "build_needed=true" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "ghostty_ref=$GHOSTTY_REF" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "resolved_sha=$RESOLVED_SHA" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "package_version=$PACKAGE_VERSION" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "storage_tag=$STORAGE_TAG" >> "$GITHUB_OUTPUT"
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
build-target:
|
||||||
|
needs: prepare
|
||||||
|
if: needs.prepare.outputs.build_needed == 'true'
|
||||||
|
runs-on: macos-15
|
||||||
|
timeout-minutes: 60
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- target: aarch64-macos
|
||||||
|
variant: macosx
|
||||||
|
- target: arm64e-macos
|
||||||
|
variant: macosx
|
||||||
|
cpu: apple_a12
|
||||||
|
- target: x86_64-macos
|
||||||
|
variant: macosx
|
||||||
|
- target: aarch64-ios
|
||||||
|
variant: iphoneos
|
||||||
|
- target: arm64e-ios
|
||||||
|
variant: iphoneos
|
||||||
|
cpu: apple_a12
|
||||||
|
- target: aarch64-ios-simulator
|
||||||
|
variant: iphonesimulator
|
||||||
|
cpu: apple_a17
|
||||||
|
- target: x86_64-ios-simulator
|
||||||
|
variant: iphonesimulator
|
||||||
|
- target: aarch64-ios-macabi
|
||||||
|
variant: maccatalyst
|
||||||
|
cpu: apple_a17
|
||||||
|
- target: arm64e-ios-macabi
|
||||||
|
variant: maccatalyst
|
||||||
|
cpu: apple_a17
|
||||||
|
- target: x86_64-ios-macabi
|
||||||
|
variant: maccatalyst
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
|
- name: Setup Zig
|
||||||
|
uses: mlugg/setup-zig@v2
|
||||||
|
with:
|
||||||
|
version: 0.15.2
|
||||||
|
|
||||||
|
- name: Clone Ghostty
|
||||||
|
run: |
|
||||||
|
git clone https://github.com/ghostty-org/ghostty ./References/ghostty-upstream
|
||||||
|
git -C ./References/ghostty-upstream checkout "${{ needs.prepare.outputs.ghostty_ref }}"
|
||||||
|
|
||||||
|
- name: Build Target
|
||||||
|
run: |
|
||||||
|
export ZIG_CPU="${{ matrix.cpu }}"
|
||||||
|
./Script/build-ghostty.sh \
|
||||||
|
"$PWD/References/ghostty-upstream" \
|
||||||
|
"${{ matrix.target }}" \
|
||||||
|
"$PWD/build/out"
|
||||||
|
|
||||||
|
- name: Pack Artifact
|
||||||
|
run: tar -czf "target--${{ matrix.variant }}--${{ matrix.target }}.tar.gz" -C ./build/out .
|
||||||
|
|
||||||
|
- name: Upload Artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: target--${{ matrix.variant }}--${{ matrix.target }}
|
||||||
|
path: target--${{ matrix.variant }}--${{ matrix.target }}.tar.gz
|
||||||
|
compression-level: 0
|
||||||
|
retention-days: 1
|
||||||
|
|
||||||
|
merge-test-release:
|
||||||
|
needs: [prepare, build-target]
|
||||||
|
if: needs.prepare.outputs.build_needed == 'true'
|
||||||
|
runs-on: macos-26
|
||||||
|
timeout-minutes: 60
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Set up Xcode
|
||||||
|
run: sudo xcode-select -s /Applications/Xcode.app
|
||||||
|
|
||||||
|
- name: Download All Artifacts
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
pattern: target-*
|
||||||
|
path: ./build/tarballs
|
||||||
|
merge-multiple: true
|
||||||
|
|
||||||
|
- name: Assemble Variants
|
||||||
|
run: |
|
||||||
|
mkdir -p ./build/targets ./build/dest
|
||||||
|
|
||||||
|
for tarball in ./build/tarballs/target--*.tar.gz; do
|
||||||
|
BASENAME=$(basename "$tarball" .tar.gz)
|
||||||
|
VARIANT=$(echo "$BASENAME" | awk -F'--' '{print $2}')
|
||||||
|
TARGET=$(echo "$BASENAME" | awk -F'--' '{print $3}')
|
||||||
|
echo "[*] unpacking $BASENAME → variant=$VARIANT target=$TARGET"
|
||||||
|
mkdir -p "./build/targets/$VARIANT/$TARGET"
|
||||||
|
tar -xzf "$tarball" -C "./build/targets/$VARIANT/$TARGET"
|
||||||
|
done
|
||||||
|
|
||||||
|
for variant_dir in ./build/targets/*/; do
|
||||||
|
VARIANT=$(basename "$variant_dir")
|
||||||
|
mkdir -p "./build/dest/$VARIANT/lib" "./build/dest/$VARIANT/include"
|
||||||
|
|
||||||
|
LIBS=()
|
||||||
|
FIRST_HEADERS=
|
||||||
|
for target_dir in "$variant_dir"*/; do
|
||||||
|
LIBS+=("$target_dir/lib/libghostty.a")
|
||||||
|
if [ -z "$FIRST_HEADERS" ]; then
|
||||||
|
FIRST_HEADERS="$target_dir/include"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
cp -R "$FIRST_HEADERS/." "./build/dest/$VARIANT/include/"
|
||||||
|
|
||||||
|
if [ "${#LIBS[@]}" -eq 1 ]; then
|
||||||
|
cp "${LIBS[0]}" "./build/dest/$VARIANT/lib/libghostty.a"
|
||||||
|
echo "[*] $VARIANT: single arch"
|
||||||
|
else
|
||||||
|
lipo -create "${LIBS[@]}" -output "./build/dest/$VARIANT/lib/libghostty.a"
|
||||||
|
echo "[*] $VARIANT: lipo ${#LIBS[@]} archs"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
- name: Merge XCFramework
|
||||||
|
run: |
|
||||||
|
./Script/merge-xcframework.sh \
|
||||||
|
"$PWD/build/dest" \
|
||||||
|
"$PWD/BinaryTarget/GhosttyKit.xcframework" \
|
||||||
|
"$PWD/build/GhosttyKit.xcframework.zip"
|
||||||
|
|
||||||
|
- name: Test XCFramework Consumer
|
||||||
|
run: ./Script/test-xcframework-consumer.sh "$PWD/BinaryTarget/GhosttyKit.xcframework"
|
||||||
|
|
||||||
|
- name: Use Local Binary Target
|
||||||
|
run: cp Package.local.swift Package.swift
|
||||||
|
|
||||||
|
- name: Build Package Matrix
|
||||||
|
run: ./Script/test.sh
|
||||||
|
|
||||||
|
- name: Run Swift Tests
|
||||||
|
run: swift test
|
||||||
|
|
||||||
|
- name: Prepare Release
|
||||||
|
run: |
|
||||||
|
STORAGE_TAG="${{ needs.prepare.outputs.storage_tag }}"
|
||||||
|
DOWNLOAD_URL="https://github.com/${{ github.repository }}/releases/download/$STORAGE_TAG/GhosttyKit.xcframework.zip"
|
||||||
|
echo "[*] predicted download url: $DOWNLOAD_URL"
|
||||||
|
|
||||||
|
mv ./build/GhosttyKit.xcframework.zip /tmp/GhosttyKit.xcframework.zip
|
||||||
|
git clean -fdx -f
|
||||||
|
git reset --hard
|
||||||
|
mv /tmp/GhosttyKit.xcframework.zip ./GhosttyKit.xcframework.zip
|
||||||
|
./Script/build-manifest.sh GhosttyKit.xcframework.zip "$DOWNLOAD_URL"
|
||||||
|
|
||||||
|
- name: Commit & Push
|
||||||
|
run: |
|
||||||
|
git config user.name "github-actions[bot]"
|
||||||
|
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||||
|
git add -A
|
||||||
|
git commit -m "chore: autopublish $(date -u +%Y-%m-%dT%H:%M:%SZ)" || echo "No changes to commit"
|
||||||
|
git push
|
||||||
|
|
||||||
|
- name: Create Storage Release
|
||||||
|
uses: softprops/action-gh-release@v2
|
||||||
|
with:
|
||||||
|
tag_name: ${{ needs.prepare.outputs.storage_tag }}
|
||||||
|
target_commitish: ${{ github.ref_name }}
|
||||||
|
body: |
|
||||||
|
GhosttyKit XCFramework
|
||||||
|
|
||||||
|
Package version: ${{ needs.prepare.outputs.package_version }}
|
||||||
|
Ghostty commit: ${{ needs.prepare.outputs.resolved_sha }}
|
||||||
|
draft: false
|
||||||
|
prerelease: false
|
||||||
|
files: |
|
||||||
|
GhosttyKit.xcframework.zip
|
||||||
|
|
||||||
|
- name: Create Release
|
||||||
|
uses: softprops/action-gh-release@v2
|
||||||
|
with:
|
||||||
|
tag_name: ${{ needs.prepare.outputs.package_version }}
|
||||||
|
target_commitish: ${{ github.ref_name }}
|
||||||
|
body: |
|
||||||
|
GhosttyKit ${{ needs.prepare.outputs.package_version }}
|
||||||
|
|
||||||
|
Package version: ${{ needs.prepare.outputs.package_version }}
|
||||||
|
Ghostty commit: ${{ needs.prepare.outputs.resolved_sha }}
|
||||||
|
draft: false
|
||||||
|
prerelease: false
|
||||||
|
|
||||||
|
- name: Verify Release Consistency
|
||||||
|
run: ./Script/verify-release.sh "${{ needs.prepare.outputs.package_version }}" "${{ needs.prepare.outputs.storage_tag }}"
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
43
vendor/libghostty-spm/.github/workflows/pages.yml
vendored
Normal file
43
vendor/libghostty-spm/.github/workflows/pages.yml
vendored
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
name: Publish Docs
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
paths:
|
||||||
|
- docs/**
|
||||||
|
- .github/workflows/pages.yml
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
pages: write
|
||||||
|
id-token: write
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: pages
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
name: Deploy GitHub Pages
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
environment:
|
||||||
|
name: github-pages
|
||||||
|
url: ${{ steps.deployment.outputs.page_url }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
|
- name: Configure Pages
|
||||||
|
uses: actions/configure-pages@v5
|
||||||
|
with:
|
||||||
|
enablement: true
|
||||||
|
|
||||||
|
- name: Upload Pages Artifact
|
||||||
|
uses: actions/upload-pages-artifact@v3
|
||||||
|
with:
|
||||||
|
path: docs
|
||||||
|
|
||||||
|
- name: Deploy Pages
|
||||||
|
id: deployment
|
||||||
|
uses: actions/deploy-pages@v4
|
||||||
38
vendor/libghostty-spm/.github/workflows/pr.yml
vendored
Normal file
38
vendor/libghostty-spm/.github/workflows/pr.yml
vendored
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
name: PR Build & Test
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
types: [opened, synchronize, reopened]
|
||||||
|
|
||||||
|
# Zero-scope GITHUB_TOKEN: PR runs untrusted contributor code, so the
|
||||||
|
# token gets no permissions at all. Public repo, so checkout works
|
||||||
|
# without auth. No secrets referenced anywhere in this workflow.
|
||||||
|
permissions: {}
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: pr-${{ github.event.pull_request.number }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-test:
|
||||||
|
name: Build & Test
|
||||||
|
runs-on: macos-26
|
||||||
|
timeout-minutes: 30
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Set up Xcode
|
||||||
|
run: sudo xcode-select -s /Applications/Xcode.app
|
||||||
|
|
||||||
|
- name: Swift Version
|
||||||
|
run: swift --version
|
||||||
|
|
||||||
|
- name: Build Package Matrix
|
||||||
|
run: ./Script/test.sh
|
||||||
|
|
||||||
|
- name: Swift Test
|
||||||
|
run: swift test
|
||||||
186
vendor/libghostty-spm/.github/workflows/ui-tests.yml
vendored
Normal file
186
vendor/libghostty-spm/.github/workflows/ui-tests.yml
vendored
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
name: UI Tests
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
types: [opened, synchronize, reopened]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions: {}
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ui-tests-${{ github.event.pull_request.number || github.ref_name }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
ios-iphone:
|
||||||
|
name: iPhone Simulator UI Tests
|
||||||
|
runs-on: macos-26
|
||||||
|
timeout-minutes: 30
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Set up Xcode
|
||||||
|
run: xcodebuild -version
|
||||||
|
|
||||||
|
- name: Run iPhone UI Tests
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
rm -rf "$RUNNER_TEMP/libghostty-ui-ios.xcresult"
|
||||||
|
RUNTIME=$(xcrun simctl list runtimes available | awk '/^iOS .*com\.apple\.CoreSimulator\.SimRuntime\.iOS/ { runtime=$NF } END { print runtime }')
|
||||||
|
[ -n "$RUNTIME" ] || { echo "no available iOS simulator runtime"; exit 1; }
|
||||||
|
DEVICE=$(xcrun simctl list devicetypes | sed -n '/iPhone Air/s/.*(\(com\.apple\.CoreSimulator\.SimDeviceType\.[^)]*\)).*/\1/p' | head -1)
|
||||||
|
if [ -z "$DEVICE" ]; then
|
||||||
|
DEVICE=$(xcrun simctl list devicetypes | sed -n '/iPhone 16/s/.*(\(com\.apple\.CoreSimulator\.SimDeviceType\.[^)]*\)).*/\1/p' | head -1)
|
||||||
|
fi
|
||||||
|
if [ -z "$DEVICE" ]; then
|
||||||
|
DEVICE=$(xcrun simctl list devicetypes | sed -n '/iPhone/s/.*(\(com\.apple\.CoreSimulator\.SimDeviceType\.[^)]*\)).*/\1/p' | head -1)
|
||||||
|
fi
|
||||||
|
[ -n "$DEVICE" ] || { echo "no available iPhone simulator device type"; exit 1; }
|
||||||
|
UDID=$(xcrun simctl create "libghostty-ui-iPhone-${GITHUB_RUN_ID}" "$DEVICE" "$RUNTIME")
|
||||||
|
trap 'xcrun simctl shutdown "$UDID" >/dev/null 2>&1 || true; xcrun simctl delete "$UDID" >/dev/null 2>&1 || true' EXIT
|
||||||
|
# Hosted runner cleanup: avoid stale simulator windows stealing UI automation focus.
|
||||||
|
xcrun simctl shutdown all >/dev/null 2>&1 || true
|
||||||
|
xcrun simctl boot "$UDID"
|
||||||
|
xcrun simctl bootstatus "$UDID" -b
|
||||||
|
xcodebuild test \
|
||||||
|
-project Example/MobileGhosttyApp.xcodeproj \
|
||||||
|
-scheme MobileGhosttyApp \
|
||||||
|
-destination "id=$UDID" \
|
||||||
|
-resultBundlePath "$RUNNER_TEMP/libghostty-ui-ios.xcresult" \
|
||||||
|
CODE_SIGNING_ALLOWED=NO \
|
||||||
|
CODE_SIGNING_REQUIRED=NO \
|
||||||
|
CODE_SIGN_IDENTITY=""
|
||||||
|
|
||||||
|
- name: Upload iPhone Result Bundle
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: libghostty-ui-ios-xcresult
|
||||||
|
path: ${{ runner.temp }}/libghostty-ui-ios.xcresult
|
||||||
|
retention-days: 7
|
||||||
|
|
||||||
|
ios-ipad:
|
||||||
|
name: iPad Simulator UI Tests
|
||||||
|
runs-on: macos-26
|
||||||
|
timeout-minutes: 30
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Set up Xcode
|
||||||
|
run: xcodebuild -version
|
||||||
|
|
||||||
|
- name: Run iPad UI Tests
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
rm -rf "$RUNNER_TEMP/libghostty-ui-ipad.xcresult"
|
||||||
|
RUNTIME=$(xcrun simctl list runtimes available | awk '/^iOS .*com\.apple\.CoreSimulator\.SimRuntime\.iOS/ { runtime=$NF } END { print runtime }')
|
||||||
|
[ -n "$RUNTIME" ] || { echo "no available iOS simulator runtime"; exit 1; }
|
||||||
|
DEVICE=$(xcrun simctl list devicetypes | sed -n '/iPad Air 13-inch (M3)/s/.*(\(com\.apple\.CoreSimulator\.SimDeviceType\.[^)]*\)).*/\1/p' | head -1)
|
||||||
|
if [ -z "$DEVICE" ]; then
|
||||||
|
DEVICE=$(xcrun simctl list devicetypes | sed -n '/iPad Air 13-inch/s/.*(\(com\.apple\.CoreSimulator\.SimDeviceType\.[^)]*\)).*/\1/p' | head -1)
|
||||||
|
fi
|
||||||
|
if [ -z "$DEVICE" ]; then
|
||||||
|
DEVICE=$(xcrun simctl list devicetypes | sed -n '/iPad Pro 13-inch/s/.*(\(com\.apple\.CoreSimulator\.SimDeviceType\.[^)]*\)).*/\1/p' | head -1)
|
||||||
|
fi
|
||||||
|
if [ -z "$DEVICE" ]; then
|
||||||
|
DEVICE=$(xcrun simctl list devicetypes | sed -n '/iPad/s/.*(\(com\.apple\.CoreSimulator\.SimDeviceType\.[^)]*\)).*/\1/p' | head -1)
|
||||||
|
fi
|
||||||
|
[ -n "$DEVICE" ] || { echo "no available iPad simulator device type"; exit 1; }
|
||||||
|
UDID=$(xcrun simctl create "libghostty-ui-iPad-${GITHUB_RUN_ID}" "$DEVICE" "$RUNTIME")
|
||||||
|
trap 'xcrun simctl shutdown "$UDID" >/dev/null 2>&1 || true; xcrun simctl delete "$UDID" >/dev/null 2>&1 || true' EXIT
|
||||||
|
# Hosted runner cleanup: avoid stale simulator windows stealing UI automation focus.
|
||||||
|
xcrun simctl shutdown all >/dev/null 2>&1 || true
|
||||||
|
xcrun simctl boot "$UDID"
|
||||||
|
xcrun simctl bootstatus "$UDID" -b
|
||||||
|
xcodebuild test \
|
||||||
|
-project Example/MobileGhosttyApp.xcodeproj \
|
||||||
|
-scheme MobileGhosttyApp \
|
||||||
|
-destination "id=$UDID" \
|
||||||
|
-resultBundlePath "$RUNNER_TEMP/libghostty-ui-ipad.xcresult" \
|
||||||
|
CODE_SIGNING_ALLOWED=NO \
|
||||||
|
CODE_SIGNING_REQUIRED=NO \
|
||||||
|
CODE_SIGN_IDENTITY=""
|
||||||
|
|
||||||
|
- name: Upload iPad Result Bundle
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: libghostty-ui-ipad-xcresult
|
||||||
|
path: ${{ runner.temp }}/libghostty-ui-ipad.xcresult
|
||||||
|
retention-days: 7
|
||||||
|
|
||||||
|
mac-catalyst:
|
||||||
|
name: Mac Catalyst UI Tests
|
||||||
|
runs-on: macos-26
|
||||||
|
timeout-minutes: 30
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Set up Xcode
|
||||||
|
run: xcodebuild -version
|
||||||
|
|
||||||
|
- name: Run Mac Catalyst UI Tests
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
rm -rf "$RUNNER_TEMP/libghostty-ui-maccatalyst.xcresult"
|
||||||
|
xcodebuild test \
|
||||||
|
-project Example/MobileGhosttyApp.xcodeproj \
|
||||||
|
-scheme MobileGhosttyApp \
|
||||||
|
-destination 'platform=macOS,variant=Mac Catalyst' \
|
||||||
|
-resultBundlePath "$RUNNER_TEMP/libghostty-ui-maccatalyst.xcresult" \
|
||||||
|
MACOSX_DEPLOYMENT_TARGET=15.0 \
|
||||||
|
CODE_SIGNING_ALLOWED=NO \
|
||||||
|
CODE_SIGNING_REQUIRED=NO \
|
||||||
|
CODE_SIGN_IDENTITY=""
|
||||||
|
|
||||||
|
- name: Upload Mac Catalyst Result Bundle
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: libghostty-ui-maccatalyst-xcresult
|
||||||
|
path: ${{ runner.temp }}/libghostty-ui-maccatalyst.xcresult
|
||||||
|
retention-days: 7
|
||||||
|
|
||||||
|
macos-appkit:
|
||||||
|
name: macOS AppKit UI Tests
|
||||||
|
runs-on: macos-26
|
||||||
|
timeout-minutes: 30
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Set up Xcode
|
||||||
|
run: xcodebuild -version
|
||||||
|
|
||||||
|
- name: Run macOS AppKit UI Tests
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
rm -rf "$RUNNER_TEMP/libghostty-ui-macos.xcresult"
|
||||||
|
xcodebuild test \
|
||||||
|
-project Example/GhosttyTerminalApp.xcodeproj \
|
||||||
|
-scheme GhosttyTerminalApp \
|
||||||
|
-destination 'platform=macOS' \
|
||||||
|
-resultBundlePath "$RUNNER_TEMP/libghostty-ui-macos.xcresult" \
|
||||||
|
CODE_SIGNING_ALLOWED=NO \
|
||||||
|
CODE_SIGNING_REQUIRED=NO \
|
||||||
|
CODE_SIGN_IDENTITY=""
|
||||||
|
|
||||||
|
- name: Upload macOS AppKit Result Bundle
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: libghostty-ui-macos-xcresult
|
||||||
|
path: ${{ runner.temp }}/libghostty-ui-macos.xcresult
|
||||||
|
retention-days: 7
|
||||||
55
vendor/libghostty-spm/.gitignore
vendored
Normal file
55
vendor/libghostty-spm/.gitignore
vendored
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
# Build
|
||||||
|
.build/
|
||||||
|
build/
|
||||||
|
*.a
|
||||||
|
*.xcframework
|
||||||
|
*.xcframework.zip
|
||||||
|
binpack*
|
||||||
|
BinaryTarget/
|
||||||
|
|
||||||
|
# Xcode
|
||||||
|
*.xcodeproj
|
||||||
|
xcuserdata/
|
||||||
|
*.xcuserstate
|
||||||
|
.swiftpm/
|
||||||
|
|
||||||
|
# Swift Package Manager
|
||||||
|
Package.resolved
|
||||||
|
|
||||||
|
# Obj-C / Swift
|
||||||
|
*.hmap
|
||||||
|
|
||||||
|
# App Packaging
|
||||||
|
*.ipa
|
||||||
|
*.dSYM
|
||||||
|
*.dSYM.zip
|
||||||
|
|
||||||
|
# Playgrounds
|
||||||
|
timeline.xctimeline
|
||||||
|
playground.xcworkspace
|
||||||
|
|
||||||
|
# macOS
|
||||||
|
.DS_Store
|
||||||
|
__MACOSX/
|
||||||
|
._*
|
||||||
|
.AppleDouble
|
||||||
|
.LSOverride
|
||||||
|
Icon[]
|
||||||
|
.DocumentRevisions-V100
|
||||||
|
.fseventsd
|
||||||
|
.Spotlight-V100
|
||||||
|
.TemporaryItems
|
||||||
|
.Trashes
|
||||||
|
.VolumeIcon.icns
|
||||||
|
.com.apple.timemachine.donotpresent
|
||||||
|
.AppleDB
|
||||||
|
.AppleDesktop
|
||||||
|
Network Trash Folder
|
||||||
|
Temporary Items
|
||||||
|
.apdisk
|
||||||
|
|
||||||
|
# Project
|
||||||
|
/temp
|
||||||
|
/Packages
|
||||||
|
/References
|
||||||
|
/references
|
||||||
0
vendor/libghostty-spm/.root
vendored
Normal file
0
vendor/libghostty-spm/.root
vendored
Normal file
159
vendor/libghostty-spm/AGENTS.md
vendored
Normal file
159
vendor/libghostty-spm/AGENTS.md
vendored
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
SPM package wrapping Ghostty terminal emulator C library for Apple platforms (macOS 13+, iOS 15+, Mac Catalyst 15+). Four library products:
|
||||||
|
|
||||||
|
- **GhosttyKit** — minimal re-export of the libghostty C API (`@_exported import libghostty`)
|
||||||
|
- **GhosttyTerminal** — Swift wrapper: native views, SwiftUI integration, input handling, display link, host-managed I/O
|
||||||
|
- **GhosttyTheme** — 485 terminal color themes from iTerm2-Color-Schemes (MIT License, depends on GhosttyTerminal)
|
||||||
|
- **ShellCraftKit** — sandboxed shell emulation framework (depends on GhosttyTerminal)
|
||||||
|
|
||||||
|
Binary target: pre-built `libghostty` XCFramework. Dependency: MSDisplayLink ^2.1.0.
|
||||||
|
|
||||||
|
## Build & Test Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build the SPM package
|
||||||
|
swift build
|
||||||
|
|
||||||
|
# Run tests
|
||||||
|
swift test
|
||||||
|
|
||||||
|
# Multi-destination build verification (macOS, iOS, iOS Simulator, Mac Catalyst)
|
||||||
|
./Script/test.sh
|
||||||
|
|
||||||
|
# Build full XCFramework from Ghostty source (requires zig)
|
||||||
|
./build.sh
|
||||||
|
./build.sh --platforms macos,ios --source /path/to/ghostty --skip-tests
|
||||||
|
|
||||||
|
# Generate Package.swift for release
|
||||||
|
./Script/build-manifest.sh
|
||||||
|
|
||||||
|
# Regenerate GhosttyTheme Swift files from iTerm2-Color-Schemes
|
||||||
|
./Script/generate-themes.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
GhosttyKit (C API re-export)
|
||||||
|
└─ libghostty.a (Zig → static lib) + ghostty.h
|
||||||
|
|
||||||
|
GhosttyTerminal (Swift wrapper, ~40 files)
|
||||||
|
├─ Configuration/ Config structs, themes, color schemes, ghostty.conf rendering
|
||||||
|
├─ Controller/ TerminalController — app lifecycle, config, surface creation
|
||||||
|
├─ InMemory/ Sandbox-safe I/O backend (no PTY), C callback bridge
|
||||||
|
├─ Metrics/ Grid size, viewport dimensions, input/scroll modifiers
|
||||||
|
├─ Platform/AppKit/ macOS NSView: input, IME, key events
|
||||||
|
├─ Platform/UIKit/ iOS UIView: UITextInput, keyboard, touch/gesture, IME, input accessory bar
|
||||||
|
├─ State/ ObservableObject TerminalViewState (SwiftUI state container)
|
||||||
|
├─ Surface/ Metal rendering bridge, display link, surface lifecycle
|
||||||
|
└─ View/ SwiftUI TerminalSurfaceView + platform representables
|
||||||
|
|
||||||
|
GhosttyTheme (485 terminal color themes)
|
||||||
|
├─ GhosttyThemeDefinition — theme data model (name, colors, palette)
|
||||||
|
├─ GhosttyThemeCatalog — static catalog, search, lookup by name
|
||||||
|
├─ +TerminalConfiguration — bridge to TerminalConfiguration/TerminalTheme, isDark helper
|
||||||
|
└─ Themes/ — auto-generated Swift files (A-Z) from iTerm2-Color-Schemes
|
||||||
|
|
||||||
|
ShellCraftKit (~5 files)
|
||||||
|
├─ Definition/ ShellDefinition, SandboxShell, ShellCommand protocol
|
||||||
|
└─ Session/ ShellSession + Bridge + Engine
|
||||||
|
```
|
||||||
|
|
||||||
|
Key types: `TerminalViewState` (ObservableObject, SwiftUI entry point), `TerminalSurfaceView` (SwiftUI view), `TerminalView` (platform typealias: UITerminalView / AppTerminalView), `TerminalController`, `InMemoryTerminalSession`, `GhosttyThemeDefinition`, `GhosttyThemeCatalog`.
|
||||||
|
|
||||||
|
### Platform Branching
|
||||||
|
|
||||||
|
Use `#if canImport(UIKit)` FIRST, then `#else #if canImport(AppKit)` — Catalyst imports both UIKit and AppKit.
|
||||||
|
|
||||||
|
### Host-Managed I/O
|
||||||
|
|
||||||
|
All example apps run in App Sandbox. Use `GHOSTTY_SURFACE_IO_BACKEND_HOST_MANAGED` for non-PTY I/O. Never disable sandbox or spawn subprocesses.
|
||||||
|
|
||||||
|
### iOS Input Architecture (UITextInput)
|
||||||
|
|
||||||
|
`UITerminalView` conforms to `UITextInput` (which includes `UIKeyInput`) to receive both software keyboard and hardware keyboard input on iOS/Catalyst. The input chain:
|
||||||
|
|
||||||
|
1. **Hardware keys** → `pressesBegan`/`pressesEnded` in `+Keyboard.swift` → builds `ghostty_input_key_s` → `surface.sendKeyEvent()`. Sets `hardwareKeyHandled = true` to suppress the duplicate `insertText`/`deleteBackward` that UIKit would otherwise deliver.
|
||||||
|
2. **Software keyboard** → UIKit calls `insertText(_:)` / `deleteBackward()` via UIKeyInput. Guarded by `hardwareKeyHandled` flag to avoid double-processing hardware key presses.
|
||||||
|
3. **Input accessory bar** (iOS only, excludes Catalyst) → `TerminalInputAccessoryView` provides a toolbar above the software keyboard with Esc, Tab, arrow keys, modifier keys (Ctrl/Alt/Cmd), symbol keys, and Paste. Modifier keys support **sticky states**: tap to arm (consumed after next key), double-tap to lock (persists until toggled off). Sticky modifier state is tracked by `TerminalStickyModifierState`. Actions are dispatched via `UITerminalView+InputAccessory.swift`. Button colors are configurable via `TerminalInputAccessoryStyle` (regular/active background and foreground), exposed as `UITerminalView.inputAccessoryStyle`.
|
||||||
|
4. **IME / marked text** → `setMarkedText` / `unmarkText` delegate to `TerminalTextInputHandler`, which calls `surface.preedit()` for inline composition preview. Committed text goes through `insertText`. Sticky modifiers are respected during IME composition.
|
||||||
|
5. **Text positioning** → `TerminalTextPosition` / `TerminalTextRange` (UITextPosition/UITextRange subclasses) provide minimal cursor geometry. `caretRect`/`firstRect` use `surface.imePoint()` for IME candidate window placement.
|
||||||
|
|
||||||
|
Files in `Platform/UIKit/`:
|
||||||
|
|
||||||
|
- `UITerminalView.swift` — main view, `canBecomeFirstResponder`, coordinator setup
|
||||||
|
- `UITerminalView+UITextInput.swift` — full UITextInput conformance (UIKeyInput, marked text, positions, geometry)
|
||||||
|
- `UITerminalView+Keyboard.swift` — hardware key handling via UIPress, modifier translation
|
||||||
|
- `UITerminalView+InputAccessory.swift` — input accessory bar integration, key actions, sticky modifier dispatch
|
||||||
|
- `UITerminalView+Interaction.swift` — touch scrolling, momentum scroll via CADisplayLink, Catalyst pointer/mouse
|
||||||
|
- `UITerminalView+Lifecycle.swift` — display scale, sublayer frames, focus, color scheme
|
||||||
|
- `TerminalInputAccessoryView.swift` — input accessory bar UIView (blur background, scrollable button layout)
|
||||||
|
- `TerminalInputAccessoryStyle.swift` — configurable button colors for the accessory bar (regular/active background and foreground)
|
||||||
|
- `TerminalInputBarKey.swift` — enum defining accessory bar key types (esc, tab, arrows, symbols, paste)
|
||||||
|
- `TerminalStickyModifierState.swift` — modifier key state machine (inactive/armed/locked, double-tap locking)
|
||||||
|
- `TerminalTextInputHandler@UIKit.swift` — IME state machine (marked text, preedit bridge, sticky modifier support)
|
||||||
|
- `TerminalTextPosition.swift` — TerminalTextPosition / TerminalTextRange subclasses
|
||||||
|
|
||||||
|
The macOS equivalent uses `NSTextInputClient` in `AppTerminalView+NSTextInputClient.swift` with a parallel `TerminalTextInputHandler@AppKit.swift`.
|
||||||
|
|
||||||
|
### iOS Long-Press Text Selection
|
||||||
|
|
||||||
|
Long-press ≥0.5s on `UITerminalView` (single-finger, iOS only — Catalyst excluded) triggers `TerminalSurfaceTextSelectionRequestDelegate.terminalDidRequestTextSelection(_:)`. The host receives a `TerminalTextSelectionRequest` (viewport text snapshot + UTF-16 `NSRange?` for pre-selection + source point) and is expected to present a host UI (e.g. UITextView sheet). Word detection uses `ghostty_surface_quicklook_word` (Apple-only); `TerminalSelectionAnchor.resolveRange` maps the result to an `NSRange` via NSString UTF-16 calculations. Same-row duplicate occurrences are disambiguated by `pointX / cellWidthPoints`; callers must convert `cellPixels / displayScale → points` so ghostty's `tl_px_x/y` host-point units match. Prefix CJK full-width characters can shift cell-vs-UTF-16 columns and degrade disambiguation (ASCII-only correct, best-effort otherwise). The recognizer is gated by `gestureRecognizerShouldBegin` to stay inactive when no host has opted in. MVP supports only the `inMemory` backend.
|
||||||
|
|
||||||
|
In iPhone UI tests, synthesize ordinary terminal taps as explicitly short presses and verify `hasKeyboardFocus` before `typeText`; a loaded hosted runner can stretch `tap()` long enough for the selection recognizer to present its sheet. Keep the ordinary XCTest tap and typing path on iPad, where short presses do not reliably publish keyboard focus through accessibility.
|
||||||
|
|
||||||
|
### Manifest Sync
|
||||||
|
|
||||||
|
When changing SwiftPM products, targets, or test dependencies, update all three together:
|
||||||
|
|
||||||
|
- `Package.swift` — production manifest (remote XCFramework URL + checksum)
|
||||||
|
- `Package.local.swift` — local development (path-based binary target)
|
||||||
|
- `Package.swift.template` — CI template with `__DOWNLOAD_URL__` / `__CHECKSUM__` placeholders
|
||||||
|
|
||||||
|
### Release Versioning
|
||||||
|
|
||||||
|
- Bare semantic-version tags are GhosttyKit Swift package versions, independent of Ghostty upstream versions.
|
||||||
|
- `Ghostty.ref` pins release builds to one immutable upstream commit; update it in a reviewed change instead of inferring an upstream ref from the package version.
|
||||||
|
- `storage.<package-version>` owns the XCFramework asset referenced by the matching package tag.
|
||||||
|
- arm64e release slices must contain real ARM64E Mach-O members and adapt pointer-authenticated callback, Block, Objective-C IMP, and system callback boundaries; never satisfy the architecture by changing only a fat-archive label.
|
||||||
|
|
||||||
|
## Swift Code Style
|
||||||
|
|
||||||
|
- **4-space indentation**, opening brace on same line
|
||||||
|
- PascalCase types, camelCase properties/methods
|
||||||
|
- PascalCase files for types, `+` for extensions (e.g., `AppTerminalView+Input.swift`)
|
||||||
|
- **ObservableObject/@Published** for SwiftUI state that must support iOS 15 / Mac Catalyst 15
|
||||||
|
- **Swift concurrency**: async/await, Task, actor, @MainActor
|
||||||
|
- Early returns, guard statements, single responsibility per type/extension
|
||||||
|
- Value types over reference types, composition over inheritance
|
||||||
|
- Dependency injection over singletons
|
||||||
|
- Avoid protocol-oriented design unless necessary
|
||||||
|
- Split files frequently — keep files small and focused (~40-100 lines typical)
|
||||||
|
- Don't extract methods unnecessarily — avoid premature abstraction
|
||||||
|
|
||||||
|
## Shell Script Style
|
||||||
|
|
||||||
|
- Shebang: `#!/bin/zsh`, failure handling: `set -euo pipefail`
|
||||||
|
- Output: `[+]` success, `[-]` failure, lowercase messages
|
||||||
|
- Minimal comments, no color output, assume tools available
|
||||||
|
- Don't add if-checks when pipefail handles failures
|
||||||
|
|
||||||
|
## GhosttyKit Design Requirements
|
||||||
|
|
||||||
|
### Wrapper Design
|
||||||
|
|
||||||
|
- GhosttyTerminal must expose **all** functionality from `ghostty.h`
|
||||||
|
- Clean Swift APIs mapping to C API: config, app lifecycle, surfaces, input, clipboard, inspector, splits, mouse, IME, text selection
|
||||||
|
- Proper Swift patterns: enums for C enums, structs for C structs, closures for callbacks
|
||||||
|
|
||||||
|
### Example App Requirements
|
||||||
|
|
||||||
|
- Apps run in **App Sandbox** — must NOT spawn subprocesses (non-negotiable)
|
||||||
|
- Use mock terminal IO with real GhosttyTerminal surface/view layer
|
||||||
|
- Use host-managed I/O backend, never disable sandbox for PTY workarounds
|
||||||
|
- Keep echo terminal as self-contained module separate from GhosttyKit integration
|
||||||
1
vendor/libghostty-spm/CLAUDE.md
vendored
Symbolic link
1
vendor/libghostty-spm/CLAUDE.md
vendored
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
AGENTS.md
|
||||||
13
vendor/libghostty-spm/Example/GhosttyTerminalApp.xcworkspace/contents.xcworkspacedata
generated
vendored
Normal file
13
vendor/libghostty-spm/Example/GhosttyTerminalApp.xcworkspace/contents.xcworkspacedata
generated
vendored
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Workspace
|
||||||
|
version = "1.0">
|
||||||
|
<FileRef
|
||||||
|
location = "group:GhosttyTerminalApp.xcodeproj">
|
||||||
|
</FileRef>
|
||||||
|
<FileRef
|
||||||
|
location = "group:MobileGhosttyApp.xcodeproj">
|
||||||
|
</FileRef>
|
||||||
|
<FileRef
|
||||||
|
location = "group:../">
|
||||||
|
</FileRef>
|
||||||
|
</Workspace>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>IDEWorkspaceSharedSettings_AutocreateContextsIfNeeded</key>
|
||||||
|
<false/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
52
vendor/libghostty-spm/Example/GhosttyTerminalApp/AppDelegate.swift
vendored
Normal file
52
vendor/libghostty-spm/Example/GhosttyTerminalApp/AppDelegate.swift
vendored
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import Cocoa
|
||||||
|
import GhosttyTerminal
|
||||||
|
|
||||||
|
final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
|
private let defaultContentSize = NSSize(width: 720, height: 480)
|
||||||
|
private let minimumContentSize = NSSize(width: 480, height: 320)
|
||||||
|
private var window: NSWindow?
|
||||||
|
|
||||||
|
func applicationDidFinishLaunching(_: Notification) {
|
||||||
|
TerminalDebugLog.enable(.standard)
|
||||||
|
|
||||||
|
let window = NSWindow(
|
||||||
|
contentRect: NSRect(origin: .zero, size: defaultContentSize),
|
||||||
|
styleMask: [.titled, .closable, .miniaturizable, .resizable],
|
||||||
|
backing: .buffered,
|
||||||
|
defer: false
|
||||||
|
)
|
||||||
|
window.title = "GhosttyKit Sandbox Demo"
|
||||||
|
window.isOpaque = true
|
||||||
|
window.backgroundColor = .windowBackgroundColor
|
||||||
|
window.titlebarAppearsTransparent = false
|
||||||
|
window.contentMinSize = minimumContentSize
|
||||||
|
window.contentViewController = ViewController()
|
||||||
|
window.center()
|
||||||
|
window.makeKeyAndOrderFront(nil)
|
||||||
|
repairRestoredWindowSizeIfNeeded(window)
|
||||||
|
NSApp.activate(ignoringOtherApps: true)
|
||||||
|
self.window = window
|
||||||
|
}
|
||||||
|
|
||||||
|
func applicationSupportsSecureRestorableState(_: NSApplication) -> Bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
func applicationShouldTerminateAfterLastWindowClosed(_: NSApplication) -> Bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
private func repairRestoredWindowSizeIfNeeded(_ window: NSWindow) {
|
||||||
|
DispatchQueue.main.async { [defaultContentSize, minimumContentSize] in
|
||||||
|
let contentRect = window.contentRect(forFrameRect: window.frame)
|
||||||
|
guard contentRect.width < minimumContentSize.width
|
||||||
|
|| contentRect.height < minimumContentSize.height
|
||||||
|
else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
window.setContentSize(defaultContentSize)
|
||||||
|
window.center()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
11
vendor/libghostty-spm/Example/GhosttyTerminalApp/AppIcon.icon/Assets/singaporedollarsign.svg
vendored
Normal file
11
vendor/libghostty-spm/Example/GhosttyTerminalApp/AppIcon.icon/Assets/singaporedollarsign.svg
vendored
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!--Generator: Apple Native CoreSVG 341-->
|
||||||
|
<!DOCTYPE svg
|
||||||
|
PUBLIC "-//W3C//DTD SVG 1.1//EN"
|
||||||
|
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
|
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 403.625 361.875">
|
||||||
|
<g>
|
||||||
|
<rect height="361.875" opacity="0" width="403.625" x="0" y="0"/>
|
||||||
|
<path d="M93.5 332.625C146.625 332.625 185.75 302.875 185.75 249.25C185.75 201.25 154 181.875 110.625 168.625L86 161.25C56.125 152.25 31.5 137.5 31.5 105.125C31.5 70.375 59.625 51.5 90.875 51.5C121.125 51.5 148.25 68 154.25 100.75C155.75 107.75 159.875 111.375 166.875 111.375C173.875 111.375 179.25 107 179.25 99C179.25 96.375 178.875 93.25 178.125 90.25C168.875 49.625 132.375 27.25 91.25 27.25C42.75 27.25 4.625 58.625 4.625 106.5C4.625 151.75 34 173.5 77.5 186.625L102.25 194.25C134.25 203.875 158.75 218.375 158.75 251.75C158.75 287.25 130.625 308.375 94.625 308.375C58.625 308.375 31 289.5 24.75 255C22.875 247.375 18.75 244.25 12.125 244.25C4.875 244.25 0 249.5 0 257C0 261.75 0.625 265.75 1.75 269.625C11.875 309.75 49.25 332.625 93.5 332.625ZM314.375 322.625C362.875 322.625 403.625 297.125 403.625 248C403.625 204 372.25 186 334.125 171.75L306.625 161.5C281.75 152.5 254.5 137.75 254.5 108C254.5 77.25 282.5 60.875 313.5 60.875C345.75 60.875 367.625 78.25 373.25 110.25C375 117.25 379.25 121.625 386.5 121.625C394.25 121.625 399 116.875 399 109.25C399 107.25 398.875 103.75 398.125 100.125C390.25 58.5 354.75 37.375 313.5 37.375C268.125 37.375 228.125 63.375 228.125 109.375C228.125 149.5 257.75 170.25 295.875 184.5L323.375 194.875C352.375 205.375 377.5 218.75 377.5 250.125C377.5 282.875 350.25 299.125 314.5 299.125C278.625 299.125 253.875 282 248.875 246.75C247.625 239.75 242.875 235.125 235.625 235.125C228.625 235.125 223 239.75 223 247.875C223 249.75 223.25 251.875 223.5 253.75C231.125 300.625 269.375 322.625 314.375 322.625ZM313.25 361.75C318.25 361.75 322.375 357.625 322.375 352.75L322.375 9C322.375 4.125 318.25 0 313.25 0C308.25 0 304.125 4.125 304.125 9L304.125 352.75C304.125 357.625 308.25 361.75 313.25 361.75Z" fill="white" fill-opacity="0.85"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.1 KiB |
30
vendor/libghostty-spm/Example/GhosttyTerminalApp/AppIcon.icon/icon.json
vendored
Normal file
30
vendor/libghostty-spm/Example/GhosttyTerminalApp/AppIcon.icon/icon.json
vendored
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"fill" : {
|
||||||
|
"automatic-gradient" : "srgb:1.00000,0.54098,0.84731,1.00000"
|
||||||
|
},
|
||||||
|
"groups" : [
|
||||||
|
{
|
||||||
|
"layers" : [
|
||||||
|
{
|
||||||
|
"glass" : false,
|
||||||
|
"image-name" : "singaporedollarsign.svg",
|
||||||
|
"name" : "singaporedollarsign"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"shadow" : {
|
||||||
|
"kind" : "neutral",
|
||||||
|
"opacity" : 0.5
|
||||||
|
},
|
||||||
|
"translucency" : {
|
||||||
|
"enabled" : true,
|
||||||
|
"value" : 0.5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"supported-platforms" : {
|
||||||
|
"circles" : [
|
||||||
|
"watchOS"
|
||||||
|
],
|
||||||
|
"squares" : "shared"
|
||||||
|
}
|
||||||
|
}
|
||||||
10
vendor/libghostty-spm/Example/GhosttyTerminalApp/Entitlement.entitlements
vendored
Normal file
10
vendor/libghostty-spm/Example/GhosttyTerminalApp/Entitlement.entitlements
vendored
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>com.apple.security.app-sandbox</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.security.files.user-selected.read-only</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
108
vendor/libghostty-spm/Example/GhosttyTerminalApp/ViewController.swift
vendored
Normal file
108
vendor/libghostty-spm/Example/GhosttyTerminalApp/ViewController.swift
vendored
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import Cocoa
|
||||||
|
import GhosttyTerminal
|
||||||
|
import ShellCraftKit
|
||||||
|
|
||||||
|
private final class AppearanceAwareView: NSView {
|
||||||
|
var onAppearanceChange: (() -> Void)?
|
||||||
|
|
||||||
|
override func viewDidChangeEffectiveAppearance() {
|
||||||
|
super.viewDidChangeEffectiveAppearance()
|
||||||
|
onAppearanceChange?()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class ViewController: NSViewController {
|
||||||
|
private lazy var terminalView: TerminalView = .init(
|
||||||
|
frame: NSRect(x: 0, y: 0, width: 720, height: 480)
|
||||||
|
)
|
||||||
|
|
||||||
|
private lazy var shellSession: ShellSession = .init(shell: defaultSandboxShell)
|
||||||
|
|
||||||
|
private lazy var controller: TerminalController = .init { builder in
|
||||||
|
builder.withBackgroundOpacity(0)
|
||||||
|
builder.withCustom("keybind", "super+k=text:\\x0c")
|
||||||
|
}
|
||||||
|
|
||||||
|
override func loadView() {
|
||||||
|
let container = AppearanceAwareView()
|
||||||
|
container.onAppearanceChange = { [weak self] in
|
||||||
|
self?.applyWindowBackgroundColor()
|
||||||
|
}
|
||||||
|
view = container
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
configureView()
|
||||||
|
configureTerminalView()
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidAppear() {
|
||||||
|
super.viewDidAppear()
|
||||||
|
activateTerminal()
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidLayout() {
|
||||||
|
super.viewDidLayout()
|
||||||
|
terminalView.fitToSize()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func configureView() {
|
||||||
|
view.wantsLayer = true
|
||||||
|
applyWindowBackgroundColor()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyWindowBackgroundColor() {
|
||||||
|
// `NSColor.windowBackgroundColor.cgColor` snapshots whichever
|
||||||
|
// appearance is current at the call site, so drawing it naively
|
||||||
|
// caches yesterday's light/dark value on the layer. Resolve it
|
||||||
|
// under the view's effective appearance so the layer follows
|
||||||
|
// system toggles.
|
||||||
|
view.effectiveAppearance.performAsCurrentDrawingAppearance {
|
||||||
|
view.layer?.backgroundColor = NSColor.windowBackgroundColor.cgColor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func configureTerminalView() {
|
||||||
|
terminalView.delegate = self
|
||||||
|
terminalView.setAccessibilityElement(true)
|
||||||
|
terminalView.setAccessibilityIdentifier("terminal.surface")
|
||||||
|
terminalView.setAccessibilityLabel("Terminal Surface")
|
||||||
|
terminalView.configuration = TerminalSurfaceOptions(
|
||||||
|
backend: .inMemory(shellSession.terminalSession)
|
||||||
|
)
|
||||||
|
terminalView.controller = controller
|
||||||
|
terminalView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
view.addSubview(terminalView)
|
||||||
|
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
terminalView.topAnchor.constraint(equalTo: view.topAnchor),
|
||||||
|
terminalView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||||
|
terminalView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||||
|
terminalView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
private func activateTerminal() {
|
||||||
|
view.window?.makeFirstResponder(terminalView)
|
||||||
|
shellSession.start()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Terminal Callbacks
|
||||||
|
|
||||||
|
extension ViewController:
|
||||||
|
TerminalSurfaceTitleDelegate,
|
||||||
|
TerminalSurfaceResizeDelegate,
|
||||||
|
TerminalSurfaceCloseDelegate
|
||||||
|
{
|
||||||
|
func terminalDidChangeTitle(_ title: String) {
|
||||||
|
view.window?.title = title
|
||||||
|
}
|
||||||
|
|
||||||
|
func terminalDidResize(columns _: Int, rows _: Int) {}
|
||||||
|
|
||||||
|
func terminalDidClose(processAlive _: Bool) {
|
||||||
|
view.window?.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
17
vendor/libghostty-spm/Example/GhosttyTerminalApp/main.swift
vendored
Normal file
17
vendor/libghostty-spm/Example/GhosttyTerminalApp/main.swift
vendored
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
//
|
||||||
|
// main.swift
|
||||||
|
// GhosttyTerminalApp
|
||||||
|
//
|
||||||
|
// Created by qaq on 19/3/2026.
|
||||||
|
//
|
||||||
|
|
||||||
|
import AppKit
|
||||||
|
|
||||||
|
MainActor.assumeIsolated {
|
||||||
|
let delegate = AppDelegate()
|
||||||
|
let app = NSApplication.shared
|
||||||
|
app.setActivationPolicy(.regular)
|
||||||
|
app.delegate = delegate
|
||||||
|
app.run()
|
||||||
|
fatalError()
|
||||||
|
}
|
||||||
178
vendor/libghostty-spm/Example/GhosttyTerminalAppUITests/GhosttyTerminalAppUITests.swift
vendored
Normal file
178
vendor/libghostty-spm/Example/GhosttyTerminalAppUITests/GhosttyTerminalAppUITests.swift
vendored
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
import XCTest
|
||||||
|
import AppKit
|
||||||
|
|
||||||
|
final class GhosttyTerminalAppUITests: XCTestCase {
|
||||||
|
private var app: XCUIApplication!
|
||||||
|
private var systemAlertMonitor: NSObjectProtocol?
|
||||||
|
|
||||||
|
override func setUpWithError() throws {
|
||||||
|
continueAfterFailure = false
|
||||||
|
app = XCUIApplication()
|
||||||
|
app.launchArguments = ["--ui-testing"]
|
||||||
|
systemAlertMonitor = installSystemAlertHandler()
|
||||||
|
app.launch()
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tearDownWithError() throws {
|
||||||
|
capture("final-state")
|
||||||
|
app = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func testTerminalUserOperations() throws {
|
||||||
|
let terminal = try requireTerminalInteractionTarget()
|
||||||
|
|
||||||
|
capture("01-launch")
|
||||||
|
clickTerminal(in: terminal)
|
||||||
|
capture("02-focus")
|
||||||
|
|
||||||
|
typeTerminalText("echo mac-single\n", in: terminal)
|
||||||
|
capture("03-single-line-input")
|
||||||
|
|
||||||
|
typeTerminalText("echo mac first line\n", in: terminal)
|
||||||
|
typeTerminalText("echo mac second line\n", in: terminal)
|
||||||
|
capture("04-multiple-lines")
|
||||||
|
|
||||||
|
typeTerminalText("中文键盘测试,标点和全角字符。\n", in: terminal)
|
||||||
|
capture("05-chinese-input")
|
||||||
|
|
||||||
|
typeTerminalText("日本語キーボードテスト、かなと漢字。\n", in: terminal)
|
||||||
|
capture("06-japanese-input")
|
||||||
|
|
||||||
|
typeTerminalText("Mixed input: English 中文 日本語 123\n", in: terminal)
|
||||||
|
capture("07-multilingual-input")
|
||||||
|
|
||||||
|
terminal.swipeUp()
|
||||||
|
capture("08-swipe-up")
|
||||||
|
terminal.swipeDown()
|
||||||
|
capture("09-swipe-down")
|
||||||
|
|
||||||
|
app.typeKey("=", modifierFlags: .command)
|
||||||
|
capture("10-keyboard-zoom-in")
|
||||||
|
app.typeKey("-", modifierFlags: .command)
|
||||||
|
capture("11-keyboard-zoom-out")
|
||||||
|
|
||||||
|
typeTerminalText("clear\n", in: terminal)
|
||||||
|
capture("12-clear-command")
|
||||||
|
|
||||||
|
assertNoCopyMenuWithoutSelection(in: terminal)
|
||||||
|
typeTerminalText("echo selection anchor\n", in: terminal)
|
||||||
|
dragPointerSelection(in: terminal)
|
||||||
|
capture("13-pointer-selection")
|
||||||
|
openCopyMenuAndCopySelection(in: terminal, screenshotName: "14-pointer-copy-menu")
|
||||||
|
longPressTerminal(in: terminal)
|
||||||
|
capture("15-long-press")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func requireTerminalInteractionTarget() throws -> XCUIElement {
|
||||||
|
let terminal = app.descendants(matching: .any)["terminal.surface"].firstMatch
|
||||||
|
if terminal.waitForExistence(timeout: 4), terminal.isHittable {
|
||||||
|
return terminal
|
||||||
|
}
|
||||||
|
|
||||||
|
let window = app.windows.firstMatch
|
||||||
|
XCTAssertTrue(window.waitForExistence(timeout: 8))
|
||||||
|
XCTAssertTrue(window.isHittable)
|
||||||
|
return window
|
||||||
|
}
|
||||||
|
|
||||||
|
private func clickTerminal(in element: XCUIElement) {
|
||||||
|
element.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.55)).click()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func installSystemAlertHandler() -> NSObjectProtocol {
|
||||||
|
addUIInterruptionMonitor(withDescription: "System alert") { alert in
|
||||||
|
let preferredButtons = [
|
||||||
|
"OK", "Ok", "好", "确定", "允许", "Allow", "继续", "Continue",
|
||||||
|
"关闭", "Close", "Dismiss",
|
||||||
|
]
|
||||||
|
for title in preferredButtons {
|
||||||
|
let button = alert.buttons[title].firstMatch
|
||||||
|
if button.exists {
|
||||||
|
button.click()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func typeTerminalText(_ text: String, in element: XCUIElement) {
|
||||||
|
clickTerminal(in: element)
|
||||||
|
element.typeText(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func longPressTerminal(in element: XCUIElement) {
|
||||||
|
element.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.55)).press(forDuration: 0.7)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func dragPointerSelection(in element: XCUIElement) {
|
||||||
|
log("pointer-selection-coordinates", "start=(0.008, 0.10), end=(0.42, 0.10), rightClick=(0.20, 0.10)")
|
||||||
|
let start = element.coordinate(withNormalizedOffset: CGVector(dx: 0.008, dy: 0.10))
|
||||||
|
let end = element.coordinate(withNormalizedOffset: CGVector(dx: 0.42, dy: 0.10))
|
||||||
|
start.press(forDuration: 0.1, thenDragTo: end)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func openCopyMenuAndCopySelection(in element: XCUIElement, screenshotName: String) {
|
||||||
|
NSPasteboard.general.clearContents()
|
||||||
|
disableSystemAlertMonitorBeforeContextMenu()
|
||||||
|
element.coordinate(withNormalizedOffset: CGVector(dx: 0.20, dy: 0.10)).rightClick()
|
||||||
|
let copy = copyMenuItem()
|
||||||
|
if !copy.waitForExistence(timeout: 3) {
|
||||||
|
capture("\(screenshotName)-missing")
|
||||||
|
XCTFail("Copy menu item not found after pointer selection right click. Hierarchy: \(app.debugDescription)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
copy.click()
|
||||||
|
capture(screenshotName)
|
||||||
|
let actual = copiedPasteboardText(timeout: 2)
|
||||||
|
log("pointer-selection-pasteboard", actual ?? "<nil>")
|
||||||
|
XCTAssertEqual(actual, "selection anchor")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func assertNoCopyMenuWithoutSelection(in element: XCUIElement) {
|
||||||
|
disableSystemAlertMonitorBeforeContextMenu()
|
||||||
|
element.coordinate(withNormalizedOffset: CGVector(dx: 0.20, dy: 0.10)).rightClick()
|
||||||
|
XCTAssertFalse(
|
||||||
|
copyMenuItem().waitForExistence(timeout: 0.5),
|
||||||
|
"Copy menu item appeared without an active terminal selection."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func disableSystemAlertMonitorBeforeContextMenu() {
|
||||||
|
if let systemAlertMonitor {
|
||||||
|
removeUIInterruptionMonitor(systemAlertMonitor)
|
||||||
|
self.systemAlertMonitor = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func copyMenuItem() -> XCUIElement {
|
||||||
|
app.menuItems["Copy"].firstMatch
|
||||||
|
}
|
||||||
|
|
||||||
|
private func copiedPasteboardText(timeout: TimeInterval) -> String? {
|
||||||
|
let deadline = Date().addingTimeInterval(timeout)
|
||||||
|
repeat {
|
||||||
|
if let string = NSPasteboard.general.string(forType: .string) {
|
||||||
|
return string
|
||||||
|
}
|
||||||
|
RunLoop.current.run(mode: .default, before: Date().addingTimeInterval(0.05))
|
||||||
|
} while Date() < deadline
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func log(_ name: String, _ value: String) {
|
||||||
|
let attachment = XCTAttachment(string: value)
|
||||||
|
attachment.name = name
|
||||||
|
attachment.lifetime = .keepAlways
|
||||||
|
add(attachment)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func capture(_ name: String) {
|
||||||
|
guard let app else { return }
|
||||||
|
let attachment = XCTAttachment(screenshot: app.screenshot())
|
||||||
|
attachment.name = name
|
||||||
|
attachment.lifetime = .keepAlways
|
||||||
|
add(attachment)
|
||||||
|
}
|
||||||
|
}
|
||||||
29
vendor/libghostty-spm/Example/MobileGhosttyApp/AppDelegate.swift
vendored
Normal file
29
vendor/libghostty-spm/Example/MobileGhosttyApp/AppDelegate.swift
vendored
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import GhosttyTerminal
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
@main
|
||||||
|
final class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||||
|
func application(
|
||||||
|
_: UIApplication,
|
||||||
|
didFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]? = nil
|
||||||
|
) -> Bool {
|
||||||
|
TerminalDebugLog.enable(.standard)
|
||||||
|
TerminalDebugLog.sink = { message in
|
||||||
|
NSLog("%@", message)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func application(
|
||||||
|
_: UIApplication,
|
||||||
|
configurationForConnecting connectingSceneSession: UISceneSession,
|
||||||
|
options _: UIScene.ConnectionOptions
|
||||||
|
) -> UISceneConfiguration {
|
||||||
|
let config = UISceneConfiguration(
|
||||||
|
name: "Default Configuration",
|
||||||
|
sessionRole: connectingSceneSession.role
|
||||||
|
)
|
||||||
|
config.delegateClass = SceneDelegate.self
|
||||||
|
return config
|
||||||
|
}
|
||||||
|
}
|
||||||
11
vendor/libghostty-spm/Example/MobileGhosttyApp/AppIcon.icon/Assets/singaporedollarsign.svg
vendored
Normal file
11
vendor/libghostty-spm/Example/MobileGhosttyApp/AppIcon.icon/Assets/singaporedollarsign.svg
vendored
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!--Generator: Apple Native CoreSVG 341-->
|
||||||
|
<!DOCTYPE svg
|
||||||
|
PUBLIC "-//W3C//DTD SVG 1.1//EN"
|
||||||
|
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
|
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 403.625 361.875">
|
||||||
|
<g>
|
||||||
|
<rect height="361.875" opacity="0" width="403.625" x="0" y="0"/>
|
||||||
|
<path d="M93.5 332.625C146.625 332.625 185.75 302.875 185.75 249.25C185.75 201.25 154 181.875 110.625 168.625L86 161.25C56.125 152.25 31.5 137.5 31.5 105.125C31.5 70.375 59.625 51.5 90.875 51.5C121.125 51.5 148.25 68 154.25 100.75C155.75 107.75 159.875 111.375 166.875 111.375C173.875 111.375 179.25 107 179.25 99C179.25 96.375 178.875 93.25 178.125 90.25C168.875 49.625 132.375 27.25 91.25 27.25C42.75 27.25 4.625 58.625 4.625 106.5C4.625 151.75 34 173.5 77.5 186.625L102.25 194.25C134.25 203.875 158.75 218.375 158.75 251.75C158.75 287.25 130.625 308.375 94.625 308.375C58.625 308.375 31 289.5 24.75 255C22.875 247.375 18.75 244.25 12.125 244.25C4.875 244.25 0 249.5 0 257C0 261.75 0.625 265.75 1.75 269.625C11.875 309.75 49.25 332.625 93.5 332.625ZM314.375 322.625C362.875 322.625 403.625 297.125 403.625 248C403.625 204 372.25 186 334.125 171.75L306.625 161.5C281.75 152.5 254.5 137.75 254.5 108C254.5 77.25 282.5 60.875 313.5 60.875C345.75 60.875 367.625 78.25 373.25 110.25C375 117.25 379.25 121.625 386.5 121.625C394.25 121.625 399 116.875 399 109.25C399 107.25 398.875 103.75 398.125 100.125C390.25 58.5 354.75 37.375 313.5 37.375C268.125 37.375 228.125 63.375 228.125 109.375C228.125 149.5 257.75 170.25 295.875 184.5L323.375 194.875C352.375 205.375 377.5 218.75 377.5 250.125C377.5 282.875 350.25 299.125 314.5 299.125C278.625 299.125 253.875 282 248.875 246.75C247.625 239.75 242.875 235.125 235.625 235.125C228.625 235.125 223 239.75 223 247.875C223 249.75 223.25 251.875 223.5 253.75C231.125 300.625 269.375 322.625 314.375 322.625ZM313.25 361.75C318.25 361.75 322.375 357.625 322.375 352.75L322.375 9C322.375 4.125 318.25 0 313.25 0C308.25 0 304.125 4.125 304.125 9L304.125 352.75C304.125 357.625 308.25 361.75 313.25 361.75Z" fill="white" fill-opacity="0.85"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.1 KiB |
30
vendor/libghostty-spm/Example/MobileGhosttyApp/AppIcon.icon/icon.json
vendored
Normal file
30
vendor/libghostty-spm/Example/MobileGhosttyApp/AppIcon.icon/icon.json
vendored
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"fill" : {
|
||||||
|
"automatic-gradient" : "srgb:1.00000,0.54098,0.84731,1.00000"
|
||||||
|
},
|
||||||
|
"groups" : [
|
||||||
|
{
|
||||||
|
"layers" : [
|
||||||
|
{
|
||||||
|
"glass" : false,
|
||||||
|
"image-name" : "singaporedollarsign.svg",
|
||||||
|
"name" : "singaporedollarsign"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"shadow" : {
|
||||||
|
"kind" : "neutral",
|
||||||
|
"opacity" : 0.5
|
||||||
|
},
|
||||||
|
"translucency" : {
|
||||||
|
"enabled" : true,
|
||||||
|
"value" : 0.5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"supported-platforms" : {
|
||||||
|
"circles" : [
|
||||||
|
"watchOS"
|
||||||
|
],
|
||||||
|
"squares" : "shared"
|
||||||
|
}
|
||||||
|
}
|
||||||
29
vendor/libghostty-spm/Example/MobileGhosttyApp/ApplicationExitController.swift
vendored
Normal file
29
vendor/libghostty-spm/Example/MobileGhosttyApp/ApplicationExitController.swift
vendored
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import UIKit
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
enum ApplicationExitController {
|
||||||
|
private static var hasRequestedExit = false
|
||||||
|
|
||||||
|
static func requestExit() {
|
||||||
|
guard !hasRequestedExit else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
hasRequestedExit = true
|
||||||
|
suspendIfAvailable()
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
|
||||||
|
exit(EXIT_SUCCESS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func suspendIfAvailable() {
|
||||||
|
let selector = NSSelectorFromString("suspend")
|
||||||
|
let application = UIApplication.shared
|
||||||
|
|
||||||
|
guard application.responds(to: selector) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = application.perform(selector)
|
||||||
|
}
|
||||||
|
}
|
||||||
21
vendor/libghostty-spm/Example/MobileGhosttyApp/SceneDelegate.swift
vendored
Normal file
21
vendor/libghostty-spm/Example/MobileGhosttyApp/SceneDelegate.swift
vendored
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import UIKit
|
||||||
|
|
||||||
|
final class SceneDelegate: UIResponder, UIWindowSceneDelegate {
|
||||||
|
var window: UIWindow?
|
||||||
|
|
||||||
|
func scene(
|
||||||
|
_ scene: UIScene,
|
||||||
|
willConnectTo _: UISceneSession,
|
||||||
|
options _: UIScene.ConnectionOptions
|
||||||
|
) {
|
||||||
|
guard let windowScene = scene as? UIWindowScene else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let window = UIWindow(windowScene: windowScene)
|
||||||
|
window.backgroundColor = .systemBackground
|
||||||
|
window.rootViewController = UINavigationController(rootViewController: ViewController())
|
||||||
|
window.makeKeyAndVisible()
|
||||||
|
self.window = window
|
||||||
|
}
|
||||||
|
}
|
||||||
86
vendor/libghostty-spm/Example/MobileGhosttyApp/TerminalSelectionViewController.swift
vendored
Normal file
86
vendor/libghostty-spm/Example/MobileGhosttyApp/TerminalSelectionViewController.swift
vendored
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import UIKit
|
||||||
|
|
||||||
|
final class TerminalSelectionViewController: UIViewController {
|
||||||
|
private let pendingText: String
|
||||||
|
private let pendingAnchorRange: NSRange?
|
||||||
|
|
||||||
|
var onDone: (() -> Void)?
|
||||||
|
|
||||||
|
private lazy var textView: UITextView = {
|
||||||
|
let view = UITextView()
|
||||||
|
view.isEditable = false
|
||||||
|
view.isSelectable = true
|
||||||
|
view.accessibilityIdentifier = "terminal.selectionTextView"
|
||||||
|
view.alwaysBounceVertical = true
|
||||||
|
view.font = .monospacedSystemFont(ofSize: 14, weight: .regular)
|
||||||
|
view.backgroundColor = .clear
|
||||||
|
view.textColor = .label
|
||||||
|
view.textContainerInset = .init(top: 12, left: 12, bottom: 12, right: 12)
|
||||||
|
view.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
return view
|
||||||
|
}()
|
||||||
|
|
||||||
|
init(text: String, anchorRange: NSRange?) {
|
||||||
|
pendingText = text
|
||||||
|
pendingAnchorRange = anchorRange
|
||||||
|
super.init(nibName: nil, bundle: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder _: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
view.backgroundColor = .clear
|
||||||
|
|
||||||
|
textView.text = pendingText
|
||||||
|
view.addSubview(textView)
|
||||||
|
|
||||||
|
// Pin to view edges (not safe area) so scrolled content extends
|
||||||
|
// under the transparent navigation bar / home indicator the way
|
||||||
|
// iOS sheets expect. UIKit's contentInsetAdjustmentBehavior keeps
|
||||||
|
// the initial scroll position below the nav bar via
|
||||||
|
// adjustedContentInset.
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
textView.topAnchor.constraint(equalTo: view.topAnchor),
|
||||||
|
textView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||||
|
textView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||||
|
textView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||||
|
])
|
||||||
|
|
||||||
|
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
||||||
|
barButtonSystemItem: .done,
|
||||||
|
target: self,
|
||||||
|
action: #selector(handleDone)
|
||||||
|
)
|
||||||
|
navigationItem.rightBarButtonItem?.accessibilityIdentifier = "terminal.selectionDoneButton"
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewWillAppear(_ animated: Bool) {
|
||||||
|
super.viewWillAppear(animated)
|
||||||
|
navigationController?.view.backgroundColor = .clear
|
||||||
|
navigationController?.navigationBar.standardAppearance.configureWithTransparentBackground()
|
||||||
|
navigationController?.navigationBar.scrollEdgeAppearance = navigationController?.navigationBar.standardAppearance
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidAppear(_ animated: Bool) {
|
||||||
|
super.viewDidAppear(animated)
|
||||||
|
textView.becomeFirstResponder()
|
||||||
|
|
||||||
|
let nsText = textView.text as NSString
|
||||||
|
if let range = pendingAnchorRange, NSMaxRange(range) <= nsText.length {
|
||||||
|
textView.selectedRange = range
|
||||||
|
textView.scrollRangeToVisible(range)
|
||||||
|
} else {
|
||||||
|
textView.selectAll(nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func handleDone() {
|
||||||
|
dismiss(animated: true) { [weak self] in
|
||||||
|
self?.onDone?()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
262
vendor/libghostty-spm/Example/MobileGhosttyApp/ViewController.swift
vendored
Normal file
262
vendor/libghostty-spm/Example/MobileGhosttyApp/ViewController.swift
vendored
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
import GhosttyTerminal
|
||||||
|
import GhosttyTheme
|
||||||
|
import ShellCraftKit
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
final class ViewController: UIViewController {
|
||||||
|
private static let lightThemeKey = "SelectedTheme.light"
|
||||||
|
private static let darkThemeKey = "SelectedTheme.dark"
|
||||||
|
|
||||||
|
private lazy var terminalView: TerminalView = .init(frame: .zero)
|
||||||
|
private lazy var shellSession: ShellSession = .init(shell: defaultSandboxShell)
|
||||||
|
private lazy var controller: TerminalController = .init(
|
||||||
|
theme: Self.savedTerminalTheme()
|
||||||
|
) { builder in
|
||||||
|
builder.withBackgroundOpacity(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
title = "Terminal"
|
||||||
|
view.backgroundColor = .systemBackground
|
||||||
|
view.isOpaque = true
|
||||||
|
configureTerminalView()
|
||||||
|
configureThemeMenu()
|
||||||
|
applyBackgroundForCurrentAppearance()
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidAppear(_ animated: Bool) {
|
||||||
|
super.viewDidAppear(animated)
|
||||||
|
activateTerminal()
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidLayoutSubviews() {
|
||||||
|
super.viewDidLayoutSubviews()
|
||||||
|
terminalView.fitToSize()
|
||||||
|
}
|
||||||
|
|
||||||
|
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
|
||||||
|
super.traitCollectionDidChange(previousTraitCollection)
|
||||||
|
guard traitCollection.hasDifferentColorAppearance(comparedTo: previousTraitCollection) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
controller.setTheme(Self.savedTerminalTheme())
|
||||||
|
applyBackgroundForCurrentAppearance()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func configureTerminalView() {
|
||||||
|
terminalView.delegate = self
|
||||||
|
terminalView.isAccessibilityElement = true
|
||||||
|
terminalView.accessibilityIdentifier = "terminal.surface"
|
||||||
|
terminalView.accessibilityLabel = "Terminal Surface"
|
||||||
|
terminalView.configuration = TerminalSurfaceOptions(
|
||||||
|
backend: .inMemory(shellSession.terminalSession)
|
||||||
|
)
|
||||||
|
terminalView.controller = controller
|
||||||
|
terminalView.backgroundColor = .clear
|
||||||
|
terminalView.isOpaque = false
|
||||||
|
terminalView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
view.addSubview(terminalView)
|
||||||
|
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
terminalView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
|
||||||
|
terminalView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||||
|
terminalView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||||
|
terminalView.bottomAnchor.constraint(equalTo: view.keyboardLayoutGuide.topAnchor),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
private func activateTerminal() {
|
||||||
|
terminalView.becomeFirstResponder()
|
||||||
|
shellSession.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Persistence
|
||||||
|
|
||||||
|
private static func savedTerminalTheme() -> TerminalTheme {
|
||||||
|
let lightConfig = savedThemeDefinition(forKey: lightThemeKey)?
|
||||||
|
.toTerminalConfiguration() ?? .alabaster
|
||||||
|
let darkConfig = savedThemeDefinition(forKey: darkThemeKey)?
|
||||||
|
.toTerminalConfiguration() ?? .afterglow
|
||||||
|
return TerminalTheme(light: lightConfig, dark: darkConfig)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func savedThemeDefinition(
|
||||||
|
forKey key: String
|
||||||
|
) -> GhosttyThemeDefinition? {
|
||||||
|
guard let name = UserDefaults.standard.string(forKey: key) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return GhosttyThemeCatalog.theme(named: name)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var isDarkMode: Bool {
|
||||||
|
traitCollection.userInterfaceStyle == .dark
|
||||||
|
}
|
||||||
|
|
||||||
|
private func saveTheme(_ theme: GhosttyThemeDefinition) {
|
||||||
|
let key = isDarkMode ? Self.darkThemeKey : Self.lightThemeKey
|
||||||
|
UserDefaults.standard.set(theme.name, forKey: key)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyBackgroundForCurrentAppearance() {
|
||||||
|
let key = isDarkMode ? Self.darkThemeKey : Self.lightThemeKey
|
||||||
|
guard let theme = Self.savedThemeDefinition(forKey: key) else { return }
|
||||||
|
if let bgColor = UIColor(hexString: theme.background) {
|
||||||
|
view.backgroundColor = bgColor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Theme Menu
|
||||||
|
|
||||||
|
private func configureThemeMenu() {
|
||||||
|
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
||||||
|
image: UIImage(systemName: "paintpalette"),
|
||||||
|
menu: buildThemeMenu()
|
||||||
|
)
|
||||||
|
navigationItem.rightBarButtonItem?.accessibilityIdentifier = "terminal.themeButton"
|
||||||
|
}
|
||||||
|
|
||||||
|
private func buildThemeMenu() -> UIMenu {
|
||||||
|
let popular = buildSubmenu(
|
||||||
|
title: "Popular",
|
||||||
|
themes: [
|
||||||
|
"Dracula", "Catppuccin Mocha", "Catppuccin Latte",
|
||||||
|
"Nord", "Solarized Dark", "Solarized Light",
|
||||||
|
"Gruvbox Dark", "Gruvbox Light", "Tokyo Night",
|
||||||
|
"One Half Dark", "One Half Light", "Rose Pine",
|
||||||
|
"Monokai Pro", "GitHub Dark", "GitHub Light",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
let dark = UIMenu(
|
||||||
|
title: "Dark",
|
||||||
|
image: UIImage(systemName: "moon.fill"),
|
||||||
|
children: alphabeticalSubmenus(
|
||||||
|
themes: GhosttyThemeCatalog.allThemes.filter(\.isDark)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
let light = UIMenu(
|
||||||
|
title: "Light",
|
||||||
|
image: UIImage(systemName: "sun.max.fill"),
|
||||||
|
children: alphabeticalSubmenus(
|
||||||
|
themes: GhosttyThemeCatalog.allThemes.filter { !$0.isDark }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return UIMenu(title: "Theme", children: [popular, dark, light])
|
||||||
|
}
|
||||||
|
|
||||||
|
private func buildSubmenu(
|
||||||
|
title: String,
|
||||||
|
themes names: [String]
|
||||||
|
) -> UIMenu {
|
||||||
|
let actions = names.compactMap { name -> UIAction? in
|
||||||
|
guard let theme = GhosttyThemeCatalog.theme(named: name) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return themeAction(for: theme)
|
||||||
|
}
|
||||||
|
return UIMenu(
|
||||||
|
title: title,
|
||||||
|
image: UIImage(systemName: "star.fill"),
|
||||||
|
children: actions
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func alphabeticalSubmenus(
|
||||||
|
themes: [GhosttyThemeDefinition]
|
||||||
|
) -> [UIMenu] {
|
||||||
|
var grouped: [String: [GhosttyThemeDefinition]] = [:]
|
||||||
|
for theme in themes {
|
||||||
|
let letter = String(theme.name.prefix(1)).uppercased()
|
||||||
|
let key = letter.first?.isLetter == true ? letter : "#"
|
||||||
|
grouped[key, default: []].append(theme)
|
||||||
|
}
|
||||||
|
|
||||||
|
return grouped.keys.sorted().map { key in
|
||||||
|
UIMenu(
|
||||||
|
title: key,
|
||||||
|
children: grouped[key]!.map { themeAction(for: $0) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func themeAction(for theme: GhosttyThemeDefinition) -> UIAction {
|
||||||
|
UIAction(title: theme.name) { [weak self] _ in
|
||||||
|
self?.applyTheme(theme)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyTheme(_ theme: GhosttyThemeDefinition) {
|
||||||
|
saveTheme(theme)
|
||||||
|
controller.setTheme(Self.savedTerminalTheme())
|
||||||
|
|
||||||
|
if let bgColor = UIColor(hexString: theme.background) {
|
||||||
|
view.backgroundColor = bgColor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Terminal Callbacks
|
||||||
|
|
||||||
|
extension ViewController:
|
||||||
|
TerminalSurfaceTitleDelegate,
|
||||||
|
TerminalSurfaceResizeDelegate,
|
||||||
|
TerminalSurfaceCloseDelegate,
|
||||||
|
TerminalSurfaceTextSelectionRequestDelegate,
|
||||||
|
UIAdaptivePresentationControllerDelegate
|
||||||
|
{
|
||||||
|
func terminalDidChangeTitle(_ title: String) {
|
||||||
|
self.title = title
|
||||||
|
}
|
||||||
|
|
||||||
|
func terminalDidResize(columns _: Int, rows _: Int) {}
|
||||||
|
|
||||||
|
func terminalDidClose(processAlive _: Bool) {
|
||||||
|
ApplicationExitController.requestExit()
|
||||||
|
}
|
||||||
|
|
||||||
|
func terminalDidRequestTextSelection(_ request: TerminalTextSelectionRequest) {
|
||||||
|
let selectionVC = TerminalSelectionViewController(
|
||||||
|
text: request.text,
|
||||||
|
anchorRange: request.anchorRange
|
||||||
|
)
|
||||||
|
selectionVC.onDone = { [weak self] in
|
||||||
|
self?.terminalView.becomeFirstResponder()
|
||||||
|
}
|
||||||
|
let nav = UINavigationController(rootViewController: selectionVC)
|
||||||
|
nav.modalPresentationStyle = .pageSheet
|
||||||
|
nav.sheetPresentationController?.detents = [.medium(), .large()]
|
||||||
|
nav.sheetPresentationController?.prefersGrabberVisible = true
|
||||||
|
nav.presentationController?.delegate = self
|
||||||
|
present(nav, animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Covers the user-gesture (grabber swipe) dismiss path only —
|
||||||
|
/// programmatic dismiss does not trigger this callback, so the Done
|
||||||
|
/// button restores focus via `onDone` instead.
|
||||||
|
func presentationControllerDidDismiss(_: UIPresentationController) {
|
||||||
|
terminalView.becomeFirstResponder()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - UIColor Hex
|
||||||
|
|
||||||
|
private extension UIColor {
|
||||||
|
convenience init?(hexString: String) {
|
||||||
|
let hex = hexString.hasPrefix("#") ? String(hexString.dropFirst()) : hexString
|
||||||
|
guard hex.count == 6,
|
||||||
|
let r = UInt8(hex.prefix(2), radix: 16),
|
||||||
|
let g = UInt8(hex.dropFirst(2).prefix(2), radix: 16),
|
||||||
|
let b = UInt8(hex.dropFirst(4).prefix(2), radix: 16)
|
||||||
|
else { return nil }
|
||||||
|
self.init(
|
||||||
|
red: CGFloat(r) / 255,
|
||||||
|
green: CGFloat(g) / 255,
|
||||||
|
blue: CGFloat(b) / 255,
|
||||||
|
alpha: 1
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user