初始提交: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:
kid
2026-07-24 10:20:46 +08:00
commit aa92d0e676
2761 changed files with 803505 additions and 0 deletions

18
.gitignore vendored Normal file
View 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 第三方源/预编译 xcframeworkvendor/*、artifacts/***有意提交**
# 以在受限网络环境下保持仓库自包含、克隆即可构建运行。

68
CLAUDE.md Normal file
View 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/execmacOS 支持本地 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 库 xcframeworklibssh2+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/` — 预编译 xcframeworkgitignore`CSSHCore`/`TsnetBridge`
## 约定 & 坑
- **CSSHCore 必须 library-only**(无 Headers/modulemaplibssh2 的 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
View 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 targetsXcodeGen 生成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.xM0
- Go + gomobileM1tsnet
- CMake + Ninja + zigM1/M2vendor 构建)

View 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 +
// -txHostmodel 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-modewindow 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)
}
}
}

View 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: "已关闭"
}
}
}

View File

@@ -0,0 +1,10 @@
import SwiftUI
@main
struct TerminalXApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}

View File

@@ -0,0 +1,242 @@
import Foundation
import GhosttyTerminal
import TXCore
/// tmux windowMVP 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()
}
}

View 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) }
}
}

View 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

View 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>

Binary file not shown.

View 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>

View File

@@ -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

View File

@@ -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.Connnetstack 虚拟连接,无真实 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 启动并等待 RunningtimeoutMs 到则返回 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

View File

@@ -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

View File

@@ -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

View File

@@ -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>

View File

@@ -0,0 +1,8 @@
framework module "TsnetBridge" {
header "ref.h"
header "Tsnetbridge.objc.h"
header "Universe.objc.h"
header "TsnetBridge.h"
export *
}

View File

@@ -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

View File

@@ -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.Connnetstack 虚拟连接,无真实 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 启动并等待 RunningtimeoutMs 到则返回 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

View File

@@ -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

View File

@@ -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

View File

@@ -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>

View File

@@ -0,0 +1,8 @@
framework module "TsnetBridge" {
header "ref.h"
header "Tsnetbridge.objc.h"
header "Universe.objc.h"
header "TsnetBridge.h"
export *
}

82
docs/HANDOFF.md Normal file
View 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不能跑本地 shellmacOS 才支持本地 PTY。
- 分发目标 App StoreM5已暂缓
- 四大核心:**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 用户态**Gogomobile 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 bindC/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 targetlibssh2 头 + `include/module.modulemap`,供 `import CSSH`)、`CSSHCore`binaryTargetlibrary-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.sendtmux 模式→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 需配置签名 + entitlementstsnet 用户态**无需** 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:NGo 侧 `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 keyephemeral可能过期用户会给新的。用于直连 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>

View 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)]
),
]
)

View 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) 次)"),
]
}
}

View 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]
}
}

View File

@@ -0,0 +1,41 @@
import Foundation
/// tmux `-CC`iTerm2 /退
/// tmux DCS `ESC P 1000 p`退`%exit` ST `ESC \`
/// shell control-mode
public enum TmuxControlSequence {
/// DCSESC P 1000 p
public static let enter: [UInt8] = [0x1B, 0x50, 0x31, 0x30, 0x30, 0x30, 0x70]
/// 退 STESC \
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
}
}

View 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") }
}

View 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())
}
}

View 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
}
}

View 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
}
}

View 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)
// 2attempt 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)
// 3attempt 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)
}
}

View 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)
}
}

View File

@@ -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])
}
}

View 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") }
}
}

View File

@@ -0,0 +1,51 @@
import Testing
import Foundation
@testable import TXCore
@Suite("tmux %output 八进制解码")
struct TmuxOutputDecoderTests {
// API decodedecoder 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))
}
}

View 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.shlibrary-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)]
),
]
)

File diff suppressed because it is too large Load Diff

View 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 */

View 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 */

View File

@@ -0,0 +1,6 @@
module CSSH {
header "libssh2.h"
header "libssh2_sftp.h"
header "libssh2_publickey.h"
export *
}

View File

@@ -0,0 +1,3 @@
// CSSH 是 libssh2 头文件 + modulemap 的载体 target供 `import CSSH`
// 实际库符号来自 CSSHCore.xcframework静态库。此处仅需一个非空翻译单元。
typedef int cssh_shim_placeholder;

View 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 使 fdtsnet 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)
}
}

View File

@@ -0,0 +1,64 @@
import Foundation
/// SSH / mosh / PTY PaneEngine
/// tsnetM1 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
View File

@@ -0,0 +1,8 @@
.DS_Store
/.build
/Packages
xcuserdata/
DerivedData/
.swiftpm/configuration/registries.json
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
.netrc

View 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 */;
}

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>

View 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>

View 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>

View 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>

View 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

View File

@@ -0,0 +1,11 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View 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
}
}

View File

@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View 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()
}
}

View 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
View 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
View 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
View 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
![Preview](Resources/SCR-20240814-qqri.jpeg)
## 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

36
vendor/MSDisplayLink/Scripts/test.build.sh vendored Executable file
View 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"

View 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)
}
}
}
}

View 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
}
}

View 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
}
}

View 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

View 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

View 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.")
}
}

View 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
View File

@@ -0,0 +1,87 @@
#!/bin/bash
# 交叉编译 libssh2 + mbedTLS → CSSHCore.xcframeworkiOS device / simulator / macOS
# mbedTLS 作为 libssh2 的 crypto backendM0 选型;后续可换 OpenSSLTXTransport 接口不变)。
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
View File

@@ -0,0 +1 @@
*.patch whitespace=-trailing-space

View 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 }}

View 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

View 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

View 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
View 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
View File

159
vendor/libghostty-spm/AGENTS.md vendored Normal file
View 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
View File

@@ -0,0 +1 @@
AGENTS.md

View 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>

View 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>IDEWorkspaceSharedSettings_AutocreateContextsIfNeeded</key>
<false/>
</dict>
</plist>

View 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()
}
}
}

View 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

View 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"
}
}

View 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>

View 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()
}
}

View 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()
}

View 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)
}
}

View 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
}
}

View 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

View 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"
}
}

View 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)
}
}

View 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
}
}

View 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?()
}
}
}

View 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