Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,27 @@ 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"
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
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
# ceiling), so widen the smoke budgets here instead of weakening the
Expand Down
18 changes: 18 additions & 0 deletions build.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -104,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;
Expand Down Expand Up @@ -625,6 +632,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(&.{ 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
// 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);
Expand Down
790 changes: 784 additions & 6 deletions build/app.zig

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions docs/src/app/docs/cli/page.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,8 @@ Package the app for distribution. The manifest is picked up at `app.json` (falli
<dd>Path to frontend assets directory.</dd>
<dt><code>--optimize</code></dt>
<dd>Optimization level.</dd>
<dt><code>--macos-minimum</code></dt>
<dd>macOS deployment floor written to <code>LSMinimumSystemVersion</code>. Standard <code>zig build package</code> graphs pass the executable's configured floor automatically.</dd>
<dt><code>--web-engine</code></dt>
<dd>Temporarily override the app manifest with <code>system</code> or macOS-only <code>chromium</code>.</dd>
<dt><code>--web-layer</code></dt>
Expand Down
56 changes: 44 additions & 12 deletions src/tooling/package.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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????");
Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand Down Expand Up @@ -822,7 +832,7 @@ fn macosInfoPlist(allocator: std.mem.Allocator, metadata: manifest_tool.Metadata
\\ <key>CFBundlePackageType</key>
\\ <string>APPL</string>
\\ <key>LSMinimumSystemVersion</key>
\\ <string>11.0</string>
\\ <string>{s}</string>
\\ <key>CFBundleShortVersionString</key>
\\ <string>{s}</string>
\\ <key>CFBundleVersion</key>
Expand All @@ -831,7 +841,19 @@ fn macosInfoPlist(allocator: std.mem.Allocator, metadata: manifest_tool.Metadata
\\</dict>
\\</plist>
\\
, .{ 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 {
Expand Down Expand Up @@ -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);
Expand All @@ -3212,18 +3234,28 @@ test "plist template includes identity executable and version" {
try std.testing.expect(std.mem.indexOf(u8, plist, "<key>CFBundleExecutable</key>\n <string>demo</string>") != 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, "<key>LSMinimumSystemVersion</key>\n <string>12.3</string>") != 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 = .{
Expand All @@ -3233,15 +3265,15 @@ 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);
try std.testing.expect(std.mem.indexOf(u8, plist, "NSScreenCaptureUsageDescription") != null);
try std.testing.expect(std.mem.indexOf(u8, plist, "Audio &amp; 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);
Expand All @@ -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, "<key>LSUIElement</key>\n <true/>") != 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);
}
Expand Down Expand Up @@ -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);
Expand Down
33 changes: 33 additions & 0 deletions tests/swift-toolchain-proof/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# 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`, 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
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.

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 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.
24 changes: 24 additions & 0 deletions tests/swift-toolchain-proof/app.zon
Original file line number Diff line number Diff line change
@@ -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",
}
66 changes: 66 additions & 0 deletions tests/swift-toolchain-proof/build.zig
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
//! 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 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 = 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" },
});
// `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 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.install.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",
"--macos-minimum",
b.fmt("{d}.{d}", .{ macos_minimum.major, macos_minimum.minor }),
"--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);
}
Loading
Loading