初始提交: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

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

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

1
vendor/libghostty-spm/Ghostty.ref vendored Normal file
View File

@@ -0,0 +1 @@
35e1a0160c4f6797e1bb1ef8e7a2b8c6b114ab58

21
vendor/libghostty-spm/LICENSE vendored Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 @Lakr233
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.

View File

@@ -0,0 +1,55 @@
// swift-tools-version: 6.0
import PackageDescription
let package = Package(
name: "GhosttyKit",
platforms: [
.iOS(.v15),
.macOS(.v13),
.macCatalyst(.v15),
],
products: [
.library(name: "GhosttyKit", targets: ["GhosttyKit"]),
.library(name: "GhosttyTerminal", targets: ["GhosttyTerminal"]),
.library(name: "ShellCraftKit", targets: ["ShellCraftKit"]),
.library(name: "GhosttyTheme", targets: ["GhosttyTheme"]),
],
dependencies: [
.package(url: "https://github.com/Lakr233/MSDisplayLink.git", from: "2.1.0"),
],
targets: [
.target(
name: "GhosttyKit",
dependencies: ["libghostty"],
path: "Sources/GhosttyKit",
linkerSettings: [
.linkedLibrary("c++"),
.linkedFramework("Carbon", .when(platforms: [.macOS])),
]
),
.target(
name: "GhosttyTerminal",
dependencies: ["GhosttyKit", "MSDisplayLink"],
path: "Sources/GhosttyTerminal"
),
.target(
name: "ShellCraftKit",
dependencies: ["GhosttyTerminal"],
path: "Sources/ShellCraftKit"
),
.target(
name: "GhosttyTheme",
dependencies: ["GhosttyTerminal"],
path: "Sources/GhosttyTheme",
exclude: ["LICENSE"]
),
.binaryTarget(
name: "libghostty",
path: "BinaryTarget/GhosttyKit.xcframework"
),
.testTarget(
name: "GhosttyKitTest",
dependencies: ["GhosttyKit", "GhosttyTerminal", "GhosttyTheme", "ShellCraftKit"]
),
]
)

59
vendor/libghostty-spm/Package.swift vendored Normal file
View File

@@ -0,0 +1,59 @@
// swift-tools-version: 6.0
import PackageDescription
// terminalX vendored local-path 使 SPM
// SwiftPM
// Package.swift.template / Package.local.swift
// libghostty xcframework (storage.1.3.2) BinaryTarget/
let package = Package(
name: "GhosttyKit",
platforms: [
.iOS(.v15),
.macOS(.v13),
.macCatalyst(.v15),
],
products: [
.library(name: "GhosttyKit", targets: ["GhosttyKit"]),
.library(name: "GhosttyTerminal", targets: ["GhosttyTerminal"]),
.library(name: "ShellCraftKit", targets: ["ShellCraftKit"]),
.library(name: "GhosttyTheme", targets: ["GhosttyTheme"]),
],
dependencies: [
.package(path: "../MSDisplayLink"),
],
targets: [
.target(
name: "GhosttyKit",
dependencies: ["libghostty"],
path: "Sources/GhosttyKit",
linkerSettings: [
.linkedLibrary("c++"),
.linkedFramework("Carbon", .when(platforms: [.macOS])),
]
),
.target(
name: "GhosttyTerminal",
dependencies: ["GhosttyKit", "MSDisplayLink"],
path: "Sources/GhosttyTerminal"
),
.target(
name: "ShellCraftKit",
dependencies: ["GhosttyTerminal"],
path: "Sources/ShellCraftKit"
),
.target(
name: "GhosttyTheme",
dependencies: ["GhosttyTerminal"],
path: "Sources/GhosttyTheme",
exclude: ["LICENSE"]
),
.binaryTarget(
name: "libghostty",
path: "BinaryTarget/GhosttyKit.xcframework"
),
.testTarget(
name: "GhosttyKitTest",
dependencies: ["GhosttyKit", "GhosttyTerminal", "GhosttyTheme", "ShellCraftKit"]
),
]
)

View File

@@ -0,0 +1,56 @@
// swift-tools-version: 6.0
import PackageDescription
let package = Package(
name: "GhosttyKit",
platforms: [
.iOS(.v15),
.macOS(.v13),
.macCatalyst(.v15),
],
products: [
.library(name: "GhosttyKit", targets: ["GhosttyKit"]),
.library(name: "GhosttyTerminal", targets: ["GhosttyTerminal"]),
.library(name: "ShellCraftKit", targets: ["ShellCraftKit"]),
.library(name: "GhosttyTheme", targets: ["GhosttyTheme"]),
],
dependencies: [
.package(url: "https://github.com/Lakr233/MSDisplayLink.git", from: "2.1.0"),
],
targets: [
.target(
name: "GhosttyKit",
dependencies: ["libghostty"],
path: "Sources/GhosttyKit",
linkerSettings: [
.linkedLibrary("c++"),
.linkedFramework("Carbon", .when(platforms: [.macOS])),
]
),
.target(
name: "GhosttyTerminal",
dependencies: ["GhosttyKit", "MSDisplayLink"],
path: "Sources/GhosttyTerminal"
),
.target(
name: "ShellCraftKit",
dependencies: ["GhosttyTerminal"],
path: "Sources/ShellCraftKit"
),
.target(
name: "GhosttyTheme",
dependencies: ["GhosttyTerminal"],
path: "Sources/GhosttyTheme",
exclude: ["LICENSE"]
),
.binaryTarget(
name: "libghostty",
url: "__DOWNLOAD_URL__",
checksum: "__CHECKSUM__"
),
.testTarget(
name: "GhosttyKitTest",
dependencies: ["GhosttyKit", "GhosttyTerminal", "GhosttyTheme", "ShellCraftKit"]
),
]
)

View File

