dotframe 0.1.9 → 0.2.0

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.0
4
+
5
+ From Craft Ones' asks (rounds 7 and 8).
6
+
7
+ - Native textures grow without a cap (it was 256, and iOS aborted with no message past it); `gpu.destroyTexture(texture)` frees one and the bind groups that sample it.
8
+ - iOS: build ios wraps init and frame so a TypeScript throw prints its message and stack before scriptc's trap aborts; `dotframe device logs <target>` copies the newest crash report from the device and summarizes it.
9
+ - Native online play: `dotframe/src/native/relay` (`connectRelayNative`, the same RelayLink as the web, polled per frame) over a WebSocket client in C (NSURLSession on macOS and iOS, WinHTTP on Windows), and `shareText` (share sheet on iOS, clipboard on desktop). `play --online --native` runs a web peer against the macOS build. Inside a scriptc executable, hostnames and TLS do not connect yet (reported to scriptc); IP addresses and localhost do.
10
+ - iOS library callbacks stay under scriptc's 32: the glue registers only the host functions the entry reaches, music and volume are one call, and networking is two.
11
+ - `dotframe/src/ui`: buttons and arrow rows laid out once for drawing and hit-testing, focus, taps from mouse and touch, and a release gate so the press that starts a match does not reach gameplay.
12
+ - Web: `run(window, setup, { fit: "window" })` fills and follows the browser window; `Draw2D.resize`; templates refit on shape changes and use it.
13
+ - `doctor` compile-checks each native target with scriptc (no C, no link), so patterns that only fail natively show up before building for the phone.
14
+ - `deploy init --site <dir> [--path /play/]` serves a static folder at / with the game at a subpath.
15
+ - `dotframe config set vendor <dir>` stores the vendor dir per machine (~/.dotframe/config.json).
16
+ - Fixed: the native templates trapped at startup since 0.1.7 (a setup function passed to run() with a narrower parameter type, and, since 0.1.9, the probe assigning globalThis). The probe no longer touches globalThis (`publishProbe` from `dotframe/src/probe-web` does, on the web), and CI now runs each template binary.
17
+
3
18
  ## 0.1.9
4
19
 
5
20
  The rest of round 7, which missed the 0.1.8 merge.
@@ -1,10 +1,28 @@
1
- import { readFileSync, writeFileSync } from "node:fs";
2
- import { join } from "node:path";
3
- import { CONFIG_FILE, CliError, type Ctx, formatJson, loadConfig, print } from "../lib";
1
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { join, resolve } from "node:path";
3
+ import { CONFIG_FILE, CliError, type Ctx, formatJson, home, loadConfig, loadUserConfig, print, saveUserConfig, USER_KEYS, userConfigPath } from "../lib";
4
4
 
5
5
  // Dotted keys: targets.web.deploy.project
