janela 0.3.1 → 0.5.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.
Files changed (39) hide show
  1. package/README.md +178 -7
  2. package/api/global.d.ts +27 -0
  3. package/api/index.d.ts +102 -0
  4. package/api/index.js +64 -0
  5. package/bin/janela.mjs +15 -1
  6. package/package.json +15 -1
  7. package/runtime/janela.ts +38 -118
  8. package/runtime/types.ts +251 -0
  9. package/templates/index.html +4 -2
  10. package/templates/main.ts +1 -1
  11. package/templates/react/deps.json +14 -2
  12. package/templates/react/files/index.html +1 -1
  13. package/templates/react/files/src/App.tsx +55 -0
  14. package/templates/react/files/src/main.tsx +12 -0
  15. package/templates/react/files/src-host/main.ts +48 -22
  16. package/templates/react/files/tsconfig.json +25 -0
  17. package/templates/solid/deps.json +11 -2
  18. package/templates/solid/files/index.html +1 -1
  19. package/templates/solid/files/src/App.tsx +55 -0
  20. package/templates/solid/files/src/main.tsx +7 -0
  21. package/templates/solid/files/src-host/main.ts +48 -22
  22. package/templates/solid/files/tsconfig.json +26 -0
  23. package/templates/svelte/deps.json +6 -1
  24. package/templates/svelte/files/index.html +1 -1
  25. package/templates/svelte/files/src/App.svelte +15 -7
  26. package/templates/svelte/files/src/main.ts +7 -0
  27. package/templates/svelte/files/src-host/main.ts +48 -22
  28. package/templates/svelte/files/tsconfig.json +23 -0
  29. package/templates/vue/deps.json +12 -2
  30. package/templates/vue/files/index.html +1 -1
  31. package/templates/vue/files/src/App.vue +16 -8
  32. package/templates/vue/files/src-host/main.ts +48 -22
  33. package/templates/vue/files/tsconfig.json +23 -0
  34. package/templates/react/files/src/App.jsx +0 -38
  35. package/templates/react/files/src/main.jsx +0 -9
  36. package/templates/solid/files/src/App.jsx +0 -35
  37. package/templates/solid/files/src/main.jsx +0 -4
  38. package/templates/svelte/files/src/main.js +0 -4
  39. /package/templates/vue/files/src/{main.js → main.ts} +0 -0
