@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/package.json +8 -5
- package/src/artifacts.ts +165 -26
- package/src/build.ts +234 -43
- package/src/bundle/linux/app.ts +275 -0
- package/src/bundle/linux/index.ts +1 -0
- package/src/{bundle.ts → bundle/macos/app.ts} +65 -23
- package/src/bundle/macos/index.ts +1 -0
- package/src/bundle/shared/cef-locales.ts +91 -0
- package/src/{bundle-win.ts → bundle/windows/app.ts} +10 -6
- package/src/bundle/windows/index.ts +1 -0
- package/src/check.ts +271 -0
- package/src/dev.ts +213 -35
- package/src/dmg.ts +22 -10
- package/src/doctor.ts +227 -0
- package/src/extras.ts +52 -13
- package/src/icons/macos/appearance.ts +96 -0
- package/src/icons/macos/index.ts +1 -0
- package/src/icons/windows/icon.ts +255 -0
- package/src/icons/windows/index.ts +1 -0
- package/src/index.ts +109 -7
- package/src/installer-inno.ts +141 -0
- package/src/installer-win.ts +17 -11
- package/src/package/linux/index.ts +2 -0
- package/src/package/linux/lib/defaults.ts +53 -0
- package/src/package/linux/lib/tools.ts +45 -0
- package/src/package/linux/lib/validation.ts +71 -0
- package/src/package/linux/package.ts +293 -0
- package/src/package/linux/types.ts +30 -0
- package/src/release/installers.ts +148 -0
- package/src/release/previous.ts +105 -0
- package/src/release.ts +166 -116
- package/src/shared/driver.ts +240 -0
- package/src/shared/inspector.ts +64 -0
- package/src/shared/report.ts +69 -0
- package/src/shared/scenario.ts +58 -0
- package/src/shared/session.ts +153 -0
- package/src/skill.ts +51 -0
- package/src/icon-win.ts +0 -81
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 {
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
49
|
-
|
|
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
|
-
|
|
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:
|
|
136
|
+
icon: iconSrc,
|
|
75
137
|
})
|
|
76
|
-
:
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
|
94
|
-
|
|
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
|
-
[
|
|
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:
|
|
208
|
+
stdio: reporter.childStdio,
|
|
100
209
|
},
|
|
101
210
|
);
|
|
102
|
-
|
|
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
|
-
|
|
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
|
-
:
|
|
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({
|
|
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
|
-
|
|
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:
|
|
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 {
|
|
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({
|
|
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
|
-
|
|
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 ?? {
|
|
194
|
-
|
|
195
|
-
|
|
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())
|