From f0eb600e32498d0f6eb2d05c9dba7173b37b1170 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Mon, 24 Aug 2026 10:35:42 -0500 Subject: [PATCH 1/6] Add Swift toolchain integration proof - Compile app-owned Swift sources into macOS app and test artifacts. - Link Swift runtime compatibility inputs with explicit deployment metadata. - Add arm64/x86_64 SwiftUI fixture, packaging, and signing coverage. --- build.zig | 14 ++ build/app.zig | 214 ++++++++++++++++++ tests/swift-toolchain-proof/README.md | 23 ++ tests/swift-toolchain-proof/app.zon | 24 ++ tests/swift-toolchain-proof/build.zig | 56 +++++ tests/swift-toolchain-proof/build.zig.zon | 8 + .../src/NativeView.swift | 21 ++ tests/swift-toolchain-proof/src/main.zig | 67 ++++++ 8 files changed, 427 insertions(+) create mode 100644 tests/swift-toolchain-proof/README.md create mode 100644 tests/swift-toolchain-proof/app.zon create mode 100644 tests/swift-toolchain-proof/build.zig create mode 100644 tests/swift-toolchain-proof/build.zig.zon create mode 100644 tests/swift-toolchain-proof/src/NativeView.swift create mode 100644 tests/swift-toolchain-proof/src/main.zig diff --git a/build.zig b/build.zig index 7f4272216..4623a5d88 100644 --- a/build.zig +++ b/build.zig @@ -46,6 +46,9 @@ pub const AppOptions = @import("build/app.zig").AppOptions; pub const addApp = @import("build/app.zig").addApp; pub const AppArtifacts = @import("build/app.zig").AppArtifacts; pub const addAppArtifacts = @import("build/app.zig").addAppArtifacts; +pub const MacOSDeploymentTarget = @import("build/app.zig").MacOSDeploymentTarget; +pub const SwiftAppSourcesOptions = @import("build/app.zig").SwiftAppSourcesOptions; +pub const addSwiftAppSources = @import("build/app.zig").addSwiftAppSources; pub const MobileLibOptions = @import("build/app.zig").MobileLibOptions; pub const addMobileLib = @import("build/app.zig").addMobileLib; const mobile_export_symbol_names = @import("build/app.zig").mobile_export_symbol_names; @@ -625,6 +628,17 @@ pub fn build(b: *std.Build) void { }; const test_step = b.step("test", "Run package and framework tests"); + const swift_toolchain_proof_step = b.step("test-swift-toolchain-proof", "Build and run the macOS Swift/AppKit toolchain proof fixture"); + if (host_target.result.os.tag == .macos) { + const swift_toolchain_proof = b.addSystemCommand(&.{ "zig", "build", "test", "-Dplatform=macos" }); + swift_toolchain_proof.setCwd(b.path("tests/swift-toolchain-proof")); + swift_toolchain_proof.setEnvironmentVariable("ZIG_GLOBAL_CACHE_DIR", ".zig-cache/global"); + // The child graph owns its own input cache. Always enter it so edits + // to the fixture and helper cannot be hidden by this outer Run step. + swift_toolchain_proof.has_side_effects = true; + swift_toolchain_proof_step.dependOn(&swift_toolchain_proof.step); + test_step.dependOn(&swift_toolchain_proof.step); + } test_step.dependOn(&b.addRunArtifact(build_graph_tests).step); test_step.dependOn(&b.addRunArtifact(geometry_tests).step); test_step.dependOn(&b.addRunArtifact(assets_tests).step); diff --git a/build/app.zig b/build/app.zig index 0ec7a2630..307615811 100644 --- a/build/app.zig +++ b/build/app.zig @@ -1608,6 +1608,220 @@ pub const AppArtifacts = struct { run: *std.Build.Step.Run, }; +/// Explicit deployment floor for Swift sources compiled into a macOS app. +/// Keep this independent from the SDK's own floor: an app may deliberately +/// use a newer Swift/AppKit API surface, and that choice must stay visible at +/// its build boundary. +pub const MacOSDeploymentTarget = struct { + major: u16, + minor: u16 = 0, + patch: u16 = 0, +}; + +/// Phase-1 Swift toolchain integration. Swift sources compile to a plain +/// object for each optimization mode used by the app artifacts; no Xcode +/// project, helper executable, dylib, or post-link rewrite is involved. +pub const SwiftAppSourcesOptions = struct { + sources: []const std.Build.LazyPath, + module_name: []const u8, + frameworks: []const []const u8 = &.{}, + macos_minimum: MacOSDeploymentTarget, +}; + +/// Compile and link app-owned Swift sources into both halves of +/// `AppArtifacts`. This is intentionally the smallest successful Phase-1 +/// seam; Phase 2 owns broader API hardening and diagnostics tests. +pub fn addSwiftAppSources(b: *std.Build, artifacts: AppArtifacts, options: SwiftAppSourcesOptions) void { + if (options.sources.len == 0) @panic("\naddSwiftAppSources needs at least one .swift source\n"); + if (options.module_name.len == 0) @panic("\naddSwiftAppSources needs a non-empty module_name\n"); + if (options.macos_minimum.major == 0) @panic("\naddSwiftAppSources needs a valid non-zero macOS deployment target\n"); + + const target = artifacts.exe.root_module.resolved_target orelse + @panic("\naddSwiftAppSources requires a resolved app target\n"); + if (target.result.os.tag != .macos) { + @panic("\naddSwiftAppSources supports macOS app targets only; remove the Swift sources or select a macOS target\n"); + } + if (b.graph.host.result.os.tag != .macos) { + @panic("\naddSwiftAppSources needs a macOS build host with Xcode; Swift/AppKit sources cannot be compiled from this host\n"); + } + + const sdk = b.sysroot orelse macosSdkPath(b) orelse + @panic("\naddSwiftAppSources could not find the macOS SDK. Install Xcode, select it with `sudo xcode-select -s /Applications/Xcode.app`, and verify `xcrun --sdk macosx --show-sdk-path`.\n"); + const swiftc = findXcrunTool(b, "swiftc") orelse + @panic("\naddSwiftAppSources could not find swiftc. Install Xcode, select it with `sudo xcode-select -s /Applications/Xcode.app`, and verify `xcrun --find swiftc`.\n"); + const swift_usr = std.fs.path.dirname(std.fs.path.dirname(swiftc) orelse "") orelse + @panic("\naddSwiftAppSources found swiftc at an unexpected path; `xcrun --find swiftc` must resolve an Xcode toolchain executable\n"); + const swift_macos_lib = b.pathJoin(&.{ swift_usr, "lib", "swift", "macosx" }); + + const exe_optimize = artifacts.exe.root_module.optimize orelse .Debug; + const test_optimize = artifacts.tests.root_module.optimize orelse .Debug; + const exe_object = compileSwiftObject(b, swiftc, sdk, target, exe_optimize, options); + const test_object = if (test_optimize == exe_optimize) + exe_object + else + compileSwiftObject(b, swiftc, sdk, target, test_optimize, options); + + // TypeScript apps use a link-only executable module over their cached app + // object. Zig-core apps point exe.root_module directly at the app module. + // Tests always own their test app module. Attach Swift to those final-link + // roots so it is added exactly once in either graph shape. + addSwiftLinkInputs(b, artifacts.exe.root_module, exe_object, swift_macos_lib, options.frameworks, exe_optimize); + if (artifacts.tests.root_module != artifacts.exe.root_module) { + addSwiftLinkInputs(b, artifacts.tests.root_module, test_object, swift_macos_lib, options.frameworks, test_optimize); + } +} + +fn findXcrunTool(b: *std.Build, tool: []const u8) ?[]const u8 { + const result = std.process.run(b.allocator, b.graph.io, .{ + .argv = &.{ "xcrun", "--find", tool }, + .stdout_limit = .limited(4096), + .stderr_limit = .limited(4096), + }) catch return null; + defer b.allocator.free(result.stderr); + if (result.term != .exited or result.term.exited != 0) { + b.allocator.free(result.stdout); + return null; + } + return std.mem.trimEnd(u8, result.stdout, "\r\n"); +} + +fn compileSwiftObject( + b: *std.Build, + swiftc: []const u8, + sdk: []const u8, + target: std.Build.ResolvedTarget, + optimize: std.builtin.OptimizeMode, + options: SwiftAppSourcesOptions, +) std.Build.LazyPath { + const triple = swiftMacosTargetTriple(b, target.result.cpu.arch, options.macos_minimum); + const compile = b.addSystemCommand(&.{swiftc}); + compile.addArgs(&.{ + "-parse-as-library", + "-emit-object", + "-whole-module-optimization", + "-module-name", + options.module_name, + "-target", + triple, + "-sdk", + sdk, + "-module-cache-path", + b.pathJoin(&.{ b.cache_root.path orelse ".zig-cache", "native-swift-module-cache" }), + }); + switch (optimize) { + .Debug => compile.addArgs(&.{ "-Onone", "-g" }), + .ReleaseSafe, .ReleaseFast => compile.addArg("-O"), + .ReleaseSmall => compile.addArg("-Osize"), + } + compile.addArg("-o"); + const object = compile.addOutputFileArg(b.fmt("{s}-{s}.o", .{ options.module_name, @tagName(optimize) })); + for (options.sources) |source| compile.addFileArg(source); + return object; +} + +fn swiftMacosTargetTriple(b: *std.Build, arch: std.Target.Cpu.Arch, minimum: MacOSDeploymentTarget) []const u8 { + const arch_name = switch (arch) { + .aarch64 => "arm64", + .x86_64 => "x86_64", + else => @panic("\naddSwiftAppSources supports Apple Silicon (aarch64) and Intel (x86_64) macOS targets only\n"), + }; + return if (minimum.patch == 0) + b.fmt("{s}-apple-macosx{d}.{d}", .{ arch_name, minimum.major, minimum.minor }) + else + b.fmt("{s}-apple-macosx{d}.{d}.{d}", .{ arch_name, minimum.major, minimum.minor, minimum.patch }); +} + +fn addSwiftLinkInputs( + b: *std.Build, + mod: *std.Build.Module, + object: std.Build.LazyPath, + swift_macos_lib: []const u8, + frameworks: []const []const u8, + optimize: std.builtin.OptimizeMode, +) void { + mod.addObjectFile(object); + if (b.sysroot) |sysroot| { + mod.addFrameworkPath(.{ .cwd_relative = b.pathJoin(&.{ sysroot, "System/Library/Frameworks" }) }); + } + // System Swift dylibs have lived at this stable OS path since macOS 10.14. + // The rpath is what Apple's Swift driver itself emits for a macOS link. + mod.addLibraryPath(.{ .cwd_relative = "/usr/lib/swift" }); + // With --sysroot Zig prefixes absolute -L paths into the SDK. The Swift + // object also autolinks libobjc, whose TBD sits at /usr/lib. + mod.addLibraryPath(.{ .cwd_relative = "/usr/lib" }); + mod.addRPath(.{ .cwd_relative = "/usr/lib/swift" }); + + // Swift's Mach-O objects carry LC_LINKER_OPTION records for imported + // overlays. Zig's Mach-O link does not consume those records, so spell + // out the system overlays exercised by SwiftUI/AppKit/Foundation. They + // are TBDs in the macOS SDK and resolve to /usr/lib/swift at runtime; + // none are copied into the app bundle. + const swift_system_libraries = [_][]const u8{ + "swiftCore", + "swiftSwiftOnoneSupport", + "swiftos", + "swiftObjectiveC", + "swift_StringProcessing", + "swift_Concurrency", + "swift_DarwinFoundation1", + "swift_DarwinFoundation2", + "swift_DarwinFoundation3", + "swiftDarwin", + "swift_Builtin_float", + "swiftXPC", + "swiftDispatch", + "swiftUniformTypeIdentifiers", + "swiftFoundation", + "swiftSystem", + "swiftObservation", + "swiftCoreFoundation", + "swiftIOKit", + "swiftsimd", + "swiftQuartzCore", + "swiftMetal", + "swiftOSLog", + "swiftCoreImage", + "swiftSpatial", + }; + for (swift_system_libraries) |library| { + if (optimize != .Debug and std.mem.eql(u8, library, "swiftSwiftOnoneSupport")) continue; + const tbd = b.pathJoin(&.{ b.sysroot.?, "usr", "lib", "swift", b.fmt("lib{s}.tbd", .{library}) }); + std.Io.Dir.cwd().access(b.graph.io, tbd, .{}) catch { + // Swift's imported-module graph varies with Xcode. A library that + // does not exist in this SDK cannot have been selected by this + // toolchain; skip it so the Phase-1 superset spans Xcode releases. + continue; + }; + // Apple's linker records imported-module overlays as weak dylibs; + // that is what lets an object compiled by a newer Xcode run on an + // older deployment target where a newly split overlay does not yet + // exist. The core runtime and Debug support library are the only + // direct, non-overlay dependencies here. + const weak = !std.mem.eql(u8, library, "swiftCore") and + !std.mem.eql(u8, library, "swiftSwiftOnoneSupport"); + mod.linkSystemLibrary(library, .{ .use_pkg_config = .no, .weak = weak }); + } + for (frameworks) |framework| mod.linkFramework(framework, .{}); + + // A macOS 11 deployment still needs these back-deployment shims from the + // active Xcode toolchain. They are static archive inputs, not bundled + // runtime dylibs. libswiftCompatibility56 contains C++ runtime code. + const compatibility_archives = [_][]const u8{ + "libswiftCompatibilityConcurrency.a", + "libswiftCompatibility56.a", + "libswiftCompatibilityPacks.a", + }; + for (compatibility_archives) |archive| { + const path = b.pathJoin(&.{ swift_macos_lib, archive }); + std.Io.Dir.cwd().access(b.graph.io, path, .{}) catch { + std.debug.panic("\naddSwiftAppSources could not find {s}. The selected Xcode Swift toolchain is incomplete or incompatible with this deployment target.\n", .{path}); + }; + mod.addObjectFile(.{ .cwd_relative = path }); + } + mod.link_libcpp = true; + mod.linkSystemLibrary("objc", .{ .use_pkg_config = .no }); +} + pub fn addApp(b: *std.Build, dep: *std.Build.Dependency, app_options: AppOptions) void { _ = addAppArtifacts(b, dep, app_options); } diff --git a/tests/swift-toolchain-proof/README.md b/tests/swift-toolchain-proof/README.md new file mode 100644 index 000000000..c267496e5 --- /dev/null +++ b/tests/swift-toolchain-proof/README.md @@ -0,0 +1,23 @@ +# Swift toolchain proof + +This macOS-only Phase 1 fixture proves that app-owned Swift can cross the C +ABI into an ordinary Native SDK app without an Xcode project, helper process, +or bundled dynamic library. + +`src/NativeView.swift` exports retained `NSHostingView` construction and +release functions with `@_cdecl`. `src/main.zig` calls only those C symbols; +the pointer remains opaque to Zig and ownership stays with the app. + +Run the focused gates from this directory: + +```sh +zig build test -Dplatform=macos +zig build -Doptimize=Debug -Dplatform=macos +./zig-out/bin/swift-toolchain-proof --swift-proof-smoke +zig build signed-package -Dplatform=macos +codesign --verify --deep --strict zig-out/package/swift-toolchain-proof.app +``` + +The fixture currently declares macOS 11.0, matching the SDK deployment floor. +It is exercised with Xcode 26 / Swift 6.2 in Phase 1; the build helper emits an +actionable error when Xcode, the macOS SDK, or `swiftc` is unavailable. diff --git a/tests/swift-toolchain-proof/app.zon b/tests/swift-toolchain-proof/app.zon new file mode 100644 index 000000000..f26002cdd --- /dev/null +++ b/tests/swift-toolchain-proof/app.zon @@ -0,0 +1,24 @@ +.{ + .id = "dev.native_sdk.swift_toolchain_proof", + .name = "swift-toolchain-proof", + .display_name = "Swift Toolchain Proof", + .version = "0.1.0", + .platforms = .{"macos"}, + .permissions = .{}, + .capabilities = .{"native_views"}, + .shell = .{ + .windows = .{ + .{ + .label = "main", + .title = "Swift Toolchain Proof", + .width = 480, + .height = 280, + .restore_policy = "center_on_primary", + .views = .{ + .{ .label = "content", .kind = "stack", .fill = true }, + }, + }, + }, + }, + .web_engine = "system", +} diff --git a/tests/swift-toolchain-proof/build.zig b/tests/swift-toolchain-proof/build.zig new file mode 100644 index 000000000..fe7f55447 --- /dev/null +++ b/tests/swift-toolchain-proof/build.zig @@ -0,0 +1,56 @@ +//! Phase-1 Swift toolchain fixture: the ordinary Native SDK app/test +//! artifacts both consume one app-owned Swift source. The app executable is +//! ReleaseFast by default while its tests are Debug, proving the helper's two +//! independently optimized Swift compiles without introducing Xcode project +//! state or a bundled helper/dylib. +const std = @import("std"); +const native_sdk = @import("native_sdk"); + +pub fn build(b: *std.Build) void { + const dep = b.dependency("native_sdk", .{}); + const artifacts = native_sdk.addAppArtifacts(b, dep, .{ .name = "swift-toolchain-proof" }); + native_sdk.addSwiftAppSources(b, artifacts, .{ + .sources = &.{b.path("src/NativeView.swift")}, + .module_name = "NativeViewHost", + .frameworks = &.{ "SwiftUI", "AppKit", "Foundation" }, + // Match Native SDK's current macOS deployment floor. Raising this is + // an explicit fixture edit, never an ambient Xcode default. + .macos_minimum = .{ .major = 11 }, + }); + // `addAppArtifacts`' test step normally need not build the app binary. + // This proof's whole purpose is to link both independently optimized + // artifacts, so make the ReleaseFast executable part of its test gate. + if (b.top_level_steps.get("test")) |test_step| { + test_step.step.dependOn(&artifacts.exe.step); + } + + // Reproducible ReleaseFast package/sign gate for Phase 1. The standard + // package step remains available; this focused step opts into ad-hoc + // signing and lets codesign's strict verifier inspect the finished app. + const package = b.addRunArtifact(dep.artifact("native")); + package.setEnvironmentVariable("NATIVE_SDK_PATH", dep.builder.pathFromRoot(".")); + package.addArgs(&.{ + "package", + "--target", + "macos", + "--manifest", + "app.zon", + "--output", + "zig-out/package/swift-toolchain-proof.app", + "--binary", + }); + package.addFileArg(artifacts.exe.getEmittedBin()); + package.addArgs(&.{ + "--optimize", + "ReleaseFast", + "--web-layer", + "exclude", + "--web-engine", + "system", + "--signing", + "adhoc", + }); + package.has_side_effects = true; + const signed_package = b.step("signed-package", "Build and ad-hoc sign the ReleaseFast Swift toolchain proof app"); + signed_package.dependOn(&package.step); +} diff --git a/tests/swift-toolchain-proof/build.zig.zon b/tests/swift-toolchain-proof/build.zig.zon new file mode 100644 index 000000000..180b0b64d --- /dev/null +++ b/tests/swift-toolchain-proof/build.zig.zon @@ -0,0 +1,8 @@ +.{ + .name = .swift_toolchain_proof, + .fingerprint = 0xf7a4ffb2f05ece1c, + .version = "0.1.0", + .minimum_zig_version = "0.16.0", + .dependencies = .{ .native_sdk = .{ .path = "../.." } }, + .paths = .{ "build.zig", "build.zig.zon", "src", "app.zon", "README.md" }, +} diff --git a/tests/swift-toolchain-proof/src/NativeView.swift b/tests/swift-toolchain-proof/src/NativeView.swift new file mode 100644 index 000000000..ad5729828 --- /dev/null +++ b/tests/swift-toolchain-proof/src/NativeView.swift @@ -0,0 +1,21 @@ +import AppKit +import SwiftUI + +private struct NativeToolchainProofView: View { + var body: some View { + Text("Native SDK Swift toolchain proof") + .padding(24) + } +} + +/// C ABI boundary consumed by Zig. Ownership is deliberately explicit: +/// creation returns a retained app-owned NSView and the caller releases it. +@_cdecl("native_swift_proof_create_view") +public func nativeSwiftProofCreateView() -> UnsafeMutableRawPointer { + Unmanaged.passRetained(NSHostingView(rootView: NativeToolchainProofView())).toOpaque() +} + +@_cdecl("native_swift_proof_release_view") +public func nativeSwiftProofReleaseView(_ pointer: UnsafeMutableRawPointer) { + Unmanaged.fromOpaque(pointer).release() +} diff --git a/tests/swift-toolchain-proof/src/main.zig b/tests/swift-toolchain-proof/src/main.zig new file mode 100644 index 000000000..ad5c98175 --- /dev/null +++ b/tests/swift-toolchain-proof/src/main.zig @@ -0,0 +1,67 @@ +const std = @import("std"); +const runner = @import("runner"); +const native_sdk = @import("native_sdk"); + +pub const panic = std.debug.FullPanic(native_sdk.debug.capturePanic); + +extern fn native_swift_proof_create_view() callconv(.c) *anyopaque; +extern fn native_swift_proof_release_view(view: *anyopaque) callconv(.c) void; + +const views = [_]native_sdk.ShellView{ + .{ .label = "content", .kind = .stack, .fill = true }, +}; +const windows = [_]native_sdk.ShellWindow{.{ + .label = "main", + .title = "Swift Toolchain Proof", + .width = 480, + .height = 280, + .views = &views, +}}; +const scene: native_sdk.ShellConfig = .{ .windows = &windows }; + +const ProofApp = struct { + fn app(self: *@This()) native_sdk.App { + return .{ + .context = self, + .name = "swift-toolchain-proof", + .scene_fn = sceneFn, + .start_fn = start, + }; + } + + fn sceneFn(_: *anyopaque) anyerror!native_sdk.ShellConfig { + return scene; + } + + fn start(_: *anyopaque, _: *native_sdk.Runtime) anyerror!void { + // Phase 1 proves construction and C-ABI ownership. Adoption into the + // stack is intentionally deferred to the hosted-window phases. + createAndReleaseHostingView(); + } +}; + +fn createAndReleaseHostingView() void { + const view = native_swift_proof_create_view(); + native_swift_proof_release_view(view); +} + +pub fn main(init: std.process.Init) !void { + const args = try init.minimal.args.toSlice(init.arena.allocator()); + if (args.len > 1 and std.mem.eql(u8, args[1], "--swift-proof-smoke")) { + createAndReleaseHostingView(); + std.debug.print("swift-toolchain-proof: NSHostingView created and released\n", .{}); + return; + } + + var proof = ProofApp{}; + try runner.runWithOptions(proof.app(), .{ + .app_name = "swift-toolchain-proof", + .window_title = "Swift Toolchain Proof", + .bundle_id = "dev.native_sdk.swift_toolchain_proof", + .default_frame = native_sdk.geometry.RectF.init(0, 0, 480, 280), + }, init); +} + +test "Swift C ABI returns a retained NSHostingView object" { + createAndReleaseHostingView(); +} From 3d96032dc513c83fd5757e4a32afaadcd3b3acb0 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Mon, 24 Aug 2026 11:27:53 -0500 Subject: [PATCH 2/6] Harden Swift app toolchain integration --- .github/workflows/ci.yml | 11 + build.zig | 2 +- build/app.zig | 318 +++++++++++++----- tests/swift-toolchain-proof/README.md | 10 +- tests/swift-toolchain-proof/build.zig | 18 +- .../src/NativeView.swift | 7 +- 6 files changed, 272 insertions(+), 94 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 09c142e6f..b3437a083 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -81,6 +81,17 @@ jobs: # codesign --verify --strict (macOS runners are the only tier with # codesign; the step skips loudly anywhere else). - run: zig build test-package-signing + # App-owned Swift must link through Zig for both app/test modes, retain + # one macOS deployment floor, execute its C ABI, and survive packaging. + - name: Swift toolchain integration + run: | + zig build test-swift-toolchain-proof + cd tests/swift-toolchain-proof + xcrun vtool -show-build zig-out/bin/swift-toolchain-proof | grep -q "minos 12.0" + otool -L zig-out/bin/swift-toolchain-proof | grep -q "libswiftAVFoundation" + ./zig-out/bin/swift-toolchain-proof --swift-proof-smoke + zig build signed-package -Dplatform=macos + codesign --verify --deep --strict zig-out/package/swift-toolchain-proof.app # Shared macos-14 runners are far noisier than a dev box (the second # CI run measured a 576 ms automation-ready against the 500 ms local # ceiling), so widen the smoke budgets here instead of weakening the diff --git a/build.zig b/build.zig index 4623a5d88..37fff8252 100644 --- a/build.zig +++ b/build.zig @@ -630,7 +630,7 @@ pub fn build(b: *std.Build) void { const test_step = b.step("test", "Run package and framework tests"); const swift_toolchain_proof_step = b.step("test-swift-toolchain-proof", "Build and run the macOS Swift/AppKit toolchain proof fixture"); if (host_target.result.os.tag == .macos) { - const swift_toolchain_proof = b.addSystemCommand(&.{ "zig", "build", "test", "-Dplatform=macos" }); + const swift_toolchain_proof = b.addSystemCommand(&.{ b.graph.zig_exe, "build", "test", "-Dplatform=macos" }); swift_toolchain_proof.setCwd(b.path("tests/swift-toolchain-proof")); swift_toolchain_proof.setEnvironmentVariable("ZIG_GLOBAL_CACHE_DIR", ".zig-cache/global"); // The child graph owns its own input cache. Always enter it so edits diff --git a/build/app.zig b/build/app.zig index 307615811..756877d89 100644 --- a/build/app.zig +++ b/build/app.zig @@ -120,6 +120,15 @@ const WebEngineOption = web_layer_contract.WebEngine; const WebLayerOption = web_layer_contract.WebViewLayer; +/// Explicit deployment floor for a macOS app and every native language it +/// contains. Keeping one target on `AppOptions` prevents the final Zig link +/// from advertising an older OS than app-owned Swift objects can run on. +pub const MacOSDeploymentTarget = struct { + major: u16, + minor: u16 = 0, + patch: u16 = 0, +}; + pub const AppOptions = struct { name: []const u8, /// Explicit manifest path. Null auto-detects app.json first, then app.zon, @@ -144,6 +153,9 @@ pub const AppOptions = struct { /// step walks wuffs/translate_c and whose full build pulls harfbuzz) /// — the load-bearing property for scaffolded apps. terminal_sessions: bool = false, + /// One deployment floor for the complete macOS artifact. Swift sources, + /// Zig modules, tests, and the final executable all inherit this target. + macos_minimum: MacOSDeploymentTarget = .{ .major = 11 }, }; /// Which core the app tree carries. No flag and no config anywhere: the @@ -1468,7 +1480,7 @@ pub const MobileTsCore = struct { /// a standalone build.zig (it registers the standard `target`/`optimize` /// options itself). pub fn addMobileLib(b: *std.Build, dep: *std.Build.Dependency, options: MobileLibOptions) void { - const target = nativeSdkTarget(b); + const target = nativeSdkTarget(b, .{ .major = 11 }); const optimize_request = b.option(std.builtin.OptimizeMode, "optimize", "Prioritize performance, safety, or binary size"); const optimize = exampleOptimizeMode(b, optimize_request, .Debug); addMobileLibWithTarget(b, dep, target, optimize, options); @@ -1608,16 +1620,6 @@ pub const AppArtifacts = struct { run: *std.Build.Step.Run, }; -/// Explicit deployment floor for Swift sources compiled into a macOS app. -/// Keep this independent from the SDK's own floor: an app may deliberately -/// use a newer Swift/AppKit API surface, and that choice must stay visible at -/// its build boundary. -pub const MacOSDeploymentTarget = struct { - major: u16, - minor: u16 = 0, - patch: u16 = 0, -}; - /// Phase-1 Swift toolchain integration. Swift sources compile to a plain /// object for each optimization mode used by the app artifacts; no Xcode /// project, helper executable, dylib, or post-link rewrite is involved. @@ -1625,7 +1627,6 @@ pub const SwiftAppSourcesOptions = struct { sources: []const std.Build.LazyPath, module_name: []const u8, frameworks: []const []const u8 = &.{}, - macos_minimum: MacOSDeploymentTarget, }; /// Compile and link app-owned Swift sources into both halves of @@ -1634,7 +1635,6 @@ pub const SwiftAppSourcesOptions = struct { pub fn addSwiftAppSources(b: *std.Build, artifacts: AppArtifacts, options: SwiftAppSourcesOptions) void { if (options.sources.len == 0) @panic("\naddSwiftAppSources needs at least one .swift source\n"); if (options.module_name.len == 0) @panic("\naddSwiftAppSources needs a non-empty module_name\n"); - if (options.macos_minimum.major == 0) @panic("\naddSwiftAppSources needs a valid non-zero macOS deployment target\n"); const target = artifacts.exe.root_module.resolved_target orelse @panic("\naddSwiftAppSources requires a resolved app target\n"); @@ -1660,14 +1660,19 @@ pub fn addSwiftAppSources(b: *std.Build, artifacts: AppArtifacts, options: Swift exe_object else compileSwiftObject(b, swiftc, sdk, target, test_optimize, options); + const exe_autolink = swiftAutolinkOptions(b, swiftc, sdk, target, exe_optimize, options.frameworks); + const test_autolink = if (test_optimize == exe_optimize) + exe_autolink + else + swiftAutolinkOptions(b, swiftc, sdk, target, test_optimize, options.frameworks); // TypeScript apps use a link-only executable module over their cached app // object. Zig-core apps point exe.root_module directly at the app module. // Tests always own their test app module. Attach Swift to those final-link // roots so it is added exactly once in either graph shape. - addSwiftLinkInputs(b, artifacts.exe.root_module, exe_object, swift_macos_lib, options.frameworks, exe_optimize); + addSwiftLinkInputs(b, artifacts.exe.root_module, exe_object, sdk, swift_macos_lib, exe_autolink, exe_optimize); if (artifacts.tests.root_module != artifacts.exe.root_module) { - addSwiftLinkInputs(b, artifacts.tests.root_module, test_object, swift_macos_lib, options.frameworks, test_optimize); + addSwiftLinkInputs(b, artifacts.tests.root_module, test_object, sdk, swift_macos_lib, test_autolink, test_optimize); } } @@ -1693,7 +1698,7 @@ fn compileSwiftObject( optimize: std.builtin.OptimizeMode, options: SwiftAppSourcesOptions, ) std.Build.LazyPath { - const triple = swiftMacosTargetTriple(b, target.result.cpu.arch, options.macos_minimum); + const triple = swiftMacosTargetTriple(b, target); const compile = b.addSystemCommand(&.{swiftc}); compile.addArgs(&.{ "-parse-as-library", @@ -1719,30 +1724,205 @@ fn compileSwiftObject( return object; } -fn swiftMacosTargetTriple(b: *std.Build, arch: std.Target.Cpu.Arch, minimum: MacOSDeploymentTarget) []const u8 { - const arch_name = switch (arch) { +fn swiftMacosTargetTriple(b: *std.Build, target: std.Build.ResolvedTarget) []const u8 { + const arch_name = switch (target.result.cpu.arch) { .aarch64 => "arm64", .x86_64 => "x86_64", else => @panic("\naddSwiftAppSources supports Apple Silicon (aarch64) and Intel (x86_64) macOS targets only\n"), }; + const minimum = switch (target.query.os_version_min orelse + @panic("\naddSwiftAppSources requires the app target to carry an explicit macOS deployment floor\n")) { + .semver => |value| value, + else => @panic("\naddSwiftAppSources found a non-semantic macOS deployment floor\n"), + }; return if (minimum.patch == 0) b.fmt("{s}-apple-macosx{d}.{d}", .{ arch_name, minimum.major, minimum.minor }) else b.fmt("{s}-apple-macosx{d}.{d}.{d}", .{ arch_name, minimum.major, minimum.minor, minimum.patch }); } +fn macosDeploymentFlag(b: *std.Build, target: std.Build.ResolvedTarget) []const u8 { + const minimum = switch (target.query.os_version_min orelse + @panic("macOS app target needs an explicit deployment floor")) { + .semver => |value| value, + else => @panic("macOS app target needs a semantic deployment floor"), + }; + return if (minimum.patch == 0) + b.fmt("-mmacosx-version-min={d}.{d}", .{ minimum.major, minimum.minor }) + else + b.fmt("-mmacosx-version-min={d}.{d}.{d}", .{ minimum.major, minimum.minor, minimum.patch }); +} + +const SwiftAutolinkOptions = struct { + libraries: []const []const u8, + frameworks: []const []const u8, +}; + +/// Swift records link directives in Mach-O objects, but Zig's Mach-O linker +/// does not consume LC_LINKER_OPTION. Compile one import-only object for the +/// requested framework modules and ask Xcode's otool for that toolchain's +/// exact closure. The probe is content-addressed under the app's build cache, +/// so unchanged Xcode/SDK/framework inputs configure without recompiling it. +fn swiftAutolinkOptions( + b: *std.Build, + swiftc: []const u8, + sdk: []const u8, + target: std.Build.ResolvedTarget, + optimize: std.builtin.OptimizeMode, + frameworks: []const []const u8, +) SwiftAutolinkOptions { + var source: std.ArrayList(u8) = .empty; + source.appendSlice(b.allocator, "// Generated import probe for Swift LC_LINKER_OPTION discovery.\n") catch @panic("OOM"); + for (frameworks) |framework| { + if (!swiftModuleNameValid(framework)) { + std.debug.panic("\naddSwiftAppSources framework {s} is not a Swift module identifier; pass importable Apple framework module names such as AppKit or AVFoundation\n", .{framework}); + } + const import = std.fmt.allocPrint(b.allocator, "import {s}\n", .{framework}) catch @panic("OOM"); + source.appendSlice(b.allocator, import) catch @panic("OOM"); + } + source.appendSlice(b.allocator, "@_cdecl(\"native_swift_autolink_probe\") public func nativeSwiftAutolinkProbe() {}\n") catch @panic("OOM"); + + const triple = swiftMacosTargetTriple(b, target); + var digest = std.crypto.hash.sha2.Sha256.init(.{}); + digest.update(swiftc); + digest.update(sdk); + digest.update(triple); + digest.update(@tagName(optimize)); + digest.update(source.items); + var digest_bytes: [std.crypto.hash.sha2.Sha256.digest_length]u8 = undefined; + digest.final(&digest_bytes); + const digest_hex = std.fmt.bytesToHex(digest_bytes, .lower); + const cache_dir = b.pathJoin(&.{ b.cache_root.path orelse ".zig-cache", "native-swift-autolink" }); + const stem = b.fmt("{s}", .{&digest_hex}); + const source_path = b.pathJoin(&.{ cache_dir, b.fmt("{s}.swift", .{stem}) }); + const object_path = b.pathJoin(&.{ cache_dir, b.fmt("{s}.o", .{stem}) }); + std.Io.Dir.cwd().createDirPath(b.graph.io, cache_dir) catch |err| + std.debug.panic("\naddSwiftAppSources could not create its autolink cache at {s}: {t}\n", .{ cache_dir, err }); + + if (!absoluteBuildFileExists(b, object_path)) { + std.Io.Dir.cwd().writeFile(b.graph.io, .{ .sub_path = source_path, .data = source.items }) catch |err| + std.debug.panic("\naddSwiftAppSources could not write its autolink probe at {s}: {t}\n", .{ source_path, err }); + const module_cache = b.pathJoin(&.{ b.cache_root.path orelse ".zig-cache", "native-swift-module-cache" }); + var argv: std.ArrayList([]const u8) = .empty; + argv.appendSlice(b.allocator, &.{ + swiftc, + "-parse-as-library", + "-emit-object", + "-whole-module-optimization", + "-module-name", + "NativeSwiftAutolinkProbe", + "-target", + triple, + "-sdk", + sdk, + "-module-cache-path", + module_cache, + }) catch @panic("OOM"); + switch (optimize) { + .Debug => argv.appendSlice(b.allocator, &.{ "-Onone", "-g" }) catch @panic("OOM"), + .ReleaseSafe, .ReleaseFast => argv.append(b.allocator, "-O") catch @panic("OOM"), + .ReleaseSmall => argv.append(b.allocator, "-Osize") catch @panic("OOM"), + } + argv.appendSlice(b.allocator, &.{ "-o", object_path, source_path }) catch @panic("OOM"); + const compile = std.process.run(b.allocator, b.graph.io, .{ + .argv = argv.items, + .stdout_limit = .limited(64 * 1024), + .stderr_limit = .limited(1024 * 1024), + }) catch |err| + std.debug.panic("\naddSwiftAppSources could not run swiftc for autolink discovery: {t}\n", .{err}); + defer b.allocator.free(compile.stdout); + defer b.allocator.free(compile.stderr); + if (compile.term != .exited or compile.term.exited != 0) { + std.debug.panic("\naddSwiftAppSources could not import its declared Swift frameworks for autolink discovery:\n{s}\n", .{compile.stderr}); + } + } + + const otool = findXcrunTool(b, "otool") orelse + @panic("\naddSwiftAppSources could not find otool. Install Xcode and verify `xcrun --find otool`.\n"); + const inspect = std.process.run(b.allocator, b.graph.io, .{ + .argv = &.{ otool, "-l", object_path }, + .stdout_limit = .limited(4 * 1024 * 1024), + .stderr_limit = .limited(64 * 1024), + }) catch |err| + std.debug.panic("\naddSwiftAppSources could not inspect Swift autolink metadata: {t}\n", .{err}); + defer b.allocator.free(inspect.stdout); + defer b.allocator.free(inspect.stderr); + if (inspect.term != .exited or inspect.term.exited != 0) { + std.debug.panic("\naddSwiftAppSources could not inspect Swift autolink metadata:\n{s}\n", .{inspect.stderr}); + } + return parseSwiftAutolinkOptions(b, inspect.stdout); +} + +fn swiftModuleNameValid(name: []const u8) bool { + if (name.len == 0 or !std.ascii.isAlphabetic(name[0]) and name[0] != '_') return false; + for (name[1..]) |char| if (!std.ascii.isAlphanumeric(char) and char != '_') return false; + return true; +} + +fn parseSwiftAutolinkOptions(b: *std.Build, output: []u8) SwiftAutolinkOptions { + var libraries: std.ArrayList([]const u8) = .empty; + var frameworks: std.ArrayList([]const u8) = .empty; + var expect_framework = false; + var lines = std.mem.splitScalar(u8, output, '\n'); + while (lines.next()) |raw_line| { + const line = std.mem.trim(u8, raw_line, " \t\r"); + if (!std.mem.startsWith(u8, line, "string #")) continue; + const first_space = std.mem.indexOfScalar(u8, line, ' ') orelse continue; + const rest = std.mem.trimStart(u8, line[first_space + 1 ..], " \t"); + const second_space = std.mem.indexOfScalar(u8, rest, ' ') orelse continue; + const value = std.mem.trim(u8, rest[second_space + 1 ..], " \t\r"); + if (expect_framework) { + appendUniqueString(b, &frameworks, value); + expect_framework = false; + } else if (std.mem.eql(u8, value, "-framework")) { + expect_framework = true; + } else if (std.mem.startsWith(u8, value, "-l") and value.len > 2) { + appendUniqueString(b, &libraries, value[2..]); + } + } + return .{ .libraries = libraries.items, .frameworks = frameworks.items }; +} + +fn appendUniqueString(b: *std.Build, list: *std.ArrayList([]const u8), value: []const u8) void { + for (list.items) |existing| if (std.mem.eql(u8, existing, value)) return; + list.append(b.allocator, b.dupe(value)) catch @panic("OOM"); +} + +fn absoluteBuildFileExists(b: *std.Build, path: []const u8) bool { + std.Io.Dir.cwd().access(b.graph.io, path, .{}) catch return false; + return true; +} + +fn swiftCompatibilityArchives(b: *std.Build, swift_macos_lib: []const u8) []const []const u8 { + var archives: std.ArrayList([]const u8) = .empty; + var dir = std.Io.Dir.cwd().openDir(b.graph.io, swift_macos_lib, .{ .iterate = true }) catch |err| + std.debug.panic("\naddSwiftAppSources could not inspect the Swift runtime directory {s}: {t}\n", .{ swift_macos_lib, err }); + defer dir.close(b.graph.io); + var iterator = dir.iterate(); + while (iterator.next(b.graph.io) catch null) |entry| { + if (entry.kind != .file and entry.kind != .sym_link) continue; + if (!std.mem.startsWith(u8, entry.name, "libswiftCompatibility") or !std.mem.endsWith(u8, entry.name, ".a")) continue; + archives.append(b.allocator, b.dupe(entry.name)) catch @panic("OOM"); + } + std.sort.pdq([]const u8, archives.items, {}, stringLessThan); + return archives.items; +} + +fn stringLessThan(_: void, lhs: []const u8, rhs: []const u8) bool { + return std.mem.lessThan(u8, lhs, rhs); +} + fn addSwiftLinkInputs( b: *std.Build, mod: *std.Build.Module, object: std.Build.LazyPath, + sdk: []const u8, swift_macos_lib: []const u8, - frameworks: []const []const u8, + autolink: SwiftAutolinkOptions, optimize: std.builtin.OptimizeMode, ) void { mod.addObjectFile(object); - if (b.sysroot) |sysroot| { - mod.addFrameworkPath(.{ .cwd_relative = b.pathJoin(&.{ sysroot, "System/Library/Frameworks" }) }); - } + mod.addFrameworkPath(.{ .cwd_relative = b.pathJoin(&.{ sdk, "System/Library/Frameworks" }) }); // System Swift dylibs have lived at this stable OS path since macOS 10.14. // The rpath is what Apple's Swift driver itself emits for a macOS link. mod.addLibraryPath(.{ .cwd_relative = "/usr/lib/swift" }); @@ -1752,70 +1932,40 @@ fn addSwiftLinkInputs( mod.addRPath(.{ .cwd_relative = "/usr/lib/swift" }); // Swift's Mach-O objects carry LC_LINKER_OPTION records for imported - // overlays. Zig's Mach-O link does not consume those records, so spell - // out the system overlays exercised by SwiftUI/AppKit/Foundation. They - // are TBDs in the macOS SDK and resolve to /usr/lib/swift at runtime; - // none are copied into the app bundle. - const swift_system_libraries = [_][]const u8{ - "swiftCore", - "swiftSwiftOnoneSupport", - "swiftos", - "swiftObjectiveC", - "swift_StringProcessing", - "swift_Concurrency", - "swift_DarwinFoundation1", - "swift_DarwinFoundation2", - "swift_DarwinFoundation3", - "swiftDarwin", - "swift_Builtin_float", - "swiftXPC", - "swiftDispatch", - "swiftUniformTypeIdentifiers", - "swiftFoundation", - "swiftSystem", - "swiftObservation", - "swiftCoreFoundation", - "swiftIOKit", - "swiftsimd", - "swiftQuartzCore", - "swiftMetal", - "swiftOSLog", - "swiftCoreImage", - "swiftSpatial", - }; - for (swift_system_libraries) |library| { + // overlays. Zig's Mach-O link does not consume those records, so discover + // the selected SDK's complete Swift overlay set instead of pinning one + // framework closure to one Xcode release. Unreferenced dylibs are omitted; + // referenced overlays stay weak for back-deployment exactly as before. + for (autolink.libraries) |library| { + if (std.mem.startsWith(u8, library, "swiftCompatibility")) continue; if (optimize != .Debug and std.mem.eql(u8, library, "swiftSwiftOnoneSupport")) continue; - const tbd = b.pathJoin(&.{ b.sysroot.?, "usr", "lib", "swift", b.fmt("lib{s}.tbd", .{library}) }); - std.Io.Dir.cwd().access(b.graph.io, tbd, .{}) catch { - // Swift's imported-module graph varies with Xcode. A library that - // does not exist in this SDK cannot have been selected by this - // toolchain; skip it so the Phase-1 superset spans Xcode releases. - continue; - }; // Apple's linker records imported-module overlays as weak dylibs; // that is what lets an object compiled by a newer Xcode run on an // older deployment target where a newly split overlay does not yet // exist. The core runtime and Debug support library are the only // direct, non-overlay dependencies here. - const weak = !std.mem.eql(u8, library, "swiftCore") and + const weak = std.mem.startsWith(u8, library, "swift") and + !std.mem.eql(u8, library, "swiftCore") and !std.mem.eql(u8, library, "swiftSwiftOnoneSupport"); mod.linkSystemLibrary(library, .{ .use_pkg_config = .no, .weak = weak }); } - for (frameworks) |framework| mod.linkFramework(framework, .{}); - - // A macOS 11 deployment still needs these back-deployment shims from the - // active Xcode toolchain. They are static archive inputs, not bundled - // runtime dylibs. libswiftCompatibility56 contains C++ runtime code. - const compatibility_archives = [_][]const u8{ - "libswiftCompatibilityConcurrency.a", - "libswiftCompatibility56.a", - "libswiftCompatibilityPacks.a", - }; - for (compatibility_archives) |archive| { + for (autolink.frameworks) |framework| { + const framework_dir = b.pathJoin(&.{ sdk, "System", "Library", "Frameworks", b.fmt("{s}.framework", .{framework}) }); + const top_level_tbd = b.pathJoin(&.{ framework_dir, b.fmt("{s}.tbd", .{framework}) }); + const versioned_tbd = b.pathJoin(&.{ framework_dir, "Versions", "A", b.fmt("{s}.tbd", .{framework}) }); + // LC_LINKER_OPTION also names subframeworks/re-exports that Apple's + // linker resolves transitively but Zig rejects as top-level + // `-framework` inputs. Add only concrete SDK framework bundles; the + // owning public framework remains in this same discovered closure. + if (!absoluteBuildFileExists(b, top_level_tbd) and !absoluteBuildFileExists(b, versioned_tbd)) continue; + mod.linkFramework(framework, .{}); + } + + // Back-deployment shims vary by Swift/Xcode release. Add exactly the set + // present in this toolchain; older Xcodes are not required to carry newer + // archive names, and archive extraction omits unused members. + for (swiftCompatibilityArchives(b, swift_macos_lib)) |archive| { const path = b.pathJoin(&.{ swift_macos_lib, archive }); - std.Io.Dir.cwd().access(b.graph.io, path, .{}) catch { - std.debug.panic("\naddSwiftAppSources could not find {s}. The selected Xcode Swift toolchain is incomplete or incompatible with this deployment target.\n", .{path}); - }; mod.addObjectFile(.{ .cwd_relative = path }); } mod.link_libcpp = true; @@ -1827,7 +1977,7 @@ pub fn addApp(b: *std.Build, dep: *std.Build.Dependency, app_options: AppOptions } pub fn addAppArtifacts(b: *std.Build, dep: *std.Build.Dependency, app_options: AppOptions) AppArtifacts { - const target = nativeSdkTarget(b); + const target = nativeSdkTarget(b, app_options.macos_minimum); const optimize_request = b.option(std.builtin.OptimizeMode, "optimize", "Prioritize performance, safety, or binary size"); const optimize = exampleOptimizeMode(b, optimize_request, .Debug); const app_optimize = exampleOptimizeMode(b, optimize_request, .ReleaseFast); @@ -2338,9 +2488,10 @@ fn addMacosInfoPlist(b: *std.Build, app_mod: *std.Build.Module, target: std.Buil app_mod.addCSourceFile(.{ .file = generated, .flags = &.{} }); } -fn nativeSdkTarget(b: *std.Build) std.Build.ResolvedTarget { +fn nativeSdkTarget(b: *std.Build, macos_minimum: MacOSDeploymentTarget) std.Build.ResolvedTarget { const target = b.standardTargetOptions(.{}); if (target.result.os.tag != .macos) return target; + if (macos_minimum.major == 0) @panic("macos_minimum needs a valid non-zero macOS deployment target"); if (b.sysroot == null) { b.sysroot = macosSdkPath(b) orelse b.sysroot; @@ -2348,7 +2499,11 @@ fn nativeSdkTarget(b: *std.Build) std.Build.ResolvedTarget { var query = target.query; query.os_tag = .macos; - query.os_version_min = .{ .semver = .{ .major = 11, .minor = 0, .patch = 0 } }; + query.os_version_min = .{ .semver = .{ + .major = macos_minimum.major, + .minor = macos_minimum.minor, + .patch = macos_minimum.patch, + } }; return b.resolveTargetQuery(query); } @@ -2665,10 +2820,11 @@ fn externalModule(b: *std.Build, dep: *std.Build.Dependency, target: std.Build.R fn linkPlatform(b: *std.Build, dep: *std.Build.Dependency, target: std.Build.ResolvedTarget, app_mod: *std.Build.Module, exe: *std.Build.Step.Compile, platform: PlatformOption, web_engine: WebEngineOption, web_layer: bool, cef_dir: []const u8, cef_auto_install: bool) void { addPlatformLinkSearchPaths(b, platform, web_engine, cef_dir, app_mod); if (platform == .macos) { + const deployment_flag = macosDeploymentFlag(b, target); switch (web_engine) { .system => { const sdk_include = if (b.sysroot) |sysroot| b.fmt("-I{s}/usr/include", .{sysroot}) else ""; - const flags: []const []const u8 = if (b.sysroot) |sysroot| &.{ "-fobjc-arc", "-fno-sanitize=builtin", "-ObjC", "-mmacosx-version-min=11.0", "-isysroot", sysroot, sdk_include } else &.{ "-fobjc-arc", "-fno-sanitize=builtin", "-ObjC", "-mmacosx-version-min=11.0" }; + const flags: []const []const u8 = if (b.sysroot) |sysroot| &.{ "-fobjc-arc", "-fno-sanitize=builtin", "-ObjC", deployment_flag, "-isysroot", sysroot, sdk_include } else &.{ "-fobjc-arc", "-fno-sanitize=builtin", "-ObjC", deployment_flag }; app_mod.addCSourceFile(.{ .file = dep.path("src/platform/macos/appkit_host.m"), .flags = flags }); app_mod.linkFramework("WebKit", .{}); }, @@ -2685,7 +2841,7 @@ fn linkPlatform(b: *std.Build, dep: *std.Build.Dependency, target: std.Build.Res // bundled libc++/libc headers). A plain -I shadows libc++'s / // wrappers in ObjC++ and surfaces SDK nullability gaps as a diagnostic flood. const sdk_include = if (b.sysroot) |sysroot| b.fmt("-isystem{s}/usr/include", .{sysroot}) else ""; - const flags: []const []const u8 = if (b.sysroot) |sysroot| &.{ "-fobjc-arc", "-fno-sanitize=builtin", "-ObjC++", "-std=c++17", "-stdlib=libc++", "-mmacosx-version-min=11.0", "-isysroot", sysroot, sdk_include, include_arg, define_arg } else &.{ "-fobjc-arc", "-fno-sanitize=builtin", "-ObjC++", "-std=c++17", "-stdlib=libc++", "-mmacosx-version-min=11.0", include_arg, define_arg }; + const flags: []const []const u8 = if (b.sysroot) |sysroot| &.{ "-fobjc-arc", "-fno-sanitize=builtin", "-ObjC++", "-std=c++17", "-stdlib=libc++", deployment_flag, "-isysroot", sysroot, sdk_include, include_arg, define_arg } else &.{ "-fobjc-arc", "-fno-sanitize=builtin", "-ObjC++", "-std=c++17", "-stdlib=libc++", deployment_flag, include_arg, define_arg }; app_mod.addCSourceFile(.{ .file = dep.path("src/platform/macos/cef_host.mm"), .flags = flags }); app_mod.addObjectFile(b.path(b.fmt("{s}/libcef_dll_wrapper/libcef_dll_wrapper.a", .{cef_dir}))); app_mod.linkFramework("Chromium Embedded Framework", .{}); diff --git a/tests/swift-toolchain-proof/README.md b/tests/swift-toolchain-proof/README.md index c267496e5..4e8b88336 100644 --- a/tests/swift-toolchain-proof/README.md +++ b/tests/swift-toolchain-proof/README.md @@ -5,8 +5,9 @@ ABI into an ordinary Native SDK app without an Xcode project, helper process, or bundled dynamic library. `src/NativeView.swift` exports retained `NSHostingView` construction and -release functions with `@_cdecl`. `src/main.zig` calls only those C symbols; -the pointer remains opaque to Zig and ownership stays with the app. +release functions with `@_cdecl`, and exercises AVFoundation's Swift overlay. +`src/main.zig` calls only those C symbols; the pointer remains opaque to Zig +and ownership stays with the app. Run the focused gates from this directory: @@ -18,6 +19,7 @@ zig build signed-package -Dplatform=macos codesign --verify --deep --strict zig-out/package/swift-toolchain-proof.app ``` -The fixture currently declares macOS 11.0, matching the SDK deployment floor. -It is exercised with Xcode 26 / Swift 6.2 in Phase 1; the build helper emits an +The fixture declares macOS 12.0 once at the app boundary; the Swift object and +final Zig executable must both carry that floor. It is exercised with Xcode 26 +and Swift 6.2 in Phase 1; the build helper emits an actionable error when Xcode, the macOS SDK, or `swiftc` is unavailable. diff --git a/tests/swift-toolchain-proof/build.zig b/tests/swift-toolchain-proof/build.zig index fe7f55447..3ef6bbe30 100644 --- a/tests/swift-toolchain-proof/build.zig +++ b/tests/swift-toolchain-proof/build.zig @@ -8,20 +8,24 @@ const native_sdk = @import("native_sdk"); pub fn build(b: *std.Build) void { const dep = b.dependency("native_sdk", .{}); - const artifacts = native_sdk.addAppArtifacts(b, dep, .{ .name = "swift-toolchain-proof" }); + const artifacts = native_sdk.addAppArtifacts(b, dep, .{ + .name = "swift-toolchain-proof", + // The one app-level floor must reach both Zig final links and Swift. + .macos_minimum = .{ .major = 12 }, + }); native_sdk.addSwiftAppSources(b, artifacts, .{ .sources = &.{b.path("src/NativeView.swift")}, .module_name = "NativeViewHost", - .frameworks = &.{ "SwiftUI", "AppKit", "Foundation" }, - // Match Native SDK's current macOS deployment floor. Raising this is - // an explicit fixture edit, never an ambient Xcode default. - .macos_minimum = .{ .major = 11 }, + // AVFoundation exercises a Swift overlay outside the original + // SwiftUI/AppKit closure; its async property API requires macOS 12. + .frameworks = &.{ "SwiftUI", "AppKit", "Foundation", "AVFoundation" }, }); // `addAppArtifacts`' test step normally need not build the app binary. // This proof's whole purpose is to link both independently optimized - // artifacts, so make the ReleaseFast executable part of its test gate. + // artifacts, so make the installed ReleaseFast executable part of its test + // gate as well; CI smoke-runs that exact zig-out/bin artifact next. if (b.top_level_steps.get("test")) |test_step| { - test_step.step.dependOn(&artifacts.exe.step); + test_step.step.dependOn(&artifacts.install.step); } // Reproducible ReleaseFast package/sign gate for Phase 1. The standard diff --git a/tests/swift-toolchain-proof/src/NativeView.swift b/tests/swift-toolchain-proof/src/NativeView.swift index ad5729828..851c4d4ee 100644 --- a/tests/swift-toolchain-proof/src/NativeView.swift +++ b/tests/swift-toolchain-proof/src/NativeView.swift @@ -1,4 +1,5 @@ import AppKit +import AVFoundation import SwiftUI private struct NativeToolchainProofView: View { @@ -12,7 +13,11 @@ private struct NativeToolchainProofView: View { /// creation returns a retained app-owned NSView and the caller releases it. @_cdecl("native_swift_proof_create_view") public func nativeSwiftProofCreateView() -> UnsafeMutableRawPointer { - Unmanaged.passRetained(NSHostingView(rootView: NativeToolchainProofView())).toOpaque() + // Exercise AVFoundation's Swift overlay rather than merely the ObjC + // framework. This API contributes swiftAVFoundation/CoreMedia overlays. + let asset = AVURLAsset(url: URL(fileURLWithPath: "/tmp/native-swift-toolchain-proof")) + Task { _ = try? await asset.load(.duration) } + return Unmanaged.passRetained(NSHostingView(rootView: NativeToolchainProofView())).toOpaque() } @_cdecl("native_swift_proof_release_view") From 33c19fdbef8fceca89bd29bdc2017f851bc37b3b Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Mon, 24 Aug 2026 12:09:51 -0500 Subject: [PATCH 3/6] Harden Swift back-deployment linking --- .github/workflows/ci.yml | 1 + build.zig | 4 + build/app.zig | 380 +++++++++++++++++++++++--- tests/swift-toolchain-proof/README.md | 6 +- 4 files changed, 357 insertions(+), 34 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b3437a083..d3970dc09 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -89,6 +89,7 @@ jobs: cd tests/swift-toolchain-proof xcrun vtool -show-build zig-out/bin/swift-toolchain-proof | grep -q "minos 12.0" otool -L zig-out/bin/swift-toolchain-proof | grep -q "libswiftAVFoundation" + ! otool -L zig-out/bin/swift-toolchain-proof | grep -q "/SwiftUICore.framework/" ./zig-out/bin/swift-toolchain-proof --swift-proof-smoke zig build signed-package -Dplatform=macos codesign --verify --deep --strict zig-out/package/swift-toolchain-proof.app diff --git a/build.zig b/build.zig index 37fff8252..27d257189 100644 --- a/build.zig +++ b/build.zig @@ -107,6 +107,10 @@ test "service archive support matches ScriptC localized object formats" { try std.testing.expect(app_build.serviceArchiveSupported(windows_host, native_windows_msvc)); } +test "Swift build helpers preserve deployment compatibility and cache identity" { + try @import("build/app.zig").testSwiftBuildHelpers(); +} + pub fn build(b: *std.Build) void { const target = b.standardTargetOptions(.{}); const host_target = b.graph.host; diff --git a/build/app.zig b/build/app.zig index 756877d89..e008cdae7 100644 --- a/build/app.zig +++ b/build/app.zig @@ -1660,19 +1660,20 @@ pub fn addSwiftAppSources(b: *std.Build, artifacts: AppArtifacts, options: Swift exe_object else compileSwiftObject(b, swiftc, sdk, target, test_optimize, options); - const exe_autolink = swiftAutolinkOptions(b, swiftc, sdk, target, exe_optimize, options.frameworks); + const toolchain_identity = swiftToolchainCacheIdentity(b, swiftc, sdk, target); + const exe_autolink = swiftAutolinkOptions(b, swiftc, sdk, target, exe_optimize, options.frameworks, toolchain_identity); const test_autolink = if (test_optimize == exe_optimize) exe_autolink else - swiftAutolinkOptions(b, swiftc, sdk, target, test_optimize, options.frameworks); + swiftAutolinkOptions(b, swiftc, sdk, target, test_optimize, options.frameworks, toolchain_identity); // TypeScript apps use a link-only executable module over their cached app // object. Zig-core apps point exe.root_module directly at the app module. // Tests always own their test app module. Attach Swift to those final-link // roots so it is added exactly once in either graph shape. - addSwiftLinkInputs(b, artifacts.exe.root_module, exe_object, sdk, swift_macos_lib, exe_autolink, exe_optimize); + addSwiftLinkInputs(b, artifacts.exe.root_module, exe_object, sdk, swift_macos_lib, options.frameworks, exe_autolink, exe_optimize); if (artifacts.tests.root_module != artifacts.exe.root_module) { - addSwiftLinkInputs(b, artifacts.tests.root_module, test_object, sdk, swift_macos_lib, test_autolink, test_optimize); + addSwiftLinkInputs(b, artifacts.tests.root_module, test_object, sdk, swift_macos_lib, options.frameworks, test_autolink, test_optimize); } } @@ -1758,11 +1759,72 @@ const SwiftAutolinkOptions = struct { frameworks: []const []const u8, }; +const SwiftToolchainCacheIdentity = struct { + target_info: []const u8, + sdk_settings: []const u8, +}; + +fn swiftToolchainCacheIdentity( + b: *std.Build, + swiftc: []const u8, + sdk: []const u8, + target: std.Build.ResolvedTarget, +) SwiftToolchainCacheIdentity { + const target_info = std.process.run(b.allocator, b.graph.io, .{ + .argv = &.{ swiftc, "-print-target-info", "-target", swiftMacosTargetTriple(b, target), "-sdk", sdk }, + .stdout_limit = .limited(1024 * 1024), + .stderr_limit = .limited(1024 * 1024), + }) catch |err| + std.debug.panic("\naddSwiftAppSources could not read the selected Swift toolchain identity: {t}\n", .{err}); + defer b.allocator.free(target_info.stderr); + if (target_info.term != .exited or target_info.term.exited != 0) { + std.debug.panic("\naddSwiftAppSources could not read the selected Swift toolchain identity:\n{s}\n", .{target_info.stderr}); + } + + const json_path = b.pathJoin(&.{ sdk, "SDKSettings.json" }); + const plist_path = b.pathJoin(&.{ sdk, "SDKSettings.plist" }); + const sdk_settings = std.Io.Dir.cwd().readFileAlloc(b.graph.io, json_path, b.allocator, .limited(1024 * 1024)) catch + std.Io.Dir.cwd().readFileAlloc(b.graph.io, plist_path, b.allocator, .limited(1024 * 1024)) catch |err| + std.debug.panic("\naddSwiftAppSources could not read SDKSettings.json or SDKSettings.plist from the selected macOS SDK at {s}: {t}\n", .{ sdk, err }); + return .{ .target_info = target_info.stdout, .sdk_settings = sdk_settings }; +} + +fn swiftAutolinkCacheDigest( + swiftc: []const u8, + sdk: []const u8, + triple: []const u8, + optimize: std.builtin.OptimizeMode, + source: []const u8, + identity: SwiftToolchainCacheIdentity, +) [std.crypto.hash.sha2.Sha256.digest_length]u8 { + var digest = std.crypto.hash.sha2.Sha256.init(.{}); + digest.update("native-swift-autolink-v2\x00"); + digest.update(swiftc); + digest.update("\x00"); + digest.update(sdk); + digest.update("\x00"); + digest.update(triple); + digest.update("\x00"); + digest.update(@tagName(optimize)); + digest.update("\x00"); + digest.update(source); + digest.update("\x00"); + digest.update(identity.target_info); + digest.update("\x00"); + digest.update(identity.sdk_settings); + var result: [std.crypto.hash.sha2.Sha256.digest_length]u8 = undefined; + digest.final(&result); + return result; +} + /// Swift records link directives in Mach-O objects, but Zig's Mach-O linker /// does not consume LC_LINKER_OPTION. Compile one import-only object for the /// requested framework modules and ask Xcode's otool for that toolchain's -/// exact closure. The probe is content-addressed under the app's build cache, -/// so unchanged Xcode/SDK/framework inputs configure without recompiling it. +/// exact runtime-library and framework closure. Framework directives are used +/// to preserve deployment-aware SDK re-exports; only app-declared modules +/// become strong direct framework dependencies. The probe is content-addressed under +/// the app's build cache, including compiler target-info and SDK settings, so +/// replacing Xcode in place cannot reuse stale directives. fn swiftAutolinkOptions( b: *std.Build, swiftc: []const u8, @@ -1770,6 +1832,7 @@ fn swiftAutolinkOptions( target: std.Build.ResolvedTarget, optimize: std.builtin.OptimizeMode, frameworks: []const []const u8, + identity: SwiftToolchainCacheIdentity, ) SwiftAutolinkOptions { var source: std.ArrayList(u8) = .empty; source.appendSlice(b.allocator, "// Generated import probe for Swift LC_LINKER_OPTION discovery.\n") catch @panic("OOM"); @@ -1783,14 +1846,7 @@ fn swiftAutolinkOptions( source.appendSlice(b.allocator, "@_cdecl(\"native_swift_autolink_probe\") public func nativeSwiftAutolinkProbe() {}\n") catch @panic("OOM"); const triple = swiftMacosTargetTriple(b, target); - var digest = std.crypto.hash.sha2.Sha256.init(.{}); - digest.update(swiftc); - digest.update(sdk); - digest.update(triple); - digest.update(@tagName(optimize)); - digest.update(source.items); - var digest_bytes: [std.crypto.hash.sha2.Sha256.digest_length]u8 = undefined; - digest.final(&digest_bytes); + const digest_bytes = swiftAutolinkCacheDigest(swiftc, sdk, triple, optimize, source.items, identity); const digest_hex = std.fmt.bytesToHex(digest_bytes, .lower); const cache_dir = b.pathJoin(&.{ b.cache_root.path orelse ".zig-cache", "native-swift-autolink" }); const stem = b.fmt("{s}", .{&digest_hex}); @@ -1850,7 +1906,7 @@ fn swiftAutolinkOptions( if (inspect.term != .exited or inspect.term.exited != 0) { std.debug.panic("\naddSwiftAppSources could not inspect Swift autolink metadata:\n{s}\n", .{inspect.stderr}); } - return parseSwiftAutolinkOptions(b, inspect.stdout); + return parseSwiftAutolinkOptions(b.allocator, inspect.stdout); } fn swiftModuleNameValid(name: []const u8) bool { @@ -1859,7 +1915,7 @@ fn swiftModuleNameValid(name: []const u8) bool { return true; } -fn parseSwiftAutolinkOptions(b: *std.Build, output: []u8) SwiftAutolinkOptions { +fn parseSwiftAutolinkOptions(allocator: std.mem.Allocator, output: []const u8) SwiftAutolinkOptions { var libraries: std.ArrayList([]const u8) = .empty; var frameworks: std.ArrayList([]const u8) = .empty; var expect_framework = false; @@ -1872,20 +1928,23 @@ fn parseSwiftAutolinkOptions(b: *std.Build, output: []u8) SwiftAutolinkOptions { const second_space = std.mem.indexOfScalar(u8, rest, ' ') orelse continue; const value = std.mem.trim(u8, rest[second_space + 1 ..], " \t\r"); if (expect_framework) { - appendUniqueString(b, &frameworks, value); + appendUniqueString(allocator, &frameworks, value); expect_framework = false; } else if (std.mem.eql(u8, value, "-framework")) { expect_framework = true; } else if (std.mem.startsWith(u8, value, "-l") and value.len > 2) { - appendUniqueString(b, &libraries, value[2..]); + appendUniqueString(allocator, &libraries, value[2..]); } } - return .{ .libraries = libraries.items, .frameworks = frameworks.items }; + return .{ + .libraries = libraries.toOwnedSlice(allocator) catch @panic("OOM"), + .frameworks = frameworks.toOwnedSlice(allocator) catch @panic("OOM"), + }; } -fn appendUniqueString(b: *std.Build, list: *std.ArrayList([]const u8), value: []const u8) void { +fn appendUniqueString(allocator: std.mem.Allocator, list: *std.ArrayList([]const u8), value: []const u8) void { for (list.items) |existing| if (std.mem.eql(u8, existing, value)) return; - list.append(b.allocator, b.dupe(value)) catch @panic("OOM"); + list.append(allocator, allocator.dupe(u8, value) catch @panic("OOM")) catch @panic("OOM"); } fn absoluteBuildFileExists(b: *std.Build, path: []const u8) bool { @@ -1893,6 +1952,188 @@ fn absoluteBuildFileExists(b: *std.Build, path: []const u8) bool { return true; } +const TbdVersion = struct { + major: u16, + minor: u16 = 0, + patch: u16 = 0, +}; + +fn parseTbdVersion(text_value: []const u8) ?TbdVersion { + var parts = std.mem.splitScalar(u8, text_value, '.'); + const major = std.fmt.parseInt(u16, parts.next() orelse return null, 10) catch return null; + const minor = std.fmt.parseInt(u16, parts.next() orelse "0", 10) catch return null; + const patch = std.fmt.parseInt(u16, parts.next() orelse "0", 10) catch return null; + if (parts.next() != null) return null; + return .{ .major = major, .minor = minor, .patch = patch }; +} + +fn tbdVersionOrder(lhs: TbdVersion, rhs: TbdVersion) std.math.Order { + if (lhs.major != rhs.major) return std.math.order(lhs.major, rhs.major); + if (lhs.minor != rhs.minor) return std.math.order(lhs.minor, rhs.minor); + return std.math.order(lhs.patch, rhs.patch); +} + +fn appendTbdPreviousSymbols( + allocator: std.mem.Allocator, + tbd: []const u8, + install_name: []const u8, + minimum: TbdVersion, + symbols: *std.ArrayList([]const u8), +) void { + const prefix = std.fmt.allocPrint(allocator, "$ld$previous${s}$$1$", .{install_name}) catch @panic("OOM"); + defer allocator.free(prefix); + var offset: usize = 0; + while (std.mem.indexOfPos(u8, tbd, offset, prefix)) |match| { + const start_offset = match + prefix.len; + const start_end = std.mem.indexOfScalarPos(u8, tbd, start_offset, '$') orelse return; + const end_offset = start_end + 1; + const end_end = std.mem.indexOfScalarPos(u8, tbd, end_offset, '$') orelse return; + const quote_end = std.mem.indexOfScalarPos(u8, tbd, end_end + 1, '\'') orelse return; + const start = parseTbdVersion(tbd[start_offset..start_end]) orelse { + offset = quote_end + 1; + continue; + }; + const end = parseTbdVersion(tbd[end_offset..end_end]) orelse { + offset = quote_end + 1; + continue; + }; + const encoded_symbol = tbd[end_end + 1 .. quote_end]; + if (tbdVersionOrder(minimum, start) != .lt and + tbdVersionOrder(minimum, end) == .lt and + encoded_symbol.len > 1 and encoded_symbol[encoded_symbol.len - 1] == '$') + { + appendUniqueString(allocator, symbols, encoded_symbol[0 .. encoded_symbol.len - 1]); + } + offset = quote_end + 1; + } +} + +fn tbdInstallName(tbd: []const u8) ?[]const u8 { + var lines = std.mem.splitScalar(u8, tbd, '\n'); + while (lines.next()) |line| { + if (!std.mem.startsWith(u8, line, "install-name:")) continue; + const value = std.mem.trim(u8, line["install-name:".len..], " \t\r"); + if (value.len < 2 or value[0] != '\'' or value[value.len - 1] != '\'') return null; + return value[1 .. value.len - 1]; + } + return null; +} + +fn frameworkTbdPath(b: *std.Build, sdk: []const u8, framework: []const u8) ?[]const u8 { + const framework_dir = b.pathJoin(&.{ sdk, "System", "Library", "Frameworks", b.fmt("{s}.framework", .{framework}) }); + const top_level = b.pathJoin(&.{ framework_dir, b.fmt("{s}.tbd", .{framework}) }); + if (absoluteBuildFileExists(b, top_level)) return top_level; + const versioned = b.pathJoin(&.{ framework_dir, "Versions", "A", b.fmt("{s}.tbd", .{framework}) }); + if (absoluteBuildFileExists(b, versioned)) return versioned; + return null; +} + +fn removeTbdReexport(allocator: std.mem.Allocator, tbd: []const u8, install_name: []const u8) []const u8 { + const block_start = std.mem.indexOf(u8, tbd, "reexported-libraries:") orelse return tbd; + const block_end = std.mem.indexOfPos(u8, tbd, block_start, "\nexports:") orelse tbd.len; + const quoted = std.fmt.allocPrint(allocator, "'{s}'", .{install_name}) catch @panic("OOM"); + defer allocator.free(quoted); + const relative = std.mem.indexOf(u8, tbd[block_start..block_end], quoted) orelse return tbd; + const value_start = block_start + relative; + const value_end = value_start + quoted.len; + var remove_start = value_start; + var remove_end = value_end; + var right = value_end; + while (right < block_end and std.ascii.isWhitespace(tbd[right])) : (right += 1) {} + if (right < block_end and tbd[right] == ',') { + remove_end = right + 1; + } else { + var left = value_start; + while (left > block_start and std.ascii.isWhitespace(tbd[left - 1])) : (left -= 1) {} + if (left > block_start and tbd[left - 1] == ',') remove_start = left - 1; + } + const result = allocator.alloc(u8, tbd.len - (remove_end - remove_start)) catch @panic("OOM"); + @memcpy(result[0..remove_start], tbd[0..remove_start]); + @memcpy(result[remove_start..], tbd[remove_end..]); + return result; +} + +fn addTbdExports(allocator: std.mem.Allocator, tbd: []const u8, symbols: []const []const u8) []const u8 { + if (symbols.len == 0) return tbd; + const marker = "exports:\n"; + const marker_start = std.mem.indexOf(u8, tbd, marker) orelse return tbd; + const insertion = marker_start + marker.len; + var result: std.ArrayList(u8) = .empty; + result.appendSlice(allocator, tbd[0..insertion]) catch @panic("OOM"); + result.appendSlice(allocator, + \\ - targets: [ x86_64-macos, arm64-macos, arm64e-macos ] + \\ symbols: [ + ) catch @panic("OOM"); + for (symbols, 0..) |symbol, index| { + if (index == 0) { + result.append(allocator, ' ') catch @panic("OOM"); + } else { + result.appendSlice(allocator, ",\n ") catch @panic("OOM"); + } + result.append(allocator, '\'') catch @panic("OOM"); + result.appendSlice(allocator, symbol) catch @panic("OOM"); + result.append(allocator, '\'') catch @panic("OOM"); + } + result.appendSlice(allocator, " ]\n") catch @panic("OOM"); + result.appendSlice(allocator, tbd[insertion..]) catch @panic("OOM"); + return result.toOwnedSlice(allocator) catch @panic("OOM"); +} + +fn addSwiftFrameworkCompatibilityOverlays( + b: *std.Build, + mod: *std.Build.Module, + sdk: []const u8, + target: std.Build.ResolvedTarget, + declared: []const []const u8, + discovered: []const []const u8, +) void { + const minimum_semver = switch (target.query.os_version_min orelse return) { + .semver => |value| value, + else => return, + }; + const minimum: TbdVersion = .{ + .major = std.math.cast(u16, minimum_semver.major) orelse return, + .minor = std.math.cast(u16, minimum_semver.minor) orelse return, + .patch = std.math.cast(u16, minimum_semver.patch) orelse return, + }; + var write_files: ?*std.Build.Step.WriteFile = null; + var overlay_root: ?std.Build.LazyPath = null; + for (declared) |parent| { + const parent_path = frameworkTbdPath(b, sdk, parent) orelse continue; + var parent_tbd: []const u8 = std.Io.Dir.cwd().readFileAlloc(b.graph.io, parent_path, b.allocator, .limited(64 * 1024 * 1024)) catch |err| + std.debug.panic("\naddSwiftAppSources could not read framework stub {s}: {t}\n", .{ parent_path, err }); + const parent_install = tbdInstallName(parent_tbd) orelse continue; + var changed = false; + var previous_symbols: std.ArrayList([]const u8) = .empty; + for (discovered) |child| { + if (std.mem.eql(u8, child, parent)) continue; + const child_path = frameworkTbdPath(b, sdk, child) orelse continue; + const child_tbd = std.Io.Dir.cwd().readFileAlloc(b.graph.io, child_path, b.allocator, .limited(64 * 1024 * 1024)) catch continue; + const child_install = tbdInstallName(child_tbd) orelse continue; + var child_previous_symbols: std.ArrayList([]const u8) = .empty; + appendTbdPreviousSymbols(b.allocator, child_tbd, parent_install, minimum, &child_previous_symbols); + if (child_previous_symbols.items.len == 0) continue; + const rewritten = removeTbdReexport(b.allocator, parent_tbd, child_install); + if (rewritten.ptr == parent_tbd.ptr) continue; + for (child_previous_symbols.items) |symbol| appendUniqueString(b.allocator, &previous_symbols, symbol); + parent_tbd = rewritten; + changed = true; + } + if (!changed) continue; + parent_tbd = addTbdExports(b.allocator, parent_tbd, previous_symbols.items); + const writer = write_files orelse writer: { + const value = b.addWriteFiles(); + write_files = value; + break :writer value; + }; + const generated = writer.add(b.fmt("{s}.framework/{s}.tbd", .{ parent, parent }), parent_tbd); + overlay_root = generated.dirname().dirname(); + } + if (overlay_root) |root| { + mod.include_dirs.insert(b.allocator, 0, .{ .framework_path = root.dupe(b) }) catch @panic("OOM"); + } +} + fn swiftCompatibilityArchives(b: *std.Build, swift_macos_lib: []const u8) []const []const u8 { var archives: std.ArrayList([]const u8) = .empty; var dir = std.Io.Dir.cwd().openDir(b.graph.io, swift_macos_lib, .{ .iterate = true }) catch |err| @@ -1918,10 +2159,12 @@ fn addSwiftLinkInputs( object: std.Build.LazyPath, sdk: []const u8, swift_macos_lib: []const u8, + frameworks: []const []const u8, autolink: SwiftAutolinkOptions, optimize: std.builtin.OptimizeMode, ) void { mod.addObjectFile(object); + addSwiftFrameworkCompatibilityOverlays(b, mod, sdk, mod.resolved_target.?, frameworks, autolink.frameworks); mod.addFrameworkPath(.{ .cwd_relative = b.pathJoin(&.{ sdk, "System/Library/Frameworks" }) }); // System Swift dylibs have lived at this stable OS path since macOS 10.14. // The rpath is what Apple's Swift driver itself emits for a macOS link. @@ -1949,17 +2192,10 @@ fn addSwiftLinkInputs( !std.mem.eql(u8, library, "swiftSwiftOnoneSupport"); mod.linkSystemLibrary(library, .{ .use_pkg_config = .no, .weak = weak }); } - for (autolink.frameworks) |framework| { - const framework_dir = b.pathJoin(&.{ sdk, "System", "Library", "Frameworks", b.fmt("{s}.framework", .{framework}) }); - const top_level_tbd = b.pathJoin(&.{ framework_dir, b.fmt("{s}.tbd", .{framework}) }); - const versioned_tbd = b.pathJoin(&.{ framework_dir, "Versions", "A", b.fmt("{s}.tbd", .{framework}) }); - // LC_LINKER_OPTION also names subframeworks/re-exports that Apple's - // linker resolves transitively but Zig rejects as top-level - // `-framework` inputs. Add only concrete SDK framework bundles; the - // owning public framework remains in this same discovered closure. - if (!absoluteBuildFileExists(b, top_level_tbd) and !absoluteBuildFileExists(b, versioned_tbd)) continue; - mod.linkFramework(framework, .{}); - } + // The caller's explicit imports are the complete direct contract. A newer + // split framework is opt-in: declare it and raise the deployment floor. + // Undeclared SDK implementation details never become load commands. + for (frameworks) |framework| mod.linkFramework(framework, .{}); // Back-deployment shims vary by Swift/Xcode release. Add exactly the set // present in this toolchain; older Xcodes are not required to carry newer @@ -1972,6 +2208,86 @@ fn addSwiftLinkInputs( mod.linkSystemLibrary("objc", .{ .use_pkg_config = .no }); } +pub fn testSwiftBuildHelpers() !void { + const parsed = parseSwiftAutolinkOptions(std.testing.allocator, + \\ cmd LC_LINKER_OPTION + \\ count 2 + \\ string #1 -framework + \\ string #2 SwiftUICore + \\ cmd LC_LINKER_OPTION + \\ count 1 + \\ string #1 -lswiftCore + \\ cmd LC_LINKER_OPTION + \\ count 1 + \\ string #1 -lswiftCore + ); + defer { + for (parsed.libraries) |library| std.testing.allocator.free(library); + std.testing.allocator.free(parsed.libraries); + for (parsed.frameworks) |framework| std.testing.allocator.free(framework); + std.testing.allocator.free(parsed.frameworks); + } + try std.testing.expectEqual(@as(usize, 1), parsed.libraries.len); + try std.testing.expectEqualStrings("swiftCore", parsed.libraries[0]); + try std.testing.expectEqual(@as(usize, 1), parsed.frameworks.len); + try std.testing.expectEqualStrings("SwiftUICore", parsed.frameworks[0]); + + const base = swiftAutolinkCacheDigest("/Xcode/swiftc", "/Xcode/MacOSX.sdk", "arm64-apple-macosx12.0", .ReleaseFast, "import SwiftUI\n", .{ + .target_info = "Swift 6.2 build A", + .sdk_settings = "macOS SDK build A", + }); + const compiler_changed = swiftAutolinkCacheDigest("/Xcode/swiftc", "/Xcode/MacOSX.sdk", "arm64-apple-macosx12.0", .ReleaseFast, "import SwiftUI\n", .{ + .target_info = "Swift 6.2 build B", + .sdk_settings = "macOS SDK build A", + }); + const sdk_changed = swiftAutolinkCacheDigest("/Xcode/swiftc", "/Xcode/MacOSX.sdk", "arm64-apple-macosx12.0", .ReleaseFast, "import SwiftUI\n", .{ + .target_info = "Swift 6.2 build A", + .sdk_settings = "macOS SDK build B", + }); + try std.testing.expect(!std.mem.eql(u8, &base, &compiler_changed)); + try std.testing.expect(!std.mem.eql(u8, &base, &sdk_changed)); + + const child_tbd = + \\--- !tapi-tbd + \\tbd-version: 4 + \\install-name: '/System/Library/Frameworks/SwiftUICore.framework/Versions/A/SwiftUICore' + \\exports: + \\ - targets: [ arm64-macos ] + \\ symbols: [ '$ld$previous$/System/Library/Frameworks/SwiftUI.framework/Versions/A/SwiftUI$$1$10.15$15.0$_$s7SwiftUI4ViewMp$' ] + ; + var previous_symbols: std.ArrayList([]const u8) = .empty; + defer { + for (previous_symbols.items) |symbol| std.testing.allocator.free(symbol); + previous_symbols.deinit(std.testing.allocator); + } + appendTbdPreviousSymbols(std.testing.allocator, child_tbd, "/System/Library/Frameworks/SwiftUI.framework/Versions/A/SwiftUI", .{ .major = 12 }, &previous_symbols); + try std.testing.expectEqual(@as(usize, 1), previous_symbols.items.len); + try std.testing.expectEqualStrings("_$s7SwiftUI4ViewMp", previous_symbols.items[0]); + var outside_symbols: std.ArrayList([]const u8) = .empty; + defer outside_symbols.deinit(std.testing.allocator); + appendTbdPreviousSymbols(std.testing.allocator, child_tbd, "/System/Library/Frameworks/SwiftUI.framework/Versions/A/SwiftUI", .{ .major = 15 }, &outside_symbols); + try std.testing.expectEqual(@as(usize, 0), outside_symbols.items.len); + + const parent_tbd = + \\--- !tapi-tbd + \\tbd-version: 4 + \\install-name: '/System/Library/Frameworks/SwiftUI.framework/Versions/A/SwiftUI' + \\reexported-libraries: + \\ - targets: [ arm64-macos ] + \\ libraries: [ '/System/Library/Frameworks/CoreTransferable.framework/Versions/A/CoreTransferable', + \\ '/System/Library/Frameworks/SwiftUICore.framework/Versions/A/SwiftUICore' ] + \\exports: + \\ - targets: [ arm64-macos ] + \\ symbols: [ '_existing' ] + ; + const without_reexport = removeTbdReexport(std.testing.allocator, parent_tbd, "/System/Library/Frameworks/SwiftUICore.framework/Versions/A/SwiftUICore"); + defer if (without_reexport.ptr != parent_tbd.ptr) std.testing.allocator.free(without_reexport); + try std.testing.expect(std.mem.indexOf(u8, without_reexport, "SwiftUICore.framework") == null); + const with_previous = addTbdExports(std.testing.allocator, without_reexport, previous_symbols.items); + defer if (with_previous.ptr != without_reexport.ptr) std.testing.allocator.free(with_previous); + try std.testing.expect(std.mem.indexOf(u8, with_previous, "'_$s7SwiftUI4ViewMp'") != null); +} + pub fn addApp(b: *std.Build, dep: *std.Build.Dependency, app_options: AppOptions) void { _ = addAppArtifacts(b, dep, app_options); } diff --git a/tests/swift-toolchain-proof/README.md b/tests/swift-toolchain-proof/README.md index 4e8b88336..abf4fb015 100644 --- a/tests/swift-toolchain-proof/README.md +++ b/tests/swift-toolchain-proof/README.md @@ -20,6 +20,8 @@ codesign --verify --deep --strict zig-out/package/swift-toolchain-proof.app ``` The fixture declares macOS 12.0 once at the app boundary; the Swift object and -final Zig executable must both carry that floor. It is exercised with Xcode 26 -and Swift 6.2 in Phase 1; the build helper emits an +final Zig executable must both carry that floor. Its direct load commands must +not absorb newer transitive framework splits such as `SwiftUICore`; the SDK's +back-deployment metadata keeps those symbols on the `SwiftUI` umbrella. +It is exercised with Xcode 26 and Swift 6.2 in Phase 1; the build helper emits an actionable error when Xcode, the macOS SDK, or `swiftc` is unavailable. From 93e96b59f31a929b407efae4290fdb461a0ea2fc Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Mon, 24 Aug 2026 12:42:59 -0500 Subject: [PATCH 4/6] Fix Swift autolinking and package deployment metadata --- .github/workflows/ci.yml | 4 ++ build/app.zig | 52 ++++++++++++----- docs/src/app/docs/cli/page.mdx | 2 + src/tooling/package.zig | 56 +++++++++++++++---- tests/swift-toolchain-proof/README.md | 13 +++-- tests/swift-toolchain-proof/build.zig | 8 ++- .../src/NativeView.swift | 8 +++ tools/native-sdk/main.zig | 8 ++- 8 files changed, 116 insertions(+), 35 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d3970dc09..d6c6a73a5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -89,9 +89,13 @@ jobs: cd tests/swift-toolchain-proof xcrun vtool -show-build zig-out/bin/swift-toolchain-proof | grep -q "minos 12.0" otool -L zig-out/bin/swift-toolchain-proof | grep -q "libswiftAVFoundation" + otool -L zig-out/bin/swift-toolchain-proof | grep -q "libswiftRegexBuilder" ! otool -L zig-out/bin/swift-toolchain-proof | grep -q "/SwiftUICore.framework/" ./zig-out/bin/swift-toolchain-proof --swift-proof-smoke + zig build package -Dplatform=macos + test "$(plutil -extract LSMinimumSystemVersion raw zig-out/package/swift-toolchain-proof.app/Contents/Info.plist)" = "12.0" zig build signed-package -Dplatform=macos + test "$(plutil -extract LSMinimumSystemVersion raw zig-out/package/swift-toolchain-proof.app/Contents/Info.plist)" = "12.0" codesign --verify --deep --strict zig-out/package/swift-toolchain-proof.app # Shared macos-14 runners are far noisier than a dev box (the second # CI run measured a 576 ms automation-ready against the 500 ms local diff --git a/build/app.zig b/build/app.zig index e008cdae7..889ab5419 100644 --- a/build/app.zig +++ b/build/app.zig @@ -1626,6 +1626,10 @@ pub const AppArtifacts = struct { pub const SwiftAppSourcesOptions = struct { sources: []const std.Build.LazyPath, module_name: []const u8, + /// Swift modules imported by the app that are not Apple frameworks. + /// Their compiler-emitted autolink libraries are forwarded to Zig's + /// linker, but they do not become framework load commands. + modules: []const []const u8 = &.{}, frameworks: []const []const u8 = &.{}, }; @@ -1661,11 +1665,11 @@ pub fn addSwiftAppSources(b: *std.Build, artifacts: AppArtifacts, options: Swift else compileSwiftObject(b, swiftc, sdk, target, test_optimize, options); const toolchain_identity = swiftToolchainCacheIdentity(b, swiftc, sdk, target); - const exe_autolink = swiftAutolinkOptions(b, swiftc, sdk, target, exe_optimize, options.frameworks, toolchain_identity); + const exe_autolink = swiftAutolinkOptions(b, swiftc, sdk, target, exe_optimize, options.modules, options.frameworks, toolchain_identity); const test_autolink = if (test_optimize == exe_optimize) exe_autolink else - swiftAutolinkOptions(b, swiftc, sdk, target, test_optimize, options.frameworks, toolchain_identity); + swiftAutolinkOptions(b, swiftc, sdk, target, test_optimize, options.modules, options.frameworks, toolchain_identity); // TypeScript apps use a link-only executable module over their cached app // object. Zig-core apps point exe.root_module directly at the app module. @@ -1754,6 +1758,13 @@ fn macosDeploymentFlag(b: *std.Build, target: std.Build.ResolvedTarget) []const b.fmt("-mmacosx-version-min={d}.{d}.{d}", .{ minimum.major, minimum.minor, minimum.patch }); } +fn macosDeploymentVersion(b: *std.Build, minimum: MacOSDeploymentTarget) []const u8 { + return if (minimum.patch == 0) + b.fmt("{d}.{d}", .{ minimum.major, minimum.minor }) + else + b.fmt("{d}.{d}.{d}", .{ minimum.major, minimum.minor, minimum.patch }); +} + const SwiftAutolinkOptions = struct { libraries: []const []const u8, frameworks: []const []const u8, @@ -1819,29 +1830,29 @@ fn swiftAutolinkCacheDigest( /// Swift records link directives in Mach-O objects, but Zig's Mach-O linker /// does not consume LC_LINKER_OPTION. Compile one import-only object for the -/// requested framework modules and ask Xcode's otool for that toolchain's -/// exact runtime-library and framework closure. Framework directives are used -/// to preserve deployment-aware SDK re-exports; only app-declared modules -/// become strong direct framework dependencies. The probe is content-addressed under -/// the app's build cache, including compiler target-info and SDK settings, so -/// replacing Xcode in place cannot reuse stale directives. +/// declared Swift modules and framework modules, then ask Xcode's otool for +/// that toolchain's exact runtime-library and framework closure. Framework +/// directives preserve deployment-aware SDK re-exports; only app-declared +/// frameworks become strong direct framework dependencies. The probe is +/// content-addressed under the app's build cache, including compiler target-info +/// and SDK settings, so replacing Xcode in place cannot reuse stale directives. fn swiftAutolinkOptions( b: *std.Build, swiftc: []const u8, sdk: []const u8, target: std.Build.ResolvedTarget, optimize: std.builtin.OptimizeMode, + modules: []const []const u8, frameworks: []const []const u8, identity: SwiftToolchainCacheIdentity, ) SwiftAutolinkOptions { var source: std.ArrayList(u8) = .empty; source.appendSlice(b.allocator, "// Generated import probe for Swift LC_LINKER_OPTION discovery.\n") catch @panic("OOM"); + for (modules) |module_name| { + appendSwiftProbeImport(b, &source, module_name, "module"); + } for (frameworks) |framework| { - if (!swiftModuleNameValid(framework)) { - std.debug.panic("\naddSwiftAppSources framework {s} is not a Swift module identifier; pass importable Apple framework module names such as AppKit or AVFoundation\n", .{framework}); - } - const import = std.fmt.allocPrint(b.allocator, "import {s}\n", .{framework}) catch @panic("OOM"); - source.appendSlice(b.allocator, import) catch @panic("OOM"); + appendSwiftProbeImport(b, &source, framework, "framework"); } source.appendSlice(b.allocator, "@_cdecl(\"native_swift_autolink_probe\") public func nativeSwiftAutolinkProbe() {}\n") catch @panic("OOM"); @@ -1889,7 +1900,7 @@ fn swiftAutolinkOptions( defer b.allocator.free(compile.stdout); defer b.allocator.free(compile.stderr); if (compile.term != .exited or compile.term.exited != 0) { - std.debug.panic("\naddSwiftAppSources could not import its declared Swift frameworks for autolink discovery:\n{s}\n", .{compile.stderr}); + std.debug.panic("\naddSwiftAppSources could not import its declared Swift modules and frameworks for autolink discovery:\n{s}\n", .{compile.stderr}); } } @@ -1909,6 +1920,16 @@ fn swiftAutolinkOptions( return parseSwiftAutolinkOptions(b.allocator, inspect.stdout); } +fn appendSwiftProbeImport(b: *std.Build, source: *std.ArrayList(u8), module_name: []const u8, kind: []const u8) void { + if (!swiftModuleNameValid(module_name)) { + std.debug.panic("\naddSwiftAppSources {s} {s} is not a Swift module identifier; pass importable module names such as RegexBuilder or Apple framework names such as AppKit\n", .{ kind, module_name }); + } + const import = std.fmt.allocPrint(b.allocator, "import {s}\n", .{module_name}) catch @panic("OOM"); + if (std.mem.indexOf(u8, source.items, import) == null) { + source.appendSlice(b.allocator, import) catch @panic("OOM"); + } +} + fn swiftModuleNameValid(name: []const u8) bool { if (name.len == 0 or !std.ascii.isAlphabetic(name[0]) and name[0] != '_') return false; for (name[1..]) |char| if (!std.ascii.isAlphanumeric(char) and char != '_') return false; @@ -2643,6 +2664,9 @@ pub fn addAppArtifacts(b: *std.Build, dep: *std.Build.Dependency, app_options: A // build graph knows the packaged binary's REAL mode, so forward // it instead of letting the CLI assume one. package_run.addArgs(&.{ "--optimize", @tagName(app_optimize) }); + if (host_os == .macos) { + package_run.addArgs(&.{ "--macos-minimum", macosDeploymentVersion(b, app_options.macos_minimum) }); + } // Forward the RESOLVED web-layer decision, never the raw inputs: // this graph already decided web vs native-only for the exe it is // packaging (app.zon declarations plus -Dweb-layer/-Dweb-engine), diff --git a/docs/src/app/docs/cli/page.mdx b/docs/src/app/docs/cli/page.mdx index 96d752f87..9e2919f6e 100644 --- a/docs/src/app/docs/cli/page.mdx +++ b/docs/src/app/docs/cli/page.mdx @@ -134,6 +134,8 @@ Package the app for distribution. The manifest is picked up at `app.json` (falli
Path to frontend assets directory.
--optimize
Optimization level.
+
--macos-minimum
+
macOS deployment floor written to LSMinimumSystemVersion. Standard zig build package graphs pass the executable's configured floor automatically.
--web-engine
Temporarily override the app manifest with system or macOS-only chromium.
--web-layer
diff --git a/src/tooling/package.zig b/src/tooling/package.zig index 937d9b154..675707843 100644 --- a/src/tooling/package.zig +++ b/src/tooling/package.zig @@ -61,6 +61,9 @@ pub const PackageOptions = struct { metadata: manifest_tool.Metadata, target: PackageTarget = .macos, optimize: []const u8 = "Debug", + /// Deployment floor written to a macOS bundle's Info.plist. Standard app + /// build graphs forward the same value used for the executable link. + macos_minimum: []const u8 = "11.0", output_path: []const u8, /// Project root used to resolve app.zon-relative packaging inputs such /// as a custom DMG background. The CLI derives it from --manifest. @@ -201,6 +204,10 @@ pub fn artifactName(buffer: []u8, metadata: manifest_tool.Metadata, target: Pack } pub fn createPackage(allocator: std.mem.Allocator, io: std.Io, options: PackageOptions) !PackageStats { + if (options.target == .macos and !validMacosMinimumVersion(options.macos_minimum)) { + std.debug.print("error: --macos-minimum must be one to three dot-separated numeric components with a non-zero major version (for example 12.0)\n", .{}); + return error.InvalidMacOSMinimumVersion; + } // Keep the manifest's accessory-app safety invariant at the artifact // boundary too. CLI package verbs and direct callers receive Metadata, // not the typed manifest that `native validate` checks, so without this @@ -355,7 +362,7 @@ pub fn createMacosApp(allocator: std.mem.Allocator, io: std.Io, options: Package try makeExecutable(package_dir, io, service_subpath); } - const info_plist = try macosInfoPlist(allocator, options.metadata, executable_name); + const info_plist = try macosInfoPlist(allocator, options.metadata, executable_name, options.macos_minimum); defer allocator.free(info_plist); try writeFile(package_dir, io, "Contents/Info.plist", info_plist); try writeFile(package_dir, io, "Contents/PkgInfo", "APPL????"); @@ -769,7 +776,8 @@ fn appRelativeAssetSubpath(assets_dir: []const u8) ?[]const u8 { return assets_dir; } -fn macosInfoPlist(allocator: std.mem.Allocator, metadata: manifest_tool.Metadata, executable_name: []const u8) ![]const u8 { +fn macosInfoPlist(allocator: std.mem.Allocator, metadata: manifest_tool.Metadata, executable_name: []const u8, minimum_system_version: []const u8) ![]const u8 { + if (!validMacosMinimumVersion(minimum_system_version)) return error.InvalidMacOSMinimumVersion; const icon_name = macosIconFile(metadata); const bundle_id = try xmlEscapeAlloc(allocator, metadata.id); defer allocator.free(bundle_id); @@ -781,6 +789,8 @@ fn macosInfoPlist(allocator: std.mem.Allocator, metadata: manifest_tool.Metadata defer allocator.free(icon); const version = try xmlEscapeAlloc(allocator, metadata.version); defer allocator.free(version); + const minimum = try xmlEscapeAlloc(allocator, minimum_system_version); + defer allocator.free(minimum); const document_types = try macosDocumentTypes(allocator, metadata); defer allocator.free(document_types); const url_types = try macosUrlTypes(allocator, metadata); @@ -822,7 +832,7 @@ fn macosInfoPlist(allocator: std.mem.Allocator, metadata: manifest_tool.Metadata \\ CFBundlePackageType \\ APPL \\ LSMinimumSystemVersion - \\ 11.0 + \\ {s} \\ CFBundleShortVersionString \\ {s} \\ CFBundleVersion @@ -831,7 +841,19 @@ fn macosInfoPlist(allocator: std.mem.Allocator, metadata: manifest_tool.Metadata \\ \\ \\ - , .{ bundle_id, display_name, display_name, executable, icon, version, version, launch_policy, about_line, privacy_descriptions, document_types, url_types }); + , .{ bundle_id, display_name, display_name, executable, icon, minimum, version, version, launch_policy, about_line, privacy_descriptions, document_types, url_types }); +} + +fn validMacosMinimumVersion(value: []const u8) bool { + var parts = std.mem.splitScalar(u8, value, '.'); + var count: usize = 0; + while (parts.next()) |part| { + count += 1; + if (count > 3 or part.len == 0) return false; + const component = std.fmt.parseInt(u16, part, 10) catch return false; + if (count == 1 and component == 0) return false; + } + return count >= 1; } fn metadataHasPermission(metadata: manifest_tool.Metadata, name: []const u8) bool { @@ -3199,7 +3221,7 @@ test "artifact names include metadata target and optimize mode" { test "plist template includes identity executable and version" { const metadata: manifest_tool.Metadata = .{ .id = "dev.example.app", .name = "demo", .display_name = "Demo App", .description = "A demo of the packaging pipeline.", .version = "1.2.3", .icons = &.{"assets/icon.icns"} }; - const plist = try macosInfoPlist(std.testing.allocator, metadata, "demo"); + const plist = try macosInfoPlist(std.testing.allocator, metadata, "demo", "12.3"); defer std.testing.allocator.free(plist); try std.testing.expect(std.mem.indexOf(u8, plist, "CFBundleIdentifier") != null); try std.testing.expect(std.mem.indexOf(u8, plist, "CFBundleDisplayName") != null); @@ -3212,18 +3234,28 @@ test "plist template includes identity executable and version" { try std.testing.expect(std.mem.indexOf(u8, plist, "CFBundleExecutable\n demo") != null); try std.testing.expect(std.mem.indexOf(u8, plist, "icon.icns") != null); try std.testing.expect(std.mem.indexOf(u8, plist, "LSMinimumSystemVersion") != null); - try std.testing.expect(std.mem.indexOf(u8, plist, "11.0") != null); + try std.testing.expect(std.mem.indexOf(u8, plist, "LSMinimumSystemVersion\n 12.3") != null); // The manifest description reaches the About panel's footer key. try std.testing.expect(std.mem.indexOf(u8, plist, "NSHumanReadableCopyright") != null); try std.testing.expect(std.mem.indexOf(u8, plist, "A demo of the packaging pipeline.") != null); // Without a description the key is absent, not emitted empty. const bare: manifest_tool.Metadata = .{ .id = "dev.example.app", .name = "demo", .version = "1.2.3" }; - const bare_plist = try macosInfoPlist(std.testing.allocator, bare, "demo"); + const bare_plist = try macosInfoPlist(std.testing.allocator, bare, "demo", "11.0"); defer std.testing.allocator.free(bare_plist); try std.testing.expect(std.mem.indexOf(u8, bare_plist, "NSHumanReadableCopyright") == null); } +test "macos deployment minimum accepts semantic components and rejects malformed values" { + try std.testing.expect(validMacosMinimumVersion("12")); + try std.testing.expect(validMacosMinimumVersion("12.3")); + try std.testing.expect(validMacosMinimumVersion("12.3.1")); + try std.testing.expect(!validMacosMinimumVersion("0.0")); + try std.testing.expect(!validMacosMinimumVersion("12.")); + try std.testing.expect(!validMacosMinimumVersion("12.beta")); + try std.testing.expect(!validMacosMinimumVersion("12.3.1.4")); +} + test "plist capture usage descriptions follow manifest permissions" { const capture_permissions = [_][]const u8{ "microphone", "system_audio" }; const capture: manifest_tool.Metadata = .{ @@ -3233,7 +3265,7 @@ test "plist capture usage descriptions follow manifest permissions" { .version = "1.0.0", .permissions = &capture_permissions, }; - const plist = try macosInfoPlist(std.testing.allocator, capture, "recorder"); + const plist = try macosInfoPlist(std.testing.allocator, capture, "recorder", "11.0"); defer std.testing.allocator.free(plist); try std.testing.expect(std.mem.indexOf(u8, plist, "NSMicrophoneUsageDescription") != null); try std.testing.expect(std.mem.indexOf(u8, plist, "NSAudioCaptureUsageDescription") != null); @@ -3241,7 +3273,7 @@ test "plist capture usage descriptions follow manifest permissions" { try std.testing.expect(std.mem.indexOf(u8, plist, "Audio & Voice captures microphone audio") != null); const bare: manifest_tool.Metadata = .{ .id = "dev.example.app", .name = "demo", .version = "1.0.0" }; - const bare_plist = try macosInfoPlist(std.testing.allocator, bare, "demo"); + const bare_plist = try macosInfoPlist(std.testing.allocator, bare, "demo", "11.0"); defer std.testing.allocator.free(bare_plist); try std.testing.expect(std.mem.indexOf(u8, bare_plist, "NSMicrophoneUsageDescription") == null); try std.testing.expect(std.mem.indexOf(u8, bare_plist, "NSAudioCaptureUsageDescription") == null); @@ -3255,12 +3287,12 @@ test "plist launch policy follows dock visibility" { .version = "1.0.0", .dock_visible = false, }; - const accessory_plist = try macosInfoPlist(std.testing.allocator, accessory, "menu"); + const accessory_plist = try macosInfoPlist(std.testing.allocator, accessory, "menu", "11.0"); defer std.testing.allocator.free(accessory_plist); try std.testing.expect(std.mem.indexOf(u8, accessory_plist, "LSUIElement\n ") != null); const regular: manifest_tool.Metadata = .{ .id = "dev.example.app", .name = "demo", .version = "1.0.0" }; - const regular_plist = try macosInfoPlist(std.testing.allocator, regular, "demo"); + const regular_plist = try macosInfoPlist(std.testing.allocator, regular, "demo", "11.0"); defer std.testing.allocator.free(regular_plist); try std.testing.expect(std.mem.indexOf(u8, regular_plist, "LSUIElement") == null); } @@ -3303,7 +3335,7 @@ test "plist template includes document and URL registrations" { .file_associations = &associations, .url_schemes = &schemes, }; - const plist = try macosInfoPlist(std.testing.allocator, metadata, "demo"); + const plist = try macosInfoPlist(std.testing.allocator, metadata, "demo", "11.0"); defer std.testing.allocator.free(plist); try std.testing.expect(std.mem.indexOf(u8, plist, "CFBundleDocumentTypes") != null); try std.testing.expect(std.mem.indexOf(u8, plist, "CFBundleTypeRole") != null); diff --git a/tests/swift-toolchain-proof/README.md b/tests/swift-toolchain-proof/README.md index abf4fb015..c1776c3e5 100644 --- a/tests/swift-toolchain-proof/README.md +++ b/tests/swift-toolchain-proof/README.md @@ -5,7 +5,9 @@ ABI into an ordinary Native SDK app without an Xcode project, helper process, or bundled dynamic library. `src/NativeView.swift` exports retained `NSHostingView` construction and -release functions with `@_cdecl`, and exercises AVFoundation's Swift overlay. +release functions with `@_cdecl`, exercises AVFoundation's Swift overlay, and +uses the non-framework `RegexBuilder` module to prove its autolink library is +forwarded to Zig. `src/main.zig` calls only those C symbols; the pointer remains opaque to Zig and ownership stays with the app. @@ -19,9 +21,10 @@ zig build signed-package -Dplatform=macos codesign --verify --deep --strict zig-out/package/swift-toolchain-proof.app ``` -The fixture declares macOS 12.0 once at the app boundary; the Swift object and -final Zig executable must both carry that floor. Its direct load commands must -not absorb newer transitive framework splits such as `SwiftUICore`; the SDK's -back-deployment metadata keeps those symbols on the `SwiftUI` umbrella. +The fixture declares macOS 12.0 at the app boundary; the Swift object, final Zig +executable, and packaged app metadata must all carry that floor. Its direct load +commands must not absorb newer transitive framework splits such as +`SwiftUICore`; the SDK's back-deployment metadata keeps those symbols on the +`SwiftUI` umbrella. It is exercised with Xcode 26 and Swift 6.2 in Phase 1; the build helper emits an actionable error when Xcode, the macOS SDK, or `swiftc` is unavailable. diff --git a/tests/swift-toolchain-proof/build.zig b/tests/swift-toolchain-proof/build.zig index 3ef6bbe30..c5da21f98 100644 --- a/tests/swift-toolchain-proof/build.zig +++ b/tests/swift-toolchain-proof/build.zig @@ -8,14 +8,18 @@ const native_sdk = @import("native_sdk"); pub fn build(b: *std.Build) void { const dep = b.dependency("native_sdk", .{}); + const macos_minimum: native_sdk.MacOSDeploymentTarget = .{ .major = 12 }; const artifacts = native_sdk.addAppArtifacts(b, dep, .{ .name = "swift-toolchain-proof", // The one app-level floor must reach both Zig final links and Swift. - .macos_minimum = .{ .major = 12 }, + .macos_minimum = macos_minimum, }); native_sdk.addSwiftAppSources(b, artifacts, .{ .sources = &.{b.path("src/NativeView.swift")}, .module_name = "NativeViewHost", + // A Swift standard-library module (not an Apple framework) proves + // that its autolink dylib reaches Zig's final link. + .modules = &.{"RegexBuilder"}, // AVFoundation exercises a Swift overlay outside the original // SwiftUI/AppKit closure; its async property API requires macOS 12. .frameworks = &.{ "SwiftUI", "AppKit", "Foundation", "AVFoundation" }, @@ -47,6 +51,8 @@ pub fn build(b: *std.Build) void { package.addArgs(&.{ "--optimize", "ReleaseFast", + "--macos-minimum", + b.fmt("{d}.{d}", .{ macos_minimum.major, macos_minimum.minor }), "--web-layer", "exclude", "--web-engine", diff --git a/tests/swift-toolchain-proof/src/NativeView.swift b/tests/swift-toolchain-proof/src/NativeView.swift index 851c4d4ee..0e7915003 100644 --- a/tests/swift-toolchain-proof/src/NativeView.swift +++ b/tests/swift-toolchain-proof/src/NativeView.swift @@ -1,5 +1,6 @@ import AppKit import AVFoundation +import RegexBuilder import SwiftUI private struct NativeToolchainProofView: View { @@ -13,6 +14,13 @@ private struct NativeToolchainProofView: View { /// creation returns a retained app-owned NSView and the caller releases it. @_cdecl("native_swift_proof_create_view") public func nativeSwiftProofCreateView() -> UnsafeMutableRawPointer { + // RegexBuilder is a Swift library module rather than a framework. Keep + // the deployment floor at 12 while proving its autolink input under an + // ordinary availability guard. + if #available(macOS 13.0, *) { + let digits = Regex { OneOrMore(.digit) } + _ = "123".wholeMatch(of: digits) + } // Exercise AVFoundation's Swift overlay rather than merely the ObjC // framework. This API contributes swiftAVFoundation/CoreMedia overlays. let asset = AVURLAsset(url: URL(fileURLWithPath: "/tmp/native-swift-toolchain-proof")) diff --git a/tools/native-sdk/main.zig b/tools/native-sdk/main.zig index 63beb2987..cd21f2f4d 100644 --- a/tools/native-sdk/main.zig +++ b/tools/native-sdk/main.zig @@ -204,8 +204,8 @@ pub fn main(init: std.process.Init) !void { std.debug.print("bundled {d} assets into {s}\n", .{ stats.asset_count, output_dir }); } else if (std.mem.eql(u8, command, "package")) { checkVerbFlags("package", args[2..], .{ - .usage = "package [--target macos] [--output path] [--binary path] [--service-binary path] [--assets path] [--web-engine system|chromium] [--web-layer auto|include|exclude] [--cef-dir path] [--cef-auto-install] [--signing none|adhoc|identity] [--identity name] [--entitlements path] [--team-id id] [--archive] [--update-archive]", - .value_flags = &.{ "--manifest", "--target", "--output", "--binary", "--service-binary", "--assets", "--web-engine", "--web-layer", "--cef-dir", "--signing", "--identity", "--entitlements", "--team-id", "--optimize" }, + .usage = "package [--target macos] [--output path] [--binary path] [--service-binary path] [--assets path] [--macos-minimum version] [--web-engine system|chromium] [--web-layer auto|include|exclude] [--cef-dir path] [--cef-auto-install] [--signing none|adhoc|identity] [--identity name] [--entitlements path] [--team-id id] [--archive] [--update-archive]", + .value_flags = &.{ "--manifest", "--target", "--output", "--binary", "--service-binary", "--assets", "--macos-minimum", "--web-engine", "--web-layer", "--cef-dir", "--signing", "--identity", "--entitlements", "--team-id", "--optimize" }, .bool_flags = &.{ "--cef-auto-install", "--archive", "--update-archive" }, }); const manifest_path = try flagValue(args, "--manifest") orelse tooling.manifest.defaultPath(init.io) orelse "app.json"; @@ -270,6 +270,7 @@ pub fn main(init: std.process.Init) !void { .metadata = metadata, .target = target, .optimize = optimize_value, + .macos_minimum = try flagValue(args, "--macos-minimum") orelse "11.0", .output_path = output_dir, .project_dir = project_dir, .binary_path = binary_path, @@ -484,7 +485,7 @@ fn usage() void { \\ doctor [--strict] [--manifest app.json] [--web-engine system|chromium] [--cef-dir path] [--cef-auto-install] \\ validate [app.json|app.zon] \\ bundle-assets [app.json|app.zon] [assets] [output] - \\ package [--target macos|windows|linux|ios|android] [--output path] [--binary path] [--service-binary path] [--assets path] [--web-engine system|chromium] [--web-layer auto|include|exclude] [--cef-dir path] [--cef-auto-install] [--signing none|adhoc|identity] [--identity name] [--entitlements path] [--team-id id] [--archive] [--update-archive] + \\ package [--target macos|windows|linux|ios|android] [--output path] [--binary path] [--service-binary path] [--assets path] [--macos-minimum version] [--web-engine system|chromium] [--web-layer auto|include|exclude] [--cef-dir path] [--cef-auto-install] [--signing none|adhoc|identity] [--identity name] [--entitlements path] [--team-id id] [--archive] [--update-archive] \\ dev [--manifest app.json] --binary path [--url http://127.0.0.1:5173/] [--command "npm run dev"] [--timeout-ms 30000] \\ package-windows [--output path] [--binary path] [--service-binary path] \\ package-linux [--output path] [--binary path] [--service-binary path] @@ -968,6 +969,7 @@ fn positionalArg(args: []const []const u8) ?[]const u8 { std.mem.eql(u8, arg, "--binary") or std.mem.eql(u8, arg, "--service-binary") or std.mem.eql(u8, arg, "--assets") or + std.mem.eql(u8, arg, "--macos-minimum") or std.mem.eql(u8, arg, "--web-engine") or std.mem.eql(u8, arg, "--web-layer") or std.mem.eql(u8, arg, "--cef-dir") or From e884d98dea53ef1de9e598bf9363340c3f807560 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Mon, 24 Aug 2026 13:16:57 -0500 Subject: [PATCH 5/6] Fix Swift back-deployment and cache relocation --- .github/workflows/ci.yml | 5 ++ build/app.zig | 80 +++++++++++++++++-- tests/swift-toolchain-proof/README.md | 7 +- .../src/NativeView.swift | 13 ++- 4 files changed, 94 insertions(+), 11 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d6c6a73a5..8bd27860d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -92,6 +92,11 @@ jobs: otool -L zig-out/bin/swift-toolchain-proof | grep -q "libswiftRegexBuilder" ! otool -L zig-out/bin/swift-toolchain-proof | grep -q "/SwiftUICore.framework/" ./zig-out/bin/swift-toolchain-proof --swift-proof-smoke + cd ../.. + cp -R tests/swift-toolchain-proof tests/swift-toolchain-proof-relocated + cd tests/swift-toolchain-proof-relocated + zig build test -Dplatform=macos + cd ../swift-toolchain-proof zig build package -Dplatform=macos test "$(plutil -extract LSMinimumSystemVersion raw zig-out/package/swift-toolchain-proof.app/Contents/Info.plist)" = "12.0" zig build signed-package -Dplatform=macos diff --git a/build/app.zig b/build/app.zig index 889ab5419..5ec6327de 100644 --- a/build/app.zig +++ b/build/app.zig @@ -1704,6 +1704,7 @@ fn compileSwiftObject( options: SwiftAppSourcesOptions, ) std.Build.LazyPath { const triple = swiftMacosTargetTriple(b, target); + const module_cache = swiftModuleCachePath(b, swiftc, sdk, triple); const compile = b.addSystemCommand(&.{swiftc}); compile.addArgs(&.{ "-parse-as-library", @@ -1716,7 +1717,7 @@ fn compileSwiftObject( "-sdk", sdk, "-module-cache-path", - b.pathJoin(&.{ b.cache_root.path orelse ".zig-cache", "native-swift-module-cache" }), + module_cache, }); switch (optimize) { .Debug => compile.addArgs(&.{ "-Onone", "-g" }), @@ -1729,6 +1730,40 @@ fn compileSwiftObject( return object; } +fn swiftModuleCacheDigest( + build_root: []const u8, + cache_root: []const u8, + swiftc: []const u8, + sdk: []const u8, + triple: []const u8, +) [std.crypto.hash.sha2.Sha256.digest_length]u8 { + var digest = std.crypto.hash.sha2.Sha256.init(.{}); + digest.update("native-swift-module-cache-v1\x00"); + for ([_][]const u8{ build_root, cache_root, swiftc, sdk, triple }) |value| { + digest.update(value); + digest.update("\x00"); + } + var result: [std.crypto.hash.sha2.Sha256.digest_length]u8 = undefined; + digest.final(&result); + return result; +} + +/// Clang PCMs record their module-cache path. Include both resolved roots in +/// the directory identity so copying an app together with `.zig-cache` cannot +/// make Swift reopen a PCM that embeds the old app's absolute path. +fn swiftModuleCachePath( + b: *std.Build, + swiftc: []const u8, + sdk: []const u8, + triple: []const u8, +) []const u8 { + const build_root = b.pathFromRoot("."); + const cache_root = b.pathResolve(&.{b.cache_root.path orelse ".zig-cache"}); + const digest_bytes = swiftModuleCacheDigest(build_root, cache_root, swiftc, sdk, triple); + const digest_hex = std.fmt.bytesToHex(digest_bytes, .lower); + return b.pathJoin(&.{ cache_root, b.fmt("native-swift-module-cache-{s}", .{digest_hex[0..16]}) }); +} + fn swiftMacosTargetTriple(b: *std.Build, target: std.Build.ResolvedTarget) []const u8 { const arch_name = switch (target.result.cpu.arch) { .aarch64 => "arm64", @@ -1869,7 +1904,7 @@ fn swiftAutolinkOptions( if (!absoluteBuildFileExists(b, object_path)) { std.Io.Dir.cwd().writeFile(b.graph.io, .{ .sub_path = source_path, .data = source.items }) catch |err| std.debug.panic("\naddSwiftAppSources could not write its autolink probe at {s}: {t}\n", .{ source_path, err }); - const module_cache = b.pathJoin(&.{ b.cache_root.path orelse ".zig-cache", "native-swift-module-cache" }); + const module_cache = swiftModuleCachePath(b, swiftc, sdk, triple); var argv: std.ArrayList([]const u8) = .empty; argv.appendSlice(b.allocator, &.{ swiftc, @@ -2100,6 +2135,28 @@ fn addTbdExports(allocator: std.mem.Allocator, tbd: []const u8, symbols: []const return result.toOwnedSlice(allocator) catch @panic("OOM"); } +/// Copy a split framework's complete export blocks into its compatibility +/// umbrella. New APIs guarded by `#available` still need to resolve at link +/// time even when the app's deployment floor predates the framework split. +/// Keeping the blocks under the umbrella's install name lets the final binary +/// retain only the back-deployable umbrella load command. +fn addTbdExportBlocks(allocator: std.mem.Allocator, parent: []const u8, child: []const u8) []const u8 { + const marker = "exports:\n"; + const parent_marker = std.mem.indexOf(u8, parent, marker) orelse return parent; + const child_marker = std.mem.indexOf(u8, child, marker) orelse return parent; + const child_start = child_marker + marker.len; + const child_end = std.mem.lastIndexOf(u8, child, "\n...") orelse child.len; + if (child_start >= child_end) return parent; + + const insertion = parent_marker + marker.len; + var result: std.ArrayList(u8) = .empty; + result.appendSlice(allocator, parent[0..insertion]) catch @panic("OOM"); + result.appendSlice(allocator, child[child_start..child_end]) catch @panic("OOM"); + if (child[child_end - 1] != '\n') result.append(allocator, '\n') catch @panic("OOM"); + result.appendSlice(allocator, parent[insertion..]) catch @panic("OOM"); + return result.toOwnedSlice(allocator) catch @panic("OOM"); +} + fn addSwiftFrameworkCompatibilityOverlays( b: *std.Build, mod: *std.Build.Module, @@ -2137,7 +2194,7 @@ fn addSwiftFrameworkCompatibilityOverlays( const rewritten = removeTbdReexport(b.allocator, parent_tbd, child_install); if (rewritten.ptr == parent_tbd.ptr) continue; for (child_previous_symbols.items) |symbol| appendUniqueString(b.allocator, &previous_symbols, symbol); - parent_tbd = rewritten; + parent_tbd = addTbdExportBlocks(b.allocator, rewritten, child_tbd); changed = true; } if (!changed) continue; @@ -2268,13 +2325,21 @@ pub fn testSwiftBuildHelpers() !void { try std.testing.expect(!std.mem.eql(u8, &base, &compiler_changed)); try std.testing.expect(!std.mem.eql(u8, &base, &sdk_changed)); + const module_cache_a = swiftModuleCacheDigest("/work/app-a", "/work/app-a/.zig-cache", "/Xcode/swiftc", "/Xcode/MacOSX.sdk", "arm64-apple-macosx12.0"); + const module_cache_b = swiftModuleCacheDigest("/work/app-b", "/work/app-b/.zig-cache", "/Xcode/swiftc", "/Xcode/MacOSX.sdk", "arm64-apple-macosx12.0"); + const module_cache_elsewhere = swiftModuleCacheDigest("/work/app-a", "/tmp/app-cache", "/Xcode/swiftc", "/Xcode/MacOSX.sdk", "arm64-apple-macosx12.0"); + try std.testing.expect(!std.mem.eql(u8, &module_cache_a, &module_cache_b)); + try std.testing.expect(!std.mem.eql(u8, &module_cache_a, &module_cache_elsewhere)); + const child_tbd = \\--- !tapi-tbd \\tbd-version: 4 \\install-name: '/System/Library/Frameworks/SwiftUICore.framework/Versions/A/SwiftUICore' \\exports: \\ - targets: [ arm64-macos ] - \\ symbols: [ '$ld$previous$/System/Library/Frameworks/SwiftUI.framework/Versions/A/SwiftUI$$1$10.15$15.0$_$s7SwiftUI4ViewMp$' ] + \\ symbols: [ '$ld$previous$/System/Library/Frameworks/SwiftUI.framework/Versions/A/SwiftUI$$1$10.15$15.0$_$s7SwiftUI4ViewMp$', + \\ '_$s7SwiftUI11glassEffectyyF' ] + \\... ; var previous_symbols: std.ArrayList([]const u8) = .empty; defer { @@ -2304,8 +2369,11 @@ pub fn testSwiftBuildHelpers() !void { const without_reexport = removeTbdReexport(std.testing.allocator, parent_tbd, "/System/Library/Frameworks/SwiftUICore.framework/Versions/A/SwiftUICore"); defer if (without_reexport.ptr != parent_tbd.ptr) std.testing.allocator.free(without_reexport); try std.testing.expect(std.mem.indexOf(u8, without_reexport, "SwiftUICore.framework") == null); - const with_previous = addTbdExports(std.testing.allocator, without_reexport, previous_symbols.items); - defer if (with_previous.ptr != without_reexport.ptr) std.testing.allocator.free(with_previous); + const with_child_exports = addTbdExportBlocks(std.testing.allocator, without_reexport, child_tbd); + defer if (with_child_exports.ptr != without_reexport.ptr) std.testing.allocator.free(with_child_exports); + try std.testing.expect(std.mem.indexOf(u8, with_child_exports, "'_$s7SwiftUI11glassEffectyyF'") != null); + const with_previous = addTbdExports(std.testing.allocator, with_child_exports, previous_symbols.items); + defer if (with_previous.ptr != with_child_exports.ptr) std.testing.allocator.free(with_previous); try std.testing.expect(std.mem.indexOf(u8, with_previous, "'_$s7SwiftUI4ViewMp'") != null); } diff --git a/tests/swift-toolchain-proof/README.md b/tests/swift-toolchain-proof/README.md index c1776c3e5..cabe0cd35 100644 --- a/tests/swift-toolchain-proof/README.md +++ b/tests/swift-toolchain-proof/README.md @@ -5,9 +5,10 @@ ABI into an ordinary Native SDK app without an Xcode project, helper process, or bundled dynamic library. `src/NativeView.swift` exports retained `NSHostingView` construction and -release functions with `@_cdecl`, exercises AVFoundation's Swift overlay, and -uses the non-framework `RegexBuilder` module to prove its autolink library is -forwarded to Zig. +release functions with `@_cdecl`, exercises AVFoundation's Swift overlay, uses +the non-framework `RegexBuilder` module to prove its autolink library is +forwarded to Zig, and calls a macOS 26 SwiftUI API behind an availability guard +to prove newer split-framework symbols remain linkable at the macOS 12 floor. `src/main.zig` calls only those C symbols; the pointer remains opaque to Zig and ownership stays with the app. diff --git a/tests/swift-toolchain-proof/src/NativeView.swift b/tests/swift-toolchain-proof/src/NativeView.swift index 0e7915003..198759048 100644 --- a/tests/swift-toolchain-proof/src/NativeView.swift +++ b/tests/swift-toolchain-proof/src/NativeView.swift @@ -5,8 +5,17 @@ import SwiftUI private struct NativeToolchainProofView: View { var body: some View { - Text("Native SDK Swift toolchain proof") - .padding(24) + if #available(macOS 26.0, *) { + // SwiftUICore owns this macOS 26 symbol. The macOS 12 build must + // resolve it through SwiftUI without gaining a SwiftUICore load + // command, exactly as Apple's linker handles guarded new APIs. + Text("Native SDK Swift toolchain proof") + .padding(24) + .glassEffect() + } else { + Text("Native SDK Swift toolchain proof") + .padding(24) + } } } From 90872b82cd106590618c5fe93ea90d89955ceb5f Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Mon, 24 Aug 2026 13:48:26 -0500 Subject: [PATCH 6/6] Support Swift proof on Xcode 15 --- tests/swift-toolchain-proof/README.md | 4 +++- tests/swift-toolchain-proof/src/NativeView.swift | 7 +++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/tests/swift-toolchain-proof/README.md b/tests/swift-toolchain-proof/README.md index cabe0cd35..13f461193 100644 --- a/tests/swift-toolchain-proof/README.md +++ b/tests/swift-toolchain-proof/README.md @@ -8,7 +8,9 @@ or bundled dynamic library. release functions with `@_cdecl`, exercises AVFoundation's Swift overlay, uses the non-framework `RegexBuilder` module to prove its autolink library is forwarded to Zig, and calls a macOS 26 SwiftUI API behind an availability guard -to prove newer split-framework symbols remain linkable at the macOS 12 floor. +under Swift 6.2+ to prove newer split-framework symbols remain linkable at the +macOS 12 floor. Older supported Xcodes compile the same fixture through its +compiler-gated fallback. `src/main.zig` calls only those C symbols; the pointer remains opaque to Zig and ownership stays with the app. diff --git a/tests/swift-toolchain-proof/src/NativeView.swift b/tests/swift-toolchain-proof/src/NativeView.swift index 198759048..b4f08ca59 100644 --- a/tests/swift-toolchain-proof/src/NativeView.swift +++ b/tests/swift-toolchain-proof/src/NativeView.swift @@ -5,6 +5,7 @@ import SwiftUI private struct NativeToolchainProofView: View { var body: some View { + #if compiler(>=6.2) if #available(macOS 26.0, *) { // SwiftUICore owns this macOS 26 symbol. The macOS 12 build must // resolve it through SwiftUI without gaining a SwiftUICore load @@ -16,6 +17,12 @@ private struct NativeToolchainProofView: View { Text("Native SDK Swift toolchain proof") .padding(24) } + #else + // Xcode 15.4 is the oldest supported CI toolchain and predates the + // split-framework API used by the newer-toolchain regression above. + Text("Native SDK Swift toolchain proof") + .padding(24) + #endif } }