@@ -0,0 +1,251 @@
1
+ /**
2
+ * Public host-side types for a janela app — the shapes `src-host/main.ts`
3
+ * works with.
4
+ *
5
+ * This file is the single definition of those types. It is both:
6
+ * - what `import type { JanelaApp } from "janela/host"` resolves to in an
7
+ * editor, via the package's exports map; and
8
+ * - re-exported by runtime/janela.ts, which is what the compiled build
9
+ * actually links against (the CLI copies both files into .janela/build/).
10
+ *
11
+ * Mostly declarations; the typed-contract helpers at the bottom are the only
12
+ * runtime code, and they are deliberately trivial.
13
+ */
14
+
15
+ // Handlers take the invoked arguments as a value and return a value; the
16
+ // runtime owns JSON at the boundary. `args` is whatever the page passed to
17
+ // janela.invoke(name, args) — cast it to the shape you expect. The return
18
+ // value is what the page's promise resolves with.
19
+ //
20
+ // Throwing is not supported by scriptc across the FFI boundary. Use
21
+ // commandAsync's `reject` to fail a call, or return an error value.
22
+ export type CommandHandler = (args: unknown) => unknown;
23
+
24
+ /**
25
+ * An async command: return immediately, answer later. `resolve`/`reject` take
26
+ * a value and settle the page's `await janela.invoke(...)` promise whenever
27
+ * they are called — from a later defer()/sleep() turn, or from another
28
+ * command. The window stays responsive for as long as the call is pending.
29
+ */
30
+ export type AsyncCommandHandler = (
31
+ args: unknown,
32
+ resolve: (value: unknown) => void,
33
+ reject: (reason: unknown) => void,
34
+ ) => void;
35
+
36
+ /**
37
+ * Completion of an async file operation. `err` is null on success; on failure
38
+ * it carries a Node-shaped message ("ENOENT: no such file or directory, open
39
+ * '/x'") and `text` is empty. Errors arrive as values, never as throws —
40
+ * scriptc cannot propagate an exception across the FFI boundary.
41
+ */
42
+ export type FsCallback = (err: string | null, text: string) => void;
43
+
44
+ /** A named group of extensions offered in a dialog's file-type popup. */
45
+ export interface DialogFilter {
46
+ name: string;
47
+ /** Bare extensions, no dot and no glob: ["png", "jpg"]. */
48
+ extensions: string[];
49
+ }
50
+
51
+ export interface OpenDialogOptions {
52
+ title?: string;
53
+ /** Directory the dialog opens in. */
54
+ defaultPath?: string;
55
+ /** Allow picking more than one entry. */
56
+ multiple?: boolean;
57
+ /** Pick directories instead of files. Not supported on Windows. */
58
+ directory?: boolean;
59
+ filters?: DialogFilter[];
60
+ }
61
+
62
+ export interface SaveDialogOptions {
63
+ title?: string;
64
+ defaultPath?: string;
65
+ /** Filename pre-filled in the name field. */
66
+ defaultName?: string;
67
+ filters?: DialogFilter[];
68
+ }
69
+
70
+ export interface WindowConfig {
71
+ title: string;
72
+ width: number;
73
+ height: number;
74
+ }
75
+
76
+ export interface JanelaApp {
77
+ handle: number;
78
+ names: string[];
79
+ handlers: CommandHandler[];
80
+ /** Register a named command, callable from the page as janela.invoke(name, args). */
81
+ command: (name: string, h: CommandHandler) => void;
82
+ /** Register a command that answers later; see AsyncCommandHandler. */
83
+ commandAsync: (name: string, h: AsyncCommandHandler) => void;
84
+ /** Run fn on the next turn of the host loop — the way to slice long work. */
85
+ defer: (fn: () => void) => void;
86
+ /** Run fn after at least ms. The host loop's timer; scriptc's setTimeout
87
+ * cannot fire while the window is open (its loop is parked inside run()). */
88
+ sleep: (ms: number, fn: () => void) => void;
89
+ /**
90
+ * Read a file without blocking the window. The syscall runs on a shim
91
+ * worker thread; the callback lands on the UI thread on a later turn.
92
+ * Prefer this over node:fs readFileSync inside a command — that one blocks
93
+ * the loop, and with it the whole window.
94
+ */
95
+ readFileAsync: (path: string, cb: FsCallback) => void;
96
+ /** Write a file without blocking the window; cb(null) on success. */
97
+ writeFileAsync: (
98
+ path: string,
99
+ data: string,
100
+ cb: (err: string | null) => void,
101
+ ) => void;
102
+ /**
103
+ * Show the native "open" dialog. `cb` gets the chosen paths, or null if the
104
+ * user cancelled. The modal runs on a later turn of the UI thread, so
105
+ * calling this from inside a command does not block that command's reply —
106
+ * pair it with commandAsync when the page is waiting for the result.
107
+ */
108
+ openFileDialog: (
109
+ options: OpenDialogOptions,
110
+ cb: (paths: string[] | null, err?: string) => void,
111
+ ) => void;
112
+ /** Show the native "save" dialog; cb gets the path, or null on cancel. */
113
+ saveFileDialog: (
114
+ options: SaveDialogOptions,
115
+ cb: (path: string | null, err?: string) => void,
116
+ ) => void;
117
+ /** Change the window title at any time, not just at startup. */
118
+ setTitle: (title: string) => void;
119
+ /**
120
+ * Resize the window. `hint` is webview's sizing hint: 0 none, 1 minimum,
121
+ * 2 maximum, 3 fixed.
122
+ */
123
+ setSize: (width: number, height: number, hint?: number) => void;
124
+ /** Enter or leave fullscreen. */
125
+ setFullscreen: (on: boolean) => void;
126
+ /** Fire an event into the page; the payload is delivered as a value. */
127
+ emit: (event: string, payload: unknown) => void;
128
+ /** Close the window and make run() return. */
129
+ quit: () => void;
130
+ /** Show the page and block until the window closes. Returns the run status. */
131
+ run: (html: string) => number;
132
+ }
133
+
134
+ // ---------------------------------------------------------------------------
135
+ // Typed IPC contract
136
+ // ---------------------------------------------------------------------------
137
+ //
138
+ // The contract is a TYPE the host declares and the frontend imports with
139
+ // `import type`. Because both sides are TypeScript, no code generation is
140
+ // involved and nothing can drift: the frontend checks against the host's own
141
+ // declarations, and the import is erased, so no host code reaches the bundle.
142
+ //
143
+ // Payloads still cross as JSON, so these types are compile-time only. Nothing
144
+ // validates a malformed payload at runtime.
145
+ //
146
+ // SHAPE NOTE: the registrars below are standalone generic FUNCTIONS taking the
147
+ // contract as a value, rather than methods on a returned registrar object.
148
+ // That is not a style choice — scriptc cannot dispatch a generic method
149
+ // through an interface-typed receiver (SC1090), so `commands.on(...)` does not
150
+ // compile in a host build, while `on(app, commands, ...)` does.
151
+
152
+ /** One command's argument and result types. */
153
+ export interface CommandShape {
154
+ args: unknown;
155
+ result: unknown;
156
+ }
157
+
158
+ /** A contract's command table: name → shape. */
159
+ export type CommandShapes = Record<string, CommandShape>;
160
+
161
+ /**
162
+ * A declared command contract. Carries `M` at the type level only — the value
163
+ * is empty, and exists so that inference has something to read at a call site.
164
+ */
165
+ export interface Commands<M extends CommandShapes> {
166
+ __commands?: M;
167
+ }
168
+
169
+ /** A declared event contract: event name → payload type. */
170
+ export interface Events<E> {
171
+ __events?: E;
172
+ }
173
+
174
+ /**
175
+ * Declare the commands a host exposes.
176
+ *
177
+ * ```ts
178
+ * export const commands = defineCommands<{
179
+ * add: { args: { a: number; b: number }; result: number };
180
+ * }>();
181
+ * ```
182
+ */
183
+ export function defineCommands<M extends CommandShapes>(): Commands<M> {
184
+ return {};
185
+ }
186
+
187
+ /** Declare the events a host emits: `defineEvents<{ added: number }>()`. */
188
+ export function defineEvents<E>(): Events<E> {
189
+ return {};
190
+ }
191
+
192
+ /**
193
+ * Register a command against the contract. `args` is inferred from the
194
+ * contract, and the return type is checked against it, so the handler is
195
+ * written once with no casts.
196
+ *
197
+ * ```ts
198
+ * on(app, commands, "add", (args) => args.a + args.b);
199
+ * ```
200
+ */
201
+ export function on<M extends CommandShapes, K extends keyof M & string>(
202
+ app: JanelaApp,
203
+ _commands: Commands<M>,
204
+ name: K,
205
+ handler: (args: M[K]["args"]) => M[K]["result"],
206
+ ): void {
207
+ app.command(name, (args: unknown) => handler(args as M[K]["args"]));
208
+ }
209
+
210
+ /**
211
+ * Register a command that answers on a later turn. `resolve` takes the
212
+ * contract's result type; see AsyncCommandHandler for the timing rules.
213
+ */
214
+ export function onAsync<M extends CommandShapes, K extends keyof M & string>(
215
+ app: JanelaApp,
216
+ _commands: Commands<M>,
217
+ name: K,
218
+ handler: (
219
+ args: M[K]["args"],
220
+ resolve: (value: M[K]["result"]) => void,
221
+ reject: (reason: unknown) => void,
222
+ ) => void,
223
+ ): void {
224
+ app.commandAsync(
225
+ name,
226
+ (args: unknown, resolve: (value: unknown) => void, reject: (reason: unknown) => void) => {
227
+ handler(
228
+ args as M[K]["args"],
229
+ (value: M[K]["result"]) => resolve(value),
230
+ reject,
231
+ );
232
+ },
233
+ );
234
+ }
235
+
236
+ /**
237
+ * Emit a declared event. The name must exist in the contract and the payload
238
+ * must match its type.
239
+ *
240
+ * ```ts
241
+ * emit(app, events, "added", 42);
242
+ * ```
243
+ */
244
+ export function emit<E, K extends keyof E & string>(
245
+ app: JanelaApp,
246
+ _events: Events<E>,
247
+ name: K,
248
+ payload: E[K],
249
+ ): void {
250
+ app.emit(name, payload);
251
+ }
@@ -30,8 +30,10 @@
30
30
  <ul id="events"></ul>
