@mirinjs/cli 0.1.0-alpha.2 → 0.1.0-alpha.20

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/src/check.ts ADDED
@@ -0,0 +1,271 @@
1
+ /**
2
+ * `mirin check` — boot the app once, capture a screenshot and accessibility
3
+ * snapshot, collect errors, exit non-zero on failure (docs/agent-devtools.md).
4
+ */
5
+
6
+ import type { CheckScenario } from "mirinjs/check";
7
+ import type { DevEvent } from "mirinjs/devtools/session";
8
+ import { readEventsFile, sessionPaths } from "mirinjs/devtools/session";
9
+ import { dev } from "./dev.ts";
10
+ import type { ScenarioOutcome } from "./shared/driver.ts";
11
+ import { InspectorClient } from "./shared/inspector.ts";
12
+ import { createReporter, type Reporter } from "./shared/report.ts";
13
+ import { loadScenario, runScenario } from "./shared/scenario.ts";
14
+
15
+ /** How long to wait for the app to show a window before giving up. */
16
+ const DEFAULT_TIMEOUT_MS = 45_000;
17
+
18
+ /** Quiet time after the first window appears, so the capture shows painted UI
19
+ * rather than an empty frame. */
20
+ const DEFAULT_SETTLE_MS = 1_000;
21
+
22
+ export interface CheckOptions {
23
+ timeoutMs?: number;
24
+ settleMs?: number;
25
+ json?: boolean;
26
+ /** Path to a scenario module (`mirin check --scenario ./check.ts`). */
27
+ scenario?: string;
28
+ }
29
+
30
+ export interface CheckReport {
31
+ ok: boolean;
32
+ reason?: string;
33
+ session: string | null;
34
+ inspector: string | null;
35
+ windows: { id: number; name?: string; url?: string; title?: string }[];
36
+ /** Path to the captured screenshot. */
37
+ screenshot: string | null;
38
+ /** Accessibility snapshot of the first window. */
39
+ snapshot: string | null;
40
+ /** Slices the app published with `devtools.expose`, read at the end of the run. */
41
+ exposed: Record<string, unknown>;
42
+ errors: DevEvent[];
43
+ eventCount: number;
44
+ durationMs: number;
45
+ /** Present when `--scenario` ran. */
46
+ scenario?: { file: string } & ScenarioOutcome;
47
+ }
48
+
49
+ function record(value: unknown): Record<string, unknown> {
50
+ return typeof value === "object" && value !== null && !Array.isArray(value)
51
+ ? (value as Record<string, unknown>)
52
+ : {};
53
+ }
54
+
55
+ function windowsOf(state: unknown): CheckReport["windows"] {
56
+ const list = record(state).windows;
57
+ if (!Array.isArray(list)) return [];
58
+ return list.flatMap((entry) => {
59
+ const win = record(entry);
60
+ const id = win.id;
61
+ if (typeof id !== "number") return [];
62
+ return [
63
+ {
64
+ id,
65
+ ...(typeof win.name === "string" ? { name: win.name } : {}),
66
+ ...(typeof win.url === "string" ? { url: win.url } : {}),
67
+ ...(typeof win.title === "string" ? { title: win.title } : {}),
68
+ },
69
+ ];
70
+ });
71
+ }
72
+
73
+ /** Poll `/state` until a window exists, or the deadline passes. */
74
+ async function waitForWindow(
75
+ client: InspectorClient,
76
+ deadline: number,
77
+ ): Promise<CheckReport["windows"]> {
78
+ let last: CheckReport["windows"] = [];
79
+ while (Date.now() < deadline) {
80
+ try {
81
+ last = windowsOf(await client.get("/state"));
82
+ if (last.length > 0) return last;
83
+ } catch {
84
+ // The inspector may still be binding; keep trying until the deadline.
85
+ }
86
+ await Bun.sleep(250);
87
+ }
88
+ return last;
89
+ }
90
+
91
+ async function capture(client: InspectorClient, report: CheckReport): Promise<void> {
92
+ try {
93
+ const shot = record(await client.get("/screenshot"));
94
+ if (typeof shot.path === "string") report.screenshot = shot.path;
95
+ } catch (err) {
96
+ report.errors.push(syntheticError(`screenshot failed: ${message(err)}`));
97
+ }
98
+ try {
99
+ const snap = record(await client.get("/snapshot"));
100
+ if (typeof snap.snapshot === "string") report.snapshot = snap.snapshot;
101
+ } catch (err) {
102
+ report.errors.push(syntheticError(`snapshot failed: ${message(err)}`));
103
+ }
104
+ try {
105
+ report.exposed = record(record(await client.get("/state")).exposed);
106
+ } catch {
107
+ // The app publishing nothing is the common case; not worth an error.
108
+ }
109
+ }
110
+
111
+ function message(err: unknown): string {
112
+ return err instanceof Error ? err.message : String(err);
113
+ }
114
+
115
+ /** A failure of the check itself, shaped like a stream event so one list suffices. */
116
+ function syntheticError(text: string): DevEvent {
117
+ return { seq: 0, ts: Date.now(), src: "main", level: "error", type: "check.error", msg: text };
118
+ }
119
+
120
+ function renderReport(report: CheckReport): void {
121
+ const mark = report.ok ? "✓" : "✗";
122
+ console.log(`\n${mark} mirin check ${report.ok ? "passed" : "failed"} in ${report.durationMs}ms`);
123
+ if (report.reason !== undefined) console.log(` reason: ${report.reason}`);
124
+
125
+ if (report.windows.length > 0) {
126
+ console.log(` windows: ${report.windows.length}`);
127
+ for (const win of report.windows) {
128
+ const label = [win.name, win.title].filter(Boolean).join(" / ");
129
+ console.log(
130
+ ` #${win.id}${label.length > 0 ? ` ${label}` : ""} ${win.url ?? ""}`.trimEnd(),
131
+ );
132
+ }
133
+ }
134
+ if (report.scenario !== undefined) {
135
+ const { file, steps, screenshots, failure } = report.scenario;
136
+ console.log(` scenario: ${file}`);
137
+ for (const step of steps) {
138
+ const mark = step.ok ? "✓" : "✗";
139
+ console.log(` ${mark} ${step.name}${step.ms >= 0 ? ` (${step.ms}ms)` : ""}`);
140
+ }
141
+ if (failure !== undefined) console.log(` → ${failure.message}`);
142
+ for (const shot of screenshots) console.log(` shot: ${shot}`);
143
+ }
144
+ const exposedKeys = Object.keys(report.exposed);
145
+ if (exposedKeys.length > 0) {
146
+ console.log(` app state: ${JSON.stringify(report.exposed).slice(0, 400)}`);
147
+ }
148
+ if (report.screenshot !== null) console.log(` screenshot: ${report.screenshot}`);
149
+ if (report.session !== null) console.log(` session: ${report.session}`);
150
+ console.log(` events: ${report.eventCount}, errors: ${report.errors.length}`);
151
+
152
+ for (const error of report.errors.slice(0, 10)) {
153
+ console.log(`\n [${error.src}] ${error.type}: ${error.msg}`);
154
+ const stack = record(error.data).stack;
155
+ if (Array.isArray(stack)) {
156
+ for (const frame of stack.slice(0, 4)) console.log(` at ${String(frame)}`);
157
+ }
158
+ }
159
+ if (report.errors.length > 10) {
160
+ console.log(`\n … ${report.errors.length - 10} more errors (see the session's events.jsonl)`);
161
+ }
162
+ if (report.snapshot !== null) {
163
+ console.log(`\n ui snapshot:\n${report.snapshot.replace(/^/gm, " ")}`);
164
+ }
165
+ }
166
+
167
+ /** Run the check. Returns the process exit code: 0 when the app came up clean. */
168
+ export async function check(
169
+ projectDir = process.cwd(),
170
+ options: CheckOptions = {},
171
+ ): Promise<number> {
172
+ const reporter: Reporter = createReporter(options.json === true);
173
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
174
+ const settleMs = options.settleMs ?? DEFAULT_SETTLE_MS;
175
+ const startedAt = Date.now();
176
+
177
+ const report: CheckReport = {
178
+ ok: false,
179
+ session: null,
180
+ inspector: null,
181
+ windows: [],
182
+ screenshot: null,
183
+ snapshot: null,
184
+ exposed: {},
185
+ errors: [],
186
+ eventCount: 0,
187
+ durationMs: 0,
188
+ };
189
+
190
+ reporter.event("start", { projectDir, timeoutMs });
191
+
192
+ // Load the scenario before anything is built: a typo in the path should cost a
193
+ // second, not a cold compile.
194
+ let scenario: CheckScenario | undefined;
195
+ if (options.scenario !== undefined) {
196
+ try {
197
+ scenario = await loadScenario(projectDir, options.scenario);
198
+ } catch (err) {
199
+ report.reason = message(err);
200
+ report.durationMs = Date.now() - startedAt;
201
+ reporter.finish({ ...report }, () => renderReport(report));
202
+ return 1;
203
+ }
204
+ }
205
+
206
+ await dev(projectDir, {
207
+ // Inherit the output mode: in JSON mode `mirin dev`'s prose would otherwise
208
+ // interleave with the report and break parsing.
209
+ json: options.json === true,
210
+ async onLaunched(context) {
211
+ const deadline = Date.now() + timeoutMs;
212
+ const session = context.session;
213
+ if (session === undefined) {
214
+ report.reason = "no dev session was created — devtools artifacts are unavailable";
215
+ return;
216
+ }
217
+ report.session = session.paths.dir;
218
+
219
+ reporter.event("await-inspector");
220
+ const endpoint = await session.waitForInspector(Math.max(1_000, deadline - Date.now()));
221
+ if (endpoint === undefined) {
222
+ report.reason = "the app never published an inspector endpoint";
223
+ return;
224
+ }
225
+ report.inspector = `http://127.0.0.1:${endpoint.port}`;
226
+ const client = new InspectorClient(endpoint);
227
+
228
+ reporter.event("await-window");
229
+ report.windows = await waitForWindow(client, deadline);
230
+ if (report.windows.length === 0) {
231
+ report.reason = `no window appeared within ${timeoutMs}ms`;
232
+ return;
233
+ }
234
+
235
+ // Let the first paint land, or the capture shows an empty frame and tree.
236
+ await Bun.sleep(settleMs);
237
+
238
+ if (scenario !== undefined && options.scenario !== undefined) {
239
+ reporter.event("scenario", { file: options.scenario });
240
+ const outcome = await runScenario(client, scenario, (name) =>
241
+ reporter.event("step", { name }),
242
+ );
243
+ report.scenario = { file: options.scenario, ...outcome };
244
+ if (outcome.failure !== undefined) {
245
+ report.reason = `scenario failed at "${outcome.failure.step}": ${outcome.failure.message}`;
246
+ }
247
+ }
248
+
249
+ // Capture after the scenario, so artifacts show the state the run ended in.
250
+ reporter.event("capture", { windows: report.windows.length });
251
+ await capture(client, report);
252
+ },
253
+ });
254
+
255
+ // Read the stream from disk rather than the inspector: by now the app has exited,
256
+ // and the file is the complete record of the run.
257
+ if (report.session !== null) {
258
+ const events = readEventsFile(sessionPaths(report.session).events);
259
+ report.eventCount = events.length;
260
+ report.errors.push(...events.filter((event) => event.level === "error"));
261
+ }
262
+
263
+ report.durationMs = Date.now() - startedAt;
264
+ report.ok = report.reason === undefined && report.errors.length === 0;
265
+ if (!report.ok && report.reason === undefined) {
266
+ report.reason = `${report.errors.length} error-level event(s) during startup`;
267
+ }
268
+
269
+ reporter.finish({ ...report }, () => renderReport(report));
270
+ return report.ok ? 0 : 1;
271
+ }
package/src/dev.ts CHANGED
@@ -6,25 +6,61 @@
6
6
  * 3. assemble + ad-hoc-sign the dev .app
