From 727197d2c0ecb160d496837467933d49614c9a98 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Mon, 16 Mar 2026 03:06:08 -0700 Subject: [PATCH 01/13] termio: inline manual ios writes --- src/termio/Termio.zig | 87 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) diff --git a/src/termio/Termio.zig b/src/termio/Termio.zig index dcd0d8cf7ff..4855681a3b0 100644 --- a/src/termio/Termio.zig +++ b/src/termio/Termio.zig @@ -58,6 +58,10 @@ size: renderer.Size, /// The mailbox implementation to use. mailbox: termio.Mailbox, +/// Manual IO currently needs an inline write path on iOS because the +/// writer-thread async wakeup is not reliably firing in that environment. +manual_linefeed_mode: std.atomic.Value(bool) = .{ .raw = false }, + /// The stream parser. This parses the stream of escape codes and so on /// from the child process and calls callbacks in the stream handler. terminal_stream: StreamHandler.Stream, @@ -402,6 +406,14 @@ pub fn queueMessage( msg: termio.Message, mutex: MutexState, ) void { + switch (self.backend) { + .manual => { + self.queueMessageManual(msg); + return; + }, + .exec => {}, + } + self.mailbox.send(msg, switch (mutex) { .locked => self.renderer_state.mutex, .unlocked => null, @@ -409,6 +421,81 @@ pub fn queueMessage( self.mailbox.notify(); } +fn queueMessageManual(self: *Termio, msg: termio.Message) void { + var td: ThreadData = .{ + .alloc = self.alloc, + .loop = undefined, + .renderer_state = self.renderer_state, + .surface_mailbox = self.surface_mailbox, + .backend = .{ .manual = .{} }, + .mailbox = &self.mailbox, + }; + + switch (msg) { + .color_scheme_report => |v| self.colorSchemeReport(&td, v.force) catch |err| { + log.warn("manual inline color_scheme_report failed err={}", .{err}); + }, + .crash => @panic("crash request, crashing intentionally"), + .change_config => |config| { + defer config.alloc.destroy(config.ptr); + self.changeConfig(&td, config.ptr) catch |err| { + log.warn("manual inline change_config failed err={}", .{err}); + }; + }, + .inspector => {}, + .resize => |v| self.resize(&td, v) catch |err| { + log.warn("manual inline resize failed err={}", .{err}); + }, + .size_report => |v| self.sizeReport(&td, v) catch |err| { + log.warn("manual inline size_report failed err={}", .{err}); + }, + .clear_screen => |v| self.clearScreen(&td, v.history) catch |err| { + log.warn("manual inline clear_screen failed err={}", .{err}); + }, + .scroll_viewport => |v| self.scrollViewport(v), + .selection_scroll => {}, + .jump_to_prompt => |v| self.jumpToPrompt(v) catch |err| { + log.warn("manual inline jump_to_prompt failed err={}", .{err}); + }, + .start_synchronized_output => {}, + .linefeed_mode => |v| self.manual_linefeed_mode.store(v, .monotonic), + .focused => |v| self.focusGained(&td, v) catch |err| { + log.warn("manual inline focused failed err={}", .{err}); + }, + .write_small => |v| self.queueWriteManual( + &td, + v.data[0..v.len], + ) catch |err| { + log.warn("manual inline write_small failed err={}", .{err}); + }, + .write_stable => |v| self.queueWriteManual(&td, v) catch |err| { + log.warn("manual inline write_stable failed err={}", .{err}); + }, + .write_alloc => |v| { + defer v.alloc.free(v.data); + self.queueWriteManual(&td, v.data) catch |err| { + log.warn("manual inline write_alloc failed err={}", .{err}); + }; + }, + } + + self.renderer_wakeup.notify() catch |err| { + log.warn("manual inline renderer wakeup failed err={}", .{err}); + }; +} + +fn queueWriteManual( + self: *Termio, + td: *ThreadData, + data: []const u8, +) !void { + const linefeed = self.manual_linefeed_mode.load(.monotonic); + switch (self.backend) { + .manual => |*manual| try manual.queueWrite(self.alloc, td, data, linefeed), + .exec => unreachable, + } +} + /// Queue a write directly to the pty. /// /// If you're using termio.Thread, this must ONLY be called from the From ed09153257682278fa315e9e5f3f398b66777ca4 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Wed, 25 Mar 2026 15:13:46 -0700 Subject: [PATCH 02/13] build: honor SDKROOT for local apple deps --- build.zig.zon | 4 +- pkg/apple-sdk/build.zig | 24 +- pkg/zig-objc/LICENSE | 21 ++ pkg/zig-objc/README.md | 101 ++++++++ pkg/zig-objc/build.zig | 99 ++++++++ pkg/zig-objc/build.zig.zon | 13 + pkg/zig-objc/src/autorelease.zig | 17 ++ pkg/zig-objc/src/block.zig | 329 +++++++++++++++++++++++++ pkg/zig-objc/src/c.zig | 24 ++ pkg/zig-objc/src/class.zig | 223 +++++++++++++++++ pkg/zig-objc/src/encoding.zig | 405 +++++++++++++++++++++++++++++++ pkg/zig-objc/src/iterator.zig | 109 +++++++++ pkg/zig-objc/src/main.zig | 40 +++ pkg/zig-objc/src/msg_send.zig | 244 +++++++++++++++++++ pkg/zig-objc/src/object.zig | 220 +++++++++++++++++ pkg/zig-objc/src/property.zig | 40 +++ pkg/zig-objc/src/protocol.zig | 60 +++++ pkg/zig-objc/src/sel.zig | 30 +++ 18 files changed, 1995 insertions(+), 8 deletions(-) create mode 100644 pkg/zig-objc/LICENSE create mode 100644 pkg/zig-objc/README.md create mode 100644 pkg/zig-objc/build.zig create mode 100644 pkg/zig-objc/build.zig.zon create mode 100644 pkg/zig-objc/src/autorelease.zig create mode 100644 pkg/zig-objc/src/block.zig create mode 100644 pkg/zig-objc/src/c.zig create mode 100644 pkg/zig-objc/src/class.zig create mode 100644 pkg/zig-objc/src/encoding.zig create mode 100644 pkg/zig-objc/src/iterator.zig create mode 100644 pkg/zig-objc/src/main.zig create mode 100644 pkg/zig-objc/src/msg_send.zig create mode 100644 pkg/zig-objc/src/object.zig create mode 100644 pkg/zig-objc/src/property.zig create mode 100644 pkg/zig-objc/src/protocol.zig create mode 100644 pkg/zig-objc/src/sel.zig diff --git a/build.zig.zon b/build.zig.zon index e7a8747f7d1..8eebfbd7c51 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -26,9 +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", + .path = "./pkg/zig-objc", .lazy = true, }, .zig_js = .{ diff --git a/pkg/apple-sdk/build.zig b/pkg/apple-sdk/build.zig index c573c391087..212aab4229b 100644 --- a/pkg/apple-sdk/build.zig +++ b/pkg/apple-sdk/build.zig @@ -41,14 +41,28 @@ pub fn addPaths( }); if (!gop.found_existing) { + const sdkroot_override = std.process.getEnvVarOwned(b.allocator, "SDKROOT") catch |err| switch (err) { + error.EnvironmentVariableNotFound => null, + else => return err, + }; + defer if (sdkroot_override) |sdkroot| b.allocator.free(sdkroot); + // Detect our SDK using the "findNative" Zig stdlib function. // This is really important because it forces using `xcrun` to // find the SDK path. - const libc = try std.zig.LibCInstallation.findNative(.{ - .allocator = b.allocator, - .target = &step.rootModuleTarget(), - .verbose = false, - }); + const libc = libc: { + if (sdkroot_override) |sdkroot| { + var libc: std.zig.LibCInstallation = .{}; + libc.include_dir = try std.fs.path.join(b.allocator, &.{ sdkroot, "usr", "include" }); + libc.sys_include_dir = try std.fs.path.join(b.allocator, &.{ sdkroot, "usr", "include" }); + break :libc libc; + } + break :libc try std.zig.LibCInstallation.findNative(.{ + .allocator = b.allocator, + .target = &step.rootModuleTarget(), + .verbose = false, + }); + }; // Render the file compatible with the `--libc` Zig flag. var stream: std.io.Writer.Allocating = .init(b.allocator); diff --git a/pkg/zig-objc/LICENSE b/pkg/zig-objc/LICENSE new file mode 100644 index 00000000000..75041973fd4 --- /dev/null +++ b/pkg/zig-objc/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 Mitchell Hashimoto + +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. diff --git a/pkg/zig-objc/README.md b/pkg/zig-objc/README.md new file mode 100644 index 00000000000..0aedd72a979 --- /dev/null +++ b/pkg/zig-objc/README.md @@ -0,0 +1,101 @@ +# zig-objc - Objective-C Runtime Bindings for Zig + +zig-objc allows Zig to call Objective-C using the macOS +[Objective-C runtime](https://developer.apple.com/documentation/objectivec/objective-c_runtime?language=objc). + +**Project Status:** This library does not currently have 100% coverage over the Objective-C +runtime, but supports enough features to be useful. I use this library in +shipping code that I run every day. + +## Features + + * Classes: + - Find classes + - Read property metadata + - Call methods + - Create subclasses + - Add methods + - Replace methods + - Add instance variables + * Objects: + - Class or class name for object + - Read and write properties + - Read and write instance variables + - Call methods + - Call superclass methods + * Protocols: + - Check conformance + - Read property metadata + * Blocks: + - Define and invoke blocks with captured values + - Pass blocks to C APIs which can then invoke your Zig code + * Autorelease pools + +There is still a bunch of the runtime API that isn't supported. It wouldn't +be hard work to add it, I just haven't needed it. For example: object +instance variables, protocols, dynamically registering new classes, etc. + +Feel free to open a pull request if you want additional features. +**Do not open issues to request features (only pull requests).** I'm +only going to add features I need, _unless_ you open a pull request to +add it yourself. + +## Example + +Here is an example that uses `NSProcessInfo` to implement a function +`macosVersionAtLeast` that returns true if the running macOS versions +is at least the given arguments. + +```zig +const objc = @import("objc"); + +pub fn macosVersionAtLeast(major: i64, minor: i64, patch: i64) bool { + /// Get the objc class from the runtime + const NSProcessInfo = objc.getClass("NSProcessInfo").?; + + /// Call a class method with no arguments that returns another objc object. + const info = NSProcessInfo.msgSend(objc.Object, "processInfo", .{}); + + /// Call an instance method that returns a boolean and takes a single + /// argument. + return info.msgSend(bool, "isOperatingSystemAtLeastVersion:", .{ + NSOperatingSystemVersion{ .major = major, .minor = minor, .patch = patch }, + }); +} + +/// This extern struct matches the Cocoa headers for layout. +const NSOperatingSystemVersion = extern struct { + major: i64, + minor: i64, + patch: i64, +}; +``` + +## Usage + +Add this repository to your `build.zig.zon` file. Then: + +```zig +pub fn build(b: *std.build.Builder) !void { + // ... other stuff + + exe.root_module.addImport("objc", b.dependency("zig_objc", .{ + .target = target, + .optimize = optimize, + }).module("objc")); +} +``` + +Note that `zig-objc` will find and link to headers from the target SDK +(macOS, iOS, etc.) automatically by finding your Xcode installation. If +Xcode is not installed, you can add it manually but you must set the +`-Dadd-paths=false` flag. + +**`zig-objc` only works with released versions of Zig.** We don't support +nightly versions because the Zig compiler is still changing too much. + +## Documentation + +Read the source code, it is well commented. If something isn't clear, please +open an issue and I'll enhance the source code. Some familiarity with +Objective-C concepts is expected for understanding the doc comments. diff --git a/pkg/zig-objc/build.zig b/pkg/zig-objc/build.zig new file mode 100644 index 00000000000..dac2d1c7cff --- /dev/null +++ b/pkg/zig-objc/build.zig @@ -0,0 +1,99 @@ +const std = @import("std"); + +pub fn build(b: *std.Build) !void { + const optimize = b.standardOptimizeOption(.{}); + const target = b.standardTargetOptions(.{}); + const add_paths = b.option( + bool, + "add-paths", + "add apple SDK paths from Xcode installation", + ) orelse true; + + const objc = b.addModule("objc", .{ + .root_source_file = b.path("src/main.zig"), + .target = target, + .optimize = optimize, + }); + if (add_paths) try addAppleSDK(b, objc); + objc.linkSystemLibrary("objc", .{}); + objc.linkFramework("Foundation", .{}); + + const tests = b.addTest(.{ + .name = "objc-test", + .root_module = b.createModule(.{ + .root_source_file = b.path("src/main.zig"), + .target = target, + .optimize = optimize, + }), + }); + tests.linkSystemLibrary("objc"); + tests.linkFramework("Foundation"); + tests.linkFramework("AppKit"); // Required by 'tagged pointer' test. + try addAppleSDK(b, tests.root_module); + b.installArtifact(tests); + + const test_step = b.step("test", "Run tests"); + const tests_run = b.addRunArtifact(tests); + test_step.dependOn(&tests_run.step); +} + +/// Add the SDK framework, include, and library paths to the given module. +/// The module target is used to determine the SDK to use so it must have +/// a resolved target. +/// +/// The Apple SDK is determined based on the build target and found using +/// xcrun, so it requires a valid Xcode installation. +pub fn addAppleSDK(b: *std.Build, m: *std.Build.Module) !void { + // The cache. This always uses b.allocator and never frees memory + // (which is idiomatic for a Zig build exe). + const Cache = struct { + const Key = struct { + arch: std.Target.Cpu.Arch, + os: std.Target.Os.Tag, + abi: std.Target.Abi, + }; + + var map: std.AutoHashMapUnmanaged(Key, ?[]const u8) = .{}; + }; + + const target = m.resolved_target.?.result; + const gop = try Cache.map.getOrPut(b.allocator, .{ + .arch = target.cpu.arch, + .os = target.os.tag, + .abi = target.abi, + }); + + // This executes `xcrun` to get the SDK path. We don't want to execute + // this multiple times so we cache the value. + if (!gop.found_existing) { + const sdkroot_override = std.process.getEnvVarOwned(b.allocator, "SDKROOT") catch |err| switch (err) { + error.EnvironmentVariableNotFound => null, + else => return err, + }; + defer if (sdkroot_override) |sdkroot| b.allocator.free(sdkroot); + + if (sdkroot_override) |sdkroot| { + gop.value_ptr.* = try b.allocator.dupe(u8, sdkroot); + } else { + gop.value_ptr.* = std.zig.system.darwin.getSdk( + b.allocator, + &m.resolved_target.?.result, + ); + } + } + + // The active SDK we want to use + const path = gop.value_ptr.* orelse return switch (target.os.tag) { + // Return a more descriptive error. Before we just returned the + // generic error but this was confusing a lot of community members. + // It costs us nothing in the build script to return something better. + .macos => error.XcodeMacOSSDKNotFound, + .ios => error.XcodeiOSSDKNotFound, + .tvos => error.XcodeTVOSSDKNotFound, + .watchos => error.XcodeWatchOSSDKNotFound, + else => error.XcodeAppleSDKNotFound, + }; + m.addSystemFrameworkPath(.{ .cwd_relative = b.pathJoin(&.{ path, "/System/Library/Frameworks" }) }); + m.addSystemIncludePath(.{ .cwd_relative = b.pathJoin(&.{ path, "/usr/include" }) }); + m.addLibraryPath(.{ .cwd_relative = b.pathJoin(&.{ path, "/usr/lib" }) }); +} diff --git a/pkg/zig-objc/build.zig.zon b/pkg/zig-objc/build.zig.zon new file mode 100644 index 00000000000..2720c3a0bf3 --- /dev/null +++ b/pkg/zig-objc/build.zig.zon @@ -0,0 +1,13 @@ +.{ + .name = .zig_objc, + .version = "0.0.0", + .fingerprint = 0x8a91772ba7d2bf22, + .paths = .{ + "src/", + "build.zig", + "build.zig.zon", + "README.md", + "LICENSE", + }, + .dependencies = .{}, +} diff --git a/pkg/zig-objc/src/autorelease.zig b/pkg/zig-objc/src/autorelease.zig new file mode 100644 index 00000000000..575cba15434 --- /dev/null +++ b/pkg/zig-objc/src/autorelease.zig @@ -0,0 +1,17 @@ +const std = @import("std"); + +pub const AutoreleasePool = opaque { + /// Create a new autorelease pool. To clean it up, call deinit. + pub inline fn init() *AutoreleasePool { + return @ptrCast(objc_autoreleasePoolPush().?); + } + + pub inline fn deinit(self: *AutoreleasePool) void { + objc_autoreleasePoolPop(self); + } +}; + +// I'm not sure if these are internal or not... they aren't in any headers, +// but its how autorelease pools are implemented. +extern "c" fn objc_autoreleasePoolPush() ?*anyopaque; +extern "c" fn objc_autoreleasePoolPop(?*anyopaque) void; diff --git a/pkg/zig-objc/src/block.zig b/pkg/zig-objc/src/block.zig new file mode 100644 index 00000000000..4df6dca32f7 --- /dev/null +++ b/pkg/zig-objc/src/block.zig @@ -0,0 +1,329 @@ +const std = @import("std"); +const assert = std.debug.assert; +const Allocator = std.mem.Allocator; +const objc = @import("main.zig"); + +// 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 +// the memory with `free`. +const alloc = std.heap.raw_c_allocator; + +/// Creates a new block type with captured (closed over) values. +/// +/// The CapturesArg is the a struct of captured values that will become +/// available to the block. The Args is a tuple of types that are additional +/// invocation-time arguments to the function. The Return param is the return +/// type of the function. +/// +/// Within the CapturesArg, only `objc.c.id` values will be automatically +/// memory managed (retained and released) when the block is copied. +/// If you are passing through NSObjects, you should use the `objc.c.id` +/// type and recreate a richer Zig type on the other side. +/// +/// The function that must be implemented is available as the `Fn` field. +/// The first argument to the function is always a pointer to the `Context` +/// type (see field in the struct). This has the captured values. +/// +/// The captures struct is always available as the `Captures` field which +/// makes it easy to use an inline type definition for the argument and +/// reference the type in a named fashion later. +/// +/// The returned block type can be initialized and invoked multiple times +/// for different captures and arguments. +/// +/// See the tests for an example. +pub fn Block( + comptime CapturesArg: type, + comptime Args: anytype, + comptime Return: type, +) type { + return struct { + const Self = @This(); + const captures_info = @typeInfo(Captures).@"struct"; + const InvokeFn = FnType(anyopaque); + const descriptor: Descriptor = .{ + .reserved = 0, + .size = @sizeOf(Context), + .copy_helper = &descCopyHelper, + .dispose_helper = &descDisposeHelper, + .signature = &objc.comptimeEncode(InvokeFn), + }; + + /// This is the function type that is called back. + pub const Fn = FnType(Context); + + /// The captures type, so it can be easily referenced again. + pub const Captures = CapturesArg; + + /// This is the block context sent as the first paramter to the function. + pub const Context = BlockContext(Captures, InvokeFn); + + /// Create a new block context. The block context is what is passed + /// (by reference) to functions that request a block. + /// + /// Note that if the captures contain reference types (like + /// NSObject), they will NOT be retained/released UNTIL the block + /// is copied. A block copy happens automatically when the block + /// is copied to a function that expects a block in ObjC. + /// + /// If you want to manualy copy a block, you can use the `copy` + /// function but you must pair it with a `dispose` function. This + /// should only be done for blocks that are not passed to external + /// functions where the runtime will automatically copy them (C, + /// C++, ObjC, etc.). + pub fn init(captures: Captures, func: *const Fn) Context { + // The block starts as a stack-allocated block. We let the + // runtime copy it to the heap. It doesn't seem to be advisable + // to allocate it on the heap directly since the way refcounting + // is done and so on is all private API. + var ctx: Context = undefined; + ctx.isa = NSConcreteStackBlock; + ctx.flags = .{ + .copy_dispose = true, + .stret = @typeInfo(Return) == .@"struct", + .signature = true, + }; + ctx.invoke = @ptrCast(func); + ctx.descriptor = &descriptor; + inline for (captures_info.fields) |field| { + @field(ctx, field.name) = @field(captures, field.name); + } + + return ctx; + } + + /// Invoke the block with the given arguments. The arguments are + /// the arguments to pass to the function beyond the captured scope. + pub fn invoke(ctx: *const Context, args: anytype) Return { + return @call( + .auto, + ctx.invoke, + .{ctx} ++ args, + ); + } + + /// Copies the given context by either literally copying it + /// to the heap or increasing the reference count. This must be + /// paired with a `release` call to release the block. + pub fn copy(ctx: *const Context) Allocator.Error!*Context { + const copied = _Block_copy(@ptrCast(@alignCast(ctx))) orelse + return error.OutOfMemory; + return @ptrCast(@alignCast(copied)); + } + + /// Release a copied block context. This must only be called on + /// contexts returned by the `copy` function. If you pass a block + /// context that was not copied, this will crash. + pub fn release(ctx: *const Context) void { + assert(@intFromPtr(ctx.isa) == @intFromPtr(NSConcreteMallocBlock)); + _Block_release(@ptrCast(@alignCast(ctx))); + } + + fn descCopyHelper(src: *anyopaque, dst: *anyopaque) callconv(.c) void { + const real_src: *Context = @ptrCast(@alignCast(src)); + const real_dst: *Context = @ptrCast(@alignCast(dst)); + inline for (captures_info.fields) |field| { + if (field.type == objc.c.id) { + _Block_object_assign( + @ptrCast(&@field(real_dst, field.name)), + @field(real_src, field.name), + .object, + ); + } + } + } + + fn descDisposeHelper(src: *anyopaque) callconv(.c) void { + const real_src: *Context = @ptrCast(@alignCast(src)); + inline for (captures_info.fields) |field| { + if (field.type == objc.c.id) { + _Block_object_dispose( + @field(real_src, field.name), + .object, + ); + } + } + } + + /// Creates a function type for the invocation function, but alters + /// the first arg. The first arg is a pointer so from an ABI perspective + /// this is always the same and can be safely casted. + fn FnType(comptime ContextArg: type) type { + var params: [Args.len + 1]std.builtin.Type.Fn.Param = undefined; + params[0] = .{ .is_generic = false, .is_noalias = false, .type = *const ContextArg }; + for (Args, 1..) |Arg, i| { + params[i] = .{ .is_generic = false, .is_noalias = false, .type = Arg }; + } + + return @Type(.{ + .@"fn" = .{ + .calling_convention = .c, + .is_generic = false, + .is_var_args = false, + .return_type = Return, + .params = ¶ms, + }, + }); + } + }; +} + +/// This is the type of a block structure that is passed as the first +/// argument to any block invocation. See Block. +fn BlockContext(comptime Captures: type, comptime InvokeFn: type) type { + const captures_info = @typeInfo(Captures).@"struct"; + var fields: [captures_info.fields.len + 5]std.builtin.Type.StructField = undefined; + fields[0] = .{ + .name = "isa", + .type = ?*anyopaque, + .default_value_ptr = null, + .is_comptime = false, + .alignment = @alignOf(*anyopaque), + }; + fields[1] = .{ + .name = "flags", + .type = BlockFlags, + .default_value_ptr = null, + .is_comptime = false, + .alignment = @alignOf(c_int), + }; + fields[2] = .{ + .name = "reserved", + .type = c_int, + .default_value_ptr = null, + .is_comptime = false, + .alignment = @alignOf(c_int), + }; + fields[3] = .{ + .name = "invoke", + .type = *const InvokeFn, + .default_value_ptr = null, + .is_comptime = false, + .alignment = @typeInfo(*const InvokeFn).pointer.alignment, + }; + fields[4] = .{ + .name = "descriptor", + .type = *const Descriptor, + .default_value_ptr = null, + .is_comptime = false, + .alignment = @alignOf(*Descriptor), + }; + + for (captures_info.fields, 5..) |capture, i| { + switch (capture.type) { + comptime_int => @compileError("capture should not be a comptime_int, try using @as"), + comptime_float => @compileError("capture should not be a comptime_float, try using @as"), + else => {}, + } + + fields[i] = .{ + .name = capture.name, + .type = capture.type, + .default_value_ptr = null, + .is_comptime = false, + .alignment = capture.alignment, + }; + } + + return @Type(.{ + .@"struct" = .{ + .layout = .@"extern", + .fields = &fields, + .decls = &.{}, + .is_tuple = false, + }, + }); +} + +// Pointer to opaque instead of anyopaque: https://github.com/ziglang/zig/issues/18461 +const NSConcreteStackBlock = @extern(*opaque {}, .{ .name = "_NSConcreteStackBlock" }); +const NSConcreteMallocBlock = @extern(*opaque {}, .{ .name = "_NSConcreteMallocBlock" }); + +// https://github.com/llvm/llvm-project/blob/734d31a464e204db699c1cf9433494926deb2aa2/compiler-rt/lib/BlocksRuntime/Block_private.h#L101-L108 +const BlockFieldFlags = enum(c_int) { + object = 3, // BLOCK_FIELD_IS_OBJECT + block = 7, // BLOCK_FIELD_IS_BLOCK + byref = 8, // BLOCK_FIELD_IS_BYREF + weak = 16, // BLOCK_FIELD_IS_WEAK + byref_caller = 128, // BLOCK_BYREF_CALLER +}; + +extern "c" fn _Block_copy(src: *const anyopaque) callconv(.c) ?*anyopaque; +extern "c" fn _Block_release(src: *const anyopaque) callconv(.c) void; +extern "c" fn _Block_object_assign(dst: *anyopaque, src: *const anyopaque, flag: BlockFieldFlags) void; +extern "c" fn _Block_object_dispose(src: *const anyopaque, flag: BlockFieldFlags) void; + +const Descriptor = extern struct { + reserved: c_ulong = 0, + size: c_ulong, + copy_helper: *const fn (dst: *anyopaque, src: *anyopaque) callconv(.c) void, + dispose_helper: *const fn (src: *anyopaque) callconv(.c) void, + signature: ?[*:0]const u8, +}; + +const BlockFlags = packed struct(c_int) { + _unused: u23 = 0, + noescape: bool = false, + _unused_2: u1 = 0, + copy_dispose: bool = false, + ctor: bool = false, + _unused_3: u1 = 0, + global: bool = false, + stret: bool = false, + signature: bool = false, + _unused_4: u1 = 0, +}; + +test "Block" { + const AddBlock = Block(struct { + x: i32, + y: i32, + }, .{}, i32); + + const captures: AddBlock.Captures = .{ + .x = 2, + .y = 3, + }; + + var block: AddBlock.Context = AddBlock.init(captures, (struct { + fn addFn(block: *const AddBlock.Context) callconv(.c) i32 { + return block.x + block.y; + } + }).addFn); + + const ret = AddBlock.invoke(&block, .{}); + try std.testing.expectEqual(@as(i32, 5), ret); + + // Try copy and release + const copied = try AddBlock.copy(&block); + AddBlock.release(copied); +} + +test "Block copy objc id" { + // Create an object, refcount 1 + const NSObject = objc.getClass("NSObject").?; + const obj = NSObject.msgSend( + objc.Object, + objc.Sel.registerName("alloc"), + .{}, + ); + _ = obj.msgSend(objc.Object, objc.Sel.registerName("init"), .{}); + + const TestBlock = Block(struct { + id: objc.c.id, + }, .{}, i32); + + var block = TestBlock.init(.{ + .id = obj.value, + }, (struct { + fn addFn(block: *const TestBlock.Context) callconv(.c) i32 { + _ = block; + return 0; + } + }).addFn); + + // Try copy and release + const copied = try TestBlock.copy(&block); + TestBlock.release(copied); +} diff --git a/pkg/zig-objc/src/c.zig b/pkg/zig-objc/src/c.zig new file mode 100644 index 00000000000..46975dc170a --- /dev/null +++ b/pkg/zig-objc/src/c.zig @@ -0,0 +1,24 @@ +pub const c = @cImport({ + @cInclude("objc/runtime.h"); + @cInclude("objc/message.h"); +}); + +/// On some targets, Objective-C uses `i8` instead of `bool`. +/// This helper casts a target value type to `bool`. +pub fn boolResult(result: c.BOOL) bool { + return switch (c.BOOL) { + bool => result, + i8 => result == 1, + else => @compileError("unexpected boolean type"), + }; +} + +/// On some targets, Objective-C uses `i8` instead of `bool`. +/// This helper casts a `bool` value to the target value type. +pub fn boolParam(param: bool) c.BOOL { + return switch (c.BOOL) { + bool => param, + i8 => @intFromBool(param), + else => @compileError("unexpected boolean type"), + }; +} diff --git a/pkg/zig-objc/src/class.zig b/pkg/zig-objc/src/class.zig new file mode 100644 index 00000000000..34feea162b9 --- /dev/null +++ b/pkg/zig-objc/src/class.zig @@ -0,0 +1,223 @@ +const std = @import("std"); +const assert = std.debug.assert; +const cpkg = @import("c.zig"); +const c = cpkg.c; +const boolResult = cpkg.boolResult; +const objc = @import("main.zig"); +const MsgSend = @import("msg_send.zig").MsgSend; + +pub const Class = struct { + value: c.Class, + + // Implement msgSend + const msg_send = MsgSend(Class); + pub const msgSend = msg_send.msgSend; + pub const msgSendSuper = msg_send.msgSendSuper; + + // Returns a property with a given name of a given class. + pub fn getProperty(self: Class, name: [:0]const u8) ?objc.Property { + return objc.Property{ + .value = c.class_getProperty(self.value, name.ptr) orelse return null, + }; + } + + /// Describes the properties declared by a class. This must be freed. + pub fn copyPropertyList(self: Class) []objc.Property { + var count: c_uint = undefined; + const list = @as([*c]objc.Property, @ptrCast(c.class_copyPropertyList(self.value, &count))); + if (count == 0) return list[0..0]; + return list[0..count]; + } + + /// Describes the protocols adopted by a class. This must be freed. + pub fn copyProtocolList(self: Class) []objc.Protocol { + var count: c_uint = undefined; + const list = @as([*c]objc.Protocol, @ptrCast(c.class_copyProtocolList(self.value, &count))); + if (count == 0) return list[0..0]; + return list[0..count]; + } + + pub fn isMetaClass(self: Class) bool { + return boolResult(c.class_isMetaClass(self.value)); + } + + pub fn getInstanceSize(self: Class) usize { + return c.class_getInstanceSize(self.value); + } + + pub fn respondsToSelector(self: Class, sel: objc.Sel) bool { + return boolResult(c.class_respondsToSelector(self.value, sel.value)); + } + + pub fn conformsToProtocol(self: Class, protocol: objc.Protocol) bool { + return boolResult(c.class_conformsToProtocol(self.value, &protocol.value)); + } + + // currently only allows for overriding methods previously defined, e.g. by a superclass. + // imp should be a function with C calling convention + // whose first two arguments are a `c.id` and a `c.SEL`. + pub fn replaceMethod(self: Class, name: [:0]const u8, imp: anytype) void { + const fn_info = @typeInfo(@TypeOf(imp)).@"fn"; + assert(std.meta.eql(fn_info.calling_convention, std.builtin.CallingConvention.c)); + assert(fn_info.is_var_args == false); + 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); + } + + // allows adding new methods; returns true on success. + // imp should be a function with C calling convention + // whose first two arguments are a `c.id` and a `c.SEL`. + pub fn addMethod(self: Class, name: [:0]const u8, imp: anytype) bool { + const Fn = @TypeOf(imp); + const fn_info = @typeInfo(Fn).@"fn"; + assert(std.meta.eql(fn_info.calling_convention, std.builtin.CallingConvention.c)); + assert(fn_info.is_var_args == false); + assert(fn_info.params.len >= 2); + assert(fn_info.params[0].type == c.id); + assert(fn_info.params[1].type == c.SEL); + const encoding = comptime objc.comptimeEncode(Fn); + return boolResult(c.class_addMethod( + self.value, + objc.sel(name).value, + @ptrCast(&imp), + &encoding, + )); + } + + // only call this function between allocateClassPair and registerClassPair + // this adds an Ivar of type `id`. + pub fn addIvar(self: Class, name: [:0]const u8) bool { + // The return type is i8 when we're cross compiling, unsure why. + const result = c.class_addIvar(self.value, name, @sizeOf(c.id), @alignOf(c.id), "@"); + return boolResult(result); + } +}; + +pub fn getClass(name: [:0]const u8) ?Class { + return .{ .value = c.objc_getClass(name.ptr) orelse return null }; +} + +pub fn getMetaClass(name: [:0]const u8) ?Class { + return .{ .value = c.objc_getMetaClass(name) orelse return null }; +} + +// begin by calling this function, then call registerClassPair on the result when you are finished +pub fn allocateClassPair(superclass: ?Class, name: [:0]const u8) ?Class { + return .{ .value = c.objc_allocateClassPair( + if (superclass) |cls| cls.value else null, + name.ptr, + 0, + ) orelse return null }; +} + +pub fn registerClassPair(class: Class) void { + c.objc_registerClassPair(class.value); +} + +pub fn disposeClassPair(class: Class) void { + c.objc_disposeClassPair(class.value); +} + +test "getClass" { + const testing = std.testing; + const NSObject = getClass("NSObject"); + try testing.expect(NSObject != null); + try testing.expect(getClass("NoWay") == null); +} + +test "msgSend" { + const testing = std.testing; + const NSObject = getClass("NSObject").?; + + // Should work with primitives + const id = NSObject.msgSend(c.id, "alloc", .{}); + try testing.expect(id != null); + { + const obj: objc.Object = .{ .value = id }; + obj.msgSend(void, "dealloc", .{}); + } + + // Should work with our wrappers + const obj = NSObject.msgSend(objc.Object, "alloc", .{}); + try testing.expect(obj.value != null); + obj.msgSend(void, "dealloc", .{}); +} + +test "getProperty" { + const testing = std.testing; + const NSObject = getClass("NSObject").?; + + try testing.expect(NSObject.getProperty("className") != null); + try testing.expect(NSObject.getProperty("nope") == null); +} + +test "copyProperyList" { + const testing = std.testing; + const NSObject = getClass("NSObject").?; + + const list = NSObject.copyPropertyList(); + defer objc.free(list); + try testing.expect(list.len > 0); +} + +test "allocatecClassPair and replaceMethod" { + const testing = std.testing; + const NSObject = getClass("NSObject").?; + var my_object = allocateClassPair(NSObject, "my_object").?; + my_object.replaceMethod("hash", struct { + fn inner(target: c.id, sel: c.SEL) callconv(.c) u64 { + _ = sel; + _ = target; + return 69; + } + }.inner); + registerClassPair(my_object); + defer disposeClassPair(my_object); + const object: objc.Object = .{ + .value = my_object.msgSend(c.id, "alloc", .{}), + }; + defer object.msgSend(void, "dealloc", .{}); + try testing.expectEqual(@as(u64, 69), object.msgSend(u64, "hash", .{})); +} + +test "Ivars" { + const testing = std.testing; + const NSObject = getClass("NSObject").?; + var my_object = allocateClassPair(NSObject, "my_object").?; + try testing.expectEqual(true, my_object.addIvar("my_ivar")); + registerClassPair(my_object); + defer disposeClassPair(my_object); + const object: objc.Object = .{ + .value = my_object.msgSend(c.id, "alloc", .{}), + }; + defer object.msgSend(void, "dealloc", .{}); + const NSString = getClass("NSString").?; + const my_string = NSString.msgSend(objc.Object, "stringWithUTF8String:", .{"69---nice"}); + defer my_string.msgSend(void, "dealloc", .{}); + object.setInstanceVariable("my_ivar", my_string); + const my_ivar = object.getInstanceVariable("my_ivar"); + const slice = std.mem.sliceTo(my_ivar.getProperty([*c]const u8, "UTF8String"), 0); + try testing.expectEqualSlices(u8, "69---nice", slice); +} + +test "addMethod" { + const testing = std.testing; + const My_Class = setup: { + const My_Class = allocateClassPair(objc.getClass("NSObject").?, "my_class").?; + defer registerClassPair(My_Class); + std.debug.assert(My_Class.addMethod("my_addition", struct { + fn imp(target: objc.c.id, sel: objc.c.SEL, a: i32, b: i32) callconv(.c) i32 { + _ = sel; + _ = target; + return a + b; + } + }.imp)); + break :setup My_Class; + }; + const result = My_Class.msgSend(objc.Object, "alloc", .{}) + .msgSend(objc.Object, "init", .{}) + .msgSend(i32, "my_addition", .{ @as(i32, 2), @as(i32, 3) }); + try testing.expectEqual(@as(i32, 5), result); +} diff --git a/pkg/zig-objc/src/encoding.zig b/pkg/zig-objc/src/encoding.zig new file mode 100644 index 00000000000..7cfdf1bc670 --- /dev/null +++ b/pkg/zig-objc/src/encoding.zig @@ -0,0 +1,405 @@ +const std = @import("std"); +const objc = @import("main.zig"); +const c = @import("c.zig").c; +const assert = std.debug.assert; +const testing = std.testing; + +/// how much space do we need to encode this type? +fn comptimeN(comptime T: type) usize { + comptime { + const encoding = objc.Encoding.init(T); + + // Figure out how much space we need + var stream: std.io.Writer.Discarding = .init(&.{}); + stream.writer.print("{f}", .{encoding}) catch unreachable; + return stream.count; + } +} + +/// Encode a type into a comptime string. +pub fn comptimeEncode(comptime T: type) [comptimeN(T):0]u8 { + comptime { + const encoding = objc.Encoding.init(T); + + // Build our final signature + var buf: [comptimeN(T) + 1]u8 = undefined; + var fbs: std.io.Writer = .fixed(buf[0 .. buf.len - 1]); + fbs.print("{f}", .{encoding}) catch unreachable; + buf[buf.len - 1] = 0; + + return buf[0 .. buf.len - 1 :0].*; + } +} + +/// Encoding union which parses type information and turns it into Obj-C +/// runtime Type Encodings. +/// +/// https://developer.apple.com/library/archive/documentation/Cocoa/Conceptual/ObjCRuntimeGuide/Articles/ocrtTypeEncodings.html +pub const Encoding = union(enum) { + char, + int, + short, + long, + longlong, + uchar, + uint, + ushort, + ulong, + ulonglong, + float, + double, + bool, + void, + char_string, + object, + class, + selector, + array: struct { arr_type: type, len: usize }, + structure: struct { struct_type: type, show_type_spec: bool }, + @"union": struct { union_type: type, show_type_spec: bool }, + bitfield: u32, + pointer: struct { ptr_type: type, size: std.builtin.Type.Pointer.Size }, + function: std.builtin.Type.Fn, + unknown, + + pub fn init(comptime T: type) Encoding { + return switch (T) { + i8, c_char => .char, + c_short => .short, + i32, c_int => .int, + c_long => .long, + i64, c_longlong => .longlong, + u8 => .uchar, + c_ushort => .ushort, + u32, c_uint => .uint, + c_ulong => .ulong, + u64, c_ulonglong => .ulonglong, + f32 => .float, + f64 => .double, + bool => .bool, + void, anyopaque => .void, + [*c]u8, [*c]const u8 => .char_string, + c.SEL, objc.Sel => .selector, + c.Class, objc.Class => .class, + c.id, objc.Object => .object, + else => switch (@typeInfo(T)) { + .@"opaque" => .void, + .@"enum" => |m| .init(m.tag_type), + .array => |arr| .{ .array = .{ .len = arr.len, .arr_type = arr.child } }, + .@"struct" => |m| switch (m.layout) { + .@"packed" => .init(m.backing_integer.?), + else => .{ .structure = .{ .struct_type = T, .show_type_spec = true } }, + }, + .@"union" => .{ .@"union" = .{ + .union_type = T, + .show_type_spec = true, + } }, + .optional => |m| switch (@typeInfo(m.child)) { + .pointer => |ptr| .{ .pointer = .{ .ptr_type = m.child, .size = ptr.size } }, + else => @compileError("unsupported non-pointer optional type: " ++ @typeName(T)), + }, + .pointer => |ptr| .{ .pointer = .{ .ptr_type = T, .size = ptr.size } }, + .@"fn" => |fn_info| .{ .function = fn_info }, + else => @compileError("unsupported type: " ++ @typeName(T)), + }, + }; + } + + pub fn format( + comptime self: Encoding, + writer: *std.io.Writer, + ) !void { + switch (self) { + .char => try writer.writeAll("c"), + .int => try writer.writeAll("i"), + .short => try writer.writeAll("s"), + .long => try writer.writeAll("l"), + .longlong => try writer.writeAll("q"), + .uchar => try writer.writeAll("C"), + .uint => try writer.writeAll("I"), + .ushort => try writer.writeAll("S"), + .ulong => try writer.writeAll("L"), + .ulonglong => try writer.writeAll("Q"), + .float => try writer.writeAll("f"), + .double => try writer.writeAll("d"), + .bool => try writer.writeAll("B"), + .void => try writer.writeAll("v"), + .char_string => try writer.writeAll("*"), + .object => try writer.writeAll("@"), + .class => try writer.writeAll("#"), + .selector => try writer.writeAll(":"), + .array => |a| { + try writer.print("[{}", .{a.len}); + const encode_type = init(a.arr_type); + try encode_type.format(writer); + try writer.writeAll("]"); + }, + .structure => |s| { + const struct_info = @typeInfo(s.struct_type); + assert(struct_info.@"struct".layout == .@"extern"); + + // Strips the fully qualified type name to leave just the + // type name. Used in naming the Struct in an encoding. + var type_name_iter = std.mem.splitBackwardsScalar(u8, @typeName(s.struct_type), '.'); + const type_name = type_name_iter.first(); + try writer.print("{{{s}", .{type_name}); + + // if the encoding should show the internal type specification + // of the struct (determined by levels of pointer indirection) + if (s.show_type_spec) { + try writer.writeAll("="); + inline for (struct_info.@"struct".fields) |field| { + const field_encode = init(field.type); + try field_encode.format(writer); + } + } + + try writer.writeAll("}"); + }, + .@"union" => |u| { + const union_info = @typeInfo(u.union_type); + assert(union_info.@"union".layout == .@"extern"); + + // Strips the fully qualified type name to leave just the + // type name. Used in naming the Union in an encoding + var type_name_iter = std.mem.splitBackwardsScalar(u8, @typeName(u.union_type), '.'); + const type_name = type_name_iter.first(); + try writer.print("({s}", .{type_name}); + + // if the encoding should show the internal type specification + // of the Union (determined by levels of pointer indirection) + if (u.show_type_spec) { + try writer.writeAll("="); + inline for (union_info.@"union".fields) |field| { + const field_encode = init(field.type); + try field_encode.format(writer); + } + } + + try writer.writeAll(")"); + }, + .bitfield => |b| try writer.print("b{}", .{b}), // not sure if needed from Zig -> Obj-C + .pointer => |p| { + switch (p.size) { + .one => { + // get the pointer info (count of levels of direction + // and the underlying type) + const pointer_info = indirectionCountAndType(p.ptr_type); + for (0..pointer_info.indirection_levels) |_| { + try writer.writeAll("^"); + } + + // create a new Encoding union from the pointers child + // type, giving an encoding of the underlying pointer type + comptime var encoding = init(pointer_info.child); + + // if the indirection levels are greater than 1, for + // certain types that means getting rid of it's + // internal type specification + // + // https://developer.apple.com/library/archive/documentation/Cocoa/Conceptual/ObjCRuntimeGuide/Articles/ocrtTypeEncodings.html#//apple_ref/doc/uid/TP40008048-CH100 + if (pointer_info.indirection_levels > 1) { + switch (encoding) { + .structure => |*s| s.show_type_spec = false, + .@"union" => |*u| u.show_type_spec = false, + else => {}, + } + } + + // call this format function again, this time with the child type encoding + try encoding.format(writer); + }, + else => @compileError("Pointer size not supported for encoding"), + } + }, + .function => |fn_info| { + assert(std.meta.eql(fn_info.calling_convention, std.builtin.CallingConvention.c)); + + // Return type is first in a method encoding + const ret_type_enc = init(fn_info.return_type.?); + try ret_type_enc.format(writer); + inline for (fn_info.params) |param| { + const param_enc = init(param.type.?); + try param_enc.format(writer); + } + }, + .unknown => {}, + } + } +}; + +/// This comptime function gets the levels of indirection from a type. If the type is a pointer type it +/// returns the underlying type from the pointer (the child) by walking the pointer to that child. +/// Returns the type and 0 for count if the type isn't a pointer +fn indirectionCountAndType(comptime T: type) struct { + child: type, + indirection_levels: comptime_int, +} { + var WalkType = T; + var count: usize = 0; + while (@typeInfo(WalkType) == .pointer) : (count += 1) { + WalkType = @typeInfo(WalkType).pointer.child; + } + + return .{ .child = WalkType, .indirection_levels = count }; +} + +fn encodingMatchesType(comptime T: type, expected_encoding: []const u8) !void { + var buf: [200]u8 = undefined; + const enc = Encoding.init(T); + const enc_string = try std.fmt.bufPrint(&buf, "{f}", .{enc}); + try testing.expectEqualStrings(expected_encoding, enc_string); +} + +test "i8 to Encoding.char encoding" { + try encodingMatchesType(i8, "c"); +} + +test "c_char to Encoding.char encoding" { + try encodingMatchesType(c_char, "c"); +} + +test "c_short to Encoding.short encoding" { + try encodingMatchesType(c_short, "s"); +} + +test "c_int to Encoding.int encoding" { + try encodingMatchesType(c_int, "i"); +} + +test "c_long to Encoding.long encoding" { + try encodingMatchesType(c_long, "l"); +} + +test "c_longlong to Encoding.longlong encoding" { + try encodingMatchesType(c_longlong, "q"); +} + +test "u8 to Encoding.uchar encoding" { + try encodingMatchesType(u8, "C"); +} + +test "c_ushort to Encoding.ushort encoding" { + try encodingMatchesType(c_ushort, "S"); +} + +test "c_uint to Encoding.uint encoding" { + try encodingMatchesType(c_uint, "I"); +} + +test "c_ulong to Encoding.ulong encoding" { + try encodingMatchesType(c_ulong, "L"); +} + +test "c_ulonglong to Encoding.ulonglong encoding" { + try encodingMatchesType(c_ulonglong, "Q"); +} + +test "f32 to Encoding.float encoding" { + try encodingMatchesType(f32, "f"); +} + +test "f64 to Encoding.double encoding" { + try encodingMatchesType(f64, "d"); +} + +test "[4]i8 to Encoding.array encoding" { + try encodingMatchesType([4]i8, "[4c]"); +} + +test "*u8 to Encoding.pointer encoding" { + try encodingMatchesType(*u8, "^C"); +} + +test "**u8 to Encoding.pointer encoding" { + try encodingMatchesType(**u8, "^^C"); +} + +test "?*u8 to Encoding.pointer encoding" { + try encodingMatchesType(?*u8, "^C"); +} + +test "Enum(c_uint) to Encoding.uint encoding" { + const TestEnum = enum(c_uint) {}; + try encodingMatchesType(TestEnum, "I"); +} + +test "TestPackedStruct to Encoding.uint encoding" { + const TestPackedStruct = packed struct(u32) { + _: u32, + }; + try encodingMatchesType(TestPackedStruct, "I"); +} + +test "*TestStruct to Encoding.pointer encoding" { + const TestStruct = extern struct { + float: f32, + char: u8, + }; + try encodingMatchesType(*TestStruct, "^{TestStruct=fC}"); +} + +test "**TestStruct to Encoding.pointer encoding" { + const TestStruct = extern struct { + float: f32, + char: u8, + }; + try encodingMatchesType(**TestStruct, "^^{TestStruct}"); +} + +test "*TestStruct with 2 level indirection NestedStruct to Encoding.pointer encoding" { + const NestedStruct = extern struct { + char: i8, + }; + const TestStruct = extern struct { + float: f32, + char: u8, + nested: **NestedStruct, + }; + try encodingMatchesType(*TestStruct, "^{TestStruct=fC^^{NestedStruct}}"); +} + +test "*TestOpaque to Encoding.pointer encoding" { + const TestOpaque = opaque {}; + try encodingMatchesType(*TestOpaque, "^v"); +} + +test "?*TestOpaque to Encoding.pointer encoding" { + const TestOpaque = opaque {}; + try encodingMatchesType(?*TestOpaque, "^v"); +} + +test "Union to Encoding.union encoding" { + const TestUnion = extern union { + int: c_int, + short: c_short, + long: c_long, + }; + try encodingMatchesType(TestUnion, "(TestUnion=isl)"); +} + +test "*Union to Encoding.union encoding" { + const TestUnion = extern union { + int: c_int, + short: c_short, + long: c_long, + }; + try encodingMatchesType(*TestUnion, "^(TestUnion=isl)"); +} + +test "**Union to Encoding.union encoding" { + const TestUnion = extern union { + int: c_int, + short: c_short, + long: c_long, + }; + try encodingMatchesType(**TestUnion, "^^(TestUnion)"); +} + +test "Fn to Encoding.function encoding" { + const test_fn = struct { + fn add(_: c.id, _: c.SEL, _: i8) callconv(.c) void {} + }; + + try encodingMatchesType(@TypeOf(test_fn.add), "v@:c"); +} diff --git a/pkg/zig-objc/src/iterator.zig b/pkg/zig-objc/src/iterator.zig new file mode 100644 index 00000000000..c28bfee0e08 --- /dev/null +++ b/pkg/zig-objc/src/iterator.zig @@ -0,0 +1,109 @@ +const std = @import("std"); +const objc = @import("main.zig"); + +// From . +const NSFastEnumerationState = extern struct { + state: c_ulong = 0, + itemsPtr: ?[*]objc.c.id = null, + mutationsPtr: ?*c_ulong = null, + extra: [5]c_ulong = [_]c_ulong{0} ** 5, +}; + +/// An iterator that uses the fast enumeration protocol[1] to iterate over +/// objects in an Objective-C collection. This can be used with any object +/// that conforms to the `NSFastEnumeration` protocol. +/// +/// [1]: Nhttps://developer.apple.com/documentation/foundation/nsfastenumeration +pub const Iterator = struct { + object: objc.Object, + sel: objc.Sel, + state: NSFastEnumerationState = .{}, + initial_mutations_value: ?c_ulong = null, + // Clang compiles `for…in` loops with a size 16 buffer. + buffer: [16]objc.c.id = [_]objc.c.id{null} ** 16, + slice: []const objc.c.id = &.{}, + + pub fn init(object: objc.Object) Iterator { + return .{ + .object = object, + .sel = objc.sel("countByEnumeratingWithState:objects:count:"), + }; + } + + pub fn next(self: *Iterator) ?objc.Object { + if (self.slice.len == 0) { + // Ask for some more objects. + const count = self.object.msgSend(c_ulong, self.sel, .{ + &self.state, + &self.buffer, + self.buffer.len, + }); + if (self.initial_mutations_value) |value| { + // Call the mutation handler if the mutations value has + // changed since the start of iteration. + if (value != self.state.mutationsPtr.?.*) { + objc.c.objc_enumerationMutation(self.object.value); + } + } else { + self.initial_mutations_value = self.state.mutationsPtr.?.*; + } + self.slice = self.state.itemsPtr.?[0..count]; + } + + if (self.slice.len == 0) return null; + + const first = self.slice[0]; + self.slice = self.slice[1..]; + return objc.Object.fromId(first); + } +}; + +test "NSArray iteration" { + const testing = std.testing; + const NSArray = objc.getClass("NSMutableArray").?; + const NSNumber = objc.getClass("NSNumber").?; + const array = NSArray.msgSend( + objc.Object, + "arrayWithCapacity:", + .{@as(c_ulong, 10)}, + ); + defer array.release(); + for (0..@as(c_int, 10)) |i| { + const i_number = NSNumber.msgSend(objc.Object, "numberWithInt:", .{i}); + defer i_number.release(); + array.msgSend(void, "addObject:", .{i_number}); + } + var result: c_int = 0; + var iter = array.iterate(); + while (iter.next()) |elem| { + result = (result * 10) + elem.getProperty(c_int, "intValue"); + } + try testing.expectEqual(123456789, result); +} + +test "NSDictionary iteration" { + const testing = std.testing; + const NSMutableDictionary = objc.getClass("NSMutableDictionary").?; + const NSNumber = objc.getClass("NSNumber").?; + const dict = NSMutableDictionary.msgSend( + objc.Object, + "dictionaryWithCapacity:", + .{@as(c_ulong, 100)}, + ); + defer dict.release(); + for (0..@as(c_int, 100)) |i| { + const i_number = NSNumber.msgSend(objc.Object, "numberWithInt:", .{i}); + defer i_number.release(); + dict.msgSend(void, "setValue:forKey:", .{ + i_number, + i_number.getProperty(objc.Object, "stringValue"), + }); + } + var result: c_int = 0; + var iter = dict.iterate(); + while (iter.next()) |key| { + const value = dict.msgSend(objc.Object, "valueForKey:", .{key}); + result += value.getProperty(c_int, "intValue"); + } + try testing.expectEqual(4950, result); +} diff --git a/pkg/zig-objc/src/main.zig b/pkg/zig-objc/src/main.zig new file mode 100644 index 00000000000..16c6cf7b405 --- /dev/null +++ b/pkg/zig-objc/src/main.zig @@ -0,0 +1,40 @@ +const std = @import("std"); + +const autorelease = @import("autorelease.zig"); +const block = @import("block.zig"); +const class = @import("class.zig"); +const encoding = @import("encoding.zig"); +const iterator = @import("iterator.zig"); +const object = @import("object.zig"); +const property = @import("property.zig"); +const protocol = @import("protocol.zig"); +const selpkg = @import("sel.zig"); + +pub const c = @import("c.zig").c; +pub const AutoreleasePool = autorelease.AutoreleasePool; +pub const Block = block.Block; +pub const Class = class.Class; +pub const getClass = class.getClass; +pub const getMetaClass = class.getMetaClass; +pub const allocateClassPair = class.allocateClassPair; +pub const registerClassPair = class.registerClassPair; +pub const disposeClassPair = class.disposeClassPair; +pub const Encoding = encoding.Encoding; +pub const comptimeEncode = encoding.comptimeEncode; +pub const Iterator = iterator.Iterator; +pub const Object = object.Object; +pub const Property = property.Property; +pub const Protocol = protocol.Protocol; +pub const getProtocol = protocol.getProtocol; +pub const sel = selpkg.sel; +pub const Sel = selpkg.Sel; + +/// This just calls the C allocator free. Some things need to be freed +/// and this is how they can be freed for objc. +pub inline fn free(ptr: anytype) void { + std.heap.c_allocator.free(ptr); +} + +test { + std.testing.refAllDecls(@This()); +} diff --git a/pkg/zig-objc/src/msg_send.zig b/pkg/zig-objc/src/msg_send.zig new file mode 100644 index 00000000000..7b5dcb2c281 --- /dev/null +++ b/pkg/zig-objc/src/msg_send.zig @@ -0,0 +1,244 @@ +const std = @import("std"); +const builtin = @import("builtin"); +const assert = std.debug.assert; +const c = @import("c.zig").c; +const objc = @import("main.zig"); + +/// Returns a struct that implements the msgSend function for type T. +pub fn MsgSend(comptime T: type) type { + // 1. T should be a struct + // 2. T should have a field "value" that can be an "id" (same size) + + return struct { + /// Invoke a selector on the target, i.e. an instance method on an + /// object or a class method on a class. The args should be a tuple. + pub fn msgSend( + target: T, + comptime Return: type, + sel_raw: anytype, + args: anytype, + ) Return { + // Our one special-case: If the return type is our own Object + // type then we wrap it. + const is_object = Return == objc.Object; + + // Our actual return value is an "id" if we are using one of + // our built-in types (see above). Otherwise, we trust the caller. + const RealReturn = if (is_object) c.id else Return; + + // We accept multiple types for sel but we need to turn it into + // an objc.sel ultimately. + const sel: objc.Sel = switch (@TypeOf(sel_raw)) { + objc.Sel => sel_raw, + else => objc.sel(sel_raw), + }; + + // Build our function type and call it + const Fn = MsgSendFn(RealReturn, @TypeOf(target.value), @TypeOf(args)); + const msg_send_fn = comptime msgSendPtr(RealReturn, false); + const msg_send_ptr: *const Fn = @ptrCast(msg_send_fn); + const result = @call(.auto, msg_send_ptr, .{ target.value, sel.value } ++ args); + + if (!is_object) return result; + return .{ .value = result }; + } + + /// Invoke a selector on the superclass. + pub fn msgSendSuper( + target: T, + superclass: objc.Class, + comptime Return: type, + sel_raw: anytype, + args: anytype, + ) Return { + // See msgSend for in depth comments on all of this. This is + // effectively the same logic. + const is_object = Return == objc.Object; + const RealReturn = if (is_object) c.id else Return; + const sel: objc.Sel = switch (@TypeOf(sel_raw)) { + objc.Sel => sel_raw, + else => objc.sel(sel_raw), + }; + + const Fn = MsgSendFn(RealReturn, *c.objc_super, @TypeOf(args)); + const msg_send_fn = comptime msgSendPtr(RealReturn, true); + const msg_send_ptr: *const Fn = @ptrCast(msg_send_fn); + var super: c.objc_super = .{ + .receiver = target.value, + .class = superclass.value, + }; + const result = @call(.auto, msg_send_ptr, .{ &super, sel.value } ++ args); + + if (!is_object) return result; + return .{ .value = result }; + } + + /// Returns the objc_msgSend or objc_msgSendSuper pointer for the + /// given return type. + fn msgSendPtr( + comptime Return: type, + comptime super: bool, + ) *const fn () callconv(.c) void { + // See objc/message.h. The high-level is that depending on the + // target architecture and return type, we must use a different + // objc_msgSend function. + return switch (builtin.target.cpu.arch) { + // Aarch64 uses objc_msgSend for everything. Hurray! + .aarch64 => if (super) &c.objc_msgSendSuper else &c.objc_msgSend, + + // x86_64 depends on the return type... + .x86_64 => switch (@typeInfo(Return)) { + // Most types use objc_msgSend + inline .int, + .bool, + .@"enum", + .pointer, + .void, + => if (super) &c.objc_msgSendSuper else &c.objc_msgSend, + + .optional => |opt| opt: { + assert(@typeInfo(opt.child) == .pointer); + break :opt if (super) &c.objc_msgSendSuper else &c.objc_msgSend; + }, + + // Structs must use objc_msgSend_stret. + // NOTE: This is probably WAY more complicated... we only + // call this if the struct is NOT returned as a register. + // And that depends on the size of the struct. But I don't + // know what the breakpoint actually is for that. This SO + // answer says 16 bytes so I'm going to use that but I have + // no idea... + .@"struct" => blk: { + if (@sizeOf(Return) > 16) { + break :blk if (super) + &c.objc_msgSendSuper_stret + else + &c.objc_msgSend_stret; + } else { + break :blk if (super) + &c.objc_msgSendSuper + else + &c.objc_msgSend; + } + }, + + // Floats use objc_msgSend_fpret for f64 on x86_64, + // but normal msgSend for other bit sizes. i386 has + // more complex rules but we don't support i386 at the time + // of this comment and probably never will since all i386 + // Apple models are discontinued at this point. + .float => |float| switch (float.bits) { + 64 => if (super) &c.objc_msgSendSuper_fpret else &c.objc_msgSend_fpret, + else => if (super) &c.objc_msgSendSuper else &c.objc_msgSend, + }, + + // Otherwise we log in case we need to add a new case above + else => { + @compileLog(@typeInfo(Return)); + @compileError("unsupported return type for objc runtime on x86_64"); + }, + }, + + else => @compileError("unsupported objc architecture"), + }; + } + }; +} + +/// This returns a function body type for `obj_msgSend` that matches +/// the given return type, target type, and arguments tuple type. +/// +/// obj_msgSend is a really interesting function, because it doesn't act +/// like a typical function. You have to call it with the C ABI as if you're +/// calling the true target function, not as a varargs C function. Therefore +/// you have to cast obj_msgSend to a function pointer type of the final +/// destination function, then call that. +/// +/// Example: you have an ObjC function like this: +/// +/// @implementation Foo +/// - (void)log: (float)x { /* stuff */ } +/// +/// If you call it like this, it won't work (you'll get garbage): +/// +/// objc_msgSend(obj, @selector(log:), (float)PI); +/// +/// You have to call it like this: +/// +/// ((void (*)(id, SEL, float))objc_msgSend)(obj, @selector(log:), M_PI); +/// +/// This comptime function returns the function body type that can be used +/// to cast and call for the proper C ABI behavior. +fn MsgSendFn( + comptime Return: type, + comptime Target: type, + comptime Args: type, +) type { + const argsInfo = @typeInfo(Args).@"struct"; + assert(argsInfo.is_tuple); + + // Target must always be an "id". Lots of types (Class, Object, etc.) + // are an "id" so we just make sure the sizes match for ABI reasons. + assert(@sizeOf(Target) == @sizeOf(c.id)); + + // Build up our argument types. + const Fn = std.builtin.Type.Fn; + const params: []Fn.Param = params: { + var acc: [argsInfo.fields.len + 2]Fn.Param = undefined; + + // First argument is always the target and selector. + acc[0] = .{ .type = Target, .is_generic = false, .is_noalias = false }; + acc[1] = .{ .type = c.SEL, .is_generic = false, .is_noalias = false }; + + // Remaining arguments depend on the args given, in the order given + for (argsInfo.fields, 0..) |field, i| { + acc[i + 2] = .{ + .type = field.type, + .is_generic = false, + .is_noalias = false, + }; + } + + break :params &acc; + }; + + return @Type(.{ + .@"fn" = .{ + .calling_convention = .c, + .is_generic = false, + .is_var_args = false, + .return_type = Return, + .params = params, + }, + }); +} + +test { + const testing = std.testing; + try testing.expectEqual(fn ( + c.id, + c.SEL, + ) callconv(.c) u64, MsgSendFn(u64, c.id, @TypeOf(.{}))); + try testing.expectEqual(fn (c.id, c.SEL, u16, u32) callconv(.c) u64, MsgSendFn(u64, c.id, @TypeOf(.{ + @as(u16, 0), + @as(u32, 0), + }))); +} + +test "subClass" { + const Subclass = objc.allocateClassPair(objc.getClass("NSObject").?, "subclass").?; + defer objc.disposeClassPair(Subclass); + const str = struct { + fn inner(target: objc.c.id, sel: objc.c.SEL) callconv(.c) objc.c.id { + _ = sel; + const self = objc.Object.fromId(target); + self.msgSendSuper(objc.getClass("NSObject").?, void, "init", .{}); + return target; + } + }; + Subclass.replaceMethod("init", str.inner); + objc.registerClassPair(Subclass); + const subclass_obj = Subclass.msgSend(objc.Object, "alloc", .{}); + defer subclass_obj.msgSend(void, "dealloc", .{}); + subclass_obj.msgSend(void, "init", .{}); +} diff --git a/pkg/zig-objc/src/object.zig b/pkg/zig-objc/src/object.zig new file mode 100644 index 00000000000..73fd4854a8d --- /dev/null +++ b/pkg/zig-objc/src/object.zig @@ -0,0 +1,220 @@ +const std = @import("std"); +const cpkg = @import("c.zig"); +const c = cpkg.c; +const boolResult = cpkg.boolResult; +const objc = @import("main.zig"); +const MsgSend = @import("msg_send.zig").MsgSend; +const Iterator = @import("iterator.zig").Iterator; + +/// Object is an instance of a class. +pub const Object = struct { + value: c.id, + + // Implement msgSend + const msg_send = MsgSend(Object); + pub const msgSend = msg_send.msgSend; + pub const msgSendSuper = msg_send.msgSendSuper; + + /// Convert a raw "id" into an Object. id must fit the size of the + /// normal C "id" type (i.e. a `usize`). + pub fn fromId(id: anytype) Object { + if (@sizeOf(@TypeOf(id)) != @sizeOf(c.id)) { + @compileError("invalid id type"); + } + + // Some pointers in Objective-C are "tagged pointers", which + // may be used for small objects and literals (NSNumber, NSString). + // It's an internal implementation detail that replaces heap + // allocation with direct encoding within the pointer itself. + // This may result in UNALIGNED POINTERS! + const ptr: c.id = blk: { + @setRuntimeSafety(false); + break :blk @ptrCast(@alignCast(id)); + }; + + return .{ .value = ptr }; + } + + /// Returns the class of an object. + pub fn getClass(self: Object) ?objc.Class { + return objc.Class{ + .value = c.object_getClass(self.value) orelse return null, + }; + } + + /// Returns the class name of a given object. + pub fn getClassName(self: Object) [:0]const u8 { + return std.mem.sliceTo(c.object_getClassName(self.value), 0); + } + + /// Set a property. This is a helper around getProperty and is + /// strictly less performant than doing it manually. Consider doing + /// this manually if performance is critical. + pub fn setProperty(self: Object, comptime n: [:0]const u8, v: anytype) void { + const Class = self.getClass().?; + const setter = setter: { + // See getProperty for why we do this. + if (Class.getProperty(n)) |prop| { + if (prop.copyAttributeValue("S")) |val| { + defer objc.free(val); + break :setter objc.sel(val); + } + } + + break :setter objc.sel( + "set" ++ + [1]u8{std.ascii.toUpper(n[0])} ++ + n[1..n.len] ++ + ":", + ); + }; + + self.msgSend(void, setter, .{v}); + } + + /// Get a property. This is a helper around Class.getProperty and is + /// strictly less performant than doing it manually. Consider doing + /// this manually if performance is critical. + pub fn getProperty(self: Object, comptime T: type, comptime n: [:0]const u8) T { + const Class = self.getClass().?; + const getter = getter: { + // Sometimes a property is not a property because it has been + // overloaded or something. I've found numerous occasions the + // Apple docs are just wrong, so we try to read it as a property + // but if we can't then we just call it as-is. + if (Class.getProperty(n)) |prop| { + if (prop.copyAttributeValue("G")) |val| { + defer objc.free(val); + break :getter objc.sel(val); + } + } + + break :getter objc.sel(n); + }; + + return self.msgSend(T, getter, .{}); + } + + pub fn copy(self: Object, size: usize) Object { + return fromId(c.object_copy(self.value, size)); + } + + pub fn dispose(self: Object) void { + c.object_dispose(self.value); + } + + pub fn isClass(self: Object) bool { + return boolResult(c.object_isClass(self.value)); + } + + pub fn getInstanceVariable(self: Object, name: [:0]const u8) Object { + const ivar = c.object_getInstanceVariable(self.value, name, null); + return fromId(c.object_getIvar(self.value, ivar)); + } + + pub fn setInstanceVariable(self: Object, name: [:0]const u8, val: Object) void { + const ivar = c.object_getInstanceVariable(self.value, name, null); + c.object_setIvar(self.value, ivar, val.value); + } + + pub fn retain(self: Object) Object { + return fromId(objc_retain(self.value)); + } + + pub fn release(self: Object) void { + objc_release(self.value); + } + + /// Return an iterator for this object. The object must implement the + /// `NSFastEnumeration` protocol. + pub fn iterate(self: Object) Iterator { + return Iterator.init(self); + } +}; + +extern "c" fn objc_retain(objc.c.id) objc.c.id; +extern "c" fn objc_release(objc.c.id) void; + +fn retainCount(obj: Object) c_ulong { + return obj.msgSend(c_ulong, objc.Sel.registerName("retainCount"), .{}); +} + +test { + const testing = std.testing; + const NSObject = objc.getClass("NSObject").?; + + // Should work with our wrappers + const obj = NSObject.msgSend(objc.Object, objc.Sel.registerName("alloc"), .{}); + try testing.expect(obj.value != null); + try testing.expectEqualStrings("NSObject", obj.getClassName()); + obj.msgSend(void, objc.sel("dealloc"), .{}); +} + +test "retain object" { + const testing = std.testing; + const NSObject = objc.getClass("NSObject").?; + + const obj = NSObject.msgSend(objc.Object, objc.Sel.registerName("alloc"), .{}); + _ = obj.msgSend(objc.Object, objc.Sel.registerName("init"), .{}); + try testing.expectEqual(@as(c_ulong, 1), retainCount(obj)); + + _ = obj.retain(); + try testing.expectEqual(@as(c_ulong, 2), retainCount(obj)); + + obj.release(); + try testing.expectEqual(@as(c_ulong, 1), retainCount(obj)); + + obj.msgSend(void, objc.sel("dealloc"), .{}); +} + +test "tagged pointer" { + const testing = std.testing; + + // We can't force Objective-C to provide us with an unaligned tagged + // pointer, so we try several times using different classes. We use + // different classes instead of values, since pointers from the same + // class will have the same alignment during a single execution (aarch64). + const obj = blk: { + var Class = objc.getClass("NSNumber").?; + var sel = objc.Sel.registerName("numberWithChar:"); + var obj = Class.msgSend(objc.Object, sel, .{@as(u8, @intCast(5))}); + + // We're only interested in an unaligned pointer. + if (!std.mem.isAligned(@intFromPtr(obj.value), @alignOf(usize))) break :blk obj; + + Class = objc.getClass("NSString").?; + sel = objc.Sel.registerName("stringWithUTF8String:"); + obj = Class.msgSend(objc.Object, sel, .{"foo"}); + if (!std.mem.isAligned(@intFromPtr(obj.value), @alignOf(usize))) break :blk obj; + + Class = objc.getClass("NSDate").?; + sel = objc.Sel.registerName("date"); + obj = Class.msgSend(objc.Object, sel, .{}); + if (!std.mem.isAligned(@intFromPtr(obj.value), @alignOf(usize))) break :blk obj; + + const colors = [_][:0]const u8{ + "clearColor", "blackColor", "blueColor", "brownColor", "cyanColor", + "darkGrayColor", "grayColor", "greenColor", "lightGrayColor", "magentaColor", + "orangeColor", "purpleColor", "redColor", "whiteColor", "yellowColor", + }; + + Class = objc.getClass("NSColor").?; + for (colors) |color| { + sel = objc.Sel.registerName(color); + obj = Class.msgSend(objc.Object, sel, .{}); + if (!std.mem.isAligned(@intFromPtr(obj.value), @alignOf(usize))) break :blk obj; + } + + // In the unlikely event that we don't find an unaligned tagged pointer. + std.log.warn("skipped 'tagged pointer' test because we couldn't find an unaligned tagged pointer", .{}); + return error.SkipZigTest; + }; + + // A tagged object is not allocated on the heap and cannot be retained. + try testing.expect(retainCount(obj) != 1); + + // `Object.fromId` must work even when the pointer is unaligned. + const obj_ptr = @intFromPtr(obj.value); + try testing.expect(!std.mem.isAligned(obj_ptr, @alignOf(usize))); + try testing.expect(std.meta.eql(obj, Object.fromId(obj.value))); +} diff --git a/pkg/zig-objc/src/property.zig b/pkg/zig-objc/src/property.zig new file mode 100644 index 00000000000..e042306e8c6 --- /dev/null +++ b/pkg/zig-objc/src/property.zig @@ -0,0 +1,40 @@ +const std = @import("std"); +const c = @import("c.zig").c; +const objc = @import("main.zig"); + +pub const Property = extern struct { + value: c.objc_property_t, + + /// Returns the name of a property. + pub fn getName(self: Property) [:0]const u8 { + return std.mem.sliceTo(c.property_getName(self.value), 0); + } + + /// Returns the value of a property attribute given the attribute name. + pub fn copyAttributeValue(self: Property, attr: [:0]const u8) ?[:0]u8 { + return std.mem.sliceTo( + c.property_copyAttributeValue(self.value, attr.ptr) orelse return null, + 0, + ); + } + + comptime { + std.debug.assert(@sizeOf(@This()) == @sizeOf(c.objc_property_t)); + std.debug.assert(@alignOf(@This()) == @alignOf(c.objc_property_t)); + } +}; + +test { + // Critical properties because we ptrCast C pointers to this. + const testing = std.testing; + try testing.expect(@sizeOf(Property) == @sizeOf(c.objc_property_t)); + try testing.expect(@alignOf(Property) == @alignOf(c.objc_property_t)); +} + +test { + const testing = std.testing; + const NSObject = objc.getClass("NSObject").?; + + const prop = NSObject.getProperty("className").?; + try testing.expectEqualStrings("className", prop.getName()); +} diff --git a/pkg/zig-objc/src/protocol.zig b/pkg/zig-objc/src/protocol.zig new file mode 100644 index 00000000000..24b64b05d15 --- /dev/null +++ b/pkg/zig-objc/src/protocol.zig @@ -0,0 +1,60 @@ +const std = @import("std"); +const cpkg = @import("c.zig"); +const c = cpkg.c; +const boolParam = cpkg.boolParam; +const boolResult = cpkg.boolResult; +const objc = @import("main.zig"); + +pub const Protocol = extern struct { + value: *c.Protocol, + + pub fn conformsToProtocol(self: Protocol, other: Protocol) bool { + return boolResult(c.protocol_conformsToProtocol(self.value, other.value)); + } + + pub fn isEqual(self: Protocol, other: Protocol) bool { + return boolResult(c.protocol_isEqual(self.value, other.value)); + } + + pub fn getName(self: Protocol) [:0]const u8 { + return std.mem.sliceTo(c.protocol_getName(self.value), 0); + } + + pub fn getProperty( + self: Protocol, + name: [:0]const u8, + is_required: bool, + is_instance: bool, + ) ?objc.Property { + return .{ .value = c.protocol_getProperty( + self.value, + name, + boolParam(is_required), + boolParam(is_instance), + ) orelse return null }; + } + + comptime { + std.debug.assert(@sizeOf(@This()) == @sizeOf([*c]c.Protocol)); + std.debug.assert(@alignOf(@This()) == @alignOf([*c]c.Protocol)); + } +}; + +pub fn getProtocol(name: [:0]const u8) ?Protocol { + return .{ .value = c.objc_getProtocol(name) orelse return null }; +} + +test Protocol { + const testing = std.testing; + const fs_proto = getProtocol("NSFileManagerDelegate") orelse return error.ProtocolNotFound; + try testing.expectEqualStrings("NSFileManagerDelegate", fs_proto.getName()); + + const obj_proto = getProtocol("NSObject") orelse return error.ProtocolNotFound; + try testing.expect(fs_proto.conformsToProtocol(obj_proto)); + + const url_proto = getProtocol("NSURLSessionDelegate") orelse return error.ProtocolNotFound; + try testing.expect(!fs_proto.conformsToProtocol(url_proto)); + + const hash_prop = obj_proto.getProperty("hash", true, true) orelse return error.ProtocolPropertyNotFound; + try testing.expectEqualStrings("hash", hash_prop.getName()); +} diff --git a/pkg/zig-objc/src/sel.zig b/pkg/zig-objc/src/sel.zig new file mode 100644 index 00000000000..8036961973b --- /dev/null +++ b/pkg/zig-objc/src/sel.zig @@ -0,0 +1,30 @@ +const std = @import("std"); +const c = @import("c.zig").c; + +// Shorthand, equivalent to Sel.registerName +pub inline fn sel(name: [:0]const u8) Sel { + return Sel.registerName(name); +} + +pub const Sel = struct { + value: c.SEL, + + /// Registers a method with the Objective-C runtime system, maps the + /// method name to a selector, and returns the selector value. + pub fn registerName(name: [:0]const u8) Sel { + return Sel{ + .value = c.sel_registerName(name.ptr), + }; + } + + /// Returns the name of the method specified by a given selector. + pub fn getName(self: Sel) [:0]const u8 { + return std.mem.sliceTo(c.sel_getName(self.value), 0); + } +}; + +test { + const testing = std.testing; + const s = Sel.registerName("yo"); + try testing.expectEqualStrings("yo", s.getName()); +} From 745a70a0798b55f3a286f408e71f0a4cb7ebe1bc Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Mon, 30 Mar 2026 18:59:00 -0700 Subject: [PATCH 03/13] feat: iOS xcframework build improvements + manual IO surface rendering - Fix xcframework build for iOS targets (SDKROOT handling, libtool, lipo) - Add IOSurface-based rendering for iOS manual IO mode - Add surface config for manual text input handling - Honor SDKROOT for local apple dependency resolution in build system --- include/ghostty.h | 9 +- macos/Sources/Ghostty/Ghostty.Surface.swift | 2 +- pkg/zig-objc/build.zig | 6 +- src/Surface.zig | 77 ++++++++++++ src/apprt/embedded.zig | 60 ++++++++++ src/build/GhosttyLib.zig | 2 +- src/build/GhosttyXCFramework.zig | 122 ++++++++++---------- src/build/GhosttyXcodebuild.zig | 2 +- src/build/LibtoolStep.zig | 41 ++++++- src/build/LipoStep.zig | 5 + src/build/MetallibStep.zig | 9 ++ src/build/XCFrameworkStep.zig | 5 + src/input.zig | 1 + src/renderer/Metal.zig | 23 ++++ src/renderer/generic.zig | 39 +++++++ src/renderer/metal/IOSurfaceLayer.zig | 12 +- src/termio/Manual.zig | 26 ++++- 17 files changed, 368 insertions(+), 73 deletions(-) diff --git a/include/ghostty.h b/include/ghostty.h index 65b1cdc5a45..bb8861154f6 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -3,8 +3,9 @@ // isn't meant to be a general purpose embedding API (yet) so there hasn't // been documentation or example work beyond that. // -// The only consumer of this API is the macOS app, but the API is built to -// be more general purpose. +// cmux uses this API on both macOS and iOS. The iOS integration depends on +// manual surface I/O via ghostty_surface_config_s.io_mode, io_write_cb, +// ghostty_surface_process_output, and ghostty_surface_text_input. #ifndef GHOSTTY_H #define GHOSTTY_H @@ -1098,6 +1099,7 @@ bool ghostty_surface_key_is_binding(ghostty_surface_t, ghostty_input_key_s, ghostty_binding_flags_e*); void ghostty_surface_text(ghostty_surface_t, const char*, uintptr_t); +void ghostty_surface_text_input(ghostty_surface_t, const char*, uintptr_t); void ghostty_surface_preedit(ghostty_surface_t, const char*, uintptr_t); void ghostty_surface_process_output(ghostty_surface_t, const char*, uintptr_t); bool ghostty_surface_mouse_captured(ghostty_surface_t); @@ -1133,6 +1135,9 @@ bool ghostty_surface_read_selection(ghostty_surface_t, ghostty_text_s*); bool ghostty_surface_read_text(ghostty_surface_t, ghostty_selection_s, ghostty_text_s*); +bool ghostty_surface_read_text_html(ghostty_surface_t, + ghostty_selection_s, + ghostty_text_s*); void ghostty_surface_free_text(ghostty_surface_t, ghostty_text_s*); #ifdef __APPLE__ diff --git a/macos/Sources/Ghostty/Ghostty.Surface.swift b/macos/Sources/Ghostty/Ghostty.Surface.swift index b072db15e36..352cb0b414b 100644 --- a/macos/Sources/Ghostty/Ghostty.Surface.swift +++ b/macos/Sources/Ghostty/Ghostty.Surface.swift @@ -44,7 +44,7 @@ extension Ghostty { text.withCString { ptr in // len includes the null terminator so we do len - 1 - ghostty_surface_text(surface, ptr, UInt(len - 1)) + ghostty_surface_text_input(surface, ptr, UInt(len - 1)) } } diff --git a/pkg/zig-objc/build.zig b/pkg/zig-objc/build.zig index dac2d1c7cff..1f3599bc6b1 100644 --- a/pkg/zig-objc/build.zig +++ b/pkg/zig-objc/build.zig @@ -93,7 +93,7 @@ pub fn addAppleSDK(b: *std.Build, m: *std.Build.Module) !void { .watchos => error.XcodeWatchOSSDKNotFound, else => error.XcodeAppleSDKNotFound, }; - m.addSystemFrameworkPath(.{ .cwd_relative = b.pathJoin(&.{ path, "/System/Library/Frameworks" }) }); - m.addSystemIncludePath(.{ .cwd_relative = b.pathJoin(&.{ path, "/usr/include" }) }); - m.addLibraryPath(.{ .cwd_relative = b.pathJoin(&.{ path, "/usr/lib" }) }); + m.addSystemFrameworkPath(.{ .cwd_relative = b.pathJoin(&.{ path, "System/Library/Frameworks" }) }); + m.addSystemIncludePath(.{ .cwd_relative = b.pathJoin(&.{ path, "usr/include" }) }); + m.addLibraryPath(.{ .cwd_relative = b.pathJoin(&.{ path, "usr/lib" }) }); } diff --git a/src/Surface.zig b/src/Surface.zig index 50e55e722a0..c9e35ae78f4 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -2042,6 +2042,31 @@ pub fn dumpTextLocked( }; } +/// Dump formatted HTML for the given selection using the terminal's +/// current palette and foreground/background colors. +pub fn dumpHTMLLocked( + self: *Surface, + alloc: Allocator, + sel: terminal.Selection, +) ![:0]const u8 { + const ScreenFormatter = terminal.formatter.ScreenFormatter; + var aw: std.Io.Writer.Allocating = .init(alloc); + var formatter: ScreenFormatter = .init(self.io.terminal.screens.active, .{ + .emit = .html, + .unwrap = true, + .trim = false, + .background = self.io.terminal.colors.background.get(), + .foreground = self.io.terminal.colors.foreground.get(), + .palette = &self.io.terminal.colors.palette.current, + }); + formatter.content = .{ .selection = sel.ordered( + self.io.terminal.screens.active, + .forward, + ) }; + try formatter.format(&aw.writer); + return try aw.toOwnedSliceSentinel(0); +} + /// Returns true if the terminal has a selection. pub fn hasSelection(self: *const Surface) bool { self.renderer_state.mutex.lock(); @@ -3325,6 +3350,17 @@ pub fn textCallback(self: *Surface, text: []const u8) !void { try self.completeClipboardPaste(text, true); } +/// Sends committed text input to the terminal without keyboard protocol +/// encoding. Unlike textCallback, this is not treated like a paste. +/// Newlines are normalized to carriage returns to match Enter semantics. +pub fn textInputCallback(self: *Surface, text: []const u8) !void { + // Crash metadata in case we crash in here + crash.sentry.thread_state = self.crashThreadState(); + defer crash.sentry.thread_state = null; + + try self.completeTextInput(text); +} + /// Callback for when the surface is fully visible or not, regardless /// of focus state. This is used to pause rendering when the surface /// is not visible, and also re-render when it becomes visible again. @@ -6324,6 +6360,47 @@ fn completeClipboardPaste( }; } +fn completeTextInput( + self: *Surface, + data: []const u8, +) !void { + if (data.len == 0) return; + + var data_duped: ?[]u8 = null; + const encoded = input.text.encode(data) catch |err| switch (err) { + error.MutableRequired => encoded: { + const buf: []u8 = try self.alloc.dupe(u8, data); + errdefer self.alloc.free(buf); + data_duped = buf; + break :encoded input.text.encode(buf); + }, + }; + defer if (data_duped) |v| self.alloc.free(v); + + if (self.child_exited) { + self.close(); + return; + } + + self.queueIo(try termio.Message.writeReq( + self.alloc, + encoded, + ), .unlocked); + + self.renderer_state.mutex.lock(); + defer self.renderer_state.mutex.unlock(); + + if (self.config.selection_clear_on_typing) { + try self.setSelection(null); + } + + if (self.config.scroll_to_bottom.keystroke) { + self.io.terminal.scrollViewport(.bottom); + } + + try self.queueRender(); +} + fn completeClipboardReadOSC52( self: *Surface, data: []const u8, diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index 76e65abf206..45bfe88793d 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -931,6 +931,13 @@ pub const Surface = struct { }; } + pub fn textInputCallback(self: *Surface, text: []const u8) void { + _ = self.core_surface.textInputCallback(text) catch |err| { + log.err("error in text input callback err={}", .{err}); + return; + }; + } + pub fn focusCallback(self: *Surface, focused: bool) void { self.core_surface.focusCallback(focused) catch |err| { log.err("error in focus callback err={}", .{err}); @@ -1687,6 +1694,23 @@ pub const CAPI = struct { return readTextLocked(surface, core_sel, result); } + /// Same as ghostty_surface_read_text but emits Ghostty-formatted HTML + /// for the given selection instead of plain text. + export fn ghostty_surface_read_text_html( + surface: *Surface, + sel: Selection, + result: *Text, + ) bool { + surface.core_surface.renderer_state.mutex.lock(); + defer surface.core_surface.renderer_state.mutex.unlock(); + + const core_sel = sel.core( + surface.core_surface.renderer_state.terminal.screens.active, + ) orelse return false; + + return readHTMLLocked(surface, core_sel, result); + } + fn readTextLocked( surface: *Surface, core_sel: terminal.Selection, @@ -1722,6 +1746,31 @@ pub const CAPI = struct { return true; } + fn readHTMLLocked( + surface: *Surface, + core_sel: terminal.Selection, + result: *Text, + ) bool { + const html = surface.core_surface.dumpHTMLLocked( + global.alloc, + core_sel, + ) catch |err| { + log.warn("error reading html err={}", .{err}); + return false; + }; + + result.* = .{ + .tl_px_x = -1, + .tl_px_y = -1, + .offset_start = 0, + .offset_len = 0, + .text = html.ptr, + .text_len = html.len, + }; + + return true; + } + export fn ghostty_surface_free_text(ptr: *Text) void { ptr.deinit(); } @@ -1851,6 +1900,17 @@ pub const CAPI = struct { surface.textCallback(ptr[0..len]); } + /// Send committed text input to the terminal. This is treated like + /// typed text, not a paste. Newlines are normalized to carriage + /// returns and bracketed paste mode is not used. + export fn ghostty_surface_text_input( + surface: *Surface, + ptr: [*]const u8, + len: usize, + ) void { + surface.textInputCallback(ptr[0..len]); + } + /// Set the preedit text for the surface. This is used for IME /// composition. If the length is 0, then the preedit text is cleared. export fn ghostty_surface_preedit( diff --git a/src/build/GhosttyLib.zig b/src/build/GhosttyLib.zig index 2ac383544ef..14719172b22 100644 --- a/src/build/GhosttyLib.zig +++ b/src/build/GhosttyLib.zig @@ -42,7 +42,7 @@ pub fn initStatic( // Add our dependencies. Get the list of all static deps so we can // build a combined archive if necessary. var lib_list = try deps.add(lib); - try lib_list.append(b.allocator, lib.getEmittedBin()); + try lib_list.insert(b.allocator, 0, lib.getEmittedBin()); if (!deps.config.target.result.os.tag.isDarwin()) return .{ .step = &lib.step, diff --git a/src/build/GhosttyXCFramework.zig b/src/build/GhosttyXCFramework.zig index 3afeb9073ba..7de902f77fa 100644 --- a/src/build/GhosttyXCFramework.zig +++ b/src/build/GhosttyXCFramework.zig @@ -15,73 +15,77 @@ pub fn init( deps: *const SharedDeps, target: Target, ) !GhosttyXCFramework { - // Universal macOS build - const macos_universal = try GhosttyLib.initMacOSUniversal(b, deps); - - // Native macOS build - const macos_native = try GhosttyLib.initStatic(b, &try deps.retarget( - b, - Config.genericMacOSTarget(b, null), - )); - - // iOS - const ios = try GhosttyLib.initStatic(b, &try deps.retarget( - b, - b.resolveTargetQuery(.{ - .cpu_arch = .aarch64, - .os_tag = .ios, - .os_version_min = Config.osVersionMin(.ios), - .abi = null, - }), - )); - - // iOS Simulator - const ios_sim = try GhosttyLib.initStatic(b, &try deps.retarget( - b, - b.resolveTargetQuery(.{ - .cpu_arch = .aarch64, - .os_tag = .ios, - .os_version_min = Config.osVersionMin(.ios), - .abi = .simulator, - - // We force the Apple CPU model because the simulator - // doesn't support the generic CPU model as of Zig 0.14 due - // to missing "altnzcv" instructions, which is false. This - // surely can't be right but we can fix this if/when we get - // back to running simulator builds. - .cpu_model = .{ .explicit = &std.Target.aarch64.cpu.apple_a17 }, - }), - )); - // The xcframework wraps our ghostty library so that we can link // it to the final app built with Swift. const xcframework = XCFrameworkStep.create(b, .{ .name = "GhosttyKit", .out_path = "macos/GhosttyKit.xcframework", .libraries = switch (target) { - .universal => &.{ - .{ - .library = macos_universal.output, - .headers = b.path("include"), - .dsym = macos_universal.dsym, - }, - .{ - .library = ios.output, - .headers = b.path("include"), - .dsym = ios.dsym, - }, - .{ - .library = ios_sim.output, - .headers = b.path("include"), - .dsym = ios_sim.dsym, - }, + .universal => libraries: { + // Universal macOS build + const macos_universal = try GhosttyLib.initMacOSUniversal(b, deps); + + // iOS + const ios = try GhosttyLib.initStatic(b, &try deps.retarget( + b, + b.resolveTargetQuery(.{ + .cpu_arch = .aarch64, + .os_tag = .ios, + .os_version_min = Config.osVersionMin(.ios), + .abi = null, + }), + )); + + // iOS Simulator + const ios_sim = try GhosttyLib.initStatic(b, &try deps.retarget( + b, + b.resolveTargetQuery(.{ + .cpu_arch = .aarch64, + .os_tag = .ios, + .os_version_min = Config.osVersionMin(.ios), + .abi = .simulator, + + // We force the Apple CPU model because the simulator + // doesn't support the generic CPU model as of Zig 0.14 due + // to missing "altnzcv" instructions, which is false. This + // surely can't be right but we can fix this if/when we get + // back to running simulator builds. + .cpu_model = .{ .explicit = &std.Target.aarch64.cpu.apple_a17 }, + }), + )); + + break :libraries &.{ + .{ + .library = macos_universal.output, + .headers = b.path("include"), + .dsym = macos_universal.dsym, + }, + .{ + .library = ios.output, + .headers = b.path("include"), + .dsym = ios.dsym, + }, + .{ + .library = ios_sim.output, + .headers = b.path("include"), + .dsym = ios_sim.dsym, + }, + }; }, - .native => &.{.{ - .library = macos_native.output, - .headers = b.path("include"), - .dsym = macos_native.dsym, - }}, + .native => libraries: { + // Native macOS build + const macos_native = try GhosttyLib.initStatic(b, &try deps.retarget( + b, + Config.genericMacOSTarget(b, deps.config.target.result.cpu.arch), + )); + + break :libraries &.{.{ + .library = macos_native.output, + .headers = b.path("include"), + .dsym = macos_native.dsym, + }}; + }, }, }); diff --git a/src/build/GhosttyXcodebuild.zig b/src/build/GhosttyXcodebuild.zig index 5ca4c5e9a64..b332222c601 100644 --- a/src/build/GhosttyXcodebuild.zig +++ b/src/build/GhosttyXcodebuild.zig @@ -41,7 +41,7 @@ pub fn init( // Native we need to override the architecture in the Xcode // project with the -arch flag. - .native => switch (builtin.cpu.arch) { + .native => switch (config.target.result.cpu.arch) { .aarch64 => "arm64", .x86_64 => "x86_64", else => @panic("unsupported macOS arch"), diff --git a/src/build/LibtoolStep.zig b/src/build/LibtoolStep.zig index d2b5149275f..a1ee08bfc90 100644 --- a/src/build/LibtoolStep.zig +++ b/src/build/LibtoolStep.zig @@ -29,9 +29,48 @@ output: LazyPath, /// static library. pub fn create(b: *std.Build, opts: Options) *LibtoolStep { const self = b.allocator.create(LibtoolStep) catch @panic("OOM"); + const env = std.process.getEnvMap(b.allocator) catch @panic("OOM"); const run_step = RunStep.create(b, b.fmt("libtool {s}", .{opts.name})); - run_step.addArgs(&.{ "libtool", "-static", "-o" }); + const env_map = b.allocator.create(std.process.EnvMap) catch @panic("OOM"); + env_map.* = .init(b.allocator); + if (env.get("PATH")) |path| env_map.put("PATH", path) catch @panic("OOM"); + run_step.env_map = env_map; + run_step.addArgs(&.{ + "/bin/sh", + "-c", + \\set -euo pipefail + \\out="$1" + \\shift + \\tmp="$(mktemp -d "${TMPDIR:-/tmp}/libtool-step.XXXXXX")" + \\cleanup() { rm -rf "$tmp"; } + \\trap cleanup EXIT + \\filelist="$tmp/objects.txt" + \\: > "$filelist" + \\index=0 + \\for source in "$@"; do + \\ ext="${source##*.}" + \\ if [ "$ext" = "o" ]; then + \\ printf '%s\n' "$source" >> "$filelist" + \\ else + \\ dir="$tmp/$index" + \\ mkdir -p "$dir" + \\ cp "$source" "$dir/input.a" + \\ ( + \\ cd "$dir" + \\ ar -x input.a + \\ ) + \\ find "$dir" -type f -name '*.o' -exec chmod u+r {} + + \\ find "$dir" -type f -name '*.o' | LC_ALL=C sort >> "$filelist" + \\ fi + \\ index=$((index + 1)) + \\done + \\mkdir -p "$(dirname "$out")" + \\/usr/bin/libtool -static -filelist "$filelist" -o "$out" + \\/usr/bin/ranlib "$out" + , + "libtool-step", + }); const output = run_step.addOutputFileArg(opts.out_name); for (opts.sources) |source| run_step.addFileArg(source); diff --git a/src/build/LipoStep.zig b/src/build/LipoStep.zig index c9c1530efa3..57d4bc2ce2a 100644 --- a/src/build/LipoStep.zig +++ b/src/build/LipoStep.zig @@ -26,8 +26,13 @@ output: LazyPath, pub fn create(b: *std.Build, opts: Options) *LipoStep { const self = b.allocator.create(LipoStep) catch @panic("OOM"); + const env = std.process.getEnvMap(b.allocator) catch @panic("OOM"); const run_step = RunStep.create(b, b.fmt("lipo {s}", .{opts.name})); + const env_map = b.allocator.create(std.process.EnvMap) catch @panic("OOM"); + env_map.* = .init(b.allocator); + if (env.get("PATH")) |path| env_map.put("PATH", path) catch @panic("OOM"); + run_step.env_map = env_map; run_step.addArgs(&.{ "lipo", "-create", "-output" }); const output = run_step.addOutputFileArg(opts.out_name); run_step.addFileArg(opts.input_a); diff --git a/src/build/MetallibStep.zig b/src/build/MetallibStep.zig index fcf3055f8c7..efe9966d2bc 100644 --- a/src/build/MetallibStep.zig +++ b/src/build/MetallibStep.zig @@ -22,6 +22,7 @@ step: *Step, output: LazyPath, pub fn create(b: *std.Build, opts: Options) ?*MetallibStep { + const env = std.process.getEnvMap(b.allocator) catch @panic("OOM"); const sdk = switch (opts.target.result.os.tag) { .macos => "macosx", .ios => switch (opts.target.result.abi) { @@ -55,6 +56,10 @@ pub fn create(b: *std.Build, opts: Options) ?*MetallibStep { b, b.fmt("metal {s}", .{opts.name}), ); + const run_ir_env = b.allocator.create(std.process.EnvMap) catch @panic("OOM"); + run_ir_env.* = .init(b.allocator); + if (env.get("PATH")) |path| run_ir_env.put("PATH", path) catch @panic("OOM"); + run_ir.env_map = run_ir_env; run_ir.addArgs(&.{ "/usr/bin/xcrun", "-sdk", sdk, "metal", "-o" }); const output_ir = run_ir.addOutputFileArg(b.fmt("{s}.ir", .{opts.name})); run_ir.addArgs(&.{"-c"}); @@ -70,6 +75,10 @@ pub fn create(b: *std.Build, opts: Options) ?*MetallibStep { b, b.fmt("metallib {s}", .{opts.name}), ); + const run_lib_env = b.allocator.create(std.process.EnvMap) catch @panic("OOM"); + run_lib_env.* = .init(b.allocator); + if (env.get("PATH")) |path| run_lib_env.put("PATH", path) catch @panic("OOM"); + run_lib.env_map = run_lib_env; run_lib.addArgs(&.{ "/usr/bin/xcrun", "-sdk", sdk, "metallib", "-o" }); const output_lib = run_lib.addOutputFileArg(b.fmt("{s}.metallib", .{opts.name})); run_lib.addFileArg(output_ir); diff --git a/src/build/XCFrameworkStep.zig b/src/build/XCFrameworkStep.zig index 39f0f9bacca..741b8abd588 100644 --- a/src/build/XCFrameworkStep.zig +++ b/src/build/XCFrameworkStep.zig @@ -35,6 +35,7 @@ step: *Step, pub fn create(b: *std.Build, opts: Options) *XCFrameworkStep { const self = b.allocator.create(XCFrameworkStep) catch @panic("OOM"); + const env = std.process.getEnvMap(b.allocator) catch @panic("OOM"); // We have to delete the old xcframework first since we're writing // to a static path. @@ -49,6 +50,10 @@ pub fn create(b: *std.Build, opts: Options) *XCFrameworkStep { const run_create = run: { const run = RunStep.create(b, b.fmt("xcframework {s}", .{opts.name})); run.has_side_effects = true; + const env_map = b.allocator.create(std.process.EnvMap) catch @panic("OOM"); + env_map.* = .init(b.allocator); + if (env.get("PATH")) |path| env_map.put("PATH", path) catch @panic("OOM"); + run.env_map = env_map; run.addArgs(&.{ "xcodebuild", "-create-xcframework" }); for (opts.libraries) |lib| { run.addArg("-library"); diff --git a/src/input.zig b/src/input.zig index bad3ac1f34a..90134ae24d6 100644 --- a/src/input.zig +++ b/src/input.zig @@ -13,6 +13,7 @@ pub const keycodes = @import("input/keycodes.zig"); pub const key_encode = @import("input/key_encode.zig"); pub const kitty = @import("input/kitty.zig"); pub const paste = @import("input/paste.zig"); +pub const text = @import("input/text.zig"); pub const ctrlOrSuper = key.ctrlOrSuper; pub const Action = key.Action; diff --git a/src/renderer/Metal.zig b/src/renderer/Metal.zig index a3ba15c2248..3ce245fed0b 100644 --- a/src/renderer/Metal.zig +++ b/src/renderer/Metal.zig @@ -261,6 +261,18 @@ pub fn initTarget(self: *const Metal, width: usize, height: usize) !Target { /// Present the provided target. pub inline fn present(self: *Metal, target: Target, sync: bool) !void { + if (comptime builtin.os.tag == .ios) { + log.warn( + "ios present sync={} surface={}x{} layer_bounds={}", + .{ + sync, + target.surface.getWidth(), + target.surface.getHeight(), + self.layer.layer.getProperty(graphics.Rect, "bounds"), + }, + ); + } + // Most of the time we want top-left gravity to avoid stretching/jank. self.layer.layer.setProperty("contentsGravity", macos.animation.kCAGravityTopLeft); @@ -280,6 +292,17 @@ pub inline fn present(self: *Metal, target: Target, sync: bool) !void { pub inline fn presentLastTarget(self: *Metal) !void { const surface = self.last_surface orelse return; + if (comptime builtin.os.tag == .ios) { + log.warn( + "ios presentLastTarget surface={}x{} layer_bounds={}", + .{ + surface.getWidth(), + surface.getHeight(), + self.layer.layer.getProperty(graphics.Rect, "bounds"), + }, + ); + } + // Keep top-left gravity during resize replay so stale surfaces never stretch. // Newly exposed regions use the layer background until a correctly sized frame arrives. self.layer.layer.setProperty("contentsGravity", macos.animation.kCAGravityTopLeft); diff --git a/src/renderer/generic.zig b/src/renderer/generic.zig index e0d8a4dd67a..70d66dfe119 100644 --- a/src/renderer/generic.zig +++ b/src/renderer/generic.zig @@ -1497,6 +1497,20 @@ pub fn Renderer(comptime GraphicsAPI: type) type { // blank flash. To avoid this, satisfy the synchronous display by re-presenting the // last completed frame and let the normal render loop catch up on the next tick. if (sync and size_changed and self.has_presented.load(.monotonic)) { + if (comptime builtin.os.tag == .ios) { + log.warn( + "ios drawFrame early presentLastTarget size_changed surface={}x{} grid={}x{} cells={}x{} has_presented={}", + .{ + surface_size.width, + surface_size.height, + self.size.grid().columns, + self.size.grid().rows, + self.cells.size.columns, + self.cells.size.rows, + self.has_presented.load(.monotonic), + }, + ); + } try self.api.presentLastTarget(); return; } @@ -1520,6 +1534,19 @@ pub fn Renderer(comptime GraphicsAPI: type) type { if (expected_grid.columns != self.cells.size.columns or expected_grid.rows != self.cells.size.rows) { + if (comptime builtin.os.tag == .ios) { + log.warn( + "ios drawFrame wait_cells surface={}x{} expected={}x{} cells={}x{}", + .{ + surface_size.width, + surface_size.height, + expected_grid.columns, + expected_grid.rows, + self.cells.size.columns, + self.cells.size.rows, + }, + ); + } try self.api.presentLastTarget(); return; } @@ -1534,6 +1561,18 @@ pub fn Renderer(comptime GraphicsAPI: type) type { sync; if (!needs_redraw) { + if (comptime builtin.os.tag == .ios) { + log.warn( + "ios drawFrame no_redraw surface={}x{} size_changed={} cells_rebuilt={} sync={}", + .{ + surface_size.width, + surface_size.height, + size_changed, + self.cells_rebuilt, + sync, + }, + ); + } // We still need to present the last target again, because the // apprt may be swapping buffers and display an outdated frame // if we don't draw something new. diff --git a/src/renderer/metal/IOSurfaceLayer.zig b/src/renderer/metal/IOSurfaceLayer.zig index 93bc99b2db8..1b2e817ee84 100644 --- a/src/renderer/metal/IOSurfaceLayer.zig +++ b/src/renderer/metal/IOSurfaceLayer.zig @@ -137,13 +137,17 @@ fn setSurfaceCallback( const width: usize = @intFromFloat(bounds.size.width * scale); const height: usize = @intFromFloat(bounds.size.height * scale); 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}", + log.warn( + "ios setSurfaceCallback discard surface={}x{} layer={}x{}", .{ surface.getWidth(), surface.getHeight(), width, height }, ); return; } + log.warn( + "ios setSurfaceCallback present surface={}x{} layer={}x{}", + .{ surface.getWidth(), surface.getHeight(), width, height }, + ); layer.setProperty("contents", surface); } @@ -154,6 +158,10 @@ fn setSurfaceUncheckedCallback( const surface: *IOSurface = block.surface; defer surface.release(); + log.warn( + "ios setSurfaceUncheckedCallback present surface={}x{}", + .{ surface.getWidth(), surface.getHeight() }, + ); layer.setProperty("contents", surface); } diff --git a/src/termio/Manual.zig b/src/termio/Manual.zig index b20be15817d..675cbab7e7d 100644 --- a/src/termio/Manual.zig +++ b/src/termio/Manual.zig @@ -94,13 +94,13 @@ pub const ThreadData = struct { test "manual queueWrite linefeed conversion" { const testing = std.testing; - var out = std.ArrayList(u8).init(testing.allocator); - defer out.deinit(); + var out: std.ArrayList(u8) = .empty; + defer out.deinit(testing.allocator); const cb = struct { fn write(ud: ?*anyopaque, ptr: [*]const u8, len: usize) callconv(.c) void { const list: *std.ArrayList(u8) = @ptrCast(@alignCast(ud.?)); - _ = list.appendSlice(ptr[0..len]) catch {}; + _ = list.appendSlice(std.testing.allocator, ptr[0..len]) catch {}; } }.write; @@ -111,3 +111,23 @@ test "manual queueWrite linefeed conversion" { try manual.queueWrite(testing.allocator, &td, "a\rb", true); try testing.expectEqualStrings("a\r\nb", out.items); } + +test "manual queueWrite passes raw bytes through when linefeed is disabled" { + const testing = std.testing; + var out: std.ArrayList(u8) = .empty; + defer out.deinit(testing.allocator); + + const cb = struct { + fn write(ud: ?*anyopaque, ptr: [*]const u8, len: usize) callconv(.c) void { + const list: *std.ArrayList(u8) = @ptrCast(@alignCast(ud.?)); + _ = list.appendSlice(std.testing.allocator, ptr[0..len]) catch {}; + } + }.write; + + var manual = try Manual.init(testing.allocator, .{ .write_cb = cb, .write_userdata = &out }); + defer manual.deinit(); + + var td: termio.Termio.ThreadData = undefined; + try manual.queueWrite(testing.allocator, &td, "a\rb", false); + try testing.expectEqualStrings("a\rb", out.items); +} From ff171a15f2f49571954abebdd4dba448022cfb49 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Mon, 30 Mar 2026 19:11:25 -0700 Subject: [PATCH 04/13] fix: add missing text.zig for iOS text input handling --- src/input/text.zig | 53 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 src/input/text.zig diff --git a/src/input/text.zig b/src/input/text.zig new file mode 100644 index 00000000000..f12aa077adf --- /dev/null +++ b/src/input/text.zig @@ -0,0 +1,53 @@ +const std = @import("std"); + +/// Encode committed text input for terminal delivery. +/// +/// This differs from paste encoding: +/// - no bracketed paste wrappers +/// - no control-byte stripping +/// - `\n` is normalized to `\r` to match Enter key semantics +pub fn encode( + data: anytype, +) switch (@TypeOf(data)) { + []u8 => []const u8, + []const u8 => Error![]const u8, + else => unreachable, +} { + const mutable = @TypeOf(data) == []u8; + + if (comptime mutable) { + std.mem.replaceScalar(u8, data, '\n', '\r'); + return data; + } + + if (std.mem.indexOfScalar(u8, data, '\n') != null) { + return Error.MutableRequired; + } + + return data; +} + +pub const Error = error{ + MutableRequired, +}; + +test "encode committed text without newlines" { + const testing = std.testing; + const result = try encode(@as([]const u8, "hello")); + try testing.expectEqualStrings("hello", result); +} + +test "encode committed text with newline const" { + const testing = std.testing; + try testing.expectError(Error.MutableRequired, encode( + @as([]const u8, "hello\nworld"), + )); +} + +test "encode committed text with newline mutable" { + const testing = std.testing; + const data: []u8 = try testing.allocator.dupe(u8, "hello\nworld"); + defer testing.allocator.free(data); + const result = encode(data); + try testing.expectEqualStrings("hello\rworld", result); +} From bb5b5706793373e2680afe4b580c2b6956c6370e Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Mon, 30 Mar 2026 21:29:00 -0700 Subject: [PATCH 05/13] fix: drain renderer mailbox in embedded draw to apply config changes On the embedded apprt (iOS), the renderer thread's main loop is never started, so the mailbox is never drained. Config changes pushed via ghostty_surface_update_config sit in the mailbox indefinitely. This means background color, palette, and font changes from config files are never applied to the renderer. Fix by draining the mailbox at the start of each draw call in embedded mode. This processes pending config changes, visibility updates, and other messages before rendering the frame. --- src/apprt/embedded.zig | 6 ++++++ src/renderer/Thread.zig | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index 45bfe88793d..317350c513c 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -790,6 +790,12 @@ pub const Surface = struct { } pub fn draw(self: *Surface) void { + // In embedded mode, the render thread's main loop is not running, + // so we need to drain the mailbox here to process pending messages + // like config changes (background color, font, etc). + self.core_surface.renderer_thread.drainMailbox() catch |err| { + log.err("error draining renderer mailbox err={}", .{err}); + }; self.core_surface.draw() catch |err| { log.err("error in draw err={}", .{err}); return; diff --git a/src/renderer/Thread.zig b/src/renderer/Thread.zig index 8ce77acb533..e127dba7cd5 100644 --- a/src/renderer/Thread.zig +++ b/src/renderer/Thread.zig @@ -334,7 +334,7 @@ fn syncDrawTimer(self: *Thread) void { } /// Drain the mailbox. -fn drainMailbox(self: *Thread) !void { +pub fn drainMailbox(self: *Thread) !void { // There's probably a more elegant way to do this... // // This is effectively an @autoreleasepool{} block, which we need in From 9e177565c88a6f7ef41cf5da4cb6e6133fb72614 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Mon, 30 Mar 2026 21:40:10 -0700 Subject: [PATCH 06/13] fix: apply renderer config directly in embedded updateConfig Instead of draining the full mailbox (which requires thread state that isn't initialized in embedded mode), apply the renderer DerivedConfig directly when ghostty_surface_update_config is called. This ensures background color, palette, and font changes take effect immediately without depending on the render thread's main loop. --- src/apprt/embedded.zig | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index 317350c513c..3d1dc85cb80 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -790,12 +790,6 @@ pub const Surface = struct { } pub fn draw(self: *Surface) void { - // In embedded mode, the render thread's main loop is not running, - // so we need to drain the mailbox here to process pending messages - // like config changes (background color, font, etc). - self.core_surface.renderer_thread.drainMailbox() catch |err| { - log.err("error draining renderer mailbox err={}", .{err}); - }; self.core_surface.draw() catch |err| { log.err("error in draw err={}", .{err}); return; @@ -1626,9 +1620,26 @@ pub const CAPI = struct { surface: *Surface, config: *const Config, ) void { + // In embedded mode, the renderer thread's mailbox is never drained + // (threadMain is not started). Apply config directly to the renderer + // instead of going through the mailbox. + const alloc = surface.core_surface.alloc; + var renderer_config = renderer.Renderer.DerivedConfig.init( + alloc, + config, + ) catch |err| { + log.err("error creating renderer config err={}", .{err}); + return; + }; + surface.core_surface.renderer.changeConfig(&renderer_config) catch |err| { + log.err("error applying renderer config err={}", .{err}); + renderer_config.deinit(); + return; + }; + + // Also update via the normal path for non-renderer state surface.core_surface.updateConfig(config) catch |err| { log.err("error updating config err={}", .{err}); - return; }; } From 093c9d7c587a3904657cc67c59a8fe5c6b23c11d Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Mon, 30 Mar 2026 21:55:53 -0700 Subject: [PATCH 07/13] revert: remove direct renderer config and mailbox drain The renderer's changeConfig and drainMailbox crash in embedded mode because GPU resources aren't initialized for the render thread. Revert to simple updateConfig which pushes to mailbox. The initial surface creation should already have the right config from the app. --- src/apprt/embedded.zig | 19 +------------------ src/renderer/Thread.zig | 2 +- 2 files changed, 2 insertions(+), 19 deletions(-) diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index 3d1dc85cb80..45bfe88793d 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -1620,26 +1620,9 @@ pub const CAPI = struct { surface: *Surface, config: *const Config, ) void { - // In embedded mode, the renderer thread's mailbox is never drained - // (threadMain is not started). Apply config directly to the renderer - // instead of going through the mailbox. - const alloc = surface.core_surface.alloc; - var renderer_config = renderer.Renderer.DerivedConfig.init( - alloc, - config, - ) catch |err| { - log.err("error creating renderer config err={}", .{err}); - return; - }; - surface.core_surface.renderer.changeConfig(&renderer_config) catch |err| { - log.err("error applying renderer config err={}", .{err}); - renderer_config.deinit(); - return; - }; - - // Also update via the normal path for non-renderer state surface.core_surface.updateConfig(config) catch |err| { log.err("error updating config err={}", .{err}); + return; }; } diff --git a/src/renderer/Thread.zig b/src/renderer/Thread.zig index e127dba7cd5..8ce77acb533 100644 --- a/src/renderer/Thread.zig +++ b/src/renderer/Thread.zig @@ -334,7 +334,7 @@ fn syncDrawTimer(self: *Thread) void { } /// Drain the mailbox. -pub fn drainMailbox(self: *Thread) !void { +fn drainMailbox(self: *Thread) !void { // There's probably a more elegant way to do this... // // This is effectively an @autoreleasepool{} block, which we need in From 8a693da8e0ff053c9dfc61e7aeabf4da54207e6b Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Tue, 31 Mar 2026 03:08:21 -0700 Subject: [PATCH 08/13] debug: log bg_color value during drawFrame to diagnose color issue --- src/renderer/generic.zig | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/renderer/generic.zig b/src/renderer/generic.zig index 70d66dfe119..9a7a6114681 100644 --- a/src/renderer/generic.zig +++ b/src/renderer/generic.zig @@ -1405,6 +1405,16 @@ pub fn Renderer(comptime GraphicsAPI: type) type { } // Update our background color + if (self.terminal_state.colors.background.r != 0 or + self.terminal_state.colors.background.g != 0 or + self.terminal_state.colors.background.b != 0) + { + log.warn("bg_color from terminal: r={} g={} b={}", .{ + self.terminal_state.colors.background.r, + self.terminal_state.colors.background.g, + self.terminal_state.colors.background.b, + }); + } self.uniforms.bg_color = .{ self.terminal_state.colors.background.r, self.terminal_state.colors.background.g, From 0b64ad08169cacd2ae731cc386804f895491bc68 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Tue, 31 Mar 2026 03:17:00 -0700 Subject: [PATCH 09/13] debug: unconditional bg_color log with both terminal and config values --- src/renderer/generic.zig | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/src/renderer/generic.zig b/src/renderer/generic.zig index 9a7a6114681..a987f36696e 100644 --- a/src/renderer/generic.zig +++ b/src/renderer/generic.zig @@ -1405,16 +1405,14 @@ pub fn Renderer(comptime GraphicsAPI: type) type { } // Update our background color - if (self.terminal_state.colors.background.r != 0 or - self.terminal_state.colors.background.g != 0 or - self.terminal_state.colors.background.b != 0) - { - log.warn("bg_color from terminal: r={} g={} b={}", .{ - self.terminal_state.colors.background.r, - self.terminal_state.colors.background.g, - self.terminal_state.colors.background.b, - }); - } + log.warn("bg_color: terminal=({},{},{}) config=({},{},{})", .{ + self.terminal_state.colors.background.r, + self.terminal_state.colors.background.g, + self.terminal_state.colors.background.b, + self.config.background.r, + self.config.background.g, + self.config.background.b, + }); self.uniforms.bg_color = .{ self.terminal_state.colors.background.r, self.terminal_state.colors.background.g, From 504b27a2ee69c9c0583213882acf0a86870fd267 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Tue, 31 Mar 2026 03:27:44 -0700 Subject: [PATCH 10/13] fix: fall back to config background when terminal state has default black On iOS embedded, the terminal state colors may not be initialized from config before the first drawFrame. Fall back to the renderer's config background color when terminal_state.colors.background is all zeros. --- src/renderer/generic.zig | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/src/renderer/generic.zig b/src/renderer/generic.zig index a987f36696e..af1cd256edd 100644 --- a/src/renderer/generic.zig +++ b/src/renderer/generic.zig @@ -1404,19 +1404,19 @@ pub fn Renderer(comptime GraphicsAPI: type) type { self.scrollbar_dirty = true; } - // Update our background color - log.warn("bg_color: terminal=({},{},{}) config=({},{},{})", .{ - self.terminal_state.colors.background.r, - self.terminal_state.colors.background.g, - self.terminal_state.colors.background.b, - self.config.background.r, - self.config.background.g, - self.config.background.b, - }); + // Update our background color. + // Use terminal state if it has a non-default color, otherwise + // fall back to the config background (for embedded apps where + // terminal_state may not have been updated yet). + const ts_bg = self.terminal_state.colors.background; + const bg = if (ts_bg.r != 0 or ts_bg.g != 0 or ts_bg.b != 0) + ts_bg + else + self.config.background; self.uniforms.bg_color = .{ - self.terminal_state.colors.background.r, - self.terminal_state.colors.background.g, - self.terminal_state.colors.background.b, + bg.r, + bg.g, + bg.b, @intFromFloat(@round(self.config.background_opacity * 255.0)), }; From 5eb5833149db25f0cecf0defc6dffd7ac6c954b8 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Tue, 31 Mar 2026 04:14:30 -0700 Subject: [PATCH 11/13] experiment: hardcode red background to test shader pipeline --- src/renderer/generic.zig | 17 ++--------------- 1 file changed, 2 insertions(+), 15 deletions(-) diff --git a/src/renderer/generic.zig b/src/renderer/generic.zig index af1cd256edd..c933ec4514d 100644 --- a/src/renderer/generic.zig +++ b/src/renderer/generic.zig @@ -1404,21 +1404,8 @@ pub fn Renderer(comptime GraphicsAPI: type) type { self.scrollbar_dirty = true; } - // Update our background color. - // Use terminal state if it has a non-default color, otherwise - // fall back to the config background (for embedded apps where - // terminal_state may not have been updated yet). - const ts_bg = self.terminal_state.colors.background; - const bg = if (ts_bg.r != 0 or ts_bg.g != 0 or ts_bg.b != 0) - ts_bg - else - self.config.background; - self.uniforms.bg_color = .{ - bg.r, - bg.g, - bg.b, - @intFromFloat(@round(self.config.background_opacity * 255.0)), - }; + // EXPERIMENT: hardcode red background to test if the shader works at all + self.uniforms.bg_color = .{ 255, 0, 0, 255 }; // If we're on macOS and have glass styles, we remove // the background opacity because the glass effect handles From 88b9fc6ff24cce4c4077a0bdd1febdd5448c589b Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Tue, 31 Mar 2026 04:23:13 -0700 Subject: [PATCH 12/13] fix: skip iOS early-return guards in drawFrame to allow initial render On iOS embedded, the size_changed and stale-grid guards in drawFrame call presentLastTarget() and return before the bg_color shader runs. This means the background color from config is never rendered, showing black forever. Skip these guards on iOS so the full render pipeline runs and the bg_color uniform actually gets painted. --- src/renderer/generic.zig | 49 +++++++++++++++------------------------- 1 file changed, 18 insertions(+), 31 deletions(-) diff --git a/src/renderer/generic.zig b/src/renderer/generic.zig index c933ec4514d..92437487a2c 100644 --- a/src/renderer/generic.zig +++ b/src/renderer/generic.zig @@ -1404,8 +1404,13 @@ pub fn Renderer(comptime GraphicsAPI: type) type { self.scrollbar_dirty = true; } - // EXPERIMENT: hardcode red background to test if the shader works at all - self.uniforms.bg_color = .{ 255, 0, 0, 255 }; + // Update our background color + self.uniforms.bg_color = .{ + self.terminal_state.colors.background.r, + self.terminal_state.colors.background.g, + self.terminal_state.colors.background.b, + @intFromFloat(@round(self.config.background_opacity * 255.0)), + }; // If we're on macOS and have glass styles, we remove // the background opacity because the glass effect handles @@ -1492,22 +1497,13 @@ pub fn Renderer(comptime GraphicsAPI: type) type { // blank flash. To avoid this, satisfy the synchronous display by re-presenting the // last completed frame and let the normal render loop catch up on the next tick. if (sync and size_changed and self.has_presented.load(.monotonic)) { - if (comptime builtin.os.tag == .ios) { - log.warn( - "ios drawFrame early presentLastTarget size_changed surface={}x{} grid={}x{} cells={}x{} has_presented={}", - .{ - surface_size.width, - surface_size.height, - self.size.grid().columns, - self.size.grid().rows, - self.cells.size.columns, - self.cells.size.rows, - self.has_presented.load(.monotonic), - }, - ); + if (comptime builtin.os.tag != .ios) { + // On macOS, present the last frame during resize to avoid flash. + // On iOS (embedded), skip this optimization to ensure the first + // real render runs and applies the background color. + try self.api.presentLastTarget(); + return; } - try self.api.presentLastTarget(); - return; } // During resize/layout transitions, the platform can trigger draws before the IO @@ -1529,21 +1525,12 @@ pub fn Renderer(comptime GraphicsAPI: type) type { if (expected_grid.columns != self.cells.size.columns or expected_grid.rows != self.cells.size.rows) { - if (comptime builtin.os.tag == .ios) { - log.warn( - "ios drawFrame wait_cells surface={}x{} expected={}x{} cells={}x{}", - .{ - surface_size.width, - surface_size.height, - expected_grid.columns, - expected_grid.rows, - self.cells.size.columns, - self.cells.size.rows, - }, - ); + // On iOS (embedded), don't skip the render. The early returns + // prevent the bg_color from ever being painted on first frames. + if (comptime builtin.os.tag != .ios) { + try self.api.presentLastTarget(); + return; } - try self.api.presentLastTarget(); - return; } } From bc0ee3142fe661f7342a9b76d712a417d59d5aae Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Tue, 31 Mar 2026 14:00:40 -0700 Subject: [PATCH 13/13] fix: iOS Metal rendering - IOSurface alignment, layer fixes, cleanup 1. IOSurface 64-byte row alignment for Metal on iOS 2. CAIOSurfaceLayer base class on iOS for compositing 3. 1px size tolerance for Retina floating-point rounding 4. Dynamic contentsScale correction 5. loopEnter setNeedsDisplay to trigger first frame 6. setSurface (main-thread-safe) instead of setSurfaceSync on iOS 7. Proper deinit: clear display callback, remove sublayer 8. loopExit: clear display callback when renderer thread exits 9. Disable CFRelease background thread on iOS 10. Add ghostty_surface_draw_now() C API for CADisplayLink pacing 11. Restore drawFrame early-return guards (needed for correct rendering) --- include/ghostty.h | 1 + pkg/macos/iosurface/iosurface.zig | 13 +++-- src/apprt/embedded.zig | 16 ++++++ src/font/shaper/coretext.zig | 77 +++++++++++++++------------ src/renderer/Metal.zig | 45 +++++++++++----- src/renderer/generic.zig | 17 ++---- src/renderer/metal/IOSurfaceLayer.zig | 50 ++++++++++++----- 7 files changed, 143 insertions(+), 76 deletions(-) diff --git a/include/ghostty.h b/include/ghostty.h index bb8861154f6..10d6b900abe 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -1085,6 +1085,7 @@ bool ghostty_surface_needs_confirm_quit(ghostty_surface_t); bool ghostty_surface_process_exited(ghostty_surface_t); void ghostty_surface_refresh(ghostty_surface_t); void ghostty_surface_draw(ghostty_surface_t); +void ghostty_surface_draw_now(ghostty_surface_t); void ghostty_surface_set_content_scale(ghostty_surface_t, double, double); void ghostty_surface_set_focus(ghostty_surface_t, bool); void ghostty_surface_set_occlusion(ghostty_surface_t, bool); diff --git a/pkg/macos/iosurface/iosurface.zig b/pkg/macos/iosurface/iosurface.zig index 37f8712ba42..42a952376d8 100644 --- a/pkg/macos/iosurface/iosurface.zig +++ b/pkg/macos/iosurface/iosurface.zig @@ -20,6 +20,10 @@ pub const IOSurface = opaque { }; pub fn init(properties: Properties) Allocator.Error!*IOSurface { + // Metal requires IOSurface row bytes to be aligned (64-byte is safe). + const row_bytes: c_int = @intCast( + (@as(usize, @intCast(properties.width)) * @as(usize, @intCast(properties.bytes_per_element)) + 63) & ~@as(usize, 63), + ); var w = try foundation.Number.create(.int, &properties.width); defer w.release(); var h = try foundation.Number.create(.int, &properties.height); @@ -28,6 +32,8 @@ pub const IOSurface = opaque { defer pf.release(); var bpe = try foundation.Number.create(.int, &properties.bytes_per_element); defer bpe.release(); + var rb = try foundation.Number.create(.int, &row_bytes); + defer rb.release(); var properties_dict = try foundation.Dictionary.create( &[_]?*const anyopaque{ @@ -35,8 +41,9 @@ pub const IOSurface = opaque { c.kIOSurfaceHeight, c.kIOSurfacePixelFormat, c.kIOSurfaceBytesPerElement, + c.kIOSurfaceBytesPerRow, }, - &[_]?*const anyopaque{ w, h, pf, bpe }, + &[_]?*const anyopaque{ w, h, pf, bpe, rb }, ); defer properties_dict.release(); @@ -84,14 +91,14 @@ pub const IOSurface = opaque { } pub inline fn lock(self: *IOSurface) void { - c.IOSurfaceLock( + _ = c.IOSurfaceLock( @ptrCast(self), 0, null, ); } pub inline fn unlock(self: *IOSurface) void { - c.IOSurfaceUnlock( + _ = c.IOSurfaceUnlock( @ptrCast(self), 0, null, diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index 45bfe88793d..def0459395f 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -796,6 +796,16 @@ pub const Surface = struct { }; } + /// Request an immediate, non-coalescing frame draw. This notifies the + /// renderer thread's draw_now async, which triggers drawFrame(true) + /// bypassing the normal wakeup coalescing. Intended for use by + /// platform display link callbacks (e.g. iOS CADisplayLink). + pub fn drawNow(self: *Surface) void { + self.core_surface.renderer_thread.draw_now.notify() catch |err| { + log.err("error in draw now err={}", .{err}); + }; + } + pub fn updateContentScale(self: *Surface, x: f64, y: f64) void { // We are an embedded API so the caller can send us all sorts of // garbage. We want to make sure that the float values are valid @@ -1786,6 +1796,12 @@ pub const CAPI = struct { surface.draw(); } + /// Request an immediate frame draw via the non-coalescing draw_now path. + /// This is intended for platform display link callbacks (iOS CADisplayLink). + export fn ghostty_surface_draw_now(surface: *Surface) void { + surface.drawNow(); + } + /// Update the size of a surface. This will trigger resize notifications /// to the pty and the renderer. export fn ghostty_surface_set_size(surface: *Surface, w: u32, h: u32) void { diff --git a/src/font/shaper/coretext.zig b/src/font/shaper/coretext.zig index 5a8a6ccbf42..37a6eea49e9 100644 --- a/src/font/shaper/coretext.zig +++ b/src/font/shaper/coretext.zig @@ -74,9 +74,9 @@ pub const Shaper = struct { /// Dedicated thread for releasing CoreFoundation objects. Some objects, /// such as those produced by CoreText, have excessively slow release - /// callback logic. - cf_release_thread: *CFReleaseThread, - cf_release_thr: std.Thread, + /// callback logic. Disabled on iOS where the thread can cause issues. + cf_release_thread: ?*CFReleaseThread, + cf_release_thr: ?std.Thread, const CellBuf = std.ArrayListUnmanaged(font.shape.Cell); const CodepointList = std.ArrayListUnmanaged(Codepoint); @@ -201,19 +201,25 @@ pub const Shaper = struct { }; errdefer typesetter_attr_dict.release(); - // 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 {}; + // Create the CF release thread (disabled on iOS where it can cause + // threading issues; CF objects are released inline instead). + var cf_release_thread: ?*CFReleaseThread = null; + var cf_release_thr: ?std.Thread = null; + if (comptime builtin.os.tag != .ios) { + var cf_thread = try alloc.create(CFReleaseThread); + errdefer alloc.destroy(cf_thread); + cf_thread.* = try .init(alloc); + errdefer cf_thread.deinit(); + + var thread = try std.Thread.spawn( + .{}, + CFReleaseThread.threadMain, + .{cf_thread}, + ); + thread.setName("cf_release") catch {}; + cf_release_thread = cf_thread; + cf_release_thr = thread; + } return .{ .alloc = alloc, @@ -258,13 +264,13 @@ pub const Shaper = struct { self.cf_release_pool.deinit(self.alloc); // Stop the CF release thread - { - self.cf_release_thread.stop.notify() catch |err| + if (self.cf_release_thread) |thread| { + 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_thr.?.join(); + thread.deinit(); + self.alloc.destroy(thread); } - self.cf_release_thread.deinit(); - self.alloc.destroy(self.cf_release_thread); } pub fn endFrame(self: *Shaper) void { @@ -279,20 +285,21 @@ pub const Shaper = struct { return; }; - // 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; + // Send the items to the release thread if available. If no thread + // (iOS), or the send fails, do a manual inline cleanup. + if (self.cf_release_thread) |thread| { + if (thread.mailbox.push(.{ .release = .{ + .refs = items, + .alloc = self.alloc, + } }, .{ .forever = {} }) != 0) { + 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); diff --git a/src/renderer/Metal.zig b/src/renderer/Metal.zig index 3ce245fed0b..aa8adbecb18 100644 --- a/src/renderer/Metal.zig +++ b/src/renderer/Metal.zig @@ -165,6 +165,17 @@ pub fn init(alloc: Allocator, opts: rendererpkg.Options) !Metal { } pub fn deinit(self: *Metal) void { + // Clear the display callback before releasing the layer. On iOS, the + // IOSurfaceLayer is a sublayer that may outlive this Metal instance. + // If UIKit's compositor calls `display` after we're freed, it would + // dereference a stale pointer -> SIGSEGV. + self.layer.setDisplayCallback(null, null); + + // On iOS, remove our IOSurfaceLayer from the view's layer hierarchy. + if (comptime builtin.os.tag == .ios) { + self.layer.layer.msgSend(void, objc.sel("removeFromSuperlayer"), .{}); + } + if (self.last_surface) |s| s.release(); self.queue.release(); self.device.release(); @@ -177,6 +188,20 @@ pub fn loopEnter(self: *Metal) void { @ptrCast(&displayCallback), @ptrCast(renderer), ); + + // On iOS, the layer is a sublayer whose bounds are already set before + // the display callback is registered. Force the first display so that + // the initial frame is rendered. + if (comptime builtin.os.tag == .ios) { + self.layer.layer.msgSend(void, objc.sel("setNeedsDisplay"), .{}); + } +} + +/// Called when the renderer thread exits its main loop. Clear the display +/// callback to prevent use-after-free if UIKit's compositor calls `display` +/// on the layer after the renderer is torn down. +pub fn loopExit(self: *Metal) void { + self.layer.setDisplayCallback(null, null); } fn displayCallback(renderer: *Renderer) align(8) void { @@ -261,18 +286,6 @@ pub fn initTarget(self: *const Metal, width: usize, height: usize) !Target { /// Present the provided target. pub inline fn present(self: *Metal, target: Target, sync: bool) !void { - if (comptime builtin.os.tag == .ios) { - log.warn( - "ios present sync={} surface={}x{} layer_bounds={}", - .{ - sync, - target.surface.getWidth(), - target.surface.getHeight(), - self.layer.layer.getProperty(graphics.Rect, "bounds"), - }, - ); - } - // Most of the time we want top-left gravity to avoid stretching/jank. self.layer.layer.setProperty("contentsGravity", macos.animation.kCAGravityTopLeft); @@ -281,6 +294,14 @@ pub inline fn present(self: *Metal, target: Target, sync: bool) !void { target.surface.retain(); self.last_surface = target.surface; + // On iOS, always go through setSurface so we stay on the main thread. + // setSurface dispatches to the main thread if needed and executes + // synchronously if already there. + if (comptime builtin.os.tag == .ios) { + try self.layer.setSurface(target.surface); + return; + } + if (sync) { self.layer.setSurfaceSync(target.surface); } else { diff --git a/src/renderer/generic.zig b/src/renderer/generic.zig index 92437487a2c..7303c750f3a 100644 --- a/src/renderer/generic.zig +++ b/src/renderer/generic.zig @@ -1497,13 +1497,8 @@ pub fn Renderer(comptime GraphicsAPI: type) type { // blank flash. To avoid this, satisfy the synchronous display by re-presenting the // last completed frame and let the normal render loop catch up on the next tick. if (sync and size_changed and self.has_presented.load(.monotonic)) { - if (comptime builtin.os.tag != .ios) { - // On macOS, present the last frame during resize to avoid flash. - // On iOS (embedded), skip this optimization to ensure the first - // real render runs and applies the background color. - try self.api.presentLastTarget(); - return; - } + try self.api.presentLastTarget(); + return; } // During resize/layout transitions, the platform can trigger draws before the IO @@ -1525,12 +1520,8 @@ pub fn Renderer(comptime GraphicsAPI: type) type { if (expected_grid.columns != self.cells.size.columns or expected_grid.rows != self.cells.size.rows) { - // On iOS (embedded), don't skip the render. The early returns - // prevent the bg_color from ever being painted on first frames. - if (comptime builtin.os.tag != .ios) { - try self.api.presentLastTarget(); - return; - } + try self.api.presentLastTarget(); + return; } } diff --git a/src/renderer/metal/IOSurfaceLayer.zig b/src/renderer/metal/IOSurfaceLayer.zig index 1b2e817ee84..85a01d8b97f 100644 --- a/src/renderer/metal/IOSurfaceLayer.zig +++ b/src/renderer/metal/IOSurfaceLayer.zig @@ -9,6 +9,8 @@ const macos = @import("macos"); const IOSurface = macos.iosurface.IOSurface; +const builtin = @import("builtin"); + const log = std.log.scoped(.IOSurfaceLayer); /// We subclass CALayer with a custom display handler, we only need @@ -33,6 +35,11 @@ pub fn init() !IOSurfaceLayer { // stretched during resize operations before a new frame has been drawn. layer.setProperty("contentsGravity", macos.animation.kCAGravityTopLeft); + // On iOS, mark the layer opaque for correct compositing and performance. + if (comptime builtin.os.tag == .ios) { + layer.setProperty("opaque", true); + } + layer.setInstanceVariable("display_cb", .{ .value = null }); layer.setInstanceVariable("display_ctx", .{ .value = null }); @@ -136,18 +143,31 @@ fn setSurfaceCallback( const scale = layer.getProperty(f64, "contentsScale"); const width: usize = @intFromFloat(bounds.size.width * scale); const height: usize = @intFromFloat(bounds.size.height * scale); - if (width != surface.getWidth() or height != surface.getHeight()) { - log.warn( - "ios setSurfaceCallback discard surface={}x{} layer={}x{}", - .{ surface.getWidth(), surface.getHeight(), width, height }, - ); - return; + const surface_width = surface.getWidth(); + const surface_height = surface.getHeight(); + const diff_w: usize = if (width > surface_width) width - surface_width else surface_width - width; + const diff_h: usize = if (height > surface_height) height - surface_height else surface_height - height; + // On iOS, allow 1px tolerance for floating-point rounding between UIKit + // points and integer pixel dimensions on Retina displays. + const tolerance: usize = if (comptime builtin.os.tag == .ios) 1 else 0; + if (diff_w > tolerance or diff_h > tolerance) { + // On iOS, try to correct the contentsScale instead of discarding. + if (comptime builtin.os.tag == .ios) { + const bw = bounds.size.width; + const bh = bounds.size.height; + if (bw > 0 and bh > 0) { + const sx: f64 = @as(f64, @floatFromInt(surface_width)) / bw; + const sy: f64 = @as(f64, @floatFromInt(surface_height)) / bh; + const new_scale: f64 = if (sx > sy) sx else sy; + if (@abs(new_scale - scale) > 0.01) { + layer.setProperty("contentsScale", new_scale); + } + } + } else { + return; + } } - log.warn( - "ios setSurfaceCallback present surface={}x{} layer={}x{}", - .{ surface.getWidth(), surface.getHeight(), width, height }, - ); layer.setProperty("contents", surface); } @@ -185,11 +205,15 @@ pub fn setDisplayCallback( fn getSubclass() error{ObjCFailed}!objc.Class { if (Subclass) |c| return c; - const CALayer = - objc.getClass("CALayer") orelse return error.ObjCFailed; + // On iOS, use CAIOSurfaceLayer as base class for better IOSurface integration. + const base_layer = switch (comptime builtin.os.tag) { + .ios => objc.getClass("CAIOSurfaceLayer") orelse + objc.getClass("CALayer") orelse return error.ObjCFailed, + else => objc.getClass("CALayer") orelse return error.ObjCFailed, + }; var subclass = - objc.allocateClassPair(CALayer, "IOSurfaceLayer") orelse return error.ObjCFailed; + objc.allocateClassPair(base_layer, "IOSurfaceLayer") orelse return error.ObjCFailed; errdefer objc.disposeClassPair(subclass); if (!subclass.addIvar("display_cb")) return error.ObjCFailed;