@@ -0,0 +1,47 @@
diff --git a/build.zig.zon b/build.zig.zon
index f80cd53b3..e6141add7 100644
--- a/build.zig.zon
+++ b/build.zig.zon
@@ -26,10 +26,7 @@
.lazy = true,
},
.zig_objc = .{
- // mitchellh/zig-objc
- .url = "https://deps.files.ghostty.org/zig_objc-f356ed02833f0f1b8e84d50bed9e807bf7cdc0ae.tar.gz",
- .hash = "zig_objc-0.0.0-Ir_Sp5gTAQCvxxR7oVIrPXxXwsfKgVP7_wqoOQrZjFeK",
- .lazy = true,
+ .path = ".libghostty-spm/zig_objc",
},
.zig_js = .{
// mitchellh/zig-js
diff --git a/pkg/macos/video/display_link.zig b/pkg/macos/video/display_link.zig
index 4866531f2..18e9e40ec 100644
--- a/pkg/macos/video/display_link.zig
+++ b/pkg/macos/video/display_link.zig
@@ -2,6 +2,8 @@ const std = @import("std");
const assert = std.debug.assert;
const Allocator = std.mem.Allocator;
const c = @import("c.zig").c;
+extern "c" fn ghostty_arm64e_sign_function(*const anyopaque) *const anyopaque;
+
pub const DisplayLink = opaque {
pub const Error = error{
@@ -58,7 +60,7 @@ pub const DisplayLink = opaque {
) Error!void {
if (c.CVDisplayLinkSetOutputCallback(
@ptrCast(self),
- @ptrCast(&(struct {
+ @ptrCast(@alignCast(ghostty_arm64e_sign_function(@ptrCast(&(struct {
fn callback(
displayLink: *DisplayLink,
inNow: *const c.CVTimeStamp,
@@ -78,7 +80,7 @@ pub const DisplayLink = opaque {
);
return c.kCVReturnSuccess;
}
- }).callback),
+ }).callback)))),
userinfo,
) != c.kCVReturnSuccess)
return error.InvalidOperation;

View File

@@ -0,0 +1,64 @@
diff --git a/src/block.zig b/src/block.zig
index bd4b17d..4d43262 100644
--- a/src/block.zig
+++ b/src/block.zig
@@ -4,6 +4,7 @@ const assert = std.debug.assert;
const Allocator = std.mem.Allocator;
const objc = @import("main.zig");
+extern "c" fn ghostty_arm64e_prepare_block(*anyopaque) void;
// We have to use the raw C allocator for all heap allocation in here
// because the objc runtime expects `malloc` to be used. If you don't use
// malloc you'll get segfaults because the objc runtime will try to free
@@ -42,7 +43,7 @@ pub fn Block(
const Self = @This();
const captures_info = @typeInfo(Captures).@"struct";
const InvokeFn = FnType(anyopaque);
- const descriptor: Descriptor = .{
+ var descriptor: Descriptor = .{
.reserved = 0,
.size = @sizeOf(Context),
.copy_helper = &descCopyHelper,
@@ -90,6 +91,7 @@ pub fn Block(
@field(ctx, field.name) = @field(captures, field.name);
}
+ ghostty_arm64e_prepare_block(@ptrCast(&ctx));
return ctx;
}
diff --git a/src/class.zig b/src/class.zig
index e0c989c..697fe31 100644
--- a/src/class.zig
+++ b/src/class.zig
@@ -7,5 +7,7 @@ const objc = @import("main.zig");
const MsgSend = @import("msg_send.zig").MsgSend;
+extern "c" fn ghostty_arm64e_sign_function(*const anyopaque) *const anyopaque;
+
pub const Class = struct {
value: c.Class,
@@ -65,7 +67,12 @@ pub const Class = struct {
assert(fn_info.params.len >= 2);
assert(fn_info.params[0].type == c.id);
assert(fn_info.params[1].type == c.SEL);
- _ = c.class_replaceMethod(self.value, objc.sel(name).value, @ptrCast(&imp), null);
+ _ = c.class_replaceMethod(
+ self.value,
+ objc.sel(name).value,
+ @ptrCast(@alignCast(ghostty_arm64e_sign_function(@ptrCast(&imp)))),
+ null,
+ );
}
// allows adding new methods; returns true on success.
@@ -81,7 +88,7 @@ pub const Class = struct {
return boolResult(c.class_addMethod(
self.value,
objc.sel(name).value,
- @ptrCast(&imp),
+ @ptrCast(@alignCast(ghostty_arm64e_sign_function(@ptrCast(&imp)))),
&encoding,
));
}

View File

@@ -0,0 +1,61 @@
#!/bin/zsh
set -euo pipefail
SOURCE_DIR=${1:-}
if [ -z "$SOURCE_DIR" ]; then
echo "[-] missing source_dir"
exit 1
fi
BUILD_ZIG="$SOURCE_DIR/build.zig"
MARKER="libghostty static install for Darwin"
if [ ! -f "$BUILD_ZIG" ]; then
echo "[-] build.zig not found: $BUILD_ZIG"
exit 1
fi
if grep -Fq "$MARKER" "$BUILD_ZIG"; then
echo "[+] patch already applied: 0001-darwin-libghostty-install"
exit 0
fi
if grep -Fq "const lib_shared = try buildpkg.GhosttyLib.initShared" "$BUILD_ZIG"; then
sed -i '' \
'/We shouldn'\''t have this guard but we don'\''t currently/,/^ }$/c\
// libghostty static install for Darwin:\
// upstream only wires this for non-Darwin today, but we need the\
// static archive for our own XCFramework assembly pipeline.\
lib_shared.installHeader();\
if (config.target.result.os.tag.isDarwin()) {\
lib_static.install("libghostty.a");\
} else if (config.target.result.os.tag == .windows) {\
lib_shared.install("ghostty-internal.dll");\
lib_static.install("ghostty-internal-static.lib");\
} else {\
lib_shared.install("ghostty-internal.so");\
lib_static.install("ghostty-internal.a");\
}' \
"$BUILD_ZIG"
else
sed -i '' \
'/We shouldn'\''t have this guard but we don'\''t currently/,/^ }$/c\
// libghostty static install for Darwin:\
// upstream only wires this for non-Darwin today, but we need the\
// static archive for our own XCFramework assembly pipeline.\
libghostty_shared.installHeader(); // Only need one header\
if (!config.target.result.os.tag.isDarwin()) {\
libghostty_shared.install("libghostty.so");\
}\
libghostty_static.install("libghostty.a");' \
"$BUILD_ZIG"
fi
if ! grep -Fq "$MARKER" "$BUILD_ZIG"; then
echo "[-] failed to apply patch: 0001-darwin-libghostty-install"
exit 1
fi
echo "[+] applied patch: 0001-darwin-libghostty-install"

View File

@@ -0,0 +1,536 @@
diff --git a/include/ghostty.h b/include/ghostty.h
index 72bbb57a8..d9e399220 100644
--- a/include/ghostty.h
+++ b/include/ghostty.h
@@ -464,10 +464,26 @@ typedef enum {
GHOSTTY_SURFACE_CONTEXT_SPLIT = 2,
} ghostty_surface_context_e;
+typedef enum {
+ GHOSTTY_SURFACE_IO_BACKEND_EXEC = 0,
+ GHOSTTY_SURFACE_IO_BACKEND_HOST_MANAGED = 1,
+} ghostty_surface_io_backend_e;
+
+typedef void (*ghostty_surface_receive_buffer_cb)(void*, const uint8_t*, size_t);
+typedef void (*ghostty_surface_receive_resize_cb)(void*,
+ uint16_t,
+ uint16_t,
+ uint32_t,
+ uint32_t);
+
typedef struct {
ghostty_platform_e platform_tag;
ghostty_platform_u platform;
void* userdata;
+ ghostty_surface_io_backend_e backend;
+ void* receive_userdata;
+ ghostty_surface_receive_buffer_cb receive_buffer;
+ ghostty_surface_receive_resize_cb receive_resize;
double scale_factor;
float font_size;
const char* working_directory;
@@ -1109,6 +1125,8 @@ GHOSTTY_API ghostty_surface_config_s ghostty_surface_inherited_config(ghostty_su
GHOSTTY_API void ghostty_surface_update_config(ghostty_surface_t, ghostty_config_t);
GHOSTTY_API bool ghostty_surface_needs_confirm_quit(ghostty_surface_t);
GHOSTTY_API bool ghostty_surface_process_exited(ghostty_surface_t);
+GHOSTTY_API void ghostty_surface_write_buffer(ghostty_surface_t, const uint8_t*, uintptr_t);
+GHOSTTY_API void ghostty_surface_process_exit(ghostty_surface_t, uint32_t, uint64_t);
GHOSTTY_API void ghostty_surface_refresh(ghostty_surface_t);
GHOSTTY_API void ghostty_surface_draw(ghostty_surface_t);
GHOSTTY_API void ghostty_surface_set_content_scale(ghostty_surface_t, double, double);
diff --git a/src/Surface.zig b/src/Surface.zig
index 2c4edc497..b35c4465e 100644
--- a/src/Surface.zig
+++ b/src/Surface.zig
@@ -628,40 +628,48 @@ pub fn init(
// This separate block ({}) is important because our errdefers must
// be scoped here to be valid.
{
- var env = rt_surface.defaultTermioEnv() catch |err| env: {
- // If an error occurs, we don't want to block surface startup.
- log.warn("error getting env map for surface err={}", .{err});
- break :env internal_os.getEnvMap(alloc) catch
- std.process.EnvMap.init(alloc);
- };
- errdefer env.deinit();
+ const backend: termio.Backend = switch (rt_surface.termioBackend()) {
+ .exec => backend: {
+ var env = rt_surface.defaultTermioEnv() catch |err| env: {
+ log.warn("error getting env map for surface err={}", .{err});
+ break :env internal_os.getEnvMap(alloc) catch
+ std.process.EnvMap.init(alloc);
+ };
+ errdefer env.deinit();
- // don't leak GHOSTTY_LOG to any subprocesses
- env.remove("GHOSTTY_LOG");
+ env.remove("GHOSTTY_LOG");
- var buf: [18]u8 = undefined;
- try env.put(
- "GHOSTTY_SURFACE_ID",
- std.fmt.bufPrint(&buf, "0x{x:0>16}", .{self.id}) catch unreachable,
- );
+ var buf: [18]u8 = undefined;
+ try env.put(
+ "GHOSTTY_SURFACE_ID",
+ std.fmt.bufPrint(&buf, "0x{x:0>16}", .{self.id}) catch unreachable,
+ );
- // Initialize our IO backend
- var io_exec = try termio.Exec.init(alloc, .{
- .command = command,
- .env = env,
- .env_override = config.env,
- .shell_integration = config.@"shell-integration",
- .shell_integration_features = config.@"shell-integration-features",
- .cursor_blink = config.@"cursor-style-blink",
- .working_directory = if (config.@"working-directory") |wd| wd.value() else null,
- .resources_dir = global_state.resources_dir.host(),
- .term = config.term,
- .rt_pre_exec_info = .init(config),
- .rt_post_fork_info = .init(config),
- });
- errdefer io_exec.deinit();
+ var io_exec = try termio.Exec.init(alloc, .{
+ .command = command,
+ .env = env,
+ .env_override = config.env,
+ .shell_integration = config.@"shell-integration",
+ .shell_integration_features = config.@"shell-integration-features",
+ .cursor_blink = config.@"cursor-style-blink",
+ .working_directory = if (config.@"working-directory") |wd| wd.value() else null,
+ .resources_dir = global_state.resources_dir.host(),
+ .term = config.term,
+ .rt_pre_exec_info = .init(config),
+ .rt_post_fork_info = .init(config),
+ });
+ errdefer io_exec.deinit();
+
+ break :backend .{ .exec = io_exec };
+ },
+
+ .host_managed => .{
+ .host_managed = termio.HostManaged.init(
+ try rt_surface.hostManagedConfig(),
+ ),
+ },
+ };
- // Initialize our IO mailbox
var io_mailbox = try termio.Mailbox.initSPSC(alloc);
errdefer io_mailbox.deinit(alloc);
@@ -669,7 +677,7 @@ pub fn init(
.size = size,
.full_config = config,
.config = try termio.Termio.DerivedConfig.init(alloc, config),
- .backend = .{ .exec = io_exec },
+ .backend = backend,
.mailbox = io_mailbox,
.renderer_state = &self.renderer_state,
.renderer_wakeup = render_thread.wakeup,
@@ -1311,9 +1319,11 @@ fn childExitedAbnormally(
const alloc = arena.allocator();
// Build up our command for the error message
- const command = try std.mem.join(alloc, " ", switch (self.io.backend) {
- .exec => |*exec| exec.subprocess.args,
- });
+ const command = switch (self.io.backend) {
+ .exec => |*exec| try std.mem.join(alloc, " ", exec.subprocess.args),
+ .host_managed => try alloc.dupe(u8, "host-managed session"),
+ };
+ defer alloc.free(command);
const runtime_str = try std.fmt.allocPrint(alloc, "{d} ms", .{info.runtime_ms});
self.renderer_state.mutex.lock();
diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig
index 7310159cc..0180884fc 100644
--- a/src/apprt/embedded.zig
+++ b/src/apprt/embedded.zig
@@ -15,6 +15,7 @@ const input = @import("../input.zig");
const internal_os = @import("../os/main.zig");
const renderer = @import("../renderer.zig");
const terminal = @import("../terminal/main.zig");
+const termio = @import("../termio.zig");
const CoreApp = @import("../App.zig");
const CoreInspector = @import("../inspector/main.zig").Inspector;
const CoreSurface = @import("../Surface.zig");
@@ -411,6 +412,8 @@ pub const Surface = struct {
app: *App,
platform: Platform,
userdata: ?*anyopaque = null,
+ termio_backend: TermioBackend = .exec,
+ host_io: HostIO = .{},
core_surface: CoreSurface,
content_scale: apprt.ContentScale,
size: apprt.SurfaceSize,
@@ -421,6 +424,17 @@ pub const Surface = struct {
/// that getTitle works without the implementer needing to save it.
title: ?[:0]const u8 = null,
+ pub const TermioBackend = enum(c_int) {
+ exec = 0,
+ host_managed = 1,
+ };
+
+ pub const HostIO = struct {
+ userdata: ?*anyopaque = null,
+ receive_buffer: ?termio.HostManaged.Config.Write = null,
+ receive_resize: ?termio.HostManaged.Config.Resize = null,
+ };
+
/// Surface initialization options.
pub const Options = extern struct {
/// The platform that this surface is being initialized for and
@@ -431,6 +445,20 @@ pub const Surface = struct {
/// Userdata passed to some of the callbacks.
userdata: ?*anyopaque = null,
+ /// The backend used to satisfy terminal IO for this surface.
+ backend: TermioBackend = .exec,
+
+ /// Userdata passed to host-managed IO callbacks.
+ receive_userdata: ?*anyopaque = null,
+
+ /// Called when Ghostty emits bytes that should be sent to the host
+ /// transport.
+ receive_buffer: ?termio.HostManaged.Config.Write = null,
+
+ /// Called when the terminal size changes and the host transport
+ /// should be updated.
+ receive_resize: ?termio.HostManaged.Config.Resize = null,
+
/// The scale factor of the screen.
scale_factor: f64 = 1,
@@ -469,6 +497,12 @@ pub const Surface = struct {
.app = app,
.platform = try .init(opts.platform_tag, opts.platform),
.userdata = opts.userdata,
+ .termio_backend = opts.backend,
+ .host_io = .{
+ .userdata = opts.receive_userdata orelse opts.userdata,
+ .receive_buffer = opts.receive_buffer,
+ .receive_resize = opts.receive_resize,
+ },
.core_surface = undefined,
.content_scale = .{
.x = @floatCast(opts.scale_factor),
@@ -634,6 +668,19 @@ pub const Surface = struct {
return &self.core_surface;
}
+ pub fn termioBackend(self: *const Surface) TermioBackend {
+ return self.termio_backend;
+ }
+
+ pub fn hostManagedConfig(self: *const Surface) !termio.HostManaged.Config {
+ const write = self.host_io.receive_buffer orelse return error.HostManagedWriteRequired;
+ return .{
+ .userdata = self.host_io.userdata,
+ .write = write,
+ .resize = self.host_io.receive_resize,
+ };
+ }
+
pub fn rtApp(self: *const Surface) *App {
return self.app;
}
@@ -1600,6 +1647,28 @@ pub const CAPI = struct {
return surface.core_surface.child_exited;
}
+ export fn ghostty_surface_write_buffer(
+ surface: *Surface,
+ ptr: [*]const u8,
+ len: usize,
+ ) void {
+ if (len == 0) return;
+ surface.core_surface.io.processOutput(ptr[0..len]);
+ }
+
+ export fn ghostty_surface_process_exit(
+ surface: *Surface,
+ exit_code: u32,
+ runtime_ms: u64,
+ ) void {
+ _ = surface.core_surface.io.surface_mailbox.push(.{
+ .child_exited = .{
+ .exit_code = exit_code,
+ .runtime_ms = runtime_ms,
+ },
+ }, .{ .forever = {} });
+ }
+
/// Returns true if the surface has a selection.
export fn ghostty_surface_has_selection(surface: *Surface) bool {
return surface.core_surface.hasSelection();
diff --git a/src/termio.zig b/src/termio.zig
index b16885109..6931fcb58 100644
--- a/src/termio.zig
+++ b/src/termio.zig
@@ -23,6 +23,7 @@ const message = @import("termio/message.zig");
pub const backend = @import("termio/backend.zig");
pub const mailbox = @import("termio/mailbox.zig");
pub const Exec = @import("termio/Exec.zig");
+pub const HostManaged = @import("termio/HostManaged.zig");
pub const Options = @import("termio/Options.zig");
pub const Termio = @import("termio/Termio.zig");
pub const Thread = @import("termio/Thread.zig");
diff --git a/src/termio/HostManaged.zig b/src/termio/HostManaged.zig
new file mode 100644
index 000000000..0d1821350
--- /dev/null
+++ b/src/termio/HostManaged.zig
@@ -0,0 +1,136 @@
+const HostManaged = @This();
+
+const std = @import("std");
+const Allocator = std.mem.Allocator;
+const renderer = @import("../renderer.zig");
+const terminal = @import("../terminal/main.zig");
+const termio = @import("../termio.zig");
+
+userdata: ?*anyopaque = null,
+write: Config.Write,
+resize_cb: ?Config.Resize = null,
+grid_size: renderer.GridSize = .{
+ .columns = 0,
+ .rows = 0,
+},
+screen_size: renderer.ScreenSize = .{
+ .width = 0,
+ .height = 0,
+},
+
+pub const Config = struct {
+ pub const Write = *const fn (?*anyopaque, [*]const u8, usize) callconv(.c) void;
+ pub const Resize = *const fn (?*anyopaque, u16, u16, u32, u32) callconv(.c) void;
+
+ userdata: ?*anyopaque = null,
+ write: Write,
+ resize: ?Resize = null,
+};
+
+pub fn init(cfg: Config) HostManaged {
+ return .{
+ .userdata = cfg.userdata,
+ .write = cfg.write,
+ .resize_cb = cfg.resize,
+ };
+}
+
+pub fn deinit(self: *HostManaged) void {
+ _ = self;
+}
+
+pub fn initTerminal(self: *HostManaged, term: *terminal.Terminal) void {
+ self.grid_size = .{
+ .columns = term.cols,
+ .rows = term.rows,
+ };
+ self.screen_size = .{
+ .width = term.width_px,
+ .height = term.height_px,
+ };
+}
+
+pub fn threadEnter(
+ self: *HostManaged,
+ alloc: Allocator,
+ io: *termio.Termio,
+ td: *termio.Termio.ThreadData,
+) !void {
+ _ = alloc;
+ _ = io;
+
+ td.backend = .{ .host_managed = .{} };
+ self.notifyResize();
+}
+
+pub fn threadExit(self: *HostManaged, td: *termio.Termio.ThreadData) void {
+ _ = self;
+ _ = td;
+}
+
+pub fn focusGained(
+ self: *HostManaged,
+ td: *termio.Termio.ThreadData,
+ focused: bool,
+) !void {
+ _ = self;
+ _ = td;
+ _ = focused;
+}
+
+pub fn resize(
+ self: *HostManaged,
+ grid_size: renderer.GridSize,
+ screen_size: renderer.ScreenSize,
+) !void {
+ self.grid_size = grid_size;
+ self.screen_size = screen_size;
+ self.notifyResize();
+}
+
+pub fn queueWrite(
+ self: *HostManaged,
+ alloc: Allocator,
+ td: *termio.Termio.ThreadData,
+ data: []const u8,
+ linefeed: bool,
+) !void {
+ _ = alloc;
+ _ = td;
+ _ = linefeed;
+
+ if (data.len == 0) return;
+ self.write(self.userdata, data.ptr, data.len);
+}
+
+pub fn childExitedAbnormally(
+ self: *HostManaged,
+ gpa: Allocator,
+ t: *terminal.Terminal,
+ exit_code: u32,
+ runtime_ms: u64,
+) !void {
+ _ = self;
+ _ = gpa;
+ _ = t;
+ _ = exit_code;
+ _ = runtime_ms;
+}
+
+fn notifyResize(self: *HostManaged) void {
+ const resize_cb = self.resize_cb orelse return;
+ resize_cb(
+ self.userdata,
+ self.grid_size.columns,
+ self.grid_size.rows,
+ self.screen_size.width,
+ self.screen_size.height,
+ );
+}
+
+pub const ThreadData = struct {
+ pub fn deinit(self: *ThreadData, alloc: Allocator) void {
+ _ = self;
+ _ = alloc;
+ }
+};
diff --git a/src/termio/backend.zig b/src/termio/backend.zig
index c29009acb..9857cbd7a 100644
--- a/src/termio/backend.zig
+++ b/src/termio/backend.zig
@@ -11,28 +11,34 @@ const ProcessInfo = @import("../pty.zig").ProcessInfo;
const WRITE_REQ_PREALLOC = std.math.pow(usize, 2, 5);
/// The kinds of backends.
-pub const Kind = enum { exec };
+pub const Kind = enum { exec, host_managed };
/// Configuration for the various backend types.
pub const Config = union(Kind) {
/// Exec uses posix exec to run a command with a pty.
exec: termio.Exec.Config,
+
+ /// Host managed forwards terminal IO through embedder callbacks.
+ host_managed: termio.HostManaged.Config,
};
/// Backend implementations. A backend is responsible for owning the pty
/// behavior and providing read/write capabilities.
pub const Backend = union(Kind) {
exec: termio.Exec,
+ host_managed: termio.HostManaged,
pub fn deinit(self: *Backend) void {
switch (self.*) {
.exec => |*exec| exec.deinit(),
+ .host_managed => |*host_managed| host_managed.deinit(),
}
}
pub fn initTerminal(self: *Backend, t: *terminal.Terminal) void {
switch (self.*) {
.exec => |*exec| exec.initTerminal(t),
+ .host_managed => |*host_managed| host_managed.initTerminal(t),
}
}
@@ -44,12 +50,14 @@ pub const Backend = union(Kind) {
) !void {
switch (self.*) {
.exec => |*exec| try exec.threadEnter(alloc, io, td),
+ .host_managed => |*host_managed| try host_managed.threadEnter(alloc, io, td),
}
}
pub fn threadExit(self: *Backend, td: *termio.Termio.ThreadData) void {
switch (self.*) {
.exec => |*exec| exec.threadExit(td),
+ .host_managed => |*host_managed| host_managed.threadExit(td),
}
}
@@ -60,6 +68,7 @@ pub const Backend = union(Kind) {
) !void {
switch (self.*) {
.exec => |*exec| try exec.focusGained(td, focused),
+ .host_managed => |*host_managed| try host_managed.focusGained(td, focused),
}
}
@@ -70,6 +79,7 @@ pub const Backend = union(Kind) {
) !void {
switch (self.*) {
.exec => |*exec| try exec.resize(grid_size, screen_size),
+ .host_managed => |*host_managed| try host_managed.resize(grid_size, screen_size),
}
}
@@ -82,6 +92,7 @@ pub const Backend = union(Kind) {
) !void {
switch (self.*) {
.exec => |*exec| try exec.queueWrite(alloc, td, data, linefeed),
+ .host_managed => |*host_managed| try host_managed.queueWrite(alloc, td, data, linefeed),
}
}
@@ -99,6 +110,12 @@ pub const Backend = union(Kind) {
exit_code,
runtime_ms,
),
+ .host_managed => |*host_managed| try host_managed.childExitedAbnormally(
+ gpa,
+ t,
+ exit_code,
+ runtime_ms,
+ ),
}
}
@@ -108,6 +125,7 @@ pub const Backend = union(Kind) {
pub fn getProcessInfo(self: *Backend, comptime info: ProcessInfo) ?ProcessInfo.Type(info) {
return switch (self.*) {
.exec => |*exec| exec.getProcessInfo(info),
+ .host_managed => null,
};
}
};
@@ -115,10 +133,12 @@ pub const Backend = union(Kind) {
/// Termio thread data. See termio.ThreadData for docs.
pub const ThreadData = union(Kind) {
exec: termio.Exec.ThreadData,
+ host_managed: termio.HostManaged.ThreadData,
pub fn deinit(self: *ThreadData, alloc: Allocator) void {
switch (self.*) {
.exec => |*exec| exec.deinit(alloc),
+ .host_managed => |*host_managed| host_managed.deinit(alloc),
}
}

View File

@@ -0,0 +1,518 @@
diff --git a/include/ghostty.h b/include/ghostty.h
index 40ff55c..4c4f01b 100644
--- a/include/ghostty.h
+++ b/include/ghostty.h
@@ -437,10 +437,26 @@ typedef enum {
GHOSTTY_SURFACE_CONTEXT_SPLIT = 2,
} ghostty_surface_context_e;
+typedef enum {
+ GHOSTTY_SURFACE_IO_BACKEND_EXEC = 0,
+ GHOSTTY_SURFACE_IO_BACKEND_HOST_MANAGED = 1,
+} ghostty_surface_io_backend_e;
+
+typedef void (*ghostty_surface_receive_buffer_cb)(void*, const uint8_t*, size_t);
+typedef void (*ghostty_surface_receive_resize_cb)(void*,
+ uint16_t,
+ uint16_t,
+ uint32_t,
+ uint32_t);
+
typedef struct {
ghostty_platform_e platform_tag;
ghostty_platform_u platform;
void* userdata;
+ ghostty_surface_io_backend_e backend;
+ void* receive_userdata;
+ ghostty_surface_receive_buffer_cb receive_buffer;
+ ghostty_surface_receive_resize_cb receive_resize;
double scale_factor;
float font_size;
const char* working_directory;
@@ -1080,6 +1096,8 @@ ghostty_surface_config_s ghostty_surface_inherited_config(ghostty_surface_t, gho
void ghostty_surface_update_config(ghostty_surface_t, ghostty_config_t);
bool ghostty_surface_needs_confirm_quit(ghostty_surface_t);
bool ghostty_surface_process_exited(ghostty_surface_t);
+void ghostty_surface_write_buffer(ghostty_surface_t, const uint8_t*, uintptr_t);
+void ghostty_surface_process_exit(ghostty_surface_t, uint32_t, uint64_t);
void ghostty_surface_refresh(ghostty_surface_t);
void ghostty_surface_draw(ghostty_surface_t);
void ghostty_surface_set_content_scale(ghostty_surface_t, double, double);
diff --git a/src/Surface.zig b/src/Surface.zig
index 4d66622..145d113 100644
--- a/src/Surface.zig
+++ b/src/Surface.zig
@@ -620,34 +620,42 @@ pub fn init(
// This separate block ({}) is important because our errdefers must
// be scoped here to be valid.
{
- var env = rt_surface.defaultTermioEnv() catch |err| env: {
- // If an error occurs, we don't want to block surface startup.
- log.warn("error getting env map for surface err={}", .{err});
- break :env internal_os.getEnvMap(alloc) catch
- std.process.EnvMap.init(alloc);
+ const backend: termio.Backend = switch (rt_surface.termioBackend()) {
+ .exec => backend: {
+ var env = rt_surface.defaultTermioEnv() catch |err| env: {
+ log.warn("error getting env map for surface err={}", .{err});
+ break :env internal_os.getEnvMap(alloc) catch
+ std.process.EnvMap.init(alloc);
+ };
+ errdefer env.deinit();
+
+ env.remove("GHOSTTY_LOG");
+
+ var io_exec = try termio.Exec.init(alloc, .{
+ .command = command,
+ .env = env,
+ .env_override = config.env,
+ .shell_integration = config.@"shell-integration",
+ .shell_integration_features = config.@"shell-integration-features",
+ .cursor_blink = config.@"cursor-style-blink",
+ .working_directory = if (config.@"working-directory") |wd| wd.value() else null,
+ .resources_dir = global_state.resources_dir.host(),
+ .term = config.term,
+ .rt_pre_exec_info = .init(config),
+ .rt_post_fork_info = .init(config),
+ });
+ errdefer io_exec.deinit();
+
+ break :backend .{ .exec = io_exec };
+ },
+
+ .host_managed => .{
+ .host_managed = termio.HostManaged.init(
+ try rt_surface.hostManagedConfig(),
+ ),
+ },
};
- errdefer env.deinit();
-
- // don't leak GHOSTTY_LOG to any subprocesses
- env.remove("GHOSTTY_LOG");
-
- // Initialize our IO backend
- var io_exec = try termio.Exec.init(alloc, .{
- .command = command,
- .env = env,
- .env_override = config.env,
- .shell_integration = config.@"shell-integration",
- .shell_integration_features = config.@"shell-integration-features",
- .cursor_blink = config.@"cursor-style-blink",
- .working_directory = if (config.@"working-directory") |wd| wd.value() else null,
- .resources_dir = global_state.resources_dir.host(),
- .term = config.term,
- .rt_pre_exec_info = .init(config),
- .rt_post_fork_info = .init(config),
- });
- errdefer io_exec.deinit();
- // Initialize our IO mailbox
var io_mailbox = try termio.Mailbox.initSPSC(alloc);
errdefer io_mailbox.deinit(alloc);
@@ -655,7 +663,7 @@ pub fn init(
.size = size,
.full_config = config,
.config = try termio.Termio.DerivedConfig.init(alloc, config),
- .backend = .{ .exec = io_exec },
+ .backend = backend,
.mailbox = io_mailbox,
.renderer_state = &self.renderer_state,
.renderer_wakeup = render_thread.wakeup,
@@ -1289,9 +1297,11 @@ fn childExitedAbnormally(
const alloc = arena.allocator();
// Build up our command for the error message
- const command = try std.mem.join(alloc, " ", switch (self.io.backend) {
- .exec => |*exec| exec.subprocess.args,
- });
+ const command = switch (self.io.backend) {
+ .exec => |*exec| try std.mem.join(alloc, " ", exec.subprocess.args),
+ .host_managed => try alloc.dupe(u8, "host-managed session"),
+ };
+ defer alloc.free(command);
const runtime_str = try std.fmt.allocPrint(alloc, "{d} ms", .{info.runtime_ms});
self.renderer_state.mutex.lock();
diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig
index 0d5a4f8..b44d4d3 100644
--- a/src/apprt/embedded.zig
+++ b/src/apprt/embedded.zig
@@ -15,6 +15,7 @@ const input = @import("../input.zig");
const internal_os = @import("../os/main.zig");
const renderer = @import("../renderer.zig");
const terminal = @import("../terminal/main.zig");
+const termio = @import("../termio.zig");
const CoreApp = @import("../App.zig");
const CoreInspector = @import("../inspector/main.zig").Inspector;
const CoreSurface = @import("../Surface.zig");
@@ -409,6 +410,8 @@ pub const Surface = struct {
app: *App,
platform: Platform,
userdata: ?*anyopaque = null,
+ termio_backend: TermioBackend = .exec,
+ host_io: HostIO = .{},
core_surface: CoreSurface,
content_scale: apprt.ContentScale,
size: apprt.SurfaceSize,
@@ -419,6 +422,17 @@ pub const Surface = struct {
/// that getTitle works without the implementer needing to save it.
title: ?[:0]const u8 = null,
+ pub const TermioBackend = enum(c_int) {
+ exec = 0,
+ host_managed = 1,
+ };
+
+ pub const HostIO = struct {
+ userdata: ?*anyopaque = null,
+ receive_buffer: ?termio.HostManaged.Config.Write = null,
+ receive_resize: ?termio.HostManaged.Config.Resize = null,
+ };
+
/// Surface initialization options.
pub const Options = extern struct {
/// The platform that this surface is being initialized for and
@@ -429,6 +443,20 @@ pub const Surface = struct {
/// Userdata passed to some of the callbacks.
userdata: ?*anyopaque = null,
+ /// The backend used to satisfy terminal IO for this surface.
+ backend: TermioBackend = .exec,
+
+ /// Userdata passed to host-managed IO callbacks.
+ receive_userdata: ?*anyopaque = null,
+
+ /// Called when Ghostty emits bytes that should be sent to the host
+ /// transport.
+ receive_buffer: ?termio.HostManaged.Config.Write = null,
+
+ /// Called when the terminal size changes and the host transport
+ /// should be updated.
+ receive_resize: ?termio.HostManaged.Config.Resize = null,
+
/// The scale factor of the screen.
scale_factor: f64 = 1,
@@ -467,6 +495,12 @@ pub const Surface = struct {
.app = app,
.platform = try .init(opts.platform_tag, opts.platform),
.userdata = opts.userdata,
+ .termio_backend = opts.backend,
+ .host_io = .{
+ .userdata = opts.receive_userdata orelse opts.userdata,
+ .receive_buffer = opts.receive_buffer,
+ .receive_resize = opts.receive_resize,
+ },
.core_surface = undefined,
.content_scale = .{
.x = @floatCast(opts.scale_factor),
@@ -632,6 +666,19 @@ pub const Surface = struct {
return &self.core_surface;
}
+ pub fn termioBackend(self: *const Surface) TermioBackend {
+ return self.termio_backend;
+ }
+
+ pub fn hostManagedConfig(self: *const Surface) !termio.HostManaged.Config {
+ const write = self.host_io.receive_buffer orelse return error.HostManagedWriteRequired;
+ return .{
+ .userdata = self.host_io.userdata,
+ .write = write,
+ .resize = self.host_io.receive_resize,
+ };
+ }
+
pub fn rtApp(self: *const Surface) *App {
return self.app;
}
@@ -1598,6 +1645,28 @@ pub const CAPI = struct {
return surface.core_surface.child_exited;
}
+ export fn ghostty_surface_write_buffer(
+ surface: *Surface,
+ ptr: [*]const u8,
+ len: usize,
+ ) void {
+ if (len == 0) return;
+ surface.core_surface.io.processOutput(ptr[0..len]);
+ }
+
+ export fn ghostty_surface_process_exit(
+ surface: *Surface,
+ exit_code: u32,
+ runtime_ms: u64,
+ ) void {
+ _ = surface.core_surface.io.surface_mailbox.push(.{
+ .child_exited = .{
+ .exit_code = exit_code,
+ .runtime_ms = runtime_ms,
+ },
+ }, .{ .forever = {} });
+ }
+
/// Returns true if the surface has a selection.
export fn ghostty_surface_has_selection(surface: *Surface) bool {
return surface.core_surface.hasSelection();
diff --git a/src/termio.zig b/src/termio.zig
index b168851..6931fcb 100644
--- a/src/termio.zig
+++ b/src/termio.zig
@@ -23,6 +23,7 @@ const message = @import("termio/message.zig");
pub const backend = @import("termio/backend.zig");
pub const mailbox = @import("termio/mailbox.zig");
pub const Exec = @import("termio/Exec.zig");
+pub const HostManaged = @import("termio/HostManaged.zig");
pub const Options = @import("termio/Options.zig");
pub const Termio = @import("termio/Termio.zig");
pub const Thread = @import("termio/Thread.zig");
diff --git a/src/termio/HostManaged.zig b/src/termio/HostManaged.zig
new file mode 100644
index 0000000..0d18213
--- /dev/null
+++ b/src/termio/HostManaged.zig
@@ -0,0 +1,136 @@
+const HostManaged = @This();
+
+const std = @import("std");
+const Allocator = std.mem.Allocator;
+const renderer = @import("../renderer.zig");
+const terminal = @import("../terminal/main.zig");
+const termio = @import("../termio.zig");
+
+userdata: ?*anyopaque = null,
+write: Config.Write,
+resize_cb: ?Config.Resize = null,
+grid_size: renderer.GridSize = .{
+ .columns = 0,
+ .rows = 0,
+},
+screen_size: renderer.ScreenSize = .{
+ .width = 0,
+ .height = 0,
+},
+
+pub const Config = struct {
+ pub const Write = *const fn (?*anyopaque, [*]const u8, usize) callconv(.c) void;
+ pub const Resize = *const fn (?*anyopaque, u16, u16, u32, u32) callconv(.c) void;
+
+ userdata: ?*anyopaque = null,
+ write: Write,
+ resize: ?Resize = null,
+};
+
+pub fn init(cfg: Config) HostManaged {
+ return .{
+ .userdata = cfg.userdata,
+ .write = cfg.write,
+ .resize_cb = cfg.resize,
+ };
+}
+
+pub fn deinit(self: *HostManaged) void {
+ _ = self;
+}
+
+pub fn initTerminal(self: *HostManaged, term: *terminal.Terminal) void {
+ self.grid_size = .{
+ .columns = term.cols,
+ .rows = term.rows,
+ };
+ self.screen_size = .{
+ .width = term.width_px,
+ .height = term.height_px,
+ };
+}
+
+pub fn threadEnter(
+ self: *HostManaged,
+ alloc: Allocator,
+ io: *termio.Termio,
+ td: *termio.Termio.ThreadData,
+) !void {
+ _ = alloc;
+ _ = io;
+
+ td.backend = .{ .host_managed = .{} };
+ self.notifyResize();
+}
+
+pub fn threadExit(self: *HostManaged, td: *termio.Termio.ThreadData) void {
+ _ = self;
+ _ = td;
+}
+
+pub fn focusGained(
+ self: *HostManaged,
+ td: *termio.Termio.ThreadData,
+ focused: bool,
+) !void {
+ _ = self;
+ _ = td;
+ _ = focused;
+}
+
+pub fn resize(
+ self: *HostManaged,
+ grid_size: renderer.GridSize,
+ screen_size: renderer.ScreenSize,
+) !void {
+ self.grid_size = grid_size;
+ self.screen_size = screen_size;
+ self.notifyResize();
+}
+
+pub fn queueWrite(
+ self: *HostManaged,
+ alloc: Allocator,
+ td: *termio.Termio.ThreadData,
+ data: []const u8,
+ linefeed: bool,
+) !void {
+ _ = alloc;
+ _ = td;
+ _ = linefeed;
+
+ if (data.len == 0) return;
+ self.write(self.userdata, data.ptr, data.len);
+}
+
+pub fn childExitedAbnormally(
+ self: *HostManaged,
+ gpa: Allocator,
+ t: *terminal.Terminal,
+ exit_code: u32,
+ runtime_ms: u64,
+) !void {
+ _ = self;
+ _ = gpa;
+ _ = t;
+ _ = exit_code;
+ _ = runtime_ms;
+}
+
+fn notifyResize(self: *HostManaged) void {
+ const resize_cb = self.resize_cb orelse return;
+ resize_cb(
+ self.userdata,
+ self.grid_size.columns,
+ self.grid_size.rows,
+ self.screen_size.width,
+ self.screen_size.height,
+ );
+}
+
+pub const ThreadData = struct {
+ pub fn deinit(self: *ThreadData, alloc: Allocator) void {
+ _ = self;
+ _ = alloc;
+ }
+};
diff --git a/src/termio/backend.zig b/src/termio/backend.zig
index ae0e200..f1d35d2 100644
--- a/src/termio/backend.zig
+++ b/src/termio/backend.zig
@@ -10,28 +10,34 @@ const termio = @import("../termio.zig");
const WRITE_REQ_PREALLOC = std.math.pow(usize, 2, 5);
/// The kinds of backends.
-pub const Kind = enum { exec };
+pub const Kind = enum { exec, host_managed };
/// Configuration for the various backend types.
pub const Config = union(Kind) {
/// Exec uses posix exec to run a command with a pty.
exec: termio.Exec.Config,
+
+ /// Host managed forwards terminal IO through embedder callbacks.
+ host_managed: termio.HostManaged.Config,
};
/// Backend implementations. A backend is responsible for owning the pty
/// behavior and providing read/write capabilities.
pub const Backend = union(Kind) {
exec: termio.Exec,
+ host_managed: termio.HostManaged,
pub fn deinit(self: *Backend) void {
switch (self.*) {
.exec => |*exec| exec.deinit(),
+ .host_managed => |*host_managed| host_managed.deinit(),
}
}
pub fn initTerminal(self: *Backend, t: *terminal.Terminal) void {
switch (self.*) {
.exec => |*exec| exec.initTerminal(t),
+ .host_managed => |*host_managed| host_managed.initTerminal(t),
}
}
@@ -43,12 +49,14 @@ pub const Backend = union(Kind) {
) !void {
switch (self.*) {
.exec => |*exec| try exec.threadEnter(alloc, io, td),
+ .host_managed => |*host_managed| try host_managed.threadEnter(alloc, io, td),
}
}
pub fn threadExit(self: *Backend, td: *termio.Termio.ThreadData) void {
switch (self.*) {
.exec => |*exec| exec.threadExit(td),
+ .host_managed => |*host_managed| host_managed.threadExit(td),
}
}
@@ -59,6 +67,7 @@ pub const Backend = union(Kind) {
) !void {
switch (self.*) {
.exec => |*exec| try exec.focusGained(td, focused),
+ .host_managed => |*host_managed| try host_managed.focusGained(td, focused),
}
}
@@ -69,6 +78,7 @@ pub const Backend = union(Kind) {
) !void {
switch (self.*) {
.exec => |*exec| try exec.resize(grid_size, screen_size),
+ .host_managed => |*host_managed| try host_managed.resize(grid_size, screen_size),
}
}
@@ -81,6 +91,7 @@ pub const Backend = union(Kind) {
) !void {
switch (self.*) {
.exec => |*exec| try exec.queueWrite(alloc, td, data, linefeed),
+ .host_managed => |*host_managed| try host_managed.queueWrite(alloc, td, data, linefeed),
}
}
@@ -98,6 +109,12 @@ pub const Backend = union(Kind) {
exit_code,
runtime_ms,
),
+ .host_managed => |*host_managed| try host_managed.childExitedAbnormally(
+ gpa,
+ t,
+ exit_code,
+ runtime_ms,
+ ),
}
}
};
@@ -105,10 +122,12 @@ pub const Backend = union(Kind) {
/// Termio thread data. See termio.ThreadData for docs.
pub const ThreadData = union(Kind) {
exec: termio.Exec.ThreadData,
+ host_managed: termio.HostManaged.ThreadData,
pub fn deinit(self: *ThreadData, alloc: Allocator) void {
switch (self.*) {
.exec => |*exec| exec.deinit(alloc),
+ .host_managed => |*host_managed| host_managed.deinit(alloc),
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,97 @@
#!/bin/bash
set -euo pipefail
SOURCE_DIR="${1:?Usage: $0 <ghostty-source-dir>}"
# Patch 1: cf_release_thread — ignore loop.run errors on iOS
# The kqueue-based event loop panics on iOS simulator due to mach port issues
CF_RELEASE="${SOURCE_DIR}/src/os/cf_release_thread.zig"
if [ -f "$CF_RELEASE" ]; then
if grep -q 'try self.loop.run(.until_done);' "$CF_RELEASE"; then
sed -i '' 's/try self\.loop\.run(\.until_done);/self.loop.run(.until_done) catch |err| { log.warn("cf release loop failed err={}", .{err}); return; };/' "$CF_RELEASE"
echo "[+] patched cf_release_thread to ignore loop errors"
else
echo "[+] cf_release_thread already patched"
fi
fi
# Patch 2: Disable private window blur API (App Store compliance)
EMBEDDED="${SOURCE_DIR}/src/apprt/embedded.zig"
if [ -f "$EMBEDDED" ]; then
if grep -q 'CGSSetWindowBackgroundBlurRadius' "$EMBEDDED"; then
python3 - "$EMBEDDED" <<'PY'
from pathlib import Path
import sys
path = Path(sys.argv[1])
text = path.read_text()
old_fn = """ export fn ghostty_set_window_background_blur(
app: *App,
window: *anyopaque,
) void {
// This is only supported on macOS
if (comptime builtin.target.os.tag != .macos) return;
const config = &app.config;
// Do nothing if we don't have background transparency enabled
if (config.@"background-opacity" >= 1.0) return;
const nswindow = objc.Object.fromId(window);
_ = CGSSetWindowBackgroundBlurRadius(
CGSDefaultConnectionForThread(),
nswindow.msgSend(usize, objc.sel("windowNumber"), .{}),
@intCast(config.@"background-blur".cval()),
);
}
/// See ghostty_set_window_background_blur
extern "c" fn CGSSetWindowBackgroundBlurRadius(*anyopaque, usize, c_int) i32;
extern "c" fn CGSDefaultConnectionForThread() *anyopaque;"""
new_fn = """ export fn ghostty_set_window_background_blur(
app: *App,
window: *anyopaque,
) void {
_ = app;
_ = window;
return;
}"""
if old_fn not in text:
print("[+] blur patch already applied or source changed")
else:
path.write_text(text.replace(old_fn, new_fn))
print("[+] patched: disabled private blur API")
PY
else
echo "[+] blur patch already applied"
fi
fi
# Patch 3: Link Metal frameworks
BUILD_ZIG="${SOURCE_DIR}/pkg/macos/build.zig"
if [ -f "$BUILD_ZIG" ]; then
if ! grep -q 'lib.linkFramework("Metal")' "$BUILD_ZIG"; then
perl -0pi -e 's/lib\.linkFramework\("IOSurface"\);/lib.linkFramework("IOSurface");\n lib.linkFramework("Metal");\n lib.linkFramework("MetalKit");/g' "$BUILD_ZIG"
perl -0pi -e 's/module\.linkFramework\("IOSurface", \.\{\}\);/module.linkFramework("IOSurface", .{});\n module.linkFramework("Metal", .{});\n module.linkFramework("MetalKit", .{});/g' "$BUILD_ZIG"
echo "[+] patched: linked Metal frameworks"
else
echo "[+] Metal frameworks already linked"
fi
fi
# Patch 4: Lower iOS deployment target to 15.0
CONFIG_ZIG="${SOURCE_DIR}/src/build/Config.zig"
if [ -f "$CONFIG_ZIG" ]; then
if grep -q '\.ios => \.{ \.semver = \.{' "$CONFIG_ZIG"; then
perl -0pi -e 's/\.ios => \.{ \.semver = \.{\n\s*\.major = \d+,\n\s*\.minor = \d+,\n\s*\.patch = \d+,/.ios => .{ .semver = .{\n .major = 15,\n .minor = 0,\n .patch = 0,/s' "$CONFIG_ZIG"
echo "[+] patched: iOS deployment target -> 15.0"
else
echo "[+] iOS deployment target already patched"
fi
fi
echo "[+] all ios-fixes patches applied"

View File

@@ -0,0 +1,473 @@
#!/bin/bash
set -euo pipefail
SOURCE_DIR="${1:?Usage: $0 <ghostty-source-dir>}"
# =============================================================================
# Patch 1: IOSurfaceLayer — iOS rendering compatibility
#
# Problem: On iOS, IOSurface dimensions may differ by ±1 pixel from the
# CALayer bounds due to rounding differences between UIKit point-to-pixel
# conversion and Metal's integer pixel sizes. The upstream code does an
# exact match and silently discards the surface, causing a blank screen.
#
# Additionally, iOS doesn't have a native CALayer subclass optimized for
# IOSurface content display. Using the private CAIOSurfaceLayer class
# (available since iOS 11) provides hardware-accelerated compositing.
#
# Fix:
# - Allow ±1px tolerance on iOS when comparing surface vs layer dimensions
# - Dynamically adjust contentsScale when dimensions don't match exactly
# - Use CAIOSurfaceLayer as base class on iOS for native IOSurface compositing
# - Mark layer as opaque since terminal content fills the entire bounds
# =============================================================================
IOSURFACE_LAYER="${SOURCE_DIR}/src/renderer/metal/IOSurfaceLayer.zig"
if [ -f "$IOSURFACE_LAYER" ]; then
if grep -q 'const log = std.log.scoped(.IOSurfaceLayer);' "$IOSURFACE_LAYER"; then
python3 - "$IOSURFACE_LAYER" <<'PY'
from pathlib import Path
import sys
path = Path(sys.argv[1])
src = path.read_text()
# Need builtin for comptime os.tag checks
src = src.replace(
'const std = @import("std");\nconst Allocator = std.mem.Allocator;',
'const std = @import("std");\nconst builtin = @import("builtin");\nconst Allocator = std.mem.Allocator;'
)
# The scoped log is only used in the size check we're replacing; drop it
src = src.replace('\nconst log = std.log.scoped(.IOSurfaceLayer);\n', '\n')
# Terminal surface is always fully opaque — tell the compositor
src = src.replace(
'layer.setProperty("contentsGravity", macos.animation.kCAGravityTopLeft);\n\n layer.setInstanceVariable',
'layer.setProperty("contentsGravity", macos.animation.kCAGravityTopLeft);\n layer.setProperty("opaque", true);\n\n layer.setInstanceVariable'
)
# Replace the strict size equality check with a platform-aware version.
# On iOS, UIKit's point→pixel rounding can produce a 1px discrepancy.
# Rather than dropping the frame entirely (→ blank screen), we accept it
# and recalculate contentsScale so CoreAnimation stretches correctly.
old_block = """ if (width != surface.getWidth() or height != surface.getHeight()) {
log.debug(
"setSurfaceCallback(): surface is wrong size for layer, discarding. surface = {d}x{d}, layer = {d}x{d}",
.{ surface.getWidth(), surface.getHeight(), width, height },
);
return;
}"""
new_block = """ const sw = surface.getWidth();
const sh = surface.getHeight();
const dw: usize = if (width > sw) width - sw else sw - width;
const dh: usize = if (height > sh) height - sh else sh - height;
// iOS UIKit rounding can produce ±1px discrepancy; macOS must match exactly
const max_drift: usize = if (comptime builtin.os.tag == .ios) 1 else 0;
if (dw > max_drift or dh > max_drift) {
if (comptime builtin.os.tag == .ios) {
// Recalculate contentsScale so CA maps surface pixels to layer points
const pw = bounds.size.width;
const ph = bounds.size.height;
if (pw > 0 and ph > 0) {
const cs_x: f64 = @as(f64, @floatFromInt(sw)) / pw;
const cs_y: f64 = @as(f64, @floatFromInt(sh)) / ph;
const cs: f64 = @max(cs_x, cs_y);
if (@abs(cs - scale) > 0.01) {
layer.setProperty("contentsScale", cs);
}
}
} else {
return;
}
}"""
if old_block not in src:
print("[!] IOSurfaceLayer size check block not found — source may have changed")
sys.exit(1)
src = src.replace(old_block, new_block)
# Use the system-provided CAIOSurfaceLayer on iOS; it handles
# IOSurface display natively with zero-copy compositing.
old_cls = """ const CALayer =
objc.getClass("CALayer") orelse return error.ObjCFailed;
var subclass =
objc.allocateClassPair(CALayer, "IOSurfaceLayer") orelse return error.ObjCFailed;"""
new_cls = """ const parent_cls = if (comptime builtin.os.tag == .ios)
// CAIOSurfaceLayer provides native zero-copy IOSurface compositing
objc.getClass("CAIOSurfaceLayer") orelse
objc.getClass("CALayer") orelse return error.ObjCFailed
else
objc.getClass("CALayer") orelse return error.ObjCFailed;
var subclass =
objc.allocateClassPair(parent_cls, "IOSurfaceLayer") orelse return error.ObjCFailed;"""
src = src.replace(old_cls, new_cls)
path.write_text(src)
print("[+] patched IOSurfaceLayer: iOS size tolerance + CAIOSurfaceLayer")
PY
else
echo "[+] IOSurfaceLayer already patched"
fi
fi
# =============================================================================
# Patch 2: Metal.zig — iOS first-frame display + synchronous present
#
# Problem 1: On iOS the IOSurfaceLayer is added as a sublayer of the UIView's
# backing layer. By the time the renderer registers its display callback the
# sublayer already has its bounds set, so no "display" message is generated.
# The first frame never renders.
#
# Problem 2: The async present path dispatches to the main thread via GCD.
# On iOS the render loop already runs on the main thread, so the async
# dispatch adds an unnecessary runloop turn of latency and can cause ordering
# issues with UIKit layout.
#
# Fix:
# - Call setNeedsDisplay after registering the display callback on iOS
# - On iOS, always use the synchronous present path (setSurface checks
# isMainThread internally and runs inline when true)
# =============================================================================
METAL_ZIG="${SOURCE_DIR}/src/renderer/Metal.zig"
if [ -f "$METAL_ZIG" ]; then
if ! grep -q 'setNeedsDisplay' "$METAL_ZIG"; then
python3 - "$METAL_ZIG" <<'PY'
from pathlib import Path
import sys
path = Path(sys.argv[1])
src = path.read_text()
# Kick the first display cycle after callback registration on iOS
old_cb = """ @ptrCast(&displayCallback),
@ptrCast(renderer),
);
}"""
new_cb = """ @ptrCast(&displayCallback),
@ptrCast(renderer),
);
// iOS: sublayer bounds are already set before the callback is wired up,
// so no display message fires automatically. Kick the first frame.
if (comptime builtin.os.tag == .ios) {
self.layer.layer.msgSend(void, objc.sel("setNeedsDisplay"), .{});
}
}"""
if old_cb not in src:
print("[!] Metal loopEnter callback not found")
sys.exit(1)
src = src.replace(old_cb, new_cb)
# iOS render loop is main-thread; skip the async dispatch path entirely.
old_present = """pub inline fn present(self: *Metal, target: Target, sync: bool) !void {
if (sync) {
self.layer.setSurfaceSync(target.surface);
} else {
try self.layer.setSurface(target.surface);
}
}"""
new_present = """pub inline fn present(self: *Metal, target: Target, sync: bool) !void {
// iOS: always present synchronously — the render loop already runs on
// the main thread, so the async GCD hop is unnecessary overhead.
if (comptime builtin.os.tag == .ios) {
try self.layer.setSurface(target.surface);
return;
}
if (sync) {
self.layer.setSurfaceSync(target.surface);
} else {
try self.layer.setSurface(target.surface);
}
}"""
if old_present not in src:
print("[!] Metal present function not found")
sys.exit(1)
src = src.replace(old_present, new_present)
path.write_text(src)
print("[+] patched Metal.zig: iOS first-frame trigger + sync present")
PY
else
echo "[+] Metal.zig already patched"
fi
fi
# =============================================================================
# Patch 3: coretext.zig — Skip CF release thread on iOS
#
# Problem: The CoreText font shaper spawns a background thread that uses
# libxev's kqueue event loop to asynchronously release CoreFoundation
# objects. On iOS, kqueue's Mach port allocation fails (sandbox restrictions
# + simulator incompatibility), crashing the thread and potentially stalling
# font shaping operations.
#
# Fix: Make the CF release thread optional. On iOS, skip thread creation
# entirely and release CF objects synchronously in endFrame(). This is
# acceptable because iOS devices have fast enough CF release performance
# and the terminal doesn't produce the same volume of shaped text as a
# desktop compositor.
# =============================================================================
CORETEXT="${SOURCE_DIR}/src/font/shaper/coretext.zig"
if [ -f "$CORETEXT" ]; then
if grep -q 'cf_release_thread: \*CFReleaseThread,' "$CORETEXT"; then
python3 - "$CORETEXT" <<'PY'
from pathlib import Path
import sys
path = Path(sys.argv[1])
src = path.read_text()
# Make the struct fields optional so nil can represent "no thread"
src = src.replace(
'cf_release_thread: *CFReleaseThread,\n cf_release_thr: std.Thread,',
'cf_release_thread: ?*CFReleaseThread,\n cf_release_thr: ?std.Thread,'
)
# Guard thread creation behind a comptime platform check
old_create = """ // Create the CF release thread.
var cf_release_thread = try alloc.create(CFReleaseThread);
errdefer alloc.destroy(cf_release_thread);
cf_release_thread.* = try .init(alloc);
errdefer cf_release_thread.deinit();
// Start the CF release thread.
var cf_release_thr = try std.Thread.spawn(
.{},
CFReleaseThread.threadMain,
.{cf_release_thread},
);
cf_release_thr.setName("cf_release") catch {};
return .{"""
new_create = """ // On iOS the kqueue-based event loop used by the release thread
// crashes due to Mach port sandbox restrictions. Skip it entirely
// and fall through to synchronous release in endFrame.
var cf_release_thread: ?*CFReleaseThread = null;
var cf_release_thr: ?std.Thread = null;
if (comptime builtin.os.tag != .ios) {
const thr_obj = try alloc.create(CFReleaseThread);
errdefer alloc.destroy(thr_obj);
thr_obj.* = try .init(alloc);
errdefer thr_obj.deinit();
const thr = try std.Thread.spawn(.{}, CFReleaseThread.threadMain, .{thr_obj});
thr.setName("cf_release") catch {};
cf_release_thread = thr_obj;
cf_release_thr = thr;
}
return .{"""
if old_create not in src:
print("[!] coretext CF release thread creation block not found")
sys.exit(1)
src = src.replace(old_create, new_create)
# Deinit: only join/stop the thread if it was created
old_deinit = """ // Stop the CF release thread
{
self.cf_release_thread.stop.notify() catch |err|
log.err("error notifying cf release thread to stop, may stall err={}", .{err});
self.cf_release_thr.join();
}
self.cf_release_thread.deinit();
self.alloc.destroy(self.cf_release_thread);"""
new_deinit = """ // Stop the CF release thread (nil on iOS)
if (self.cf_release_thread) |thr_obj| {
thr_obj.stop.notify() catch |err|
log.err("error notifying cf release thread to stop, may stall err={}", .{err});
self.cf_release_thr.?.join();
thr_obj.deinit();
self.alloc.destroy(thr_obj);
}"""
if old_deinit not in src:
print("[!] coretext CF release thread deinit block not found")
sys.exit(1)
src = src.replace(old_deinit, new_deinit)
# endFrame: guard the mailbox push behind an optional check.
# When nil (iOS), fall through to the synchronous release below.
old_end = """ // Send the items. If the send succeeds then we wake up the
// thread to process the items. If the send fails then do a manual
// cleanup.
if (self.cf_release_thread.mailbox.push(.{ .release = .{
.refs = items,
.alloc = self.alloc,
} }, .{ .forever = {} }) != 0) {
self.cf_release_thread.wakeup.notify() catch |err| {
log.warn(
"error notifying cf release thread to wake up, may stall err={}",
.{err},
);
};
return;
}
for (items) |ref| macos.foundation.CFRelease(ref);"""
new_end = """ // Offload to the background release thread when available.
// On iOS cf_release_thread is nil, so we fall through to sync release.
if (self.cf_release_thread) |thr_obj| {
if (thr_obj.mailbox.push(.{ .release = .{
.refs = items,
.alloc = self.alloc,
} }, .{ .forever = {} }) != 0) {
thr_obj.wakeup.notify() catch |err| {
log.warn(
"error notifying cf release thread to wake up, may stall err={}",
.{err},
);
};
return;
}
}
for (items) |ref| macos.foundation.CFRelease(ref);"""
if old_end not in src:
print("[!] coretext endFrame mailbox block not found")
sys.exit(1)
src = src.replace(old_end, new_end)
path.write_text(src)
print("[+] patched coretext.zig: CF release thread disabled on iOS")
PY
else
echo "[+] coretext.zig already patched"
fi
fi
# =============================================================================
# Patch 4: iosurface.zig — Explicit row byte alignment
#
# Problem: When creating an IOSurface without specifying bytesPerRow, the
# system picks whatever alignment it wants. Metal textures created from
# these surfaces may have mismatched row strides, causing corrupted or
# shifted glyph rendering (especially visible on font atlas textures).
#
# Fix: Calculate 64-byte-aligned row bytes and pass kIOSurfaceBytesPerRow
# when creating the IOSurface. Also suppress unused return value warnings
# from IOSurfaceLock/Unlock.
# =============================================================================
IOSURFACE="${SOURCE_DIR}/pkg/macos/iosurface/iosurface.zig"
if [ -f "$IOSURFACE" ]; then
if ! grep -q 'kIOSurfaceBytesPerRow' "$IOSURFACE"; then
python3 - "$IOSURFACE" <<'PY'
from pathlib import Path
import sys
path = Path(sys.argv[1])
src = path.read_text()
# Compute aligned row stride before creating the Number objects
old_start = """ pub fn init(properties: Properties) Allocator.Error!*IOSurface {
var w = try foundation.Number.create(.int, &properties.width);"""
new_start = """ pub fn init(properties: Properties) Allocator.Error!*IOSurface {
// Ensure row stride is 64-byte aligned for Metal texture compatibility.
const aligned_stride: c_int = @intCast(
(properties.width * properties.bytes_per_element + 63) & ~@as(c_int, 63),
);
var w = try foundation.Number.create(.int, &properties.width);"""
if old_start not in src:
print("[!] iosurface init start not found")
sys.exit(1)
src = src.replace(old_start, new_start)
# Create a Number for the stride and include it in the dictionary
old_dict_setup = """ var bpe = try foundation.Number.create(.int, &properties.bytes_per_element);
defer bpe.release();
var properties_dict = try foundation.Dictionary.create("""
new_dict_setup = """ var bpe = try foundation.Number.create(.int, &properties.bytes_per_element);
defer bpe.release();
var stride_num = try foundation.Number.create(.int, &aligned_stride);
defer stride_num.release();
var properties_dict = try foundation.Dictionary.create("""
if old_dict_setup not in src:
print("[!] iosurface bpe block not found")
sys.exit(1)
src = src.replace(old_dict_setup, new_dict_setup)
# Extend the dictionary keys/values arrays
old_dict = """ &[_]?*const anyopaque{
c.kIOSurfaceWidth,
c.kIOSurfaceHeight,
c.kIOSurfacePixelFormat,
c.kIOSurfaceBytesPerElement,
},
&[_]?*const anyopaque{ w, h, pf, bpe },"""
new_dict = """ &[_]?*const anyopaque{
c.kIOSurfaceWidth,
c.kIOSurfaceHeight,
c.kIOSurfacePixelFormat,
c.kIOSurfaceBytesPerElement,
c.kIOSurfaceBytesPerRow,
},
&[_]?*const anyopaque{ w, h, pf, bpe, stride_num },"""
if old_dict not in src:
print("[!] iosurface dictionary keys not found")
sys.exit(1)
src = src.replace(old_dict, new_dict)
# Silence unused return value from IOSurfaceLock/Unlock
src = src.replace(
' c.IOSurfaceLock(\n @ptrCast(self),\n 0,\n null,\n );',
' _ = c.IOSurfaceLock(\n @ptrCast(self),\n 0,\n null,\n );'
)
src = src.replace(
' c.IOSurfaceUnlock(\n @ptrCast(self),\n 0,\n null,\n );',
' _ = c.IOSurfaceUnlock(\n @ptrCast(self),\n 0,\n null,\n );'
)
path.write_text(src)
print("[+] patched iosurface.zig: 64-byte stride alignment for Metal")
PY
else
echo "[+] iosurface.zig already patched"
fi
fi
# =============================================================================
# Patch 5: build.zig.zon — Update libxev to fix iOS kqueue mach port panic
#
# Problem: The bundled libxev uses mach ports for async wakeup on Darwin.
# Its kqueue backend checks `os.tag != .macos` and returns null for mach port
# kevents on non-macOS Darwin (iOS). The caller then unwraps null with `.?`
# causing a panic. A newer libxev version fixes this by properly supporting
# iOS as a Darwin target.
#
# Fix: Update the libxev dependency URL and hash to a version that handles
# iOS mach ports correctly.
# =============================================================================
BUILD_ZON="${SOURCE_DIR}/build.zig.zon"
if [ -f "$BUILD_ZON" ]; then
if ! grep -q '7e7d2f2ab4700544657f8ec268715c8ef320d839' "$BUILD_ZON"; then
sed -i '' 's|"https://deps.files.ghostty.org/libxev-34fa50878aec6e5fa8f532867001ab3c36fae23e.tar.gz"|"https://github.com/mitchellh/libxev/archive/7e7d2f2ab4700544657f8ec268715c8ef320d839.tar.gz"|' "$BUILD_ZON"
sed -i '' 's|"libxev-0.0.0-86vtc4IcEwCqEYxEYoN_3KXmc6A9VLcm22aVImfvecYs"|"libxev-0.0.0-86vtcwE9EwB942iWRnaNMXHv3n0BeLAs_tVhrs5cT8cQ"|' "$BUILD_ZON"
echo "[+] patched build.zig.zon: updated libxev for iOS mach port fix"
else
echo "[+] libxev already updated"
fi
fi
echo "[+] all ios metal rendering patches applied"

View File

@@ -0,0 +1,197 @@
#!/bin/bash
set -euo pipefail
SOURCE_DIR="${1:?Usage: $0 <ghostty-source-dir>}"
MARKER="LIBGHOSTTY_SPM_TRIM_PATCH"
# Skip if already applied
if grep -q "$MARKER" "$SOURCE_DIR/src/build/Config.zig" 2>/dev/null; then
echo "[+] trim patch already applied"
exit 0
fi
python3 - "$SOURCE_DIR" "$MARKER" <<'PYEOF'
import sys
from pathlib import Path
source_dir = Path(sys.argv[1])
marker = sys.argv[2]
# ──────────────────────────────────────────────────────────────────────
# 1. Config.zig — add custom_shaders feature flag
# ──────────────────────────────────────────────────────────────────────
config_path = source_dir / "src/build/Config.zig"
text = config_path.read_text()
# Add field
text = text.replace(
"sentry: bool = true,",
f"sentry: bool = true,\ncustom_shaders: bool = true, // {marker}",
)
# Add option parsing after sentry block
sentry_end = """ ) orelse sentry: {
switch (target.result.os.tag) {
.macos, .ios => break :sentry true,
// Note its false for linux because the crash reports on Linux
// don't have much useful information.
else => break :sentry false,
}
};"""
new_options = sentry_end + """
config.custom_shaders = b.option(
bool,
"custom-shaders",
"Build with custom shader (glslang/spirv-cross) support.",
) orelse true;"""
text = text.replace(sentry_end, new_options)
# Add to addOptions
text = text.replace(
'step.addOption(bool, "sentry", self.sentry);',
'step.addOption(bool, "sentry", self.sentry);\n'
' step.addOption(bool, "custom_shaders", self.custom_shaders);',
)
config_path.write_text(text)
print("[+] patched Config.zig")
# ──────────────────────────────────────────────────────────────────────
# 2. SharedDeps.zig — gate glslang + spirv-cross on custom_shaders
# ──────────────────────────────────────────────────────────────────────
shared_path = source_dir / "src/build/SharedDeps.zig"
text = shared_path.read_text()
# Gate glslang — wrap with custom_shaders check
text = text.replace(
' // Glslang\n if (b.lazyDependency("glslang", .{',
' // Glslang — only needed for custom shaders\n if (self.config.custom_shaders) if (b.lazyDependency("glslang", .{',
)
# Close the extra if at end of glslang block
text = text.replace(
""" step.linkLibrary(glslang_dep.artifact("glslang"));
try static_libs.append(
b.allocator,
glslang_dep.artifact("glslang").getEmittedBin(),
);
}
}
// Spirv-cross""",
""" step.linkLibrary(glslang_dep.artifact("glslang"));
try static_libs.append(
b.allocator,
glslang_dep.artifact("glslang").getEmittedBin(),
);
}
};
// Spirv-cross""",
)
# Gate spirv-cross — wrap with custom_shaders check
text = text.replace(
' // Spirv-cross\n if (b.lazyDependency("spirv_cross", .{',
' // Spirv-cross — only needed for custom shaders\n if (self.config.custom_shaders) if (b.lazyDependency("spirv_cross", .{',
)
text = text.replace(
""" step.linkLibrary(spirv_cross_dep.artifact("spirv_cross"));
try static_libs.append(
b.allocator,
spirv_cross_dep.artifact("spirv_cross").getEmittedBin(),
);
}
}
// Sentry""",
""" step.linkLibrary(spirv_cross_dep.artifact("spirv_cross"));
try static_libs.append(
b.allocator,
spirv_cross_dep.artifact("spirv_cross").getEmittedBin(),
);
}
};
// Sentry""",
)
shared_path.write_text(text)
print("[+] patched SharedDeps.zig")
# ──────────────────────────────────────────────────────────────────────
# 3. build_config.zig — re-export custom_shaders flag
# ──────────────────────────────────────────────────────────────────────
bc_path = source_dir / "src/build_config.zig"
text = bc_path.read_text()
if "custom_shaders" not in text:
text = text.replace(
'const options = @import("build_options");',
'const options = @import("build_options");\npub const custom_shaders = options.custom_shaders;',
)
bc_path.write_text(text)
print("[+] patched build_config.zig")
# ──────────────────────────────────────────────────────────────────────
# 4. global.zig — conditional glslang init
# (global.zig already imports build_config)
# ──────────────────────────────────────────────────────────────────────
global_path = source_dir / "src/global.zig"
text = global_path.read_text()
text = text.replace(
'const glslang = @import("glslang");',
'const glslang = if (build_config.custom_shaders) @import("glslang") else struct {\n'
' pub fn init() !void {}\n'
'};',
)
global_path.write_text(text)
print("[+] patched global.zig")
# ──────────────────────────────────────────────────────────────────────
# 5. renderer/shadertoy.zig — gate shader imports and loadFromFile
# When custom_shaders is disabled, loadFromFile is unreachable
# so glslang/spirv_cross are never semantically analyzed
# ──────────────────────────────────────────────────────────────────────
shader_path = source_dir / "src/renderer/shadertoy.zig"
text = shader_path.read_text()
# Make imports conditional — these won't be analyzed if never reached
text = text.replace(
'const glslang = @import("glslang");',
'const build_config = @import("../build_config.zig");\n'
'const glslang = @import("glslang");',
)
# Add early return in loadFromFiles when custom_shaders is disabled
# This prevents loadFromFile (and thus spirvFromGlsl etc) from being analyzed
text = text.replace(
"""pub fn loadFromFiles(
alloc_gpa: Allocator,
paths: configpkg.RepeatablePath,
target: Target,
) ![]const [:0]const u8 {
var list: std.ArrayList([:0]const u8) = .empty;""",
"""pub fn loadFromFiles(
alloc_gpa: Allocator,
paths: configpkg.RepeatablePath,
target: Target,
) ![]const [:0]const u8 {
if (comptime !build_config.custom_shaders) return &.{};
var list: std.ArrayList([:0]const u8) = .empty;""",
)
shader_path.write_text(text)
print("[+] patched renderer/shadertoy.zig")
print(f"[+] all trim patches complete ({marker})")
PYEOF
echo "[+] trim patch applied"

View File

@@ -0,0 +1,408 @@
#!/bin/bash
set -euo pipefail
SOURCE_DIR="${1:?Usage: $0 <ghostty-source-dir>}"
MARKER="LIBGHOSTTY_SPM_INSPECTOR_DISABLE"
if grep -q "$MARKER" "$SOURCE_DIR/src/build/Config.zig" 2>/dev/null; then
echo "[+] inspector disable patch already applied"
exit 0
fi
python3 - "$SOURCE_DIR" "$MARKER" <<'PYEOF'
import sys
from pathlib import Path
source_dir = Path(sys.argv[1])
marker = sys.argv[2]
def patch_file(rel_path, replacements):
path = source_dir / rel_path
text = path.read_text()
for old, new in replacements:
if old not in text:
print(f"[-] pattern not found in {rel_path}:")
print(f" {old[:80]}...")
sys.exit(1)
count = text.count(old)
if count > 1:
print(f"[-] pattern matched {count} times in {rel_path} (expected 1):")
print(f" {old[:80]}...")
sys.exit(1)
text = text.replace(old, new, 1)
path.write_text(text)
print(f"[+] patched {rel_path}")
# ──────────────────────────────────────────────────────────────────────
# 1. Config.zig — add inspector feature flag
# (runs AFTER 0006, so custom_shaders line already exists)
# ──────────────────────────────────────────────────────────────────────
patch_file("src/build/Config.zig", [
# Add field after custom_shaders (added by 0006)
(
f"custom_shaders: bool = true, // LIBGHOSTTY_SPM_TRIM_PATCH",
f"custom_shaders: bool = true, // LIBGHOSTTY_SPM_TRIM_PATCH\n"
f"inspector: bool = true, // {marker}",
),
# Add option parsing after the custom_shaders option block (added by 0006)
(
' "custom-shaders",\n'
' "Build with custom shader (glslang/spirv-cross) support.",\n'
' ) orelse true;',
' "custom-shaders",\n'
' "Build with custom shader (glslang/spirv-cross) support.",\n'
' ) orelse true;\n'
'\n'
' config.inspector = b.option(\n'
' bool,\n'
' "inspector",\n'
' "Build with terminal inspector (dcimgui) support.",\n'
' ) orelse true;',
),
# Add to addOptions after custom_shaders (added by 0006)
(
' step.addOption(bool, "custom_shaders", self.custom_shaders);',
' step.addOption(bool, "custom_shaders", self.custom_shaders);\n'
' step.addOption(bool, "inspector", self.inspector);',
),
])
# ──────────────────────────────────────────────────────────────────────
# 2. SharedDeps.zig — gate dcimgui linking on inspector flag
# ──────────────────────────────────────────────────────────────────────
patch_file("src/build/SharedDeps.zig", [
(
' // cimgui\n'
' if (b.lazyDependency("dcimgui", .{',
' // cimgui — only needed for inspector\n'
' if (self.config.inspector) if (b.lazyDependency("dcimgui", .{',
),
# Close the extra if — find the end of the dcimgui block
(
' );\n'
' }\n'
'\n'
' // Fonts',
' );\n'
' };\n'
'\n'
' // Fonts',
),
])
# ──────────────────────────────────────────────────────────────────────
# 3. build_config.zig — re-export inspector flag
# (runs AFTER 0006, so custom_shaders line already exists)
# ──────────────────────────────────────────────────────────────────────
patch_file("src/build_config.zig", [
(
'pub const custom_shaders = options.custom_shaders;',
'pub const custom_shaders = options.custom_shaders;\n'
'pub const inspector = options.inspector;',
),
])
# ──────────────────────────────────────────────────────────────────────
# 4. inspector/main.zig — COMPLETE FILE REPLACEMENT with stub module
#
# This is the key stability improvement. Instead of pattern-matching
# downstream files, we replace the single chokepoint module with
# stubs that satisfy all downstream type/method requirements.
# When inspector=true, everything re-exports as before.
# When inspector=false, stub types with no-op methods are provided
# so Surface.zig, renderer, termio all compile without modification.
# ──────────────────────────────────────────────────────────────────────
inspector_main = source_dir / "src/inspector/main.zig"
inspector_main.write_text('''\
const build_config = @import("../build_config.zig");
const std = @import("std");
const terminal = @import("../terminal/main.zig");
const input = @import("../input.zig");
const renderer = @import("../renderer.zig");
pub const widgets = if (build_config.inspector) @import("widgets.zig") else struct {
pub const key = struct {
pub const Event = StubKeyEvent;
};
pub const renderer = struct {
pub const Info = StubRendererInfo;
};
pub const surface = struct {
pub const Mouse = StubMouse;
};
};
pub const Inspector = if (build_config.inspector) @import("Inspector.zig") else StubInspector;
pub const KeyEvent = widgets.key.Event;
/// Stub types — these satisfy all downstream method/field accesses
/// when inspector is disabled, so no other files need patching.
const StubMouse = struct {
last_xpos: f64 = 0,
last_ypos: f64 = 0,
last_point: ?terminal.Pin = null,
};
const StubRendererInfo = struct {
pub const empty: @This() = .{};
pub fn deinit(_: *@This(), _: std.mem.Allocator) void {}
pub fn overlayFeatures(
_: *const @This(),
_: std.mem.Allocator,
) std.mem.Allocator.Error![]renderer.Overlay.Feature {
return &.{};
}
};
const StubKeyEvent = struct {
event: input.KeyEvent = undefined,
binding: []const input.Binding.Action = &.{},
pty: []const u8 = "",
pub fn deinit(_: *const @This(), _: std.mem.Allocator) void {}
};
const StubInspector = struct {
mouse: StubMouse = .{},
pub fn setup() void {}
pub fn init(_: std.mem.Allocator) !@This() { return .{}; }
pub fn deinit(_: *@This(), _: std.mem.Allocator) void {}
pub fn render(_: *@This(), _: anytype) void {}
pub fn rendererInfo(_: *@This()) *StubRendererInfo {
return &stub_renderer_info;
}
pub fn recordKeyEvent(
_: *@This(),
_: std.mem.Allocator,
_: StubKeyEvent,
) std.mem.Allocator.Error!void {}
pub fn recordPtyRead(
_: *@This(),
_: std.mem.Allocator,
_: *terminal.Terminal,
_: []const u8,
) !void {}
var stub_renderer_info: StubRendererInfo = .{};
};
test {
if (build_config.inspector) {
@import("std").testing.refAllDecls(@This());
}
}
''')
print("[+] replaced src/inspector/main.zig with stub module")
# ──────────────────────────────────────────────────────────────────────
# 5. input/key.zig — gate dcimgui import + imguiKey method
# ──────────────────────────────────────────────────────────────────────
patch_file("src/input/key.zig", [
(
'const cimgui = @import("dcimgui");',
'const _build_config = @import("../build_config.zig");\n'
'const cimgui = if (_build_config.inspector) @import("dcimgui") else struct {};',
),
(
' pub fn imguiKey(self: Key) ?c_int {',
' pub fn imguiKey(self: Key) ?c_int {\n'
' if (comptime !_build_config.inspector) return null;',
),
])
# ──────────────────────────────────────────────────────────────────────
# 6. apprt/embedded.zig — gate Inspector struct + CoreInspector import
# ──────────────────────────────────────────────────────────────────────
embedded_path = source_dir / "src/apprt/embedded.zig"
text = embedded_path.read_text()
# 6a. Gate CoreInspector import
old = 'const CoreInspector = @import("../inspector/main.zig").Inspector;'
new = 'const CoreInspector = if (@import("../build_config.zig").inspector) @import("../inspector/main.zig").Inspector else struct {};'
assert old in text, f"pattern not found: {old}"
text = text.replace(old, new, 1)
# 6b. Gate the Inspector struct definition using brace-depth counting
# Find the struct opening
struct_marker = 'pub const Inspector = struct {\n const cimgui = @import("dcimgui");'
assert struct_marker in text, f"Inspector struct marker not found"
struct_start_idx = text.index('pub const Inspector = struct {\n const cimgui = @import("dcimgui");')
# Find the opening brace
brace_start = text.index('{', struct_start_idx)
# Count braces to find the matching close
depth = 0
i = brace_start
while i < len(text):
if text[i] == '{':
depth += 1
elif text[i] == '}':
depth -= 1
if depth == 0:
break
i += 1
# i now points to the closing }
# The struct ends with `};` — we want to capture up to and including `}`
# but NOT the `;` since the semicolon will come after the else branch
struct_end_brace = i + 1 # position after closing }
# Check for trailing semicolon to know where to resume after replacement
struct_end = struct_end_brace
if struct_end < len(text) and text[struct_end] == ';':
struct_end += 1 # skip the original ; in the replacement range
# Extract the original struct content (up to } but not ;)
original_struct = text[struct_start_idx:struct_end_brace]
# Wrap: replace opening with comptime conditional
new_struct = original_struct.replace(
'pub const Inspector = struct {',
'pub const Inspector = if (@import("../build_config.zig").inspector) struct {',
1,
)
# Add else branch after closing };
stub = """ else struct {
surface: *Surface = undefined,
pub fn init(_: *Surface) !@This() { return .{}; }
pub fn deinit(_: *@This()) void {}
};"""
new_struct = new_struct + stub
text = text[:struct_start_idx] + new_struct + text[struct_end:]
# 6c. Gate initInspector body
old_init = """ pub fn initInspector(self: *Surface) !*Inspector {
if (self.inspector) |v| return v;
const alloc = self.app.core_app.alloc;
const inspector = try alloc.create(Inspector);
errdefer alloc.destroy(inspector);
inspector.* = try .init(self);
self.inspector = inspector;
return inspector;
}"""
new_init = """ pub fn initInspector(self: *Surface) !*Inspector {
if (comptime !@import("../build_config.zig").inspector) return error.InspectorUnavailable;
if (self.inspector) |v| return v;
const alloc = self.app.core_app.alloc;
const inspector = try alloc.create(Inspector);
errdefer alloc.destroy(inspector);
inspector.* = try .init(self);
self.inspector = inspector;
return inspector;
}"""
assert old_init in text, "initInspector pattern not found"
text = text.replace(old_init, new_init, 1)
# 6d. Gate freeInspector body
old_free = """ pub fn freeInspector(self: *Surface) void {
if (self.inspector) |v| {
v.deinit();
self.app.core_app.alloc.destroy(v);
self.inspector = null;
}
}"""
new_free = """ pub fn freeInspector(self: *Surface) void {
if (comptime !@import("../build_config.zig").inspector) return;
if (self.inspector) |v| {
v.deinit();
self.app.core_app.alloc.destroy(v);
self.inspector = null;
}
}"""
assert old_free in text, "freeInspector pattern not found"
text = text.replace(old_free, new_free, 1)
# 6e. Gate CAPI inspector functions that call methods on Inspector
# These functions take *Inspector and call methods that don't exist on the stub struct.
# We add comptime early-returns so the method calls are never analyzed.
capi_guards = [
(' export fn ghostty_inspector_set_size(ptr: *Inspector, w: u32, h: u32) void {\n'
' ptr.updateSize(w, h);',
' export fn ghostty_inspector_set_size(ptr: *Inspector, w: u32, h: u32) void {\n'
' if (comptime !@import("../build_config.zig").inspector) return;\n'
' ptr.updateSize(w, h);'),
(' export fn ghostty_inspector_set_content_scale(ptr: *Inspector, x: f64, y: f64) void {\n'
' ptr.updateContentScale(x, y);',
' export fn ghostty_inspector_set_content_scale(ptr: *Inspector, x: f64, y: f64) void {\n'
' if (comptime !@import("../build_config.zig").inspector) return;\n'
' ptr.updateContentScale(x, y);'),
(' ptr.mouseButtonCallback(\n'
' action,\n'
' button,',
' if (comptime !@import("../build_config.zig").inspector) return;\n'
' ptr.mouseButtonCallback(\n'
' action,\n'
' button,'),
(' export fn ghostty_inspector_mouse_pos(ptr: *Inspector, x: f64, y: f64) void {\n'
' ptr.cursorPosCallback(x, y);',
' export fn ghostty_inspector_mouse_pos(ptr: *Inspector, x: f64, y: f64) void {\n'
' if (comptime !@import("../build_config.zig").inspector) return;\n'
' ptr.cursorPosCallback(x, y);'),
(' ptr.scrollCallback(\n'
' x,\n'
' y,',
' if (comptime !@import("../build_config.zig").inspector) return;\n'
' ptr.scrollCallback(\n'
' x,\n'
' y,'),
(' ptr.keyCallback(\n'
' action,\n'
' key,',
' if (comptime !@import("../build_config.zig").inspector) return;\n'
' ptr.keyCallback(\n'
' action,\n'
' key,'),
(' export fn ghostty_inspector_text(\n'
' ptr: *Inspector,\n'
' str: [*:0]const u8,\n'
' ) void {\n'
' ptr.textCallback(std.mem.sliceTo(str, 0));',
' export fn ghostty_inspector_text(\n'
' ptr: *Inspector,\n'
' str: [*:0]const u8,\n'
' ) void {\n'
' if (comptime !@import("../build_config.zig").inspector) return;\n'
' ptr.textCallback(std.mem.sliceTo(str, 0));'),
(' export fn ghostty_inspector_set_focus(ptr: *Inspector, focused: bool) void {\n'
' ptr.focusCallback(focused);',
' export fn ghostty_inspector_set_focus(ptr: *Inspector, focused: bool) void {\n'
' if (comptime !@import("../build_config.zig").inspector) return;\n'
' ptr.focusCallback(focused);'),
(' export fn ghostty_inspector_metal_init(ptr: *Inspector, device: objc.c.id) bool {\n'
' return ptr.initMetal(.fromId(device));',
' export fn ghostty_inspector_metal_init(ptr: *Inspector, device: objc.c.id) bool {\n'
' if (comptime !@import("../build_config.zig").inspector) return false;\n'
' return ptr.initMetal(.fromId(device));'),
(' return ptr.renderMetal(\n'
' .fromId(command_buffer),',
' if (comptime !@import("../build_config.zig").inspector) return;\n'
' return ptr.renderMetal(\n'
' .fromId(command_buffer),'),
(' export fn ghostty_inspector_metal_shutdown(ptr: *Inspector) void {\n'
' if (ptr.backend) |v| {',
' export fn ghostty_inspector_metal_shutdown(ptr: *Inspector) void {\n'
' if (comptime !@import("../build_config.zig").inspector) return;\n'
' if (ptr.backend) |v| {'),
]
for old_capi, new_capi in capi_guards:
assert old_capi in text, f"CAPI pattern not found: {old_capi[:60]}..."
text = text.replace(old_capi, new_capi, 1)
embedded_path.write_text(text)
print("[+] patched apprt/embedded.zig")
print(f"[+] all inspector patches complete ({marker})")
PYEOF
echo "[+] inspector disable patch applied"

View File

@@ -0,0 +1,111 @@
#!/bin/bash
set -euo pipefail
SOURCE_DIR="${1:?Usage: $0 <ghostty-source-dir>}"
METAL_ZIG="$SOURCE_DIR/src/renderer/Metal.zig"
if [ ! -f "$METAL_ZIG" ]; then
echo "[-] Metal.zig not found"
exit 1
fi
if grep -q 'LIBGHOSTTY_SPM_TEXTURE_STORAGE_PATCH' "$METAL_ZIG"; then
echo "[+] Metal texture storage already patched"
exit 0
fi
python3 - "$METAL_ZIG" <<'PY'
from pathlib import Path
import sys
path = Path(sys.argv[1])
src = path.read_text()
replacements = [
(
"""default_storage_mode: mtl.MTLResourceOptions.StorageMode,
/// The maximum 2D texture width and height supported by the device.
""",
"""default_storage_mode: mtl.MTLResourceOptions.StorageMode,
/// The default storage mode to use for MTLTexture resources.
default_texture_storage_mode: mtl.MTLResourceOptions.StorageMode,
/// The maximum 2D texture width and height supported by the device.
""",
),
(
""" const max_texture_size = queryMaxTextureSize(device);
log.debug(
"device properties default_storage_mode={} max_texture_size={}",
.{ default_storage_mode, max_texture_size },
);
""",
""" // LIBGHOSTTY_SPM_TEXTURE_STORAGE_PATCH
// MTLStorageModeShared is valid for textures on Apple GPUs, while Intel
// and AMD macOS GPUs require managed textures even when hasUnifiedMemory is
// true. Keep buffer storage unchanged, but choose texture storage from the
// Metal GPU family as Apple recommends for CPU-updated textures.
const default_texture_storage_mode: mtl.MTLResourceOptions.StorageMode = switch (comptime builtin.os.tag) {
.ios => .shared,
.macos => if (device.msgSend(
bool,
objc.sel("supportsFamily:"),
.{mtl.MTLGPUFamily.apple1},
)) .shared else .managed,
else => default_storage_mode,
};
const max_texture_size = queryMaxTextureSize(device);
log.debug(
"device properties default_storage_mode={} default_texture_storage_mode={} max_texture_size={}",
.{ default_storage_mode, default_texture_storage_mode, max_texture_size },
);
""",
),
(
""" .default_storage_mode = default_storage_mode,
.max_texture_size = max_texture_size,
""",
""" .default_storage_mode = default_storage_mode,
.default_texture_storage_mode = default_texture_storage_mode,
.max_texture_size = max_texture_size,
""",
),
]
for old, new in replacements:
if old not in src:
print("[-] Metal.zig structure block not found")
sys.exit(1)
src = src.replace(old, new, 1)
old_count = src.count(".storage_mode = self.default_storage_mode")
if old_count < 4:
print("[-] expected texture storage call sites not found")
sys.exit(1)
src = src.replace(
".storage_mode = self.default_storage_mode",
".storage_mode = self.default_texture_storage_mode",
)
buffer_marker = """pub inline fn bufferOptions(self: Metal) bufferpkg.Options {"""
buffer_start = src.find(buffer_marker)
if buffer_start == -1:
print("[-] bufferOptions not found")
sys.exit(1)
src = (
src[:buffer_start]
+ src[buffer_start:].replace(
".storage_mode = self.default_texture_storage_mode",
".storage_mode = self.default_storage_mode",
1,
)
)
path.write_text(src)
print("[+] patched Metal.zig: split buffer and texture storage modes")
PY

View File

@@ -0,0 +1,95 @@
#!/bin/bash
set -euo pipefail
SOURCE_DIR="${1:?Usage: $0 <ghostty-source-dir>}"
# Zig's bundled libc++ headers ship with Apple vendor availability annotations
# disabled, so C++ code references runtime symbols that the Apple system
# libc++.1.dylib may not export at our deployment floors (e.g.
# std::__1::__libcpp_verbose_abort, exported only since iOS 16.3 /
# macOS 13.3 / tvOS 16.3). Forcing the annotations on makes libc++ headers
# degrade gracefully below those floors (exactly like Apple SDK clang) and
# turns any hard dependency on a too-new symbol into a compile error instead
# of a dyld crash at app launch.
#
# -Wno-macro-redefined: zig predefines the macro to 0 on its own command
# line; our -D override redefines it.
# Patch 1: highway flags (hwy/abort.cc et al reference __libcpp_verbose_abort
# through the -fno-exceptions throw helpers)
HIGHWAY_BUILD="${SOURCE_DIR}/pkg/highway/build.zig"
if [ ! -f "$HIGHWAY_BUILD" ]; then
echo "[-] missing: $HIGHWAY_BUILD; upstream changed, update this patch"
exit 1
fi
if ! grep -q '_LIBCPP_HAS_VENDOR_AVAILABILITY_ANNOTATIONS' "$HIGHWAY_BUILD"; then
perl -0pi -e 's/try flags\.appendSlice\(b\.allocator, &\.\{\n/try flags.appendSlice(b.allocator, &.{\n "-D_LIBCPP_HAS_VENDOR_AVAILABILITY_ANNOTATIONS=1",\n "-Wno-macro-redefined",\n/' "$HIGHWAY_BUILD"
grep -q '_LIBCPP_HAS_VENDOR_AVAILABILITY_ANNOTATIONS' "$HIGHWAY_BUILD" || {
echo "[-] highway flags block not found; upstream changed, update this patch"
exit 1
}
echo "[+] patched: highway libc++ availability annotations"
else
echo "[+] highway libc++ availability already patched"
fi
# Patch 2: simdutf flags
SIMDUTF_BUILD="${SOURCE_DIR}/pkg/simdutf/build.zig"
if [ ! -f "$SIMDUTF_BUILD" ]; then
echo "[-] missing: $SIMDUTF_BUILD; upstream changed, update this patch"
exit 1
fi
if ! grep -q '_LIBCPP_HAS_VENDOR_AVAILABILITY_ANNOTATIONS' "$SIMDUTF_BUILD"; then
perl -0pi -e 's/try flags\.appendSlice\(b\.allocator, &\.\{\n/try flags.appendSlice(b.allocator, &.{\n "-D_LIBCPP_HAS_VENDOR_AVAILABILITY_ANNOTATIONS=1",\n "-Wno-macro-redefined",\n/' "$SIMDUTF_BUILD"
grep -q '_LIBCPP_HAS_VENDOR_AVAILABILITY_ANNOTATIONS' "$SIMDUTF_BUILD" || {
echo "[-] simdutf flags block not found; upstream changed, update this patch"
exit 1
}
echo "[+] patched: simdutf libc++ availability annotations"
else
echo "[+] simdutf libc++ availability already patched"
fi
# Patch 3: ghostty's own C++ SIMD sources (src/simd/*.cpp)
SHARED_DEPS="${SOURCE_DIR}/src/build/SharedDeps.zig"
if [ ! -f "$SHARED_DEPS" ]; then
echo "[-] missing: $SHARED_DEPS; upstream changed, update this patch"
exit 1
fi
if grep -q 'HWY_NO_LIBCXX' "$SHARED_DEPS" &&
grep -q 'SIMDUTF_NO_LIBCXX' "$SHARED_DEPS"; then
echo "[+] src/simd already builds without libc++"
elif ! grep -q '_LIBCPP_HAS_VENDOR_AVAILABILITY_ANNOTATIONS' "$SHARED_DEPS"; then
python3 - "$SHARED_DEPS" <<'PY'
from pathlib import Path
import sys
path = Path(sys.argv[1])
text = path.read_text()
old_flags = """ .flags = if (target.result.cpu.arch == .x86_64) &.{
b.fmt("-DHWY_DISABLED_TARGETS={}", .{HWY_DISABLED_TARGETS}),
} else &.{},"""
new_flags = """ .flags = if (target.result.cpu.arch == .x86_64) &.{
"-D_LIBCPP_HAS_VENDOR_AVAILABILITY_ANNOTATIONS=1",
"-Wno-macro-redefined",
b.fmt("-DHWY_DISABLED_TARGETS={}", .{HWY_DISABLED_TARGETS}),
} else &.{
"-D_LIBCPP_HAS_VENDOR_AVAILABILITY_ANNOTATIONS=1",
"-Wno-macro-redefined",
},"""
if old_flags not in text:
print("[-] src/simd flags block not found; upstream changed, update this patch")
sys.exit(1)
path.write_text(text.replace(old_flags, new_flags))
print("[+] patched: src/simd libc++ availability annotations")
PY
else
echo "[+] src/simd libc++ availability already patched"
fi
echo "[+] all libcxx-apple-availability patches applied"

View File

@@ -0,0 +1,22 @@
diff --git a/src/Surface.zig b/src/Surface.zig
index 4d66622..c0fe19e 100644
--- a/src/Surface.zig
+++ b/src/Surface.zig
@@ -3430,7 +3430,7 @@ pub fn scrollCallback(
}
// We scroll by the number of rows in the offset and save the remainder
- const amount = poff / cell_size;
+ const amount = @trunc(poff / cell_size);
assert(@abs(amount) >= 1);
self.mouse.pending_scroll_y = poff - (amount * cell_size);
@@ -3455,7 +3455,7 @@ pub fn scrollCallback(
break :x .{};
}
- const amount = poff / cell_size;
+ const amount = @trunc(poff / cell_size);
assert(@abs(amount) >= 1);
self.mouse.pending_scroll_x = poff - (amount * cell_size);
const delta: isize = @intFromFloat(@trunc(amount));

View File

@@ -0,0 +1,25 @@
# Ghostty Patches
This directory is the single place for local upstream Ghostty patches used by
the `libghostty-spm` build pipeline.
## Rules
- Keep patches numbered so they apply in a stable order.
- Prefer standard unified diff files (`.patch`) when the upstream context is
stable.
- Use executable patch scripts (`.sh`) only when upstream context is too
unstable for a reliable diff.
- Keep version-specific variants beside the original patch and select them in
`Script/apply-patches.sh` using an upstream API marker.
- Preserve newer Ghostty's renamed internal-library outputs when extending its
Darwin static-library build path.
- Every patch in this directory must be safe to re-run.
- Patches here are applied automatically by `Script/build-ghostty.sh`, so they
affect macOS, iOS, and Mac Catalyst builds equally.
## Current goal
This patch workflow exists so we can carry host-managed IO work required for
sandboxed iOS, macOS, and Mac Catalyst integration without hiding upstream
modifications inside ad-hoc build script edits.

174
vendor/libghostty-spm/README.md vendored Normal file
View File

@@ -0,0 +1,174 @@
# GhosttyKit
Swift Package wrapping [Ghostty](https://ghostty.org)'s terminal emulator library for Apple platforms.
> Pre-built `libghostty` static library distributed as an XCFramework binary target.
## Platforms
- macOS 13+
- iOS 15+
- Mac Catalyst 15+
## Products
| Library | Description |
| ----------------- | ------------------------------------------------------------------------------- |
| `GhosttyKit` | Re-exports the libghostty C API (`ghostty.h`) |
| `GhosttyTerminal` | Swift wrapper — native views, SwiftUI integration, input handling, display link |
| `GhosttyTheme` | 485 terminal color themes from [iTerm2-Color-Schemes](https://github.com/mbadolato/iTerm2-Color-Schemes) (MIT License) |
| `ShellCraftKit` | Sandboxed shell emulation framework (depends on GhosttyTerminal) |
## Installation
Add to your `Package.swift`:
```swift
dependencies: [
.package(url: "https://github.com/Lakr233/libghostty-spm.git", from: "1.2.0"),
]
```
Then add the product you need:
```swift
.target(
name: "YourTarget",
dependencies: [
.product(name: "GhosttyTerminal", package: "libghostty-spm"),
]
)
```
## Usage
The example apps are the best starting point for real integration:
- `Example/GhosttyTerminalApp/` — macOS AppKit demo with delegate callbacks
- `Example/MobileGhosttyApp/` — iOS UIKit demo with keyboard, safe area, themes, and text selection
### SwiftUI (iOS 15+ / macOS 13+ / Mac Catalyst 15+)
```swift
import SwiftUI
import GhosttyTerminal
struct ContentView: View {
@StateObject private var terminal = TerminalViewState()
private let session = InMemoryTerminalSession(
write: { data in
// Handle bytes produced by the terminal.
},
resize: { viewport in
// Keep your host backend in sync with the terminal grid.
}
)
var body: some View {
TerminalSurfaceView(context: terminal)
.navigationTitle(terminal.title)
.onAppear {
terminal.configuration = TerminalSurfaceOptions(
backend: .inMemory(session)
)
}
}
}
```
### UIKit / AppKit
```swift
import GhosttyTerminal
let terminalView = TerminalView(frame: .zero)
terminalView.delegate = self
terminalView.controller = TerminalController(configFilePath: path)
terminalView.configuration = TerminalSurfaceOptions(
backend: .inMemory(session)
)
```
`TerminalView` is a type alias that resolves to `UITerminalView` (iOS/Catalyst) or `AppTerminalView` (macOS).
### Prompt and scrollback navigation
`TerminalViewState`, `TerminalView`, and `TerminalSurface` expose the same
programmatic navigation APIs:
```swift
terminal.jumpToPrompt(by: -1) // Previous prompt.
terminal.jumpToPrompt(by: 1) // Next prompt.
terminal.scrollToRow(0) // First absolute scrollback row.
```
Prompt navigation requires [Ghostty shell integration](https://ghostty.org/docs/features/shell-integration),
which records prompt boundaries. A host-managed backend must preserve or emit
equivalent OSC 133 prompt markers. Arbitrary Ghostty actions remain available
through `performBindingAction(_:)`.
## Notes
- `TerminalViewState` is the SwiftUI state container.
- `TerminalView` is the UIKit/AppKit view typealias.
- `TerminalController` owns app lifecycle, config resolution, themes, and surface creation.
- `InMemoryTerminalSession` provides the host-managed backend used by the sandboxed example apps.
- `GhosttyThemeCatalog` exposes bundled iTerm2 color schemes.
## Building from Source
The package includes a pre-built XCFramework. To rebuild libghostty from the Ghostty source:
```bash
# Requires: zig compiler
./Script/build.sh
```
This applies patches from `Patches/ghostty/`, builds for all target architectures, and assembles the XCFramework.
## Release Versioning
Bare semantic-version tags such as `1.3.1` are GhosttyKit Swift package
versions. They are independent from Ghostty's upstream tags. The matching
`storage.1.3.1` release stores the XCFramework consumed by that package tag.
Release builds use the immutable upstream Ghostty commit recorded in
`Ghostty.ref`. Updating Ghostty requires a reviewed change to that file, so a
package release cannot silently switch to a different upstream tag or commit.
Manual releases require an explicit package version; scheduled releases only
increment the package patch version when `main` is newer than the latest
package tag.
## Trimmed Build
The bundled `libghostty` is a trimmed build optimized for sandboxed, embedded use on Apple platforms.
| Component | Upstream Ghostty | libghostty-spm | Reason |
| -------------------------------- | ---------------- | ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
| Terminal emulation core | Yes | Yes | Full VT parser, state machine, grid — retained |
| Metal renderer | Yes | Yes | GPU rendering via CAMetalLayer / IOSurface — retained |
| Font rasterization & shaping | Yes | Yes | CoreText font backend — retained |
| Configuration system | Yes | Yes | All terminal config options — retained |
| Input handling (key, mouse, IME) | Yes | Yes | Full keyboard/mouse/touch/IME pipeline — retained |
| Text selection & clipboard | Yes | Yes | Selection, copy/paste APIs — retained |
| Custom shaders (GLSL) | Yes | **No** | `glslang` and `spirv-cross` removed (`-Dcustom-shaders=false`). Shadertoy/post-processing shaders are a desktop feature unnecessary for embedded use. |
| Terminal inspector (ImGui) | Yes | **No** | `dcimgui` removed (`-Dinspector=false`). Debug inspector UI replaced with no-op stubs. |
| Sentry crash reporting | Yes | **No** | Disabled (`-Dsentry=false`). |
| Native app runtime | Yes | **No** | Cocoa/GTK/Wayland app shell disabled (`-Dapp-runtime=none`). The host app provides its own runtime. |
| Standalone executable | Yes | **No** | No terminal `.app` or CLI binary emitted (`-Demit-exe=false`). |
| Documentation generation | Yes | **No** | Skipped (`-Demit-docs=false`). |
| Frame data generator | Build-time tool | **Pre-compiled** | `framedata.compressed` shipped pre-built; framegen C tool dependency removed. |
| Host-managed I/O backend | No | **Added** | New `GHOSTTY_SURFACE_IO_BACKEND_HOST_MANAGED` for non-PTY, sandbox-safe terminal I/O. |
| iOS Metal rendering fixes | No | **Added** | IOSurface +1px tolerance, synchronous present, 64-byte row alignment for iOS. |
| iOS platform fixes | No | **Added** | Deployment target lowered, private API removed, kqueue fix for simulator. |
## License
MIT License. See [LICENSE](LICENSE) for details.
The bundled `libghostty` binary is built from [Ghostty](https://ghostty.org), which has its own license terms.
## Sponsor
- [LookInside](https://lookinside-app.com/) helps you inspect a running iOS or macOS app UI from your Mac.
- This project/repository is sponsored by AFK AI, INC.

106
vendor/libghostty-spm/Script/apply-patches.sh vendored Executable file
View File

@@ -0,0 +1,106 @@
#!/bin/zsh
set -euo pipefail
cd "$(dirname "$0")/.."
if [ ! -f .root ]; then
echo "[-] malformed project structure"
exit 1
fi
SOURCE_DIR=${1:-}
PATCH_DIR=${2:-"$(pwd)/Patches/ghostty"}
if [ -z "$SOURCE_DIR" ]; then
echo "Usage: $0 <source_dir> [patch_dir]"
exit 1
fi
if [ ! -d "$SOURCE_DIR" ]; then
echo "[-] ghostty source directory not found: $SOURCE_DIR"
exit 1
fi
if [ ! -d "$PATCH_DIR" ]; then
echo "[+] no patches directory found: $PATCH_DIR"
exit 0
fi
apply_unified_patch() {
local patch_file="$1"
if [ -d "$SOURCE_DIR/.git" ] && command -v git >/dev/null 2>&1; then
if git -C "$SOURCE_DIR" apply --check --reverse "$patch_file" >/dev/null 2>&1; then
echo "[+] patch already applied: $(basename "$patch_file")"
return
fi
if ! git -C "$SOURCE_DIR" apply --check "$patch_file" >/dev/null 2>&1; then
echo "[-] failed to validate patch: $patch_file"
exit 1
fi
git -C "$SOURCE_DIR" apply "$patch_file"
echo "[+] applied patch: $(basename "$patch_file")"
return
fi
if patch -p1 -R --dry-run -d "$SOURCE_DIR" <"$patch_file" >/dev/null 2>&1; then
echo "[+] patch already applied: $(basename "$patch_file")"
return
fi
if ! patch -p1 --dry-run -d "$SOURCE_DIR" <"$patch_file" >/dev/null 2>&1; then
echo "[-] failed to validate patch: $patch_file"
exit 1
fi
patch -p1 -d "$SOURCE_DIR" <"$patch_file" >/dev/null
echo "[+] applied patch: $(basename "$patch_file")"
}
modern_host_io=false
if grep -q "ghostty_surface_foreground_pid" "$SOURCE_DIR/include/ghostty.h"; then
modern_host_io=true
fi
host_io_applied=false
if grep -q "GHOSTTY_SURFACE_IO_BACKEND_HOST_MANAGED" "$SOURCE_DIR/include/ghostty.h"; then
host_io_applied=true
fi
for patch_file in "$PATCH_DIR"/*; do
[ -e "$patch_file" ] || continue
patch_name=$(basename "$patch_file")
case "$patch_name" in
0002-host-managed-io.patch)
[ "$modern_host_io" = false ] || continue
if [ "$host_io_applied" = true ]; then
echo "[+] patch already applied: $patch_name"
continue
fi
apply_unified_patch "$patch_file"
;;
0002-host-managed-io-modern.patch)
[ "$modern_host_io" = true ] || continue
if [ "$host_io_applied" = true ]; then
echo "[+] patch already applied: $patch_name"
continue
fi
apply_unified_patch "$patch_file"
;;
*.md) ;;
*.patch)
apply_unified_patch "$patch_file"
;;
*.sh)
"$patch_file" "$SOURCE_DIR"
;;
*)
echo "[-] unsupported patch file: $patch_file"
exit 1
;;
esac
done

273
vendor/libghostty-spm/Script/build-ghostty.sh vendored Executable file
View File

@@ -0,0 +1,273 @@
#!/bin/bash
set -euo pipefail
cd "$(dirname "$0")/.."
if [ ! -f .root ]; then
echo "[*] malformed project structure"
exit 1
fi
ROOT_DIR=$(pwd)
SOURCE_DIR=${1:-}
ZIG_TARGET=${2:-}
OUTPUT_DIR=${3:-}
ZIG_CPU=${ZIG_CPU:-}
ZIG_BUILD_EXTRA_ARGS=${ZIG_BUILD_EXTRA_ARGS:-}
if [ -z "$SOURCE_DIR" ] || [ -z "$ZIG_TARGET" ] || [ -z "$OUTPUT_DIR" ]; then
echo "Usage: $0 <source_dir> <zig_target> <output_dir>"
exit 1
fi
if [ ! -d "$SOURCE_DIR" ]; then
echo "[!] ghostty source directory not found: $SOURCE_DIR"
exit 1
fi
if [ ! -f "$SOURCE_DIR/include/ghostty.h" ]; then
echo "[!] ghostty header not found: $SOURCE_DIR/include/ghostty.h"
exit 1
fi
if ! command -v zig >/dev/null 2>&1; then
echo "[!] zig not found"
exit 1
fi
REQUESTED_TARGET="$ZIG_TARGET"
IS_ARM64E=0
APPLE_SDK=
APPLE_TARGET=
case "$REQUESTED_TARGET" in
arm64e-macos)
IS_ARM64E=1
ZIG_TARGET="aarch64-macos"
APPLE_SDK="macosx"
APPLE_TARGET="arm64e-apple-macosx13.0"
;;
arm64e-ios)
IS_ARM64E=1
ZIG_TARGET="aarch64-ios"
APPLE_SDK="iphoneos"
APPLE_TARGET="arm64e-apple-ios15.0"
;;
arm64e-ios-macabi)
IS_ARM64E=1
ZIG_TARGET="aarch64-ios-macabi"
APPLE_SDK="macosx"
APPLE_TARGET="arm64e-apple-ios15.0-macabi"
;;
esac
./Script/apply-patches.sh "$SOURCE_DIR"
CACHE_ROOT="${BUILD_CACHE_ROOT:-$ROOT_DIR/build/cache}"
GLOBAL_CACHE_DIR="${ZIG_GLOBAL_CACHE_DIR:-$CACHE_ROOT/zig-global}"
LOCAL_CACHE_DIR="$CACHE_ROOT/$REQUESTED_TARGET/zig-local"
MODULE_CACHE_DIR="${CLANG_MODULE_CACHE_ROOT:-$CACHE_ROOT/clang-module-cache}/$REQUESTED_TARGET"
if [ "$IS_ARM64E" -eq 1 ]; then
if ! command -v xcrun >/dev/null 2>&1; then
echo "[!] xcrun not found for arm64e build"
exit 1
fi
./Script/prepare-arm64e-source.sh "$SOURCE_DIR" "$GLOBAL_CACHE_DIR"
fi
echo "[*] building ghostty static library"
echo " target: $REQUESTED_TARGET"
echo " source: $SOURCE_DIR"
echo " output: $OUTPUT_DIR"
rm -rf "$OUTPUT_DIR" "$LOCAL_CACHE_DIR" "$MODULE_CACHE_DIR"
mkdir -p \
"$OUTPUT_DIR/lib" \
"$OUTPUT_DIR/include" \
"$GLOBAL_CACHE_DIR" \
"$LOCAL_CACHE_DIR" \
"$MODULE_CACHE_DIR"
rm -rf "$SOURCE_DIR/zig-out"
ZIG_BUILD_COMMAND=(
zig build
-Doptimize=${ZIG_OPTIMIZE:-ReleaseFast}
-Dapp-runtime=none
-Demit-exe=false
-Demit-xcframework=false
-Demit-macos-app=false
-Demit-docs=false
-Dsentry=false
-Dcustom-shaders=false
-Dinspector=false
-Dtarget="$ZIG_TARGET"
)
if [ -n "$ZIG_CPU" ]; then
ZIG_BUILD_COMMAND+=("-Dcpu=$ZIG_CPU")
fi
if [ -n "$ZIG_BUILD_EXTRA_ARGS" ]; then
# shellcheck disable=SC2206
EXTRA_ARGS=($ZIG_BUILD_EXTRA_ARGS)
ZIG_BUILD_COMMAND+=("${EXTRA_ARGS[@]}")
fi
(
cd "$SOURCE_DIR"
env \
CLANG_MODULE_CACHE_PATH="$MODULE_CACHE_DIR" \
ZIG_GLOBAL_CACHE_DIR="$GLOBAL_CACHE_DIR" \
ZIG_LOCAL_CACHE_DIR="$LOCAL_CACHE_DIR" \
"${ZIG_BUILD_COMMAND[@]}"
)
find_built_library() {
local preferred_name="$1"
find "$LOCAL_CACHE_DIR/o" -type f -name "$preferred_name" -print 2>/dev/null | sort | tail -n 1
}
LIBRARY_PATH=
if [ -f "$SOURCE_DIR/zig-out/lib/libghostty.a" ]; then
LIBRARY_PATH="$SOURCE_DIR/zig-out/lib/libghostty.a"
fi
if [ -z "$LIBRARY_PATH" ]; then
LIBRARY_PATH=$(find_built_library "libghostty-fat.a")
fi
if [ -z "$LIBRARY_PATH" ]; then
LIBRARY_PATH=$(find_built_library "libghostty.a")
fi
if [ -z "$LIBRARY_PATH" ]; then
echo "[!] failed to locate built libghostty archive in $LOCAL_CACHE_DIR"
if [[ "$ZIG_TARGET" == *macos* || "$ZIG_TARGET" == *ios* || "$ZIG_TARGET" == *tvos* || "$ZIG_TARGET" == *visionos* || "$ZIG_TARGET" == *watchos* ]]; then
echo "[!] note: upstream Ghostty does not install Darwin libghostty for app-runtime=none unless extra build wiring is triggered"
echo "[!] try again with ZIG_BUILD_EXTRA_ARGS='-Demit-xcframework=true' if you want to force Darwin libghostty build graph execution"
fi
find "$LOCAL_CACHE_DIR" -maxdepth 3 -type f | sort | tail -n 50
exit 1
fi
# Resolve std::__1::__libcpp_verbose_abort inside the archive: the Apple
# system libc++ only exports it since iOS 16.3 / macOS 13.3 / tvOS 16.3, and
# Zig's bundled libc++ headers reference it strongly (no availability markup),
# which crashes consumers at launch on older OS versions.
COMPAT_SOURCE="$ROOT_DIR/Script/support/libcxx-verbose-abort-compat.c"
COMPAT_OBJECT="$LOCAL_CACHE_DIR/libcxx-verbose-abort-compat.o"
zig cc -target "$ZIG_TARGET" -Os -fno-sanitize=undefined -c "$COMPAT_SOURCE" -o "$COMPAT_OBJECT"
if [ "$IS_ARM64E" -eq 1 ]; then
RAW_ARCHIVE="$LOCAL_CACHE_DIR/libghostty-arm64.a"
CONVERTED_ARCHIVE="$LOCAL_CACHE_DIR/libghostty-arm64e.a"
CORE_OBJECT="$LOCAL_CACHE_DIR/libghostty-arm64e-core.o"
ARM64E_COMPAT_OBJECT="$LOCAL_CACHE_DIR/arm64e-compat.o"
xcrun libtool -static -no_warning_for_no_symbols -o "$RAW_ARCHIVE" "$LIBRARY_PATH" "$COMPAT_OBJECT"
python3 "$ROOT_DIR/Script/convert-archive-arm64e.py" "$RAW_ARCHIVE" "$CONVERTED_ARCHIVE"
xcrun --sdk "$APPLE_SDK" clang \
-target "$APPLE_TARGET" \
-r \
-nostdlib \
"-Wl,-force_load,$CONVERTED_ARCHIVE" \
-Wl,-alias,_ghostty_app_new,_ghostty_arm64e_core_app_new \
-Wl,-unexported_symbol,_ghostty_app_new \
-Wl,-alias,_ghostty_surface_new,_ghostty_arm64e_core_surface_new \
-Wl,-unexported_symbol,_ghostty_surface_new \
-o "$CORE_OBJECT"
xcrun --sdk "$APPLE_SDK" clang \
-target "$APPLE_TARGET" \
-Os \
-fno-sanitize=undefined \
-I "$SOURCE_DIR/include" \
-c "$ROOT_DIR/Script/support/arm64e-compat.c" \
-o "$ARM64E_COMPAT_OBJECT"
xcrun libtool -static -no_warning_for_no_symbols \
-o "$OUTPUT_DIR/lib/libghostty.a" \
"$CORE_OBJECT" \
"$ARM64E_COMPAT_OBJECT"
if [ "$(lipo -archs "$OUTPUT_DIR/lib/libghostty.a")" != "arm64e" ]; then
echo "[!] built archive is not a single arm64e slice"
exit 1
fi
SYMBOLS=$(nm -gjU "$OUTPUT_DIR/lib/libghostty.a" | sort -u)
for symbol in \
_ghostty_app_new \
_ghostty_surface_new \
_ghostty_arm64e_core_app_new \
_ghostty_arm64e_core_surface_new \
_pa_pthread_cr \
_pa_qs; do
if ! grep -Fxq "$symbol" <<<"$SYMBOLS"; then
echo "[!] arm64e archive missing symbol: $symbol"
exit 1
fi
done
CORE_IMPORTS=$(nm -u "$CORE_OBJECT" | sort -u)
for symbol in _pa_pthread_cr _pa_qs; do
if ! grep -Fxq "$symbol" <<<"$CORE_IMPORTS"; then
echo "[!] arm64e core missing redirected import: $symbol"
exit 1
fi
done
for symbol in _pthread_create _qsort; do
if grep -Fxq "$symbol" <<<"$CORE_IMPORTS"; then
echo "[!] arm64e core contains unsigned callback import: $symbol"
exit 1
fi
done
echo "[*] built pointer-authenticated arm64e archive"
else
ARM64E_COMPAT_OBJECT="$LOCAL_CACHE_DIR/arm64e-compat.o"
case "$ZIG_TARGET" in
*-ios-simulator) COMPAT_SDK="iphonesimulator" ;;
*-ios-macabi | *-macos) COMPAT_SDK="macosx" ;;
*-ios) COMPAT_SDK="iphoneos" ;;
*-tvos-simulator) COMPAT_SDK="appletvsimulator" ;;
*-tvos) COMPAT_SDK="appletvos" ;;
*-visionos-simulator) COMPAT_SDK="xrsimulator" ;;
*-visionos) COMPAT_SDK="xros" ;;
*-watchos-simulator) COMPAT_SDK="watchsimulator" ;;
*-watchos) COMPAT_SDK="watchos" ;;
*)
echo "[!] unsupported compatibility target: $ZIG_TARGET"
exit 1
;;
esac
COMPAT_SDK_PATH=$(xcrun --sdk "$COMPAT_SDK" --show-sdk-path)
zig cc \
-target "$ZIG_TARGET" \
-Os \
-fno-sanitize=undefined \
-isystem "$COMPAT_SDK_PATH/usr/include" \
-I "$SOURCE_DIR/include" \
-c "$ROOT_DIR/Script/support/arm64e-compat.c" \
-o "$ARM64E_COMPAT_OBJECT"
xcrun libtool -static -no_warning_for_no_symbols \
-o "$OUTPUT_DIR/lib/libghostty.a" \
"$LIBRARY_PATH" \
"$COMPAT_OBJECT" \
"$ARM64E_COMPAT_OBJECT"
echo "[*] appended compatibility objects"
fi
cp "$SOURCE_DIR/include/ghostty.h" "$OUTPUT_DIR/include/ghostty.h"
cat >"$OUTPUT_DIR/include/module.modulemap" <<'EOF'
module libghostty {
umbrella header "ghostty.h"
export *
}
EOF
echo "[*] built archive: $OUTPUT_DIR/lib/libghostty.a"

View File

@@ -0,0 +1,32 @@
#!/bin/bash
set -euo pipefail
cd "$(dirname "$0")/.."
if [ ! -f .root ]; then
echo "[*] malformed project structure"
exit 1
fi
XCFRAMEWORK_PATH_ZIP=${1:-}
DOWNLOAD_URL=${2:-}
if [ -z "$XCFRAMEWORK_PATH_ZIP" ] || [ -z "$DOWNLOAD_URL" ]; then
echo "Usage: $0 <xcframework_zip> <download_url>"
exit 1
fi
if [ ! -f "$XCFRAMEWORK_PATH_ZIP" ]; then
echo "[!] $XCFRAMEWORK_PATH_ZIP not found"
exit 1
fi
SHA256SUM=$(shasum -a 256 "$XCFRAMEWORK_PATH_ZIP" | awk '{print $1}')
echo "[*] SHA256: $SHA256SUM"
PACKAGE_MANIFEST=$(cat Package.swift.template)
PACKAGE_MANIFEST=${PACKAGE_MANIFEST/__DOWNLOAD_URL__/$DOWNLOAD_URL}
PACKAGE_MANIFEST=${PACKAGE_MANIFEST/__CHECKSUM__/$SHA256SUM}
echo "$PACKAGE_MANIFEST" >Package.swift

126
vendor/libghostty-spm/Script/build-platform.sh vendored Executable file
View File

@@ -0,0 +1,126 @@
#!/bin/bash
set -euo pipefail
cd "$(dirname "$0")/.."
if [ ! -f .root ]; then
echo "[*] malformed project structure"
exit 1
fi
SOURCE_DIR=${1:-}
PLATFORM_GROUP=${2:-}
OUTPUT_DIR=${3:-}
if [ -z "$SOURCE_DIR" ] || [ -z "$PLATFORM_GROUP" ] || [ -z "$OUTPUT_DIR" ]; then
echo "Usage: $0 <source_dir> <platform_group> <output_dir>"
exit 1
fi
if [ ! -d "$SOURCE_DIR" ]; then
echo "[!] ghostty source directory not found: $SOURCE_DIR"
exit 1
fi
build_variant() {
local variant_name="$1"
shift
local variant_dir="$OUTPUT_DIR/$variant_name"
local intermediate_dir="$OUTPUT_DIR/.intermediates/$variant_name"
local first_headers=
local libraries=()
local target_spec=
local target=
local cpu=
rm -rf "$variant_dir" "$intermediate_dir"
mkdir -p "$variant_dir/lib" "$variant_dir/include" "$intermediate_dir"
for target_spec in "$@"; do
target=${target_spec%%@*}
cpu=
if [ "$target_spec" != "$target" ]; then
cpu=${target_spec#*@}
fi
local target_dir="$intermediate_dir/$target"
if [ -n "$cpu" ]; then
env ZIG_CPU="$cpu" ./Script/build-ghostty.sh "$SOURCE_DIR" "$target" "$target_dir"
else
./Script/build-ghostty.sh "$SOURCE_DIR" "$target" "$target_dir"
fi
libraries+=("$target_dir/lib/libghostty.a")
if [ -z "$first_headers" ]; then
first_headers="$target_dir/include"
fi
done
if [ -z "$first_headers" ]; then
echo "[!] no libraries were built for variant: $variant_name"
exit 1
fi
cp -R "$first_headers/." "$variant_dir/include/"
if [ "${#libraries[@]}" -eq 1 ]; then
cp "${libraries[0]}" "$variant_dir/lib/libghostty.a"
else
lipo -create "${libraries[@]}" -output "$variant_dir/lib/libghostty.a"
fi
echo "[*] assembled variant: $variant_name"
}
mkdir -p "$OUTPUT_DIR"
case "$PLATFORM_GROUP" in
macos)
build_variant "macosx" \
"aarch64-macos" \
"arm64e-macos@apple_a12" \
"x86_64-macos"
;;
ios)
build_variant "iphoneos" \
"aarch64-ios" \
"arm64e-ios@apple_a12"
build_variant "iphonesimulator" \
"aarch64-ios-simulator@apple_a17" \
"x86_64-ios-simulator"
;;
maccatalyst)
build_variant "maccatalyst" \
"aarch64-ios-macabi@apple_a17" \
"arm64e-ios-macabi@apple_a17" \
"x86_64-ios-macabi"
;;
tvos)
build_variant "appletvos" \
"aarch64-tvos"
build_variant "appletvsimulator" \
"aarch64-tvos-simulator@apple_a17" \
"x86_64-tvos-simulator"
;;
visionos)
build_variant "xros" \
"aarch64-visionos"
build_variant "xrsimulator" \
"aarch64-visionos-simulator@apple_a17" \
"x86_64-visionos-simulator"
;;
watchos)
build_variant "watchos" \
"aarch64-watchos"
build_variant "watchsimulator" \
"aarch64-watchos-simulator@apple_a17" \
"x86_64-watchos-simulator"
;;
*)
echo "[!] unknown platform group: $PLATFORM_GROUP"
exit 1
;;
esac
echo "[*] built platform group: $PLATFORM_GROUP"

130
vendor/libghostty-spm/Script/build.sh vendored Executable file
View File

@@ -0,0 +1,130 @@
#!/bin/bash
set -euo pipefail
cd "$(dirname "$0")/.."
if [ ! -f .root ]; then
echo "[*] malformed project structure"
exit 1
fi
usage() {
cat <<'EOF'
Usage: ./build.sh [options]
Options:
--source <path> Use an existing Ghostty checkout.
--ref <tag-or-commit> Checkout the given ref in the source checkout.
--platforms <csv> Build platform groups. Default:
macos,ios,maccatalyst
--download-url <url> Generate Package.swift from Package.swift.template.
--skip-tests Skip local xcodebuild and swift test verification.
-h, --help Show this help.
Notes:
- Default source path is ./References/ghostty-upstream
- This builds real per-target static archives, then assembles
BinaryTarget/GhosttyKit.xcframework and build/GhosttyKit.xcframework.zip
- Upstream Ghostty patches from ./Patches/ghostty are applied automatically
- Current verified groups: macos, ios, maccatalyst
- Current upstream Ghostty crashes for: tvos, visionos, watchos
EOF
}
ROOT_DIR=$(pwd)
SOURCE_DIR="$ROOT_DIR/References/ghostty-upstream"
PLATFORMS="macos,ios,maccatalyst"
DOWNLOAD_URL=${DOWNLOAD_URL:-}
GHOSTTY_REF=
SKIP_TESTS=0
while [ $# -gt 0 ]; do
case "$1" in
--source)
SOURCE_DIR="$2"
shift 2
;;
--ref)
GHOSTTY_REF="$2"
shift 2
;;
--platforms)
PLATFORMS="$2"
shift 2
;;
--download-url)
DOWNLOAD_URL="$2"
shift 2
;;
--skip-tests)
SKIP_TESTS=1
shift
;;
-h | --help)
usage
exit 0
;;
*)
echo "[!] unknown argument: $1"
usage
exit 1
;;
esac
done
if ! command -v zig >/dev/null 2>&1; then
echo "[!] zig not found"
exit 1
fi
if [ ! -d "$SOURCE_DIR" ]; then
echo "[*] ghostty source not found, cloning into $SOURCE_DIR"
mkdir -p "$(dirname "$SOURCE_DIR")"
git clone https://github.com/ghostty-org/ghostty "$SOURCE_DIR"
fi
if [ -n "$GHOSTTY_REF" ]; then
echo "[*] checking out ghostty ref: $GHOSTTY_REF"
git -C "$SOURCE_DIR" fetch --tags origin
git -C "$SOURCE_DIR" checkout "$GHOSTTY_REF"
fi
ARTIFACTS_DIR="$ROOT_DIR/build/artifacts"
XCFRAMEWORK_PATH="$ROOT_DIR/BinaryTarget/GhosttyKit.xcframework"
XCFRAMEWORK_ZIP="$ROOT_DIR/build/GhosttyKit.xcframework.zip"
rm -rf "$ARTIFACTS_DIR" "$XCFRAMEWORK_PATH" "$XCFRAMEWORK_ZIP"
mkdir -p "$ARTIFACTS_DIR" "$(dirname "$XCFRAMEWORK_PATH")"
echo "[*] zig version: $(zig version)"
echo "[*] ghostty source: $SOURCE_DIR"
echo "[*] platform groups: $PLATFORMS"
OLD_IFS=$IFS
IFS=','
set -- $PLATFORMS
IFS=$OLD_IFS
for platform_group in "$@"; do
platform_group=$(echo "$platform_group" | xargs)
[ -n "$platform_group" ] || continue
./Script/build-platform.sh "$SOURCE_DIR" "$platform_group" "$ARTIFACTS_DIR"
done
./Script/merge-xcframework.sh \
"$ARTIFACTS_DIR" \
"$XCFRAMEWORK_PATH" \
"$XCFRAMEWORK_ZIP"
if [ -n "$DOWNLOAD_URL" ]; then
./Script/build-manifest.sh "$XCFRAMEWORK_ZIP" "$DOWNLOAD_URL"
fi
if [ "$SKIP_TESTS" -eq 0 ]; then
./Script/test.sh
swift test
fi
echo "[*] xcframework: $XCFRAMEWORK_PATH"
echo "[*] zip: $XCFRAMEWORK_ZIP"

View File

@@ -0,0 +1,194 @@
#!/usr/bin/env python3
import os
import pathlib
import struct
import sys
AR_MAGIC = b"!<arch>\n"
AR_HEADER_SIZE = 60
MACHO64_LITTLE_ENDIAN_MAGIC = b"\xcf\xfa\xed\xfe"
CPU_TYPE_ARM64 = 0x0100000C
CPU_SUBTYPE_ARM64_ALL = 0
CPU_SUBTYPE_ARM64E = 0x80000002
LC_SYMTAB = 0x2
N_TYPE_MASK = 0x0E
N_UNDF = 0x0
NLIST64_SIZE = 16
UNDEFINED_SYMBOL_RENAMES = {
b"_pthread_create": b"_pa_pthread_cr",
b"_qsort": b"_pa_qs",
}
def fail(message: str) -> None:
raise SystemExit(f"[-] {message}")
def parse_decimal(raw: bytes, field: str) -> int:
try:
return int(raw.decode("ascii").strip())
except ValueError:
fail(f"invalid {field}: {raw!r}")
def rewrite_undefined_symbols(
data: bytearray,
object_start: int,
member_end: int,
) -> dict[bytes, int]:
object_size = member_end - object_start
if object_size < 32:
fail(f"truncated Mach-O header at offset {object_start}")
command_count, command_bytes = struct.unpack_from("<II", data, object_start + 16)
command_start = object_start + 32
command_end = command_start + command_bytes
if command_end > member_end:
fail(f"Mach-O load commands cross member boundary at offset {object_start}")
symtab = None
cursor = command_start
for _ in range(command_count):
if cursor + 8 > command_end:
fail(f"truncated Mach-O load command at offset {cursor}")
command, command_size = struct.unpack_from("<II", data, cursor)
if command_size < 8 or cursor + command_size > command_end:
fail(f"invalid Mach-O load command size at offset {cursor}")
if command == LC_SYMTAB:
if command_size < 24 or symtab is not None:
fail(f"invalid Mach-O symbol table command at offset {cursor}")
symtab = struct.unpack_from("<IIII", data, cursor + 8)
cursor += command_size
if cursor != command_end:
fail(f"Mach-O load command sizes do not match header at offset {object_start}")
if symtab is None:
return {name: 0 for name in UNDEFINED_SYMBOL_RENAMES}
symbol_offset, symbol_count, string_offset, string_size = symtab
symbol_bytes = symbol_count * NLIST64_SIZE
if symbol_offset + symbol_bytes > object_size:
fail(f"Mach-O symbol table crosses member boundary at offset {object_start}")
if string_offset + string_size > object_size:
fail(f"Mach-O string table crosses member boundary at offset {object_start}")
string_start = object_start + string_offset
string_end = string_start + string_size
renamed = {name: 0 for name in UNDEFINED_SYMBOL_RENAMES}
for index in range(symbol_count):
entry = object_start + symbol_offset + index * NLIST64_SIZE
string_index = struct.unpack_from("<I", data, entry)[0]
symbol_type = data[entry + 4]
if string_index == 0 or symbol_type & N_TYPE_MASK != N_UNDF:
continue
name_start = string_start + string_index
if name_start >= string_end:
fail(f"Mach-O symbol name crosses string table at offset {entry}")
name_end = data.find(0, name_start, string_end)
if name_end < 0:
fail(f"unterminated Mach-O symbol name at offset {name_start}")
name = bytes(data[name_start:name_end])
replacement = UNDEFINED_SYMBOL_RENAMES.get(name)
if replacement is None:
continue
if len(replacement) > len(name):
fail(f"replacement symbol is longer than source: {name!r}")
replacement_size = len(name) + 1
data[name_start : name_start + replacement_size] = replacement.ljust(
replacement_size,
b"\0",
)
renamed[name] += 1
return renamed
def convert_archive(source: pathlib.Path, destination: pathlib.Path) -> int:
data = bytearray(source.read_bytes())
if not data.startswith(AR_MAGIC):
fail(f"not a static archive: {source}")
cursor = len(AR_MAGIC)
patched_objects = 0
renamed_symbols = {name: 0 for name in UNDEFINED_SYMBOL_RENAMES}
while cursor < len(data):
if cursor + AR_HEADER_SIZE > len(data):
fail(f"truncated archive member header at offset {cursor}")
header = data[cursor : cursor + AR_HEADER_SIZE]
if header[58:60] != b"`\n":
fail(f"invalid archive member header at offset {cursor}")
member_size = parse_decimal(header[48:58], "archive member size")
member_start = cursor + AR_HEADER_SIZE
member_end = member_start + member_size
if member_end > len(data):
fail(f"archive member crosses file boundary at offset {cursor}")
object_start = member_start
member_name = header[:16].decode("ascii", errors="replace").strip()
if member_name.startswith("#1/"):
name_size = parse_decimal(member_name[3:].encode("ascii"), "extended member name size")
object_start += name_size
if object_start > member_end:
fail(f"extended member name crosses member boundary at offset {cursor}")
if data[object_start : object_start + 4] == MACHO64_LITTLE_ENDIAN_MAGIC:
if object_start + 12 > member_end:
fail(f"truncated Mach-O header at offset {object_start}")
cpu_type, cpu_subtype = struct.unpack_from("<II", data, object_start + 4)
if cpu_type != CPU_TYPE_ARM64:
fail(f"unexpected Mach-O CPU type 0x{cpu_type:08x} at offset {object_start}")
if cpu_subtype not in {CPU_SUBTYPE_ARM64_ALL, CPU_SUBTYPE_ARM64E}:
fail(f"unexpected ARM64 CPU subtype 0x{cpu_subtype:08x} at offset {object_start}")
struct.pack_into("<I", data, object_start + 8, CPU_SUBTYPE_ARM64E)
member_renames = rewrite_undefined_symbols(data, object_start, member_end)
for name, count in member_renames.items():
renamed_symbols[name] += count
patched_objects += 1
cursor = member_end + (member_size & 1)
if cursor != len(data):
fail(f"archive alignment crosses file boundary: {source}")
if patched_objects == 0:
fail(f"archive contains no ARM64 Mach-O objects: {source}")
destination.parent.mkdir(parents=True, exist_ok=True)
temporary = destination.with_name(f".{destination.name}.{os.getpid()}.tmp")
temporary.write_bytes(data)
os.replace(temporary, destination)
for name, replacement in UNDEFINED_SYMBOL_RENAMES.items():
print(
f"[+] redirected {renamed_symbols[name]} {name.decode()} imports "
f"to {replacement.decode()}"
)
return patched_objects
def main() -> None:
if len(sys.argv) != 3:
fail(f"usage: {sys.argv[0]} <arm64_archive> <arm64e_archive>")
source = pathlib.Path(sys.argv[1])
destination = pathlib.Path(sys.argv[2])
if not source.is_file():
fail(f"archive not found: {source}")
if source.resolve() == destination.resolve():
fail("source and destination must differ")
count = convert_archive(source, destination)
print(f"[+] converted {count} Mach-O objects to arm64e: {destination}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,195 @@
#!/bin/zsh
set -euo pipefail
# Downloads all Ghostty theme files from iTerm2-Color-Schemes and generates
# Swift source files for the GhosttyTheme library.
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
ROOT_DIR="$(dirname "$SCRIPT_DIR")"
OUTPUT_DIR="$ROOT_DIR/Sources/GhosttyTheme/Themes"
TEMP_DIR="$(mktemp -d)"
trap 'rm -rf "$TEMP_DIR"' EXIT
mkdir -p "$OUTPUT_DIR"
# Fetch the list of theme files from GitHub API
echo "[+] fetching theme list from github api"
curl -sL "https://api.github.com/repos/mbadolato/iTerm2-Color-Schemes/contents/ghostty" \
| python3 -c "
import json, sys
data = json.load(sys.stdin)
for item in data:
if item['type'] == 'file':
print(item['name'])
" > "$TEMP_DIR/theme_list.txt"
THEME_COUNT=$(wc -l < "$TEMP_DIR/theme_list.txt" | tr -d ' ')
echo "[+] found $THEME_COUNT themes"
# Download all themes
echo "[+] downloading themes"
mkdir -p "$TEMP_DIR/themes"
while IFS= read -r name; do
encoded_name=$(python3 -c "import urllib.parse; print(urllib.parse.quote('$name'))")
curl -sL "https://raw.githubusercontent.com/mbadolato/iTerm2-Color-Schemes/master/ghostty/$encoded_name" \
-o "$TEMP_DIR/themes/$name" &
# Limit concurrent downloads
if (( $(jobs -r | wc -l) >= 20 )); then
wait -n
fi
done < "$TEMP_DIR/theme_list.txt"
wait
echo "[+] downloaded all themes"
# Generate Swift files using Python
echo "[+] generating swift source files"
python3 - "$TEMP_DIR/themes" "$OUTPUT_DIR" "$TEMP_DIR/theme_list.txt" << 'PYEOF'
import os, sys, re, string
themes_dir = sys.argv[1]
output_dir = sys.argv[2]
list_file = sys.argv[3]
with open(list_file) as f:
theme_names = [line.strip() for line in f if line.strip()]
def parse_theme(path):
result = {}
palette = {}
with open(path) as f:
for line in f:
line = line.strip()
if not line or line.startswith('#'):
continue
if '=' not in line:
continue
key, _, value = line.partition('=')
key = key.strip()
value = value.strip()
if key == 'palette':
# format: N=#RRGGBB or N=RRGGBB
idx_str, _, color = value.partition('=')
idx = int(idx_str.strip())
color = color.strip().lstrip('#')
palette[idx] = color
else:
# strip # from color values
clean_val = value.lstrip('#')
result[key] = clean_val
result['_palette'] = palette
return result
def swift_identifier(name):
"""Convert theme name to a valid Swift identifier."""
# Replace + with Plus before stripping
ident = name.replace('+', 'Plus')
# Replace non-alphanumeric with underscore
ident = re.sub(r'[^a-zA-Z0-9]', '_', ident)
# Collapse multiple underscores
ident = re.sub(r'_+', '_', ident)
# Strip leading/trailing underscores
ident = ident.strip('_')
# Prefix with underscore if starts with digit
if ident and ident[0].isdigit():
ident = '_' + ident
# camelCase: lowercase first char
if ident:
ident = ident[0].lower() + ident[1:]
return ident
def swift_string(s):
return '"' + s.replace('\\', '\\\\').replace('"', '\\"') + '"'
def optional_field(data, ghostty_key, swift_type="String"):
val = data.get(ghostty_key)
if val:
return swift_string(val)
return "nil"
# Group themes by first letter
groups = {}
all_themes = []
for name in sorted(theme_names, key=str.lower):
path = os.path.join(themes_dir, name)
if not os.path.isfile(path):
continue
try:
data = parse_theme(path)
except Exception as e:
print(f" [-] skipping {name}: {e}", file=sys.stderr)
continue
bg = data.get('background')
fg = data.get('foreground')
if not bg or not fg:
print(f" [-] skipping {name}: missing background/foreground", file=sys.stderr)
continue
ident = swift_identifier(name)
first_char = name[0].upper()
if first_char not in string.ascii_uppercase:
first_char = 'Symbols'
palette = data.get('_palette', {})
palette_str = '['
for i in sorted(palette.keys()):
palette_str += f'{i}: {swift_string(palette[i])}, '
if palette_str.endswith(', '):
palette_str = palette_str[:-2]
palette_str += ']'
entry = {
'name': name,
'ident': ident,
'background': swift_string(bg),
'foreground': swift_string(fg),
'cursorColor': optional_field(data, 'cursor-color'),
'cursorText': optional_field(data, 'cursor-text'),
'selectionBackground': optional_field(data, 'selection-background'),
'selectionForeground': optional_field(data, 'selection-foreground'),
'palette': palette_str,
'group': first_char,
}
groups.setdefault(first_char, []).append(entry)
all_themes.append(entry)
# Write per-group Swift files
for group, entries in sorted(groups.items()):
filename = f"Themes_{group}.swift"
filepath = os.path.join(output_dir, filename)
with open(filepath, 'w') as f:
f.write("// Auto-generated by Script/generate-themes.sh — do not edit\n\n")
f.write("public extension GhosttyThemeDefinition {\n")
for e in entries:
f.write(f" static let {e['ident']} = GhosttyThemeDefinition(\n")
f.write(f" name: {swift_string(e['name'])},\n")
f.write(f" background: {e['background']},\n")
f.write(f" foreground: {e['foreground']},\n")
f.write(f" cursorColor: {e['cursorColor']},\n")
f.write(f" cursorText: {e['cursorText']},\n")
f.write(f" selectionBackground: {e['selectionBackground']},\n")
f.write(f" selectionForeground: {e['selectionForeground']},\n")
f.write(f" palette: {e['palette']}\n")
f.write(f" )\n\n")
f.write("}\n")
print(f" [+] wrote {filename} ({len(entries)} themes)")
# Write catalog file
catalog_path = os.path.join(output_dir, "ThemeCatalog_Generated.swift")
with open(catalog_path, 'w') as f:
f.write("// Auto-generated by Script/generate-themes.sh — do not edit\n\n")
f.write("public extension GhosttyThemeCatalog {\n")
f.write(" static let allThemes: [GhosttyThemeDefinition] = [\n")
for e in all_themes:
f.write(f" .{e['ident']},\n")
f.write(" ]\n")
f.write("}\n")
print(f" [+] wrote ThemeCatalog_Generated.swift ({len(all_themes)} themes)")
print(f"[+] generated {len(all_themes)} themes total")
PYEOF
echo "[+] done"

View File

@@ -0,0 +1,93 @@
#!/bin/bash
set -euo pipefail
cd "$(dirname "$0")/.."
if [ ! -f .root ]; then
echo "[*] malformed project structure"
exit 1
fi
ARTIFACTS_DIR=${1:-}
OUTPUT_XCFRAMEWORK=${2:-}
OUTPUT_ZIP=${3:-}
if [ -z "$ARTIFACTS_DIR" ] || [ -z "$OUTPUT_XCFRAMEWORK" ]; then
echo "Usage: $0 <artifacts_dir> <output_xcframework> [output_zip]"
exit 1
fi
if [ ! -d "$ARTIFACTS_DIR" ]; then
echo "[!] artifacts directory not found: $ARTIFACTS_DIR"
exit 1
fi
XCFRAMEWORK_COMMAND=()
FOUND_VARIANTS=0
verify_headers() {
local header_dir="$1"
if [ ! -f "$header_dir/ghostty.h" ]; then
echo "[!] missing header: $header_dir/ghostty.h"
exit 1
fi
if [ ! -f "$header_dir/module.modulemap" ]; then
echo "[!] missing module map: $header_dir/module.modulemap"
exit 1
fi
}
add_library() {
local variant_dir="$1"
local library_path="$variant_dir/lib/libghostty.a"
local header_dir="$variant_dir/include"
if [ ! -f "$library_path" ]; then
echo "[!] missing library: $library_path"
exit 1
fi
if [ ! -d "$header_dir" ]; then
echo "[!] missing headers: $header_dir"
exit 1
fi
verify_headers "$header_dir"
XCFRAMEWORK_COMMAND+=("-library" "$library_path" "-headers" "$header_dir")
FOUND_VARIANTS=$((FOUND_VARIANTS + 1))
}
for variant_dir in "$ARTIFACTS_DIR"/*; do
[ -d "$variant_dir" ] || continue
[ "$(basename "$variant_dir")" = ".intermediates" ] && continue
add_library "$variant_dir"
done
if [ "$FOUND_VARIANTS" -eq 0 ]; then
echo "[!] no staged variants found in $ARTIFACTS_DIR"
exit 1
fi
mkdir -p "$(dirname "$OUTPUT_XCFRAMEWORK")"
rm -rf "$OUTPUT_XCFRAMEWORK"
echo "[*] creating static-library xcframework: $OUTPUT_XCFRAMEWORK"
xcodebuild -create-xcframework \
-output "$OUTPUT_XCFRAMEWORK" \
"${XCFRAMEWORK_COMMAND[@]}"
./Script/verify-xcframework.sh "$OUTPUT_XCFRAMEWORK"
if [ -n "$OUTPUT_ZIP" ]; then
mkdir -p "$(dirname "$OUTPUT_ZIP")"
rm -f "$OUTPUT_ZIP"
(
cd "$(dirname "$OUTPUT_XCFRAMEWORK")"
ditto -c -k --sequesterRsrc --keepParent "$(basename "$OUTPUT_XCFRAMEWORK")" "$(basename "$OUTPUT_ZIP")"
)
mv "$(dirname "$OUTPUT_XCFRAMEWORK")/$(basename "$OUTPUT_ZIP")" "$OUTPUT_ZIP"
./Script/verify-xcframework.sh "$OUTPUT_ZIP"
echo "[*] packed xcframework zip: $OUTPUT_ZIP"
fi

View File

@@ -0,0 +1,53 @@
#!/bin/zsh
set -euo pipefail
cd "$(dirname "$0")/.."
if [ ! -f .root ]; then
echo "[-] malformed project structure"
exit 1
fi
SOURCE_DIR=${1:-}
GLOBAL_CACHE_DIR=${2:-}
if [ -z "$SOURCE_DIR" ] || [ -z "$GLOBAL_CACHE_DIR" ]; then
echo "Usage: $0 <source_dir> <zig_global_cache_dir>"
exit 1
fi
if [ ! -d "$SOURCE_DIR" ]; then
echo "[-] ghostty source directory not found: $SOURCE_DIR"
exit 1
fi
ZIG_OBJC_URL="https://deps.files.ghostty.org/zig_objc-f356ed02833f0f1b8e84d50bed9e807bf7cdc0ae.tar.gz"
ZIG_OBJC_HASH="zig_objc-0.0.0-Ir_Sp5gTAQCvxxR7oVIrPXxXwsfKgVP7_wqoOQrZjFeK"
VENDOR_DIR="$SOURCE_DIR/.libghostty-spm/zig_objc"
VENDOR_PATCH="$(pwd)/Patches/arm64e/zig-objc/0001-arm64e-boundaries.patch"
VENDOR_PATCH_MARKER="$VENDOR_DIR/.libghostty-spm-arm64e-patched"
./Script/apply-patches.sh "$SOURCE_DIR" "$(pwd)/Patches/arm64e/ghostty"
if [ ! -f "$VENDOR_DIR/src/block.zig" ]; then
fetched_hash=$(zig fetch --global-cache-dir "$GLOBAL_CACHE_DIR" "$ZIG_OBJC_URL")
if [ "$fetched_hash" != "$ZIG_OBJC_HASH" ]; then
echo "[-] zig-objc hash mismatch: expected $ZIG_OBJC_HASH, got $fetched_hash"
exit 1
fi
mkdir -p "$(dirname "$VENDOR_DIR")"
cp -R "$GLOBAL_CACHE_DIR/p/$ZIG_OBJC_HASH" "$VENDOR_DIR"
echo "[+] vendored pinned zig-objc for arm64e"
fi
if [ ! -f "$VENDOR_PATCH_MARKER" ]; then
patch -p1 -f -d "$VENDOR_DIR" < "$VENDOR_PATCH" >/dev/null
touch "$VENDOR_PATCH_MARKER"
echo "[+] applied zig-objc arm64e patch"
else
echo "[+] zig-objc arm64e patch already applied"
fi
echo "[+] prepared arm64e pointer-authentication boundaries"

View File

@@ -0,0 +1,152 @@
#include "ghostty.h"
#include <stdbool.h>
#include <stdint.h>
#if defined(__arm64e__)
#include <pthread.h>
#include <ptrauth.h>
#include <stdatomic.h>
#include <stdlib.h>
enum {
ghostty_block_has_copy_dispose = 1 << 25,
ghostty_objc_isa_discriminator = 0x6AE1,
};
typedef struct {
uintptr_t reserved;
uintptr_t size;
void *copy_helper;
void *dispose_helper;
} ghostty_block_descriptor_s;
typedef struct {
void *isa;
int flags;
int reserved;
void *invoke;
ghostty_block_descriptor_s *descriptor;
} ghostty_block_s;
static atomic_flag ghostty_descriptor_lock = ATOMIC_FLAG_INIT;
static void *ghostty_sign_function_pointer(void *pointer,
uintptr_t discriminator) {
void *raw = ptrauth_strip(pointer, ptrauth_key_function_pointer);
return ptrauth_sign_unauthenticated(
raw, ptrauth_key_function_pointer, discriminator);
}
static void *ghostty_sign_objc_isa_pointer(void *pointer,
uintptr_t discriminator) {
void *raw = ptrauth_strip(pointer, ptrauth_key_objc_isa_pointer);
return ptrauth_sign_unauthenticated(
raw, ptrauth_key_objc_isa_pointer, discriminator);
}
static void ghostty_prepare_block_descriptor(ghostty_block_descriptor_s *descriptor) {
while (atomic_flag_test_and_set_explicit(&ghostty_descriptor_lock,
memory_order_acquire)) {
}
descriptor->copy_helper = ghostty_sign_function_pointer(
descriptor->copy_helper,
(uintptr_t)&descriptor->copy_helper);
descriptor->dispose_helper = ghostty_sign_function_pointer(
descriptor->dispose_helper,
(uintptr_t)&descriptor->dispose_helper);
atomic_flag_clear_explicit(&ghostty_descriptor_lock, memory_order_release);
}
const void *ghostty_arm64e_sign_function(const void *pointer) {
return ghostty_sign_function_pointer((void *)pointer, 0);
}
void ghostty_arm64e_prepare_block(void *raw_block) {
ghostty_block_s *block = raw_block;
if ((block->flags & ghostty_block_has_copy_dispose) != 0) {
ghostty_prepare_block_descriptor(block->descriptor);
}
block->isa = ghostty_sign_objc_isa_pointer(
block->isa,
ptrauth_blend_discriminator(&block->isa, ghostty_objc_isa_discriminator));
block->invoke = ghostty_sign_function_pointer(
block->invoke,
(uintptr_t)&block->invoke);
#if __has_feature(ptrauth_signed_block_descriptors)
block->descriptor = ptrauth_sign_unauthenticated(
ptrauth_strip(block->descriptor, ptrauth_key_block_descriptor_pointer),
ptrauth_key_block_descriptor_pointer,
ptrauth_blend_discriminator(&block->descriptor, 0xC0BB));
#endif
}
__attribute__((visibility("hidden"))) int pa_pthread_cr(
pthread_t *thread,
const pthread_attr_t *attributes,
void *(*start_routine)(void *),
void *argument) {
start_routine = ghostty_sign_function_pointer((void *)start_routine, 0);
return pthread_create(thread, attributes, start_routine, argument);
}
__attribute__((visibility("hidden"))) void pa_qs(
void *base,
size_t count,
size_t size,
int (*comparator)(const void *, const void *)) {
comparator = ghostty_sign_function_pointer((void *)comparator, 0);
qsort(base, count, size, comparator);
}
extern ghostty_app_t ghostty_arm64e_core_app_new(
const ghostty_runtime_config_s *,
ghostty_config_t);
extern ghostty_surface_t ghostty_arm64e_core_surface_new(
ghostty_app_t,
const ghostty_surface_config_s *);
GHOSTTY_API ghostty_app_t ghostty_app_new(
const ghostty_runtime_config_s *config,
ghostty_config_t ghostty_config) {
ghostty_runtime_config_s raw = *config;
raw.wakeup_cb = ptrauth_strip(raw.wakeup_cb, ptrauth_key_function_pointer);
raw.action_cb = ptrauth_strip(raw.action_cb, ptrauth_key_function_pointer);
raw.read_clipboard_cb =
ptrauth_strip(raw.read_clipboard_cb, ptrauth_key_function_pointer);
raw.confirm_read_clipboard_cb =
ptrauth_strip(raw.confirm_read_clipboard_cb, ptrauth_key_function_pointer);
raw.write_clipboard_cb =
ptrauth_strip(raw.write_clipboard_cb, ptrauth_key_function_pointer);
raw.close_surface_cb =
ptrauth_strip(raw.close_surface_cb, ptrauth_key_function_pointer);
return ghostty_arm64e_core_app_new(&raw, ghostty_config);
}
GHOSTTY_API ghostty_surface_t ghostty_surface_new(
ghostty_app_t app,
const ghostty_surface_config_s *config) {
ghostty_surface_config_s raw = *config;
raw.receive_buffer =
ptrauth_strip(raw.receive_buffer, ptrauth_key_function_pointer);
raw.receive_resize =
ptrauth_strip(raw.receive_resize, ptrauth_key_function_pointer);
return ghostty_arm64e_core_surface_new(app, &raw);
}
#else
const void *ghostty_arm64e_sign_function(const void *pointer) {
return pointer;
}
void ghostty_arm64e_prepare_block(void *block) {
(void)block;
}
#endif

View File

@@ -0,0 +1,18 @@
// libc++'s std::__1::__libcpp_verbose_abort is only exported by the Apple
// system libc++.1.dylib since iOS 16.3 / macOS 13.3 / tvOS 16.3. Zig's
// bundled libc++ headers reference it unconditionally (no Apple availability
// markup), so apps linking libghostty.a crash at launch on older OS versions
// with dyld "Symbol missing". Shipping the definition inside the archive
// resolves the reference at static link time instead.
//
// Header-free on purpose: zig cc has no Apple sysroot in this build.
__attribute__((noreturn)) void abort(void);
__attribute__((visibility("hidden"), noreturn))
void libghostty_libcpp_verbose_abort(const char *format, ...) __asm__("__ZNSt3__122__libcpp_verbose_abortEPKcz");
void libghostty_libcpp_verbose_abort(const char *format, ...) {
(void)format;
abort();
}

80
vendor/libghostty-spm/Script/tag-release.sh vendored Executable file
View File

@@ -0,0 +1,80 @@
#!/bin/zsh
set -euo pipefail
cd "$(dirname "$0")/.."
if [ ! -f .root ]; then
echo "[-] malformed project structure"
exit 1
fi
usage() {
cat <<'EOF'
Usage: ./Script/tag-release.sh [options]
Options:
--push push the new tag to origin after creating it
--suffix <value> override the ci-style release suffix
-h, --help show this help
Notes:
- release tags follow the ci pattern: 1.0.<suffix>
- this script tags swift source releases only
- this script does not create storage.* tags or update binaries
EOF
}
PUSH_TAG=0
RELEASE_SUFFIX=
while [ $# -gt 0 ]; do
case "$1" in
--push)
PUSH_TAG=1
shift
;;
--suffix)
RELEASE_SUFFIX="$2"
shift 2
;;
-h | --help)
usage
exit 0
;;
*)
echo "[-] unknown argument: $1"
usage
exit 1
;;
esac
done
if [ -z "$RELEASE_SUFFIX" ]; then
RELEASE_SUFFIX=$(date -u +%s)
fi
RELEASE_TAG="1.0.$RELEASE_SUFFIX"
RELEASE_DATE=$(date -u +%Y-%m-%d)
HEAD_SHA=$(git rev-parse --short=12 HEAD)
if git rev-parse "$RELEASE_TAG" >/dev/null 2>&1; then
echo "[-] tag already exists: $RELEASE_TAG"
exit 1
fi
TAG_MESSAGE=$(cat <<EOF
Swift source release $RELEASE_DATE
Tag: $RELEASE_TAG
Commit: $HEAD_SHA
EOF
)
git tag -a "$RELEASE_TAG" -m "$TAG_MESSAGE"
echo "[+] created tag $RELEASE_TAG for $HEAD_SHA on $RELEASE_DATE"
if [ "$PUSH_TAG" -eq 1 ]; then
git push origin "$RELEASE_TAG"
echo "[+] pushed tag $RELEASE_TAG"
fi

View File

@@ -0,0 +1,288 @@
#!/bin/bash
set -euo pipefail
cd "$(dirname "$0")/.."
if [ ! -f .root ]; then
echo "[*] malformed project structure"
exit 1
fi
XCFRAMEWORK_PATH=${1:-BinaryTarget/GhosttyKit.xcframework}
if [ ! -d "$XCFRAMEWORK_PATH" ]; then
echo "[!] xcframework not found: $XCFRAMEWORK_PATH"
exit 1
fi
format_output() {
if command -v xcbeautify >/dev/null 2>&1; then
xcbeautify
else
cat
fi
}
WORK_DIR=$(mktemp -d)
trap 'rm -rf "$WORK_DIR"' EXIT
mkdir -p "$WORK_DIR/Consumer/BinaryTarget" "$WORK_DIR/Consumer/Sources/Consumer"
ditto "$XCFRAMEWORK_PATH" "$WORK_DIR/Consumer/BinaryTarget/GhosttyKit.xcframework"
cat >"$WORK_DIR/Consumer/Package.swift" <<'EOF'
// swift-tools-version: 6.0
import PackageDescription
let package = Package(
name: "Consumer",
platforms: [
.iOS(.v15),
.macOS(.v13),
.macCatalyst(.v15),
],
products: [
.library(name: "Consumer", targets: ["Consumer"]),
],
targets: [
.binaryTarget(
name: "libghostty",
path: "BinaryTarget/GhosttyKit.xcframework"
),
.target(
name: "Consumer",
dependencies: ["libghostty"],
linkerSettings: [
.linkedLibrary("c++"),
.linkedFramework("Carbon", .when(platforms: [.macOS])),
]
),
]
)
EOF
cat >"$WORK_DIR/Consumer/Sources/Consumer/Consumer.swift" <<'EOF'
import libghostty
public func passThroughPlatform(_ platform: ghostty_platform_e) -> ghostty_platform_e {
platform
}
EOF
ARM64E_LINK_SOURCE="$WORK_DIR/arm64e-link.c"
cat >"$ARM64E_LINK_SOURCE" <<'EOF'
#include "ghostty.h"
#include <pthread.h>
#include <ptrauth.h>
#include <stdlib.h>
extern int pa_pthread_cr(
pthread_t *,
const pthread_attr_t *,
void *(*)(void *),
void *);
extern void pa_qs(
void *,
size_t,
size_t,
int (*)(const void *, const void *));
static void wakeup(void *userdata) {
(void)userdata;
}
static void *thread_main(void *context) {
int *value = context;
*value = 42;
return NULL;
}
static int compare_ints(const void *lhs, const void *rhs) {
const int left = *(const int *)lhs;
const int right = *(const int *)rhs;
return (left > right) - (left < right);
}
int main(void) {
if (ghostty_init(0, NULL) != GHOSTTY_SUCCESS) {
return 1;
}
ghostty_config_t config = ghostty_config_new();
if (config == NULL) {
return 2;
}
ghostty_config_finalize(config);
ghostty_runtime_config_s runtime = {0};
runtime.wakeup_cb = wakeup;
ghostty_app_t app = ghostty_app_new(&runtime, config);
if (app == NULL) {
ghostty_config_free(config);
return 3;
}
int thread_value = 0;
pthread_t thread;
void *(*raw_thread_main)(void *) =
ptrauth_strip(&thread_main, ptrauth_key_function_pointer);
if (pa_pthread_cr(&thread, NULL, raw_thread_main, &thread_value) != 0) {
return 4;
}
if (pthread_join(thread, NULL) != 0 || thread_value != 42) {
return 5;
}
int values[] = {3, 1, 2};
int (*raw_compare)(const void *, const void *) =
ptrauth_strip(&compare_ints, ptrauth_key_function_pointer);
pa_qs(values, 3, sizeof(values[0]), raw_compare);
if (values[0] != 1 || values[1] != 2 || values[2] != 3) {
return 6;
}
ghostty_app_free(app);
ghostty_config_free(config);
return 0;
}
EOF
find_slice_paths() {
local platform="$1"
local platform_variant="$2"
python3 - "$XCFRAMEWORK_PATH" "$platform" "$platform_variant" <<'PY'
import os
import plistlib
import sys
xcframework, expected_platform, expected_variant = sys.argv[1:]
expected_variant = expected_variant or None
with open(os.path.join(xcframework, "Info.plist"), "rb") as handle:
libraries = plistlib.load(handle)["AvailableLibraries"]
for library in libraries:
if library.get("SupportedPlatform") != expected_platform:
continue
if library.get("SupportedPlatformVariant") != expected_variant:
continue
root = os.path.join(xcframework, library["LibraryIdentifier"])
print(os.path.join(root, library["LibraryPath"]))
print(os.path.join(root, library["HeadersPath"]))
break
else:
raise SystemExit(
f"[!] xcframework slice not found: {expected_platform}/{expected_variant}"
)
PY
}
link_arm64e() {
local name="$1"
local sdk="$2"
local target="$3"
local platform="$4"
local platform_variant="$5"
local slice_paths
local library_path
local headers_path
local output_path="$WORK_DIR/arm64e-link-$name"
local command
slice_paths=$(find_slice_paths "$platform" "$platform_variant")
library_path=${slice_paths%%$'\n'*}
headers_path=${slice_paths#*$'\n'}
command=(
xcrun --sdk "$sdk" clang
-target "$target"
"$ARM64E_LINK_SOURCE"
-I "$headers_path"
"-Wl,-force_load,$library_path"
-lc++
-framework Foundation
-framework CoreFoundation
-framework CoreGraphics
-framework CoreText
-framework CoreVideo
-framework QuartzCore
-framework IOSurface
-framework Metal
-framework MetalKit
)
case "$name" in
macos)
command+=(-framework Carbon)
;;
ios)
command+=(-framework UIKit)
;;
maccatalyst)
local sdk_path
sdk_path=$(xcrun --sdk macosx --show-sdk-path)
command+=(
-F "$sdk_path/System/iOSSupport/System/Library/Frameworks"
-L "$sdk_path/System/iOSSupport/usr/lib"
-framework UIKit
)
;;
esac
command+=(-o "$output_path")
echo "[*] full link platform=$name architecture=arm64e"
"${command[@]}"
if [ "$(lipo -archs "$output_path")" != "arm64e" ]; then
echo "[!] linked executable is not a single arm64e slice: $output_path"
exit 1
fi
if [ "$name" = "macos" ] && [ "$(uname -m)" = "arm64" ]; then
echo "[*] runtime smoke platform=macos architecture=arm64e"
"$output_path"
fi
}
test_build() {
local destination="$1"
local architecture=${2:-}
local command=(
xcodebuild
-scheme Consumer
-destination "$destination"
-derivedDataPath "$WORK_DIR/DerivedData"
-packageCachePath "$WORK_DIR/PackageCache"
)
if [ -n "$architecture" ]; then
command+=("ARCHS=$architecture" "ONLY_ACTIVE_ARCH=YES")
fi
command+=(build)
echo "[*] consumer build destination=$destination architecture=${architecture:-default}"
"${command[@]}" 2>&1 | format_output
local exit_code=${PIPESTATUS[0]}
if [ "$exit_code" -ne 0 ]; then
echo "[!] consumer build failed destination=$destination"
exit "$exit_code"
fi
}
(
cd "$WORK_DIR/Consumer"
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=macOS" "arm64e"
test_build "generic/platform=macOS,variant=Mac Catalyst" "arm64e"
test_build "generic/platform=iOS" "arm64e"
)
link_arm64e "macos" "macosx" "arm64e-apple-macosx13.0" "macos" ""
link_arm64e "ios" "iphoneos" "arm64e-apple-ios15.0" "ios" ""
link_arm64e "maccatalyst" "macosx" "arm64e-apple-ios15.0-macabi" "ios" "maccatalyst"
echo "[*] xcframework consumer tests passed"

56
vendor/libghostty-spm/Script/test.sh vendored Executable file
View File

@@ -0,0 +1,56 @@
#!/bin/bash
set -euo pipefail
cd "$(dirname "$0")/.."
export CLANG_MODULE_CACHE_PATH="${CLANG_MODULE_CACHE_PATH:-/tmp/clang-module-cache}"
export SWIFTPM_MODULECACHE_OVERRIDE="${SWIFTPM_MODULECACHE_OVERRIDE:-/tmp/swiftpm-module-cache}"
format_output() {
if command -v xcbeautify >/dev/null 2>&1; then
xcbeautify
else
cat
fi
}
test_build() {
local scheme="$1"
local destination="$2"
local architecture=${3:-}
local command=(
xcodebuild
-scheme "$scheme"
-destination "$destination"
)
if [ -n "$architecture" ]; then
command+=("ARCHS=$architecture" "ONLY_ACTIVE_ARCH=YES")
fi
command+=(build)
echo "[*] build scheme=$scheme destination=$destination architecture=${architecture:-default}"
"${command[@]}" 2>&1 | format_output
local exit_code=${PIPESTATUS[0]}
if [ "$exit_code" -ne 0 ]; then
echo "[!] failed scheme=$scheme destination=$destination"
exit "$exit_code"
fi
}
test_build "GhosttyKit" "generic/platform=macOS"
test_build "GhosttyKit" "generic/platform=iOS"
test_build "GhosttyKit" "generic/platform=iOS Simulator"
test_build "GhosttyTerminal" "generic/platform=macOS"
test_build "GhosttyTerminal" "generic/platform=macOS,variant=Mac Catalyst"
test_build "GhosttyTerminal" "generic/platform=iOS"
test_build "GhosttyTerminal" "generic/platform=iOS Simulator"
test_build "GhosttyKit" "generic/platform=macOS" "arm64e"
test_build "GhosttyKit" "generic/platform=macOS,variant=Mac Catalyst" "arm64e"
test_build "GhosttyKit" "generic/platform=iOS" "arm64e"
test_build "GhosttyTerminal" "generic/platform=macOS" "arm64e"
test_build "GhosttyTerminal" "generic/platform=macOS,variant=Mac Catalyst" "arm64e"
test_build "GhosttyTerminal" "generic/platform=iOS" "arm64e"
echo "[*] all tests passed"

View File

@@ -0,0 +1,73 @@
#!/bin/bash
set -euo pipefail
cd "$(dirname "$0")/.."
if [ ! -f .root ]; then
echo "[*] malformed project structure"
exit 1
fi
PACKAGE_TAG=${1:-}
STORAGE_TAG=${2:-}
ASSET_NAME=${3:-GhosttyKit.xcframework.zip}
if [ -z "$PACKAGE_TAG" ] || [ -z "$STORAGE_TAG" ]; then
echo "Usage: $0 <package_tag> <storage_tag> [asset_name]"
exit 1
fi
if ! command -v gh >/dev/null 2>&1; then
echo "[!] gh not found"
exit 1
fi
git fetch --tags origin
package_commit=$(git rev-parse "refs/tags/$PACKAGE_TAG")
storage_commit=$(git rev-parse "refs/tags/$STORAGE_TAG")
if [ "$package_commit" != "$storage_commit" ]; then
echo "[!] package tag and storage tag point at different commits"
echo " $PACKAGE_TAG: $package_commit"
echo " $STORAGE_TAG: $storage_commit"
exit 1
fi
manifest=$(git show "$PACKAGE_TAG:Package.swift")
download_url=$(printf '%s\n' "$manifest" | python3 -c 'import re, sys; text=sys.stdin.read(); urls=re.findall(r"url:\s*\"([^\"]+)\"", text); matches=[url for url in urls if "/releases/download/" in url and url.endswith("/GhosttyKit.xcframework.zip")]; print(matches[0] if matches else "", end="")')
checksum=$(printf '%s\n' "$manifest" | python3 -c 'import re, sys; text=sys.stdin.read(); match=re.search(r"checksum:\s*\"([0-9a-f]{64})\"", text); print(match.group(1) if match else "", end="")')
if [ -z "$download_url" ] || [ -z "$checksum" ]; then
echo "[!] failed to read binary target URL/checksum from Package.swift at $PACKAGE_TAG"
exit 1
fi
expected_url="https://github.com/Lakr233/libghostty-spm/releases/download/$STORAGE_TAG/$ASSET_NAME"
if [ "$download_url" != "$expected_url" ]; then
echo "[!] Package.swift download URL does not match storage release"
echo " expected: $expected_url"
echo " actual: $download_url"
exit 1
fi
asset_digest=$(
gh release view "$STORAGE_TAG" \
--json assets \
--jq ".assets[] | select(.name == \"$ASSET_NAME\") | .digest"
)
if [ -z "$asset_digest" ]; then
echo "[!] release asset not found: $STORAGE_TAG/$ASSET_NAME"
exit 1
fi
if [ "$asset_digest" != "sha256:$checksum" ]; then
echo "[!] release asset digest does not match Package.swift checksum"
echo " asset: $asset_digest"
echo " manifest: sha256:$checksum"
exit 1
fi
echo "[*] release verified: $PACKAGE_TAG -> $STORAGE_TAG/$ASSET_NAME"

View File

@@ -0,0 +1,125 @@
#!/bin/bash
set -euo pipefail
cd "$(dirname "$0")/.."
if [ ! -f .root ]; then
echo "[*] malformed project structure"
exit 1
fi
INPUT_PATH=${1:-}
if [ -z "$INPUT_PATH" ]; then
echo "Usage: $0 <xcframework_or_zip>"
exit 1
fi
if [ ! -e "$INPUT_PATH" ]; then
echo "[!] not found: $INPUT_PATH"
exit 1
fi
TEMP_DIR=
XCFRAMEWORK_PATH="$INPUT_PATH"
cleanup() {
if [ -n "$TEMP_DIR" ]; then
rm -rf "$TEMP_DIR"
fi
}
trap cleanup EXIT
if [[ "$INPUT_PATH" == *.zip ]]; then
TEMP_DIR=$(mktemp -d)
ditto -x -k "$INPUT_PATH" "$TEMP_DIR"
XCFRAMEWORK_PATH=$(find "$TEMP_DIR" -maxdepth 1 -name "*.xcframework" -type d | head -1)
fi
if [ -z "$XCFRAMEWORK_PATH" ] || [ ! -d "$XCFRAMEWORK_PATH" ]; then
echo "[!] xcframework not found in input: $INPUT_PATH"
exit 1
fi
python3 - "$XCFRAMEWORK_PATH" <<'PY'
import os
import plistlib
import subprocess
import sys
xcframework = sys.argv[1]
info_path = os.path.join(xcframework, "Info.plist")
if not os.path.isfile(info_path):
raise SystemExit(f"[!] missing xcframework Info.plist: {info_path}")
for root, dirs, _ in os.walk(xcframework):
for directory in dirs:
if directory.endswith(".framework"):
raise SystemExit(f"[!] framework wrapper is not allowed in static-library xcframework: {os.path.join(root, directory)}")
with open(info_path, "rb") as handle:
info = plistlib.load(handle)
libraries = info.get("AvailableLibraries")
if not isinstance(libraries, list) or not libraries:
raise SystemExit("[!] AvailableLibraries is empty or missing")
for library in libraries:
identifier = library.get("LibraryIdentifier")
library_path = library.get("LibraryPath")
binary_path = library.get("BinaryPath")
headers_path = library.get("HeadersPath")
platform = library.get("SupportedPlatform")
platform_variant = library.get("SupportedPlatformVariant")
declared_architectures = set(library.get("SupportedArchitectures", []))
if not identifier:
raise SystemExit("[!] library entry missing LibraryIdentifier")
if platform not in {"ios", "macos", "tvos", "watchos", "xros"}:
raise SystemExit(f"[!] unsupported platform in {identifier}: {platform}")
if library_path != "libghostty.a" or binary_path != "libghostty.a":
raise SystemExit(f"[!] {identifier} must reference libghostty.a, got LibraryPath={library_path!r} BinaryPath={binary_path!r}")
if headers_path != "Headers":
raise SystemExit(f"[!] {identifier} must use HeadersPath=Headers, got {headers_path!r}")
variant_dir = os.path.join(xcframework, identifier)
archive_path = os.path.join(variant_dir, library_path)
headers_dir = os.path.join(variant_dir, headers_path)
header_path = os.path.join(headers_dir, "ghostty.h")
modulemap_path = os.path.join(headers_dir, "module.modulemap")
for required_path in (archive_path, header_path, modulemap_path):
if not os.path.exists(required_path):
raise SystemExit(f"[!] {identifier} missing required file: {required_path}")
actual_architectures = set(
subprocess.check_output(["lipo", "-archs", archive_path], text=True).split()
)
if actual_architectures != declared_architectures:
raise SystemExit(
f"[!] {identifier} architecture metadata mismatch: "
f"plist={sorted(declared_architectures)} archive={sorted(actual_architectures)}"
)
expected_architectures = {
("macos", None): {"arm64", "arm64e", "x86_64"},
("ios", None): {"arm64", "arm64e"},
("ios", "maccatalyst"): {"arm64", "arm64e", "x86_64"},
("ios", "simulator"): {"arm64", "x86_64"},
}.get((platform, platform_variant))
if expected_architectures is not None and actual_architectures != expected_architectures:
raise SystemExit(
f"[!] {identifier} architectures must be {sorted(expected_architectures)}, "
f"got {sorted(actual_architectures)}"
)
with open(modulemap_path, "r", encoding="utf-8") as handle:
modulemap = handle.read()
if "framework module libghostty" in modulemap:
raise SystemExit(f"[!] {identifier} module map must not declare a framework module")
if "module libghostty" not in modulemap:
raise SystemExit(f"[!] {identifier} module map does not declare module libghostty")
print(f"[*] verified static-library xcframework layout and architectures: {xcframework}")
PY

View File

@@ -0,0 +1,8 @@
//
// GhosttyKit.swift
// libghostty-spm
//
// Created by Lakr233 on 2026/3/16.
//
@_exported import libghostty

View File

@@ -0,0 +1,40 @@
//
// GhosttyConfigRenderer.swift
// libghostty-spm
//
// Created by Lakr233 on 2026/3/17.
//
import Foundation
enum GhosttyConfigRenderer {
static func render(
baseContents: String,
configuration: TerminalConfiguration,
theme: TerminalConfiguration
) -> String {
var sections: [String] = []
let normalizedBase = normalize(baseContents)
if !normalizedBase.isEmpty {
sections.append(normalizedBase)
}
let configurationLines = configuration.commands.map(\.renderedLine)
if !configurationLines.isEmpty {
sections.append(configurationLines.joined(separator: "\n"))
}
let themeLines = theme.commands.map(\.renderedLine)
if !themeLines.isEmpty {
sections.append(themeLines.joined(separator: "\n"))
}
guard !sections.isEmpty else { return "" }
return sections.joined(separator: "\n") + "\n"
}
private static func normalize(_ contents: String) -> String {
contents.trimmingCharacters(in: .whitespacesAndNewlines)
}
}

View File

@@ -0,0 +1,21 @@
//
// TerminalColorScheme+SwiftUI.swift
// libghostty-spm
//
// Created by Lakr233 on 2026/3/17.
//
#if canImport(SwiftUI)
import SwiftUI
extension TerminalColorScheme {
init(_ colorScheme: ColorScheme) {
switch colorScheme {
case .dark:
self = .dark
default:
self = .light
}
}
}
#endif

View File

@@ -0,0 +1,18 @@
//
// TerminalColorScheme.swift
// libghostty-spm
//
import GhosttyKit
public enum TerminalColorScheme: Sendable {
case light
case dark
var ghosttyValue: ghostty_color_scheme_e {
switch self {
case .light: GHOSTTY_COLOR_SCHEME_LIGHT
case .dark: GHOSTTY_COLOR_SCHEME_DARK
}
}
}

View File

@@ -0,0 +1,360 @@
//
// TerminalConfiguration.swift
// libghostty-spm
//
// Created by Lakr233 on 2026/3/17.
//
public enum TerminalCursorStyle: String, Sendable, Hashable {
case block
case bar
case underline
}
public enum TerminalConfigCommand: Sendable, Hashable {
// Font
case fontFamily(String)
case fontSize(Float)
case fontThicken(Bool)
case fontThickenStrength(Int)
// Cursor
case cursorStyle(TerminalCursorStyle)
case cursorStyleBlink(Bool)
case cursorColor(String)
case cursorText(String)
case cursorOpacity(Double)
// Colors
case background(String)
case foreground(String)
case selectionBackground(String)
case selectionForeground(String)
case boldColor(String)
case palette(index: Int, color: String)
case minimumContrast(Double)
// Background
case backgroundOpacity(Double)
case backgroundBlur(Int)
// Layout
case windowPaddingX(Int)
case windowPaddingY(Int)
/// Escape hatch
case custom(key: String, value: String)
var renderedLine: String {
switch self {
case let .fontFamily(value):
"font-family = \(value)"
case let .fontSize(value):
"font-size = \(value.formatted(.number.precision(.fractionLength(0 ... 2))))"
case let .fontThicken(enabled):
"font-thicken = \(enabled)"
case let .fontThickenStrength(value):
"font-thicken-strength = \(value)"
case let .cursorStyle(style):
"cursor-style = \(style.rawValue)"
case let .cursorStyleBlink(enabled):
"cursor-style-blink = \(enabled)"
case let .cursorColor(value):
"cursor-color = \(value)"
case let .cursorText(value):
"cursor-text = \(value)"
case let .cursorOpacity(value):
"cursor-opacity = \(value.formatted(.number.precision(.fractionLength(0 ... 3))))"
case let .background(value):
"background = \(value)"
case let .foreground(value):
"foreground = \(value)"
case let .selectionBackground(value):
"selection-background = \(value)"
case let .selectionForeground(value):
"selection-foreground = \(value)"
case let .boldColor(value):
"bold-color = \(value)"
case let .palette(index, color):
"palette = \(index)=\(color)"
case let .minimumContrast(value):
"minimum-contrast = \(value.formatted(.number.precision(.fractionLength(0 ... 2))))"
case let .backgroundOpacity(value):
"background-opacity = \(value.formatted(.number.precision(.fractionLength(0 ... 3))))"
case let .backgroundBlur(value):
"background-blur = \(value)"
case let .windowPaddingX(value):
"window-padding-x = \(value)"
case let .windowPaddingY(value):
"window-padding-y = \(value)"
case let .custom(key, value):
"\(key) = \(value)"
}
}
}
public struct TerminalConfiguration: Sendable, Hashable {
public struct Builder {
var commands: [TerminalConfigCommand] = []
public init() {}
init(commands: [TerminalConfigCommand]) {
self.commands = commands
}
/// Font
public mutating func withFontFamily(_ value: String) {
commands.append(.fontFamily(value))
}
public mutating func withFontSize(_ value: Float) {
commands.append(.fontSize(value))
}
public mutating func withFontThicken(_ enabled: Bool) {
commands.append(.fontThicken(enabled))
}
public mutating func withFontThickenStrength(_ value: Int) {
commands.append(.fontThickenStrength(value))
}
/// Cursor
public mutating func withCursorStyle(_ style: TerminalCursorStyle) {
commands.append(.cursorStyle(style))
}
public mutating func withCursorStyleBlink(_ enabled: Bool) {
commands.append(.cursorStyleBlink(enabled))
}
public mutating func withCursorColor(_ value: String) {
commands.append(.cursorColor(value))
}
public mutating func withCursorText(_ value: String) {
commands.append(.cursorText(value))
}
public mutating func withCursorOpacity(_ value: Double) {
commands.append(.cursorOpacity(value))
}
/// Colors
public mutating func withBackground(_ value: String) {
commands.append(.background(value))
}
public mutating func withForeground(_ value: String) {
commands.append(.foreground(value))
}
public mutating func withSelectionBackground(_ value: String) {
commands.append(.selectionBackground(value))
}
public mutating func withSelectionForeground(_ value: String) {
commands.append(.selectionForeground(value))
}
public mutating func withBoldColor(_ value: String) {
commands.append(.boldColor(value))
}
public mutating func withPalette(_ index: Int, color: String) {
commands.append(.palette(index: index, color: color))
}
public mutating func withMinimumContrast(_ value: Double) {
commands.append(.minimumContrast(value))
}
/// Background
public mutating func withBackgroundOpacity(_ value: Double) {
commands.append(.backgroundOpacity(value))
}
public mutating func withBackgroundBlur(_ value: Int) {
commands.append(.backgroundBlur(value))
}
/// Layout
public mutating func withWindowPaddingX(_ value: Int) {
commands.append(.windowPaddingX(value))
}
public mutating func withWindowPaddingY(_ value: Int) {
commands.append(.windowPaddingY(value))
}
/// Escape hatch
public mutating func withCustom(_ key: String, _ value: String) {
commands.append(.custom(key: key, value: value))
}
}
let commands: [TerminalConfigCommand]
public init() {
commands = []
}
public init(configure: (inout Builder) -> Void) {
self.init(startingFrom: .init(), configure: configure)
}
public init(
startingFrom base: TerminalConfiguration,
configure: (inout Builder) -> Void
) {
var builder = Builder(commands: base.commands)
configure(&builder)
commands = builder.commands
}
public func appending(_ command: TerminalConfigCommand) -> TerminalConfiguration {
TerminalConfiguration(commands: commands + [command])
}
// MARK: - Font
public func fontFamily(_ value: String) -> TerminalConfiguration {
appending(.fontFamily(value))
}
public func fontSize(_ value: Float) -> TerminalConfiguration {
appending(.fontSize(value))
}
public func fontThicken(_ enabled: Bool) -> TerminalConfiguration {
appending(.fontThicken(enabled))
}
public func fontThickenStrength(_ value: Int) -> TerminalConfiguration {
appending(.fontThickenStrength(value))
}
// MARK: - Cursor
public func cursorStyle(_ style: TerminalCursorStyle) -> TerminalConfiguration {
appending(.cursorStyle(style))
}
public func cursorStyleBlink(_ enabled: Bool) -> TerminalConfiguration {
appending(.cursorStyleBlink(enabled))
}
public func cursorColor(_ value: String) -> TerminalConfiguration {
appending(.cursorColor(value))
}
public func cursorText(_ value: String) -> TerminalConfiguration {
appending(.cursorText(value))
}
public func cursorOpacity(_ value: Double) -> TerminalConfiguration {
appending(.cursorOpacity(value))
}
// MARK: - Colors
public func background(_ value: String) -> TerminalConfiguration {
appending(.background(value))
}
public func foreground(_ value: String) -> TerminalConfiguration {
appending(.foreground(value))
}
public func selectionBackground(_ value: String) -> TerminalConfiguration {
appending(.selectionBackground(value))
}
public func selectionForeground(_ value: String) -> TerminalConfiguration {
appending(.selectionForeground(value))
}
public func boldColor(_ value: String) -> TerminalConfiguration {
appending(.boldColor(value))
}
public func palette(_ index: Int, color: String) -> TerminalConfiguration {
appending(.palette(index: index, color: color))
}
public func minimumContrast(_ value: Double) -> TerminalConfiguration {
appending(.minimumContrast(value))
}
// MARK: - Background
public func backgroundOpacity(_ value: Double) -> TerminalConfiguration {
appending(.backgroundOpacity(value))
}
public func backgroundBlur(_ value: Int) -> TerminalConfiguration {
appending(.backgroundBlur(value))
}
// MARK: - Layout
public func windowPaddingX(_ value: Int) -> TerminalConfiguration {
appending(.windowPaddingX(value))
}
public func windowPaddingY(_ value: Int) -> TerminalConfiguration {
appending(.windowPaddingY(value))
}
// MARK: - Escape Hatch
public func custom(_ key: String, _ value: String) -> TerminalConfiguration {
appending(.custom(key: key, value: value))
}
// MARK: - Defaults
public static let `default` = TerminalConfiguration { builder in
builder.withCursorStyle(.block)
builder.withCursorStyleBlink(true)
#if os(iOS)
builder.withFontSize(10)
#else
builder.withFontSize(14)
#endif
builder.withFontThicken(true)
}
public var rendered: String {
commands.map(\.renderedLine).joined(separator: "\n")
}
var isEmpty: Bool {
commands.isEmpty
}
init(commands: [TerminalConfigCommand]) {
self.commands = commands
}
}

View File

@@ -0,0 +1,70 @@
//
// TerminalTheme+Defaults.swift
// libghostty-spm
//
public extension TerminalTheme {
/// Afterglow (dark) + Alabaster (light) default theme.
static let `default` = TerminalTheme(
light: .alabaster,
dark: .afterglow
)
}
public extension TerminalConfiguration {
/// Alabaster light terminal theme.
///
/// Background: #F7F7F7, Foreground: #000000
static let alabaster = TerminalConfiguration { builder in
builder.withBackground("F7F7F7")
builder.withForeground("000000")
builder.withCursorColor("007ACC")
builder.withSelectionBackground("C9D0D9")
// Normal colors (0-7)
builder.withPalette(0, color: "#000000")
builder.withPalette(1, color: "#AA3731")
builder.withPalette(2, color: "#448C27")
builder.withPalette(3, color: "#CB8800")
builder.withPalette(4, color: "#325CC0")
builder.withPalette(5, color: "#7A3E9D")
builder.withPalette(6, color: "#0083B2")
builder.withPalette(7, color: "#F7F7F7")
// Bright colors (8-15)
builder.withPalette(8, color: "#777777")
builder.withPalette(9, color: "#F03E31")
builder.withPalette(10, color: "#60CB00")
builder.withPalette(11, color: "#FFBC5D")
builder.withPalette(12, color: "#007ACC")
builder.withPalette(13, color: "#E64CE6")
builder.withPalette(14, color: "#00AACB")
builder.withPalette(15, color: "#F7F7F7")
}
/// Afterglow dark terminal theme.
///
/// Background: #212121, Foreground: #D0D0D0
static let afterglow = TerminalConfiguration { builder in
builder.withBackground("212121")
builder.withForeground("D0D0D0")
builder.withCursorColor("D0D0D0")
builder.withSelectionBackground("303030")
// Normal colors (0-7)
builder.withPalette(0, color: "#151515")
builder.withPalette(1, color: "#AC4142")
builder.withPalette(2, color: "#7E8E50")
builder.withPalette(3, color: "#E4B567")
builder.withPalette(4, color: "#6C99BB")
builder.withPalette(5, color: "#9F4E86")
builder.withPalette(6, color: "#7DD5CF")
builder.withPalette(7, color: "#D0D0D0")
// Bright colors (8-15)
builder.withPalette(8, color: "#505050")
builder.withPalette(9, color: "#AC4142")
builder.withPalette(10, color: "#7E8E50")
builder.withPalette(11, color: "#E4B567")
builder.withPalette(12, color: "#6C99BB")
builder.withPalette(13, color: "#9F4E86")
builder.withPalette(14, color: "#7DD5CF")
builder.withPalette(15, color: "#F5F5F5")
}
}

View File

@@ -0,0 +1,32 @@
//
// TerminalTheme.swift
// libghostty-spm
//
// Created by Lakr233 on 2026/3/17.
//
public struct TerminalTheme: Sendable, Hashable {
public var light: TerminalConfiguration
public var dark: TerminalConfiguration
public init(
light: TerminalConfiguration = .init(),
dark: TerminalConfiguration = .init()
) {
self.light = light
self.dark = dark
}
var isEmpty: Bool {
light.isEmpty && dark.isEmpty
}
func configuration(for colorScheme: TerminalColorScheme) -> TerminalConfiguration {
switch colorScheme {
case .light:
light
case .dark:
dark
}
}
}

View File

@@ -0,0 +1,197 @@
//
// TerminalController+Callbacks.swift
// libghostty-spm
//
import Foundation
import GhosttyKit
#if canImport(UIKit)
import UIKit
#elseif canImport(AppKit)
import AppKit
#endif
private enum TerminalCallbacks {
static func wakeup(userdata: UnsafeMutableRawPointer?) {
guard let userdata else { return }
let controller = Unmanaged<TerminalController>.fromOpaque(userdata)
.takeUnretainedValue()
terminalRunOnMain {
controller.handleWakeup()
}
}
static func action(
appPtr: ghostty_app_t?,
target: ghostty_target_s,
action: ghostty_action_s
) -> Bool {
guard let appPtr else { return false }
guard ghostty_app_userdata(appPtr) != nil else { return false }
guard target.tag == GHOSTTY_TARGET_SURFACE else { return false }
guard let surfacePtr = target.target.surface else { return false }
guard let bridgePtr = ghostty_surface_userdata(surfacePtr) else { return false }
let bridge = Unmanaged<TerminalCallbackBridge>
.fromOpaque(bridgePtr)
.takeUnretainedValue()
terminalRunOnMain {
bridge.handleAction(action)
}
return false
}
static func closeSurface(
userdata: UnsafeMutableRawPointer?,
processAlive: Bool
) {
guard let userdata else { return }
let bridge = Unmanaged<TerminalCallbackBridge>
.fromOpaque(userdata)
.takeUnretainedValue()
terminalRunOnMain {
bridge.handleClose(processAlive: processAlive)
}
}
static func writeClipboard(
userdata _: UnsafeMutableRawPointer?,
clipboard _: ghostty_clipboard_e,
contents: UnsafePointer<ghostty_clipboard_content_s>?,
contentsLen: Int,
confirm _: Bool
) {
guard contentsLen > 0 else { return }
guard let content = contents?.pointee else { return }
guard let data = content.data else { return }
let string = String(cString: data)
#if canImport(UIKit)
UIPasteboard.general.string = string
#elseif canImport(AppKit)
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
pasteboard.setString(string, forType: .string)
#endif
}
static func readClipboard(
userdata: UnsafeMutableRawPointer?,
clipboard _: ghostty_clipboard_e,
opaquePtr: UnsafeMutableRawPointer?
) -> Bool {
guard let userdata, let opaquePtr else { return false }
let bridge = Unmanaged<TerminalCallbackBridge>
.fromOpaque(userdata)
.takeUnretainedValue()
guard let surface = bridge.rawSurface else { return false }
#if canImport(UIKit)
let string = UIPasteboard.general.string
#elseif canImport(AppKit)
let string = NSPasteboard.general.string(forType: .string)
#endif
guard let string else {
TerminalDebugLog.log(.input, "clipboard paste read empty")
return false
}
TerminalDebugLog.log(
.input,
"clipboard paste read bytes=\(string.utf8.count) lines=\(TerminalInputText.lineCount(in: string))"
)
string.withCString { cString in
ghostty_surface_complete_clipboard_request(surface, cString, opaquePtr, false)
}
TerminalDebugLog.log(.input, "clipboard paste complete")
return true
}
static func confirmReadClipboard(
userdata: UnsafeMutableRawPointer?,
string: UnsafePointer<CChar>?,
opaquePtr: UnsafeMutableRawPointer?,
request: ghostty_clipboard_request_e
) {
guard let userdata, let string, let opaquePtr else { return }
let bridge = Unmanaged<TerminalCallbackBridge>
.fromOpaque(userdata)
.takeUnretainedValue()
guard let surface = bridge.rawSurface else { return }
let text = String(cString: string)
TerminalDebugLog.log(
.input,
"clipboard paste confirm request=\(request.rawValue) bytes=\(text.utf8.count) lines=\(TerminalInputText.lineCount(in: text))"
)
text.withCString { cString in
ghostty_surface_complete_clipboard_request(surface, cString, opaquePtr, true)
}
TerminalDebugLog.log(.input, "clipboard paste confirmed")
}
}
func terminalControllerWakeupCallback(userdata: UnsafeMutableRawPointer?) {
TerminalCallbacks.wakeup(userdata: userdata)
}
func terminalControllerActionCallback(
appPtr: ghostty_app_t?,
target: ghostty_target_s,
action: ghostty_action_s
) -> Bool {
TerminalCallbacks.action(appPtr: appPtr, target: target, action: action)
}
func terminalControllerCloseSurfaceCallback(
userdata: UnsafeMutableRawPointer?,
processAlive: Bool
) {
TerminalCallbacks.closeSurface(userdata: userdata, processAlive: processAlive)
}
func terminalControllerWriteClipboardCallback(
userdata: UnsafeMutableRawPointer?,
clipboard: ghostty_clipboard_e,
contents: UnsafePointer<ghostty_clipboard_content_s>?,
contentsLen: Int,
confirm: Bool
) {
TerminalCallbacks.writeClipboard(
userdata: userdata,
clipboard: clipboard,
contents: contents,
contentsLen: contentsLen,
confirm: confirm
)
}
func terminalControllerReadClipboardCallback(
userdata: UnsafeMutableRawPointer?,
clipboard: ghostty_clipboard_e,
opaquePtr: UnsafeMutableRawPointer?
) -> Bool {
TerminalCallbacks.readClipboard(
userdata: userdata,
clipboard: clipboard,
opaquePtr: opaquePtr
)
}
func terminalControllerConfirmReadClipboardCallback(
userdata: UnsafeMutableRawPointer?,
string: UnsafePointer<CChar>?,
opaquePtr: UnsafeMutableRawPointer?,
request: ghostty_clipboard_request_e
) {
TerminalCallbacks.confirmReadClipboard(
userdata: userdata,
string: string,
opaquePtr: opaquePtr,
request: request
)
}

View File

@@ -0,0 +1,218 @@
//
// TerminalController+Config.swift
// libghostty-spm
//
import Foundation
import GhosttyKit
extension TerminalController {
@discardableResult
public func updateConfigSource(_ source: ConfigSource) -> Bool {
guard source != configSource else { return true }
switch Self.prepareConfig(source: source) {
case let .success(value):
applyPreparedConfigToRuntime(value, source: source)
return true
case let .failure(issue):
lastConfigurationIssue = issue.description
Self.reportConfigurationIssue(issue.description)
return false
}
}
func applyResolvedConfig(
_ resolved: (source: ConfigSource, contents: String),
willChange: (() -> Void)?,
applyState: () -> Void = {}
) -> Bool {
guard resolved.source != configSource else {
// ObservableObject subscribers expect will-change semantics.
willChange?()
applyState()
renderedConfigContents = resolved.contents
return true
}
switch Self.prepareConfig(source: resolved.source) {
case let .success(prepared):
// Notify after validation succeeds, but before committed state
// changes become visible through computed TerminalViewState APIs.
willChange?()
applyState()
applyPreparedConfigToRuntime(prepared, source: resolved.source)
return true
case let .failure(issue):
lastConfigurationIssue = issue.description
Self.reportConfigurationIssue(issue.description)
return false
}
}
private func applyPreparedConfigToRuntime(_ prepared: PreparedConfig, source: ConfigSource) {
let previousConfig = config
let previousManagedConfigURL = managedConfigURL
let nextConfig = prepared.rawValue
if let app {
ghostty_app_update_config(app, nextConfig)
}
for bridge in retainedBridges {
guard let surface = bridge.rawSurface else { continue }
ghostty_surface_update_config(surface, nextConfig)
}
applyPreparedConfig(prepared, source: source)
if let previousConfig {
ghostty_config_free(previousConfig)
}
if let previousManagedConfigURL, previousManagedConfigURL != managedConfigURL {
try? FileManager.default.removeItem(at: previousManagedConfigURL)
}
}
func applyInitialConfig(source: ConfigSource) {
switch Self.prepareConfig(source: source) {
case let .success(prepared):
applyPreparedConfig(prepared, source: source)
case let .failure(issue):
lastConfigurationIssue = issue.description
Self.reportConfigurationIssue(issue.description)
guard source != .none else { return }
guard case let .success(fallback) = Self.prepareConfig(source: ConfigSource.none) else {
return
}
applyPreparedConfig(fallback, source: .none)
}
}
func createApp() {
guard let cfg = config else { return }
let userdata = Unmanaged.passUnretained(self).toOpaque()
var runtimeConfig = ghostty_runtime_config_s()
runtimeConfig.userdata = userdata
runtimeConfig.supports_selection_clipboard = true
runtimeConfig.wakeup_cb = terminalControllerWakeupCallback
runtimeConfig.action_cb = terminalControllerActionCallback
runtimeConfig.close_surface_cb = terminalControllerCloseSurfaceCallback
runtimeConfig.write_clipboard_cb = terminalControllerWriteClipboardCallback
runtimeConfig.read_clipboard_cb = terminalControllerReadClipboardCallback
runtimeConfig.confirm_read_clipboard_cb = terminalControllerConfirmReadClipboardCallback
app = ghostty_app_new(&runtimeConfig, cfg)
}
private static func prepareConfig(
source: ConfigSource
) -> Result<PreparedConfig, ConfigurationIssue> {
let resolvedContents: String
let configPath: String
let managedConfigURL: URL?
switch source {
case .none:
resolvedContents = defaultRenderedConfig
switch writeManagedConfig(contents: resolvedContents) {
case let .success(url):
managedConfigURL = url
configPath = url.path
case let .failure(issue):
return .failure(issue)
}
case let .generated(contents):
resolvedContents = contents
switch writeManagedConfig(contents: contents) {
case let .success(url):
managedConfigURL = url
configPath = url.path
case let .failure(issue):
return .failure(issue)
}
case let .file(path):
do {
resolvedContents = try String(contentsOfFile: path, encoding: .utf8)
} catch {
return .failure(ConfigurationIssue("failed to load ghostty config template: \(error)"))
}
managedConfigURL = nil
configPath = path
}
guard let rawValue = ghostty_config_new() else {
if let managedConfigURL {
try? FileManager.default.removeItem(at: managedConfigURL)
}
return .failure(ConfigurationIssue("ghostty_config_new returned nil"))
}
ghostty_config_load_file(rawValue, configPath)
ghostty_config_finalize(rawValue)
let diagnostics = configDiagnostics(from: rawValue)
guard diagnostics.isEmpty else {
ghostty_config_free(rawValue)
if let managedConfigURL {
try? FileManager.default.removeItem(at: managedConfigURL)
}
return .failure(
ConfigurationIssue("ghostty config diagnostics: \(diagnostics.joined(separator: " | "))")
)
}
return .success(
PreparedConfig(
rawValue: rawValue,
managedConfigURL: managedConfigURL,
renderedContents: resolvedContents
)
)
}
private static func writeManagedConfig(contents: String) -> Result<URL, ConfigurationIssue> {
let url = FileManager.default.temporaryDirectory
.appendingPathComponent("ghostty-config-\(UUID().uuidString)")
.appendingPathExtension("conf")
do {
try contents.write(to: url, atomically: true, encoding: .utf8)
return .success(url)
} catch {
return .failure(ConfigurationIssue("failed to write generated ghostty config: \(error)"))
}
}
private static func configDiagnostics(from config: ghostty_config_t) -> [String] {
let count = ghostty_config_diagnostics_count(config)
guard count > 0 else { return [] }
return (0 ..< count).compactMap { index in
let diagnostic = ghostty_config_get_diagnostic(config, index)
guard let message = diagnostic.message else { return nil }
return String(cString: message)
}
}
private static func reportConfigurationIssue(_ message: String) {
NSLog("GhosttyTerminal configuration issue: %@", message)
}
private func applyPreparedConfig(_ prepared: PreparedConfig, source: ConfigSource) {
config = prepared.rawValue
managedConfigURL = prepared.managedConfigURL
renderedConfigContents = prepared.renderedContents
configSource = source
lastConfigurationIssue = nil
}
}

View File

@@ -0,0 +1,145 @@
//
// TerminalController+Surface.swift
// libghostty-spm
//
import Foundation
import GhosttyKit
extension TerminalController {
/// Creates a new Ghostty surface with the given configuration.
///
/// The `platformSetup` closure lets the caller fill in
/// platform-specific fields (`platform_tag`, `platform`, `scale_factor`)
/// on the raw surface config struct before the surface is created.
func createSurface(
bridge: TerminalCallbackBridge,
configuration: TerminalSurfaceOptions,
platformSetup: (inout ghostty_surface_config_s) -> Void
) -> ghostty_surface_t? {
guard let app else { return nil }
var surfaceConfig = ghostty_surface_config_new()
surfaceConfig.userdata = Unmanaged.passUnretained(bridge).toOpaque()
surfaceConfig.context = configuration.context.ghosttyValue
configureBackend(&surfaceConfig, from: configuration)
if let fontSize = configuration.fontSize {
surfaceConfig.font_size = fontSize
}
// Like `working_directory` below, the pointers only need to outlive
// `ghostty_surface_new`, which copies the values during surface init.
return withEnvVarEntries(configuration.envVars) { entries, count in
surfaceConfig.env_vars = entries
surfaceConfig.env_var_count = count
return finalizeSurface(
app: app,
bridge: bridge,
configuration: configuration,
config: &surfaceConfig,
workingDirectory: configuration.workingDirectory,
platformSetup: platformSetup
)
}
}
/// Runs `body` with a C representation of `envVars` (`ghostty_env_var_s`
/// entries) that stays valid for the duration of the call.
private func withEnvVarEntries<T>(
_ envVars: [String: String],
_ body: (UnsafeMutablePointer<ghostty_env_var_s>?, Int) -> T
) -> T {
guard !envVars.isEmpty else { return body(nil, 0) }
let strings: [(key: UnsafeMutablePointer<CChar>, value: UnsafeMutablePointer<CChar>)] =
envVars.map { (strdup($0.key), strdup($0.value)) }
defer {
for entry in strings {
free(entry.key)
free(entry.value)
}
}
var entries = strings.map { ghostty_env_var_s(key: $0.key, value: $0.value) }
return entries.withUnsafeMutableBufferPointer { buffer in
body(buffer.baseAddress, buffer.count)
}
}
func retain(_ bridge: TerminalCallbackBridge) {
retainedBridges.append(bridge)
}
func remove(_ bridge: TerminalCallbackBridge) {
retainedBridges.removeAll { $0 === bridge }
}
var retainedBridgeCount: Int {
retainedBridges.count
}
private func configureBackend(
_ config: inout ghostty_surface_config_s,
from options: TerminalSurfaceOptions
) {
guard case let .inMemory(session) = options.backend else {
config.backend = GHOSTTY_SURFACE_IO_BACKEND_EXEC
return
}
config.backend = GHOSTTY_SURFACE_IO_BACKEND_HOST_MANAGED
config.receive_userdata = Unmanaged.passUnretained(session).toOpaque()
config.receive_buffer = InMemoryTerminalSession.receiveBufferCallback
config.receive_resize = InMemoryTerminalSession.receiveResizeCallback
}
private func finalizeSurface(
app: ghostty_app_t,
bridge: TerminalCallbackBridge,
configuration: TerminalSurfaceOptions,
config: inout ghostty_surface_config_s,
workingDirectory: String?,
platformSetup: (inout ghostty_surface_config_s) -> Void
) -> ghostty_surface_t? {
guard let workingDirectory else {
return buildSurface(
app: app,
bridge: bridge,
configuration: configuration,
config: &config,
platformSetup: platformSetup
)
}
return workingDirectory.withCString { ptr in
config.working_directory = ptr
return buildSurface(
app: app,
bridge: bridge,
configuration: configuration,
config: &config,
platformSetup: platformSetup
)
}
}
private func buildSurface(
app: ghostty_app_t,
bridge: TerminalCallbackBridge,
configuration: TerminalSurfaceOptions,
config: inout ghostty_surface_config_s,
platformSetup: (inout ghostty_surface_config_s) -> Void
) -> ghostty_surface_t? {
platformSetup(&config)
guard let surface = ghostty_surface_new(app, &config) else {
return nil
}
retain(bridge)
if case let .inMemory(session) = configuration.backend {
session.setSurface(surface)
}
return surface
}
}

View File

@@ -0,0 +1,313 @@
//
// TerminalController.swift
// libghostty-spm
//
// Created by Lakr233 on 2026/3/16.
//
import Foundation
import GhosttyKit
#if canImport(UIKit)
import UIKit
#elseif canImport(AppKit)
import AppKit
#endif
/// Manages the Ghostty app lifecycle, configuration loading, and surface
/// creation.
///
/// `TerminalController` is the **single source of truth** for terminal
/// configuration, including the base config, per-session overrides, theme
/// colors, and the active color scheme. When any of these change the
/// controller re-resolves the effective config and pushes it to ghostty.
@MainActor
public final class TerminalController {
struct PreparedConfig {
let rawValue: ghostty_config_t
let managedConfigURL: URL?
let renderedContents: String
}
struct ConfigurationIssue: Error, CustomStringConvertible {
let description: String
init(_ description: String) {
self.description = description
}
}
public enum ConfigSource: Sendable, Hashable {
case none
case file(String)
case generated(String)
}
public static let shared = TerminalController()
static let defaultRenderedConfig = TerminalConfiguration.default.rendered
private static var runtimeInitialized = false
nonisolated(unsafe) var app: ghostty_app_t?
nonisolated(unsafe) var config: ghostty_config_t?
var retainedBridges: [TerminalCallbackBridge] = []
var configSource: ConfigSource
var managedConfigURL: URL?
var renderedConfigContents: String = TerminalController.defaultRenderedConfig
public internal(set) var lastConfigurationIssue: String?
var onWakeup: (() -> Void)?
var shouldProcessWakeup: (() -> Bool)?
// MARK: - Config Resolution State
/// The base config before theme/colorScheme are applied.
private let baseConfigSource: ConfigSource
private var baseConfigTemplate: String = ""
/// Per-session configuration overrides (e.g. font size changes).
public private(set) var terminalConfiguration: TerminalConfiguration
/// Color theme (light + dark variants).
public private(set) var theme: TerminalTheme
/// The currently active color scheme.
public private(set) var effectiveColorScheme: TerminalColorScheme = .light
// MARK: - Public Accessors
public var currentConfigSource: ConfigSource {
configSource
}
public var renderedConfig: String {
renderedConfigContents
}
// MARK: - Initializers
/// Creates a controller with the default terminal configuration.
public convenience init() {
self.init(configuration: .default)
}
/// Creates a controller with a fully custom configuration.
public convenience init(
configuration: TerminalConfiguration,
theme: TerminalTheme = .default
) {
self.init(
configSource: .generated(configuration.rendered),
theme: theme
)
}
/// Creates a controller by composing additional commands on top of
/// the default configuration.
///
/// TerminalController {
/// $0.withBackgroundOpacity(0)
/// $0.withCustom("keybind", "super+k=text:\\x0c")
/// }
public convenience init(
theme: TerminalTheme = .default,
configure: (inout TerminalConfiguration.Builder) -> Void
) {
self.init(
configuration: TerminalConfiguration(
startingFrom: .default,
configure: configure
),
theme: theme
)
}
/// Creates a controller that loads its configuration from a file.
public convenience init(
configFilePath: String?,
theme: TerminalTheme = .default
) {
guard let configFilePath else {
self.init(configSource: .none, theme: theme)
return
}
self.init(configSource: .file(configFilePath), theme: theme)
}
/// Low-level initialiser for full control over the config source.
public init(
configSource: ConfigSource = .none,
theme: TerminalTheme = .default,
terminalConfiguration: TerminalConfiguration = .init()
) {
Self.initializeRuntimeIfNeeded()
baseConfigSource = configSource
self.theme = theme
self.terminalConfiguration = terminalConfiguration
self.configSource = configSource
// Load the base config (without theme) so ghostty validates it.
applyInitialConfig(source: configSource)
baseConfigTemplate = renderedConfigContents
// Now apply theme on top and push to ghostty.
reconfigure()
createApp()
}
// MARK: - Color Scheme
/// Updates the active color scheme and reconfigures the terminal.
///
/// Called by platform views when the OS appearance changes. This is
/// the only method views need to call the controller handles all
/// config resolution internally.
public func setColorScheme(_ scheme: TerminalColorScheme) {
setColorScheme(scheme, willChange: nil)
}
@discardableResult
func setColorScheme(
_ scheme: TerminalColorScheme,
willChange: (() -> Void)?
) -> Bool {
let previous = effectiveColorScheme
guard scheme != previous else {
if let app {
ghostty_app_set_color_scheme(app, scheme.ghosttyValue)
}
return false
}
let resolved = resolveEffectiveConfig(colorScheme: scheme)
guard applyResolvedConfig(
resolved,
willChange: willChange,
applyState: { effectiveColorScheme = scheme }
) else {
return false
}
if let app {
ghostty_app_set_color_scheme(app, scheme.ghosttyValue)
}
return true
}
// MARK: - Theme
/// Updates the theme and reconfigures the terminal.
@discardableResult
public func setTheme(_ theme: TerminalTheme) -> Bool {
setTheme(theme, willChange: nil)
}
@discardableResult
func setTheme(
_ theme: TerminalTheme,
willChange: (() -> Void)?
) -> Bool {
guard theme != self.theme else { return false }
let resolved = resolveEffectiveConfig(theme: theme)
return applyResolvedConfig(
resolved,
willChange: willChange,
applyState: { self.theme = theme }
)
}
// MARK: - Terminal Configuration
/// Updates per-session configuration overrides and reconfigures.
@discardableResult
public func setTerminalConfiguration(
_ terminalConfiguration: TerminalConfiguration
) -> Bool {
setTerminalConfiguration(terminalConfiguration, willChange: nil)
}
@discardableResult
func setTerminalConfiguration(
_ terminalConfiguration: TerminalConfiguration,
willChange: (() -> Void)?
) -> Bool {
guard terminalConfiguration != self.terminalConfiguration else { return false }
let resolved = resolveEffectiveConfig(terminalConfiguration: terminalConfiguration)
return applyResolvedConfig(
resolved,
willChange: willChange,
applyState: { self.terminalConfiguration = terminalConfiguration }
)
}
// MARK: - Config Resolution
@discardableResult
private func reconfigure() -> Bool {
applyResolvedConfig(resolveEffectiveConfig(), willChange: nil)
}
private func resolveEffectiveConfig() -> (
source: ConfigSource, contents: String
) {
resolveEffectiveConfig(
theme: theme,
terminalConfiguration: terminalConfiguration,
colorScheme: effectiveColorScheme
)
}
private func resolveEffectiveConfig(
theme: TerminalTheme? = nil,
terminalConfiguration: TerminalConfiguration? = nil,
colorScheme: TerminalColorScheme? = nil
) -> (source: ConfigSource, contents: String) {
let nextTheme = theme ?? self.theme
let nextTerminalConfiguration = terminalConfiguration ?? self.terminalConfiguration
let nextColorScheme = colorScheme ?? effectiveColorScheme
let themeConfig = nextTheme.configuration(for: nextColorScheme)
if nextTerminalConfiguration.isEmpty, themeConfig.isEmpty {
return (baseConfigSource, baseConfigTemplate)
}
let contents = GhosttyConfigRenderer.render(
baseContents: baseConfigTemplate,
configuration: nextTerminalConfiguration,
theme: themeConfig
)
return (.generated(contents), contents)
}
// MARK: - Tick
public func tick() {
guard let app else { return }
ghostty_app_tick(app)
}
func handleWakeup() {
guard shouldProcessWakeup?() ?? true else {
TerminalDebugLog.log(.lifecycle, "wakeup suspended")
return
}
tick()
onWakeup?()
}
private static func initializeRuntimeIfNeeded() {
guard !runtimeInitialized else { return }
runtimeInitialized = true
ghostty_init(0, nil)
}
deinit {
if let app { ghostty_app_free(app) }
if let config { ghostty_config_free(config) }
if let managedConfigURL {
try? FileManager.default.removeItem(at: managedConfigURL)
}
}
}

View File

@@ -0,0 +1,304 @@
import Foundation
import GhosttyKit
public struct TerminalDebugCategory: OptionSet, Sendable {
public let rawValue: UInt16
public init(rawValue: UInt16) {
self.rawValue = rawValue
}
public static let lifecycle = Self(rawValue: 1 << 0)
public static let metrics = Self(rawValue: 1 << 1)
public static let input = Self(rawValue: 1 << 2)
public static let output = Self(rawValue: 1 << 3)
public static let ime = Self(rawValue: 1 << 4)
public static let actions = Self(rawValue: 1 << 5)
public static let render = Self(rawValue: 1 << 6)
public static let standard: Self = [
.lifecycle,
.metrics,
.input,
.output,
.ime,
.actions,
]
public static let all: Self = [
.standard,
.render,
]
}
public enum TerminalDebugLog {
public typealias Sink = @Sendable (String) -> Void
private struct Snapshot {
let isEnabled: Bool
let categories: TerminalDebugCategory
let sink: Sink
}
private final class Store: @unchecked Sendable {
let lock = NSLock()
var isEnabled = false
var categories: TerminalDebugCategory = .standard
var sink: Sink = { message in
Swift.print(message)
}
}
private static let store = Store()
public static var isEnabled: Bool {
get {
withSnapshot { $0.isEnabled }
}
set {
updateStore { $0.isEnabled = newValue }
}
}
public static var categories: TerminalDebugCategory {
get {
withSnapshot { $0.categories }
}
set {
updateStore { $0.categories = newValue }
}
}
public static var sink: Sink {
get {
withSnapshot { $0.sink }
}
set {
updateStore { $0.sink = newValue }
}
}
public static func enable(_ categories: TerminalDebugCategory = .standard) {
updateStore {
$0.isEnabled = true
$0.categories = categories
}
}
public static func disable() {
updateStore { $0.isEnabled = false }
}
static func log(
_ category: TerminalDebugCategory,
_ message: @autoclosure () -> String
) {
let snapshot = snapshot()
guard snapshot.isEnabled else { return }
guard snapshot.categories.contains(category) else { return }
snapshot.sink(
"[GhosttyTerminal][\(timestamp())][\(label(for: category))] \(message())"
)
}
static func describe(_ string: String?, limit: Int = 96) -> String {
guard let string else { return "nil" }
return "\"\(escaped(string, limit: limit))\""
}
static func describe(_ data: Data, limit: Int = 48) -> String {
let preview = data.prefix(limit)
let text = String(decoding: preview, as: UTF8.self)
let hex = preview.map { String(format: "%02X", $0) }.joined(separator: " ")
let suffix = data.count > limit ? "..." : ""
return "bytes=\(data.count) utf8=\"\(escaped(text, limit: limit))\(suffix)\" hex=\(hex)\(suffix)"
}
static func describe(_ range: NSRange) -> String {
"{location=\(range.location), length=\(range.length)}"
}
static func describe(_ action: ghostty_input_action_e) -> String {
switch action {
case GHOSTTY_ACTION_PRESS:
"press"
case GHOSTTY_ACTION_RELEASE:
"release"
case GHOSTTY_ACTION_REPEAT:
"repeat"
default:
"unknown(\(action.rawValue))"
}
}
static func describe(_ state: ghostty_input_mouse_state_e) -> String {
switch state {
case GHOSTTY_MOUSE_PRESS:
"press"
case GHOSTTY_MOUSE_RELEASE:
"release"
default:
"unknown(\(state.rawValue))"
}
}
static func describe(_ tag: ghostty_action_tag_e) -> String {
switch tag {
case GHOSTTY_ACTION_CELL_SIZE:
"cell_size"
case GHOSTTY_ACTION_SET_TITLE:
"set_title"
case GHOSTTY_ACTION_SET_TAB_TITLE:
"set_tab_title"
case GHOSTTY_ACTION_RING_BELL:
"ring_bell"
case GHOSTTY_ACTION_RENDER:
"render"
case GHOSTTY_ACTION_CONFIG_CHANGE:
"config_change"
case GHOSTTY_ACTION_RELOAD_CONFIG:
"reload_config"
default:
"tag(\(tag.rawValue))"
}
}
private static func withSnapshot<T>(
_ body: (Snapshot) -> T
) -> T {
body(snapshot())
}
private static func snapshot() -> Snapshot {
store.lock.lock()
defer { store.lock.unlock() }
return Snapshot(
isEnabled: store.isEnabled,
categories: store.categories,
sink: store.sink
)
}
private static func updateStore(
_ body: (Store) -> Void
) {
store.lock.lock()
defer { store.lock.unlock() }
body(store)
}
private static func label(
for category: TerminalDebugCategory
) -> String {
switch category {
case .lifecycle:
"lifecycle"
case .metrics:
"metrics"
case .input:
"input"
case .output:
"output"
case .ime:
"ime"
case .actions:
"actions"
case .render:
"render"
default:
"debug"
}
}
private static func timestamp() -> String {
String(format: "%.3f", Date().timeIntervalSince1970)
}
private static func escaped(
_ string: String,
limit: Int
) -> String {
var result = ""
var emitted = 0
for scalar in string.unicodeScalars {
guard emitted < limit else {
result.append("...")
break
}
switch scalar.value {
case 0x09:
result.append("\\t")
case 0x0A:
result.append("\\n")
case 0x0D:
result.append("\\r")
case 0x1B:
result.append("\\e")
case 0x20 ..< 0x7F:
result.append(Character(scalar))
default:
if scalar.value < 0x20 || scalar.value == 0x7F {
result.append(String(format: "\\u{%02X}", Int(scalar.value)))
} else {
result.append(Character(scalar))
}
}
emitted += 1
}
return result
}
}
extension TerminalGridMetrics {
var debugSummary: String {
"cols=\(columns) rows=\(rows) pixels=\(widthPixels)x\(heightPixels) cell=\(cellWidthPixels)x\(cellHeightPixels)"
}
}
extension TerminalViewportMetrics {
var debugSummary: String {
"\(surfaceSize.debugSummary) scale=\(String(format: "%.2f", scale))"
}
}
extension TerminalSessionBackend {
var debugSummary: String {
switch self {
case .exec:
"exec"
case .inMemory:
"in-memory"
}
}
}
extension TerminalSurfaceOptions {
var debugSummary: String {
let fontSizeDescription = fontSize.map { String($0) } ?? "nil"
return "backend=\(backend.debugSummary) fontSize=\(fontSizeDescription) workingDirectory=\(workingDirectory ?? "nil") context=\(context.debugSummary)"
}
}
extension TerminalSurfaceContext {
var debugSummary: String {
switch self {
case .window:
"window"
case .split:
"split"
}
}
}
extension TerminalHardwareKeyDelivery {
var debugSummary: String {
switch self {
case let .ghostty(key):
"ghostty(\(key.rawValue))"
case let .data(data):
"data(\(TerminalDebugLog.describe(data)))"
}
}
}

View File

@@ -0,0 +1,291 @@
//
// InMemoryTerminalSession.swift
// libghostty-spm
//
// Created by Lakr233 on 2026/3/16.
//
import Foundation
import GhosttyKit
public final class InMemoryTerminalSession: @unchecked Sendable {
private static let slowSurfaceWriteThreshold: TimeInterval = 0.5
private let resizeLock = NSLock()
private let surfaceAccess: InMemoryTerminalSurfaceAccess
private var lastResize: InMemoryTerminalViewport?
private let writeHandler: @Sendable (Data) -> Void
private let resizeHandler: @Sendable (InMemoryTerminalViewport) -> Void
public init(
write: @escaping @Sendable (Data) -> Void,
resize: @escaping @Sendable (InMemoryTerminalViewport) -> Void
) {
writeHandler = write
resizeHandler = resize
surfaceAccess = InMemoryTerminalSurfaceAccess(
write: Self.writeToSurface,
processExit: Self.reportProcessExit
)
}
init(
write: @escaping @Sendable (Data) -> Void,
resize: @escaping @Sendable (InMemoryTerminalViewport) -> Void,
surfaceWrite: @escaping InMemoryTerminalSurfaceAccess.Write,
processExit: @escaping InMemoryTerminalSurfaceAccess.ProcessExit =
InMemoryTerminalSession.reportProcessExit
) {
writeHandler = write
resizeHandler = resize
surfaceAccess = InMemoryTerminalSurfaceAccess(
write: surfaceWrite,
processExit: processExit
)
}
// MARK: - Surface Lifecycle
func setSurface(_ surface: ghostty_surface_t?) {
surfaceAccess.setSurface(surface)
TerminalDebugLog.log(
.lifecycle,
"in-memory session surface=\(surface == nil ? "nil" : "set")"
)
}
func clearSurface(ifMatches expectedSurface: ghostty_surface_t?) {
guard surfaceAccess.clearSurface(ifMatches: expectedSurface) else {
TerminalDebugLog.log(
.lifecycle,
"in-memory session clear skipped expected=\(expectedSurface == nil ? "nil" : "set") current=\(surfaceAccess.currentSurface == nil ? "nil" : "set")"
)
return
}
TerminalDebugLog.log(.lifecycle, "in-memory session surface=nil matched")
}
var currentSurface: ghostty_surface_t? {
surfaceAccess.currentSurface
}
// MARK: - Viewport Read
/// Returns the active viewport as a UTF-8 string, or `nil` if no surface
/// is attached. Lines are separated by `\n`. The `ghostty_text_s`
/// lifecycle (allocate via `ghostty_surface_read_text`, free via
/// `ghostty_surface_free_text`) is fully encapsulated callers never
/// touch the C buffer.
///
/// Selection grammar: `(VIEWPORT, TOP_LEFT)` to `(VIEWPORT, BOTTOM_RIGHT)`
/// with `rectangle: false` (linear flow). This reads exactly the visible
/// rows and ignores scrollback. Empty viewports return an empty string.
///
/// Thread-safe: keeps the surface alive for the duration of the read,
/// preventing access against a surface mid-replacement.
public func readViewportText() -> String? {
surfaceAccess.withCurrentSurface { surface in
let topLeft = ghostty_point_s(
tag: GHOSTTY_POINT_VIEWPORT,
coord: GHOSTTY_POINT_COORD_TOP_LEFT,
x: 0,
y: 0
)
let bottomRight = ghostty_point_s(
tag: GHOSTTY_POINT_VIEWPORT,
coord: GHOSTTY_POINT_COORD_BOTTOM_RIGHT,
x: 0,
y: 0
)
let selection = ghostty_selection_s(
top_left: topLeft,
bottom_right: bottomRight,
rectangle: false
)
var out = ghostty_text_s()
guard ghostty_surface_read_text(surface, selection, &out) else {
return nil
}
defer { ghostty_surface_free_text(surface, &out) }
guard let textPtr = out.text, out.text_len > 0 else {
return ""
}
let bytes = UnsafeBufferPointer(start: textPtr, count: Int(out.text_len))
.map { UInt8(bitPattern: $0) }
return String(decoding: bytes, as: UTF8.self)
} ?? nil
}
func updateViewport(_ size: TerminalGridMetrics) {
TerminalDebugLog.log(.metrics, "in-memory viewport update \(size.debugSummary)")
dispatchResize(InMemoryTerminalViewport(
columns: size.columns,
rows: size.rows,
widthPixels: size.widthPixels,
heightPixels: size.heightPixels,
cellWidthPixels: size.cellWidthPixels,
cellHeightPixels: size.cellHeightPixels
))
}
// MARK: - Receiving Data
/// Enqueue data for the terminal from the host backend.
///
/// Writes are processed in order on a per-session serial queue so parsing
/// cannot block the caller or the main thread.
public func receive(_ data: Data) {
guard surfaceAccess.enqueueWrite(data) else {
TerminalDebugLog.log(
.output,
"terminal <- host dropped \(TerminalDebugLog.describe(data))"
)
return
}
TerminalDebugLog.log(
.output,
"terminal <- host \(TerminalDebugLog.describe(data))"
)
}
/// Feed a UTF-8 string into the terminal from the host backend.
public func receive(_ string: String) {
guard let data = string.data(using: .utf8) else { return }
receive(data)
}
/// Inject input bytes directly into the host-side consumer.
///
/// This bypasses `ghostty_surface_key` translation and is intended for
/// control sequences that the in-memory backend must interpret itself.
public func sendInput(_ data: Data) {
TerminalDebugLog.log(
.input,
"host <- direct input \(TerminalDebugLog.describe(data))"
)
writeHandler(data)
}
// MARK: - Process Exit
/// Enqueue a host-managed process exit after all previously received data.
public func finish(exitCode: UInt32, runtimeMilliseconds: UInt64) {
guard surfaceAccess.enqueueProcessExit(
exitCode: exitCode,
runtimeMilliseconds: runtimeMilliseconds
) else {
TerminalDebugLog.log(
.lifecycle,
"process exit ignored: missing surface exitCode=\(exitCode) runtimeMs=\(runtimeMilliseconds)"
)
return
}
TerminalDebugLog.log(
.lifecycle,
"process exit exitCode=\(exitCode) runtimeMs=\(runtimeMilliseconds)"
)
}
// MARK: - C Callbacks
static let receiveBufferCallback: ghostty_surface_receive_buffer_cb = { userdata, ptr, len in
guard let userdata, let ptr else { return }
let session = Unmanaged<InMemoryTerminalSession>
.fromOpaque(userdata)
.takeUnretainedValue()
let data = Data(bytes: ptr, count: len)
TerminalDebugLog.log(
.input,
"host <- terminal \(TerminalDebugLog.describe(data))"
)
session.writeHandler(data)
}
static let receiveResizeCallback: ghostty_surface_receive_resize_cb = { userdata, cols, rows, widthPx, heightPx in
guard let userdata else { return }
let session = Unmanaged<InMemoryTerminalSession>
.fromOpaque(userdata)
.takeUnretainedValue()
TerminalDebugLog.log(
.metrics,
"receive resize cols=\(cols) rows=\(rows) pixels=\(widthPx)x\(heightPx)"
)
session.dispatchResize(InMemoryTerminalViewport(
columns: cols,
rows: rows,
widthPixels: widthPx,
heightPixels: heightPx
))
}
private func dispatchResize(_ resize: InMemoryTerminalViewport) {
resizeLock.lock()
let mergedResize = mergedResize(resize)
guard mergedResize != lastResize else {
resizeLock.unlock()
TerminalDebugLog.log(
.metrics,
"resize unchanged cols=\(mergedResize.columns) rows=\(mergedResize.rows) pixels=\(mergedResize.widthPixels)x\(mergedResize.heightPixels) cell=\(mergedResize.cellWidthPixels)x\(mergedResize.cellHeightPixels)"
)
return
}
lastResize = mergedResize
resizeLock.unlock()
TerminalDebugLog.log(
.metrics,
"resize dispatched cols=\(mergedResize.columns) rows=\(mergedResize.rows) pixels=\(mergedResize.widthPixels)x\(mergedResize.heightPixels) cell=\(mergedResize.cellWidthPixels)x\(mergedResize.cellHeightPixels)"
)
resizeHandler(mergedResize)
}
private func mergedResize(_ resize: InMemoryTerminalViewport) -> InMemoryTerminalViewport {
guard let lastResize else { return resize }
return InMemoryTerminalViewport(
columns: resize.columns,
rows: resize.rows,
widthPixels: resize.widthPixels == 0 ? lastResize.widthPixels : resize.widthPixels,
heightPixels: resize.heightPixels == 0 ? lastResize.heightPixels : resize.heightPixels,
cellWidthPixels: resize.cellWidthPixels == 0 ? lastResize.cellWidthPixels : resize.cellWidthPixels,
cellHeightPixels: resize.cellHeightPixels == 0 ? lastResize.cellHeightPixels : resize.cellHeightPixels
)
}
func waitForPendingOutput() {
surfaceAccess.waitForPendingOutput()
}
private static func writeToSurface(_ surface: ghostty_surface_t, _ data: Data) {
let start = ProcessInfo.processInfo.systemUptime
defer {
let duration = ProcessInfo.processInfo.systemUptime - start
if duration >= slowSurfaceWriteThreshold {
TerminalDebugLog.log(
.output,
"surface write slow bytes=\(data.count) duration=\(String(format: "%.3f", duration))s"
)
}
}
data.withUnsafeBytes { buffer in
guard let ptr = buffer.baseAddress?.assumingMemoryBound(to: UInt8.self) else {
return
}
ghostty_surface_write_buffer(surface, ptr, UInt(buffer.count))
}
}
private static func reportProcessExit(
_ surface: ghostty_surface_t,
_ exitCode: UInt32,
_ runtimeMilliseconds: UInt64
) {
ghostty_surface_process_exit(surface, exitCode, runtimeMilliseconds)
}
}

Some files were not shown because too many files have changed in this diff Show More