初始提交: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:
13
vendor/libghostty-spm/Example/GhosttyTerminalApp.xcworkspace/contents.xcworkspacedata
generated
vendored
Normal file
13
vendor/libghostty-spm/Example/GhosttyTerminalApp.xcworkspace/contents.xcworkspacedata
generated
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "group:GhosttyTerminalApp.xcodeproj">
|
||||
</FileRef>
|
||||
<FileRef
|
||||
location = "group:MobileGhosttyApp.xcodeproj">
|
||||
</FileRef>
|
||||
<FileRef
|
||||
location = "group:../">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>IDEWorkspaceSharedSettings_AutocreateContextsIfNeeded</key>
|
||||
<false/>
|
||||
</dict>
|
||||
</plist>
|
||||
52
vendor/libghostty-spm/Example/GhosttyTerminalApp/AppDelegate.swift
vendored
Normal file
52
vendor/libghostty-spm/Example/GhosttyTerminalApp/AppDelegate.swift
vendored
Normal file
@@ -0,0 +1,52 @@
|
||||
import Cocoa
|
||||
import GhosttyTerminal
|
||||
|
||||
final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
private let defaultContentSize = NSSize(width: 720, height: 480)
|
||||
private let minimumContentSize = NSSize(width: 480, height: 320)
|
||||
private var window: NSWindow?
|
||||
|
||||
func applicationDidFinishLaunching(_: Notification) {
|
||||
TerminalDebugLog.enable(.standard)
|
||||
|
||||
let window = NSWindow(
|
||||
contentRect: NSRect(origin: .zero, size: defaultContentSize),
|
||||
styleMask: [.titled, .closable, .miniaturizable, .resizable],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
)
|
||||
window.title = "GhosttyKit Sandbox Demo"
|
||||
window.isOpaque = true
|
||||
window.backgroundColor = .windowBackgroundColor
|
||||
window.titlebarAppearsTransparent = false
|
||||
window.contentMinSize = minimumContentSize
|
||||
window.contentViewController = ViewController()
|
||||
window.center()
|
||||
window.makeKeyAndOrderFront(nil)
|
||||
repairRestoredWindowSizeIfNeeded(window)
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
self.window = window
|
||||
}
|
||||
|
||||
func applicationSupportsSecureRestorableState(_: NSApplication) -> Bool {
|
||||
true
|
||||
}
|
||||
|
||||
func applicationShouldTerminateAfterLastWindowClosed(_: NSApplication) -> Bool {
|
||||
true
|
||||
}
|
||||
|
||||
private func repairRestoredWindowSizeIfNeeded(_ window: NSWindow) {
|
||||
DispatchQueue.main.async { [defaultContentSize, minimumContentSize] in
|
||||
let contentRect = window.contentRect(forFrameRect: window.frame)
|
||||
guard contentRect.width < minimumContentSize.width
|
||||
|| contentRect.height < minimumContentSize.height
|
||||
else {
|
||||
return
|
||||
}
|
||||
|
||||
window.setContentSize(defaultContentSize)
|
||||
window.center()
|
||||
}
|
||||
}
|
||||
}
|
||||
11
vendor/libghostty-spm/Example/GhosttyTerminalApp/AppIcon.icon/Assets/singaporedollarsign.svg
vendored
Normal file
11
vendor/libghostty-spm/Example/GhosttyTerminalApp/AppIcon.icon/Assets/singaporedollarsign.svg
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--Generator: Apple Native CoreSVG 341-->
|
||||
<!DOCTYPE svg
|
||||
PUBLIC "-//W3C//DTD SVG 1.1//EN"
|
||||
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 403.625 361.875">
|
||||
<g>
|
||||
<rect height="361.875" opacity="0" width="403.625" x="0" y="0"/>
|
||||
<path d="M93.5 332.625C146.625 332.625 185.75 302.875 185.75 249.25C185.75 201.25 154 181.875 110.625 168.625L86 161.25C56.125 152.25 31.5 137.5 31.5 105.125C31.5 70.375 59.625 51.5 90.875 51.5C121.125 51.5 148.25 68 154.25 100.75C155.75 107.75 159.875 111.375 166.875 111.375C173.875 111.375 179.25 107 179.25 99C179.25 96.375 178.875 93.25 178.125 90.25C168.875 49.625 132.375 27.25 91.25 27.25C42.75 27.25 4.625 58.625 4.625 106.5C4.625 151.75 34 173.5 77.5 186.625L102.25 194.25C134.25 203.875 158.75 218.375 158.75 251.75C158.75 287.25 130.625 308.375 94.625 308.375C58.625 308.375 31 289.5 24.75 255C22.875 247.375 18.75 244.25 12.125 244.25C4.875 244.25 0 249.5 0 257C0 261.75 0.625 265.75 1.75 269.625C11.875 309.75 49.25 332.625 93.5 332.625ZM314.375 322.625C362.875 322.625 403.625 297.125 403.625 248C403.625 204 372.25 186 334.125 171.75L306.625 161.5C281.75 152.5 254.5 137.75 254.5 108C254.5 77.25 282.5 60.875 313.5 60.875C345.75 60.875 367.625 78.25 373.25 110.25C375 117.25 379.25 121.625 386.5 121.625C394.25 121.625 399 116.875 399 109.25C399 107.25 398.875 103.75 398.125 100.125C390.25 58.5 354.75 37.375 313.5 37.375C268.125 37.375 228.125 63.375 228.125 109.375C228.125 149.5 257.75 170.25 295.875 184.5L323.375 194.875C352.375 205.375 377.5 218.75 377.5 250.125C377.5 282.875 350.25 299.125 314.5 299.125C278.625 299.125 253.875 282 248.875 246.75C247.625 239.75 242.875 235.125 235.625 235.125C228.625 235.125 223 239.75 223 247.875C223 249.75 223.25 251.875 223.5 253.75C231.125 300.625 269.375 322.625 314.375 322.625ZM313.25 361.75C318.25 361.75 322.375 357.625 322.375 352.75L322.375 9C322.375 4.125 318.25 0 313.25 0C308.25 0 304.125 4.125 304.125 9L304.125 352.75C304.125 357.625 308.25 361.75 313.25 361.75Z" fill="white" fill-opacity="0.85"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
30
vendor/libghostty-spm/Example/GhosttyTerminalApp/AppIcon.icon/icon.json
vendored
Normal file
30
vendor/libghostty-spm/Example/GhosttyTerminalApp/AppIcon.icon/icon.json
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"fill" : {
|
||||
"automatic-gradient" : "srgb:1.00000,0.54098,0.84731,1.00000"
|
||||
},
|
||||
"groups" : [
|
||||
{
|
||||
"layers" : [
|
||||
{
|
||||
"glass" : false,
|
||||
"image-name" : "singaporedollarsign.svg",
|
||||
"name" : "singaporedollarsign"
|
||||
}
|
||||
],
|
||||
"shadow" : {
|
||||
"kind" : "neutral",
|
||||
"opacity" : 0.5
|
||||
},
|
||||
"translucency" : {
|
||||
"enabled" : true,
|
||||
"value" : 0.5
|
||||
}
|
||||
}
|
||||
],
|
||||
"supported-platforms" : {
|
||||
"circles" : [
|
||||
"watchOS"
|
||||
],
|
||||
"squares" : "shared"
|
||||
}
|
||||
}
|
||||
10
vendor/libghostty-spm/Example/GhosttyTerminalApp/Entitlement.entitlements
vendored
Normal file
10
vendor/libghostty-spm/Example/GhosttyTerminalApp/Entitlement.entitlements
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.app-sandbox</key>
|
||||
<true/>
|
||||
<key>com.apple.security.files.user-selected.read-only</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
108
vendor/libghostty-spm/Example/GhosttyTerminalApp/ViewController.swift
vendored
Normal file
108
vendor/libghostty-spm/Example/GhosttyTerminalApp/ViewController.swift
vendored
Normal file
@@ -0,0 +1,108 @@
|
||||
import Cocoa
|
||||
import GhosttyTerminal
|
||||
import ShellCraftKit
|
||||
|
||||
private final class AppearanceAwareView: NSView {
|
||||
var onAppearanceChange: (() -> Void)?
|
||||
|
||||
override func viewDidChangeEffectiveAppearance() {
|
||||
super.viewDidChangeEffectiveAppearance()
|
||||
onAppearanceChange?()
|
||||
}
|
||||
}
|
||||
|
||||
final class ViewController: NSViewController {
|
||||
private lazy var terminalView: TerminalView = .init(
|
||||
frame: NSRect(x: 0, y: 0, width: 720, height: 480)
|
||||
)
|
||||
|
||||
private lazy var shellSession: ShellSession = .init(shell: defaultSandboxShell)
|
||||
|
||||
private lazy var controller: TerminalController = .init { builder in
|
||||
builder.withBackgroundOpacity(0)
|
||||
builder.withCustom("keybind", "super+k=text:\\x0c")
|
||||
}
|
||||
|
||||
override func loadView() {
|
||||
let container = AppearanceAwareView()
|
||||
container.onAppearanceChange = { [weak self] in
|
||||
self?.applyWindowBackgroundColor()
|
||||
}
|
||||
view = container
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
configureView()
|
||||
configureTerminalView()
|
||||
}
|
||||
|
||||
override func viewDidAppear() {
|
||||
super.viewDidAppear()
|
||||
activateTerminal()
|
||||
}
|
||||
|
||||
override func viewDidLayout() {
|
||||
super.viewDidLayout()
|
||||
terminalView.fitToSize()
|
||||
}
|
||||
|
||||
private func configureView() {
|
||||
view.wantsLayer = true
|
||||
applyWindowBackgroundColor()
|
||||
}
|
||||
|
||||
private func applyWindowBackgroundColor() {
|
||||
// `NSColor.windowBackgroundColor.cgColor` snapshots whichever
|
||||
// appearance is current at the call site, so drawing it naively
|
||||
// caches yesterday's light/dark value on the layer. Resolve it
|
||||
// under the view's effective appearance so the layer follows
|
||||
// system toggles.
|
||||
view.effectiveAppearance.performAsCurrentDrawingAppearance {
|
||||
view.layer?.backgroundColor = NSColor.windowBackgroundColor.cgColor
|
||||
}
|
||||
}
|
||||
|
||||
private func configureTerminalView() {
|
||||
terminalView.delegate = self
|
||||
terminalView.setAccessibilityElement(true)
|
||||
terminalView.setAccessibilityIdentifier("terminal.surface")
|
||||
terminalView.setAccessibilityLabel("Terminal Surface")
|
||||
terminalView.configuration = TerminalSurfaceOptions(
|
||||
backend: .inMemory(shellSession.terminalSession)
|
||||
)
|
||||
terminalView.controller = controller
|
||||
terminalView.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.addSubview(terminalView)
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
terminalView.topAnchor.constraint(equalTo: view.topAnchor),
|
||||
terminalView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
terminalView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
terminalView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||
])
|
||||
}
|
||||
|
||||
private func activateTerminal() {
|
||||
view.window?.makeFirstResponder(terminalView)
|
||||
shellSession.start()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Terminal Callbacks
|
||||
|
||||
extension ViewController:
|
||||
TerminalSurfaceTitleDelegate,
|
||||
TerminalSurfaceResizeDelegate,
|
||||
TerminalSurfaceCloseDelegate
|
||||
{
|
||||
func terminalDidChangeTitle(_ title: String) {
|
||||
view.window?.title = title
|
||||
}
|
||||
|
||||
func terminalDidResize(columns _: Int, rows _: Int) {}
|
||||
|
||||
func terminalDidClose(processAlive _: Bool) {
|
||||
view.window?.close()
|
||||
}
|
||||
}
|
||||
17
vendor/libghostty-spm/Example/GhosttyTerminalApp/main.swift
vendored
Normal file
17
vendor/libghostty-spm/Example/GhosttyTerminalApp/main.swift
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
//
|
||||
// main.swift
|
||||
// GhosttyTerminalApp
|
||||
//
|
||||
// Created by qaq on 19/3/2026.
|
||||
//
|
||||
|
||||
import AppKit
|
||||
|
||||
MainActor.assumeIsolated {
|
||||
let delegate = AppDelegate()
|
||||
let app = NSApplication.shared
|
||||
app.setActivationPolicy(.regular)
|
||||
app.delegate = delegate
|
||||
app.run()
|
||||
fatalError()
|
||||
}
|
||||
178
vendor/libghostty-spm/Example/GhosttyTerminalAppUITests/GhosttyTerminalAppUITests.swift
vendored
Normal file
178
vendor/libghostty-spm/Example/GhosttyTerminalAppUITests/GhosttyTerminalAppUITests.swift
vendored
Normal file
@@ -0,0 +1,178 @@
|
||||
import XCTest
|
||||
import AppKit
|
||||
|
||||
final class GhosttyTerminalAppUITests: XCTestCase {
|
||||
private var app: XCUIApplication!
|
||||
private var systemAlertMonitor: NSObjectProtocol?
|
||||
|
||||
override func setUpWithError() throws {
|
||||
continueAfterFailure = false
|
||||
app = XCUIApplication()
|
||||
app.launchArguments = ["--ui-testing"]
|
||||
systemAlertMonitor = installSystemAlertHandler()
|
||||
app.launch()
|
||||
}
|
||||
|
||||
override func tearDownWithError() throws {
|
||||
capture("final-state")
|
||||
app = nil
|
||||
}
|
||||
|
||||
func testTerminalUserOperations() throws {
|
||||
let terminal = try requireTerminalInteractionTarget()
|
||||
|
||||
capture("01-launch")
|
||||
clickTerminal(in: terminal)
|
||||
capture("02-focus")
|
||||
|
||||
typeTerminalText("echo mac-single\n", in: terminal)
|
||||
capture("03-single-line-input")
|
||||
|
||||
typeTerminalText("echo mac first line\n", in: terminal)
|
||||
typeTerminalText("echo mac second line\n", in: terminal)
|
||||
capture("04-multiple-lines")
|
||||
|
||||
typeTerminalText("中文键盘测试,标点和全角字符。\n", in: terminal)
|
||||
capture("05-chinese-input")
|
||||
|
||||
typeTerminalText("日本語キーボードテスト、かなと漢字。\n", in: terminal)
|
||||
capture("06-japanese-input")
|
||||
|
||||
typeTerminalText("Mixed input: English 中文 日本語 123\n", in: terminal)
|
||||
capture("07-multilingual-input")
|
||||
|
||||
terminal.swipeUp()
|
||||
capture("08-swipe-up")
|
||||
terminal.swipeDown()
|
||||
capture("09-swipe-down")
|
||||
|
||||
app.typeKey("=", modifierFlags: .command)
|
||||
capture("10-keyboard-zoom-in")
|
||||
app.typeKey("-", modifierFlags: .command)
|
||||
capture("11-keyboard-zoom-out")
|
||||
|
||||
typeTerminalText("clear\n", in: terminal)
|
||||
capture("12-clear-command")
|
||||
|
||||
assertNoCopyMenuWithoutSelection(in: terminal)
|
||||
typeTerminalText("echo selection anchor\n", in: terminal)
|
||||
dragPointerSelection(in: terminal)
|
||||
capture("13-pointer-selection")
|
||||
openCopyMenuAndCopySelection(in: terminal, screenshotName: "14-pointer-copy-menu")
|
||||
longPressTerminal(in: terminal)
|
||||
capture("15-long-press")
|
||||
}
|
||||
|
||||
private func requireTerminalInteractionTarget() throws -> XCUIElement {
|
||||
let terminal = app.descendants(matching: .any)["terminal.surface"].firstMatch
|
||||
if terminal.waitForExistence(timeout: 4), terminal.isHittable {
|
||||
return terminal
|
||||
}
|
||||
|
||||
let window = app.windows.firstMatch
|
||||
XCTAssertTrue(window.waitForExistence(timeout: 8))
|
||||
XCTAssertTrue(window.isHittable)
|
||||
return window
|
||||
}
|
||||
|
||||
private func clickTerminal(in element: XCUIElement) {
|
||||
element.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.55)).click()
|
||||
}
|
||||
|
||||
private func installSystemAlertHandler() -> NSObjectProtocol {
|
||||
addUIInterruptionMonitor(withDescription: "System alert") { alert in
|
||||
let preferredButtons = [
|
||||
"OK", "Ok", "好", "确定", "允许", "Allow", "继续", "Continue",
|
||||
"关闭", "Close", "Dismiss",
|
||||
]
|
||||
for title in preferredButtons {
|
||||
let button = alert.buttons[title].firstMatch
|
||||
if button.exists {
|
||||
button.click()
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private func typeTerminalText(_ text: String, in element: XCUIElement) {
|
||||
clickTerminal(in: element)
|
||||
element.typeText(text)
|
||||
}
|
||||
|
||||
private func longPressTerminal(in element: XCUIElement) {
|
||||
element.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.55)).press(forDuration: 0.7)
|
||||
}
|
||||
|
||||
private func dragPointerSelection(in element: XCUIElement) {
|
||||
log("pointer-selection-coordinates", "start=(0.008, 0.10), end=(0.42, 0.10), rightClick=(0.20, 0.10)")
|
||||
let start = element.coordinate(withNormalizedOffset: CGVector(dx: 0.008, dy: 0.10))
|
||||
let end = element.coordinate(withNormalizedOffset: CGVector(dx: 0.42, dy: 0.10))
|
||||
start.press(forDuration: 0.1, thenDragTo: end)
|
||||
}
|
||||
|
||||
private func openCopyMenuAndCopySelection(in element: XCUIElement, screenshotName: String) {
|
||||
NSPasteboard.general.clearContents()
|
||||
disableSystemAlertMonitorBeforeContextMenu()
|
||||
element.coordinate(withNormalizedOffset: CGVector(dx: 0.20, dy: 0.10)).rightClick()
|
||||
let copy = copyMenuItem()
|
||||
if !copy.waitForExistence(timeout: 3) {
|
||||
capture("\(screenshotName)-missing")
|
||||
XCTFail("Copy menu item not found after pointer selection right click. Hierarchy: \(app.debugDescription)")
|
||||
return
|
||||
}
|
||||
copy.click()
|
||||
capture(screenshotName)
|
||||
let actual = copiedPasteboardText(timeout: 2)
|
||||
log("pointer-selection-pasteboard", actual ?? "<nil>")
|
||||
XCTAssertEqual(actual, "selection anchor")
|
||||
}
|
||||
|
||||
private func assertNoCopyMenuWithoutSelection(in element: XCUIElement) {
|
||||
disableSystemAlertMonitorBeforeContextMenu()
|
||||
element.coordinate(withNormalizedOffset: CGVector(dx: 0.20, dy: 0.10)).rightClick()
|
||||
XCTAssertFalse(
|
||||
copyMenuItem().waitForExistence(timeout: 0.5),
|
||||
"Copy menu item appeared without an active terminal selection."
|
||||
)
|
||||
}
|
||||
|
||||
private func disableSystemAlertMonitorBeforeContextMenu() {
|
||||
if let systemAlertMonitor {
|
||||
removeUIInterruptionMonitor(systemAlertMonitor)
|
||||
self.systemAlertMonitor = nil
|
||||
}
|
||||
}
|
||||
|
||||
private func copyMenuItem() -> XCUIElement {
|
||||
app.menuItems["Copy"].firstMatch
|
||||
}
|
||||
|
||||
private func copiedPasteboardText(timeout: TimeInterval) -> String? {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
repeat {
|
||||
if let string = NSPasteboard.general.string(forType: .string) {
|
||||
return string
|
||||
}
|
||||
RunLoop.current.run(mode: .default, before: Date().addingTimeInterval(0.05))
|
||||
} while Date() < deadline
|
||||
return nil
|
||||
}
|
||||
|
||||
private func log(_ name: String, _ value: String) {
|
||||
let attachment = XCTAttachment(string: value)
|
||||
attachment.name = name
|
||||
attachment.lifetime = .keepAlways
|
||||
add(attachment)
|
||||
}
|
||||
|
||||
private func capture(_ name: String) {
|
||||
guard let app else { return }
|
||||
let attachment = XCTAttachment(screenshot: app.screenshot())
|
||||
attachment.name = name
|
||||
attachment.lifetime = .keepAlways
|
||||
add(attachment)
|
||||
}
|
||||
}
|
||||
29
vendor/libghostty-spm/Example/MobileGhosttyApp/AppDelegate.swift
vendored
Normal file
29
vendor/libghostty-spm/Example/MobileGhosttyApp/AppDelegate.swift
vendored
Normal file
@@ -0,0 +1,29 @@
|
||||
import GhosttyTerminal
|
||||
import UIKit
|
||||
|
||||
@main
|
||||
final class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
func application(
|
||||
_: UIApplication,
|
||||
didFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]? = nil
|
||||
) -> Bool {
|
||||
TerminalDebugLog.enable(.standard)
|
||||
TerminalDebugLog.sink = { message in
|
||||
NSLog("%@", message)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func application(
|
||||
_: UIApplication,
|
||||
configurationForConnecting connectingSceneSession: UISceneSession,
|
||||
options _: UIScene.ConnectionOptions
|
||||
) -> UISceneConfiguration {
|
||||
let config = UISceneConfiguration(
|
||||
name: "Default Configuration",
|
||||
sessionRole: connectingSceneSession.role
|
||||
)
|
||||
config.delegateClass = SceneDelegate.self
|
||||
return config
|
||||
}
|
||||
}
|
||||
11
vendor/libghostty-spm/Example/MobileGhosttyApp/AppIcon.icon/Assets/singaporedollarsign.svg
vendored
Normal file
11
vendor/libghostty-spm/Example/MobileGhosttyApp/AppIcon.icon/Assets/singaporedollarsign.svg
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--Generator: Apple Native CoreSVG 341-->
|
||||
<!DOCTYPE svg
|
||||
PUBLIC "-//W3C//DTD SVG 1.1//EN"
|
||||
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 403.625 361.875">
|
||||
<g>
|
||||
<rect height="361.875" opacity="0" width="403.625" x="0" y="0"/>
|
||||
<path d="M93.5 332.625C146.625 332.625 185.75 302.875 185.75 249.25C185.75 201.25 154 181.875 110.625 168.625L86 161.25C56.125 152.25 31.5 137.5 31.5 105.125C31.5 70.375 59.625 51.5 90.875 51.5C121.125 51.5 148.25 68 154.25 100.75C155.75 107.75 159.875 111.375 166.875 111.375C173.875 111.375 179.25 107 179.25 99C179.25 96.375 178.875 93.25 178.125 90.25C168.875 49.625 132.375 27.25 91.25 27.25C42.75 27.25 4.625 58.625 4.625 106.5C4.625 151.75 34 173.5 77.5 186.625L102.25 194.25C134.25 203.875 158.75 218.375 158.75 251.75C158.75 287.25 130.625 308.375 94.625 308.375C58.625 308.375 31 289.5 24.75 255C22.875 247.375 18.75 244.25 12.125 244.25C4.875 244.25 0 249.5 0 257C0 261.75 0.625 265.75 1.75 269.625C11.875 309.75 49.25 332.625 93.5 332.625ZM314.375 322.625C362.875 322.625 403.625 297.125 403.625 248C403.625 204 372.25 186 334.125 171.75L306.625 161.5C281.75 152.5 254.5 137.75 254.5 108C254.5 77.25 282.5 60.875 313.5 60.875C345.75 60.875 367.625 78.25 373.25 110.25C375 117.25 379.25 121.625 386.5 121.625C394.25 121.625 399 116.875 399 109.25C399 107.25 398.875 103.75 398.125 100.125C390.25 58.5 354.75 37.375 313.5 37.375C268.125 37.375 228.125 63.375 228.125 109.375C228.125 149.5 257.75 170.25 295.875 184.5L323.375 194.875C352.375 205.375 377.5 218.75 377.5 250.125C377.5 282.875 350.25 299.125 314.5 299.125C278.625 299.125 253.875 282 248.875 246.75C247.625 239.75 242.875 235.125 235.625 235.125C228.625 235.125 223 239.75 223 247.875C223 249.75 223.25 251.875 223.5 253.75C231.125 300.625 269.375 322.625 314.375 322.625ZM313.25 361.75C318.25 361.75 322.375 357.625 322.375 352.75L322.375 9C322.375 4.125 318.25 0 313.25 0C308.25 0 304.125 4.125 304.125 9L304.125 352.75C304.125 357.625 308.25 361.75 313.25 361.75Z" fill="white" fill-opacity="0.85"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
30
vendor/libghostty-spm/Example/MobileGhosttyApp/AppIcon.icon/icon.json
vendored
Normal file
30
vendor/libghostty-spm/Example/MobileGhosttyApp/AppIcon.icon/icon.json
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"fill" : {
|
||||
"automatic-gradient" : "srgb:1.00000,0.54098,0.84731,1.00000"
|
||||
},
|
||||
"groups" : [
|
||||
{
|
||||
"layers" : [
|
||||
{
|
||||
"glass" : false,
|
||||
"image-name" : "singaporedollarsign.svg",
|
||||
"name" : "singaporedollarsign"
|
||||
}
|
||||
],
|
||||
"shadow" : {
|
||||
"kind" : "neutral",
|
||||
"opacity" : 0.5
|
||||
},
|
||||
"translucency" : {
|
||||
"enabled" : true,
|
||||
"value" : 0.5
|
||||
}
|
||||
}
|
||||
],
|
||||
"supported-platforms" : {
|
||||
"circles" : [
|
||||
"watchOS"
|
||||
],
|
||||
"squares" : "shared"
|
||||
}
|
||||
}
|
||||
29
vendor/libghostty-spm/Example/MobileGhosttyApp/ApplicationExitController.swift
vendored
Normal file
29
vendor/libghostty-spm/Example/MobileGhosttyApp/ApplicationExitController.swift
vendored
Normal file
@@ -0,0 +1,29 @@
|
||||
import UIKit
|
||||
|
||||
@MainActor
|
||||
enum ApplicationExitController {
|
||||
private static var hasRequestedExit = false
|
||||
|
||||
static func requestExit() {
|
||||
guard !hasRequestedExit else {
|
||||
return
|
||||
}
|
||||
|
||||
hasRequestedExit = true
|
||||
suspendIfAvailable()
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
|
||||
exit(EXIT_SUCCESS)
|
||||
}
|
||||
}
|
||||
|
||||
private static func suspendIfAvailable() {
|
||||
let selector = NSSelectorFromString("suspend")
|
||||
let application = UIApplication.shared
|
||||
|
||||
guard application.responds(to: selector) else {
|
||||
return
|
||||
}
|
||||
|
||||
_ = application.perform(selector)
|
||||
}
|
||||
}
|
||||
21
vendor/libghostty-spm/Example/MobileGhosttyApp/SceneDelegate.swift
vendored
Normal file
21
vendor/libghostty-spm/Example/MobileGhosttyApp/SceneDelegate.swift
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
import UIKit
|
||||
|
||||
final class SceneDelegate: UIResponder, UIWindowSceneDelegate {
|
||||
var window: UIWindow?
|
||||
|
||||
func scene(
|
||||
_ scene: UIScene,
|
||||
willConnectTo _: UISceneSession,
|
||||
options _: UIScene.ConnectionOptions
|
||||
) {
|
||||
guard let windowScene = scene as? UIWindowScene else {
|
||||
return
|
||||
}
|
||||
|
||||
let window = UIWindow(windowScene: windowScene)
|
||||
window.backgroundColor = .systemBackground
|
||||
window.rootViewController = UINavigationController(rootViewController: ViewController())
|
||||
window.makeKeyAndVisible()
|
||||
self.window = window
|
||||
}
|
||||
}
|
||||
86
vendor/libghostty-spm/Example/MobileGhosttyApp/TerminalSelectionViewController.swift
vendored
Normal file
86
vendor/libghostty-spm/Example/MobileGhosttyApp/TerminalSelectionViewController.swift
vendored
Normal file
@@ -0,0 +1,86 @@
|
||||
import UIKit
|
||||
|
||||
final class TerminalSelectionViewController: UIViewController {
|
||||
private let pendingText: String
|
||||
private let pendingAnchorRange: NSRange?
|
||||
|
||||
var onDone: (() -> Void)?
|
||||
|
||||
private lazy var textView: UITextView = {
|
||||
let view = UITextView()
|
||||
view.isEditable = false
|
||||
view.isSelectable = true
|
||||
view.accessibilityIdentifier = "terminal.selectionTextView"
|
||||
view.alwaysBounceVertical = true
|
||||
view.font = .monospacedSystemFont(ofSize: 14, weight: .regular)
|
||||
view.backgroundColor = .clear
|
||||
view.textColor = .label
|
||||
view.textContainerInset = .init(top: 12, left: 12, bottom: 12, right: 12)
|
||||
view.translatesAutoresizingMaskIntoConstraints = false
|
||||
return view
|
||||
}()
|
||||
|
||||
init(text: String, anchorRange: NSRange?) {
|
||||
pendingText = text
|
||||
pendingAnchorRange = anchorRange
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder _: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
view.backgroundColor = .clear
|
||||
|
||||
textView.text = pendingText
|
||||
view.addSubview(textView)
|
||||
|
||||
// Pin to view edges (not safe area) so scrolled content extends
|
||||
// under the transparent navigation bar / home indicator the way
|
||||
// iOS sheets expect. UIKit's contentInsetAdjustmentBehavior keeps
|
||||
// the initial scroll position below the nav bar via
|
||||
// adjustedContentInset.
|
||||
NSLayoutConstraint.activate([
|
||||
textView.topAnchor.constraint(equalTo: view.topAnchor),
|
||||
textView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
textView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
textView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||
])
|
||||
|
||||
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
||||
barButtonSystemItem: .done,
|
||||
target: self,
|
||||
action: #selector(handleDone)
|
||||
)
|
||||
navigationItem.rightBarButtonItem?.accessibilityIdentifier = "terminal.selectionDoneButton"
|
||||
}
|
||||
|
||||
override func viewWillAppear(_ animated: Bool) {
|
||||
super.viewWillAppear(animated)
|
||||
navigationController?.view.backgroundColor = .clear
|
||||
navigationController?.navigationBar.standardAppearance.configureWithTransparentBackground()
|
||||
navigationController?.navigationBar.scrollEdgeAppearance = navigationController?.navigationBar.standardAppearance
|
||||
}
|
||||
|
||||
override func viewDidAppear(_ animated: Bool) {
|
||||
super.viewDidAppear(animated)
|
||||
textView.becomeFirstResponder()
|
||||
|
||||
let nsText = textView.text as NSString
|
||||
if let range = pendingAnchorRange, NSMaxRange(range) <= nsText.length {
|
||||
textView.selectedRange = range
|
||||
textView.scrollRangeToVisible(range)
|
||||
} else {
|
||||
textView.selectAll(nil)
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func handleDone() {
|
||||
dismiss(animated: true) { [weak self] in
|
||||
self?.onDone?()
|
||||
}
|
||||
}
|
||||
}
|
||||
262
vendor/libghostty-spm/Example/MobileGhosttyApp/ViewController.swift
vendored
Normal file
262
vendor/libghostty-spm/Example/MobileGhosttyApp/ViewController.swift
vendored
Normal file
@@ -0,0 +1,262 @@
|
||||
import GhosttyTerminal
|
||||
import GhosttyTheme
|
||||
import ShellCraftKit
|
||||
import UIKit
|
||||
|
||||
final class ViewController: UIViewController {
|
||||
private static let lightThemeKey = "SelectedTheme.light"
|
||||
private static let darkThemeKey = "SelectedTheme.dark"
|
||||
|
||||
private lazy var terminalView: TerminalView = .init(frame: .zero)
|
||||
private lazy var shellSession: ShellSession = .init(shell: defaultSandboxShell)
|
||||
private lazy var controller: TerminalController = .init(
|
||||
theme: Self.savedTerminalTheme()
|
||||
) { builder in
|
||||
builder.withBackgroundOpacity(0)
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
title = "Terminal"
|
||||
view.backgroundColor = .systemBackground
|
||||
view.isOpaque = true
|
||||
configureTerminalView()
|
||||
configureThemeMenu()
|
||||
applyBackgroundForCurrentAppearance()
|
||||
}
|
||||
|
||||
override func viewDidAppear(_ animated: Bool) {
|
||||
super.viewDidAppear(animated)
|
||||
activateTerminal()
|
||||
}
|
||||
|
||||
override func viewDidLayoutSubviews() {
|
||||
super.viewDidLayoutSubviews()
|
||||
terminalView.fitToSize()
|
||||
}
|
||||
|
||||
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
|
||||
super.traitCollectionDidChange(previousTraitCollection)
|
||||
guard traitCollection.hasDifferentColorAppearance(comparedTo: previousTraitCollection) else {
|
||||
return
|
||||
}
|
||||
controller.setTheme(Self.savedTerminalTheme())
|
||||
applyBackgroundForCurrentAppearance()
|
||||
}
|
||||
|
||||
private func configureTerminalView() {
|
||||
terminalView.delegate = self
|
||||
terminalView.isAccessibilityElement = true
|
||||
terminalView.accessibilityIdentifier = "terminal.surface"
|
||||
terminalView.accessibilityLabel = "Terminal Surface"
|
||||
terminalView.configuration = TerminalSurfaceOptions(
|
||||
backend: .inMemory(shellSession.terminalSession)
|
||||
)
|
||||
terminalView.controller = controller
|
||||
terminalView.backgroundColor = .clear
|
||||
terminalView.isOpaque = false
|
||||
terminalView.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.addSubview(terminalView)
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
terminalView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
|
||||
terminalView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
terminalView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
terminalView.bottomAnchor.constraint(equalTo: view.keyboardLayoutGuide.topAnchor),
|
||||
])
|
||||
}
|
||||
|
||||
private func activateTerminal() {
|
||||
terminalView.becomeFirstResponder()
|
||||
shellSession.start()
|
||||
}
|
||||
|
||||
// MARK: - Persistence
|
||||
|
||||
private static func savedTerminalTheme() -> TerminalTheme {
|
||||
let lightConfig = savedThemeDefinition(forKey: lightThemeKey)?
|
||||
.toTerminalConfiguration() ?? .alabaster
|
||||
let darkConfig = savedThemeDefinition(forKey: darkThemeKey)?
|
||||
.toTerminalConfiguration() ?? .afterglow
|
||||
return TerminalTheme(light: lightConfig, dark: darkConfig)
|
||||
}
|
||||
|
||||
private static func savedThemeDefinition(
|
||||
forKey key: String
|
||||
) -> GhosttyThemeDefinition? {
|
||||
guard let name = UserDefaults.standard.string(forKey: key) else {
|
||||
return nil
|
||||
}
|
||||
return GhosttyThemeCatalog.theme(named: name)
|
||||
}
|
||||
|
||||
private var isDarkMode: Bool {
|
||||
traitCollection.userInterfaceStyle == .dark
|
||||
}
|
||||
|
||||
private func saveTheme(_ theme: GhosttyThemeDefinition) {
|
||||
let key = isDarkMode ? Self.darkThemeKey : Self.lightThemeKey
|
||||
UserDefaults.standard.set(theme.name, forKey: key)
|
||||
}
|
||||
|
||||
private func applyBackgroundForCurrentAppearance() {
|
||||
let key = isDarkMode ? Self.darkThemeKey : Self.lightThemeKey
|
||||
guard let theme = Self.savedThemeDefinition(forKey: key) else { return }
|
||||
if let bgColor = UIColor(hexString: theme.background) {
|
||||
view.backgroundColor = bgColor
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Theme Menu
|
||||
|
||||
private func configureThemeMenu() {
|
||||
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
||||
image: UIImage(systemName: "paintpalette"),
|
||||
menu: buildThemeMenu()
|
||||
)
|
||||
navigationItem.rightBarButtonItem?.accessibilityIdentifier = "terminal.themeButton"
|
||||
}
|
||||
|
||||
private func buildThemeMenu() -> UIMenu {
|
||||
let popular = buildSubmenu(
|
||||
title: "Popular",
|
||||
themes: [
|
||||
"Dracula", "Catppuccin Mocha", "Catppuccin Latte",
|
||||
"Nord", "Solarized Dark", "Solarized Light",
|
||||
"Gruvbox Dark", "Gruvbox Light", "Tokyo Night",
|
||||
"One Half Dark", "One Half Light", "Rose Pine",
|
||||
"Monokai Pro", "GitHub Dark", "GitHub Light",
|
||||
]
|
||||
)
|
||||
|
||||
let dark = UIMenu(
|
||||
title: "Dark",
|
||||
image: UIImage(systemName: "moon.fill"),
|
||||
children: alphabeticalSubmenus(
|
||||
themes: GhosttyThemeCatalog.allThemes.filter(\.isDark)
|
||||
)
|
||||
)
|
||||
|
||||
let light = UIMenu(
|
||||
title: "Light",
|
||||
image: UIImage(systemName: "sun.max.fill"),
|
||||
children: alphabeticalSubmenus(
|
||||
themes: GhosttyThemeCatalog.allThemes.filter { !$0.isDark }
|
||||
)
|
||||
)
|
||||
|
||||
return UIMenu(title: "Theme", children: [popular, dark, light])
|
||||
}
|
||||
|
||||
private func buildSubmenu(
|
||||
title: String,
|
||||
themes names: [String]
|
||||
) -> UIMenu {
|
||||
let actions = names.compactMap { name -> UIAction? in
|
||||
guard let theme = GhosttyThemeCatalog.theme(named: name) else {
|
||||
return nil
|
||||
}
|
||||
return themeAction(for: theme)
|
||||
}
|
||||
return UIMenu(
|
||||
title: title,
|
||||
image: UIImage(systemName: "star.fill"),
|
||||
children: actions
|
||||
)
|
||||
}
|
||||
|
||||
private func alphabeticalSubmenus(
|
||||
themes: [GhosttyThemeDefinition]
|
||||
) -> [UIMenu] {
|
||||
var grouped: [String: [GhosttyThemeDefinition]] = [:]
|
||||
for theme in themes {
|
||||
let letter = String(theme.name.prefix(1)).uppercased()
|
||||
let key = letter.first?.isLetter == true ? letter : "#"
|
||||
grouped[key, default: []].append(theme)
|
||||
}
|
||||
|
||||
return grouped.keys.sorted().map { key in
|
||||
UIMenu(
|
||||
title: key,
|
||||
children: grouped[key]!.map { themeAction(for: $0) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func themeAction(for theme: GhosttyThemeDefinition) -> UIAction {
|
||||
UIAction(title: theme.name) { [weak self] _ in
|
||||
self?.applyTheme(theme)
|
||||
}
|
||||
}
|
||||
|
||||
private func applyTheme(_ theme: GhosttyThemeDefinition) {
|
||||
saveTheme(theme)
|
||||
controller.setTheme(Self.savedTerminalTheme())
|
||||
|
||||
if let bgColor = UIColor(hexString: theme.background) {
|
||||
view.backgroundColor = bgColor
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Terminal Callbacks
|
||||
|
||||
extension ViewController:
|
||||
TerminalSurfaceTitleDelegate,
|
||||
TerminalSurfaceResizeDelegate,
|
||||
TerminalSurfaceCloseDelegate,
|
||||
TerminalSurfaceTextSelectionRequestDelegate,
|
||||
UIAdaptivePresentationControllerDelegate
|
||||
{
|
||||
func terminalDidChangeTitle(_ title: String) {
|
||||
self.title = title
|
||||
}
|
||||
|
||||
func terminalDidResize(columns _: Int, rows _: Int) {}
|
||||
|
||||
func terminalDidClose(processAlive _: Bool) {
|
||||
ApplicationExitController.requestExit()
|
||||
}
|
||||
|
||||
func terminalDidRequestTextSelection(_ request: TerminalTextSelectionRequest) {
|
||||
let selectionVC = TerminalSelectionViewController(
|
||||
text: request.text,
|
||||
anchorRange: request.anchorRange
|
||||
)
|
||||
selectionVC.onDone = { [weak self] in
|
||||
self?.terminalView.becomeFirstResponder()
|
||||
}
|
||||
let nav = UINavigationController(rootViewController: selectionVC)
|
||||
nav.modalPresentationStyle = .pageSheet
|
||||
nav.sheetPresentationController?.detents = [.medium(), .large()]
|
||||
nav.sheetPresentationController?.prefersGrabberVisible = true
|
||||
nav.presentationController?.delegate = self
|
||||
present(nav, animated: true)
|
||||
}
|
||||
|
||||
/// Covers the user-gesture (grabber swipe) dismiss path only —
|
||||
/// programmatic dismiss does not trigger this callback, so the Done
|
||||
/// button restores focus via `onDone` instead.
|
||||
func presentationControllerDidDismiss(_: UIPresentationController) {
|
||||
terminalView.becomeFirstResponder()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - UIColor Hex
|
||||
|
||||
private extension UIColor {
|
||||
convenience init?(hexString: String) {
|
||||
let hex = hexString.hasPrefix("#") ? String(hexString.dropFirst()) : hexString
|
||||
guard hex.count == 6,
|
||||
let r = UInt8(hex.prefix(2), radix: 16),
|
||||
let g = UInt8(hex.dropFirst(2).prefix(2), radix: 16),
|
||||
let b = UInt8(hex.dropFirst(4).prefix(2), radix: 16)
|
||||
else { return nil }
|
||||
self.init(
|
||||
red: CGFloat(r) / 255,
|
||||
green: CGFloat(g) / 255,
|
||||
blue: CGFloat(b) / 255,
|
||||
alpha: 1
|
||||
)
|
||||
}
|
||||
}
|
||||
449
vendor/libghostty-spm/Example/MobileGhosttyAppUITests/MobileGhosttyAppUITests.swift
vendored
Normal file
449
vendor/libghostty-spm/Example/MobileGhosttyAppUITests/MobileGhosttyAppUITests.swift
vendored
Normal file
@@ -0,0 +1,449 @@
|
||||
import XCTest
|
||||
|
||||
#if targetEnvironment(macCatalyst)
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
#endif
|
||||
|
||||
final class MobileGhosttyAppUITests: XCTestCase {
|
||||
private var app: XCUIApplication!
|
||||
|
||||
override func setUpWithError() throws {
|
||||
continueAfterFailure = false
|
||||
app = XCUIApplication()
|
||||
app.launchArguments = ["--ui-testing"]
|
||||
installSystemAlertHandler()
|
||||
#if !targetEnvironment(macCatalyst)
|
||||
if UIDevice.current.userInterfaceIdiom == .pad {
|
||||
XCUIDevice.shared.orientation = .landscapeLeft
|
||||
}
|
||||
#endif
|
||||
app.launch()
|
||||
}
|
||||
|
||||
override func tearDownWithError() throws {
|
||||
capture("final-state")
|
||||
app = nil
|
||||
}
|
||||
|
||||
func testTerminalUserOperations() throws {
|
||||
let terminal = try requireTerminalInteractionTarget()
|
||||
|
||||
capture("01-launch")
|
||||
typeTerminalText("echo ui-single\n", in: terminal)
|
||||
capture("02-single-line-input")
|
||||
|
||||
typeTerminalText("echo first line\n", in: terminal)
|
||||
typeTerminalText("echo second line\n", in: terminal)
|
||||
capture("03-multiple-lines")
|
||||
|
||||
typeTerminalText("中文键盘测试,标点和全角字符。\n", in: terminal)
|
||||
capture("04-chinese-input")
|
||||
|
||||
typeTerminalText("日本語キーボードテスト、かなと漢字。\n", in: terminal)
|
||||
capture("05-japanese-input")
|
||||
|
||||
typeTerminalText("Mixed input: English 中文 日本語 123\n", in: terminal)
|
||||
capture("06-multilingual-input")
|
||||
|
||||
tapTerminal(in: terminal)
|
||||
capture("07-tap-dismiss-keyboard")
|
||||
tapTerminal(in: terminal)
|
||||
capture("08-tap-refocus")
|
||||
|
||||
typeTerminalText("help\n", in: terminal)
|
||||
capture("09-help-output")
|
||||
|
||||
terminal.swipeUp()
|
||||
capture("10-swipe-up")
|
||||
terminal.swipeDown()
|
||||
capture("11-swipe-down")
|
||||
|
||||
#if targetEnvironment(macCatalyst)
|
||||
app.typeKey("=", modifierFlags: .command)
|
||||
#else
|
||||
terminal.pinch(withScale: 1.25, velocity: 1.0)
|
||||
#endif
|
||||
capture("12-zoom-in")
|
||||
// Use the keyboard zoom-out path on iOS because XCTest's second
|
||||
// pinch in one test session can report an invalid coordinate.
|
||||
app.typeKey("-", modifierFlags: .command)
|
||||
capture("13-zoom-out")
|
||||
|
||||
typeTerminalText("clear\n", in: terminal)
|
||||
capture("14-clear-command")
|
||||
|
||||
let pointerSelectionCommand: String
|
||||
#if targetEnvironment(macCatalyst)
|
||||
pointerSelectionCommand = "echo \(catalystPointerSelectionPrefix)\(expectedPointerSelection)\n"
|
||||
#else
|
||||
pointerSelectionCommand = isIPad
|
||||
? "echo \(iPadPointerSelectionPrefix)\(expectedPointerSelection)\n"
|
||||
: "echo \(expectedPointerSelection)\n"
|
||||
#endif
|
||||
typeTerminalText(pointerSelectionCommand, in: terminal)
|
||||
#if targetEnvironment(macCatalyst)
|
||||
dragPointerSelection(in: terminal)
|
||||
capture("15-pointer-selection-catalyst")
|
||||
openCopyMenuAndCopySelection(in: terminal, screenshotName: "16-pointer-copy-menu-catalyst")
|
||||
longPressTerminal(in: terminal)
|
||||
capture("17-long-press-catalyst")
|
||||
#else
|
||||
if isIPad {
|
||||
longPressTerminal(in: terminal, offset: CGVector(dx: 0.35, dy: 0.18))
|
||||
XCTAssertTrue(selectionTextView().waitForExistence(timeout: 4))
|
||||
capture("15-long-press-selection")
|
||||
dismissSelectionSheet()
|
||||
|
||||
hideSoftwareKeyboardIfVisible()
|
||||
capture("16-ipad-keyboard-hidden-before-pointer")
|
||||
dragIPadPointerSelection(in: terminal)
|
||||
capture("17-ipad-pointer-selection")
|
||||
openCopyMenuAndCopySelection(
|
||||
in: terminal,
|
||||
screenshotName: "18-ipad-pointer-copy-menu",
|
||||
rightClickOffset: CGVector(dx: 0.10, dy: 0.035)
|
||||
)
|
||||
} else {
|
||||
longPressTerminal(in: terminal, offset: CGVector(dx: 0.35, dy: 0.18))
|
||||
XCTAssertTrue(selectionTextView().waitForExistence(timeout: 4))
|
||||
capture("15-long-press-selection")
|
||||
}
|
||||
#endif
|
||||
|
||||
#if !targetEnvironment(macCatalyst)
|
||||
if isIPad {
|
||||
tapTerminal(in: terminal)
|
||||
tapAccessoryButton("Tab", screenshotName: "16-accessory-tab")
|
||||
tapAccessoryButton("Esc", screenshotName: "17-accessory-esc")
|
||||
tapAccessoryButton("Right", screenshotName: "18-accessory-right")
|
||||
}
|
||||
#endif
|
||||
|
||||
#if targetEnvironment(macCatalyst)
|
||||
openThemeMenuAndSelectPopularTheme()
|
||||
capture("19-theme-menu-selection")
|
||||
#else
|
||||
if isIPad {
|
||||
openThemeMenuAndSelectPopularTheme()
|
||||
capture("19-theme-menu-selection")
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
private func installSystemAlertHandler() {
|
||||
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 {
|
||||
self.activateInterruptionButton(button)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
let firstButton = alert.buttons.firstMatch
|
||||
guard firstButton.exists else { return false }
|
||||
if firstButton.identifier == "InputSource" ||
|
||||
firstButton.label.hasPrefix("com.apple.inputmethod.")
|
||||
{
|
||||
self.app.typeKey(.escape, modifierFlags: [])
|
||||
return true
|
||||
}
|
||||
self.activateInterruptionButton(firstButton)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
private func activateInterruptionButton(_ button: XCUIElement) {
|
||||
#if targetEnvironment(macCatalyst)
|
||||
button.click()
|
||||
#else
|
||||
button.tap()
|
||||
#endif
|
||||
}
|
||||
|
||||
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 tapTerminal(in element: XCUIElement) {
|
||||
let coordinate = element.coordinate(withNormalizedOffset: terminalInteractionOffset)
|
||||
#if targetEnvironment(macCatalyst)
|
||||
coordinate.click()
|
||||
#else
|
||||
if isIPad {
|
||||
coordinate.tap()
|
||||
} else {
|
||||
coordinate.press(forDuration: 0.01)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
private func typeTerminalText(_ text: String, in element: XCUIElement) {
|
||||
#if targetEnvironment(macCatalyst)
|
||||
element.coordinate(withNormalizedOffset: terminalInteractionOffset).click()
|
||||
app.typeText(text)
|
||||
#else
|
||||
if !isIPad, !prepareTerminalForTyping(element) {
|
||||
return
|
||||
}
|
||||
element.typeText(text)
|
||||
#endif
|
||||
}
|
||||
|
||||
#if !targetEnvironment(macCatalyst)
|
||||
private func prepareTerminalForTyping(_ element: XCUIElement) -> Bool {
|
||||
if selectionTextView().exists {
|
||||
dismissSelectionSheet()
|
||||
}
|
||||
if waitForKeyboardFocus(in: element, timeout: 0.5) {
|
||||
return true
|
||||
}
|
||||
|
||||
for _ in 0 ..< 2 {
|
||||
tapTerminal(in: element)
|
||||
if selectionTextView().waitForExistence(timeout: 0.25) {
|
||||
dismissSelectionSheet()
|
||||
}
|
||||
if waitForKeyboardFocus(in: element, timeout: 2) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
XCTFail("Terminal did not acquire keyboard focus before typing")
|
||||
return false
|
||||
}
|
||||
|
||||
private func waitForKeyboardFocus(
|
||||
in element: XCUIElement,
|
||||
timeout: TimeInterval
|
||||
) -> Bool {
|
||||
let expectation = XCTNSPredicateExpectation(
|
||||
predicate: NSPredicate(format: "hasKeyboardFocus == true"),
|
||||
object: element
|
||||
)
|
||||
return XCTWaiter.wait(for: [expectation], timeout: timeout) == .completed
|
||||
}
|
||||
#endif
|
||||
|
||||
private func longPressTerminal(in element: XCUIElement, offset: CGVector? = nil) {
|
||||
element.coordinate(withNormalizedOffset: offset ?? terminalInteractionOffset).press(forDuration: 0.7)
|
||||
}
|
||||
|
||||
#if targetEnvironment(macCatalyst)
|
||||
private func dragPointerSelection(in element: XCUIElement) {
|
||||
log("pointer-selection-coordinates", "start=(0.008, 0.045), end=(0.42, 0.045), rightClick=(0.20, 0.045)")
|
||||
let start = element.coordinate(withNormalizedOffset: CGVector(dx: 0.008, dy: 0.045))
|
||||
let end = element.coordinate(withNormalizedOffset: CGVector(dx: 0.42, dy: 0.045))
|
||||
start.press(forDuration: 0.1, thenDragTo: end)
|
||||
}
|
||||
#else
|
||||
private var isIPad: Bool {
|
||||
UIDevice.current.userInterfaceIdiom == .pad
|
||||
}
|
||||
|
||||
private func dragIPadPointerSelection(in element: XCUIElement) {
|
||||
log(
|
||||
"ipad-pointer-selection-coordinates",
|
||||
"frame=\(element.frame) command=echo \(iPadPointerSelectionPrefix)\(expectedPointerSelection) start=(0.015, 0.035), end=(0.205, 0.035), rightClick=(0.10, 0.035)"
|
||||
)
|
||||
let start = element.coordinate(withNormalizedOffset: CGVector(dx: 0.015, dy: 0.035))
|
||||
let end = element.coordinate(withNormalizedOffset: CGVector(dx: 0.205, dy: 0.035))
|
||||
start.click(forDuration: 0.1, thenDragTo: end)
|
||||
}
|
||||
|
||||
private func hideSoftwareKeyboardIfVisible() {
|
||||
let hideKeyboard = app.buttons["Hide keyboard"].firstMatch
|
||||
if hideKeyboard.waitForExistence(timeout: 1), hideKeyboard.isHittable {
|
||||
hideKeyboard.tap()
|
||||
XCTAssertFalse(hideKeyboard.waitForExistence(timeout: 2))
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
private func openCopyMenuAndCopySelection(
|
||||
in element: XCUIElement,
|
||||
screenshotName: String,
|
||||
rightClickOffset: CGVector? = nil
|
||||
) {
|
||||
UIPasteboard.general.string = nil
|
||||
let offset = rightClickOffset ?? CGVector(dx: 0.20, dy: 0.045)
|
||||
log("pointer-copy-menu-coordinate", "frame=\(element.frame) rightClick=(\(offset.dx), \(offset.dy))")
|
||||
element.coordinate(withNormalizedOffset: offset).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
|
||||
}
|
||||
capture(screenshotName)
|
||||
activateCopyMenuItem(copy)
|
||||
let actual = copiedSelectionText(in: element, timeout: 2)
|
||||
log("pointer-selection-pasteboard", actual ?? "<nil>")
|
||||
XCTAssertEqual(actual, expectedPointerSelection)
|
||||
}
|
||||
|
||||
private func activateCopyMenuItem(_ copy: XCUIElement) {
|
||||
#if targetEnvironment(macCatalyst)
|
||||
copy.click()
|
||||
#else
|
||||
copy.tap()
|
||||
#endif
|
||||
}
|
||||
|
||||
private func copyMenuItem() -> XCUIElement {
|
||||
#if targetEnvironment(macCatalyst)
|
||||
app.menuItems["Copy"].firstMatch
|
||||
#else
|
||||
app.buttons["Copy"].firstMatch
|
||||
#endif
|
||||
}
|
||||
|
||||
private func copiedSelectionText(in element: XCUIElement, timeout: TimeInterval) -> String? {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
repeat {
|
||||
if let string = copiedSelectionTextSnapshot(in: element, timeout: 0.25) {
|
||||
return string
|
||||
}
|
||||
RunLoop.current.run(mode: .default, before: Date().addingTimeInterval(0.05))
|
||||
} while Date() < deadline
|
||||
return nil
|
||||
}
|
||||
|
||||
private func copiedSelectionTextSnapshot(in element: XCUIElement, timeout: TimeInterval) -> String? {
|
||||
#if !targetEnvironment(macCatalyst)
|
||||
return element.value as? String
|
||||
#else
|
||||
_ = timeout
|
||||
return UIPasteboard.general.string
|
||||
#endif
|
||||
}
|
||||
|
||||
private func log(_ name: String, _ value: String) {
|
||||
let attachment = XCTAttachment(string: value)
|
||||
attachment.name = name
|
||||
attachment.lifetime = .keepAlways
|
||||
add(attachment)
|
||||
}
|
||||
|
||||
#if targetEnvironment(macCatalyst)
|
||||
private var isIPad: Bool {
|
||||
false
|
||||
}
|
||||
#endif
|
||||
|
||||
private var terminalInteractionOffset: CGVector {
|
||||
CGVector(dx: 0.5, dy: 0.55)
|
||||
}
|
||||
|
||||
private func tapAccessoryButton(_ label: String, screenshotName: String) {
|
||||
dismissKeyboardOnboardingIfVisible()
|
||||
let button = app.buttons[label]
|
||||
guard button.waitForExistence(timeout: 2), button.isHittable else {
|
||||
capture("\(screenshotName)-not-visible")
|
||||
return
|
||||
}
|
||||
button.tap()
|
||||
capture(screenshotName)
|
||||
}
|
||||
|
||||
private func dismissKeyboardOnboardingIfVisible() {
|
||||
let continueButton = app.buttons["Continue"].firstMatch
|
||||
guard continueButton.waitForExistence(timeout: 1), continueButton.isHittable else {
|
||||
return
|
||||
}
|
||||
#if targetEnvironment(macCatalyst)
|
||||
continueButton.click()
|
||||
#else
|
||||
continueButton.tap()
|
||||
#endif
|
||||
}
|
||||
|
||||
private func openThemeMenuAndSelectPopularTheme() {
|
||||
let themeButton = app.buttons["terminal.themeButton"].firstMatch
|
||||
guard themeButton.waitForExistence(timeout: 2), themeButton.isHittable else {
|
||||
capture("theme-button-not-visible")
|
||||
return
|
||||
}
|
||||
themeButton.tap()
|
||||
capture("theme-menu-open")
|
||||
|
||||
let popular = app.buttons["Popular"].firstMatch
|
||||
if popular.waitForExistence(timeout: 1), popular.isHittable {
|
||||
popular.tap()
|
||||
capture("theme-menu-popular")
|
||||
}
|
||||
|
||||
let dracula = app.buttons["Dracula"].firstMatch
|
||||
if dracula.waitForExistence(timeout: 2), dracula.isHittable {
|
||||
dracula.tap()
|
||||
} else {
|
||||
capture("theme-dracula-not-visible")
|
||||
dismissOpenMenu()
|
||||
}
|
||||
}
|
||||
|
||||
private func dismissOpenMenu() {
|
||||
#if targetEnvironment(macCatalyst)
|
||||
app.typeKey(.escape, modifierFlags: [])
|
||||
#else
|
||||
let window = app.windows.firstMatch
|
||||
guard window.exists else { return }
|
||||
window.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.9)).tap()
|
||||
#endif
|
||||
}
|
||||
|
||||
private func dismissSelectionSheet() {
|
||||
let done = app.buttons["terminal.selectionDoneButton"].firstMatch
|
||||
if done.waitForExistence(timeout: 2), done.isHittable {
|
||||
done.tap()
|
||||
XCTAssertTrue(selectionTextView().waitForNonExistence(timeout: 3))
|
||||
}
|
||||
}
|
||||
|
||||
private func selectionTextView() -> XCUIElement {
|
||||
app.textViews["terminal.selectionTextView"].firstMatch
|
||||
}
|
||||
|
||||
private func capture(_ name: String) {
|
||||
guard let app else { return }
|
||||
let attachment = XCTAttachment(screenshot: app.screenshot())
|
||||
attachment.name = name
|
||||
attachment.lifetime = .keepAlways
|
||||
add(attachment)
|
||||
}
|
||||
|
||||
private var expectedPointerSelection: String {
|
||||
"selection anchor"
|
||||
}
|
||||
|
||||
private var iPadPointerSelectionPrefix: String {
|
||||
// iPadOS/XCTest clamps the indirect pointer drag start a couple of
|
||||
// cells inside the view edge. The prefix keeps the single fixed drag
|
||||
// selecting the same expected terminal text without retries.
|
||||
"xx"
|
||||
}
|
||||
|
||||
#if targetEnvironment(macCatalyst)
|
||||
private var catalystPointerSelectionPrefix: String {
|
||||
// Mac Catalyst clamps the left-edge pointer drag inside the first
|
||||
// text cell on GitHub runners. The prefix keeps the copied text
|
||||
// anchored to the same expected selection.
|
||||
"x"
|
||||
}
|
||||
#endif
|
||||
}
|
||||
Reference in New Issue
Block a user