janela 0.2.0 → 0.3.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/README.md +66 -12
- package/bin/janela.mjs +351 -39
- package/package.json +1 -1
- package/runtime/janela.ts +170 -25
- package/shim/wvshim.cc +539 -63
- package/templates/index.html +17 -0
- package/templates/main.ts +32 -0
- package/templates/react/deps.json +4 -0
- package/templates/react/files/index.html +11 -0
- package/templates/react/files/janela.conf.json +10 -0
- package/templates/react/files/src/App.css +3 -0
- package/templates/react/files/src/App.jsx +38 -0
- package/templates/react/files/src/main.jsx +9 -0
- package/templates/react/files/src-host/main.ts +42 -0
- package/templates/react/files/vite.config.js +6 -0
- package/templates/solid/deps.json +4 -0
- package/templates/solid/files/index.html +11 -0
- package/templates/solid/files/janela.conf.json +10 -0
- package/templates/solid/files/src/App.css +3 -0
- package/templates/solid/files/src/App.jsx +35 -0
- package/templates/solid/files/src/main.jsx +4 -0
- package/templates/solid/files/src-host/main.ts +42 -0
- package/templates/solid/files/vite.config.js +6 -0
- package/templates/svelte/deps.json +7 -0
- package/templates/svelte/files/index.html +11 -0
- package/templates/svelte/files/janela.conf.json +10 -0
- package/templates/svelte/files/src/App.svelte +33 -0
- package/templates/svelte/files/src/main.js +4 -0
- package/templates/svelte/files/src-host/main.ts +42 -0
- package/templates/svelte/files/svelte.config.js +3 -0
- package/templates/svelte/files/vite.config.js +6 -0
- package/templates/vue/deps.json +4 -0
- package/templates/vue/files/index.html +11 -0
- package/templates/vue/files/janela.conf.json +10 -0
- package/templates/vue/files/src/App.vue +39 -0
- package/templates/vue/files/src/main.js +4 -0
- package/templates/vue/files/src-host/main.ts +42 -0
- package/templates/vue/files/vite.config.js +6 -0
package/README.md
CHANGED
|
@@ -18,6 +18,19 @@ janela dev # build + run with logs in the terminal
|
|
|
18
18
|
janela build # .janela/out/my-app (+ my-app.app on macOS)
|
|
19
19
|
```
|
|
20
20
|
|
|
21
|
+
Or start from a frontend framework — any Vite-based one:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
janela init my-app --template vue # or react | svelte | solid | vanilla
|
|
25
|
+
cd my-app && npm install
|
|
26
|
+
janela dev # Vite dev server + HMR, in a native window
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
`vanilla` is the default and needs no frontend toolchain at all. With a
|
|
30
|
+
framework, `janela dev` runs your Vite dev server and points the window at it,
|
|
31
|
+
and `janela build` flattens the production bundle into the binary — see
|
|
32
|
+
[docs/frontend.md](../../docs/frontend.md).
|
|
33
|
+
|
|
21
34
|
Requirements: Node 18+, a C++ compiler (Xcode CLT on macOS; g++ +
|
|
22
35
|
`libwebkit2gtk-4.1-dev` on Linux; see [Windows](#windows) below). A worked
|
|
23
36
|
example lives in [`examples/demo`](examples/demo) — commands, events, and a
|
|
@@ -41,9 +54,15 @@ containing `WebView2.h`. No `WebView2Loader.dll` is needed — webview.h has its
|
|
|
41
54
|
own loader — and end users need only the **WebView2 runtime**, which is
|
|
42
55
|
preinstalled on current Windows 10/11 (it ships with Edge).
|
|
43
56
|
|
|
44
|
-
|
|
45
|
-
appears behind the UI
|
|
46
|
-
|
|
57
|
+
`janela build` produces a **GUI-subsystem** `.exe`, so no console window
|
|
58
|
+
appears behind the UI — which also means `console.log` from a command has
|
|
59
|
+
nowhere to go. `janela dev` keeps the console subsystem, so logs are there
|
|
60
|
+
while you work. (scriptc exposes no way to pass `-mwindows` to the linker, so
|
|
61
|
+
janela rewrites the PE `Subsystem` field after linking; see
|
|
62
|
+
[docs/native-shell.md](../../docs/native-shell.md).)
|
|
63
|
+
|
|
64
|
+
One caveat today: there is no installer step — you get a bare `.exe`, not an
|
|
65
|
+
MSI.
|
|
47
66
|
|
|
48
67
|
[llvm-mingw]: https://github.com/mstorsjo/llvm-mingw/releases
|
|
49
68
|
|
|
@@ -56,6 +75,10 @@ my-app/
|
|
|
56
75
|
└── janela.conf.json name, bundle identifier, version, window
|
|
57
76
|
```
|
|
58
77
|
|
|
78
|
+
A Vite project adds a `vite.config.js` and a `src/` tree — that config is what
|
|
79
|
+
makes janela build the frontend with Vite instead of inlining `index.html`
|
|
80
|
+
directly.
|
|
81
|
+
|
|
59
82
|
Frontend API (injected before page load):
|
|
60
83
|
|
|
61
84
|
```js
|
|
@@ -131,9 +154,9 @@ Errors arrive as values, never throws — `err` carries a Node-shaped message
|
|
|
131
154
|
(`ENOENT: no such file or directory, open '/x'`). UTF-8 round-trips exactly,
|
|
132
155
|
astral characters included.
|
|
133
156
|
|
|
134
|
-
The payload
|
|
135
|
-
costs about **
|
|
136
|
-
documents,
|
|
157
|
+
The payload crosses in a single call (format 3), and the drain that follows it
|
|
158
|
+
costs about **10 ms per MB on the UI thread** — fine for config files and
|
|
159
|
+
documents, still worth chunking for very large media. See
|
|
137
160
|
[docs/async.md](../../docs/async.md) for the measurements.
|
|
138
161
|
|
|
139
162
|
**Use `app.sleep`, not `setTimeout`.** scriptc's own event loop is parked for
|
|
@@ -152,6 +175,37 @@ The backend is ordinary TypeScript with scriptc's stdlib — including a
|
|
|
152
175
|
`node:fs` subset — so "read a file" or "call an API" is just code in a
|
|
153
176
|
command handler, no plugin layer needed.
|
|
154
177
|
|
|
178
|
+
## Native dialogs and window control
|
|
179
|
+
|
|
180
|
+
```ts
|
|
181
|
+
app.commandAsync("openFile", (_args, resolve) => {
|
|
182
|
+
app.openFileDialog(
|
|
183
|
+
{ title: "Pick a file", filters: [{ name: "Text", extensions: ["txt", "md"] }] },
|
|
184
|
+
(paths, err) => {
|
|
185
|
+
if (paths === null) { resolve({ cancelled: true }); return; } // cancel
|
|
186
|
+
app.readFileAsync(paths[0], (rerr, text) => resolve({ path: paths[0], text }));
|
|
187
|
+
},
|
|
188
|
+
);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
app.saveFileDialog({ defaultName: "untitled.txt" }, (path) => { /* … */ });
|
|
192
|
+
|
|
193
|
+
app.setTitle("new title");
|
|
194
|
+
app.setSize(720, 480, 0);
|
|
195
|
+
app.setFullscreen(true);
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
A cancel is `null`, not an error. Options: `title`, `defaultPath`, `filters`,
|
|
199
|
+
plus `multiple` and `directory` for open, and `defaultName` for save.
|
|
200
|
+
`directory: true` is not supported on Windows and reports `ENOTSUP`.
|
|
201
|
+
|
|
202
|
+
Use `commandAsync` for dialogs — the user may take as long as they like, and
|
|
203
|
+
the window keeps serving other calls meanwhile. The modal itself runs on a
|
|
204
|
+
later UI-thread turn rather than inside the call that requests it, because a
|
|
205
|
+
nested modal loop would otherwise re-enter the host loop underneath a live TS
|
|
206
|
+
frame; [docs/native-shell.md](../../docs/native-shell.md) has the details, the
|
|
207
|
+
per-platform table, and the Windows GUI-subsystem note.
|
|
208
|
+
|
|
155
209
|
## Migrating from 0.1.x
|
|
156
210
|
|
|
157
211
|
Commands used to take and return **JSON text**; they now take and return
|
|
@@ -206,12 +260,12 @@ no `-framework` support) and the binary is wrapped into an ad-hoc-signed
|
|
|
206
260
|
|
|
207
261
|
## Status
|
|
208
262
|
|
|
209
|
-
Early proof of concept, macOS (arm64)
|
|
210
|
-
notes and scriptc findings behind it are in
|
|
211
|
-
[docs/findings.md](docs/findings.md). Not yet:
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
icons/installers/notarization.
|
|
263
|
+
Early proof of concept, on macOS (arm64), Linux (WebKitGTK) and Windows
|
|
264
|
+
(WebView2). The design notes and scriptc findings behind it are in
|
|
265
|
+
[docs/findings.md](../../docs/findings.md). Not yet: async commands that run in
|
|
266
|
+
parallel (host code is single-threaded; `commandAsync` interleaves instead),
|
|
267
|
+
tray icons and menus, multi-window, directory picking on Windows,
|
|
268
|
+
`app.center()`, and icons/installers/notarization.
|
|
215
269
|
|
|
216
270
|
## Releasing
|
|
217
271
|
|
package/bin/janela.mjs
CHANGED
|
@@ -10,10 +10,11 @@
|
|
|
10
10
|
// webview.h, the vendored webview, the scriptc runtime library, the FFI
|
|
11
11
|
// manifest — lives in this package and is assembled into .janela/ at build time.
|
|
12
12
|
|
|
13
|
-
import { spawnSync } from "node:child_process";
|
|
13
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
14
14
|
import {
|
|
15
|
-
cpSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync,
|
|
15
|
+
cpSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync,
|
|
16
16
|
} from "node:fs";
|
|
17
|
+
import { createServer } from "node:net";
|
|
17
18
|
import { createRequire } from "node:module";
|
|
18
19
|
import { dirname, join, relative, resolve, sep } from "node:path";
|
|
19
20
|
import { fileURLToPath } from "node:url";
|
|
@@ -54,6 +55,149 @@ function loadConf(root) {
|
|
|
54
55
|
return c;
|
|
55
56
|
}
|
|
56
57
|
|
|
58
|
+
// ---- frontend: plain HTML, or a Vite app -----------------------------------
|
|
59
|
+
//
|
|
60
|
+
// A project is in "Vite mode" when it has a vite config at its root; otherwise
|
|
61
|
+
// index.html is inlined verbatim, exactly as janela has always done.
|
|
62
|
+
//
|
|
63
|
+
// There is no file server behind the window: the shim hands the webview one
|
|
64
|
+
// HTML document (webview_set_html). So a Vite build is flattened into that one
|
|
65
|
+
// document — JS inlined as a module script, CSS as a <style>, everything else
|
|
66
|
+
// as a data: URI. The alternative designs (embedding the dist tree and serving
|
|
67
|
+
// it from a localhost HTTP server in the shim, or registering a custom URI
|
|
68
|
+
// scheme per platform) each cost a platform-specific implementation in C++ and
|
|
69
|
+
// buy nothing until an app outgrows a single document.
|
|
70
|
+
|
|
71
|
+
const VITE_CONFIGS = [
|
|
72
|
+
"vite.config.js", "vite.config.ts", "vite.config.mjs",
|
|
73
|
+
"vite.config.mts", "vite.config.cjs", "vite.config.cts",
|
|
74
|
+
];
|
|
75
|
+
|
|
76
|
+
function viteConfigPath(root) {
|
|
77
|
+
for (const f of VITE_CONFIGS) {
|
|
78
|
+
const p = join(root, f);
|
|
79
|
+
if (existsSync(p)) return p;
|
|
80
|
+
}
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function viteBin(root) {
|
|
85
|
+
const p = join(root, "node_modules", ".bin", process.platform === "win32" ? "vite.cmd" : "vite");
|
|
86
|
+
if (!existsSync(p)) {
|
|
87
|
+
fail(
|
|
88
|
+
"this project has a vite config but no local vite — run your package manager's " +
|
|
89
|
+
"install first (npm install / pnpm install)",
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
return p;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const MIME = {
|
|
96
|
+
".css": "text/css", ".js": "text/javascript", ".json": "application/json",
|
|
97
|
+
".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
|
|
98
|
+
".gif": "image/gif", ".webp": "image/webp", ".avif": "image/avif",
|
|
99
|
+
".svg": "image/svg+xml", ".ico": "image/x-icon",
|
|
100
|
+
".woff": "font/woff", ".woff2": "font/woff2", ".ttf": "font/ttf", ".otf": "font/otf",
|
|
101
|
+
".mp3": "audio/mpeg", ".mp4": "video/mp4", ".webm": "video/webm",
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
function mimeFor(p) {
|
|
105
|
+
const dot = p.lastIndexOf(".");
|
|
106
|
+
return (dot < 0 ? null : MIME[p.slice(dot).toLowerCase()]) ?? "application/octet-stream";
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// A dist-relative reference ("/assets/x.js", "./assets/x.js") → absolute path,
|
|
110
|
+
// or null when it points outside the build (a CDN URL, a data: URI, an anchor).
|
|
111
|
+
function distAsset(distDir, ref) {
|
|
112
|
+
if (!ref || /^(https?:|data:|blob:|#|mailto:)/i.test(ref)) return null;
|
|
113
|
+
const clean = ref.split("?")[0].split("#")[0].replace(/^\.?\//, "");
|
|
114
|
+
if (!clean) return null;
|
|
115
|
+
const p = join(distDir, clean);
|
|
116
|
+
return existsSync(p) && statSync(p).isFile() ? p : null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function dataUri(file) {
|
|
120
|
+
return `data:${mimeFor(file)};base64,${readFileSync(file).toString("base64")}`;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function attr(tag, name) {
|
|
124
|
+
const m = tag.match(new RegExp(`\\b${name}\\s*=\\s*["']([^"']*)["']`, "i"));
|
|
125
|
+
return m ? m[1] : null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// url(...) inside CSS → data: URIs, so a stylesheet's images and fonts survive
|
|
129
|
+
// the flattening too.
|
|
130
|
+
function inlineCssUrls(css, distDir, cssFile) {
|
|
131
|
+
return css.replace(/url\(\s*(['"]?)([^'")]+)\1\s*\)/gi, (whole, _q, ref) => {
|
|
132
|
+
const rel = ref.startsWith("/")
|
|
133
|
+
? distAsset(distDir, ref)
|
|
134
|
+
: distAsset(distDir, relative(distDir, join(dirname(cssFile), ref)));
|
|
135
|
+
return rel ? `url("${dataUri(rel)}")` : whole;
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Flatten dist/ into one self-contained HTML document.
|
|
140
|
+
function inlineDist(distDir) {
|
|
141
|
+
const htmlPath = join(distDir, "index.html");
|
|
142
|
+
if (!existsSync(htmlPath)) fail(`vite build produced no ${relative(process.cwd(), htmlPath)}`);
|
|
143
|
+
let html = readFileSync(htmlPath, "utf8");
|
|
144
|
+
|
|
145
|
+
// Preloads only matter when there are separate files to fetch.
|
|
146
|
+
html = html.replace(/<link\b[^>]*\brel\s*=\s*["'](?:modulepreload|preload|prefetch)["'][^>]*>\s*/gi, "");
|
|
147
|
+
|
|
148
|
+
html = html.replace(/<script\b([^>]*)><\/script>/gi, (whole, attrs) => {
|
|
149
|
+
const file = distAsset(distDir, attr(attrs, "src"));
|
|
150
|
+
if (!file) return whole;
|
|
151
|
+
const type = /\btype\s*=\s*["']module["']/i.test(attrs) ? ' type="module"' : "";
|
|
152
|
+
// A literal </script> inside the code would close this tag early.
|
|
153
|
+
const js = readFileSync(file, "utf8").replace(/<\/script/gi, "<\\/script");
|
|
154
|
+
return `<script${type}>${js}</script>`;
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
html = html.replace(/<link\b([^>]*)>/gi, (whole, attrs) => {
|
|
158
|
+
const href = attr(attrs, "href");
|
|
159
|
+
const file = distAsset(distDir, href);
|
|
160
|
+
if (!file) return whole;
|
|
161
|
+
if (/\brel\s*=\s*["']stylesheet["']/i.test(attrs)) {
|
|
162
|
+
const css = inlineCssUrls(readFileSync(file, "utf8"), distDir, file).replace(/<\/style/gi, "<\\/style");
|
|
163
|
+
return `<style>${css}</style>`;
|
|
164
|
+
}
|
|
165
|
+
return whole.replace(href, dataUri(file));
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
// Anything still pointing at a file in dist (favicons, <img>, <source>).
|
|
169
|
+
html = html.replace(/\b(src|href)\s*=\s*["']([^"']+)["']/gi, (whole, name, ref) => {
|
|
170
|
+
const file = distAsset(distDir, ref);
|
|
171
|
+
return file ? `${name}="${dataUri(file)}"` : whole;
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
return html;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// The document handed to the webview. In dev mode it is a stub that hands the
|
|
178
|
+
// window over to the Vite server: location.replace leaves no history entry, and
|
|
179
|
+
// the janela bootstrap is injected per-document (webview_init), so the served
|
|
180
|
+
// page gets window.janela exactly like an inlined one.
|
|
181
|
+
function frontendHtml(root, conf, devUrl) {
|
|
182
|
+
if (devUrl) {
|
|
183
|
+
return `<!doctype html><html><head><meta charset="utf-8"><title>${conf.name}</title></head>` +
|
|
184
|
+
`<body><script>location.replace(${JSON.stringify(devUrl)});</script></body></html>`;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (viteConfigPath(root)) {
|
|
188
|
+
console.log("janela: building the frontend with vite");
|
|
189
|
+
run([viteBin(root), "build"], { cwd: root });
|
|
190
|
+
const distDir = resolve(root, conf.frontend?.dist ?? "dist");
|
|
191
|
+
const html = inlineDist(distDir);
|
|
192
|
+
console.log(`janela: frontend inlined (${(Buffer.byteLength(html) / 1024).toFixed(0)} kB)`);
|
|
193
|
+
return html;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const htmlSrc = join(root, "index.html");
|
|
197
|
+
if (!existsSync(htmlSrc)) fail("missing index.html");
|
|
198
|
+
return readFileSync(htmlSrc, "utf8");
|
|
199
|
+
}
|
|
200
|
+
|
|
57
201
|
// ---- Windows toolchain ------------------------------------------------------
|
|
58
202
|
|
|
59
203
|
// scriptc's win32 lane is MinGW-shaped twice over: its runtime uses POSIX
|
|
@@ -198,9 +342,11 @@ function ffiManifest(shimLib) {
|
|
|
198
342
|
// async file I/O: the blocking syscall runs on a shim worker thread
|
|
199
343
|
{ name: "wvFsRead", symbol: "wv_fs_read", params: ["i32", "string"], returns: "i32" },
|
|
200
344
|
{ name: "wvFsWrite", symbol: "wv_fs_write", params: ["i32", "string", "string"], returns: "i32" },
|
|
201
|
-
|
|
345
|
+
// Job accessors, shared by file I/O and dialogs: both are work whose
|
|
346
|
+
// answer cannot be produced during the FFI call that starts it.
|
|
347
|
+
{ name: "wvJobStatus", symbol: "wv_job_status", params: ["i32", "i32"], returns: "i32" },
|
|
202
348
|
{
|
|
203
|
-
name: "
|
|
349
|
+
name: "wvJobTake", symbol: "wv_job_take",
|
|
204
350
|
params: [
|
|
205
351
|
"i32", "i32",
|
|
206
352
|
{ callback: { id: "sink", params: ["string", { context: "sink" }], returns: "void", lifetime: "call" } },
|
|
@@ -208,7 +354,16 @@ function ffiManifest(shimLib) {
|
|
|
208
354
|
],
|
|
209
355
|
returns: "i32",
|
|
210
356
|
},
|
|
211
|
-
{ name: "
|
|
357
|
+
{ name: "wvJobFree", symbol: "wv_job_free", params: ["i32", "i32"], returns: "i32" },
|
|
358
|
+
// Native dialogs: the modal runs on a later UI-thread turn, so asking for
|
|
359
|
+
// one never blocks the invoke that asked. Options ride as plain params
|
|
360
|
+
// (kind, flags, title, defaultPath, defaultName, filters).
|
|
361
|
+
{
|
|
362
|
+
name: "wvDialog", symbol: "wv_dialog",
|
|
363
|
+
params: ["i32", "i32", "i32", "string", "string", "string", "string"],
|
|
364
|
+
returns: "i32",
|
|
365
|
+
},
|
|
366
|
+
{ name: "wvSetFullscreen", symbol: "wv_set_fullscreen", params: ["i32", "i32"], returns: "i32" },
|
|
212
367
|
];
|
|
213
368
|
|
|
214
369
|
if (process.platform === "win32") {
|
|
@@ -230,6 +385,8 @@ function ffiManifest(shimLib) {
|
|
|
230
385
|
system_libraries: [
|
|
231
386
|
"c++", "pthread",
|
|
232
387
|
"ole32", "oleaut32", "shlwapi", "shell32", "user32", "version", "gdi32",
|
|
388
|
+
// GetOpenFileNameW / GetSaveFileNameW for the native file dialogs.
|
|
389
|
+
"comdlg32",
|
|
233
390
|
],
|
|
234
391
|
};
|
|
235
392
|
}
|
|
@@ -262,9 +419,55 @@ function ffiManifest(shimLib) {
|
|
|
262
419
|
};
|
|
263
420
|
}
|
|
264
421
|
|
|
422
|
+
// ---- Windows subsystem ------------------------------------------------------
|
|
423
|
+
|
|
424
|
+
// A console-subsystem .exe makes Windows open a console window behind the UI.
|
|
425
|
+
// The fix is normally `-mwindows` at link time, but scriptc exposes no way to
|
|
426
|
+
// pass a linker flag: `system_libraries` entries are validated as bare library
|
|
427
|
+
// names and explicitly rejected if they start with "-", `libraries` entries
|
|
428
|
+
// must resolve to existing files, and no env var is read for extra flags
|
|
429
|
+
// (checked in @scriptc/compiler 0.0.35: backend/cc.js, ffi/profile.js).
|
|
430
|
+
//
|
|
431
|
+
// So the subsystem byte is rewritten in the linked PE instead. The entry point
|
|
432
|
+
// is untouched — MinGW's mainCRTStartup runs either way; the field only tells
|
|
433
|
+
// the loader whether to allocate a console. Every offset is checked before
|
|
434
|
+
// anything is written, and a file that does not look like a console-subsystem
|
|
435
|
+
// PE is left alone.
|
|
436
|
+
const IMAGE_SUBSYSTEM_WINDOWS_GUI = 2;
|
|
437
|
+
const IMAGE_SUBSYSTEM_WINDOWS_CUI = 3;
|
|
438
|
+
|
|
439
|
+
function makeGuiSubsystem(exePath) {
|
|
440
|
+
const buf = readFileSync(exePath);
|
|
441
|
+
if (buf.length < 0x40 || buf.readUInt16LE(0) !== 0x5a4d) {
|
|
442
|
+
fail(`${exePath} is not a PE image (no MZ header)`);
|
|
443
|
+
}
|
|
444
|
+
const peOff = buf.readUInt32LE(0x3c);
|
|
445
|
+
if (peOff + 24 > buf.length || buf.readUInt32LE(peOff) !== 0x00004550) {
|
|
446
|
+
fail(`${exePath} has no PE signature at ${peOff}`);
|
|
447
|
+
}
|
|
448
|
+
// Optional header starts after the 4-byte signature and 20-byte COFF header;
|
|
449
|
+
// Subsystem sits at +68 in both PE32 (0x10b) and PE32+ (0x20b).
|
|
450
|
+
const optOff = peOff + 24;
|
|
451
|
+
const magic = buf.readUInt16LE(optOff);
|
|
452
|
+
if (magic !== 0x10b && magic !== 0x20b) {
|
|
453
|
+
fail(`${exePath} has an unrecognised optional header magic 0x${magic.toString(16)}`);
|
|
454
|
+
}
|
|
455
|
+
const subOff = optOff + 68;
|
|
456
|
+
if (subOff + 2 > buf.length) fail(`${exePath} is truncated before its Subsystem field`);
|
|
457
|
+
const current = buf.readUInt16LE(subOff);
|
|
458
|
+
if (current === IMAGE_SUBSYSTEM_WINDOWS_GUI) return;
|
|
459
|
+
if (current !== IMAGE_SUBSYSTEM_WINDOWS_CUI) {
|
|
460
|
+
fail(`${exePath} has an unexpected subsystem ${current}; refusing to rewrite it`);
|
|
461
|
+
}
|
|
462
|
+
buf.writeUInt16LE(IMAGE_SUBSYSTEM_WINDOWS_GUI, subOff);
|
|
463
|
+
writeFileSync(exePath, buf);
|
|
464
|
+
}
|
|
465
|
+
|
|
265
466
|
// ---- build ----------------------------------------------------------------
|
|
266
467
|
|
|
267
|
-
|
|
468
|
+
// `devUrl` points the window at a running vite server instead of inlining the
|
|
469
|
+
// frontend; `gui` asks for a GUI-subsystem .exe on Windows (build, not dev).
|
|
470
|
+
function build(root, { devUrl = null, gui = true } = {}) {
|
|
268
471
|
const conf = loadConf(root);
|
|
269
472
|
const buildDir = join(root, ".janela", "build");
|
|
270
473
|
const cacheDir = join(root, ".janela", "cache");
|
|
@@ -279,12 +482,10 @@ function build(root) {
|
|
|
279
482
|
if (!existsSync(mainSrc)) fail("missing src-host/main.ts");
|
|
280
483
|
cpSync(mainSrc, join(buildDir, "main.ts"));
|
|
281
484
|
|
|
282
|
-
const
|
|
283
|
-
if (!existsSync(htmlSrc)) fail("missing index.html");
|
|
284
|
-
const html = readFileSync(htmlSrc, "utf8");
|
|
485
|
+
const html = frontendHtml(root, conf, devUrl);
|
|
285
486
|
writeFileSync(
|
|
286
487
|
join(buildDir, "frontend.ts"),
|
|
287
|
-
`// Generated by janela
|
|
488
|
+
`// Generated by janela — do not edit.\nexport const INDEX_HTML: string = ${JSON.stringify(html)};\n`,
|
|
288
489
|
);
|
|
289
490
|
|
|
290
491
|
const w = conf.window;
|
|
@@ -322,6 +523,14 @@ function build(root) {
|
|
|
322
523
|
// inside the .exe rather than a side-by-side .pdb, so Windows benefits too.)
|
|
323
524
|
run(["strip", bin]);
|
|
324
525
|
|
|
526
|
+
// `janela dev` keeps the console subsystem so console.log from a command is
|
|
527
|
+
// visible in the terminal; a shipped `janela build` must not flash a console
|
|
528
|
+
// window behind the UI, and loses stdout as the price.
|
|
529
|
+
if (process.platform === "win32" && gui) {
|
|
530
|
+
makeGuiSubsystem(bin);
|
|
531
|
+
console.log("janela: linked as a GUI-subsystem .exe (no console window)");
|
|
532
|
+
}
|
|
533
|
+
|
|
325
534
|
if (process.platform === "darwin") {
|
|
326
535
|
const bundle = join(outDir, `${conf.name}.app`);
|
|
327
536
|
mkdirSync(join(bundle, "Contents", "MacOS"), { recursive: true });
|
|
@@ -354,8 +563,25 @@ function build(root) {
|
|
|
354
563
|
|
|
355
564
|
// ---- init -----------------------------------------------------------------
|
|
356
565
|
|
|
357
|
-
|
|
358
|
-
|
|
566
|
+
const TEMPLATES = ["vanilla", "vue", "react", "svelte", "solid"];
|
|
567
|
+
|
|
568
|
+
// Copy a template tree, substituting the project name in text files.
|
|
569
|
+
function copyTemplate(from, to, name) {
|
|
570
|
+
for (const entry of readdirSync(from, { withFileTypes: true })) {
|
|
571
|
+
const src = join(from, entry.name);
|
|
572
|
+
const dst = join(to, entry.name);
|
|
573
|
+
if (entry.isDirectory()) {
|
|
574
|
+
mkdirSync(dst, { recursive: true });
|
|
575
|
+
copyTemplate(src, dst, name);
|
|
576
|
+
} else {
|
|
577
|
+
writeFileSync(dst, readFileSync(src, "utf8").replaceAll("__NAME__", name));
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
function init(name, template) {
|
|
583
|
+
if (!name || !/^[a-z][a-z0-9-]*$/.test(name)) fail("usage: janela init <name> [--template <t>] (lowercase, digits, dashes)");
|
|
584
|
+
if (!TEMPLATES.includes(template)) fail(`unknown template '${template}' (${TEMPLATES.join(", ")})`);
|
|
359
585
|
const dir = resolve(process.cwd(), name);
|
|
360
586
|
if (existsSync(dir)) fail(`${name}/ already exists`);
|
|
361
587
|
mkdirSync(join(dir, "src-host"), { recursive: true });
|
|
@@ -367,44 +593,130 @@ function init(name) {
|
|
|
367
593
|
? `^${kitPkg.version}`
|
|
368
594
|
: `file:${relative(dir, KIT) || "."}`;
|
|
369
595
|
|
|
370
|
-
const
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
596
|
+
const pkg = {
|
|
597
|
+
name,
|
|
598
|
+
private: true,
|
|
599
|
+
scripts: { dev: "janela dev", build: "janela build" },
|
|
600
|
+
devDependencies: { janela: janelaDep },
|
|
601
|
+
};
|
|
602
|
+
|
|
603
|
+
if (template === "vanilla") {
|
|
604
|
+
// The zero-dependency shape janela has always scaffolded: no frontend
|
|
605
|
+
// toolchain, no npm install needed before the first build.
|
|
606
|
+
const t = (f) => readFileSync(join(KIT, "templates", f), "utf8").replaceAll("__NAME__", name);
|
|
607
|
+
writeFileSync(join(dir, "index.html"), t("index.html"));
|
|
608
|
+
writeFileSync(join(dir, "src-host", "main.ts"), t("main.ts"));
|
|
609
|
+
writeFileSync(join(dir, "janela.conf.json"), t("janela.conf.json"));
|
|
610
|
+
} else {
|
|
611
|
+
const tdir = join(KIT, "templates", template);
|
|
612
|
+
copyTemplate(join(tdir, "files"), dir, name);
|
|
613
|
+
const extra = JSON.parse(readFileSync(join(tdir, "deps.json"), "utf8"));
|
|
614
|
+
pkg.type = "module";
|
|
615
|
+
Object.assign(pkg.devDependencies, extra.devDependencies ?? {});
|
|
616
|
+
if (extra.dependencies) pkg.dependencies = extra.dependencies;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
writeFileSync(join(dir, ".gitignore"), ".janela/\nnode_modules/\ndist/\n");
|
|
620
|
+
writeFileSync(join(dir, "package.json"), JSON.stringify(pkg, null, 2) + "\n");
|
|
621
|
+
|
|
622
|
+
const install = template === "vanilla" ? "" : "npm install && ";
|
|
623
|
+
console.log(`janela: created ${name}/ (${template}) — next: cd ${name} && ${install}janela dev`);
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
// ---- dev --------------------------------------------------------------------
|
|
627
|
+
|
|
628
|
+
function freePort() {
|
|
629
|
+
return new Promise((ok, no) => {
|
|
630
|
+
const s = createServer();
|
|
631
|
+
s.on("error", no);
|
|
632
|
+
s.listen(0, "127.0.0.1", () => {
|
|
633
|
+
const { port } = s.address();
|
|
634
|
+
s.close(() => ok(port));
|
|
635
|
+
});
|
|
636
|
+
});
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
async function waitForServer(url, child, timeoutMs = 30000) {
|
|
640
|
+
const deadline = Date.now() + timeoutMs;
|
|
641
|
+
while (Date.now() < deadline) {
|
|
642
|
+
if (child.exitCode !== null) fail(`the vite dev server exited (code ${child.exitCode})`);
|
|
643
|
+
try {
|
|
644
|
+
const r = await fetch(url, { signal: AbortSignal.timeout(1000) });
|
|
645
|
+
if (r.ok || r.status === 404) return;
|
|
646
|
+
} catch {
|
|
647
|
+
// not listening yet
|
|
648
|
+
}
|
|
649
|
+
await new Promise((ok) => setTimeout(ok, 150));
|
|
650
|
+
}
|
|
651
|
+
fail(`the vite dev server did not answer at ${url} within ${timeoutMs / 1000}s`);
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
async function dev(root) {
|
|
655
|
+
let vite = null;
|
|
656
|
+
let devUrl = null;
|
|
657
|
+
|
|
658
|
+
if (viteConfigPath(root)) {
|
|
659
|
+
const port = await freePort();
|
|
660
|
+
devUrl = `http://localhost:${port}/`;
|
|
661
|
+
console.log(`janela: starting the vite dev server on ${devUrl}`);
|
|
662
|
+
vite = spawn(viteBin(root), ["--port", String(port), "--strictPort"], {
|
|
663
|
+
cwd: root,
|
|
664
|
+
stdio: "inherit",
|
|
665
|
+
// Windows resolves .cmd shims through the shell.
|
|
666
|
+
shell: process.platform === "win32",
|
|
667
|
+
});
|
|
668
|
+
const stop = () => { if (vite && vite.exitCode === null) vite.kill(); };
|
|
669
|
+
process.on("exit", stop);
|
|
670
|
+
process.on("SIGINT", () => { stop(); process.exit(130); });
|
|
671
|
+
await waitForServer(devUrl, vite);
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
// Console subsystem on Windows: dev is where you want the logs.
|
|
675
|
+
const bin = build(root, { devUrl, gui: false });
|
|
676
|
+
console.log("janela: running (close the window or Ctrl-C to stop)");
|
|
677
|
+
// The frontend hot-reloads through vite; host changes need another `dev`.
|
|
678
|
+
run([bin]);
|
|
679
|
+
if (vite && vite.exitCode === null) vite.kill();
|
|
389
680
|
}
|
|
390
681
|
|
|
391
682
|
// ---- main -----------------------------------------------------------------
|
|
392
683
|
|
|
393
|
-
const
|
|
684
|
+
const argv = process.argv.slice(2);
|
|
685
|
+
const cmd = argv[0];
|
|
686
|
+
|
|
687
|
+
function flag(name, fallback) {
|
|
688
|
+
const eq = argv.find((a) => a.startsWith(`--${name}=`));
|
|
689
|
+
if (eq) return eq.slice(name.length + 3);
|
|
690
|
+
const i = argv.indexOf(`--${name}`);
|
|
691
|
+
if (i >= 0 && argv[i + 1] && !argv[i + 1].startsWith("--")) return argv[i + 1];
|
|
692
|
+
return fallback;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
// Everything after the subcommand that is neither a flag nor a flag's value.
|
|
696
|
+
function positionals() {
|
|
697
|
+
const out = [];
|
|
698
|
+
for (let i = 1; i < argv.length; i++) {
|
|
699
|
+
const a = argv[i];
|
|
700
|
+
if (!a.startsWith("--")) { out.push(a); continue; }
|
|
701
|
+
if (!a.includes("=") && argv[i + 1] && !argv[i + 1].startsWith("--")) i++;
|
|
702
|
+
}
|
|
703
|
+
return out;
|
|
704
|
+
}
|
|
705
|
+
|
|
394
706
|
switch (cmd) {
|
|
395
707
|
case "init":
|
|
396
|
-
init(
|
|
708
|
+
init(positionals()[0], flag("template", "vanilla"));
|
|
397
709
|
break;
|
|
398
710
|
case "build":
|
|
399
711
|
build(process.cwd());
|
|
400
712
|
break;
|
|
401
|
-
case "dev":
|
|
402
|
-
|
|
403
|
-
console.log("janela: running (close the window or Ctrl-C to stop)");
|
|
404
|
-
run([bin]);
|
|
713
|
+
case "dev":
|
|
714
|
+
await dev(process.cwd());
|
|
405
715
|
break;
|
|
406
|
-
}
|
|
407
716
|
default:
|
|
408
|
-
console.log(
|
|
717
|
+
console.log(
|
|
718
|
+
"usage: janela init <name> [--template vanilla|vue|react|svelte|solid]\n" +
|
|
719
|
+
" janela build | janela dev",
|
|
720
|
+
);
|
|
409
721
|
process.exit(cmd ? 1 : 0);
|
|
410
722
|
}
|