7
7
  * 4. start the Vite dev server (rolldown-vite) for the UI
8
8
  * 5. launch the app pointed at the Vite URL, with RPC injected into the webview
9
+ *
10
+ * Each run also opens a dev session under `.mirin/dev/` and records its phase
11
+ * timeline there (docs/agent-devtools.md).
9
12
  */
10
13
 
11
- import { $ } from "bun";
12
- import { mkdirSync, rmSync, symlinkSync, existsSync } from "node:fs";
14
+ import { existsSync, mkdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
13
15
  import { createServer } from "node:net";
16
+ import { homedir } from "node:os";
14
17
  import { join } from "node:path";
15
- import { buildAppBundle } from "./bundle.ts";
16
- import { buildWindowsBundle } from "./bundle-win.ts";
18
+ import { $ } from "bun";
17
19
  import { resolveArtifacts } from "./artifacts.ts";
20
+ import {
21
+ buildLinuxBundle,
22
+ desktopEntry,
23
+ resolveLinuxDesktopIconPng,
24
+ resolveLinuxIconPng,
25
+ } from "./bundle/linux/index.ts";
26
+ import { buildAppBundle } from "./bundle/macos/index.ts";
27
+ import { buildWindowsBundle } from "./bundle/windows/index.ts";
18
28
 
19
29
  const IS_WINDOWS = process.platform === "win32";
30
+ const IS_LINUX = process.platform === "linux";
31
+
32
+ import { compileWorkers, normalizeSidecars } from "./extras.ts";
33
+ import { createReporter } from "./shared/report.ts";
34
+ import { DevSession } from "./shared/session.ts";
20
35
  import { sweepBuildTemps } from "./temps.ts";
21
- import { normalizeSidecars, compileWorkers } from "./extras.ts";
22
36
 
23
37
  /** Vite's default port. `mirin dev` probes upward from here for a free one, so a
24
38
  * second dev session (or anything already on 5173) doesn't collide. */
25
39
  const DEV_PORT_BASE = 5173;
26
40
 
27
- export async function dev(projectDir = process.cwd()): Promise<number> {
41
+ /** Where the CEF DevTools-protocol port search starts. Well clear of Vite's range
42
+ * so concurrent dev sessions of different apps don't fight over either. */
43
+ const CDP_PORT_BASE = 9222;
44
+
45
+ export interface DevLaunchContext {
46
+ session?: DevSession;
47
+ devUrl: string;
48
+ cdpPort: number;
49
+ pid: number;
50
+ /** Stop the app and the Vite server. */
51
+ stop(): void;
52
+ }
53
+
54
+ export interface DevOptions {
55
+ /** Emit newline-delimited JSON progress instead of prose. */
56
+ json?: boolean;
57
+ /** Called once the app is running. The app is always stopped after the hook
58
+ * settles, whether it threw or not. */
59
+ onLaunched?(context: DevLaunchContext): Promise<void> | void;
60
+ }
61
+
62
+ export async function dev(projectDir = process.cwd(), options?: DevOptions): Promise<number> {
63
+ const reporter = createReporter(options?.json === true);
28
64
  const work = join(projectDir, ".mirin");
29
65
  mkdirSync(work, { recursive: true });
30
66
  // `bun build --compile` drops temp *.bun-build files in the cwd; clear any left
@@ -36,22 +72,46 @@ export async function dev(projectDir = process.cwd()): Promise<number> {
36
72
  const appName: string = config.name ?? "Mirin App";
37
73
  const bundleId: string = config.id ?? "dev.mirin.app";
38
74
  const mainEntry = join(projectDir, config.main ?? "main/main.ts");
75
+ const iconSrc = config.icon ? join(projectDir, config.icon) : undefined;
76
+ // Linux: resolve the app icon to a concrete PNG the core stamps onto the window
77
+ // as `_NET_WM_ICON` (cosmic dock/taskbar). Passed straight through in dev — no
78
+ // bundling; the file is read from the project at window-create time.
79
+ const linuxIconPng = IS_LINUX && iconSrc ? resolveLinuxIconPng(iconSrc) : undefined;
80
+
81
+ // --- open the dev session (agent devtools) ---
82
+ const session = DevSession.create({
83
+ projectDir,
84
+ appName,
85
+ appId: bundleId,
86
+ version: typeof config.version === "string" ? config.version : undefined,
87
+ });
39
88
 
40
89
  // --- native artifacts ---
41
- const artifacts = await resolveArtifacts({ release: false });
90
+ const artifacts = await resolveArtifacts({ release: false, reporter });
42
91
 
43
92
  // --- compile the Bun host + bundle the Worker ---
44
- console.log("[mirin dev] compiling host + bundling main process…");
93
+ const compilePhase = session?.phase("compile");
94
+ reporter.info("[mirin dev] compiling host + bundling main process…");
95
+ reporter.event("compile");
45
96
  // `bun build --compile` emits an `.exe` on Windows; name it so explicitly.
46
97
  const hostExe = join(work, IS_WINDOWS ? "host.exe" : "host");
47
98
  const workerJs = join(work, "worker.js");
48
- await $`bun build --compile ${artifacts.hostEntry} --outfile ${hostExe}`.cwd(projectDir);
49
- await $`bun build ${mainEntry} --target=bun --outfile ${workerJs}`.cwd(projectDir);
99
+ const hostTarget =
100
+ process.platform === "win32" && process.arch === "arm64" ? ["--target=bun-windows-x64"] : [];
101
+ await reporter.build(
102
+ $`bun build --compile ${hostTarget} ${artifacts.hostEntry} --outfile ${hostExe}`.cwd(
103
+ projectDir,
104
+ ),
105
+ );
106
+ await reporter.build(
107
+ $`bun build ${mainEntry} --target=bun --outfile ${workerJs}`.cwd(projectDir),
108
+ );
109
+ compilePhase?.ok();
50
110
 
51
111
  // Extra assets (dev): compile workers into .mirin/workers and symlink sidecar
52
112
  // binaries into .mirin/sidecars (no copy/sign in dev — they run unsigned locally).
53
113
  const workersDir = join(work, "workers");
54
- await compileWorkers(projectDir, config.workers, workersDir, false);
114
+ await compileWorkers(projectDir, config.workers, workersDir, false, reporter);
55
115
  const sidecarsDir = join(work, "sidecars");
56
116
  mkdirSync(sidecarsDir, { recursive: true });
57
117
  for (const sc of normalizeSidecars(projectDir, config.sidecars)) {
@@ -61,8 +121,10 @@ export async function dev(projectDir = process.cwd()): Promise<number> {
61
121
  else console.warn(`[mirin dev] sidecar "${sc.name}" not found: ${sc.src}`);
62
122
  }
63
123
 
64
- // --- assemble the dev bundle (Windows app folder, or macOS .app) ---
65
- console.log("[mirin dev] assembling dev bundle…");
124
+ // --- assemble the dev bundle (Windows/Linux app folder, or macOS .app) ---
125
+ const bundlePhase = session?.phase("bundle");
126
+ reporter.info("[mirin dev] assembling dev bundle…");
127
+ reporter.event("bundle");
66
128
  const { app, exe } = IS_WINDOWS
67
129
  ? await buildWindowsBundle({
68
130
  appName,
@@ -71,56 +133,154 @@ export async function dev(projectDir = process.cwd()): Promise<number> {
71
133
  coreDll: artifacts.coreDylib,
72
134
  helperExe: artifacts.helperBin,
73
135
  cefPath: artifacts.cefPath,
74
- icon: config.icon ? join(projectDir, config.icon) : undefined,
136
+ icon: iconSrc,
75
137
  })
76
- : await buildAppBundle({
77
- appName,
78
- bundleId,
79
- outDir: work,
80
- hostExe,
81
- coreDylib: artifacts.coreDylib,
82
- helperBin: artifacts.helperBin,
83
- cefPath: artifacts.cefPath,
84
- icon: config.icon ? join(projectDir, config.icon) : undefined,
85
- urlSchemes: config.urlSchemes,
86
- });
138
+ : IS_LINUX
139
+ ? await buildLinuxBundle({
140
+ appName,
141
+ outDir: work,
142
+ hostExe,
143
+ coreDll: artifacts.coreDylib,
144
+ helperExe: artifacts.helperBin,
145
+ cefPath: artifacts.cefPath,
146
+ icon: iconSrc,
147
+ })
148
+ : await buildAppBundle({
149
+ appName,
150
+ bundleId,
151
+ outDir: work,
152
+ hostExe,
153
+ coreDylib: artifacts.coreDylib,
154
+ helperBin: artifacts.helperBin,
155
+ cefPath: artifacts.cefPath,
156
+ icon: iconSrc,
157
+ urlSchemes: config.urlSchemes,
158
+ });
159
+ bundlePhase?.ok();
160
+
161
+ // Linux: install a dev `.desktop` entry so cosmic's dock can resolve the app's
162
+ // icon. cosmic matches a running X11 window's `WM_CLASS` (which the core sets to
163
+ // the bundle id) against `StartupWMClass`, then shows that entry's `Icon`. Written
164
+ // to the per-user applications dir; harmless on other DEs.
165
+ if (IS_LINUX && iconSrc) {
166
+ const desktopIcon = resolveLinuxDesktopIconPng(iconSrc);
167
+ if (desktopIcon) {
168
+ const appsDir = join(homedir(), ".local", "share", "applications");
169
+ mkdirSync(appsDir, { recursive: true });
170
+ writeFileSync(
171
+ join(appsDir, `${bundleId}.desktop`),
172
+ desktopEntry({ name: appName, exec: exe, iconPng: desktopIcon, wmClass: bundleId }),
173
+ );
174
+ }
175
+ }
87
176
 
88
177
  // --- start Vite on a free port so concurrent dev sessions don't collide ---
89
178
  // `--port <free> --strictPort` pins Vite to the port we probed (overriding any
90
179
  // port/strictPort in the app's vite.config) and passes the real URL to the app,
91
180
  // so the host and Vite never disagree about which port is in use.
181
+ //
182
+ // Bind + address on 127.0.0.1 (IPv4), NOT "localhost": on hosts where Vite binds
183
+ // IPv6-only (`[::1]`) while Chromium resolves `localhost` to IPv4 (common on
184
+ // Linux), the webview's page load is refused and the window stays blank. Pinning
185
+ // both sides to 127.0.0.1 avoids the family mismatch (and matches the loopback
186
+ // RPC server). `--host 127.0.0.1` forces Vite's bind.
92
187
  const port = await findFreePort(DEV_PORT_BASE);
93
- const devUrl = `http://localhost:${port}`;
94
- console.log(`[mirin dev] starting Vite dev server on ${devUrl}…`);
188
+ const cdpPort = await findFreePort(CDP_PORT_BASE);
189
+ const devUrl = `http://127.0.0.1:${port}`;
190
+ const vitePhase = session?.phase("vite");
191
+ session?.setDevUrl(devUrl);
192
+ reporter.info(`[mirin dev] starting Vite dev server on ${devUrl}…`);
193
+ reporter.event("vite", { devUrl });
95
194
  const vite = Bun.spawn(
96
- ["bunx", "vite", "--clearScreen", "false", "--port", String(port), "--strictPort"],
195
+ [
196
+ "bunx",
197
+ "vite",
198
+ "--clearScreen",
199
+ "false",
200
+ "--host",
201
+ "127.0.0.1",
202
+ "--port",
203
+ String(port),
204
+ "--strictPort",
205
+ ],
97
206
  {
98
207
  cwd: projectDir,
99
- stdio: ["ignore", "inherit", "inherit"],
208
+ stdio: reporter.childStdio,
100
209
  },
101
210
  );
102
- await waitForUrl(devUrl, 15_000);
211
+ try {
212
+ await waitForUrl(devUrl, 15_000);
213
+ vitePhase?.ok(devUrl);
214
+ } catch (err) {
215
+ vitePhase?.fail(err instanceof Error ? err.message : String(err));
216
+ vite.kill();
217
+ throw err;
218
+ }
103
219
 
104
220
  // --- launch the app ---
105
- console.log(`[mirin dev] launching ${appName}…`);
221
+ const launchPhase = session?.phase("launch");
222
+ reporter.info(`[mirin dev] launching ${appName}…`);
106
223
  const appProc = Bun.spawn([exe], {
107
224
  cwd: projectDir,
108
225
  env: {
109
226
  ...process.env,
110
227
  MIRIN_CORE: IS_WINDOWS
111
228
  ? join(app, "mirin_core.dll")
112
- : join(app, "Contents", "MacOS", "libmirin_core.dylib"),
229
+ : IS_LINUX
230
+ ? join(app, "libmirin_core.so")
231
+ : join(app, "Contents", "MacOS", "libmirin_core.dylib"),
232
+ // Linux: the flat app dir holds libcef.so + libmirin_core.so; put it on the
233
+ // loader path so the host's dlopen and libmirin_core's NEEDED libcef.so (and
234
+ // the spawned mirin-helper) all resolve. (macOS uses the framework loader;
235
+ // Windows the exe-dir search — neither needs this.)
236
+ ...(IS_LINUX
237
+ ? {
238
+ LD_LIBRARY_PATH: `${app}${process.env.LD_LIBRARY_PATH ? ":" + process.env.LD_LIBRARY_PATH : ""}`,
239
+ }
240
+ : {}),
113
241
  MIRIN_WORKER: workerJs,
114
242
  MIRIN_DEV_URL: devUrl,
115
- MIRIN_MANIFEST_JSON: JSON.stringify({ windows: config.windows }),
243
+ MIRIN_MANIFEST_JSON: JSON.stringify({
244
+ id: bundleId,
245
+ windows: config.windows,
246
+ devtools: config.devtools,
247
+ }),
116
248
  // dev: true → enables inspect-element AND gives this run its own `-dev`
117
249
  // CEF cache dir, so `mirin dev` can run alongside the installed app.
118
- MIRIN_CONFIG_JSON: JSON.stringify({ dev: true }),
250
+ // `icon_path` (Linux) → the window's `_NET_WM_ICON` (cosmic dock/taskbar).
251
+ MIRIN_CONFIG_JSON: JSON.stringify({
252
+ dev: true,
253
+ ...(linuxIconPng ? { icon_path: linuxIconPng } : {}),
254
+ }),
119
255
  MIRIN_SIDECAR_DIR: sidecarsDir,
120
256
  MIRIN_WORKERS_DIR: workersDir,
257
+ // CEF's DevTools-protocol port (screenshots, snapshots, eval, input).
258
+ // Loopback only, and only ever set for a dev run (docs/agent-devtools.md).
259
+ MIRIN_CDP_PORT: String(cdpPort),
260
+ ...(session?.env() ?? {}),
121
261
  },
122
- stdio: ["ignore", "inherit", "inherit"],
262
+ stdio: reporter.childStdio,
123
263
  });
264
+ session?.setPid(appProc.pid);
265
+ launchPhase?.ok();
266
+ reporter.event("launch", {
267
+ pid: appProc.pid,
268
+ devUrl,
269
+ cdpPort,
270
+ session: session?.paths.dir ?? null,
271
+ });
272
+ if (session) {
273
+ reporter.info(`[mirin dev] dev session: ${session.paths.dir}`);
274
+ // The app publishes its inspector endpoint once the Worker binds it; report it
275
+ // when it lands, without holding up the dev loop if it never does.
276
+ void session.waitForInspector().then((endpoint) => {
277
+ if (endpoint === undefined) return;
278
+ reporter.info(
279
+ `[mirin dev] inspector: http://127.0.0.1:${endpoint.port} — token in inspector.json`,
280
+ );
281
+ reporter.event("inspector", { url: `http://127.0.0.1:${endpoint.port}` });
282
+ });
283
+ }
124
284
 
125
285
  const cleanup = () => {
126
286
  vite.kill();
@@ -129,8 +289,26 @@ export async function dev(projectDir = process.cwd()): Promise<number> {
129
289
  process.on("SIGINT", cleanup);
130
290
  process.on("SIGTERM", cleanup);
131
291
 
292
+ if (options?.onLaunched !== undefined) {
293
+ try {
294
+ await options.onLaunched({
295
+ ...(session !== undefined ? { session } : {}),
296
+ devUrl,
297
+ cdpPort,
298
+ pid: appProc.pid,
299
+ stop: cleanup,
300
+ });
301
+ } catch (err) {
302
+ console.error(`[mirin] launch hook failed: ${err instanceof Error ? err.message : err}`);
303
+ } finally {
304
+ // A hooked run is a one-shot: stop the app whether the hook succeeded or not.
305
+ cleanup();
306
+ }
307
+ }
308
+
132
309
  const code = await appProc.exited;
133
310
  vite.kill();
311
+ session?.finish(code, appProc.signalCode ?? undefined);
134
312
  return code;
135
313
  }
136
314
 
package/src/dmg.ts CHANGED
@@ -15,10 +15,10 @@
15
15
  * stapled so a fresh download opens without a Gatekeeper prompt.
16
16
  */
17
17
 
18
- import { $ } from "bun";
19
- import { cpSync, mkdirSync, rmSync, symlinkSync, existsSync } from "node:fs";
20
- import { join, basename, isAbsolute } from "node:path";
18
+ import { cpSync, existsSync, mkdirSync, rmSync, symlinkSync } from "node:fs";
21
19
  import { tmpdir } from "node:os";
20
+ import { basename, isAbsolute, join } from "node:path";
21
+ import { $ } from "bun";
22
22
 
23
23
  export interface DmgOptions {
24
24
  volumeName?: string;
@@ -148,12 +148,20 @@ export async function buildDmg(input: BuildDmgInput): Promise<string> {
148
148
  let built = false;
149
149
  if (wantsLayout(options)) {
150
150
  try {
151
- await buildLaidOutDmg({ staging, vol, format, dmgPath, options, projectDir, appFile: basename(app) });
151
+ await buildLaidOutDmg({
152
+ staging,
153
+ vol,
154
+ format,
155
+ dmgPath,
156
+ options,
157
+ projectDir,
158
+ appFile: basename(app),
159
+ });
152
160
  built = true;
153
161
  } catch (e) {
154
162
  console.warn(
155
163
  `[mirin release] DMG layout failed (${e instanceof Error ? e.message : e}); ` +
156
- `falling back to a plain DMG.`,
164
+ "falling back to a plain DMG.",
157
165
  );
158
166
  rmSync(dmgPath, { force: true });
159
167
  }
@@ -190,9 +198,14 @@ async function buildLaidOutDmg(args: {
190
198
  const { staging, vol, format, dmgPath, options, projectDir, appFile } = args;
191
199
  const win = options.windowSize ?? { width: 640, height: 400 };
192
200
  const iconSize = options.iconSize ?? 128;
193
- const appPos = options.appPosition ?? { x: Math.round(win.width * 0.25), y: Math.round(win.height * 0.5) };
194
- const appsPos =
195
- options.applicationsPosition ?? { x: Math.round(win.width * 0.75), y: Math.round(win.height * 0.5) };
201
+ const appPos = options.appPosition ?? {
202
+ x: Math.round(win.width * 0.25),
203
+ y: Math.round(win.height * 0.5),
204
+ };
205
+ const appsPos = options.applicationsPosition ?? {
206
+ x: Math.round(win.width * 0.75),
207
+ y: Math.round(win.height * 0.5),
208
+ };
196
209
 
197
210
  // Stage a background image (if any) under a hidden folder Finder can reference.
198
211
  let bgFile: string | undefined;
@@ -214,8 +227,7 @@ async function buildLaidOutDmg(args: {
214
227
  await $`hdiutil create -volname ${vol} -srcfolder ${staging} -fs HFS+ -format UDRW -ov ${rw}`.quiet();
215
228
 
216
229
  // Attach without auto-opening a Finder window; parse the real mountpoint.
217
- const attach =
218
- await $`hdiutil attach ${rw} -readwrite -noverify -noautoopen`.text();
230
+ const attach = await $`hdiutil attach ${rw} -readwrite -noverify -noautoopen`.text();
219
231
  const mount = attach
220
232
  .split("\n")
221
233
  .map((l) => l.trim())