mirinjs 0.1.0-alpha.25 → 0.1.0-alpha.27
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 +1 -1
- package/src/app/facade.ts +1 -16
- package/src/app/lib/rpc-emitters.ts +37 -0
- package/src/app/types.ts +8 -3
- package/src/client.ts +39 -12
- package/src/config/manifest.ts +8 -0
- package/src/index.ts +1 -1
- package/src/logger.ts +3 -2
- package/src/rpc-server.ts +24 -10
- package/src/rpc.ts +23 -4
- package/src/runtime.ts +24 -2
- package/src/shared/console.ts +46 -0
- package/src/shared/rpc-paths.ts +83 -0
- package/src/updater/lib/version.ts +45 -0
- package/src/updater/updater.ts +2 -1
package/package.json
CHANGED
package/src/app/facade.ts
CHANGED
|
@@ -12,6 +12,7 @@ import type { Updater } from "../updater/index.ts";
|
|
|
12
12
|
import { updater } from "../updater/index.ts";
|
|
13
13
|
import { Emitter } from "./lib/emitter.ts";
|
|
14
14
|
import { normalizeMaterial } from "./lib/material.ts";
|
|
15
|
+
import { type RpcEmitters, rpcEmitters } from "./lib/rpc-emitters.ts";
|
|
15
16
|
import type {
|
|
16
17
|
AppEvents,
|
|
17
18
|
BroadcastEmitters,
|
|
@@ -139,22 +140,6 @@ export class WindowHandle extends Emitter<WindowEvents> {
|
|
|
139
140
|
}
|
|
140
141
|
}
|
|
141
142
|
|
|
142
|
-
type RpcEmitters = Record<
|
|
143
|
-
string,
|
|
144
|
-
{ emit(payload: unknown): void; broadcast(payload: unknown): void }
|
|
145
|
-
>;
|
|
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
143
|
class Windows {
|
|
159
144
|
#byName = new Map<string, WindowHandle>();
|
|
160
145
|
#byId = new Map<number, WindowHandle>();
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The untyped emitters behind `app.rpc` and a window's `rpc`: `rpc.progress`
|
|
3
|
+
* sends the event `progress`, and `rpc.window.focused` the nested router's
|
|
4
|
+
* `window.focused` (docs/api-design.md §3). `app.serve` hands out the same
|
|
5
|
+
* object typed by the router (BroadcastEmitters).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { SEPARATOR } from "../../shared/rpc-paths.ts";
|
|
9
|
+
|
|
10
|
+
/** An event by name, and the events of a nested router under it (`rpc.window.focused`). */
|
|
11
|
+
export type RpcEmitter = {
|
|
12
|
+
emit(payload: unknown): void;
|
|
13
|
+
broadcast(payload: unknown): void;
|
|
14
|
+
} & RpcEmitters;
|
|
15
|
+
|
|
16
|
+
export interface RpcEmitters {
|
|
17
|
+
readonly [name: string]: RpcEmitter;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function rpcEmitters(send: (method: string, payload: unknown) => void): RpcEmitters {
|
|
21
|
+
const node = (method: string): RpcEmitter =>
|
|
22
|
+
new Proxy({} as RpcEmitter, {
|
|
23
|
+
get(_t, prop) {
|
|
24
|
+
if (prop === "emit" || prop === "broadcast") {
|
|
25
|
+
return (payload: unknown) => send(method, payload);
|
|
26
|
+
}
|
|
27
|
+
// Not a promise, and nothing behind symbols.
|
|
28
|
+
if (typeof prop !== "string" || prop === "then") return undefined;
|
|
29
|
+
return node(`${method}${SEPARATOR}${prop}`);
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
return new Proxy({} as RpcEmitters, {
|
|
33
|
+
get(_t, method) {
|
|
34
|
+
return typeof method === "string" ? node(method) : undefined;
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
}
|
package/src/app/types.ts
CHANGED
|
@@ -41,13 +41,18 @@ export interface WindowOpenOptions extends WindowConfig {
|
|
|
41
41
|
name?: string;
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
-
/** Typed push emitters derived from a router's `event` procedures
|
|
44
|
+
/** Typed push emitters derived from a router's `event` procedures, nested
|
|
45
|
+
* routers under their names (`rpc.window.focused.broadcast(...)`).
|
|
45
46
|
* Type-level `any` keeps the caller's router payload inference intact. */
|
|
46
47
|
export type BroadcastEmitters<R extends Router<any>> = R extends Router<infer T>
|
|
47
48
|
? {
|
|
48
|
-
[K in keyof T as T[K] extends EventProc<any>
|
|
49
|
+
[K in keyof T as T[K] extends EventProc<any> | Router<any>
|
|
50
|
+
? K
|
|
51
|
+
: never]: T[K] extends EventProc<infer P>
|
|
49
52
|
? { broadcast(payload: P): void }
|
|
50
|
-
:
|
|
53
|
+
: T[K] extends Router<any>
|
|
54
|
+
? BroadcastEmitters<T[K]>
|
|
55
|
+
: never;
|
|
51
56
|
}
|
|
52
57
|
: never;
|
|
53
58
|
|
package/src/client.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import type { EventProc, MutationProc, QueryProc, Router } from "./rpc.ts";
|
|
9
|
+
import { SEPARATOR } from "./shared/rpc-paths.ts";
|
|
9
10
|
|
|
10
11
|
// Type-level `any` preserves the app router's concrete procedure inference for UI callers.
|
|
11
12
|
export type ClientFor<R extends Router<any>> = R extends Router<infer T>
|
|
@@ -16,7 +17,9 @@ export type ClientFor<R extends Router<any>> = R extends Router<infer T>
|
|
|
16
17
|
? (input: I) => Promise<Awaited<O>>
|
|
17
18
|
: T[K] extends EventProc<infer P>
|
|
18
19
|
? { on(listener: (payload: P) => void): () => void }
|
|
19
|
-
:
|
|
20
|
+
: T[K] extends Router<any>
|
|
21
|
+
? ClientFor<T[K]>
|
|
22
|
+
: never;
|
|
20
23
|
}
|
|
21
24
|
: never;
|
|
22
25
|
|
|
@@ -51,20 +54,44 @@ function transport(): MirinTransport {
|
|
|
51
54
|
*/
|
|
52
55
|
// Type-level `any` keeps `client<AppRouter>()` inference intact.
|
|
53
56
|
export function client<R extends Router<any>>(): ClientFor<R> {
|
|
54
|
-
const
|
|
55
|
-
|
|
57
|
+
const nodes = new Map<string, unknown>();
|
|
58
|
+
const node = (path: string): unknown => {
|
|
59
|
+
let found = nodes.get(path);
|
|
60
|
+
if (found === undefined) {
|
|
61
|
+
found = clientNode(path, node);
|
|
62
|
+
nodes.set(path, found);
|
|
63
|
+
}
|
|
64
|
+
return found;
|
|
65
|
+
};
|
|
56
66
|
return new Proxy({} as ClientFor<R>, {
|
|
57
67
|
get(_target, prop) {
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
68
|
+
return typeof prop === "string" ? node(prop) : undefined;
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** What a function's caller expects to find on it; anything else is a nested name. */
|
|
74
|
+
const FUNCTION_MEMBERS = new Set(["apply", "bind", "call", "toString"]);
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* One name in the client, `path` from the root. Procedure kind is unknowable
|
|
78
|
+
* from the type-erased proxy, so it is all of them at once: callable (a query
|
|
79
|
+
* or mutation), event-shaped (`.on`), and a router (any other name is a child
|
|
80
|
+
* at `path.name`). The router type makes misuse a compile error, and
|
|
81
|
+
* rpc.router keeps nested routers off the names used here.
|
|
82
|
+
*/
|
|
83
|
+
function clientNode(path: string, node: (path: string) => unknown): unknown {
|
|
84
|
+
const call = (input: unknown) => transport().call(path, input);
|
|
85
|
+
const on = (listener: (payload: unknown) => void) => transport().onEvent(path, listener);
|
|
86
|
+
return new Proxy(call, {
|
|
87
|
+
get(target, prop, receiver) {
|
|
88
|
+
if (prop === "on") return on;
|
|
89
|
+
// Not a promise: `await` and promise helpers must not take a node for one.
|
|
90
|
+
if (prop === "then") return undefined;
|
|
91
|
+
if (typeof prop !== "string" || FUNCTION_MEMBERS.has(prop)) {
|
|
92
|
+
return Reflect.get(target, prop, receiver);
|
|
66
93
|
}
|
|
67
|
-
return
|
|
94
|
+
return node(`${path}${SEPARATOR}${prop}`);
|
|
68
95
|
},
|
|
69
96
|
});
|
|
70
97
|
}
|
package/src/config/manifest.ts
CHANGED
|
@@ -183,6 +183,14 @@ export interface MirinConfig {
|
|
|
183
183
|
* app worker owns AppKit/CEF (see docs/architecture.md).
|
|
184
184
|
*/
|
|
185
185
|
workers?: Record<string, string>;
|
|
186
|
+
/**
|
|
187
|
+
* Extra directories bundled verbatim into the app (`Contents/Resources/<name>`
|
|
188
|
+
* on macOS, `resources/<name>` elsewhere), for assets the main process reads
|
|
189
|
+
* at runtime: a bundled server, templates, data files. Maps a filename-safe
|
|
190
|
+
* name to a project-relative directory. Resolve one with `resolveResource(name)`;
|
|
191
|
+
* under `mirin dev` it points at the source directory.
|
|
192
|
+
*/
|
|
193
|
+
resources?: Record<string, string>;
|
|
186
194
|
/**
|
|
187
195
|
* Custom URL schemes this app registers as a handler for (macOS
|
|
188
196
|
* `CFBundleURLTypes`), e.g. `["anko"]` so `anko://…` links launch the app (or
|
package/src/index.ts
CHANGED
|
@@ -62,7 +62,7 @@ export type { MenuItemTemplate, MenuRole } from "./menu.ts";
|
|
|
62
62
|
export { menu } from "./menu.ts";
|
|
63
63
|
export type { NotificationOptions } from "./notification.ts";
|
|
64
64
|
export { notification } from "./notification.ts";
|
|
65
|
-
export { NotAttachedError, resolveWorker } from "./runtime.ts";
|
|
65
|
+
export { NotAttachedError, resolveResource, resolveWorker } from "./runtime.ts";
|
|
66
66
|
export { globalShortcut } from "./shortcut.ts";
|
|
67
67
|
export type { SidecarOptions, SidecarProcess } from "./sidecar.ts";
|
|
68
68
|
export { resolveSidecar, sidecar } from "./sidecar.ts";
|
package/src/logger.ts
CHANGED
|
@@ -14,7 +14,9 @@
|
|
|
14
14
|
* Every emitted line is also recorded to the devtools sink (docs/agent-devtools.md).
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
+
import { format } from "node:util";
|
|
17
18
|
import { record } from "./devtools/sink.ts";
|
|
19
|
+
import { writeLine } from "./shared/console.ts";
|
|
18
20
|
import { firstError, formatArgs, formatStack } from "./shared/format.ts";
|
|
19
21
|
|
|
20
22
|
export type LogLevel = "debug" | "info" | "warn" | "error" | "silent";
|
|
@@ -97,8 +99,7 @@ export class Logger {
|
|
|
97
99
|
const name = this.#scope ? `[mirin:${this.#scope}]` : "[mirin]";
|
|
98
100
|
const prefix = useColor ? `${COLOR.dim}${name}${COLOR.reset}` : name;
|
|
99
101
|
const tag = useColor ? `${COLOR[level]}${level}${COLOR.reset}` : level;
|
|
100
|
-
|
|
101
|
-
out(`${prefix} ${tag}`, ...args);
|
|
102
|
+
writeLine(level === "warn" || level === "error" ? 2 : 1, `${prefix} ${tag} ${format(...args)}`);
|
|
102
103
|
this.#record(level, args);
|
|
103
104
|
}
|
|
104
105
|
|
package/src/rpc-server.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { devtoolsOptions } from "./devtools/options.ts";
|
|
|
9
9
|
import { record } from "./devtools/sink.ts";
|
|
10
10
|
import type { Procedure, Router, RpcContext } from "./rpc.ts";
|
|
11
11
|
import { formatArg } from "./shared/format.ts";
|
|
12
|
+
import { procedures, SEPARATOR } from "./shared/rpc-paths.ts";
|
|
12
13
|
|
|
13
14
|
interface SocketData {
|
|
14
15
|
webview: number;
|
|
@@ -44,8 +45,8 @@ export type ControlHandler = (frame: ControlFrame, webview: number) => void;
|
|
|
44
45
|
export class RpcServer {
|
|
45
46
|
readonly token = crypto.randomUUID();
|
|
46
47
|
#server?: ReturnType<typeof Bun.serve>;
|
|
47
|
-
|
|
48
|
-
#
|
|
48
|
+
/** The served router's procedures by path (`greet`, `window.min`). */
|
|
49
|
+
#procedures = new Map<string, Procedure>();
|
|
49
50
|
#control?: ControlHandler;
|
|
50
51
|
#sockets = new Set<ServerWebSocket<SocketData>>();
|
|
51
52
|
|
|
@@ -96,16 +97,16 @@ export class RpcServer {
|
|
|
96
97
|
await server.stop(true);
|
|
97
98
|
}
|
|
98
99
|
|
|
99
|
-
/**
|
|
100
|
+
/** Serve `router`, replacing the one served before (compose routers to serve
|
|
101
|
+
* several). Type-level `any` keeps the caller's concrete router shape. */
|
|
100
102
|
setRouter(router: Router<any>): void {
|
|
101
|
-
this.#
|
|
103
|
+
this.#procedures = procedures(router);
|
|
102
104
|
}
|
|
103
105
|
|
|
104
|
-
/** The registered procedures, for the inspector's `/state`: a tool can
|
|
105
|
-
* the RPC surface without reading source. */
|
|
106
|
+
/** The registered procedures by path, for the inspector's `/state`: a tool can
|
|
107
|
+
* discover the RPC surface without reading source. */
|
|
106
108
|
routes(): { name: string; type: string }[] {
|
|
107
|
-
|
|
108
|
-
return Object.entries(routes).map(([name, proc]) => ({ name, type: proc.type }));
|
|
109
|
+
return [...this.#procedures].map(([name, proc]) => ({ name, type: proc.type }));
|
|
109
110
|
}
|
|
110
111
|
|
|
111
112
|
/** Register the handler for internal `mirin:*` control actions (e.g. window drag). */
|
|
@@ -127,6 +128,19 @@ export class RpcServer {
|
|
|
127
128
|
}
|
|
128
129
|
}
|
|
129
130
|
|
|
131
|
+
/** Why `method` can't be called: unknown, an event, or a router rather than one of its procedures. */
|
|
132
|
+
#notCallable(method: string, proc: Procedure | undefined): string {
|
|
133
|
+
if (proc?.type === "event") {
|
|
134
|
+
return `${method} is an event: the main process emits it and the UI listens with .on()`;
|
|
135
|
+
}
|
|
136
|
+
const prefix = `${method}${SEPARATOR}`;
|
|
137
|
+
const inside = [...this.#procedures.keys()].filter((name) => name.startsWith(prefix));
|
|
138
|
+
if (inside.length > 0) {
|
|
139
|
+
return `no such procedure: ${method} is a router; call one of its procedures (${inside.slice(0, 5).join(", ")}${inside.length > 5 ? ", …" : ""})`;
|
|
140
|
+
}
|
|
141
|
+
return `no such procedure: ${method}`;
|
|
142
|
+
}
|
|
143
|
+
|
|
130
144
|
async #onMessage(ws: ServerWebSocket<SocketData>, raw: string | Buffer): Promise<void> {
|
|
131
145
|
let frame: IncomingFrame;
|
|
132
146
|
try {
|
|
@@ -148,9 +162,9 @@ export class RpcServer {
|
|
|
148
162
|
const webview = ws.data.webview;
|
|
149
163
|
traceRequest(frame, webview);
|
|
150
164
|
|
|
151
|
-
const proc = this.#
|
|
165
|
+
const proc = this.#procedures.get(frame.method);
|
|
152
166
|
if (!proc || (proc.type !== "query" && proc.type !== "mutation")) {
|
|
153
|
-
const error =
|
|
167
|
+
const error = this.#notCallable(frame.method, proc);
|
|
154
168
|
traceFailure(frame, webview, 0, error, "unknown-procedure");
|
|
155
169
|
reply(false, { error });
|
|
156
170
|
return;
|
package/src/rpc.ts
CHANGED
|
@@ -3,9 +3,13 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Imported by BOTH the main process (handlers run here, in the Bun Worker)
|
|
5
5
|
* and UI code (type-only, via `client<Router>()` from mirin/client).
|
|
6
|
-
* The router is global; handlers learn their caller from `ctx`.
|
|
6
|
+
* The router is global; handlers learn their caller from `ctx`. Routers nest:
|
|
7
|
+
* a router's routes can be other routers, and their procedures are called by
|
|
8
|
+
* path (`api.window.min()`, `window.min` on the wire).
|
|
7
9
|
*/
|
|
8
10
|
|
|
11
|
+
import { procedures } from "./shared/rpc-paths.ts";
|
|
12
|
+
|
|
9
13
|
export interface RpcContext {
|
|
10
14
|
/** Name of the calling window, if it was opened with one. */
|
|
11
15
|
window?: string;
|
|
@@ -35,14 +39,29 @@ export interface EventProc<P> {
|
|
|
35
39
|
// no runtime value is widened through this union.
|
|
36
40
|
export type Procedure = QueryProc<any, any> | MutationProc<any, any> | EventProc<any>;
|
|
37
41
|
|
|
38
|
-
|
|
42
|
+
/** What a router holds: procedures, and other routers under their own names. */
|
|
43
|
+
export interface Routes {
|
|
44
|
+
// Type-level `any` keeps each nested router's own procedure types.
|
|
45
|
+
readonly [name: string]: Procedure | Router<any>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface Router<T extends Routes = Routes> {
|
|
39
49
|
readonly type: "router";
|
|
40
50
|
readonly routes: T;
|
|
41
51
|
}
|
|
42
52
|
|
|
43
53
|
export const rpc = {
|
|
44
|
-
|
|
45
|
-
|
|
54
|
+
/**
|
|
55
|
+
* Define a router. Nested routers keep their procedures apart
|
|
56
|
+
* (`window.close` and `install.close` are two procedures). Throws when two
|
|
57
|
+
* procedures end up with the same path, or when a nested router uses a name
|
|
58
|
+
* the client needs (`on`, `then`, `emit`, `broadcast`, `apply`, `bind`,
|
|
59
|
+
* `call`, `toString`).
|
|
60
|
+
*/
|
|
61
|
+
router<T extends Routes>(routes: T): Router<T> {
|
|
62
|
+
const router: Router<T> = { type: "router", routes };
|
|
63
|
+
procedures(router);
|
|
64
|
+
return router;
|
|
46
65
|
},
|
|
47
66
|
|
|
48
67
|
/** Request/response, semantically a read. Runtime-identical to mutation in the MVP. */
|
package/src/runtime.ts
CHANGED
|
@@ -4,12 +4,13 @@
|
|
|
4
4
|
* API lives in app/index.ts and the feature modules, which subscribe here.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import { join } from "node:path";
|
|
7
|
+
import { isAbsolute, join } from "node:path";
|
|
8
8
|
import { workerData } from "node:worker_threads";
|
|
9
9
|
import type { DevtoolsConfig, WindowConfig } from "./config/index.ts";
|
|
10
10
|
import { parseDevtoolsConfig } from "./devtools/options.ts";
|
|
11
11
|
import { Core } from "./native.ts";
|
|
12
12
|
import { RpcServer } from "./rpc-server.ts";
|
|
13
|
+
import { installWorkerConsole } from "./shared/console.ts";
|
|
13
14
|
|
|
14
15
|
export interface ManifestWindowConfig extends WindowConfig {
|
|
15
16
|
name: string;
|
|
@@ -32,6 +33,8 @@ export interface Runtime {
|
|
|
32
33
|
sidecarDir?: string;
|
|
33
34
|
/** Dir holding bundled extra-worker JS (for `resolveWorker`). */
|
|
34
35
|
workersDir?: string;
|
|
36
|
+
/** Declared resource directories: source paths in dev, bundle-relative names in prod. */
|
|
37
|
+
resources: Record<string, string>;
|
|
35
38
|
/** `devtools` block from the manifest, resolved by the devtools module. */
|
|
36
39
|
devtools?: DevtoolsConfig;
|
|
37
40
|
}
|
|
@@ -137,7 +140,11 @@ export function claimBootSlot(): boolean {
|
|
|
137
140
|
export function boot(): void {
|
|
138
141
|
const data = (workerData ?? {}) as {
|
|
139
142
|
corePath?: string;
|
|
140
|
-
manifest?: {
|
|
143
|
+
manifest?: {
|
|
144
|
+
windows?: Record<string, WindowConfig>;
|
|
145
|
+
devtools?: unknown;
|
|
146
|
+
resources?: Record<string, string>;
|
|
147
|
+
};
|
|
141
148
|
id?: string;
|
|
142
149
|
devUrl?: string;
|
|
143
150
|
resourcesDir?: string;
|
|
@@ -146,6 +153,7 @@ export function boot(): void {
|
|
|
146
153
|
};
|
|
147
154
|
const corePath = data.corePath ?? process.env.MIRIN_CORE;
|
|
148
155
|
if (!corePath) return; // not under the host; the API stays detached
|
|
156
|
+
installWorkerConsole();
|
|
149
157
|
if (!claimBootSlot()) {
|
|
150
158
|
console.error(
|
|
151
159
|
"[mirin] refusing to boot a second mirinjs copy in this process — the bundle " +
|
|
@@ -199,6 +207,7 @@ export function boot(): void {
|
|
|
199
207
|
corePath,
|
|
200
208
|
sidecarDir: data.sidecarDir,
|
|
201
209
|
workersDir: data.workersDir,
|
|
210
|
+
resources: data.manifest?.resources ?? {},
|
|
202
211
|
devtools: parseDevtoolsConfig(data.manifest?.devtools),
|
|
203
212
|
};
|
|
204
213
|
core.onEvent(dispatch);
|
|
@@ -210,6 +219,19 @@ export function boot(): void {
|
|
|
210
219
|
* Works in `mirin dev` and the built `.app`. Extra workers run off the main
|
|
211
220
|
* thread and must not call window/native APIs.
|
|
212
221
|
*/
|
|
222
|
+
/**
|
|
223
|
+
* Resolve a bundled resource directory (declared in `mirin.config.ts` `resources`)
|
|
224
|
+
* to an absolute path. Under `mirin dev` this is the source directory; in a built
|
|
225
|
+
* app it lives under the bundle's resources directory.
|
|
226
|
+
*/
|
|
227
|
+
export function resolveResource(name: string): string {
|
|
228
|
+
const entry = current?.resources[name];
|
|
229
|
+
if (!current || entry === undefined) throw new NotAttachedError(`resolveResource("${name}")`);
|
|
230
|
+
if (isAbsolute(entry)) return entry;
|
|
231
|
+
if (!current.resourcesDir) throw new NotAttachedError(`resolveResource("${name}")`);
|
|
232
|
+
return join(current.resourcesDir, entry);
|
|
233
|
+
}
|
|
234
|
+
|
|
213
235
|
export function resolveWorker(name: string): string {
|
|
214
236
|
const dir = current?.workersDir;
|
|
215
237
|
if (!dir) throw new NotAttachedError("resolveWorker");
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { writeSync } from "node:fs";
|
|
2
|
+
import { format } from "node:util";
|
|
3
|
+
import { record } from "../devtools/sink.ts";
|
|
4
|
+
import type { DevEventLevel } from "../devtools/types.ts";
|
|
5
|
+
import { firstError, formatArgs, formatStack } from "./format.ts";
|
|
6
|
+
|
|
7
|
+
export type LineWriter = (fd: 1 | 2, line: string) => void;
|
|
8
|
+
|
|
9
|
+
// The app Worker's console normally hands output to the main thread, which sits
|
|
10
|
+
// inside CEF's message loop for the life of the app, so nothing is ever written.
|
|
11
|
+
// A direct fd write from the Worker thread bypasses that.
|
|
12
|
+
export function writeLine(fd: 1 | 2, line: string): void {
|
|
13
|
+
try {
|
|
14
|
+
writeSync(fd, line.endsWith("\n") ? line : `${line}\n`);
|
|
15
|
+
} catch {
|
|
16
|
+
// A closed pipe (parent gone) must not take the app down with a log line.
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const CONSOLE_LEVELS = {
|
|
21
|
+
log: "info",
|
|
22
|
+
info: "info",
|
|
23
|
+
debug: "debug",
|
|
24
|
+
warn: "warn",
|
|
25
|
+
error: "error",
|
|
26
|
+
} as const satisfies Record<string, DevEventLevel>;
|
|
27
|
+
|
|
28
|
+
export type ConsoleMethod = keyof typeof CONSOLE_LEVELS;
|
|
29
|
+
|
|
30
|
+
/** Route `console.*` from the Worker to fd 1/2 directly and into the devtools sink. */
|
|
31
|
+
export function installWorkerConsole(write: LineWriter = writeLine): void {
|
|
32
|
+
for (const method of Object.keys(CONSOLE_LEVELS) as ConsoleMethod[]) {
|
|
33
|
+
const level = CONSOLE_LEVELS[method];
|
|
34
|
+
console[method] = (...args: unknown[]) => {
|
|
35
|
+
write(level === "warn" || level === "error" ? 2 : 1, format(...args));
|
|
36
|
+
const error = firstError(args);
|
|
37
|
+
record({
|
|
38
|
+
src: "main",
|
|
39
|
+
level,
|
|
40
|
+
type: "console",
|
|
41
|
+
msg: formatArgs(args),
|
|
42
|
+
data: { method, ...(error !== undefined ? { stack: formatStack(error) } : {}) },
|
|
43
|
+
});
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Nested routers on the wire (docs/api-design.md §3). A router can hold other
|
|
3
|
+
* routers; each procedure is then named by its path, joined with dots
|
|
4
|
+
* (`window.min`), which is the method name a request frame carries. Flat
|
|
5
|
+
* routers keep their names as they were.
|
|
6
|
+
*
|
|
7
|
+
* The UI client and the main-process emitters are proxies that only know the
|
|
8
|
+
* router's type, so inside a nested router a few names belong to them: on a
|
|
9
|
+
* client node `on` subscribes, `then` would make it look like a promise, and
|
|
10
|
+
* `apply`, `bind`, `call`, `toString` keep it a normal function; on an emitter
|
|
11
|
+
* node `emit` and `broadcast` send. A nested router can't use those names.
|
|
12
|
+
* Top-level names are not restricted, as before.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { Procedure, Router } from "../rpc.ts";
|
|
16
|
+
|
|
17
|
+
export const SEPARATOR = ".";
|
|
18
|
+
|
|
19
|
+
export const RESERVED_NESTED_NAMES: ReadonlySet<string> = new Set([
|
|
20
|
+
"on",
|
|
21
|
+
"then",
|
|
22
|
+
"emit",
|
|
23
|
+
"broadcast",
|
|
24
|
+
"apply",
|
|
25
|
+
"bind",
|
|
26
|
+
"call",
|
|
27
|
+
"toString",
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
const KINDS = new Set(["query", "mutation", "event"]);
|
|
31
|
+
|
|
32
|
+
export function isRouter(value: unknown): value is Router {
|
|
33
|
+
if (typeof value !== "object" || value === null) return false;
|
|
34
|
+
const candidate = value as { type?: unknown; routes?: unknown };
|
|
35
|
+
return (
|
|
36
|
+
candidate.type === "router" && typeof candidate.routes === "object" && candidate.routes !== null
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function isProcedure(value: unknown): value is Procedure {
|
|
41
|
+
if (typeof value !== "object" || value === null) return false;
|
|
42
|
+
return KINDS.has(String((value as { type?: unknown }).type));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Every procedure of `router` by its full path. Throws on a value that is not
|
|
47
|
+
* a procedure or a router, on two procedures with the same path (a flat
|
|
48
|
+
* `"window.min"` beside a nested `window: { min }`), on a router that contains
|
|
49
|
+
* itself, and on a reserved name inside a nested router.
|
|
50
|
+
*/
|
|
51
|
+
export function procedures(router: Router): Map<string, Procedure> {
|
|
52
|
+
const found = new Map<string, Procedure>();
|
|
53
|
+
const walk = (current: Router, prefix: string, open: Set<Router>): void => {
|
|
54
|
+
open.add(current);
|
|
55
|
+
for (const [name, value] of Object.entries(current.routes)) {
|
|
56
|
+
const path = prefix === "" ? name : `${prefix}${SEPARATOR}${name}`;
|
|
57
|
+
if (prefix !== "" && RESERVED_NESTED_NAMES.has(name)) {
|
|
58
|
+
throw new Error(
|
|
59
|
+
`rpc.router: "${path}" can't be used inside a nested router: ${[...RESERVED_NESTED_NAMES].join(", ")} are taken by the client and the event emitters. Rename it.`,
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
if (isRouter(value)) {
|
|
63
|
+
if (open.has(value)) throw new Error(`rpc.router: "${path}" contains itself`);
|
|
64
|
+
walk(value, path, open);
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
if (!isProcedure(value)) {
|
|
68
|
+
throw new Error(
|
|
69
|
+
`rpc.router: "${path}" is not a procedure or a router (use rpc.query, rpc.mutation, rpc.event, or rpc.router)`,
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
if (found.has(path)) {
|
|
73
|
+
throw new Error(
|
|
74
|
+
`rpc.router: two procedures are named "${path}" (a nested router's procedures are named <router>${SEPARATOR}<procedure>). Rename one.`,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
found.set(path, value);
|
|
78
|
+
}
|
|
79
|
+
open.delete(current);
|
|
80
|
+
};
|
|
81
|
+
walk(router, "", new Set());
|
|
82
|
+
return found;
|
|
83
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Loose semver: "1.2.3", "v1.2.3", "1.2.3-nightly.4". Numeric segments compare
|
|
2
|
+
// as numbers, anything else lexically; a prerelease sorts below its release.
|
|
3
|
+
function parse(version: string): { main: string[]; pre: string[] } {
|
|
4
|
+
const trimmed = version.trim().replace(/^v/i, "");
|
|
5
|
+
const plus = trimmed.indexOf("+");
|
|
6
|
+
const noBuild = plus === -1 ? trimmed : trimmed.slice(0, plus);
|
|
7
|
+
const dash = noBuild.indexOf("-");
|
|
8
|
+
const main = (dash === -1 ? noBuild : noBuild.slice(0, dash)).split(".");
|
|
9
|
+
const pre = dash === -1 ? [] : noBuild.slice(dash + 1).split(".");
|
|
10
|
+
return { main, pre };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function compareSegment(a: string | undefined, b: string | undefined): number {
|
|
14
|
+
if (a === undefined && b === undefined) return 0;
|
|
15
|
+
if (a === undefined) return -1;
|
|
16
|
+
if (b === undefined) return 1;
|
|
17
|
+
const na = /^\d+$/.test(a) ? Number(a) : null;
|
|
18
|
+
const nb = /^\d+$/.test(b) ? Number(b) : null;
|
|
19
|
+
if (na !== null && nb !== null) return na === nb ? 0 : na < nb ? -1 : 1;
|
|
20
|
+
if (na !== null) return -1;
|
|
21
|
+
if (nb !== null) return 1;
|
|
22
|
+
return a === b ? 0 : a < b ? -1 : 1;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Negative when `a` is older than `b`, zero when equal, positive when newer. */
|
|
26
|
+
export function compareVersions(a: string, b: string): number {
|
|
27
|
+
const pa = parse(a);
|
|
28
|
+
const pb = parse(b);
|
|
29
|
+
for (let i = 0; i < Math.max(pa.main.length, pb.main.length); i++) {
|
|
30
|
+
const c = compareSegment(pa.main[i] ?? "0", pb.main[i] ?? "0");
|
|
31
|
+
if (c !== 0) return c;
|
|
32
|
+
}
|
|
33
|
+
if (pa.pre.length === 0 && pb.pre.length === 0) return 0;
|
|
34
|
+
if (pa.pre.length === 0) return 1;
|
|
35
|
+
if (pb.pre.length === 0) return -1;
|
|
36
|
+
for (let i = 0; i < Math.max(pa.pre.length, pb.pre.length); i++) {
|
|
37
|
+
const c = compareSegment(pa.pre[i], pb.pre[i]);
|
|
38
|
+
if (c !== 0) return c;
|
|
39
|
+
}
|
|
40
|
+
return 0;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function isNewerVersion(candidate: string, current: string): boolean {
|
|
44
|
+
return compareVersions(candidate, current) > 0;
|
|
45
|
+
}
|
package/src/updater/updater.ts
CHANGED
|
@@ -25,6 +25,7 @@ import { downloadVerifiedArtifact, verifyFileSha256 } from "./lib/integrity.ts";
|
|
|
25
25
|
import { parseManifest } from "./lib/manifest.ts";
|
|
26
26
|
import { IS_LINUX, IS_MAC, IS_WINDOWS, platformName } from "./lib/platform.ts";
|
|
27
27
|
import { artifactUrl, assertTrustedUpdateUrl, trustedBaseUrl } from "./lib/urls.ts";
|
|
28
|
+
import { isNewerVersion } from "./lib/version.ts";
|
|
28
29
|
import type {
|
|
29
30
|
Listener,
|
|
30
31
|
Manifest,
|
|
@@ -85,7 +86,7 @@ export class Updater {
|
|
|
85
86
|
platform: platformName(),
|
|
86
87
|
arch: process.arch,
|
|
87
88
|
});
|
|
88
|
-
if (!m.version || m.version
|
|
89
|
+
if (!m.version || !isNewerVersion(m.version, v.version)) {
|
|
89
90
|
this.#manifest = null;
|
|
90
91
|
this.#setStatus("idle");
|
|
91
92
|
return null;
|