6
6
  export async function configCmd(ctx: Ctx, args: string[]): Promise<void> {
7
7
  const [op, key, value] = args;
8
+ // Machine paths go to ~/.dotframe/config.json, never into the repo.
9
+ if (key && (USER_KEYS as readonly string[]).includes(key)) {
10
+ const user = loadUserConfig();
11
+ if (op === "get") {
12
+ print(ctx, { key, value: user.vendor ?? null, file: userConfigPath() }, (): string => user.vendor ?? "(not set)");
13
+ return;
14
+ }
15
+ if (op !== "set" || value === undefined) throw new CliError("MISSING_ARG", `config set ${key} needs a value`, `dotframe config set ${key} ~/Programming/crafter-games/dotframe/vendor`);
16
+ const dir = resolve(home(value));
17
+ if (!existsSync(dir)) throw new CliError("BAD_ARG", `${dir} does not exist`, "point at a vendor dir that holds wgpu/ and SDL3-*/");
18
+ if (ctx.dryRun) {
19
+ print(ctx, { dryRun: true, key, value: dir, file: userConfigPath() }, (): string => `would set ${key} = ${dir} in ${userConfigPath()}`);
20
+ return;
21
+ }
22
+ saveUserConfig({ ...user, vendor: dir });
23
+ print(ctx, { key, value: dir, file: userConfigPath() }, (): string => `${key} = ${dir} (${userConfigPath()})`);
24
+ return;
25
+ }
8
26
  const config = loadConfig();
9
27
  const file = join(config.root, CONFIG_FILE);
10
28
  const raw = JSON.parse(readFileSync(file, "utf8")) as Record<string, unknown>;
@@ -39,7 +39,13 @@ function versions(gameRoot: string, repo: string): Versions {
39
39
  }
40
40
 
41
41
  // game: the game's path inside the repo ("" at the root); context: the repo root, relative to deploy/.
42
- function files(config: Config, game: string, out: string, context: string, v: Versions): Record<string, string> {
42
+ // site: a static folder (relative to the game root) served at /, with the game at gamePath (for example "/play/").
43
+ interface Layout {
44
+ site?: string;
45
+ gamePath: string;
46
+ }
47
+
48
+ function files(config: Config, game: string, out: string, context: string, v: Versions, layout: Layout): Record<string, string> {
43
49
  const requires = (config.requires ?? []).filter((t: string): boolean => t !== "bun");
44
50
  const apt = requires.length > 0 ? `RUN apt-get update && apt-get install -y --no-install-recommends ${requires.join(" ")} && rm -rf /var/lib/apt/lists/*\n` : "";
45
51
  const at = game === "" ? "." : game;
@@ -52,7 +58,8 @@ RUN cd ${at} && bun install --frozen-lockfile && bun node_modules/dotframe/cli/m
52
58
 
53
59
  FROM nginx:1.27-alpine
54
60
  COPY ${join(at, DIR, "nginx.conf")} /etc/nginx/conf.d/default.conf
55
- COPY --from=build ${join("/app", at, out)} /usr/share/nginx/html
61
+ ${layout.site ? `COPY ${join(at, layout.site)} /usr/share/nginx/html
62
+ ` : ""}COPY --from=build ${join("/app", at, out)} ${join("/usr/share/nginx/html", layout.gamePath)}
56
63
  EXPOSE 80
57
64
  `,
58
65
  "Dockerfile.relay": `# Netplay relay for ${config.name}: dotframe relay serve. Generated by dotframe deploy init.
@@ -64,20 +71,7 @@ ENV PORT=8787
64
71
  EXPOSE 8787
65
72
  CMD ["bun", "node_modules/dotframe/cli/main.ts", "relay", "serve"]
66
73
  `,
67
- "nginx.conf": `server {
68
- listen 80;
69
- server_name _;
70
- root /usr/share/nginx/html;
71
- index index.html;
72
- gzip on;
73
- gzip_types text/css application/javascript application/json image/svg+xml;
74
- gzip_min_length 1024;
75
- # The bundle name is content-hashed; index.html must never be cached (Discord caches aggressively).
76
- location = /index.html { add_header Cache-Control "no-cache"; }
77
- location / { try_files $uri $uri/ /index.html; }
78
- location ~* \\.(png|mp3|js|json)$ { expires 7d; add_header Cache-Control "public"; }
79
- }
80
- `,
74
+ "nginx.conf": nginx(layout),
81
75
  "compose.yaml": `# ${config.name}: the game at / and the netplay relay at /relay on the same host. In Dokploy, point the domain's
82
76
  # / at service web (port 80) and /relay at service relay (port 8787).
83
77
  services:
@@ -102,7 +96,28 @@ services:
102
96
  };
103
97
  }
104
98
 
105
- export async function deployInit(ctx: Ctx, name: string | undefined, provider: string | undefined, compose: string | undefined): Promise<void> {
99
+ function nginx(layout: Layout): string {
100
+ const sub = layout.gamePath !== "/";
101
+ const base = layout.gamePath.replace(/\/$/, "");
102
+ return `server {
103
+ listen 80;
104
+ server_name _;
105
+ root /usr/share/nginx/html;
106
+ index index.html;
107
+ gzip on;
108
+ gzip_types text/css application/javascript application/json image/svg+xml;
109
+ gzip_min_length 1024;
110
+ # Bundles are content-hashed; HTML must never be cached (Discord caches aggressively).
111
+ location ~ \\.html$ { add_header Cache-Control "no-cache"; }
112
+ ${sub ? ` location = ${base} { return 301 ${base}/$is_args$args; }
113
+ location ${base}/ { try_files $uri $uri/ ${base}/index.html; }
114
+ ` : ""} location / { try_files $uri $uri/ /index.html; }
115
+ location ~* \\.(png|mp3|js|json|css|woff2|webp|jpg|ico)$ { expires 7d; add_header Cache-Control "public"; }
116
+ }
117
+ `;
118
+ }
119
+
120
+ export async function deployInit(ctx: Ctx, name: string | undefined, provider: string | undefined, compose: string | undefined, site?: string, path?: string): Promise<void> {
106
121
  if (!name) throw new CliError("MISSING_ARG", "deploy init needs a target", "dotframe deploy init web --provider dokploy", "export-web");
107
122
  if (provider !== "dokploy") throw new CliError("UNSUPPORTED", `deploy init supports --provider dokploy (got ${provider ?? "none"}); vercel needs no files`, "dotframe deploy init web --provider dokploy", "export-web");
108
123
  const config = loadConfig();
@@ -111,7 +126,9 @@ export async function deployInit(ctx: Ctx, name: string | undefined, provider: s
111
126
  const repo = await gitRoot(ctx, config.root);
112
127
  const game = relative(repo, config.root);
113
128
  // compose.yaml sits in <game>/deploy; the web build context is the repo root, so workspace code is visible.
114
- const generated = files(config, game, t.out, relative(join(config.root, DIR), repo) || ".", versions(config.root, repo));
129
+ if (site && !existsSync(join(config.root, site))) throw new CliError("BAD_ARG", `--site ${site} does not exist under the game root`, "pass a folder with an index.html, relative to the game root", "export-web");
130
+ const gamePath = path ? `/${path.replace(/^\/+|\/+$/g, "")}/`.replace("//", "/") : site ? "/play/" : "/";
131
+ const generated = files(config, game, t.out, relative(join(config.root, DIR), repo) || ".", versions(config.root, repo), { site, gamePath });
115
132
  const dir = join(config.root, DIR);
116
133
  const written = Object.keys(generated).map((f: string): string => join(DIR, f));
117
134
  const existing = written.filter((f: string): boolean => existsSync(join(config.root, f)));
@@ -125,7 +142,7 @@ export async function deployInit(ctx: Ctx, name: string | undefined, provider: s
125
142
  // The repo-root .dockerignore is the one docker reads for a repo-root context.
126
143
  if (!existsSync(join(repo, ".dockerignore"))) writeFileSync(join(repo, ".dockerignore"), generated[".dockerignore"]);
127
144
  const raw = JSON.parse(readFileSync(join(config.root, CONFIG_FILE), "utf8")) as { targets: Record<string, { deploy?: unknown }> };
128
- raw.targets[name].deploy = { provider: "dokploy", compose: compose ?? "", dir: DIR };
145
+ raw.targets[name].deploy = { provider: "dokploy", compose: compose ?? "", dir: DIR, ...(site ? { site } : {}), ...(gamePath !== "/" ? { path: gamePath } : {}) };
129
146
  writeFileSync(join(config.root, CONFIG_FILE), `${formatJson(raw)}\n`);
130
147
  print(ctx, { files: written, next: ["git add deploy dotframe.json && git commit && git push", "create the compose in Dokploy from this repo (compose path below)", `dotframe config set targets.${name}.deploy.compose '"<compose id>"'`] }, (): string =>
131
148
  [`wrote ${written.join(", ")}`, `compose path for Dokploy: ${join(game, DIR, "compose.yaml")}`, `then: dotframe config set targets.${name}.deploy.compose '"<compose id>"' and dotframe deploy ${name}`].join("\n"),
@@ -1,10 +1,10 @@
1
1
  import { existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, symlinkSync, unlinkSync } from "node:fs";
2
2
  import { dirname, join, relative, resolve, sep } from "node:path";
3
- import { type Config, type Ctx, findRoot, home, loadConfig, print, which } from "../lib";
3
+ import { CliError, type Config, type Ctx, findRoot, home, loadConfig, print, type Target, which } from "../lib";
4
4
  import type { Draw2D } from "../../src/draw2d";
5
5
  import type { Sim } from "../../src/sim";
6
- import { importGraph, vendorFix, vendorStatus } from "../native";
7
- import { iosPackFix, iosRuntimePack } from "../ios";
6
+ import { buildNative, importGraph, vendorFix, vendorStatus } from "../native";
7
+ import { buildIos, iosPackFix, iosRuntimePack } from "../ios";
8
8
  import { dockerCheck } from "./docker";
9
9
  import { firstDifference, flatten, headlessRun, loadSim } from "../simkit";
10
10
 
@@ -59,6 +59,7 @@ export async function doctor(ctx: Ctx, fix: boolean, docker = false): Promise<vo
59
59
  }
60
60
  for (const [name, t] of Object.entries(config.targets)) {
61
61
  if (!t.native) continue;
62
+ checks.push(await nativeCheck(ctx, config, name, t.native));
62
63
  const v = vendorStatus(t.native.platform);
63
64
  checks.push({ check: `vendor:${name}`, ok: v.missing.length === 0, detail: v.missing.length === 0 ? `${v.dir} (${v.sdl})` : `missing in ${v.dir}: ${v.missing.join(", ")}`, fix: v.missing.length === 0 ? "" : vendorFix(t.native.platform), skill: t.native.platform });
64
65
  checks.push(tool("scriptc", "native builds", "npm i -g scriptc", t.native.platform));
@@ -196,3 +197,23 @@ function mathCheck(simFile: string): Check {
196
197
  fix: "use dotframe/src/detmath (dsin, dcos, datan2, dexp, dpow, ...) in simulation code; mark render-only lines with // dotframe-allow-math, or a render-only file with // dotframe-allow-math-file",
197
198
  };
198
199
  }
200
+
201
+ // Compiles a native target's TypeScript the way the real build does (staging plus scriptc; for iOS the library),
202
+ // without C or linking: as const tuples and other patterns that work on the web fail here, not on the phone.
203
+ async function nativeCheck(ctx: Ctx, config: Config, name: string, native: NonNullable<Target["native"]>): Promise<Check> {
204
+ const base = { check: `native:${name}`, skill: native.platform };
205
+ if (!which("scriptc")) return { ...base, ok: false, detail: "scriptc not found", fix: "npm i -g scriptc" };
206
+ const quiet = { ...ctx, json: true, dryRun: false };
207
+ try {
208
+ const started = performance.now();
209
+ if (native.platform === "ios") await buildIos(quiet, config.root, config.name, name, native, true);
210
+ else await buildNative(quiet, config.root, config.name, name, native, true);
211
+ return { ...base, ok: true, detail: `${native.entry} compiles with scriptc (${Math.round(performance.now() - started)} ms)`, fix: "" };
212
+ } catch (error) {
213
+ if (!(error instanceof CliError)) throw error;
214
+ // Missing tools or packs have their own checks; this one is about the code.
215
+ if (error.code !== "STEP_FAILED") return { ...base, ok: false, detail: error.message.split("\n")[0], fix: error.fix };
216
+ const first = error.message.split("\n").find((l: string): boolean => /error SC\d+/.test(l)) ?? error.message.split("\n")[0];
217
+ return { ...base, ok: false, detail: `scriptc rejects ${native.entry}: ${first.replace(/^.*?\.dotframe\/check\/[^/]+\/(game|tree)\//, "")}`, fix: `read ${error.log}; the macos skill lists what scriptc rejects` };
218
+ }
219
+ }
@@ -1,6 +1,7 @@
1
- import { existsSync, mkdirSync } from "node:fs";
1
+ import { existsSync, mkdirSync, readFileSync, rmSync } from "node:fs";
2
2
  import { join, resolve } from "node:path";
3
3
  import { CliError, type Ctx, exec, loadConfig, num, print, runSteps, target, which } from "../lib";
4
+ import { buildNative, type NativeTarget } from "../native";
4
5
  import { startRelay } from "../relay";
5
6
 
6
7
  interface Probe {
@@ -13,7 +14,7 @@ interface Probe {
13
14
  // Two browsers play one online match through a local relay, each with a scripted masher, and their checksums at
14
15
  // every 30th confirmed frame must agree. The game's web entry honors ?room=, ?relay= and ?mash=, and publishes
15
16
  // globalThis.__dotframe = { frame, confirmed, status, sums } (the templates do; see the netplay skill).
16
- export async function playOnline(ctx: Ctx, room: string | undefined, args: { frames?: string; out?: string; seeds?: string }): Promise<void> {
17
+ export async function playOnline(ctx: Ctx, room: string | undefined, args: { frames?: string; out?: string; seeds?: string; native?: boolean }): Promise<void> {
17
18
  const skill = "netplay";
18
19
  const config = loadConfig();
19
20
  const t = target(config, "web");
@@ -27,6 +28,15 @@ export async function playOnline(ctx: Ctx, room: string | undefined, args: { fra
27
28
  const name = room ?? `play-${Date.now().toString(36)}`;
28
29
  const outDir = resolve(process.cwd(), args.out ?? ".dotframe/play");
29
30
  await runSteps({ ...ctx, json: true }, config.root, t.steps ?? [], "export-web");
31
+ // --native: peer 1 is the macOS build, launched with DOTFRAME_ROOM, DOTFRAME_RELAY, DOTFRAME_MASH and DOTFRAME_PROBE.
32
+ let nativeBinary = "";
33
+ if (args.native) {
34
+ const entry = Object.entries(config.targets).find(([, target]) => target.native?.platform === "macos");
35
+ if (!entry) throw new CliError("UNKNOWN_TARGET", "play --native needs a target with native.platform macos", "add \"macos\": {\"native\": {\"platform\": \"macos\", \"entry\": \"main.native.ts\"}}", skill);
36
+ const [nativeName, nativeTarget] = entry;
37
+ const built = await buildNative({ ...ctx, json: true }, config.root, config.name, nativeName, nativeTarget.native as NativeTarget);
38
+ nativeBinary = built.binary;
39
+ }
30
40
  const web = resolve(config.root, t.out);
31
41
  const relay = startRelay(0);
32
42
  const server = Bun.serve({
@@ -37,7 +47,9 @@ export async function playOnline(ctx: Ctx, room: string | undefined, args: { fra
37
47
  return existsSync(file) ? new Response(Bun.file(file)) : new Response("not found", { status: 404 });
38
48
  },
39
49
  });
40
- const sessions = ["dotframe-play-0", "dotframe-play-1"];
50
+ const sessions = args.native ? ["dotframe-play-0"] : ["dotframe-play-0", "dotframe-play-1"];
51
+ const probeFile = join(outDir, "native-probe.json");
52
+ let nativeProcess: ReturnType<typeof Bun.spawn> | null = null;
41
53
  const ab = (session: string, ...a: string[]) => exec({ ...ctx, json: true }, config.root, { label: `agent-browser ${a[0]}`, argv: ["agent-browser", "--session", session, ...a] });
42
54
  const read = async (session: string): Promise<Probe | null> => {
43
55
  const r = await ab(session, "eval", "JSON.stringify(globalThis.__dotframe ?? null)");
@@ -54,13 +66,30 @@ export async function playOnline(ctx: Ctx, room: string | undefined, args: { fra
54
66
  const opened = await ab(session, "open", url);
55
67
  if (opened.code !== 0) throw new CliError("BROWSER_FAILED", opened.tail, "agent-browser install", skill);
56
68
  }
69
+ if (args.native) {
70
+ mkdirSync(outDir, { recursive: true });
71
+ rmSync(probeFile, { force: true });
72
+ nativeProcess = Bun.spawn([nativeBinary], {
73
+ cwd: config.root,
74
+ env: { ...process.env, DOTFRAME_ROOM: name, DOTFRAME_RELAY: `ws://localhost:${relay.port}`, DOTFRAME_MASH: String(seeds[1]), DOTFRAME_PROBE: probeFile },
75
+ stdout: "ignore",
76
+ stderr: "ignore",
77
+ });
78
+ }
79
+ const readNative = (): Probe | null => {
80
+ try {
81
+ return JSON.parse(readFileSync(probeFile, "utf8")) as Probe;
82
+ } catch {
83
+ return null;
84
+ }
85
+ };
57
86
  let probes: (Probe | null)[] = [null, null];
58
87
  const started = Date.now();
59
88
  // Wait until both peers confirm `frames` frames, or stop making progress.
60
89
  let last = -1;
61
90
  let lastChange = Date.now();
62
91
  for (;;) {
63
- probes = [await read(sessions[0]), await read(sessions[1])];
92
+ probes = [await read(sessions[0]), args.native ? readNative() : await read(sessions[1])];
64
93
  const confirmed = Math.min(...probes.map((p) => p?.confirmed ?? 0));
65
94
  if (confirmed >= frames) break;
66
95
  if (confirmed !== last) {
@@ -76,20 +105,22 @@ export async function playOnline(ctx: Ctx, room: string | undefined, args: { fra
76
105
  const compared = Object.keys(a.sums).filter((f: string): boolean => b.sums[f] !== undefined).map(Number).sort((x, y) => x - y);
77
106
  const mismatch = compared.find((f: number): boolean => a.sums[f] !== b.sums[f]);
78
107
  const shots = sessions.map((_s, i) => join(outDir, `peer-${i}.png`));
108
+ // The native peer's window is not captured; its checksums are the comparison.
79
109
  mkdirSync(outDir, { recursive: true });
80
110
  for (const [i, session] of sessions.entries()) {
81
111
  const shot = await ab(session, "screenshot", shots[i]);
82
112
  if (shot.code !== 0 || !existsSync(shots[i])) throw new CliError("BROWSER_FAILED", `screenshot of peer ${i} failed: ${shot.tail}`, "", skill);
83
113
  }
84
114
  const ok = mismatch === undefined && compared.length > 0;
85
- print(ctx, { ok, room: name, seeds, frames: Math.min(a.confirmed, b.confirmed), compared: compared.length, firstMismatch: mismatch ?? null, seconds: Math.round((Date.now() - started) / 1000), screenshots: shots }, (): string =>
115
+ print(ctx, { ok, room: name, seeds, peers: args.native ? ["web", "native macOS"] : ["web", "web"], frames: Math.min(a.confirmed, b.confirmed), compared: compared.length, firstMismatch: mismatch ?? null, seconds: Math.round((Date.now() - started) / 1000), screenshots: shots }, (): string =>
86
116
  [
87
- ok ? `in sync: ${compared.length} checksums compared over ${Math.min(a.confirmed, b.confirmed)} confirmed frames` : mismatch !== undefined ? `DESYNC at frame ${mismatch}` : "no checksums to compare",
117
+ ok ? `in sync${args.native ? " (web vs native macOS)" : ""}: ${compared.length} checksums compared over ${Math.min(a.confirmed, b.confirmed)} confirmed frames` : mismatch !== undefined ? `DESYNC at frame ${mismatch}` : "no checksums to compare",
88
118
  `screenshots: ${shots.join(", ")} (open them and look)`,
89
119
  ].join("\n"),
90
120
  );
91
121
  if (!ok) process.exit(1);
92
122
  } finally {
123
+ nativeProcess?.kill();
93
124
  for (const session of sessions) await ab(session, "close");
94
125
  server.stop(true);
95
126
  relay.stop(true);
@@ -1,4 +1,4 @@
1
- import { existsSync, readdirSync, statSync } from "node:fs";
1
+ import { existsSync, mkdirSync, readdirSync, readFileSync, statSync } from "node:fs";
2
2
  import { join, resolve } from "node:path";
3
3
  import { buildIos } from "../ios";
4
4
  import { deployDokploy } from "./docker";
@@ -142,3 +142,59 @@ export async function vendor(ctx: Ctx, platform: string | undefined): Promise<vo
142
142
  if (after.missing.length > 0) throw new CliError("VENDOR_MISSING", `vendor.sh finished but ${after.missing.join(", ")} is still missing`, "", "macos");
143
143
  print(ctx, { platform: p, dir, ready: true }, (): string => `vendor for ${p} ready in ${dir}`);
144
144
  }
145
+
146
+ interface CrashFile {
147
+ name: string;
148
+ metadata: { lastModDate: string };
149
+ }
150
+
151
+ // The newest crash report of a target's app on the device, summarized: exception, termination, and the crashed
152
+ // thread's frames. iOS keeps reports as <Process>-<date>.ips under systemCrashLogs. Read-only on the device.
153
+ export async function deviceLogs(ctx: Ctx, name: string): Promise<void> {
154
+ const config = loadConfig();
155
+ const t = target(config, name);
156
+ if (!t.device) throw new CliError("NO_DEVICE", `target "${name}" needs "device" in dotframe.json`, "xcrun devicectl list devices", "ios");
157
+ const builtApp = existsSync(join(config.root, "dist", name)) ? readdirSync(join(config.root, "dist", name)).find((f: string): boolean => f.endsWith(".app")) : undefined;
158
+ const processName = (t.app ? t.app.split("/").pop() : builtApp)?.replace(/\.app$/, "");
159
+ if (!processName) throw new CliError("NOT_BUILT", "no built app to name the process", `dotframe build ${name}`, "ios");
160
+ const logDir = join(config.root, ".dotframe", "logs");
161
+ mkdirSync(logDir, { recursive: true });
162
+ const listing = join(logDir, "device-crashes.json");
163
+ const list = await exec({ ...ctx, json: true }, config.root, { label: "devicectl files", argv: ["xcrun", "devicectl", "device", "info", "files", "--device", t.device, "--domain-type", "systemCrashLogs", "--json-output", listing] });
164
+ if (list.code !== 0) throw new CliError("DEVICE_UNAVAILABLE", list.tail, "connect and unlock the device; xcrun devicectl list devices", "ios");
165
+ const files = (JSON.parse(readFileSync(listing, "utf8")) as { result: { files: CrashFile[] } }).result.files;
166
+ const reports = files
167
+ .filter((f: CrashFile): boolean => f.name.startsWith(`${processName}-`) && f.name.endsWith(".ips"))
168
+ .sort((a: CrashFile, b: CrashFile): number => b.metadata.lastModDate.localeCompare(a.metadata.lastModDate));
169
+ if (reports.length === 0) {
170
+ print(ctx, { process: processName, crashes: 0 }, (): string => `no crash reports for ${processName} on the device`);
171
+ return;
172
+ }
173
+ const newest = reports[0];
174
+ const local = join(logDir, newest.name);
175
+ const copy = await exec({ ...ctx, json: true }, config.root, { label: "devicectl copy", argv: ["xcrun", "devicectl", "device", "copy", "from", "--device", t.device, "--domain-type", "systemCrashLogs", "--source", newest.name, "--destination", local] });
176
+ if (copy.code !== 0) throw new CliError("DEVICE_UNAVAILABLE", copy.tail, "", "ios");
177
+ const [, body] = readFileSync(local, "utf8").split(/\n(.*)/s);
178
+ const report = JSON.parse(body) as {
179
+ exception?: { type?: string; signal?: string };
180
+ termination?: { indicator?: string };
181
+ faultingThread?: number;
182
+ threads?: { frames: { symbol?: string; imageIndex: number }[] }[];
183
+ usedImages?: { name?: string }[];
184
+ };
185
+ const frames = (report.threads?.[report.faultingThread ?? 0]?.frames ?? [])
186
+ .slice(0, 12)
187
+ .map((f) => `${report.usedImages?.[f.imageIndex]?.name ?? "?"} ${f.symbol ?? "?"}`);
188
+ const summary = {
189
+ process: processName,
190
+ report: local,
191
+ when: newest.metadata.lastModDate,
192
+ crashes: reports.length,
193
+ exception: `${report.exception?.type ?? "?"} ${report.exception?.signal ?? ""}`.trim(),
194
+ termination: report.termination?.indicator ?? null,
195
+ frames,
196
+ };
197
+ print(ctx, summary, (): string =>
198
+ [`${processName} crashed ${newest.metadata.lastModDate} (${reports.length} reports on the device)`, `${summary.exception}${summary.termination ? `, ${summary.termination}` : ""}`, ...frames.map((f) => ` ${f}`), `full report: ${local}`, "a dotframe: ... threw line in the device log names a TypeScript error (build ios wraps init and frame)"].join("\n"),
199
+ );
200
+ }
package/cli/ios.ts CHANGED
@@ -1,8 +1,8 @@
1
- import { cpSync, existsSync, mkdirSync, readdirSync, rmSync, writeFileSync } from "node:fs";
1
+ import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
- import { join, relative, resolve } from "node:path";
3
+ import { join, relative, resolve, sep } from "node:path";
4
4
  import { CliError, type Ctx, exec, print, type RunResult, which } from "./lib";
5
- import { ENGINE, sdlLibrary, stageGame, vendorFix, vendorStatus } from "./native";
5
+ import { ENGINE, importGraph, sdlLibrary, stageGame, vendorFix, vendorStatus } from "./native";
6
6
 
7
7
  export interface IosTarget {
8
8
  platform: "ios";
@@ -158,6 +158,7 @@ targets:
158
158
  - path: ${o.id}_glue.c
159
159
  - path: ${join(ENGINE, "native", "df_native.c")}
160
160
  - path: ${join(ENGINE, "native", "df_audio.c")}
161
+ - path: ${join(ENGINE, "native", "df_ws_apple.m")}
161
162
  - path: game
162
163
  type: folder
163
164
  buildPhase: resources
@@ -178,6 +179,7 @@ ${o.icon ? " ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon\n" : ""}
178
179
  - ${join(o.vendor, "wgpu", "ios", "lib", "libwgpu_native.a")}
179
180
  - -lc++
180
181
  - -liconv
182
+ CLANG_ENABLE_OBJC_ARC: YES
181
183
  GCC_WARN_INHIBIT_ALL_WARNINGS: YES
182
184
  ENABLE_BITCODE: NO
183
185
  dependencies:
@@ -185,8 +187,20 @@ ${frameworks.map((f) => ` - sdk: ${f}.framework`).join("\n")}
185
187
  `;
186
188
  }
187
189
 
188
- export async function buildIos(ctx: Ctx, root: string, gameName: string, targetName: string, t: IosTarget): Promise<{ app: string; log: string; steps: RunResult[] }> {
189
- for (const tool of ["scriptc", "xcodegen", "xcodebuild", "sips"]) {
190
+ // check: stop after the scriptc library (library mode is stricter than an executable build), for doctor.
191
+ // The df* host functions the code reachable from the entry calls (engine files included, since staged imports are
192
+ // relative): only those become library callbacks, because scriptc caps them at 32.
193
+ function usedHostFunctions(entry: string): string[] {
194
+ const used = new Set<string>();
195
+ for (const file of importGraph(entry).files) {
196
+ if (!file.endsWith(".ts") || file.endsWith(`${sep}ffi.ts`)) continue;
197
+ for (const m of readFileSync(file, "utf8").matchAll(/\b(df[A-Z]\w*)\(/g)) used.add(m[1]);
198
+ }
199
+ return [...used].sort();
200
+ }
201
+
202
+ export async function buildIos(ctx: Ctx, root: string, gameName: string, targetName: string, t: IosTarget, check = false): Promise<{ app: string; log: string; steps: RunResult[] }> {
203
+ for (const tool of check ? ["scriptc"] : ["scriptc", "xcodegen", "xcodebuild", "sips"]) {
190
204
  if (!which(tool)) throw new CliError("TOOL_MISSING", `${tool} not found`, tool === "xcodegen" ? "brew install xcodegen" : tool === "scriptc" ? "npm i -g scriptc" : "install Xcode", SKILL);
191
205
  }
192
206
  for (const key of ["entry", "bundleId", "team"] as const) {
@@ -194,7 +208,7 @@ export async function buildIos(ctx: Ctx, root: string, gameName: string, targetN
194
208
  }
195
209
  if (!existsSync(join(root, t.entry))) throw new CliError("ENTRY_MISSING", `iOS entry ${t.entry} does not exist`, "add it (see the ios skill: export init and frame)", SKILL);
196
210
  const vendor = vendorStatus("ios");
197
- if (vendor.missing.length > 0) throw new CliError("VENDOR_MISSING", `iOS needs SDL3 and wgpu-native in ${vendor.dir}; missing ${vendor.missing.join(", ")}`, vendorFix("ios"), SKILL);
211
+ if (vendor.missing.length > 0 && !check) throw new CliError("VENDOR_MISSING", `iOS needs SDL3 and wgpu-native in ${vendor.dir}; missing ${vendor.missing.join(", ")}`, vendorFix("ios"), SKILL);
198
212
  const pack = await iosRuntimePack(ctx, root);
199
213
  if (!pack.path) {
200
214
  throw new CliError("TOOL_MISSING", pack.found ? `scriptc iOS runtime pack is ${pack.found}, scriptc is ${pack.want}` : `scriptc iOS runtime pack ${pack.want} not found`, iosPackFix(pack.want), SKILL);
@@ -203,11 +217,11 @@ export async function buildIos(ctx: Ctx, root: string, gameName: string, targetN
203
217
  const display = t.displayName ?? gameName;
204
218
  const id = identifier(gameName);
205
219
  const name = scheme(display);
206
- const stage = join(root, ".dotframe", "native", targetName);
220
+ const stage = join(root, ".dotframe", check ? "check" : "native", targetName);
207
221
  const out = join(root, "dist", targetName);
208
222
  const app = join(out, `${name}.app`);
209
223
  const logDir = join(root, ".dotframe", "logs");
210
- const log = join(logDir, `build-${targetName}.log`);
224
+ const log = join(logDir, `${check ? "check" : "build"}-${targetName}.log`);
211
225
  if (ctx.dryRun) {
212
226
  print(ctx, { dryRun: true, platform: "ios", stage, app, scheme: name, bundleId: t.bundleId, team: t.team, runtimePack: pack.path }, (): string => `would stage ${t.entry} in ${stage} and build ${app}`);
213
227
  return { app, log, steps: [] };
@@ -218,9 +232,41 @@ export async function buildIos(ctx: Ctx, root: string, gameName: string, targetN
218
232
  const engineStage = join(stage, "dotframe");
219
233
  cpSync(join(ENGINE, "src"), join(engineStage, "src"), { recursive: true });
220
234
  const gameStage = stageGame(root, t.entry, join(stage, "tree"), engineStage);
235
+ // A throw in library mode becomes a trap that aborts with no message (SIGABRT in SDL_AppInit). The wrapper prints
236
+ // the error and its stack to stderr, which the device log keeps, before rethrowing.
237
+ const wrapper = join(gameStage, "dotframe-entry.ts");
238
+ const gameEntry = `./${t.entry.replace(/\.ts$/, "")}`;
239
+ writeFileSync(
240
+ wrapper,
241
+ `import { frame as gameFrame, init as gameInit } from ${JSON.stringify(gameEntry)};
242
+
243
+ function report(where: string, error: unknown): void {
244
+ const detail = error instanceof Error ? \`\${error.message}\\n\${error.stack ?? ""}\` : String(error);
245
+ console.error(\`dotframe: \${where} threw: \${detail}\`);
246
+ }
247
+
248
+ export function init(base: string): void {
249
+ try {
250
+ gameInit(base);
251
+ } catch (error) {
252
+ report("init", error);
253
+ throw error;
254
+ }
255
+ }
256
+
257
+ export function frame(time: number): boolean {
258
+ try {
259
+ return gameFrame(time);
260
+ } catch (error) {
261
+ report("frame", error);
262
+ throw error;
263
+ }
264
+ }
265
+ `,
266
+ );
221
267
  writeFileSync(
222
268
  join(stage, "app.json"),
223
- JSON.stringify({ name: id, entry: relative(stage, join(gameStage, t.entry)), exports: [{ export: "init", params: ["string"], returns: "void" }, { export: "frame", params: ["f64"], returns: "bool" }] }, null, 2),
269
+ JSON.stringify({ name: id, entry: relative(stage, wrapper), exports: [{ export: "init", params: ["string"], returns: "void" }, { export: "frame", params: ["f64"], returns: "bool" }] }, null, 2),
224
270
  );
225
271
  writeFileSync(join(stage, "host", "main.c"), hostC(id, display));
226
272
  writeFileSync(join(stage, "host", "Info.plist"), infoPlist(display, t.orientation ?? "landscape"));
@@ -232,7 +278,7 @@ export async function buildIos(ctx: Ctx, root: string, gameName: string, targetN
232
278
  cpSync(join(ENGINE, "assets", "fonts"), join(bundle, "dotframe", "assets", "fonts"), { recursive: true });
233
279
 
234
280
  const steps: { label: string; argv: string[]; cwd?: string; env?: Record<string, string> }[] = [];
235
- if (t.icon) {
281
+ if (t.icon && !check) {
236
282
  const icons = join(stage, "Assets.xcassets", "AppIcon.appiconset");
237
283
  mkdirSync(icons, { recursive: true });
238
284
  writeFileSync(join(stage, "Assets.xcassets", "Contents.json"), '{"info":{"author":"xcode","version":1}}\n');
@@ -240,7 +286,7 @@ export async function buildIos(ctx: Ctx, root: string, gameName: string, targetN
240
286
  steps.push({ label: "icon", argv: ["sips", "-z", "1024", "1024", resolve(root, t.icon), "--out", join(icons, "icon-1024.png")] });
241
287
  }
242
288
  steps.push(
243
- { label: "library glue", argv: ["bun", join(ENGINE, "tools", "gen-library-glue.ts"), join(stage, "app.json"), stage] },
289
+ { label: "library glue", argv: ["bun", join(ENGINE, "tools", "gen-library-glue.ts"), join(stage, "app.json"), stage, join(ENGINE, "native", "ffi.macos.json"), usedHostFunctions(wrapper).join(",")] },
244
290
  { label: "scriptc library", argv: ["scriptc", "build", "--lib", "--profile", `${id}.profile.json`], cwd: stage, env: { SCRIPTC_TARGET: "aarch64-apple-ios", SCRIPTC_RUNTIME_PACK: pack.path } },
245
291
  );
246
292
  const results: RunResult[] = [];
@@ -257,6 +303,7 @@ export async function buildIos(ctx: Ctx, root: string, gameName: string, targetN
257
303
  }
258
304
  };
259
305
  for (const step of steps) await run(step);
306
+ if (check) return { app, log, steps: results };
260
307
  const lib = readdirSync(join(stage, ".scriptc")).find((f: string): boolean => f.endsWith(".lib.a"));
261
308
  if (!lib) throw new CliError("STEP_FAILED", "scriptc produced no .lib.a", `read ${log}`, SKILL, 1, log);
262
309
  writeFileSync(join(stage, "project.yml"), projectYml({ name, id, bundleId: t.bundleId, team: t.team, vendor: vendor.dir, sdl: vendor.sdl ?? "", lib: join(stage, ".scriptc", lib), icon: !!t.icon }));
package/cli/lib.ts CHANGED
@@ -57,7 +57,7 @@ export interface Target {
57
57
  out?: string;
58
58
  app?: string;
59
59
  device?: string;
60
- deploy?: { provider: "vercel"; project: string; scope: string } | { provider: "dokploy"; compose: string; dir?: string };
60
+ deploy?: { provider: "vercel"; project: string; scope: string } | { provider: "dokploy"; compose: string; dir?: string; site?: string; path?: string };
61
61
  }
62
62
 
63
63
  export interface Config {
@@ -191,3 +191,31 @@ export function formatJson(value: unknown, indent = "", width = 100): string {
191
191
  const entries = Object.entries(value as Record<string, unknown>);
192
192
  return `{\n${entries.map(([k, v]) => `${next}${JSON.stringify(k)}: ${formatJson(v, next, width)}`).join(",\n")}\n${indent}}`;
193
193
  }
194
+
195
+ // Per-machine settings (paths that must not go into a repo), in ~/.dotframe/config.json.
196
+ export interface UserConfig {
197
+ vendor?: string;
198
+ }
199
+
200
+ export function userConfigPath(): string {
201
+ return join(process.env.HOME ?? "", ".dotframe", "config.json");
202
+ }
203
+
204
+ export function loadUserConfig(): UserConfig {
205
+ const path = userConfigPath();
206
+ if (!existsSync(path)) return {};
207
+ try {
208
+ return JSON.parse(readFileSync(path, "utf8")) as UserConfig;
209
+ } catch {
210
+ throw new CliError("BAD_CONFIG", `${path} is not valid JSON`, `fix or delete ${path}`);
211
+ }
212
+ }
213
+
214
+ export function saveUserConfig(config: UserConfig): void {
215
+ const path = userConfigPath();
216
+ mkdirSync(dirname(path), { recursive: true });
217
+ writeFileSync(path, `${JSON.stringify(config, null, 2)}\n`);
218
+ }
219
+
220
+ // Keys stored per machine rather than in dotframe.json.
221
+ export const USER_KEYS = ["vendor"] as const;