mirinjs 0.0.1-alpha.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/package.json +41 -0
- package/src/app.ts +300 -0
- package/src/client.ts +67 -0
- package/src/clipboard.ts +12 -0
- package/src/config.ts +79 -0
- package/src/dialog.ts +62 -0
- package/src/host.ts +57 -0
- package/src/index.ts +50 -0
- package/src/menu.ts +74 -0
- package/src/native.ts +156 -0
- package/src/rpc-server.ts +108 -0
- package/src/rpc.ts +63 -0
- package/src/runtime.ts +92 -0
- package/src/shortcut.ts +42 -0
- package/src/tray.ts +42 -0
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "mirinjs",
|
|
3
|
+
"version": "0.0.1-alpha.0",
|
|
4
|
+
"description": "Build desktop apps with Bun, TypeScript, and Chromium (CEF). The Bun-native Electron alternative.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"homepage": "https://github.com/Netko-Labs/mirin#readme",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/Netko-Labs/mirin.git",
|
|
11
|
+
"directory": "packages/mirin"
|
|
12
|
+
},
|
|
13
|
+
"bugs": "https://github.com/Netko-Labs/mirin/issues",
|
|
14
|
+
"keywords": [
|
|
15
|
+
"bun",
|
|
16
|
+
"desktop",
|
|
17
|
+
"cef",
|
|
18
|
+
"chromium",
|
|
19
|
+
"electron-alternative",
|
|
20
|
+
"macos",
|
|
21
|
+
"rpc",
|
|
22
|
+
"webview"
|
|
23
|
+
],
|
|
24
|
+
"exports": {
|
|
25
|
+
".": "./src/index.ts",
|
|
26
|
+
"./config": "./src/config.ts",
|
|
27
|
+
"./rpc": "./src/rpc.ts",
|
|
28
|
+
"./client": "./src/client.ts",
|
|
29
|
+
"./host": "./src/host.ts"
|
|
30
|
+
},
|
|
31
|
+
"files": [
|
|
32
|
+
"src",
|
|
33
|
+
"README.md"
|
|
34
|
+
],
|
|
35
|
+
"engines": {
|
|
36
|
+
"bun": ">=1.2.0"
|
|
37
|
+
},
|
|
38
|
+
"publishConfig": {
|
|
39
|
+
"access": "public"
|
|
40
|
+
}
|
|
41
|
+
}
|
package/src/app.ts
ADDED
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `app` singleton, window handles, and typed event emitters
|
|
3
|
+
* (docs/api-design.md). Talks to the native core through the runtime.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { runtime, onNativeEvent, resolveUrl, type NativeEvent } from "./runtime.ts";
|
|
7
|
+
import type { Router, EventProc } from "./rpc.ts";
|
|
8
|
+
import type { WindowConfig, WindowMaterial, WindowMaterialOptions } from "./config.ts";
|
|
9
|
+
|
|
10
|
+
/** Which native backend a window's `material` resolved to. */
|
|
11
|
+
export type WindowMaterialInfo = {
|
|
12
|
+
/** The material that was requested. */
|
|
13
|
+
requested: string;
|
|
14
|
+
/** What actually rendered: real Liquid Glass, a vibrancy material, or none. */
|
|
15
|
+
backend: "liquidGlass" | "vibrancy" | "none";
|
|
16
|
+
/** Whether Apple's Liquid Glass (NSGlassEffectView, macOS 26+) is available. */
|
|
17
|
+
liquidGlassAvailable: boolean;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export type WindowEvents = {
|
|
21
|
+
focus: void;
|
|
22
|
+
blur: void;
|
|
23
|
+
moved: void;
|
|
24
|
+
resized: void;
|
|
25
|
+
closed: void;
|
|
26
|
+
/** Fired when a native background material is applied (see setMaterial). */
|
|
27
|
+
material: WindowMaterialInfo;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export type AppEvents = {
|
|
31
|
+
ready: void;
|
|
32
|
+
"window-all-closed": void;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
type Listener<P> = (payload: P) => void;
|
|
36
|
+
|
|
37
|
+
class Emitter<Events extends Record<string, unknown>> {
|
|
38
|
+
#listeners = new Map<keyof Events, Set<Listener<unknown>>>();
|
|
39
|
+
|
|
40
|
+
on<K extends keyof Events>(type: K, listener: Listener<Events[K]>): () => void {
|
|
41
|
+
let set = this.#listeners.get(type);
|
|
42
|
+
if (!set) this.#listeners.set(type, (set = new Set()));
|
|
43
|
+
set.add(listener as Listener<unknown>);
|
|
44
|
+
return () => set!.delete(listener as Listener<unknown>);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async *events<K extends keyof Events>(type: K): AsyncIterableIterator<Events[K]> {
|
|
48
|
+
const queue: Events[K][] = [];
|
|
49
|
+
let wake: (() => void) | undefined;
|
|
50
|
+
const off = this.on(type, (payload) => {
|
|
51
|
+
queue.push(payload);
|
|
52
|
+
wake?.();
|
|
53
|
+
});
|
|
54
|
+
try {
|
|
55
|
+
while (true) {
|
|
56
|
+
while (queue.length) yield queue.shift()!;
|
|
57
|
+
await new Promise<void>((resolve) => (wake = resolve));
|
|
58
|
+
}
|
|
59
|
+
} finally {
|
|
60
|
+
off();
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
protected emit<K extends keyof Events>(type: K, payload: Events[K]): void {
|
|
65
|
+
this.#listeners.get(type)?.forEach((fn) => fn(payload));
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface WindowOpenOptions extends WindowConfig {
|
|
70
|
+
name?: string;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** A live, typed handle to an open window. */
|
|
74
|
+
export class WindowHandle extends Emitter<WindowEvents> {
|
|
75
|
+
constructor(
|
|
76
|
+
readonly id: number,
|
|
77
|
+
readonly name: string | undefined,
|
|
78
|
+
) {
|
|
79
|
+
super();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async setTitle(title: string): Promise<void> {
|
|
83
|
+
runtime().core.windowSetTitle(this.id, title);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async loadUrl(url: string): Promise<void> {
|
|
87
|
+
runtime().core.windowLoadUrl(this.id, url);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async close(): Promise<void> {
|
|
91
|
+
runtime().core.windowClose(this.id);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async minimize(): Promise<void> {
|
|
95
|
+
this.#control("minimize");
|
|
96
|
+
}
|
|
97
|
+
async maximize(): Promise<void> {
|
|
98
|
+
this.#control("maximize");
|
|
99
|
+
}
|
|
100
|
+
async restore(): Promise<void> {
|
|
101
|
+
this.#control("restore");
|
|
102
|
+
}
|
|
103
|
+
async toggleFullscreen(): Promise<void> {
|
|
104
|
+
this.#control("fullscreen");
|
|
105
|
+
}
|
|
106
|
+
async focus(): Promise<void> {
|
|
107
|
+
this.#control("focus");
|
|
108
|
+
}
|
|
109
|
+
async show(): Promise<void> {
|
|
110
|
+
this.#control("show");
|
|
111
|
+
}
|
|
112
|
+
async hide(): Promise<void> {
|
|
113
|
+
this.#control("hide");
|
|
114
|
+
}
|
|
115
|
+
async center(): Promise<void> {
|
|
116
|
+
this.#control("center");
|
|
117
|
+
}
|
|
118
|
+
async setAlwaysOnTop(on: boolean): Promise<void> {
|
|
119
|
+
this.#control(on ? "alwaysOnTop:on" : "alwaysOnTop:off");
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Change the window's native background material live (macOS, transparent
|
|
124
|
+
* windows only). Pass `null` to remove it. See {@link WindowMaterial}.
|
|
125
|
+
*/
|
|
126
|
+
async setMaterial(material: WindowMaterial | WindowMaterialOptions | null): Promise<void> {
|
|
127
|
+
runtime().core.windowSetMaterial(this.id, JSON.stringify(normalizeMaterial(material)));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
#control(verb: string): void {
|
|
131
|
+
runtime().core.windowControl(this.id, verb);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Typed push to this window's webview (used by router event procedures). */
|
|
135
|
+
get rpc(): RpcEmitters {
|
|
136
|
+
return rpcEmitters((method, payload) => runtime().rpc.emitTo(this.id, method, payload));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** @internal */
|
|
140
|
+
_emit<K extends keyof WindowEvents>(type: K, payload: WindowEvents[K]): void {
|
|
141
|
+
this.emit(type, payload);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
type RpcEmitters = Record<string, { emit(payload: unknown): void; broadcast(payload: unknown): void }>;
|
|
146
|
+
|
|
147
|
+
function rpcEmitters(send: (method: string, payload: unknown) => void): RpcEmitters {
|
|
148
|
+
return new Proxy({} as RpcEmitters, {
|
|
149
|
+
get(_t, method: string) {
|
|
150
|
+
return {
|
|
151
|
+
emit: (payload: unknown) => send(method, payload),
|
|
152
|
+
broadcast: (payload: unknown) => send(method, payload),
|
|
153
|
+
};
|
|
154
|
+
},
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Typed push emitters derived from a router's `event` procedures. */
|
|
159
|
+
export type BroadcastEmitters<R extends Router<any>> =
|
|
160
|
+
R extends Router<infer T>
|
|
161
|
+
? {
|
|
162
|
+
[K in keyof T as T[K] extends EventProc<any> ? K : never]: T[K] extends EventProc<infer P>
|
|
163
|
+
? { broadcast(payload: P): void }
|
|
164
|
+
: never;
|
|
165
|
+
}
|
|
166
|
+
: never;
|
|
167
|
+
|
|
168
|
+
export interface ServeHandle<R extends Router<any>> {
|
|
169
|
+
readonly rpc: BroadcastEmitters<R>;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
class Windows {
|
|
173
|
+
#byName = new Map<string, WindowHandle>();
|
|
174
|
+
#byId = new Map<number, WindowHandle>();
|
|
175
|
+
|
|
176
|
+
get(name: string): WindowHandle {
|
|
177
|
+
const win = this.#byName.get(name);
|
|
178
|
+
if (!win) throw new Error(`no window named "${name}" is open`);
|
|
179
|
+
return win;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
all(): WindowHandle[] {
|
|
183
|
+
return [...this.#byId.values()];
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Look up a window by its numeric id (e.g. an RPC ctx.webview). */
|
|
187
|
+
byId(id: number): WindowHandle | undefined {
|
|
188
|
+
return this.#byId.get(id);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async open(options: WindowOpenOptions | string): Promise<WindowHandle> {
|
|
192
|
+
const opts = typeof options === "string" ? manifestWindow(options) : options;
|
|
193
|
+
const id = runtime().core.windowCreate(
|
|
194
|
+
JSON.stringify({
|
|
195
|
+
...opts,
|
|
196
|
+
url: resolveUrl(opts.url),
|
|
197
|
+
material: normalizeMaterial(opts.material),
|
|
198
|
+
}),
|
|
199
|
+
);
|
|
200
|
+
const handle = new WindowHandle(id, opts.name);
|
|
201
|
+
this.#register(handle);
|
|
202
|
+
return handle;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
#register(handle: WindowHandle): void {
|
|
206
|
+
this.#byId.set(handle.id, handle);
|
|
207
|
+
if (handle.name) this.#byName.set(handle.name, handle);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** @internal */
|
|
211
|
+
_byId(id: number): WindowHandle | undefined {
|
|
212
|
+
return this.#byId.get(id);
|
|
213
|
+
}
|
|
214
|
+
/** @internal */
|
|
215
|
+
_remove(id: number): void {
|
|
216
|
+
const handle = this.#byId.get(id);
|
|
217
|
+
if (handle) {
|
|
218
|
+
this.#byId.delete(id);
|
|
219
|
+
if (handle.name) this.#byName.delete(handle.name);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function manifestWindow(name: string): WindowOpenOptions {
|
|
225
|
+
const found = runtime().manifestWindows.find((w) => w.name === name);
|
|
226
|
+
if (!found) throw new Error(`no window "${name}" declared in the manifest`);
|
|
227
|
+
return found;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Normalize the `material` option (name or object, or null) to native form. */
|
|
231
|
+
function normalizeMaterial(
|
|
232
|
+
material: WindowMaterial | WindowMaterialOptions | null | undefined,
|
|
233
|
+
): WindowMaterialOptions | null {
|
|
234
|
+
if (!material) return null;
|
|
235
|
+
return typeof material === "string" ? { type: material } : material;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
class MirinApp extends Emitter<AppEvents> {
|
|
239
|
+
readonly windows = new Windows();
|
|
240
|
+
|
|
241
|
+
serve<R extends Router<any>>(router: R): ServeHandle<R> {
|
|
242
|
+
runtime().rpc.setRouter(router);
|
|
243
|
+
return { rpc: this.rpc as BroadcastEmitters<R> };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
get rpc(): RpcEmitters {
|
|
247
|
+
return rpcEmitters((method, payload) => runtime().rpc.broadcast(method, payload));
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
quit(): void {
|
|
251
|
+
runtime().core.quit();
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** @internal */
|
|
255
|
+
_emit<K extends keyof AppEvents>(type: K, payload: AppEvents[K]): void {
|
|
256
|
+
this.emit(type, payload);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export const app = new MirinApp();
|
|
261
|
+
|
|
262
|
+
// ---- wire native events to the app/window emitters ----
|
|
263
|
+
|
|
264
|
+
const WINDOW_EVENTS = ["focus", "blur", "moved", "resized"] as const;
|
|
265
|
+
|
|
266
|
+
export function wireAppEvents(): void {
|
|
267
|
+
onNativeEvent("core.ready", () => {
|
|
268
|
+
for (const cfg of runtime().manifestWindows) {
|
|
269
|
+
if (cfg.open === "manual") continue;
|
|
270
|
+
void app.windows.open(cfg as WindowOpenOptions);
|
|
271
|
+
}
|
|
272
|
+
app._emit("ready", undefined);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
onNativeEvent("window.closed", (event: NativeEvent) => {
|
|
276
|
+
const id = event.id as number | undefined;
|
|
277
|
+
if (id == null) return;
|
|
278
|
+
app.windows._byId(id)?._emit("closed", undefined);
|
|
279
|
+
app.windows._remove(id);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
onNativeEvent("window.all-closed", () => app._emit("window-all-closed", undefined));
|
|
283
|
+
|
|
284
|
+
onNativeEvent("window.material", (event: NativeEvent) => {
|
|
285
|
+
const id = event.id as number | undefined;
|
|
286
|
+
if (id == null) return;
|
|
287
|
+
app.windows._byId(id)?._emit("material", {
|
|
288
|
+
requested: String(event.requested ?? ""),
|
|
289
|
+
backend: (event.backend as WindowMaterialInfo["backend"]) ?? "none",
|
|
290
|
+
liquidGlassAvailable: Boolean(event.liquidGlassAvailable),
|
|
291
|
+
});
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
for (const kind of WINDOW_EVENTS) {
|
|
295
|
+
onNativeEvent(`window.${kind}`, (event: NativeEvent) => {
|
|
296
|
+
const id = event.id as number | undefined;
|
|
297
|
+
if (id != null) app.windows._byId(id)?._emit(kind, undefined);
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
}
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mirin/client — the browser-side RPC client (docs/api-design.md §3).
|
|
3
|
+
*
|
|
4
|
+
* Runs inside webviews. Talks to the `window.mirin` transport installed by the
|
|
5
|
+
* preload bootstrap (token-authenticated WebSocket to the Bun Worker — M3).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { EventProc, MutationProc, QueryProc, Router } from "./rpc.ts";
|
|
9
|
+
|
|
10
|
+
export type ClientFor<R extends Router<any>> =
|
|
11
|
+
R extends Router<infer T>
|
|
12
|
+
? {
|
|
13
|
+
[K in keyof T]: T[K] extends QueryProc<infer I, infer O>
|
|
14
|
+
? (input: I) => Promise<Awaited<O>>
|
|
15
|
+
: T[K] extends MutationProc<infer I, infer O>
|
|
16
|
+
? (input: I) => Promise<Awaited<O>>
|
|
17
|
+
: T[K] extends EventProc<infer P>
|
|
18
|
+
? { on(listener: (payload: P) => void): () => void }
|
|
19
|
+
: never;
|
|
20
|
+
}
|
|
21
|
+
: never;
|
|
22
|
+
|
|
23
|
+
interface MirinTransport {
|
|
24
|
+
call(method: string, input: unknown): Promise<unknown>;
|
|
25
|
+
onEvent(method: string, listener: (payload: unknown) => void): () => void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
declare global {
|
|
29
|
+
interface Window {
|
|
30
|
+
/** Installed by the mirin preload bootstrap before page scripts run. */
|
|
31
|
+
mirin?: MirinTransport;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function transport(): MirinTransport {
|
|
36
|
+
const t = window.mirin;
|
|
37
|
+
if (!t) {
|
|
38
|
+
throw new Error(
|
|
39
|
+
"window.mirin is not available — this page is not running inside a mirin webview " +
|
|
40
|
+
"(or the preload bootstrap failed to authenticate).",
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
return t;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Create the typed client for the app's router. Pass the Router *type* only —
|
|
48
|
+
* never import the router value (and its handlers) into UI code.
|
|
49
|
+
*/
|
|
50
|
+
export function client<R extends Router<any>>(): ClientFor<R> {
|
|
51
|
+
const eventCache = new Map<string, { on(l: (p: unknown) => void): () => void }>();
|
|
52
|
+
|
|
53
|
+
return new Proxy({} as ClientFor<R>, {
|
|
54
|
+
get(_target, prop) {
|
|
55
|
+
if (typeof prop !== "string") return undefined;
|
|
56
|
+
// Procedure kind is unknowable from the type-erased proxy; expose a callable
|
|
57
|
+
// that is *also* event-shaped. The router type makes misuse a compile error.
|
|
58
|
+
const callable = (input: unknown) => transport().call(prop, input);
|
|
59
|
+
let ev = eventCache.get(prop);
|
|
60
|
+
if (!ev) {
|
|
61
|
+
ev = { on: (listener) => transport().onEvent(prop, listener) };
|
|
62
|
+
eventCache.set(prop, ev);
|
|
63
|
+
}
|
|
64
|
+
return Object.assign(callable, ev);
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
}
|
package/src/clipboard.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** Clipboard text access (synchronous). */
|
|
2
|
+
|
|
3
|
+
import { runtime } from "./runtime.ts";
|
|
4
|
+
|
|
5
|
+
export const clipboard = {
|
|
6
|
+
readText(): string {
|
|
7
|
+
return runtime().core.clipboardReadText();
|
|
8
|
+
},
|
|
9
|
+
writeText(text: string): void {
|
|
10
|
+
runtime().core.clipboardWriteText(text);
|
|
11
|
+
},
|
|
12
|
+
};
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mirin/config — the pure-data manifest (docs/api-design.md §1).
|
|
3
|
+
*
|
|
4
|
+
* `mirin.config.ts` must stay serializable: no functions, no side effects.
|
|
5
|
+
* The CLI and the host bootstrap both read it without running app code.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* A native macOS background material rendered behind the web UI. macOS only.
|
|
10
|
+
*
|
|
11
|
+
* - `"liquidGlass"` — Apple's Liquid Glass (NSGlassEffectView, macOS 26+);
|
|
12
|
+
* automatically falls back to a frosted vibrancy material on older systems.
|
|
13
|
+
* - the rest map to NSVisualEffectView vibrancy materials.
|
|
14
|
+
*/
|
|
15
|
+
export type WindowMaterial =
|
|
16
|
+
| "liquidGlass"
|
|
17
|
+
| "sidebar"
|
|
18
|
+
| "menu"
|
|
19
|
+
| "popover"
|
|
20
|
+
| "hud"
|
|
21
|
+
| "fullScreenUI"
|
|
22
|
+
| "underWindowBackground"
|
|
23
|
+
| "contentBackground"
|
|
24
|
+
| "windowBackground"
|
|
25
|
+
| "titlebar"
|
|
26
|
+
| "selection"
|
|
27
|
+
| "headerView"
|
|
28
|
+
| "sheet"
|
|
29
|
+
| "toolTip";
|
|
30
|
+
|
|
31
|
+
export interface WindowMaterialOptions {
|
|
32
|
+
type: WindowMaterial;
|
|
33
|
+
/** Liquid Glass tint as a CSS hex color (e.g. "#3b82f6" or "#3b82f680"). */
|
|
34
|
+
tint?: string;
|
|
35
|
+
/** Corner radius in points (defaults to the panel radius, 14). */
|
|
36
|
+
cornerRadius?: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface WindowConfig {
|
|
40
|
+
/** Page to load: `app://` (bundled assets) or http(s). */
|
|
41
|
+
url: string;
|
|
42
|
+
title?: string;
|
|
43
|
+
width?: number;
|
|
44
|
+
height?: number;
|
|
45
|
+
/** "ready" (default) shows on first paint to avoid a white flash. */
|
|
46
|
+
show?: "ready" | "immediately";
|
|
47
|
+
/** "auto" (default) opens at launch; "manual" windows are templates for app.windows.open(name). */
|
|
48
|
+
open?: "auto" | "manual";
|
|
49
|
+
/** Custom title bar: hide it (content fills) or inset the traffic lights. */
|
|
50
|
+
titleBarStyle?: "hidden" | "hiddenInset";
|
|
51
|
+
/** Non-opaque window (for transparent/blurred UIs). */
|
|
52
|
+
transparent?: boolean;
|
|
53
|
+
/**
|
|
54
|
+
* Native background material behind the web UI (macOS). Implies `transparent`,
|
|
55
|
+
* so the page should use a translucent or clear background. Pass a material
|
|
56
|
+
* name or an object with `tint`/`cornerRadius`.
|
|
57
|
+
*/
|
|
58
|
+
material?: WindowMaterial | WindowMaterialOptions;
|
|
59
|
+
/** Float above normal windows. */
|
|
60
|
+
alwaysOnTop?: boolean;
|
|
61
|
+
/** Drag the window from anywhere in its background. */
|
|
62
|
+
movableByBackground?: boolean;
|
|
63
|
+
/** Create the window hidden (e.g. a Spotlight panel shown on a hotkey). */
|
|
64
|
+
visible?: boolean;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface MirinConfig {
|
|
68
|
+
/** Reverse-DNS app id, e.g. "dev.peje.hello". */
|
|
69
|
+
id: string;
|
|
70
|
+
name: string;
|
|
71
|
+
/** Main-process entry, relative to the project root (runs in the Bun Worker). */
|
|
72
|
+
main: string;
|
|
73
|
+
windows: Record<string, WindowConfig>;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Identity function for typing/intellisense. `const` generic preserves window names as literal keys. */
|
|
77
|
+
export function defineConfig<const T extends MirinConfig>(config: T): T {
|
|
78
|
+
return config;
|
|
79
|
+
}
|
package/src/dialog.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Native dialogs. Async: each call gets a `requestId` the native side echoes
|
|
3
|
+
* back in a `dialog.result` event, which resolves the matching promise.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { runtime, onNativeEvent } from "./runtime.ts";
|
|
7
|
+
|
|
8
|
+
export interface OpenDialogOptions {
|
|
9
|
+
message?: string;
|
|
10
|
+
/** Allow selecting multiple items. */
|
|
11
|
+
multiple?: boolean;
|
|
12
|
+
/** Choose directories instead of files. */
|
|
13
|
+
directories?: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface SaveDialogOptions {
|
|
17
|
+
message?: string;
|
|
18
|
+
/** Pre-filled file name. */
|
|
19
|
+
defaultName?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface MessageDialogOptions {
|
|
23
|
+
message: string;
|
|
24
|
+
detail?: string;
|
|
25
|
+
/** Button titles, left to right; the result is the clicked index. */
|
|
26
|
+
buttons?: string[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const pending = new Map<number, (value: unknown) => void>();
|
|
30
|
+
let nextRequestId = 1;
|
|
31
|
+
|
|
32
|
+
onNativeEvent("dialog.result", (event) => {
|
|
33
|
+
const requestId = event.requestId as number;
|
|
34
|
+
const resolve = pending.get(requestId);
|
|
35
|
+
if (resolve) {
|
|
36
|
+
pending.delete(requestId);
|
|
37
|
+
resolve(event.value);
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
function show<T>(spec: Record<string, unknown>): Promise<T> {
|
|
42
|
+
const requestId = nextRequestId++;
|
|
43
|
+
return new Promise<T>((resolve) => {
|
|
44
|
+
pending.set(requestId, resolve as (value: unknown) => void);
|
|
45
|
+
runtime().core.dialogShow(JSON.stringify({ ...spec, requestId }));
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export const dialog = {
|
|
50
|
+
/** Open file/directory picker; resolves to the chosen paths, or null if cancelled. */
|
|
51
|
+
openFile(options: OpenDialogOptions = {}): Promise<string[] | null> {
|
|
52
|
+
return show({ kind: "openFile", ...options });
|
|
53
|
+
},
|
|
54
|
+
/** Save panel; resolves to the chosen path, or null if cancelled. */
|
|
55
|
+
saveFile(options: SaveDialogOptions = {}): Promise<string | null> {
|
|
56
|
+
return show({ kind: "saveFile", ...options });
|
|
57
|
+
},
|
|
58
|
+
/** Alert with buttons; resolves to `{ button }` (0-based clicked index). */
|
|
59
|
+
message(options: MessageDialogOptions): Promise<{ button: number }> {
|
|
60
|
+
return show({ kind: "message", ...options });
|
|
61
|
+
},
|
|
62
|
+
};
|
package/src/host.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host bootstrap — the process main-thread entry (docs/architecture.md §1).
|
|
3
|
+
*
|
|
4
|
+
* Compiled with `bun build --compile` into the bundle's `Contents/MacOS/<exe>`
|
|
5
|
+
* so CEF's library loader can resolve the framework relative to it. It spawns
|
|
6
|
+
* the user's app as a Bun Worker, then hands the main thread to CEF via
|
|
7
|
+
* `mirin_run` (which blocks until quit).
|
|
8
|
+
*
|
|
9
|
+
* Two modes:
|
|
10
|
+
* - Dev (`mirin dev`): configured via env (MIRIN_CORE / MIRIN_WORKER /
|
|
11
|
+
* MIRIN_MANIFEST_JSON / MIRIN_DEV_URL); windows load the Vite dev server.
|
|
12
|
+
* - Production (built .app): no env — paths and the manifest are resolved
|
|
13
|
+
* from inside the bundle, relative to this executable, and windows load
|
|
14
|
+
* their `app://` URLs served from Contents/Resources.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { Worker } from "node:worker_threads";
|
|
18
|
+
import { dirname, join } from "node:path";
|
|
19
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
20
|
+
import { Core } from "./native.ts";
|
|
21
|
+
|
|
22
|
+
const exeDir = dirname(process.execPath); // Contents/MacOS
|
|
23
|
+
const resourcesDir = join(exeDir, "..", "Resources");
|
|
24
|
+
|
|
25
|
+
const corePath = process.env.MIRIN_CORE ?? join(exeDir, "libmirin_core.dylib");
|
|
26
|
+
const workerPath = process.env.MIRIN_WORKER ?? join(resourcesDir, "worker.js");
|
|
27
|
+
|
|
28
|
+
if (!existsSync(corePath) || !existsSync(workerPath)) {
|
|
29
|
+
console.error(`[mirin host] missing core (${corePath}) or worker (${workerPath})`);
|
|
30
|
+
process.exit(1);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const manifest = JSON.parse(
|
|
34
|
+
process.env.MIRIN_MANIFEST_JSON ?? readManifestFromBundle() ?? "{}",
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
const coreConfig = JSON.parse(
|
|
38
|
+
process.env.MIRIN_CONFIG_JSON ??
|
|
39
|
+
JSON.stringify(process.env.MIRIN_DEV_URL ? {} : { resources_path: resourcesDir }),
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
const worker = new Worker(workerPath, {
|
|
43
|
+
workerData: { corePath, manifest, devUrl: process.env.MIRIN_DEV_URL },
|
|
44
|
+
});
|
|
45
|
+
worker.on("error", (err) => console.error("[mirin worker]", err));
|
|
46
|
+
|
|
47
|
+
// Hand the main thread to CEF. Blocks in the message loop until the app quits.
|
|
48
|
+
const core = new Core(corePath);
|
|
49
|
+
const exitCode = core.run(JSON.stringify(coreConfig));
|
|
50
|
+
|
|
51
|
+
void worker.terminate();
|
|
52
|
+
process.exit(exitCode);
|
|
53
|
+
|
|
54
|
+
function readManifestFromBundle(): string | undefined {
|
|
55
|
+
const path = join(resourcesDir, "mirin.manifest.json");
|
|
56
|
+
return existsSync(path) ? readFileSync(path, "utf8") : undefined;
|
|
57
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mirin — the main-process API (runs in the Bun Worker).
|
|
3
|
+
*
|
|
4
|
+
* Importing this module boots the runtime (loads libmirin_core, starts the RPC
|
|
5
|
+
* server, begins draining native events) and exposes the developer-facing API:
|
|
6
|
+
* the `app` singleton plus the `menu`, `Tray`, `dialog`, `clipboard`, and
|
|
7
|
+
* `globalShortcut` features (docs/api-design.md).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { app, wireAppEvents } from "./app.ts";
|
|
11
|
+
import { boot } from "./runtime.ts";
|
|
12
|
+
|
|
13
|
+
// Side-effect imports: each feature subscribes its native-event handlers.
|
|
14
|
+
import "./menu.ts";
|
|
15
|
+
import "./tray.ts";
|
|
16
|
+
import "./dialog.ts";
|
|
17
|
+
import "./shortcut.ts";
|
|
18
|
+
|
|
19
|
+
export { app } from "./app.ts";
|
|
20
|
+
export { menu } from "./menu.ts";
|
|
21
|
+
export { Tray } from "./tray.ts";
|
|
22
|
+
export { dialog } from "./dialog.ts";
|
|
23
|
+
export { clipboard } from "./clipboard.ts";
|
|
24
|
+
export { globalShortcut } from "./shortcut.ts";
|
|
25
|
+
export { NotAttachedError } from "./runtime.ts";
|
|
26
|
+
|
|
27
|
+
export type {
|
|
28
|
+
WindowEvents,
|
|
29
|
+
WindowMaterialInfo,
|
|
30
|
+
AppEvents,
|
|
31
|
+
WindowHandle,
|
|
32
|
+
WindowOpenOptions,
|
|
33
|
+
ServeHandle,
|
|
34
|
+
BroadcastEmitters,
|
|
35
|
+
} from "./app.ts";
|
|
36
|
+
export type { MenuItemTemplate, MenuRole } from "./menu.ts";
|
|
37
|
+
export type { TrayOptions } from "./tray.ts";
|
|
38
|
+
export type { OpenDialogOptions, SaveDialogOptions, MessageDialogOptions } from "./dialog.ts";
|
|
39
|
+
export type {
|
|
40
|
+
MirinConfig,
|
|
41
|
+
WindowConfig,
|
|
42
|
+
WindowMaterial,
|
|
43
|
+
WindowMaterialOptions,
|
|
44
|
+
} from "./config.ts";
|
|
45
|
+
|
|
46
|
+
wireAppEvents();
|
|
47
|
+
boot();
|
|
48
|
+
|
|
49
|
+
// Re-export so `import mirin from "mirin"` style also works if desired.
|
|
50
|
+
export default app;
|
package/src/menu.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Native menus. A template item's `click` handler is kept in a registry keyed
|
|
3
|
+
* by an auto-assigned id; the native side reports clicks as `menu.click` events
|
|
4
|
+
* carrying that id. `role` items map to AppKit's standard actions.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { runtime, onNativeEvent } from "./runtime.ts";
|
|
8
|
+
|
|
9
|
+
export type MenuRole =
|
|
10
|
+
| "quit" | "close" | "minimize" | "zoom" | "front" | "togglefullscreen"
|
|
11
|
+
| "hide" | "hideothers" | "unhide"
|
|
12
|
+
| "undo" | "redo" | "cut" | "copy" | "paste" | "selectall" | "delete";
|
|
13
|
+
|
|
14
|
+
export interface MenuItemTemplate {
|
|
15
|
+
label?: string;
|
|
16
|
+
role?: MenuRole;
|
|
17
|
+
type?: "normal" | "separator" | "submenu";
|
|
18
|
+
/** e.g. "Cmd+N", "Cmd+Shift+P". */
|
|
19
|
+
accelerator?: string;
|
|
20
|
+
enabled?: boolean;
|
|
21
|
+
checked?: boolean;
|
|
22
|
+
click?: () => void;
|
|
23
|
+
submenu?: MenuItemTemplate[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface NativeMenuItem {
|
|
27
|
+
id?: number;
|
|
28
|
+
label?: string;
|
|
29
|
+
role?: string;
|
|
30
|
+
type?: string;
|
|
31
|
+
accelerator?: string;
|
|
32
|
+
enabled?: boolean;
|
|
33
|
+
checked?: boolean;
|
|
34
|
+
submenu?: NativeMenuItem[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
let nextId = 1;
|
|
38
|
+
const clickHandlers = new Map<number, () => void>();
|
|
39
|
+
|
|
40
|
+
onNativeEvent("menu.click", (event) => {
|
|
41
|
+
clickHandlers.get(event.id as number)?.();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
/** Convert a template to the native JSON shape, registering click handlers. */
|
|
45
|
+
export function buildNativeMenu(items: MenuItemTemplate[]): NativeMenuItem[] {
|
|
46
|
+
return items.map((item) => {
|
|
47
|
+
const native: NativeMenuItem = {
|
|
48
|
+
label: item.label,
|
|
49
|
+
role: item.role,
|
|
50
|
+
type: item.type,
|
|
51
|
+
accelerator: item.accelerator,
|
|
52
|
+
enabled: item.enabled,
|
|
53
|
+
checked: item.checked,
|
|
54
|
+
};
|
|
55
|
+
if (item.click) {
|
|
56
|
+
const id = nextId++;
|
|
57
|
+
clickHandlers.set(id, item.click);
|
|
58
|
+
native.id = id;
|
|
59
|
+
}
|
|
60
|
+
if (item.submenu) native.submenu = buildNativeMenu(item.submenu);
|
|
61
|
+
return native;
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export const menu = {
|
|
66
|
+
/** Replace the application menu bar. */
|
|
67
|
+
setApplicationMenu(template: MenuItemTemplate[]): void {
|
|
68
|
+
runtime().core.setAppMenu(JSON.stringify(buildNativeMenu(template)));
|
|
69
|
+
},
|
|
70
|
+
/** Show a context menu at the cursor. */
|
|
71
|
+
popup(template: MenuItemTemplate[]): void {
|
|
72
|
+
runtime().core.popupMenu(JSON.stringify(buildNativeMenu(template)));
|
|
73
|
+
},
|
|
74
|
+
};
|
package/src/native.ts
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FFI bindings to libmirin_core (docs/architecture.md §3).
|
|
3
|
+
*
|
|
4
|
+
* Loaded in the Bun Worker (commands + event handler) and, separately, in the
|
|
5
|
+
* host's main thread (just `mirin_run`). Both dlopen the same dylib in the same
|
|
6
|
+
* process, so the Rust statics behind these symbols are shared.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { dlopen, FFIType, ptr, CString, type Pointer } from "bun:ffi";
|
|
10
|
+
|
|
11
|
+
function nullTerminated(s: string): Uint8Array {
|
|
12
|
+
return new TextEncoder().encode(s + "\0");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const symbols = {
|
|
16
|
+
mirin_run: { args: [FFIType.ptr], returns: FFIType.i32 },
|
|
17
|
+
mirin_poll_event: { args: [], returns: FFIType.ptr },
|
|
18
|
+
mirin_set_rpc_endpoint: { args: [FFIType.u16, FFIType.ptr], returns: FFIType.void },
|
|
19
|
+
mirin_is_ready: { args: [], returns: FFIType.i32 },
|
|
20
|
+
mirin_window_create: { args: [FFIType.ptr], returns: FFIType.u32 },
|
|
21
|
+
mirin_window_close: { args: [FFIType.u32], returns: FFIType.void },
|
|
22
|
+
mirin_window_load_url: { args: [FFIType.u32, FFIType.ptr], returns: FFIType.void },
|
|
23
|
+
mirin_window_set_title: { args: [FFIType.u32, FFIType.ptr], returns: FFIType.void },
|
|
24
|
+
mirin_window_control: { args: [FFIType.u32, FFIType.ptr], returns: FFIType.void },
|
|
25
|
+
mirin_window_set_material: { args: [FFIType.u32, FFIType.ptr], returns: FFIType.void },
|
|
26
|
+
mirin_app_quit: { args: [], returns: FFIType.void },
|
|
27
|
+
mirin_set_app_menu: { args: [FFIType.ptr], returns: FFIType.void },
|
|
28
|
+
mirin_popup_menu: { args: [FFIType.ptr], returns: FFIType.void },
|
|
29
|
+
mirin_tray_create: { args: [FFIType.ptr], returns: FFIType.void },
|
|
30
|
+
mirin_tray_destroy: { args: [FFIType.u32], returns: FFIType.void },
|
|
31
|
+
mirin_shortcut_register: { args: [FFIType.u32, FFIType.ptr], returns: FFIType.i32 },
|
|
32
|
+
mirin_shortcut_unregister: { args: [FFIType.u32], returns: FFIType.void },
|
|
33
|
+
mirin_clipboard_read_text: { args: [], returns: FFIType.ptr },
|
|
34
|
+
mirin_clipboard_write_text: { args: [FFIType.ptr], returns: FFIType.void },
|
|
35
|
+
mirin_dialog_show: { args: [FFIType.ptr], returns: FFIType.void },
|
|
36
|
+
} as const;
|
|
37
|
+
|
|
38
|
+
export type EventListener = (event: string) => void;
|
|
39
|
+
|
|
40
|
+
export class Core {
|
|
41
|
+
#lib: ReturnType<typeof dlopen<typeof symbols>>;
|
|
42
|
+
#polling = false;
|
|
43
|
+
|
|
44
|
+
constructor(libraryPath: string) {
|
|
45
|
+
this.#lib = dlopen(libraryPath, symbols);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Blocks: runs CEF's message loop until quit. Call on the process main thread. */
|
|
49
|
+
run(configJson: string): number {
|
|
50
|
+
const buf = nullTerminated(configJson);
|
|
51
|
+
return this.#lib.symbols.mirin_run(ptr(buf));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Drain native events on an interval and dispatch them. We poll because the
|
|
56
|
+
* host's main thread is blocked in `mirin_run`, so a bun:ffi callback invoked
|
|
57
|
+
* from it never reaches this Worker's loop (see engine::poll_event).
|
|
58
|
+
*/
|
|
59
|
+
onEvent(listener: EventListener): void {
|
|
60
|
+
if (this.#polling) return;
|
|
61
|
+
this.#polling = true;
|
|
62
|
+
const drain = () => {
|
|
63
|
+
for (;;) {
|
|
64
|
+
const p = this.#lib.symbols.mirin_poll_event() as Pointer | null;
|
|
65
|
+
if (!p) break;
|
|
66
|
+
listener(new CString(p).toString());
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
setInterval(drain, 8);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
setRpcEndpoint(port: number, token: string): void {
|
|
73
|
+
const buf = nullTerminated(token);
|
|
74
|
+
this.#lib.symbols.mirin_set_rpc_endpoint(port, ptr(buf));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
isReady(): boolean {
|
|
78
|
+
return this.#lib.symbols.mirin_is_ready() === 1;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
windowCreate(optsJson: string): number {
|
|
82
|
+
const buf = nullTerminated(optsJson);
|
|
83
|
+
return this.#lib.symbols.mirin_window_create(ptr(buf));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
windowClose(id: number): void {
|
|
87
|
+
this.#lib.symbols.mirin_window_close(id);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
windowLoadUrl(id: number, url: string): void {
|
|
91
|
+
const buf = nullTerminated(url);
|
|
92
|
+
this.#lib.symbols.mirin_window_load_url(id, ptr(buf));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
windowSetTitle(id: number, title: string): void {
|
|
96
|
+
const buf = nullTerminated(title);
|
|
97
|
+
this.#lib.symbols.mirin_window_set_title(id, ptr(buf));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
windowControl(id: number, verb: string): void {
|
|
101
|
+
const buf = nullTerminated(verb);
|
|
102
|
+
this.#lib.symbols.mirin_window_control(id, ptr(buf));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
windowSetMaterial(id: number, specJson: string): void {
|
|
106
|
+
const buf = nullTerminated(specJson);
|
|
107
|
+
this.#lib.symbols.mirin_window_set_material(id, ptr(buf));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
quit(): void {
|
|
111
|
+
this.#lib.symbols.mirin_app_quit();
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
setAppMenu(templateJson: string): void {
|
|
115
|
+
const buf = nullTerminated(templateJson);
|
|
116
|
+
this.#lib.symbols.mirin_set_app_menu(ptr(buf));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
popupMenu(templateJson: string): void {
|
|
120
|
+
const buf = nullTerminated(templateJson);
|
|
121
|
+
this.#lib.symbols.mirin_popup_menu(ptr(buf));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
trayCreate(specJson: string): void {
|
|
125
|
+
const buf = nullTerminated(specJson);
|
|
126
|
+
this.#lib.symbols.mirin_tray_create(ptr(buf));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
trayDestroy(id: number): void {
|
|
130
|
+
this.#lib.symbols.mirin_tray_destroy(id);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
shortcutRegister(id: number, accelerator: string): boolean {
|
|
134
|
+
const buf = nullTerminated(accelerator);
|
|
135
|
+
return this.#lib.symbols.mirin_shortcut_register(id, ptr(buf)) === 1;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
shortcutUnregister(id: number): void {
|
|
139
|
+
this.#lib.symbols.mirin_shortcut_unregister(id);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
clipboardReadText(): string {
|
|
143
|
+
const p = this.#lib.symbols.mirin_clipboard_read_text() as Pointer | null;
|
|
144
|
+
return p ? new CString(p).toString() : "";
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
clipboardWriteText(text: string): void {
|
|
148
|
+
const buf = nullTerminated(text);
|
|
149
|
+
this.#lib.symbols.mirin_clipboard_write_text(ptr(buf));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
dialogShow(specJson: string): void {
|
|
153
|
+
const buf = nullTerminated(specJson);
|
|
154
|
+
this.#lib.symbols.mirin_dialog_show(ptr(buf));
|
|
155
|
+
}
|
|
156
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RPC data plane (docs/architecture.md §4): a token-authenticated localhost
|
|
3
|
+
* WebSocket the injected `window.mirin` connects to. Requests dispatch to the
|
|
4
|
+
* app's router here, in the Bun Worker; events push back to webviews.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { Router, RpcContext } from "./rpc.ts";
|
|
8
|
+
import type { ServerWebSocket } from "bun";
|
|
9
|
+
|
|
10
|
+
interface SocketData {
|
|
11
|
+
webview: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface RequestFrame {
|
|
15
|
+
kind: "request";
|
|
16
|
+
id: number;
|
|
17
|
+
method: string;
|
|
18
|
+
input: unknown;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export class RpcServer {
|
|
22
|
+
readonly token = crypto.randomUUID();
|
|
23
|
+
#server?: ReturnType<typeof Bun.serve>;
|
|
24
|
+
#router?: Router<any>;
|
|
25
|
+
#sockets = new Set<ServerWebSocket<SocketData>>();
|
|
26
|
+
|
|
27
|
+
/** Start listening on an ephemeral loopback port; returns the bound port. */
|
|
28
|
+
start(): number {
|
|
29
|
+
const token = this.token;
|
|
30
|
+
const onMessage = (ws: ServerWebSocket<SocketData>, raw: string | Buffer) =>
|
|
31
|
+
this.#onMessage(ws, raw);
|
|
32
|
+
const sockets = this.#sockets;
|
|
33
|
+
|
|
34
|
+
const server = Bun.serve<SocketData>({
|
|
35
|
+
port: 0,
|
|
36
|
+
hostname: "127.0.0.1",
|
|
37
|
+
fetch(req, server) {
|
|
38
|
+
const url = new URL(req.url);
|
|
39
|
+
if (url.searchParams.get("token") !== token) {
|
|
40
|
+
return new Response("unauthorized", { status: 401 });
|
|
41
|
+
}
|
|
42
|
+
const webview = Number(url.searchParams.get("webview") ?? "0");
|
|
43
|
+
if (server.upgrade(req, { data: { webview } })) return undefined;
|
|
44
|
+
return new Response("mirin rpc");
|
|
45
|
+
},
|
|
46
|
+
websocket: {
|
|
47
|
+
open(ws) {
|
|
48
|
+
sockets.add(ws);
|
|
49
|
+
},
|
|
50
|
+
message(ws, message) {
|
|
51
|
+
onMessage(ws, message);
|
|
52
|
+
},
|
|
53
|
+
close(ws) {
|
|
54
|
+
sockets.delete(ws);
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
this.#server = server;
|
|
59
|
+
const port = server.port;
|
|
60
|
+
if (port == null) throw new Error("mirin rpc server failed to bind a port");
|
|
61
|
+
return port;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
setRouter(router: Router<any>): void {
|
|
65
|
+
this.#router = router;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Push an event to every connected webview. */
|
|
69
|
+
broadcast(method: string, payload: unknown): void {
|
|
70
|
+
const frame = JSON.stringify({ kind: "event", method, payload });
|
|
71
|
+
for (const ws of this.#sockets) ws.send(frame);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Push an event to a single webview. */
|
|
75
|
+
emitTo(webview: number, method: string, payload: unknown): void {
|
|
76
|
+
const frame = JSON.stringify({ kind: "event", method, payload });
|
|
77
|
+
for (const ws of this.#sockets) {
|
|
78
|
+
if (ws.data.webview === webview) ws.send(frame);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async #onMessage(ws: ServerWebSocket<SocketData>, raw: string | Buffer): Promise<void> {
|
|
83
|
+
let frame: RequestFrame;
|
|
84
|
+
try {
|
|
85
|
+
frame = JSON.parse(typeof raw === "string" ? raw : raw.toString());
|
|
86
|
+
} catch {
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
if (frame.kind !== "request") return;
|
|
90
|
+
|
|
91
|
+
const reply = (ok: boolean, body: { result?: unknown; error?: string }) =>
|
|
92
|
+
ws.send(JSON.stringify({ kind: "response", id: frame.id, ok, ...body }));
|
|
93
|
+
|
|
94
|
+
const proc = this.#router?.routes[frame.method];
|
|
95
|
+
if (!proc || (proc.type !== "query" && proc.type !== "mutation")) {
|
|
96
|
+
reply(false, { error: `no such procedure: ${frame.method}` });
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const ctx: RpcContext = { webview: ws.data.webview };
|
|
101
|
+
try {
|
|
102
|
+
const result = await proc.handler(frame.input, ctx);
|
|
103
|
+
reply(true, { result });
|
|
104
|
+
} catch (err) {
|
|
105
|
+
reply(false, { error: err instanceof Error ? err.message : String(err) });
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
package/src/rpc.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mirin/rpc — schema-derived typed RPC (docs/api-design.md §3).
|
|
3
|
+
*
|
|
4
|
+
* Imported by BOTH the main process (handlers run here, in the Bun Worker)
|
|
5
|
+
* and UI code (type-only, via `client<Router>()` from mirin/client).
|
|
6
|
+
* The router is global; handlers learn their caller from `ctx`.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export interface RpcContext {
|
|
10
|
+
/** Name of the calling window, if it was opened with one. */
|
|
11
|
+
window?: string;
|
|
12
|
+
/** Numeric id of the calling webview. */
|
|
13
|
+
webview: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type Handler<I, O> = (input: I, ctx: RpcContext) => O | Promise<O>;
|
|
17
|
+
|
|
18
|
+
export interface QueryProc<I, O> {
|
|
19
|
+
readonly type: "query";
|
|
20
|
+
readonly handler: Handler<I, O>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface MutationProc<I, O> {
|
|
24
|
+
readonly type: "mutation";
|
|
25
|
+
readonly handler: Handler<I, O>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface EventProc<P> {
|
|
29
|
+
readonly type: "event";
|
|
30
|
+
/** Phantom field carrying the payload type; never set at runtime. */
|
|
31
|
+
readonly __payload?: P;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type Procedure =
|
|
35
|
+
| QueryProc<any, any>
|
|
36
|
+
| MutationProc<any, any>
|
|
37
|
+
| EventProc<any>;
|
|
38
|
+
|
|
39
|
+
export interface Router<T extends Record<string, Procedure> = Record<string, Procedure>> {
|
|
40
|
+
readonly type: "router";
|
|
41
|
+
readonly routes: T;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const rpc = {
|
|
45
|
+
router<T extends Record<string, Procedure>>(routes: T): Router<T> {
|
|
46
|
+
return { type: "router", routes };
|
|
47
|
+
},
|
|
48
|
+
|
|
49
|
+
/** Request/response, semantically a read. Runtime-identical to mutation in the MVP. */
|
|
50
|
+
query<I, O>(handler: Handler<I, O>): QueryProc<I, O> {
|
|
51
|
+
return { type: "query", handler };
|
|
52
|
+
},
|
|
53
|
+
|
|
54
|
+
/** Request/response, semantically a write. */
|
|
55
|
+
mutation<I, O>(handler: Handler<I, O>): MutationProc<I, O> {
|
|
56
|
+
return { type: "mutation", handler };
|
|
57
|
+
},
|
|
58
|
+
|
|
59
|
+
/** Main → UI push channel. Emit via window handles or app.rpc broadcast. */
|
|
60
|
+
event<P>(): EventProc<P> {
|
|
61
|
+
return { type: "event" };
|
|
62
|
+
},
|
|
63
|
+
};
|
package/src/runtime.ts
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Worker-side runtime: loads libmirin_core, starts the RPC server, and pumps
|
|
3
|
+
* native events to feature modules. Infrastructure only — the developer-facing
|
|
4
|
+
* API lives in app.ts and the feature modules, which subscribe here.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { workerData } from "node:worker_threads";
|
|
8
|
+
import { Core } from "./native.ts";
|
|
9
|
+
import { RpcServer } from "./rpc-server.ts";
|
|
10
|
+
import type { WindowConfig } from "./config.ts";
|
|
11
|
+
|
|
12
|
+
export interface ManifestWindowConfig extends WindowConfig {
|
|
13
|
+
name: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface Runtime {
|
|
17
|
+
core: Core;
|
|
18
|
+
rpc: RpcServer;
|
|
19
|
+
manifestWindows: ManifestWindowConfig[];
|
|
20
|
+
devUrl?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export class NotAttachedError extends Error {
|
|
24
|
+
constructor(what: string) {
|
|
25
|
+
super(`${what}: the mirin native host is not attached. Run via \`mirin dev\`.`);
|
|
26
|
+
this.name = "NotAttachedError";
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
let current: Runtime | undefined;
|
|
31
|
+
|
|
32
|
+
/** The live runtime; throws if the native host isn't attached. */
|
|
33
|
+
export function runtime(): Runtime {
|
|
34
|
+
if (!current) throw new NotAttachedError("mirin runtime");
|
|
35
|
+
return current;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Dev: every window loads the Vite server. Production: its manifest app:// URL. */
|
|
39
|
+
export function resolveUrl(url: string): string {
|
|
40
|
+
return current?.devUrl ?? url;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ---- native event dispatch ----
|
|
44
|
+
|
|
45
|
+
export interface NativeEvent {
|
|
46
|
+
type: string;
|
|
47
|
+
[key: string]: unknown;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
type NativeListener = (event: NativeEvent) => void;
|
|
51
|
+
const listeners = new Map<string, Set<NativeListener>>();
|
|
52
|
+
|
|
53
|
+
/** Subscribe to a native event type (e.g. "menu.click"). Safe before boot. */
|
|
54
|
+
export function onNativeEvent(type: string, listener: NativeListener): () => void {
|
|
55
|
+
let set = listeners.get(type);
|
|
56
|
+
if (!set) listeners.set(type, (set = new Set()));
|
|
57
|
+
set.add(listener);
|
|
58
|
+
return () => set!.delete(listener);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function dispatch(raw: string): void {
|
|
62
|
+
let event: NativeEvent;
|
|
63
|
+
try {
|
|
64
|
+
event = JSON.parse(raw);
|
|
65
|
+
} catch {
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
listeners.get(event.type)?.forEach((fn) => fn(event));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Boot the runtime from the Worker's workerData. No-op when run detached. */
|
|
72
|
+
export function boot(): void {
|
|
73
|
+
const data = (workerData ?? {}) as {
|
|
74
|
+
corePath?: string;
|
|
75
|
+
manifest?: { windows?: Record<string, WindowConfig> };
|
|
76
|
+
devUrl?: string;
|
|
77
|
+
};
|
|
78
|
+
const corePath = data.corePath ?? process.env.MIRIN_CORE;
|
|
79
|
+
if (!corePath) return; // not under the host; the API stays detached
|
|
80
|
+
|
|
81
|
+
const core = new Core(corePath);
|
|
82
|
+
const rpc = new RpcServer();
|
|
83
|
+
const port = rpc.start();
|
|
84
|
+
core.setRpcEndpoint(port, rpc.token);
|
|
85
|
+
|
|
86
|
+
const manifestWindows: ManifestWindowConfig[] = Object.entries(
|
|
87
|
+
data.manifest?.windows ?? {},
|
|
88
|
+
).map(([name, cfg]) => ({ name, ...cfg }));
|
|
89
|
+
|
|
90
|
+
current = { core, rpc, manifestWindows, devUrl: data.devUrl };
|
|
91
|
+
core.onEvent(dispatch);
|
|
92
|
+
}
|
package/src/shortcut.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Global (system-wide) keyboard shortcuts. Each registration gets an id; the
|
|
3
|
+
* native side reports presses as `shortcut.trigger` events.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { runtime, onNativeEvent } from "./runtime.ts";
|
|
7
|
+
|
|
8
|
+
let nextId = 1;
|
|
9
|
+
const handlers = new Map<number, () => void>();
|
|
10
|
+
const idByAccelerator = new Map<string, number>();
|
|
11
|
+
|
|
12
|
+
onNativeEvent("shortcut.trigger", (event) => {
|
|
13
|
+
handlers.get(event.id as number)?.();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
export const globalShortcut = {
|
|
17
|
+
/** Register a global hotkey, e.g. "Cmd+Shift+K". Returns false if invalid. */
|
|
18
|
+
register(accelerator: string, handler: () => void): boolean {
|
|
19
|
+
this.unregister(accelerator);
|
|
20
|
+
const id = nextId++;
|
|
21
|
+
handlers.set(id, handler);
|
|
22
|
+
const ok = runtime().core.shortcutRegister(id, accelerator);
|
|
23
|
+
if (ok) {
|
|
24
|
+
idByAccelerator.set(accelerator, id);
|
|
25
|
+
} else {
|
|
26
|
+
handlers.delete(id);
|
|
27
|
+
}
|
|
28
|
+
return ok;
|
|
29
|
+
},
|
|
30
|
+
|
|
31
|
+
unregister(accelerator: string): void {
|
|
32
|
+
const id = idByAccelerator.get(accelerator);
|
|
33
|
+
if (id == null) return;
|
|
34
|
+
runtime().core.shortcutUnregister(id);
|
|
35
|
+
handlers.delete(id);
|
|
36
|
+
idByAccelerator.delete(accelerator);
|
|
37
|
+
},
|
|
38
|
+
|
|
39
|
+
unregisterAll(): void {
|
|
40
|
+
for (const accelerator of [...idByAccelerator.keys()]) this.unregister(accelerator);
|
|
41
|
+
},
|
|
42
|
+
};
|
package/src/tray.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Menu-bar tray items. A tray with a `menu` shows it on click (items route
|
|
3
|
+
* through the shared menu registry); a tray without a menu fires `onClick`.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { runtime, onNativeEvent } from "./runtime.ts";
|
|
7
|
+
import { buildNativeMenu, type MenuItemTemplate } from "./menu.ts";
|
|
8
|
+
|
|
9
|
+
export interface TrayOptions {
|
|
10
|
+
title?: string;
|
|
11
|
+
tooltip?: string;
|
|
12
|
+
menu?: MenuItemTemplate[];
|
|
13
|
+
onClick?: () => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
let nextId = 1;
|
|
17
|
+
const clickHandlers = new Map<number, () => void>();
|
|
18
|
+
|
|
19
|
+
onNativeEvent("tray.click", (event) => {
|
|
20
|
+
clickHandlers.get(event.id as number)?.();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
export class Tray {
|
|
24
|
+
readonly id = nextId++;
|
|
25
|
+
|
|
26
|
+
constructor(options: TrayOptions) {
|
|
27
|
+
if (options.onClick) clickHandlers.set(this.id, options.onClick);
|
|
28
|
+
runtime().core.trayCreate(
|
|
29
|
+
JSON.stringify({
|
|
30
|
+
id: this.id,
|
|
31
|
+
title: options.title,
|
|
32
|
+
tooltip: options.tooltip,
|
|
33
|
+
menu: options.menu ? buildNativeMenu(options.menu) : undefined,
|
|
34
|
+
}),
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
destroy(): void {
|
|
39
|
+
runtime().core.trayDestroy(this.id);
|
|
40
|
+
clickHandlers.delete(this.id);
|
|
41
|
+
}
|
|
42
|
+
}
|