craft-native 0.0.396 → 0.0.408

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7629,6 +7629,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
7629
7629
  appearance.configureWithOpaqueBackground()
7630
7630
  appearance.backgroundColor = screenBackgroundColor ?? config.resolvedBackgroundColor
7631
7631
  appearance.shadowColor = .clear
7632
+ CraftChromeFonts.apply(to: appearance)
7632
7633
  navigationItem.standardAppearance = appearance
7633
7634
  navigationItem.scrollEdgeAppearance = appearance
7634
7635
  navigationItem.compactAppearance = appearance
@@ -517,6 +517,95 @@ enum CraftSymbols {
517
517
  /// controller, which offers no way to say which style it wants, so the first
518
518
  /// call teaches that controller's class to answer from here. Only that exact
519
519
  /// class is touched, and only once.
520
+ /// The app's typefaces on UIKit's own chrome. A screen's text follows the
521
+ /// stx tree's `fontFamily`; the navigation bar, the tab bar and bar button
522
+ /// labels are UIKit's, outside that tree, so a branded app drew them in the
523
+ /// system face beside screens in its own. `fontFamily` / `headingFontFamily`
524
+ /// (CSS stacks, as the screens use) put the same faces there, through the
525
+ /// appearance proxies every bar starts from.
526
+ enum CraftChromeFonts {
527
+ /// The packaged family a body or heading stack resolved to; nil keeps the system face.
528
+ private(set) static var body: String?
529
+ private(set) static var heading: String?
530
+
531
+ /// The first family of a CSS stack the app can draw. A generic family
532
+ /// ahead of any packaged one means the system face: nil.
533
+ static func family(_ stack: String?) -> String? {
534
+ guard let stack else { return nil }
535
+ for part in stack.split(separator: ",") {
536
+ let name = part.trimmingCharacters(in: .whitespacesAndNewlines).trimmingCharacters(in: CharacterSet(charactersIn: "\"'"))
537
+ switch name.lowercased() {
538
+ case "": continue
539
+ case "sans-serif", "ui-sans-serif", "system-ui", "-apple-system", "serif", "ui-serif", "monospace", "ui-monospace": return nil
540
+ default: if UIFont(name: name, size: 12) != nil { return name }
541
+ }
542
+ }
543
+ return nil
544
+ }
545
+
546
+ /// `family` at a chrome size, bold for a heavy weight and scaled with Dynamic Type.
547
+ static func font(_ family: String?, size: CGFloat, weight: UIFont.Weight, textStyle: UIFont.TextStyle) -> UIFont? {
548
+ guard let family, let custom = UIFont(name: family, size: size) else { return nil }
549
+ var traits = custom.fontDescriptor.symbolicTraits
550
+ if weight >= .semibold { traits.insert(.traitBold) }
551
+ let descriptor = custom.fontDescriptor.withSymbolicTraits(traits) ?? custom.fontDescriptor
552
+ return UIFontMetrics(forTextStyle: textStyle).scaledFont(for: UIFont(descriptor: descriptor, size: size))
553
+ }
554
+
555
+ static var titleFont: UIFont? { font(heading, size: 17, weight: .semibold, textStyle: .headline) }
556
+ static var largeTitleFont: UIFont? { font(heading, size: 34, weight: .bold, textStyle: .largeTitle) }
557
+ static var tabLabelFont: UIFont? { font(body, size: 10, weight: .medium, textStyle: .caption2) }
558
+ static var barButtonFont: UIFont? { font(body, size: 17, weight: .regular, textStyle: .body) }
559
+
560
+ static func configure(_ config: CraftConfig) {
561
+ body = family(config.fontFamily)
562
+ heading = family(config.headingFontFamily) ?? body
563
+ if let title = titleFont, let large = largeTitleFont {
564
+ let bar = UINavigationBar.appearance()
565
+ bar.titleTextAttributes = [.font: title]
566
+ bar.largeTitleTextAttributes = [.font: large]
567
+ }
568
+ if let label = tabLabelFont {
569
+ for state in [UIControl.State.normal, .selected] {
570
+ UITabBarItem.appearance().setTitleTextAttributes([.font: label], for: state)
571
+ }
572
+ }
573
+ if let button = barButtonFont {
574
+ for state in [UIControl.State.normal, .highlighted, .disabled] {
575
+ UIBarButtonItem.appearance().setTitleTextAttributes([.font: button], for: state)
576
+ }
577
+ }
578
+ }
579
+
580
+ /// The label face on a tab bar. The Liquid Glass bar reads its labels from
581
+ /// its own appearance and ignores UITabBarItem's proxy, so the face goes on
582
+ /// a copy of the bar's appearance, which keeps its material untouched.
583
+ static func apply(to tabBar: UITabBar) {
584
+ guard let label = tabLabelFont else { return }
585
+ func labelled(_ appearance: UITabBarAppearance) -> UITabBarAppearance {
586
+ let copy = appearance.copy()
587
+ for layout in [copy.stackedLayoutAppearance, copy.inlineLayoutAppearance, copy.compactInlineLayoutAppearance] {
588
+ for state in [layout.normal, layout.selected, layout.focused, layout.disabled] {
589
+ state.titleTextAttributes[.font] = label
590
+ }
591
+ }
592
+ return copy
593
+ }
594
+ tabBar.standardAppearance = labelled(tabBar.standardAppearance)
595
+ if let edge = tabBar.scrollEdgeAppearance { tabBar.scrollEdgeAppearance = labelled(edge) }
596
+ }
597
+
598
+ /// The title faces on a bar appearance a screen builds itself (an opaque bar).
599
+ static func apply(to appearance: UINavigationBarAppearance) {
600
+ if let title = titleFont { appearance.titleTextAttributes[.font] = title }
601
+ if let large = largeTitleFont { appearance.largeTitleTextAttributes[.font] = large }
602
+ if let button = barButtonFont {
603
+ appearance.buttonAppearance.normal.titleTextAttributes[.font] = button
604
+ appearance.doneButtonAppearance.normal.titleTextAttributes[.font] = button
605
+ }
606
+ }
607
+ }
608
+
520
609
  enum CraftStatusBar {
521
610
  static var style: UIStatusBarStyle = .default
522
611
  private static var taught = Set<ObjectIdentifier>()
@@ -17,7 +17,7 @@ import Darwin
17
17
  /// openurl <url> opens a link through the system, answering "Open in the app?"
18
18
  /// universallink <https-url> waits up to 60s for an OS-verified link; never opens a browser
19
19
  /// shot <name> screenshot → shots/<name>.png
20
- /// tree <name> accessibility tree → shots/<name>.txt
20
+ /// tree <name> accessibility tree + identified snapshot values → shots/<name>.txt / <name>-values.json
21
21
  /// tap <text> first hittable element whose label contains text
22
22
  /// tapif <text> tap it when it is on screen, else carry on (the big timer's "Show the controls")
23
23
  /// tapid <identifier>
@@ -367,6 +367,22 @@ final class DriverTests: XCTestCase {
367
367
  post("/save?name=\(arg).png", shot.pngRepresentation)
368
368
  case "tree":
369
369
  post("/save?name=\(arg).txt", Data(app.debugDescription.utf8))
370
+ // debugDescription abbreviates values further than the snapshot.
371
+ // Retain snapshot values separately; long receipts chunk themselves.
372
+ do {
373
+ var values: [[String: String]] = []
374
+ func collect(_ node: XCUIElementSnapshot) {
375
+ if !node.identifier.isEmpty, let value = node.value as? String {
376
+ values.append(["id": node.identifier, "label": node.label, "value": value])
377
+ }
378
+ for child in node.children { collect(child) }
379
+ }
380
+ collect(try app.snapshot())
381
+ let data = try JSONSerialization.data(withJSONObject: values, options: [.sortedKeys])
382
+ post("/save?name=\(arg)-values.json", data)
383
+ } catch {
384
+ XCTFail("Could not retain accessibility values: \(error)")
385
+ }
370
386
  case "tap":
371
387
  if let target = waitFor(arg, 10) { press(target); note("tap \(arg): ok") }
372
388
  else { XCTFail("Element not found"); note("FAIL tap \(arg): NOT FOUND") }
@@ -1,15 +1,26 @@
1
+ export { macosToolchain, pantryExecutable, resolveMacOSToolchain, resolveZig, toolchainCacheDir } from './toolchain.js';
2
+ export type { MacOSToolchain } from './toolchain.js';
1
3
  export interface MacOSNativeBuildOptions {
2
4
  outdir: string;
3
5
  /** A compiled stx native route bundle, never HTML. */
4
6
  nativeBundle: string;
7
+ /** Defaults to the pinned compiler, installed by Pantry (see `resolveMacOSToolchain`). */
5
8
  zig?: string;
6
- /** zig-js checkout and its public static archive. No system JSC fallback. */
7
- zigJS: {
9
+ /** zig-js checkout and its public static archive. Default: the pinned revision, fetched and built once. No system JSC fallback. */
10
+ zigJS?: {
8
11
  source: string;
9
12
  library: string;
10
13
  };
14
+ /** Where the toolchain is cached; default `~/.cache/craft/toolchain`. */
15
+ toolchainDir?: string;
16
+ /**
17
+ * Link WebKit so screens can show web pages in a `<WebView tab="...">` and drive
18
+ * tabs through `craft.web` (a browser, a help viewer). Off by default: the host
19
+ * then links no WebKit at all, and `WebView` reports itself unsupported.
20
+ */
21
+ webkit?: boolean;
11
22
  }
12
- /** Compile Craft's AppKit host and the shared Zig layout core. No WebKit. */
23
+ /** Compile Craft's AppKit host and the shared Zig layout core. No WebKit unless `webkit` is set. */
13
24
  export declare function buildMacOSNative(options: MacOSNativeBuildOptions): Promise<{
14
25
  executable: string;
15
26
  bundle: string;
@@ -1,18 +1,109 @@
1
1
  // @bun
2
2
  // ../macos/src/index.ts
3
3
  import { cp, mkdir } from "fs/promises";
4
- import { existsSync } from "fs";
5
- import { join, resolve } from "path";
4
+ import { existsSync as existsSync2 } from "fs";
5
+ import { join as join2, resolve } from "path";
6
+ import process2 from "process";
7
+
8
+ // ../macos/src/toolchain.ts
9
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
10
+ import { homedir } from "os";
11
+ import { join } from "path";
6
12
  import process from "process";
13
+ var macosToolchain = {
14
+ zig: "0.18.0-dev.252+86dc5ce25",
15
+ sources: {
16
+ "zig-js": { repository: "zig-utils/zig-js", revision: "f0072c95780712d1bb789280e08e7e390e83ab94", paths: ["src", "include", "tests", "tools", "docs", "bench", "conformance"] },
17
+ "zig-regex": { repository: "zig-utils/zig-regex", revision: "fdd9c2e333ea6ac2fd06d080d939ef7f11d61b1b", paths: ["src"] },
18
+ "zig-gc": { repository: "zig-utils/zig-gc", revision: "0285ef801dd221a4ae8e88b31a09ae31627657a3", paths: ["src"] }
19
+ }
20
+ };
21
+ var defaultRun = async (args, cwd) => {
22
+ const child = Bun.spawn(args, { cwd, stdout: "pipe", stderr: "pipe" });
23
+ const [exitCode, stdout, stderr] = await Promise.all([child.exited, new Response(child.stdout).text(), new Response(child.stderr).text()]);
24
+ return { exitCode, stdout, stderr };
25
+ };
26
+ function toolchainCacheDir(env = process.env) {
27
+ return env.CRAFT_TOOLCHAIN_DIR || join(env.XDG_CACHE_HOME || join(env.HOME || homedir(), ".cache"), "craft", "toolchain");
28
+ }
29
+ function pantryExecutable(env = process.env, exists = existsSync) {
30
+ const home = env.HOME || homedir();
31
+ return [env.PANTRY_BIN, join(process.cwd(), "pantry/.bin/pantry"), join(home, ".local/bin/pantry")].find((path) => path && exists(path)) ?? "pantry";
32
+ }
33
+ async function resolveZig(options = {}) {
34
+ const env = options.env ?? process.env;
35
+ if (env.CRAFT_ZIG)
36
+ return env.CRAFT_ZIG;
37
+ const cache = options.cacheDir ?? toolchainCacheDir(env);
38
+ const zig = join(cache, "pantry/ziglang.org", `v${macosToolchain.zig}`, "bin/zig");
39
+ if (existsSync(zig))
40
+ return zig;
41
+ const run = options.run ?? defaultRun;
42
+ mkdirSync(cache, { recursive: true });
43
+ options.log?.(`Installing Zig ${macosToolchain.zig} with Pantry`);
44
+ const result = await run([pantryExecutable(env), "install", `ziglang.org@${macosToolchain.zig}`, "--no-save", "--ignore-scripts"], cache);
45
+ if (result.exitCode !== 0 || !existsSync(zig))
46
+ throw new Error(`Pantry could not install ziglang.org@${macosToolchain.zig} into ${cache}: ${result.stderr?.trim().slice(-400) ?? ""}. Install Pantry (https://pantry.dev) or set CRAFT_ZIG.`);
47
+ return zig;
48
+ }
49
+ async function fetchSource(name, cache, run, log) {
50
+ const pin = macosToolchain.sources[name];
51
+ const directory = join(cache, "sources", name);
52
+ const git = async (args) => {
53
+ const result = await run(["git", ...args], directory);
54
+ if (result.exitCode !== 0)
55
+ throw new Error(`git ${args.join(" ")} failed in ${directory}: ${result.stderr?.trim() ?? ""}`);
56
+ return result.stdout.trim();
57
+ };
58
+ mkdirSync(directory, { recursive: true });
59
+ if (!existsSync(join(directory, ".git"))) {
60
+ await git(["init", "--quiet"]);
61
+ await git(["remote", "add", "origin", `https://github.com/${pin.repository}.git`]);
62
+ await git(["sparse-checkout", "set", ...pin.paths]);
63
+ }
64
+ const head = await run(["git", "rev-parse", "HEAD"], directory);
65
+ if (head.exitCode !== 0 || head.stdout.trim() !== pin.revision) {
66
+ log?.(`Fetching ${name} ${pin.revision.slice(0, 8)}`);
67
+ await git(["fetch", "--quiet", "--filter=blob:none", "--depth=1", "origin", pin.revision]);
68
+ await git(["checkout", "--quiet", "--detach", "FETCH_HEAD"]);
69
+ }
70
+ return directory;
71
+ }
72
+ async function resolveMacOSToolchain(options = {}) {
73
+ const env = options.env ?? process.env;
74
+ const run = options.run ?? defaultRun;
75
+ const cache = options.cacheDir ?? toolchainCacheDir(env);
76
+ const zig = await resolveZig({ ...options, cacheDir: cache });
77
+ const source = await fetchSource("zig-js", cache, run, options.log);
78
+ await fetchSource("zig-regex", cache, run, options.log);
79
+ await fetchSource("zig-gc", cache, run, options.log);
80
+ const library = join(source, "zig-out/lib/libzig-js.a");
81
+ const stamp = join(source, "zig-out/.craft-toolchain");
82
+ const wanted = `${macosToolchain.zig}
83
+ ${macosToolchain.sources["zig-js"].revision}
84
+ ${macosToolchain.sources["zig-regex"].revision}
85
+ ${macosToolchain.sources["zig-gc"].revision}
86
+ `;
87
+ if (!existsSync(library) || !existsSync(stamp) || readFileSync(stamp, "utf8") !== wanted) {
88
+ options.log?.("Building the zig-js engine");
89
+ const result = await run([zig, "build", "home-tool-bootstrap", "-Doptimize=ReleaseFast"], source);
90
+ if (result.exitCode !== 0 || !existsSync(library))
91
+ throw new Error(`zig-js did not build (${library}): ${result.stderr?.trim() ?? ""}`);
92
+ writeFileSync(stamp, wanted);
93
+ }
94
+ return { zig, zigJS: { source, library } };
95
+ }
96
+
97
+ // ../macos/src/index.ts
7
98
  async function buildMacOSNative(options) {
8
- if (process.platform !== "darwin")
99
+ if (process2.platform !== "darwin")
9
100
  throw new Error("The AppKit host must be built on macOS.");
10
101
  const outdir = resolve(options.outdir);
11
- const templates = join(import.meta.dir, "../templates");
12
- const sourceRoot = join(import.meta.dir, "../..");
13
- const layout = existsSync(join(sourceRoot, "zig/src/native_layout.zig")) ? join(sourceRoot, "zig/src/native_layout.zig") : join(templates, "native_layout.zig");
14
- const mutation = existsSync(join(sourceRoot, "ios/templates/CraftNativeMutation.swift")) ? join(sourceRoot, "ios/templates/CraftNativeMutation.swift") : join(templates, "CraftNativeMutation.swift");
15
- if (!existsSync(options.nativeBundle))
102
+ const templates = join2(import.meta.dir, "../templates");
103
+ const sourceRoot = join2(import.meta.dir, "../..");
104
+ const layout = existsSync2(join2(sourceRoot, "zig/src/native_layout.zig")) ? join2(sourceRoot, "zig/src/native_layout.zig") : join2(templates, "native_layout.zig");
105
+ const mutation = existsSync2(join2(sourceRoot, "ios/templates/CraftNativeMutation.swift")) ? join2(sourceRoot, "ios/templates/CraftNativeMutation.swift") : join2(templates, "CraftNativeMutation.swift");
106
+ if (!existsSync2(options.nativeBundle))
16
107
  throw new Error(`Native STX bundle not found: ${options.nativeBundle}`);
17
108
  await mkdir(outdir, { recursive: true });
18
109
  async function run(args) {
@@ -20,38 +111,39 @@ async function buildMacOSNative(options) {
20
111
  if (await child.exited !== 0)
21
112
  throw new Error(`Native build failed: ${args[0]}`);
22
113
  }
23
- const zig = options.zig || process.env.CRAFT_ZIG || Bun.which("zig");
24
- if (!zig)
25
- throw new Error("Install the pinned Craft Zig toolchain or set CRAFT_ZIG.");
26
- const layoutLibrary = join(outdir, "libcraft-layout.a");
114
+ const toolchain = options.zig && options.zigJS ? undefined : await resolveMacOSToolchain({ cacheDir: options.toolchainDir, log: (line) => console.log(line) });
115
+ const zig = options.zig || toolchain.zig;
116
+ const layoutLibrary = join2(outdir, "libcraft-layout.a");
27
117
  await run([zig, "build-lib", layout, "-O", "ReleaseSafe", "-static", "-lc", `-femit-bin=${layoutLibrary}`]);
28
- const executable = join(outdir, "craft-native-macos");
29
- const engine = options.zigJS;
30
- if (!existsSync(engine.library))
118
+ const executable = join2(outdir, "craft-native-macos");
119
+ const engine = options.zigJS || toolchain.zigJS;
120
+ if (!existsSync2(engine.library))
31
121
  throw new Error(`Build zig-js first: ${engine.library}`);
32
- if (!existsSync(join(engine.source, "include/module.modulemap")))
122
+ if (!existsSync2(join2(engine.source, "include/module.modulemap")))
33
123
  throw new Error("zig-js must expose its owned ZigJS Swift module.");
34
- const bridge = join(outdir, "zig-js-objc.o");
35
- const dispatch = join(outdir, "zig-js-dispatch.o");
36
- const assembly = join(engine.source, `src/objc_abi_${process.arch === "arm64" ? "aarch64" : "x86_64"}.S`);
37
- await run(["xcrun", "clang", "-fobjc-arc", "-fblocks", "-Wno-incomplete-implementation", "-I", join(engine.source, "include"), "-include", join(engine.source, "include/ZigJS.h"), "-c", join(engine.source, "src/objc_bridge.m"), "-o", bridge]);
38
- await run([zig, "build-obj", join(engine.source, "src/objc_abi_dispatch.zig"), assembly, "-O", "ReleaseFast", `-femit-bin=${dispatch}`]);
124
+ const bridge = join2(outdir, "zig-js-objc.o");
125
+ const dispatch = join2(outdir, "zig-js-dispatch.o");
126
+ const assembly = join2(engine.source, `src/objc_abi_${process2.arch === "arm64" ? "aarch64" : "x86_64"}.S`);
127
+ await run(["xcrun", "clang", "-fobjc-arc", "-fblocks", "-Wno-incomplete-implementation", "-I", join2(engine.source, "include"), "-include", join2(engine.source, "include/ZigJS.h"), "-c", join2(engine.source, "src/objc_bridge.m"), "-o", bridge]);
128
+ await run([zig, "build-obj", join2(engine.source, "src/objc_abi_dispatch.zig"), assembly, "-O", "ReleaseFast", `-femit-bin=${dispatch}`]);
129
+ const web = options.webkit ? ["-D", "CRAFT_WEBKIT", "-framework", "WebKit", join2(templates, "CraftMacWebView.swift")] : [];
39
130
  await run([
40
131
  "swiftc",
41
132
  "-O",
42
133
  "-swift-version",
43
134
  "5",
44
135
  "-I",
45
- join(engine.source, "include"),
136
+ join2(engine.source, "include"),
46
137
  "-framework",
47
138
  "Cocoa",
48
139
  "-framework",
49
140
  "AVKit",
50
141
  "-framework",
51
142
  "UserNotifications",
52
- join(templates, "CraftMacRuntime.swift"),
53
- join(templates, "CraftMacRenderer.swift"),
54
- join(templates, "CraftMacApplication.swift"),
143
+ ...web,
144
+ join2(templates, "CraftMacRuntime.swift"),
145
+ join2(templates, "CraftMacRenderer.swift"),
146
+ join2(templates, "CraftMacApplication.swift"),
55
147
  mutation,
56
148
  bridge,
57
149
  dispatch,
@@ -65,11 +157,16 @@ async function buildMacOSNative(options) {
65
157
  "-o",
66
158
  executable
67
159
  ]);
68
- const bundle = join(outdir, "native-screen.js");
160
+ const bundle = join2(outdir, "native-screen.js");
69
161
  if (resolve(options.nativeBundle) !== bundle)
70
162
  await cp(options.nativeBundle, bundle);
71
163
  return { executable, bundle };
72
164
  }
73
165
  export {
74
- buildMacOSNative
166
+ buildMacOSNative,
167
+ macosToolchain,
168
+ pantryExecutable,
169
+ resolveMacOSToolchain,
170
+ resolveZig,
171
+ toolchainCacheDir
75
172
  };
@@ -0,0 +1,56 @@
1
+ /**
2
+ * What the AppKit host is built with. Pinned here, beside the Swift that needs
3
+ * it, so an app never copies a lock file of its own: the Zig compiler (installed
4
+ * by Pantry, which mirrors Zig dev builds) and the zig-js engine with the two
5
+ * libraries it builds against (fetched at an exact revision).
6
+ */
7
+ export declare const macosToolchain: {
8
+ readonly zig: '0.18.0-dev.252+86dc5ce25';
9
+ readonly sources: {
10
+ readonly 'zig-js': {
11
+ readonly repository: 'zig-utils/zig-js';
12
+ readonly revision: 'f0072c95780712d1bb789280e08e7e390e83ab94';
13
+ readonly paths: readonly ['src', 'include', 'tests', 'tools', 'docs', 'bench', 'conformance'];
14
+ };
15
+ readonly 'zig-regex': {
16
+ readonly repository: 'zig-utils/zig-regex';
17
+ readonly revision: 'fdd9c2e333ea6ac2fd06d080d939ef7f11d61b1b';
18
+ readonly paths: readonly ['src'];
19
+ };
20
+ readonly 'zig-gc': {
21
+ readonly repository: 'zig-utils/zig-gc';
22
+ readonly revision: '0285ef801dd221a4ae8e88b31a09ae31627657a3';
23
+ readonly paths: readonly ['src'];
24
+ };
25
+ };
26
+ };
27
+ export interface MacOSToolchain {
28
+ zig: string;
29
+ zigJS: {
30
+ source: string;
31
+ library: string;
32
+ };
33
+ }
34
+ export type ToolchainRunner = (args: string[], cwd?: string) => Promise<{
35
+ exitCode: number;
36
+ stdout: string;
37
+ stderr?: string;
38
+ }>;
39
+ export interface ResolveToolchainOptions {
40
+ /** Where the compiler and sources are kept. Default: `$CRAFT_TOOLCHAIN_DIR`, else `~/.cache/craft/toolchain`. */
41
+ cacheDir?: string;
42
+ env?: Record<string, string | undefined>;
43
+ run?: ToolchainRunner;
44
+ log?: (line: string) => void;
45
+ }
46
+ export declare function toolchainCacheDir(env?: Record<string, string | undefined>): string;
47
+ /** The Pantry executable: PANTRY_BIN, the project's own, the user's, else `pantry` on PATH. */
48
+ export declare function pantryExecutable(env?: Record<string, string | undefined>, exists?: (path: string) => boolean): string;
49
+ /**
50
+ * The pinned Zig compiler. `CRAFT_ZIG` wins when set (a deliberate override,
51
+ * e.g. CI); otherwise Pantry installs exactly the pinned version into the
52
+ * cache. Zig is never taken from PATH: the wrong snapshot builds nothing.
53
+ */
54
+ export declare function resolveZig(options?: ResolveToolchainOptions): Promise<string>;
55
+ /** Zig, the zig-js checkout at its pinned revision and its built static library; everything missing is fetched and built once. */
56
+ export declare function resolveMacOSToolchain(options?: ResolveToolchainOptions): Promise<MacOSToolchain>;
@@ -4,6 +4,13 @@ import CoreImage
4
4
  import UserNotifications
5
5
  import UniformTypeIdentifiers
6
6
 
7
+ /// A window that tells its screen when Escape reaches it unhandled: the screen hears it as the desktop
8
+ /// command `escape` (close the open item, dismiss a sheet).
9
+ final class CraftMacWindow: NSWindow {
10
+ weak var runtime: CraftMacRuntime?
11
+ override func cancelOperation(_ sender: Any?) { runtime?.send("CRAFT_EVENT", ["event": "craftDesktopCommand", "detail": ["id": "escape"]]) }
12
+ }
13
+
7
14
  final class CraftMacScene: NSObject, NSWindowDelegate {
8
15
  let name: String
9
16
  let window: NSWindow
@@ -14,7 +21,10 @@ final class CraftMacScene: NSObject, NSWindowDelegate {
14
21
  runtime = CraftMacRuntime(route: route, params: options["params"] as? [String: Any] ?? [:], network: application.network)
15
22
  renderer = CraftMacRenderer(runtime: runtime)
16
23
  let width = number(options["width"]) ?? 1120, height = number(options["height"]) ?? 740
17
- window = NSWindow(contentRect: CGRect(x: 0, y: 0, width: width, height: height), styleMask: [.titled, .closable, .resizable, .miniaturizable], backing: .buffered, defer: false)
24
+ let transparent = (options["titlebarStyle"] as? String ?? application.titlebarStyle) == "transparent"
25
+ let panel = CraftMacWindow(contentRect: CGRect(x: 0, y: 0, width: width, height: height), styleMask: transparent ? [.titled, .closable, .resizable, .miniaturizable, .fullSizeContentView] : [.titled, .closable, .resizable, .miniaturizable], backing: .buffered, defer: false)
26
+ panel.runtime = runtime
27
+ window = panel
18
28
  super.init()
19
29
  runtime.scene = self; renderer.window = window
20
30
  window.title = options["title"] as? String ?? application.appName
@@ -54,6 +64,10 @@ final class CraftMacApplication: NSObject, NSApplicationDelegate, UNUserNotifica
54
64
  var bundle = ""
55
65
  var initialRoute = "Main"
56
66
  var titlebarStyle = "standard"
67
+ /// Apps that ship their own agent (Attic) hand out a session cookie at
68
+ /// `/native/session`. An app that talks to a hosted API has no such
69
+ /// endpoint and starts with `--no-session`.
70
+ var requiresSession = true
57
71
  var mainWindowOptions: [String: Any] = [:]
58
72
  var network: CraftMacNetwork!
59
73
  var scenes: [String: CraftMacScene] = [:]
@@ -70,6 +84,11 @@ final class CraftMacApplication: NSObject, NSApplicationDelegate, UNUserNotifica
70
84
  DispatchQueue.main.async { self?.appearanceChanged() }
71
85
  }
72
86
  NSApp.activate(ignoringOtherApps: true)
87
+ if !requiresSession {
88
+ open(name: "main", route: initialRoute, options: mainWindowOptions.merging(["title": appName]) { _, new in new })
89
+ startTestDriver()
90
+ return
91
+ }
73
92
  // The agent issues an HttpOnly cookie. It stays in URLSession, shared
74
93
  // by native windows, and is never handed to the JavaScript context.
75
94
  let request = URLRequest(url: network.base.appendingPathComponent("native/session"))
@@ -174,7 +193,11 @@ final class CraftMacApplication: NSObject, NSApplicationDelegate, UNUserNotifica
174
193
  if let old = bar.items.first(where: { $0.title == title }) { bar.removeItem(old) }
175
194
  let menu = NSMenu(title: title); menu.autoenablesItems = false
176
195
  for item in raw["items"] as? [[String: Any]] ?? [] { menu.addItem(menuItem(item, target: self, action: #selector(command(_:)))) }
177
- addMenu(menu, to: bar)
196
+ let item = NSMenuItem(title: menu.title, action: nil, keyEquivalent: ""); item.submenu = menu
197
+ // A File menu is the first after the app menu; the others sit
198
+ // between Edit and Window, where a Mac app's own menus belong.
199
+ let index = title == "File" ? 1 : (bar.items.firstIndex(where: { $0.title == "Window" }) ?? bar.items.count)
200
+ bar.insertItem(item, at: min(index, bar.items.count))
178
201
  }; reply(true)
179
202
  case "contextMenu":
180
203
  let target = CraftMacMenuTarget(), menu = NSMenu(); menu.autoenablesItems = false
@@ -298,6 +321,16 @@ final class CraftMacApplication: NSObject, NSApplicationDelegate, UNUserNotifica
298
321
  guard box.window?.isVisible == true, !box.isHidden, box.frame.width > 0, box.frame.height > 0 else { return ["ok": false, "error": "Control is not visible"] }
299
322
  if let control = box.control as? NSControl, !control.isEnabled { return ["ok": false, "error": "Control is disabled"] }
300
323
  switch kind {
324
+ case "key":
325
+ // Return, Tab, Escape and the arrows, as the field editor receives them: they reach the same delegate commands a keystroke does.
326
+ let commands = ["return": #selector(NSResponder.insertNewline(_:)), "tab": #selector(NSResponder.insertTab(_:)), "escape": #selector(NSResponder.cancelOperation(_:)),
327
+ "up": #selector(NSResponder.moveUp(_:)), "down": #selector(NSResponder.moveDown(_:)), "backspace": #selector(NSResponder.deleteBackward(_:))]
328
+ guard let name = action["value"] as? String, let command = commands[name] else { return ["ok": false, "error": "Unknown key"] }
329
+ let target: NSView? = (box.control as? NSScrollView)?.documentView ?? box.control
330
+ guard let target, scene.window.makeFirstResponder(target) else { return ["ok": false, "error": "Control cannot take focus"] }
331
+ let editor = (target as? NSTextField)?.currentEditor() as? NSTextView ?? target as? NSTextView
332
+ guard let editor else { return ["ok": false, "error": "Not a text input"] }
333
+ editor.doCommand(by: command)
301
334
  case "click": if let button = box.control as? NSButton { button.performClick(nil) } else { box.pressed() }
302
335
  case "fill":
303
336
  let value = action["value"] as? String ?? ""
@@ -343,18 +376,30 @@ final class CraftMacApplication: NSObject, NSApplicationDelegate, UNUserNotifica
343
376
 
344
377
  @main struct CraftMacMain {
345
378
  static func main() throws {
346
- func argument(_ flag: String, fallback: String = "") -> String { guard let index = CommandLine.arguments.firstIndex(of: flag), CommandLine.arguments.indices.contains(index + 1) else { return fallback }; return CommandLine.arguments[index + 1] }
379
+ // A bundled app is launched by Finder with no arguments: its settings are
380
+ // `Contents/Resources/craft-app.json`, written by the build. A flag on the
381
+ // command line still wins, so a development run can point elsewhere.
382
+ let packaged = Bundle.main.url(forResource: "craft-app", withExtension: "json")
383
+ .flatMap { try? Data(contentsOf: $0) }
384
+ .flatMap { try? JSONSerialization.jsonObject(with: $0) as? [String: Any] } ?? [:]
385
+ func argument(_ flag: String, key: String? = nil, fallback: String = "") -> String {
386
+ if let index = CommandLine.arguments.firstIndex(of: flag), CommandLine.arguments.indices.contains(index + 1) { return CommandLine.arguments[index + 1] }
387
+ if let key, let value = packaged[key] { return String(describing: value) }
388
+ return fallback
389
+ }
347
390
  let application = CraftMacApplication.shared
348
391
  if CommandLine.arguments.contains("--version") { print("craft native macOS renderer 0.0.172 (zig-js)"); return }
349
- guard let api = URL(string: argument("--api-url")), ["http", "https"].contains(api.scheme ?? ""), api.host != nil else { throw NSError(domain: "Craft", code: 1, userInfo: [NSLocalizedDescriptionKey: "--api-url must name the application's HTTP agent"] ) }
350
- let bundlePath = argument("--native-bundle")
392
+ guard let api = URL(string: argument("--api-url", key: "apiUrl")), ["http", "https"].contains(api.scheme ?? ""), api.host != nil else { throw NSError(domain: "Craft", code: 1, userInfo: [NSLocalizedDescriptionKey: "--api-url must name the application's HTTP agent"] ) }
393
+ let bundledScreens = Bundle.main.path(forResource: "native-screen", ofType: "js") ?? ""
394
+ let bundlePath = argument("--native-bundle", fallback: bundledScreens)
351
395
  guard !bundlePath.isEmpty else { throw NSError(domain: "Craft", code: 2, userInfo: [NSLocalizedDescriptionKey: "--native-bundle must name a compiled STX bundle"] ) }
352
396
  application.bundle = try String(contentsOfFile: bundlePath, encoding: .utf8)
353
- application.appName = argument("--title", fallback: "Craft")
354
- application.initialRoute = argument("--route", fallback: "Main")
355
- application.titlebarStyle = argument("--titlebar-style", fallback: "standard")
397
+ application.appName = argument("--title", key: "title", fallback: "Craft")
398
+ application.initialRoute = argument("--route", key: "route", fallback: "Main")
399
+ application.titlebarStyle = argument("--titlebar-style", key: "titlebarStyle", fallback: "standard")
400
+ application.requiresSession = !CommandLine.arguments.contains("--no-session") && packaged["session"] as? Bool != false
356
401
  for (flag, key) in [("--width", "width"), ("--height", "height"), ("--min-width", "minWidth"), ("--min-height", "minHeight")] {
357
- if let value = Double(argument(flag)), value.isFinite, value > 0 { application.mainWindowOptions[key] = value }
402
+ if let value = Double(argument(flag, key: key)), value.isFinite, value > 0 { application.mainWindowOptions[key] = value }
358
403
  }
359
404
  application.network = CraftMacNetwork(base: api)
360
405
  let testDirectory = argument("--test-dir")