31
31
 
32
32
  <script>
33
- // `janela.invoke(name, args)` and `janela.listen(event, cb)` are injected by
34
- // janela before the page loads — the Tauri-shaped frontend API.
33
+ // This template has no bundler, so it uses the `janela` global that the
34
+ // host injects before the page loads. Projects with a build step should
35
+ // `import { invoke, listen } from "janela/api"` instead — same functions,
36
+ // but typed and resolvable by the bundler.
35
37
  window.onload = async () => {
36
38
  const out = document.getElementById("out");
37
39
  const events = document.getElementById("events");
package/templates/main.ts CHANGED
@@ -8,7 +8,7 @@
8
8
  // complete variable initializer — wrap it in any expression (`+ 0`). Plain
9
9
  // TypeScript like everything in this file is unaffected.
10
10
 
11
- import type { JanelaApp } from "./janela";
11
+ import type { JanelaApp } from "janela/host";
12
12
 
13
13
  export function setup(app: JanelaApp): void {
14
14
  app.command("add", (args) => {
@@ -1,4 +1,16 @@
1
1
  {
2
- "dependencies": { "react": "^19.0.0", "react-dom": "^19.0.0" },
3
- "devDependencies": { "@vitejs/plugin-react": "^4.3.4", "vite": "^6.0.7" }
2
+ "dependencies": {
3
+ "react": "^19.0.0",
4
+ "react-dom": "^19.0.0"
5
+ },
6
+ "devDependencies": {
7
+ "@vitejs/plugin-react": "^4.3.4",
8
+ "vite": "^6.0.7",
9
+ "@types/react": "^19.0.7",
10
+ "@types/react-dom": "^19.0.3",
11
+ "typescript": "^5.7.3"
12
+ },
13
+ "scripts": {
14
+ "typecheck": "tsc --noEmit"
15
+ }
4
16
  }
@@ -6,6 +6,6 @@
6
6
  </head>
7
7
  <body>
8
8
  <div id="root"></div>
9
- <script type="module" src="/src/main.jsx"></script>
9
+ <script type="module" src="/src/main.tsx"></script>
10
10
  </body>
11
11
  </html>
@@ -0,0 +1,55 @@
1
+ import { useEffect, useState } from "react";
2
+ import { createClient } from "janela/api";
3
+ // Type-only import of the host contract: erased at compile time, so no host
4
+ // code is bundled into the page.
5
+ import type { App as Contract } from "../src-host/main";
6
+ import "./App.css";
7
+
8
+ const client = createClient<Contract>();
9
+
10
+ export default function App() {
11
+ const [greeting, setGreeting] = useState("…");
12
+ const [a, setA] = useState(2);
13
+ const [b, setB] = useState(40);
14
+ const [sum, setSum] = useState<number | null>(null);
15
+ const [events, setEvents] = useState<string[]>([]);
16
+
17
+ useEffect(() => {
18
+ // The event name is checked against the contract, `value` is inferred,
19
+ // and `on` returns a disposer.
20
+ const off = client.on("added", (value) =>
21
+ setEvents((prev) => [`host emitted: ${value}`, ...prev]),
22
+ );
23
+ client.invoke("greet", { name: "__NAME__" }).then(setGreeting);
24
+ return off;
25
+ }, []);
26
+
27
+ const add = async () =>
28
+ setSum(await client.invoke("add", { a, b }));
29
+
30
+ return (
31
+ <>
32
+ <h1>{greeting}</h1>
33
+ <p>
34
+ <input
35
+ type="number"
36
+ value={a}
37
+ onChange={(e) => setA(Number(e.target.value))}
38
+ />{" "}
39
+ +
40
+ <input
41
+ type="number"
42
+ value={b}
43
+ onChange={(e) => setB(Number(e.target.value))}
44
+ />
45
+ <button onClick={add}>add</button>
46
+ {sum !== null && <span> = {sum}</span>}
47
+ </p>
48
+ <ul>
49
+ {events.map((e, i) => (
50
+ <li key={i}>{e}</li>
51
+ ))}
52
+ </ul>
53
+ </>
54
+ );
55
+ }
@@ -0,0 +1,12 @@
1
+ import { StrictMode } from "react";
2
+ import { createRoot } from "react-dom/client";
3
+ import App from "./App.tsx";
4
+
5
+ const root = document.getElementById("root");
6
+ if (!root) throw new Error("index.html is missing #root");
7
+
8
+ createRoot(root).render(
9
+ <StrictMode>
10
+ <App />
11
+ </StrictMode>,
12
+ );
@@ -1,42 +1,68 @@
1
1
  // src-host/main.ts — your app's backend, compiled to native code by scriptc.
2
2
  //
3
- // Register commands here; the page calls them with `await janela.invoke(name, args)`.
4
- // Handlers take the arguments as a value and return a value — the runtime owns
5
- // JSON at the boundary, so there is no parsing or stringifying to do here.
3
+ // The contract below is the single declaration of what this app exposes. The
4
+ // frontend imports `App` with `import type`, so the page is checked against
5
+ // these exact types — command names, argument shapes, results and event
6
+ // payloads — with no code generation and nothing to keep in sync.
6
7
  //
7
- // Gotcha inherited from scriptc: never use a bare FFI-backed call as a
8
- // complete variable initializer — wrap it in any expression (`+ 0`). Plain
9
- // TypeScript like everything in this file is unaffected.
8
+ // Two gotchas inherited from scriptc:
9
+ // - never use a bare FFI-backed call as a complete variable initializer;
10
+ // wrap it in any expression (`+ 0`);
11
+ // - write `args: null` for a command that takes nothing, not `undefined` —
12
+ // an `undefined` argument lowers to a zero-parameter function and fails
13
+ // to compile.
10
14
 
11
- import type { JanelaApp } from "./janela";
15
+ import {
16
+ defineCommands,
17
+ defineEvents,
18
+ emit,
19
+ on,
20
+ onAsync,
21
+ type JanelaApp,
22
+ } from "janela/host";
23
+
24
+ export const commands = defineCommands<{
25
+ add: { args: { a: number; b: number }; result: number };
26
+ greet: { args: { name: string }; result: string };
27
+ log: { args: string; result: null };
28
+ wait: { args: { ms: number }; result: string };
29
+ quit: { args: null; result: null };
30
+ }>();
31
+
32
+ export const events = defineEvents<{
33
+ added: number;
34
+ }>();
35
+
36
+ /** The contract the page imports with `import type { App } from "../src-host/main"`. */
37
+ export type App = { commands: typeof commands; events: typeof events };
12
38
 
13
39
  export function setup(app: JanelaApp): void {
14
- app.command("add", (args) => {
15
- const a = args as { a: number; b: number };
16
- const sum = a.a + a.b;
17
- // Backend→frontend event: the page listens with janela.listen("added", …).
18
- app.emit("added", sum);
40
+ // `args` is inferred from the contract, and the return type is checked
41
+ // against it — no casts, and no way to drift from what the page expects.
42
+ on(app, commands, "add", (args) => {
43
+ const sum = args.a + args.b;
44
+ emit(app, events, "added", sum);
19
45
  return sum;
20
46
  });
21
47
 
22
- app.command("greet", (args) => {
23
- const a = args as { name: string };
24
- return "Hello, " + a.name + " — from the native TS binary";
48
+ on(app, commands, "greet", (args) => {
49
+ return "Hello, " + args.name + " — from the native TS binary";
25
50
  });
26
51
 
27
- app.command("log", (args) => {
28
- console.log("[host] page says:", args as string);
52
+ on(app, commands, "log", (args) => {
53
+ console.log("[host] page says:", args);
54
+ return null;
29
55
  });
30
56
 
31
57
  // An async command: answers later, without freezing the window.
32
- app.commandAsync("wait", (args, resolve) => {
33
- const a = args as { ms: number };
34
- app.sleep(a.ms, () => {
35
- resolve("waited " + a.ms + "ms without blocking the UI");
58
+ onAsync(app, commands, "wait", (args, resolve) => {
59
+ app.sleep(args.ms, () => {
60
+ resolve("waited " + args.ms + "ms without blocking the UI");
36
61
  });
37
62
  });
38
63
 
39
- app.command("quit", () => {
64
+ on(app, commands, "quit", (_args) => {
40
65
  app.quit();
66
+ return null;
41
67
  });
42
68
  }
@@ -0,0 +1,25 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "lib": [
7
+ "ES2022",
8
+ "DOM",
9
+ "DOM.Iterable"
10
+ ],
11
+ "types": [],
12
+ "jsx": "react-jsx",
13
+ "strict": true,
14
+ "noEmit": true,
15
+ "allowImportingTsExtensions": true,
16
+ "skipLibCheck": true,
17
+ "isolatedModules": true,
18
+ "verbatimModuleSyntax": true
19
+ },
20
+ "include": [
21
+ "src/**/*.ts",
22
+ "src/**/*.tsx",
23
+ "src-host/**/*.ts"
24
+ ]
25
+ }
@@ -1,4 +1,13 @@
1
1
  {
2
- "dependencies": { "solid-js": "^1.9.4" },
3
- "devDependencies": { "vite": "^6.0.7", "vite-plugin-solid": "^2.11.0" }
2
+ "dependencies": {
3
+ "solid-js": "^1.9.4"
4
+ },
5
+ "devDependencies": {
6
+ "vite": "^6.0.7",
7
+ "vite-plugin-solid": "^2.11.0",
8
+ "typescript": "^5.7.3"
9
+ },
10
+ "scripts": {
11
+ "typecheck": "tsc --noEmit"
12
+ }
4
13
  }
@@ -6,6 +6,6 @@
6
6
  </head>
7
7
  <body>
8
8
  <div id="root"></div>
9
- <script type="module" src="/src/main.jsx"></script>
9
+ <script type="module" src="/src/main.tsx"></script>
10
10
  </body>
11
11
  </html>
@@ -0,0 +1,55 @@
1
+ import { createSignal, onMount, For, Show } from "solid-js";
2
+ import { createClient } from "janela/api";
3
+ // Type-only import of the host contract: erased at compile time, so no host
4
+ // code is bundled into the page.
5
+ import type { App as Contract } from "../src-host/main";
6
+ import "./App.css";
7
+
8
+ const client = createClient<Contract>();
9
+
10
+ export default function App() {
11
+ const [greeting, setGreeting] = createSignal("…");
12
+ const [a, setA] = createSignal(2);
13
+ const [b, setB] = createSignal(40);
14
+ const [sum, setSum] = createSignal<number | null>(null);
15
+ const [events, setEvents] = createSignal<string[]>([]);
16
+
17
+ // Backend→frontend events. The payload arrives as a value, and the generic
18
+ // says which value.
19
+ client.on("added", (value) =>
20
+ setEvents((prev) => [`host emitted: ${value}`, ...prev]),
21
+ );
22
+
23
+ onMount(async () =>
24
+ setGreeting(await client.invoke("greet", { name: "__NAME__" })),
25
+ );
26
+
27
+ const add = async () =>
28
+ setSum(await client.invoke("add", { a: a(), b: b() }));
29
+
30
+ return (
31
+ <>
32
+ <h1>{greeting()}</h1>
33
+ <p>
34
+ <input
35
+ type="number"
36
+ value={a()}
37
+ onInput={(e) => setA(Number(e.currentTarget.value))}
38
+ />{" "}
39
+ +
40
+ <input
41
+ type="number"
42
+ value={b()}
43
+ onInput={(e) => setB(Number(e.currentTarget.value))}
44
+ />
45
+ <button onClick={add}>add</button>
46
+ <Show when={sum() !== null}>
47
+ <span> = {sum()}</span>
48
+ </Show>
49
+ </p>
50
+ <ul>
51
+ <For each={events()}>{(e) => <li>{e}</li>}</For>
52
+ </ul>
53
+ </>
54
+ );
55
+ }
@@ -0,0 +1,7 @@
1
+ import { render } from "solid-js/web";
2
+ import App from "./App.tsx";
3
+
4
+ const root = document.getElementById("root");
5
+ if (!root) throw new Error("index.html is missing #root");
6
+
7
+ render(() => <App />, root);