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/include/ghostty.h b/include/ghostty.h index 65b1cdc5a45..10d6b900abe 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 @@ -1084,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); @@ -1098,6 +1100,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 +1136,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/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/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/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..1f3599bc6b1 --- /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()); +} 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..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 @@ -931,6 +941,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 +1704,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 +1756,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(); } @@ -1737,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 { @@ -1851,6 +1916,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/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/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/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); +} diff --git a/src/renderer/Metal.zig b/src/renderer/Metal.zig index a3ba15c2248..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 { @@ -269,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 { @@ -280,6 +313,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..7303c750f3a 100644 --- a/src/renderer/generic.zig +++ b/src/renderer/generic.zig @@ -1534,6 +1534,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..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,12 +143,29 @@ 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.debug( - "setSurfaceCallback(): surface is wrong size for layer, discarding. surface = {d}x{d}, layer = {d}x{d}", - .{ 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; + } } layer.setProperty("contents", surface); @@ -154,6 +178,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); } @@ -177,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; 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); +} 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