mirinjs 0.1.0-alpha.0 → 0.1.0-alpha.10

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mirinjs",
3
- "version": "0.1.0-alpha.0",
3
+ "version": "0.1.0-alpha.10",
4
4
  "description": "Build desktop apps with Bun, TypeScript, and Chromium (CEF). The Bun-native Electron alternative.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/app.ts CHANGED
@@ -289,6 +289,25 @@ class MirinApp extends Emitter<AppEvents> {
289
289
  /** Auto-updater (macOS). Inert unless the app was built with `release` set. */
290
290
  readonly updater: Updater = updater;
291
291
 
292
+ /** This app's bundle id (mirin.config `id`). `undefined` when run detached. */
293
+ get id(): string | undefined {
294
+ try {
295
+ return runtime().id;
296
+ } catch {
297
+ return undefined;
298
+ }
299
+ }
300
+
301
+ /** True under `mirin dev`; false in a packaged build (and when detached). Use it
302
+ * to keep dev and installed data (caches, databases) separate. */
303
+ get isDev(): boolean {
304
+ try {
305
+ return runtime().isDev;
306
+ } catch {
307
+ return false;
308
+ }
309
+ }
310
+
292
311
  /** macOS Dock-icon controls. Hiding suits resident, hotkey-summoned apps. */
293
312
  readonly dock: Dock = {
294
313
  hide: () => this.#setDock(false),
package/src/config.ts CHANGED
@@ -42,6 +42,9 @@ export interface WindowConfig {
42
42
  title?: string;
43
43
  width?: number;
44
44
  height?: number;
45
+ /** Minimum window size; the OS prevents resizing below it. */
46
+ minWidth?: number;
47
+ minHeight?: number;
45
48
  /** Screen position (bottom-left origin, points). Centered when absent. */
46
49
  x?: number;
47
50
  y?: number;
@@ -78,6 +81,12 @@ export interface MirinConfig {
78
81
  /** Reverse-DNS app id, e.g. "dev.peje.hello". */
79
82
  id: string;
80
83
  name: string;
84
+ /**
85
+ * Publisher / company name (e.g. "Netko Labs"). Shown in the Windows installer,
86
+ * the exe's file properties (CompanyName), and Add/Remove Programs. Defaults to
87
+ * the app `name`.
88
+ */
89
+ publisher?: string;
81
90
  /** Main-process entry, relative to the project root (runs in the Bun Worker). */
82
91
  main: string;
83
92
  /**
@@ -101,6 +110,22 @@ export interface MirinConfig {
101
110
  * notary credentials are set) notarized + stapled like the `.app`.
102
111
  */
103
112
  dmg?: boolean | DmgConfig;
113
+ /**
114
+ * Windows installer (NSIS) produced by `mirin release` alongside the updater
115
+ * artifacts. `true`/omitted builds a default assisted installer (Program Files
116
+ * or per-user, Start Menu + Desktop shortcuts, an uninstaller + Add/Remove
117
+ * Programs entry); an object customizes it; `false` ships only the portable
118
+ * `.zip`. Requires `makensis` (NSIS) on the build machine.
119
+ */
120
+ nsis?: boolean | NsisConfig;
121
+ /**
122
+ * Windows installer via **Inno Setup** — a modern flat wizard
123
+ * (`WizardStyle=modern`, the installer VS Code ships). Preferred over `nsis`
124
+ * when both are available; `true`/omitted builds the default installer, an
125
+ * object customizes it, `false` falls back to NSIS / the portable `.zip`.
126
+ * Requires `iscc` (Inno Setup 6+) on the build machine.
127
+ */
128
+ inno?: boolean | InnoConfig;
104
129
  /**
105
130
  * External binaries bundled into the `.app` and spawned at runtime with
106
131
  * `app.sidecar(name)`. Maps a logical name to a path (relative to the project
@@ -130,6 +155,12 @@ export interface MirinConfig {
130
155
  */
131
156
  urlSchemes?: string[];
132
157
  windows: Record<string, WindowConfig>;
158
+ /**
159
+ * Single-instance app (default true): a second launch focuses the running
160
+ * window and exits, instead of opening another window. Set false to allow
161
+ * multiple instances (each gets its own CEF cache dir).
162
+ */
163
+ singleInstance?: boolean;
133
164
  }
134
165
 
135
166
  /** A bundled sidecar binary with optional hardened-runtime entitlements. */
@@ -176,6 +207,51 @@ export interface DmgConfig {
176
207
  applicationsPosition?: { x: number; y: number };
177
208
  }
178
209
 
210
+ export interface NsisConfig {
211
+ /**
212
+ * Install for all users under Program Files (requires elevation), vs the
213
+ * default per-user install under `%LOCALAPPDATA%\Programs` (no admin prompt,
214
+ * and the in-app updater can swap the folder without elevation).
215
+ */
216
+ perMachine?: boolean;
217
+ /**
218
+ * One-click install (no wizard — just a progress bar, then run), vs the default
219
+ * assisted wizard (welcome → optional license → directory → install → finish).
220
+ */
221
+ oneClick?: boolean;
222
+ /** Default install directory (absolute, or with NSIS vars like `$PROGRAMFILES64`). */
223
+ installDir?: string;
224
+ /** Let the user change the install directory in the assisted wizard. Default true. */
225
+ allowChangeInstallDir?: boolean;
226
+ /** Create a Desktop shortcut. Default true. */
227
+ desktopShortcut?: boolean;
228
+ /** Create a Start Menu shortcut. Default true. */
229
+ startMenuShortcut?: boolean;
230
+ /** License file shown on a wizard page (path relative to the project root; .txt/.rtf). */
231
+ license?: string;
232
+ /** Publisher / company name, shown in the Add/Remove Programs entry. */
233
+ publisher?: string;
234
+ /** Offer to launch the app on the finish page. Default true. */
235
+ runAfterFinish?: boolean;
236
+ /**
237
+ * Installer + uninstaller `.ico` (path relative to the project root). Defaults to
238
+ * the app `icon` (rendered to `.ico` like the window icon).
239
+ */
240
+ installerIcon?: string;
241
+ /**
242
+ * Raw NSIS script injected near the top of the generated script (advanced) — for
243
+ * custom pages, macros, or `Section` hooks beyond the options above.
244
+ */
245
+ include?: string;
246
+ }
247
+
248
+ /**
249
+ * Inno Setup installer options — the same knobs as {@link NsisConfig}
250
+ * (perMachine, oneClick, shortcuts, license, publisher, runAfterFinish,
251
+ * installerIcon, and a raw `include` injected as `.iss` instead of NSIS script).
252
+ */
253
+ export type InnoConfig = NsisConfig;
254
+
179
255
  export interface ReleaseConfig {
180
256
  /** Flat directory URL hosting `{channel}-{platform}-{arch}-*` update files. */
181
257
  baseUrl: string;
package/src/host.ts CHANGED
@@ -50,6 +50,10 @@ const coreConfig = JSON.parse(
50
50
  process.env.MIRIN_DEV_URL ? { dev: true } : { resources_path: resourcesDir },
51
51
  ),
52
52
  );
53
+ // Opt out of single-instance (the core default) when the app allows multiple.
54
+ if (manifest.singleInstance === false) coreConfig.single_instance = false;
55
+ // The app's bundle id keys the per-app CEF cache dir (Windows has no OS bundle id).
56
+ if (typeof manifest.id === "string") coreConfig.identifier = manifest.id;
53
57
 
54
58
  // Load the native core on the main thread FIRST. The Worker also dlopens the
55
59
  // same dylib in its boot; doing the main-thread dlopen before spawning the
@@ -61,6 +65,7 @@ const worker = new Worker(workerPath, {
61
65
  workerData: {
62
66
  corePath,
63
67
  manifest,
68
+ id: typeof manifest.id === "string" ? manifest.id : undefined,
64
69
  devUrl: process.env.MIRIN_DEV_URL,
65
70
  resourcesDir,
66
71
  sidecarDir,
package/src/index.ts CHANGED
@@ -51,6 +51,8 @@ export type {
51
51
  WindowMaterialOptions,
52
52
  ReleaseConfig,
53
53
  DmgConfig,
54
+ NsisConfig,
55
+ InnoConfig,
54
56
  SidecarSpec,
55
57
  SidecarEntitlement,
56
58
  } from "./config.ts";
package/src/native.ts CHANGED
@@ -24,7 +24,7 @@ const symbols = {
24
24
  mirin_window_control: { args: [FFIType.u32, FFIType.ptr], returns: FFIType.void },
25
25
  mirin_window_set_material: { args: [FFIType.u32, FFIType.ptr], returns: FFIType.void },
26
26
  mirin_window_set_position: { args: [FFIType.u32, FFIType.f64, FFIType.f64], returns: FFIType.void },
27
- mirin_window_maybe_start_drag: { args: [FFIType.u32, FFIType.i32, FFIType.i32, FFIType.i32], returns: FFIType.void },
27
+ mirin_window_maybe_start_drag: { args: [FFIType.u32, FFIType.i32, FFIType.i32, FFIType.i32, FFIType.i32], returns: FFIType.void },
28
28
  mirin_app_quit: { args: [], returns: FFIType.void },
29
29
  mirin_app_set_dock_visible: { args: [FFIType.i32], returns: FFIType.void },
30
30
  mirin_set_app_menu: { args: [FFIType.ptr], returns: FFIType.void },
@@ -114,8 +114,8 @@ export class Core {
114
114
  this.#lib.symbols.mirin_window_set_position(id, x, y);
115
115
  }
116
116
 
117
- windowMaybeStartDrag(id: number, x: number, y: number, detail: number): void {
118
- this.#lib.symbols.mirin_window_maybe_start_drag(id, x, y, detail);
117
+ windowMaybeStartDrag(id: number, x: number, y: number, detail: number, ht: number): void {
118
+ this.#lib.symbols.mirin_window_maybe_start_drag(id, x, y, detail, ht);
119
119
  }
120
120
 
121
121
  quit(): void {
package/src/rpc-server.ts CHANGED
@@ -29,6 +29,8 @@ interface ControlFrame {
29
29
  verb?: string;
30
30
  /** Click count for pointer actions (2 = double-click → maximize). */
31
31
  detail?: number;
32
+ /** Win32 resize hit-test code when the mousedown is on a window edge/corner. */
33
+ ht?: number;
32
34
  }
33
35
 
34
36
  type IncomingFrame = RequestFrame | ControlFrame;
package/src/runtime.ts CHANGED
@@ -18,6 +18,10 @@ export interface Runtime {
18
18
  core: Core;
19
19
  rpc: RpcServer;
20
20
  manifestWindows: ManifestWindowConfig[];
21
+ /** App bundle id (mirin.config `id`); undefined when detached. */
22
+ id?: string;
23
+ /** True under `mirin dev`; false in a packaged build. */
24
+ isDev: boolean;
21
25
  devUrl?: string;
22
26
  /** Contents/Resources of the running .app (for the updater). Absent in dev. */
23
27
  resourcesDir?: string;
@@ -87,6 +91,7 @@ export function boot(): void {
87
91
  const data = (workerData ?? {}) as {
88
92
  corePath?: string;
89
93
  manifest?: { windows?: Record<string, WindowConfig> };
94
+ id?: string;
90
95
  devUrl?: string;
91
96
  resourcesDir?: string;
92
97
  sidecarDir?: string;
@@ -107,7 +112,7 @@ export function boot(): void {
107
112
  rpc.setControlHandler((frame, webview) => {
108
113
  switch (frame.action) {
109
114
  case "window.maybeStartDrag":
110
- core.windowMaybeStartDrag(webview, frame.x ?? 0, frame.y ?? 0, frame.detail ?? 1);
115
+ core.windowMaybeStartDrag(webview, frame.x ?? 0, frame.y ?? 0, frame.detail ?? 1, frame.ht ?? 0);
111
116
  break;
112
117
  case "window.control":
113
118
  if (frame.verb) core.windowControl(webview, frame.verb);
@@ -126,6 +131,8 @@ export function boot(): void {
126
131
  core,
127
132
  rpc,
128
133
  manifestWindows,
134
+ id: data.id,
135
+ isDev: !!data.devUrl,
129
136
  devUrl: data.devUrl,
130
137
  resourcesDir: data.resourcesDir,
131
138
  corePath,
package/src/updater.ts CHANGED
@@ -13,9 +13,17 @@
13
13
  * packaged app with `release` set; in `mirin dev` the updater is idle.
14
14
  */
15
15
 
16
- import { homedir } from "node:os";
16
+ import { homedir, tmpdir } from "node:os";
17
17
  import { join } from "node:path";
18
- import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync } from "node:fs";
18
+ import {
19
+ existsSync,
20
+ mkdirSync,
21
+ readFileSync,
22
+ readdirSync,
23
+ renameSync,
24
+ rmSync,
25
+ writeFileSync,
26
+ } from "node:fs";
19
27
  import { $ } from "bun";
20
28
  import { runtime } from "./runtime.ts";
21
29
  import { loadCodec } from "./codec.ts";
@@ -213,20 +221,59 @@ export class Updater {
213
221
  // to exit (the exe/bundle is locked while running), swaps the folder, relaunches.
214
222
  if (IS_WINDOWS) {
215
223
  const runningApp = join(this.#resourcesDir()!, "..");
216
- const exe = join(runningApp, `${this.#version()!.name}.exe`);
224
+ const name = this.#version()!.name;
225
+ const exe = join(runningApp, `${name}.exe`);
226
+ const script = join(tmpdir(), `mirin-apply-${name}.ps1`);
227
+ const applyVbs = join(tmpdir(), `mirin-apply-${name}.vbs`);
228
+ const launchVbs = join(tmpdir(), `mirin-launch-${name}.vbs`);
217
229
  const ps = [
218
230
  `$ErrorActionPreference='SilentlyContinue'`,
231
+ // Never let the app dir be this process's cwd (Windows can't delete a cwd).
232
+ `Set-Location $env:TEMP`,
233
+ // Wait for the host to exit (it locks the exe), then a beat for CEF's
234
+ // helper subprocesses to release their file locks (libcef.dll etc.).
219
235
  `while (Get-Process -Id ${process.pid} -ErrorAction SilentlyContinue) { Start-Sleep -Milliseconds 200 }`,
220
- `Start-Sleep -Milliseconds 400`,
221
- `Remove-Item -Recurse -Force ${psq(runningApp)}`,
236
+ `Start-Sleep -Milliseconds 700`,
237
+ // Replace the app folder, retrying ~10s while any lingering lock clears.
238
+ `for ($i=0; $i -lt 50; $i++) { Remove-Item -Recurse -Force ${psq(runningApp)}; if (-not (Test-Path ${psq(runningApp)})) { break }; Start-Sleep -Milliseconds 200 }`,
222
239
  `Move-Item -Force ${psq(this.#staged)} ${psq(runningApp)}`,
223
- `Start-Process ${psq(exe)}`,
224
- ].join("; ");
225
- Bun.spawn(["powershell", "-NoProfile", "-NonInteractive", "-Command", ps], {
240
+ `Start-Process -FilePath ${psq(exe)} -WorkingDirectory ${psq(runningApp)}`,
241
+ // self-clean the temp launch scripts (this .ps1 + the two .vbs shims)
242
+ `Remove-Item -Force ${psq(script)},${psq(applyVbs)},${psq(launchVbs)}`,
243
+ ].join("\n");
244
+ writeFileSync(script, ps, "utf8");
245
+
246
+ // No terminal window may appear during the update. powershell.exe is a
247
+ // console-subsystem program: launching it directly (even with
248
+ // -WindowStyle Hidden) flashes a console, because the OS allocates the
249
+ // console before powershell can hide it. wscript.exe is a GUI-subsystem
250
+ // host with no console; its Shell.Run(cmd, 0) starts powershell with
251
+ // SW_HIDE in the initial STARTUPINFO, so no window is ever shown.
252
+ writeFileSync(
253
+ applyVbs,
254
+ `CreateObject("WScript.Shell").Run "powershell -NoProfile -NonInteractive ` +
255
+ `-ExecutionPolicy Bypass -WindowStyle Hidden -File ""${script}""", 0, False\n`,
256
+ "utf8",
257
+ );
258
+
259
+ // The swap MUST outlive this process. A Bun.spawn child is in our job
260
+ // object and is killed when we quit — even with unref/detached (verified).
261
+ // WMI's Win32_Process.Create has the WMI service spawn it, outside our job,
262
+ // so it survives. Issue that WMI call from a console-less wscript host too,
263
+ // so the launcher itself never flashes a window. `await` the short launcher
264
+ // so the swap process exists before core.quit() (no race), then quit.
265
+ writeFileSync(
266
+ launchVbs,
267
+ `GetObject("winmgmts:Win32_Process").Create ` +
268
+ `"wscript.exe //B //Nologo ""${applyVbs}""", Null, Null, pid\n`,
269
+ "utf8",
270
+ );
271
+ const launcher = Bun.spawn(["wscript.exe", "//B", "//Nologo", launchVbs], {
226
272
  stdin: "ignore",
227
273
  stdout: "ignore",
228
274
  stderr: "ignore",
229
- }).unref();
275
+ });
276
+ await launcher.exited;
230
277
  } else {
231
278
  const runningApp = join(this.#resourcesDir()!, "..", "..");
232
279
  const script = [