jops-core 1.1.5 → 1.1.6

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 CHANGED
@@ -19,10 +19,12 @@ No Webpack. No Babel. No compiler. Just `<script type="module">` and a browser.
19
19
  | Angular | ❌ | ✅ | ✅ | ✅ |
20
20
  | Svelte | ❌ (compiler) | ✅ | ✅ | ✅ |
21
21
 
22
- **Target audience:** Teams building internal tools, dashboards, embedded WebViews, or native mobile hybrid apps who want a structured, class-based frontend architecture without the npm/webpack/babel ecosystem overhead.
22
+ **Target audience:** Teams building internal tools, dashboards, embedded WebViews, native mobile hybrid apps, or desktop apps who want a structured, class-based frontend architecture without the npm/webpack/babel ecosystem overhead.
23
23
 
24
24
  **Next.js teams:** a JOPS SPA can also run inside a Next.js page, next to React, with Next.js as its backend. See [Next.js Integration](#nextjs-integration).
25
25
 
26
+ **Desktop apps:** a JOPS SPA can be the UI of an Electron app on macOS, Windows and Linux, with no bundler. See [Desktop Apps (Electron)](#desktop-apps-electron).
27
+
26
28
  **WordPress sites:** a JOPS SPA can run inside a WordPress page (Elementor or the block editor) through the JOPS plugin and its `[jops]` shortcode. See [WordPress Integration](#wordpress-integration).
27
29
 
28
30
  ---
@@ -92,7 +94,8 @@ npx jops-core init
92
94
  | 1 | Empty JOPS project: the basic skeleton of a JOPS app |
93
95
  | 2 | Hello World JOPS project: an example app using every feature |
94
96
  | 3 | Next.js JOPS Embedded SPA: a Next.js project hosting a JOPS SPA (see [Next.js Integration](#nextjs-integration)) |
95
- | 4 | None: only the library, `index.html` and scripts |
97
+ | 4 | Electron JOPS Desktop App: an Electron Forge project whose UI is a JOPS SPA (see [Desktop Apps (Electron)](#desktop-apps-electron)) |
98
+ | 5 | None: only the library, `index.html` and scripts |
96
99
 
97
100
  ### Updating the library
98
101
 
@@ -295,6 +298,126 @@ A `cordova_plugins.js` "not found" error in the console is expected when the pro
295
298
 
296
299
  ---
297
300
 
301
+ ## Desktop Apps (Electron)
302
+
303
+ A JOPS SPA can be the UI (renderer process) of an [Electron](https://www.electronjs.org/) app. The page is loaded from disk with `loadFile()`, JOPS and the app's files are packaged as they are (no bundler), and the views reach the operating system through Electron's preload bridge. Tested with Electron 45 and Electron Forge 8 on macOS and Windows.
304
+
305
+ ### Quick start: the Electron template
306
+
307
+ In an empty folder (or a fresh npm project):
308
+
309
+ ```bash
310
+ npx jops-core init
311
+ ```
312
+
313
+ and pick **4. Electron JOPS Desktop App**. `init` writes an Electron Forge project into the current folder, merges its dependencies and scripts into an existing `package.json` (existing entries are kept), vendors the library and runs `npm install`. Then:
314
+
315
+ ```bash
316
+ npm start # run the app
317
+ npm run make # package it (out/make/)
318
+ ```
319
+
320
+ | Path | Process | What it is |
321
+ | --- | --- | --- |
322
+ | `src/main.js` | main | Creates the window and answers two IPC calls: `app:info` and `files:list` |
323
+ | `src/preload.js` | preload | Exposes only those two functions to the page, as `window.api.*` |
324
+ | `src/index.html` | renderer | Content-Security-Policy, import map, jops-core script (`data-app="App.js"`) |
325
+ | `src/App.js`, `src/layout/main.html` | renderer | Root view: a sidebar and `<Router jops screens>` |
326
+ | `src/HomeView.js`, `src/FilesView.js` | renderer | Two screens that call `window.api` |
327
+ | `src/lib/jops-core.min.js` | renderer | The JOPS library, vendored |
328
+
329
+ `init` refuses to run in a folder that already has an Electron project (`forge.config.*`, `src/index.html`, `src/App.js`, or a `"main"` file) and changes nothing there; use the setup below instead.
330
+
331
+ ### Adding JOPS to an existing Electron project
332
+
333
+ ```bash
334
+ npm i -D jops-core
335
+ npx jops-core init
336
+ ```
337
+
338
+ `init` recognises an Electron project by `electron` in `package.json`; pick template 1, 2 or 5. It works with Electron Forge's default template (`npx create-electron-app@latest my-app`) and other projects that load a plain HTML page; Forge's **vite** and **webpack** templates build the page with a bundler and are not supported. What `init` configures:
339
+
340
+ - The **web folder** is the folder of the page the main process loads: the folder of `"main"` when it has an `index.html` (`src/` in Forge's template).
341
+ - The library goes to `<web folder>/lib/jops-core.min.js`.
342
+ - Forge's sample page is moved to `index.forge.html` and replaced by a JOPS `index.html` with a Content-Security-Policy, the import map and the module script. A page you already changed is patched instead.
343
+ - Templates 1 and 2 are copied into the web folder next to `index.html` (`App.js`, `layout/`, …), with their paths made page-relative (see below).
344
+ - `jops-core` moves to `devDependencies` and a `jops:update` script is added. No `build` or `postinstall` script: Electron Forge packages the web folder itself.
345
+
346
+ After upgrading jops-core, copy the new library into the app:
347
+
348
+ ```bash
349
+ npm i -D jops-core@latest
350
+ npm run jops:update
351
+ ```
352
+
353
+ ### Paths under `file://`
354
+
355
+ The page is loaded from `file://`, where a leading `/` means the root of the disk. Every path JOPS resolves (`data-app`, `src=`, `layout=`, `loadLayout()`, `setStyle()`) is written **relative to the page, without a leading `./` or `/`**:
356
+
357
+ ```html
358
+ <script type="module" src="lib/jops-core.min.js" data-app="App.js"></script>
359
+ ```
360
+
361
+ ```html
362
+ <FilesView jops src="FilesView.js" layout="layout/files_view.html" path="/files"></FilesView>
363
+ ```
364
+
365
+ The import map is read by the browser, not by JOPS, and its value must look like a URL, so it keeps its `./`: `{ "imports": { "jops-core": "./lib/jops-core.min.js" } }`. The same applies to `<img src>` and other URLs in your markup: `assets/logo.svg`, not `/assets/logo.svg`. Packaging changes nothing: the files are read from inside `app.asar` with the same relative paths.
366
+
367
+ ### Calling the main process (IPC)
368
+
369
+ JOPS adds no IPC layer: Electron's preload script and `contextBridge` are the bridge, and views call the functions it exposes, the same way Cordova plugins are called from `window`.
370
+
371
+ ```js
372
+ // src/main.js
373
+ ipcMain.handle("files:list", async () => { /* … */ return { dir, entries }; });
374
+
375
+ // src/preload.js
376
+ contextBridge.exposeInMainWorld("api", {
377
+ listFiles: () => ipcRenderer.invoke("files:list"),
378
+ });
379
+
380
+ // src/FilesView.js
381
+ async onResume() {
382
+ if (!window.api) return; // opened in a regular browser
383
+ const { dir, entries } = await window.api.listFiles();
384
+ // …
385
+ }
386
+ ```
387
+
388
+ - Expose named functions only, never `ipcRenderer` itself, and validate arguments in the main process.
389
+ - **Events from the main process** (notifications, live data): `webContents.send()` in the main process, and in the preload a function that registers a listener and returns its unsubscribe function:
390
+
391
+ ```js
392
+ // src/preload.js
393
+ onProgress: (callback) => {
394
+ const listener = (event, value) => callback(value);
395
+ ipcRenderer.on("progress", listener);
396
+ return () => ipcRenderer.removeListener("progress", listener);
397
+ },
398
+ ```
399
+
400
+ A view subscribes in `onResume()` and unsubscribes in `onPause()` or `destroy()`, or forwards the data into an owned `EventBus` subscription (`EventBus.subscribe(this, …)`), which `destroy()` removes.
401
+
402
+ ### Content-Security-Policy
403
+
404
+ `init` writes this policy into `index.html`, so Electron shows no security warning:
405
+
406
+ ```html
407
+ <meta http-equiv="Content-Security-Policy"
408
+ content="default-src 'self'; script-src 'self' 'unsafe-inline' blob:; style-src 'self' 'unsafe-inline'; img-src 'self' data:" />
409
+ ```
410
+
411
+ Only the app's own files load. `'unsafe-inline'` is needed for the inline import map and the `on*` attributes that `bindEvents()` wires, `blob:` for `Thread` workers. Remote scripts and `eval` stay blocked. Add hosts as your app needs them, e.g. `connect-src 'self' https://api.example.com` before calling a remote API.
412
+
413
+ ### Conventions
414
+
415
+ - **Screens mode** (`<Router jops screens>`) suits desktop layouts: each route fills the router area and scrolls on its own, below a fixed sidebar or toolbar.
416
+ - Keep **Electron's secure defaults** (`contextIsolation`, `sandbox`, no `nodeIntegration`); the views need nothing else.
417
+ - `jops-core` is a **devDependency**: the app uses the vendored copy, so the npm package (with its CLI and templates) stays out of `app.asar`.
418
+
419
+ ---
420
+
298
421
  ## Next.js Integration
299
422
 
300
423
  A JOPS SPA can run inside a page of a Next.js (App Router) app. Next.js keeps what it does best (server rendering, data, Route Handlers, its own routing and React ecosystem) and the JOPS app adds class-based views with an Android-style lifecycle on the pages that need them. Both render on the same page:
@@ -987,12 +1110,12 @@ t.terminate();
987
1110
 
988
1111
  | Command | Description |
989
1112
  | ---------------------- | -------------------------------------------------------------------------- |
990
- | `npx jops-core init` | Vendor library, configure `index.html`, inject scripts into `package.json`. Detects Cordova and Capacitor projects (see [Hybrid Mobile Apps](#hybrid-mobile-apps-cordova-ionic--capacitor)). Template 3 creates a Next.js project instead (see [Next.js Integration](#nextjs-integration)) |
1113
+ | `npx jops-core init` | Vendor library, configure `index.html`, inject scripts into `package.json`. Detects Cordova and Capacitor projects (see [Hybrid Mobile Apps](#hybrid-mobile-apps-cordova-ionic--capacitor)) and Electron projects (see [Desktop Apps (Electron)](#desktop-apps-electron)). Template 3 creates a Next.js project and template 4 an Electron project instead |
991
1114
  | `npx jops-core init --wp` | Install the JOPS WordPress plugin: into `plugins/jops/` of a `wp-env` project, otherwise into the plugins folder of the WordPress installation found in this folder or above it (see [WordPress Integration](#wordpress-integration)) |
992
- | `npx jops-core update` | Copy the latest `jops-core.min.js` from `node_modules` to `lib/` (`public/lib/` in a Next.js project); also refreshes an installed JOPS WordPress plugin |
993
- | `npx jops-core build` | Generate production-ready `dist/` folder (skipped in Cordova/Capacitor projects) |
1115
+ | `npx jops-core update` | Copy the latest `jops-core.min.js` from `node_modules` to `lib/` (`public/lib/` in a Next.js project, `src/lib/` in an Electron project); also refreshes an installed JOPS WordPress plugin |
1116
+ | `npx jops-core build` | Generate production-ready `dist/` folder (skipped in Cordova/Capacitor and Electron projects) |
994
1117
 
995
- `init` and `update` accept `--cordova`, `--capacitor` or `--web` to override the detected project type, and `--wp-root <path>` to point to a WordPress folder instead of searching for it (implies `--wp`).
1118
+ `init` and `update` accept `--cordova`, `--capacitor`, `--electron` or `--web` to override the detected project type, and `--wp-root <path>` to point to a WordPress folder instead of searching for it (implies `--wp`).
996
1119
 
997
1120
  After each command the CLI checks npm for a newer jops-core and prints a notice if one is available (at most 1.5 s, silent when offline). `build` also warns if `lib/jops-core.min.js` doesn't match the installed version. Turn the check off with `--no-update-check` or `JOPS_NO_UPDATE_CHECK=1`; it is skipped automatically when `CI` is set.
998
1121
 
package/bin/init.js CHANGED
@@ -14,12 +14,13 @@ if (command !== "init" && command !== "build" && command !== "update") {
14
14
  console.log(" npx jops-core init --wp — install the JOPS WordPress plugin: into plugins/jops/ of a wp-env project");
15
15
  console.log(" (folder with .wp-env.json), otherwise into the plugins folder of the");
16
16
  console.log(" WordPress installation found in this folder or above it");
17
- console.log(" npx jops-core update — copy the latest jops-core.min.js to lib/ (public/lib/ in a Next.js project);");
17
+ console.log(" npx jops-core update — copy the latest jops-core.min.js to lib/ (public/lib/ in a Next.js project,");
18
+ console.log(" src/lib/ in an Electron project);");
18
19
  console.log(" also refreshes an installed JOPS WordPress plugin (found as for init --wp)");
19
20
  console.log(" npx jops-core build — generate production-ready dist/ folder");
20
21
  console.log("");
21
22
  console.log("Options (init, update):");
22
- console.log(" --cordova | --capacitor | --web override the detected project type");
23
+ console.log(" --cordova | --capacitor | --electron | --web override the detected project type");
23
24
  console.log(" --wp-root <path> WordPress folder to use instead of searching (implies --wp)");
24
25
  console.log("Options (all commands):");
25
26
  console.log(" --no-update-check skip the check for a newer jops-core version");
@@ -84,7 +85,7 @@ function isNewer(a, b) {
84
85
  // projectRoot: package.json, .vscode/ webRoot: index.html, lib/, src/
85
86
  // They differ in hybrid projects: Cordova serves www/, Capacitor serves its webDir.
86
87
 
87
- const forced = ["--cordova", "--capacitor", "--web"]
88
+ const forced = ["--cordova", "--capacitor", "--electron", "--web"]
88
89
  .find((f) => flags.includes(f))?.slice(2);
89
90
 
90
91
  function readCapacitorWebDir(dir) {
@@ -112,7 +113,66 @@ function detectHybrid(dir) {
112
113
  return null;
113
114
  }
114
115
 
116
+ // Electron: a package.json with electron in (dev)dependencies. The web folder is
117
+ // the folder of the page the main process loads: the folder of "main" when it
118
+ // has an index.html (Forge's default template: src/), otherwise src/ or the
119
+ // project root when they have one, otherwise the folder of "main".
120
+ // Bundler templates (Forge vite/webpack) build the page, so JOPS cannot be used there.
121
+ function readPackageJson(dir) {
122
+ try {
123
+ return JSON.parse(fs.readFileSync(path.join(dir, "package.json"), "utf8"));
124
+ } catch {
125
+ return null;
126
+ }
127
+ }
128
+
129
+ function isElectronPackage(p) {
130
+ return Boolean(p?.dependencies?.electron || p?.devDependencies?.electron);
131
+ }
132
+
133
+ function electronWebRoot(dir, p) {
134
+ const mainDir = path.dirname(path.resolve(dir, p?.main ?? "index.js"));
135
+ for (const candidate of [mainDir, path.join(dir, "src"), dir]) {
136
+ if (fs.existsSync(path.join(candidate, "index.html"))) return candidate;
137
+ }
138
+ return mainDir;
139
+ }
140
+
141
+ function isElectronBundler(dir, p) {
142
+ if (/(^|[\\/])\.(vite|webpack)([\\/]|$)/.test(p?.main ?? "")) return true;
143
+ return ["forge.config.js", "forge.config.cjs", "forge.config.mjs", "forge.config.ts"].some((f) => {
144
+ const file = path.join(dir, f);
145
+ return fs.existsSync(file) && /plugin-(vite|webpack)/.test(fs.readFileSync(file, "utf8"));
146
+ });
147
+ }
148
+
149
+ function detectElectron(dir, force = false) {
150
+ const p = readPackageJson(dir);
151
+ if (!p || (!force && !isElectronPackage(p))) return null;
152
+ return { type: "electron", webRoot: electronWebRoot(dir, p), bundler: isElectronBundler(dir, p) };
153
+ }
154
+
115
155
  function resolveProject(cwd) {
156
+ if (forced === "electron") {
157
+ const here = detectElectron(cwd, true);
158
+ if (here) return { ...here, projectRoot: cwd };
159
+ for (let dir = path.dirname(cwd); dir !== path.dirname(dir); dir = path.dirname(dir)) {
160
+ const up = detectElectron(dir, true);
161
+ if (up) return { ...up, projectRoot: dir };
162
+ }
163
+ return { type: "electron", projectRoot: cwd, webRoot: cwd, bundler: false };
164
+ }
165
+ if (!forced && !detectHybrid(cwd)) {
166
+ const here = detectElectron(cwd);
167
+ if (here) return { ...here, projectRoot: cwd };
168
+ // Run from inside the web folder (src/): find the Electron project above.
169
+ for (let dir = path.dirname(cwd); dir !== path.dirname(dir); dir = path.dirname(dir)) {
170
+ const up = detectElectron(dir);
171
+ if (!up) continue;
172
+ if (up.webRoot === cwd) return { ...up, projectRoot: dir };
173
+ break;
174
+ }
175
+ }
116
176
  if (forced !== "web") {
117
177
  const here = detectHybrid(cwd); // run from the project root
118
178
  if (here) return { type: forced ?? here.type, projectRoot: cwd, webRoot: here.webRoot };
@@ -127,8 +187,9 @@ function resolveProject(cwd) {
127
187
  return { type: forced ?? "web", projectRoot: cwd, webRoot: cwd };
128
188
  }
129
189
 
130
- const { type, projectRoot, webRoot } = resolveProject(process.cwd());
131
- const isHybrid = type !== "web";
190
+ const { type, projectRoot, webRoot, bundler } = resolveProject(process.cwd());
191
+ const isElectron = type === "electron";
192
+ const isHybrid = type === "cordova" || type === "capacitor";
132
193
  const rel = (p) => path.relative(projectRoot, p) || ".";
133
194
 
134
195
  function copyDir(src, dest) {
@@ -241,7 +302,10 @@ function copyTemplateProject(src, dest, root = dest) {
241
302
 
242
303
  // Creates the template's package.json, or merges its dependencies and scripts
243
304
  // into an existing one: existing entries (e.g. an installed jops-core) are kept.
244
- function mergeTemplatePackageJson(templatePath, targetPath) {
305
+ // Next.js template: removes "type": "commonjs". Electron template: removes
306
+ // "type": "module" (the main process uses require), sets "main" and keeps
307
+ // jops-core in devDependencies (the app uses the vendored copy).
308
+ function mergeTemplatePackageJson(templatePath, targetPath, electron = false) {
245
309
  const template = JSON.parse(fs.readFileSync(templatePath, "utf8"));
246
310
  if (!fs.existsSync(targetPath)) {
247
311
  fs.writeFileSync(targetPath, JSON.stringify(template, null, 2) + "\n", "utf8");
@@ -252,10 +316,20 @@ function mergeTemplatePackageJson(templatePath, targetPath) {
252
316
  pkg.private ??= true;
253
317
  // `npm init -y` writes "type": "commonjs", which makes Next.js (Turbopack)
254
318
  // reject the project's ES module sources; Next.js projects have no "type".
255
- if (pkg.type === "commonjs") {
319
+ if (!electron && pkg.type === "commonjs") {
256
320
  delete pkg.type;
257
321
  console.log('[jops-core] Removed "type": "commonjs" from package.json (Next.js uses ES modules)');
258
322
  }
323
+ if (electron) {
324
+ if (pkg.type === "module") {
325
+ delete pkg.type;
326
+ console.log('[jops-core] Removed "type": "module" from package.json (the Electron main process uses require)');
327
+ }
328
+ pkg.main = template.main;
329
+ if (moveToDevDependencies(pkg, "jops-core")) {
330
+ console.log("[jops-core] Moved jops-core to devDependencies (the app uses the vendored copy in src/lib/)");
331
+ }
332
+ }
259
333
  for (const section of ["dependencies", "devDependencies"]) {
260
334
  for (const [name, spec] of Object.entries(template[section] ?? {})) {
261
335
  const existing = pkg.dependencies?.[name] ?? pkg.devDependencies?.[name];
@@ -275,7 +349,54 @@ function mergeTemplatePackageJson(templatePath, targetPath) {
275
349
  pkg.scripts = { ...pkg.scripts, [name]: command };
276
350
  }
277
351
  fs.writeFileSync(targetPath, JSON.stringify(pkg, null, 2) + "\n", "utf8");
278
- console.log("[jops-core] Added the Next.js dependencies and scripts to package.json");
352
+ console.log(`[jops-core] Added the ${electron ? "Electron" : "Next.js"} dependencies and scripts to package.json`);
353
+ }
354
+
355
+ // Moves a package from dependencies to devDependencies. Returns true when moved.
356
+ function moveToDevDependencies(pkg, name) {
357
+ const spec = pkg.dependencies?.[name];
358
+ if (!spec) return false;
359
+ delete pkg.dependencies[name];
360
+ if (!Object.keys(pkg.dependencies).length) delete pkg.dependencies;
361
+ pkg.devDependencies = { ...pkg.devDependencies, [name]: pkg.devDependencies?.[name] ?? spec };
362
+ return true;
363
+ }
364
+
365
+ // ─── Electron helpers ────────────────────────────────────────────────────────
366
+
367
+ // CSP of an Electron page: only the app's own files, plus inline scripts (the
368
+ // import map, and the on* attributes that bindEvents() wires) and blob: workers
369
+ // (Thread). Remote scripts and eval stay blocked.
370
+ const ELECTRON_CSP =
371
+ "default-src 'self'; script-src 'self' 'unsafe-inline' blob:; style-src 'self' 'unsafe-inline'; img-src 'self' data:";
372
+
373
+ // Copies the contents of a web template's src/ (and its assets/) flat into the
374
+ // Electron web folder, rewriting root-based paths (/src/x, ./src/x, /assets/x)
375
+ // to page-relative ones (x, assets/x): under file:// a leading / is the disk root.
376
+ function copyElectronTemplate(templateDir, dest) {
377
+ const rewrite = (text) => text
378
+ .replace(/(["'`(])\.?\/src\//g, "$1")
379
+ .replace(/(["'`(])\.?\/assets\//g, "$1assets/");
380
+ const copy = (src, to) => {
381
+ fs.mkdirSync(to, { recursive: true });
382
+ for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
383
+ if (entry.name === ".DS_Store" || entry.name.endsWith(".md")) continue;
384
+ const srcPath = path.join(src, entry.name);
385
+ const destPath = path.join(to, entry.name);
386
+ if (entry.isDirectory()) copy(srcPath, destPath);
387
+ else if (fs.existsSync(destPath)) console.log(`[jops-core] Kept the existing ${rel(destPath)}`);
388
+ else {
389
+ if ([".js", ".html", ".css"].includes(path.extname(entry.name))) {
390
+ fs.writeFileSync(destPath, rewrite(fs.readFileSync(srcPath, "utf8")), "utf8");
391
+ } else {
392
+ fs.copyFileSync(srcPath, destPath);
393
+ }
394
+ console.log(`[jops-core] Created ${rel(destPath)}`);
395
+ }
396
+ }
397
+ };
398
+ copy(path.join(templateDir, "src"), dest);
399
+ if (fs.existsSync(path.join(templateDir, "assets"))) copy(path.join(templateDir, "assets"), path.join(dest, "assets"));
279
400
  }
280
401
 
281
402
  // ─── template selection ──────────────────────────────────────────────────────
@@ -289,8 +410,9 @@ async function askTemplate() {
289
410
  " 1. Empty JOPS project (basic skeleton for JOPS project architecture)\n" +
290
411
  " 2. Hello World JOPS project (an example project utilizing all features)\n" +
291
412
  " 3. Next.js JOPS Embedded SPA (a Next.js project hosting a JOPS SPA)\n" +
292
- " 4. None (I'll create my own architecture)\n" +
293
- "Enter choice (1-4): ",
413
+ " 4. Electron JOPS Desktop App (an Electron Forge project with a JOPS SPA)\n" +
414
+ " 5. None (I'll create my own architecture)\n" +
415
+ "Enter choice (1-5): ",
294
416
  resolve,
295
417
  ));
296
418
  rl.close();
@@ -368,8 +490,8 @@ if (command === "init" && choice === "3") {
368
490
  "tsconfig.json", "src/app", "public/jops-spa", "data/quotes.json",
369
491
  ].filter((p) => fs.existsSync(path.join(cwd, p)));
370
492
 
371
- if (isHybrid) {
372
- console.error(`[jops-core] This is a ${type} project; create the Next.js project in another folder.`);
493
+ if (isHybrid || isElectron) {
494
+ console.error(`[jops-core] This is ${isElectron ? "an Electron" : `a ${type}`} project; create the Next.js project in another folder.`);
373
495
  process.exitCode = 1;
374
496
  } else if (conflicts.length) {
375
497
  console.error(`[jops-core] This folder already has ${conflicts.join(", ")}. Nothing was changed.`);
@@ -401,6 +523,161 @@ if (command === "init" && choice === "3") {
401
523
  }
402
524
  }
403
525
 
526
+ // ─── init: Electron JOPS Desktop App (template 4) ─────────────────────────────
527
+
528
+ if (command === "init" && choice === "4") {
529
+ const cwd = process.cwd();
530
+ const templateDir = path.join(__dirname, "../templates/electron-jops-app");
531
+ const existing = readPackageJson(cwd);
532
+ // Paths that would clash with an existing project: nothing is written then.
533
+ const conflicts = [
534
+ "forge.config.js", "forge.config.cjs", "forge.config.mjs", "forge.config.ts",
535
+ "src/main.js", "src/index.html", "src/App.js",
536
+ ].filter((p) => fs.existsSync(path.join(cwd, p)));
537
+ const mainFile = existing?.main && path.resolve(cwd, existing.main);
538
+ if (mainFile && mainFile !== path.join(cwd, "src", "main.js") && fs.existsSync(mainFile)) {
539
+ conflicts.push(`"main": "${existing.main}"`);
540
+ }
541
+
542
+ if (isHybrid) {
543
+ console.error(`[jops-core] This is a ${type} project; create the Electron project in another folder.`);
544
+ process.exitCode = 1;
545
+ } else if (conflicts.length) {
546
+ console.error(`[jops-core] This folder already has ${conflicts.join(", ")}. Nothing was changed.`);
547
+ console.error(" Run init in an empty folder (or a fresh npm project) to create the Electron project.");
548
+ if (isElectron) console.error(" To add JOPS to this Electron project, run init again and choose 1, 2 or 5.");
549
+ process.exitCode = 1;
550
+ } else {
551
+ copyTemplateProject(templateDir, cwd);
552
+ mergeTemplatePackageJson(path.join(templateDir, "package.json"), path.join(cwd, "package.json"), true);
553
+ console.log("[jops-core] Created the Electron JOPS Desktop App project in this folder.");
554
+
555
+ const libDir = path.join(cwd, "src", "lib");
556
+ fs.mkdirSync(libDir, { recursive: true });
557
+ fs.copyFileSync(path.join(__dirname, "../lib/jops-core.min.js"), path.join(libDir, "jops-core.min.js"));
558
+ console.log("[jops-core] Vendored jops-core.min.js → src/lib/jops-core.min.js");
559
+
560
+ console.log("[jops-core] Installing dependencies (npm install)…");
561
+ const install = spawnSync("npm", ["install"], {
562
+ cwd,
563
+ stdio: "inherit",
564
+ shell: process.platform === "win32",
565
+ });
566
+ if (install.status !== 0) {
567
+ console.warn("[jops-core] npm install did not complete. Run it yourself: npm install");
568
+ }
569
+ console.log("");
570
+ console.log("[jops-core] Next steps:");
571
+ console.log(" npm start run the app");
572
+ console.log(" npm run make package it (out/make/)");
573
+ console.log(" Paths JOPS resolves are page-relative (layout/x.html, not ./ or /): the page is loaded from file://");
574
+ }
575
+ }
576
+
577
+ // ─── init: existing Electron project (templates 1, 2 and none) ───────────────
578
+ // The library goes into the web folder (lib/), index.html gets an import map,
579
+ // the module script and a CSP (ELECTRON_CSP), and the web
580
+ // templates are copied flat into the web folder with page-relative paths.
581
+
582
+ const FORGE_SAMPLE_MARK = "Welcome to your Electron application";
583
+
584
+ if (command === "init" && !wpMode && isElectron && choice !== "3" && choice !== "4") {
585
+ const indexPath = path.join(webRoot, "index.html");
586
+ const template = choice === "1" ? "empty" : choice === "2" ? "hello-world" : null;
587
+
588
+ if (bundler) {
589
+ console.error("[jops-core] This Electron project builds its page with a bundler (Forge vite/webpack template).");
590
+ console.error(" JOPS loads its files as they are: use Forge's default template");
591
+ console.error(" (npx create-electron-app@latest my-app), or run `npx jops-core init` in an empty");
592
+ console.error(" folder and choose 4 (Electron JOPS Desktop App).");
593
+ process.exitCode = 1;
594
+ } else if (template && fs.existsSync(path.join(webRoot, "App.js"))) {
595
+ console.error(`[jops-core] ${rel(path.join(webRoot, "App.js"))} already exists. Nothing was changed.`);
596
+ process.exitCode = 1;
597
+ } else {
598
+ console.log(`[jops-core] ${forced ? "Using" : "Detected"} Electron project → web folder: ${rel(webRoot)}/`);
599
+ fs.mkdirSync(path.join(webRoot, "lib"), { recursive: true });
600
+ fs.copyFileSync(path.join(__dirname, "../lib/jops-core.min.js"), path.join(webRoot, "lib", "jops-core.min.js"));
601
+ console.log(`[jops-core] Vendored jops-core.min.js → ${rel(path.join(webRoot, "lib", "jops-core.min.js"))}`);
602
+
603
+ const IMPORTMAP = `<script type="importmap">
604
+ { "imports": { "jops-core": "./lib/jops-core.min.js" } }
605
+ </script>`;
606
+ const MODULE_SCRIPT = `<script type="module" src="lib/jops-core.min.js" data-app="App.js"></script>`;
607
+ const PAGE = `<!doctype html>
608
+ <html lang="en">
609
+ <head>
610
+ <meta charset="UTF-8" />
611
+ <meta http-equiv="Content-Security-Policy" content="${ELECTRON_CSP}" />
612
+ <title>JOPS App</title>
613
+ ${IMPORTMAP}
614
+ </head>
615
+ <body>
616
+ ${MODULE_SCRIPT}
617
+ </body>
618
+ </html>
619
+ `;
620
+
621
+ const isSample = fs.existsSync(indexPath) && fs.readFileSync(indexPath, "utf8").includes(FORGE_SAMPLE_MARK);
622
+ if (isSample) {
623
+ let backup = path.join(webRoot, "index.forge.html");
624
+ for (let n = 1; fs.existsSync(backup); n++) backup = path.join(webRoot, `index.forge-${n}.html`);
625
+ fs.renameSync(indexPath, backup);
626
+ console.log(`[jops-core] Moved Forge's sample page to ${rel(backup)} (its index.css is no longer used).`);
627
+ }
628
+ if (!fs.existsSync(indexPath)) {
629
+ fs.writeFileSync(indexPath, PAGE, "utf8");
630
+ console.log(`[jops-core] Created ${rel(indexPath)} with a CSP, the import map and the module script.`);
631
+ } else {
632
+ let html = fs.readFileSync(indexPath, "utf8");
633
+ const original = html;
634
+ if (!(/type=["']importmap["']/.test(html) && html.includes('"jops-core"'))) {
635
+ html = html.replace("</head>", ` ${IMPORTMAP}\n </head>`);
636
+ console.log(`[jops-core] Added the import map to ${rel(indexPath)}.`);
637
+ }
638
+ if (!/src=["'][^"']*jops-core\.min\.js["']/.test(html)) {
639
+ html = html.replace("</body>", ` ${MODULE_SCRIPT}\n </body>`);
640
+ console.log(`[jops-core] Added the module script to ${rel(indexPath)}.`);
641
+ }
642
+ const allowed = allowJopsScripts(html);
643
+ if (allowed !== html) {
644
+ html = allowed;
645
+ console.log("[jops-core] Content-Security-Policy: allowed the inline importmap ('unsafe-inline') and Thread workers (blob:).");
646
+ } else if (!/http-equiv=["']Content-Security-Policy["']/i.test(html)) {
647
+ console.log("[jops-core] Note: the page has no Content-Security-Policy; Electron warns about that. See the README (Electron).");
648
+ }
649
+ if (html !== original) fs.writeFileSync(indexPath, html, "utf8");
650
+ else console.log(`[jops-core] ${rel(indexPath)} already configured.`);
651
+ }
652
+
653
+ writeVscodeSettings();
654
+
655
+ const pkgPath = path.join(projectRoot, "package.json");
656
+ if (fs.existsSync(pkgPath)) {
657
+ const p = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
658
+ let changed = false;
659
+ if (moveToDevDependencies(p, "jops-core")) {
660
+ changed = true;
661
+ console.log(`[jops-core] Moved jops-core to devDependencies (the app uses the vendored copy in ${rel(path.join(webRoot, "lib"))}/).`);
662
+ }
663
+ if (!p.scripts?.["jops:update"]) {
664
+ p.scripts = { ...p.scripts, "jops:update": "jops-core update" };
665
+ changed = true;
666
+ console.log('[jops-core] Added "jops:update" script to package.json.');
667
+ }
668
+ if (changed) fs.writeFileSync(pkgPath, JSON.stringify(p, null, 2) + "\n", "utf8");
669
+ }
670
+
671
+ if (template) {
672
+ copyElectronTemplate(path.join(__dirname, "../templates", template), webRoot);
673
+ console.log(`[jops-core] Created ${template === "empty" ? "empty JOPS project skeleton" : "Hello World JOPS project"} in ${rel(webRoot)}/.`);
674
+ } else {
675
+ console.log(`[jops-core] No template applied. Create ${rel(path.join(webRoot, "App.js"))} (data-app="App.js").`);
676
+ }
677
+ console.log("[jops-core] Paths JOPS resolves are page-relative (layout/x.html, not ./ or /): the page is loaded from file://");
678
+ }
679
+ }
680
+
404
681
  // ─── index.html helpers (hybrid projects) ────────────────────────────────────
405
682
 
406
683
  // Same policy as Cordova's default, plus 'unsafe-inline' for the inline import map
@@ -498,9 +775,50 @@ function schemeWarnings(xml) {
498
775
  return warnings;
499
776
  }
500
777
 
778
+ // VS Code: lit-html extension recommendation and settings, in the project root.
779
+ function writeVscodeSettings() {
780
+ const vscodeSettingsDir = path.join(projectRoot, ".vscode");
781
+ if (!fs.existsSync(vscodeSettingsDir)) fs.mkdirSync(vscodeSettingsDir);
782
+
783
+ const extensionsJsonPath = path.join(vscodeSettingsDir, "extensions.json");
784
+ const extensionsJson = fs.existsSync(extensionsJsonPath)
785
+ ? JSON.parse(fs.readFileSync(extensionsJsonPath, "utf8"))
786
+ : { recommendations: [] };
787
+ if (!extensionsJson.recommendations.includes("bierner.lit-html")) {
788
+ extensionsJson.recommendations.push("bierner.lit-html");
789
+ fs.writeFileSync(extensionsJsonPath, JSON.stringify(extensionsJson, null, 2) + "\n", "utf8");
790
+ }
791
+
792
+ const settingsJsonPath = path.join(vscodeSettingsDir, "settings.json");
793
+ const settingsJson = fs.existsSync(settingsJsonPath)
794
+ ? JSON.parse(fs.readFileSync(settingsJsonPath, "utf8"))
795
+ : {};
796
+ if (!settingsJson["lit-html.tags"]?.includes("layout")) {
797
+ settingsJson["lit-html.tags"] = [...(settingsJson["lit-html.tags"] ?? []), "layout"];
798
+ }
799
+ const jopsTagColor = "#569CD6";
800
+ const rules = settingsJson["editor.tokenColorCustomizations"]?.textMateRules ?? [];
801
+ const hasRule = rules.some(r => r.scope === "invalid.illegal.unrecognized-tag.html");
802
+ if (!hasRule) {
803
+ settingsJson["editor.tokenColorCustomizations"] = {
804
+ ...settingsJson["editor.tokenColorCustomizations"],
805
+ textMateRules: [
806
+ ...rules,
807
+ {
808
+ scope: "invalid.illegal.unrecognized-tag.html",
809
+ settings: { foreground: jopsTagColor }
810
+ }
811
+ ]
812
+ };
813
+ }
814
+ fs.writeFileSync(settingsJsonPath, JSON.stringify(settingsJson, null, 2) + "\n", "utf8");
815
+
816
+ console.log("[jops-core] VS Code: install the recommended 'lit-html' extension (bierner.lit-html) for HTML highlighting inside layout`` template literals.");
817
+ }
818
+
501
819
  // ─── init ────────────────────────────────────────────────────────────────────
502
820
 
503
- if (command === "init" && !wpMode && choice !== "3") {
821
+ if (command === "init" && !wpMode && !isElectron && choice !== "3" && choice !== "4") {
504
822
  fs.mkdirSync(webRoot, { recursive: true });
505
823
  if (isHybrid) {
506
824
  console.log(`[jops-core] ${forced ? "Using" : "Detected"} ${type} project → web folder: ${rel(webRoot)}/`);
@@ -606,43 +924,7 @@ ${cordovaBody}${MODULE_SCRIPT}
606
924
  }
607
925
 
608
926
  // VS Code extension recommendation (project root, so it is never bundled into a hybrid app)
609
- const vscodeSettingsDir = path.join(projectRoot, ".vscode");
610
- if (!fs.existsSync(vscodeSettingsDir)) fs.mkdirSync(vscodeSettingsDir);
611
-
612
- const extensionsJsonPath = path.join(vscodeSettingsDir, "extensions.json");
613
- const extensionsJson = fs.existsSync(extensionsJsonPath)
614
- ? JSON.parse(fs.readFileSync(extensionsJsonPath, "utf8"))
615
- : { recommendations: [] };
616
- if (!extensionsJson.recommendations.includes("bierner.lit-html")) {
617
- extensionsJson.recommendations.push("bierner.lit-html");
618
- fs.writeFileSync(extensionsJsonPath, JSON.stringify(extensionsJson, null, 2) + "\n", "utf8");
619
- }
620
-
621
- const settingsJsonPath = path.join(vscodeSettingsDir, "settings.json");
622
- const settingsJson = fs.existsSync(settingsJsonPath)
623
- ? JSON.parse(fs.readFileSync(settingsJsonPath, "utf8"))
624
- : {};
625
- if (!settingsJson["lit-html.tags"]?.includes("layout")) {
626
- settingsJson["lit-html.tags"] = [...(settingsJson["lit-html.tags"] ?? []), "layout"];
627
- }
628
- const jopsTagColor = "#569CD6";
629
- const rules = settingsJson["editor.tokenColorCustomizations"]?.textMateRules ?? [];
630
- const hasRule = rules.some(r => r.scope === "invalid.illegal.unrecognized-tag.html");
631
- if (!hasRule) {
632
- settingsJson["editor.tokenColorCustomizations"] = {
633
- ...settingsJson["editor.tokenColorCustomizations"],
634
- textMateRules: [
635
- ...rules,
636
- {
637
- scope: "invalid.illegal.unrecognized-tag.html",
638
- settings: { foreground: jopsTagColor }
639
- }
640
- ]
641
- };
642
- }
643
- fs.writeFileSync(settingsJsonPath, JSON.stringify(settingsJson, null, 2) + "\n", "utf8");
644
-
645
- console.log("[jops-core] VS Code: install the recommended 'lit-html' extension (bierner.lit-html) for HTML highlighting inside layout`` template literals.");
927
+ writeVscodeSettings();
646
928
 
647
929
  // Inject scripts into consumer's package.json
648
930
  const pkgPath = path.join(projectRoot, "package.json");
@@ -719,8 +1001,11 @@ if (command === "update") {
719
1001
  }
720
1002
 
721
1003
  if (command === "update") {
722
- // A Next.js project serves the library from public/lib/.
723
- const libRoot = type === "web" && isNextProject(projectRoot) ? path.join(projectRoot, "public") : webRoot;
1004
+ // A Next.js project serves the library from public/lib/. An Electron project
1005
+ // vendors it into its web folder (src/lib/), or into the project's lib/ when set up by hand.
1006
+ const libRoot = type === "web" && isNextProject(projectRoot) ? path.join(projectRoot, "public")
1007
+ : isElectron && !fs.existsSync(path.join(webRoot, "lib")) && fs.existsSync(path.join(projectRoot, "lib")) ? projectRoot
1008
+ : webRoot;
724
1009
  const libDir = path.join(libRoot, "lib");
725
1010
  const vendorDest = path.join(libDir, "jops-core.min.js");
726
1011
  const vendorSrc = path.join(__dirname, "../lib/jops-core.min.js");
@@ -734,6 +1019,7 @@ if (command === "update") {
734
1019
  fs.copyFileSync(vendorSrc, vendorDest);
735
1020
  console.log(`[jops-core] Updated ${rel(vendorDest)} to v${pkg.version}`);
736
1021
  }
1022
+
737
1023
  }
738
1024
 
739
1025
  // ─── build ───────────────────────────────────────────────────────────────────
@@ -744,6 +1030,10 @@ if (command === "build") {
744
1030
  console.log(`[jops-core] Not needed in a ${type} project — ${rel(webRoot)}/ is shipped as-is. Use \`${native}\`.`);
745
1031
  process.exit(0);
746
1032
  }
1033
+ if (isElectron) {
1034
+ console.log(`[jops-core] Not needed in an Electron project — ${rel(webRoot)}/ is packaged as-is. Use \`npm run make\`.`);
1035
+ process.exit(0);
1036
+ }
747
1037
 
748
1038
  const BUILD_EXCLUDE = new Set([
749
1039
  "node_modules", "dist", ".git",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jops-core",
3
- "version": "1.1.5",
3
+ "version": "1.1.6",
4
4
  "description": "A no-bundler, OOP-first SPA framework with Android-style view composition",
5
5
  "type": "module",
6
6
  "exports": {
@@ -68,7 +68,9 @@
68
68
  "next-js",
69
69
  "next.js",
70
70
  "ssr",
71
- "wordpress"
71
+ "wordpress",
72
+ "electron",
73
+ "desktop"
72
74
  ],
73
75
  "author": "arik@ui-db.com",
74
76
  "license": "MIT",
@@ -0,0 +1,27 @@
1
+ # Electron JOPS Desktop App
2
+
3
+ An [Electron](https://www.electronjs.org/) desktop app whose UI is a [JOPS](https://github.com/uidb-dev/jops-core) SPA. Created with `npx jops-core init` (choice 4).
4
+
5
+ ```bash
6
+ npm start # run the app
7
+ npm run make # package it (out/make/)
8
+ npm run jops:update # after upgrading jops-core: copy the new library to src/lib/
9
+ ```
10
+
11
+ ## Structure
12
+
13
+ | Path | Process | Role |
14
+ |---|---|---|
15
+ | `src/main.js` | main | creates the window, answers IPC calls (`app:info`, `files:list`) |
16
+ | `src/preload.js` | preload | exposes only named functions to the page as `window.api.*` (`contextBridge`) |
17
+ | `src/index.html` | renderer | CSP, import map, the jops-core script (`data-app="App.js"`) |
18
+ | `src/App.js`, `src/layout/main.html` | renderer | root view: sidebar and `<Router jops screens>` |
19
+ | `src/HomeView.js`, `src/FilesView.js` | renderer | the two screens; both call `window.api` |
20
+ | `src/lib/jops-core.min.js` | renderer | the vendored library (jops-core itself is a devDependency) |
21
+
22
+ ## Rules
23
+
24
+ - **Paths:** the page is loaded from `file://`, so every path JOPS resolves (`data-app`, `src=`, `layout=`, `loadLayout()`, `setStyle()`) is page-relative, with no leading `./` or `/`: `layout/main.html`, not `/layout/main.html` (that would be the disk root). The import map value is a URL and keeps its `./`.
25
+ - **Calling the main process:** add an `ipcMain.handle("name", …)` in `main.js`, expose it in `preload.js`, call `await window.api.name()` from a view. Validate arguments in the main process.
26
+ - **Events from the main process:** expose `onX(callback)` in the preload (`ipcRenderer.on`) returning an unsubscribe function, subscribe in `onResume()` and unsubscribe in `onPause()` or `destroy()`, or forward the data into an owned `EventBus` subscription (`EventBus.subscribe(this, …)`).
27
+ - **CSP** (`src/index.html`): only the app's own files load; inline scripts are allowed for the import map and the `on*` attributes JOPS binds, `blob:` for `Thread` workers. Add a host to `connect-src` (e.g. `connect-src 'self' https://api.example.com`) before calling a remote API.
@@ -0,0 +1,28 @@
1
+ const { FusesPlugin } = require("@electron-forge/plugin-fuses");
2
+ const { FuseV1Options, FuseVersion } = require("@electron/fuses");
3
+
4
+ module.exports = {
5
+ packagerConfig: {
6
+ asar: true,
7
+ },
8
+ rebuildConfig: {},
9
+ makers: [
10
+ { name: "@electron-forge/maker-squirrel", config: {} },
11
+ { name: "@electron-forge/maker-zip", platforms: ["darwin", "win32"] },
12
+ { name: "@electron-forge/maker-deb", config: {} },
13
+ { name: "@electron-forge/maker-rpm", config: {} },
14
+ ],
15
+ plugins: [
16
+ { name: "@electron-forge/plugin-auto-unpack-natives", config: {} },
17
+ // Fuses enable/disable Electron features at package time, before code signing.
18
+ new FusesPlugin({
19
+ version: FuseVersion.V1,
20
+ [FuseV1Options.RunAsNode]: false,
21
+ [FuseV1Options.EnableCookieEncryption]: true,
22
+ [FuseV1Options.EnableNodeOptionsEnvironmentVariable]: false,
23
+ [FuseV1Options.EnableNodeCliInspectArguments]: false,
24
+ [FuseV1Options.EnableEmbeddedAsarIntegrityValidation]: true,
25
+ [FuseV1Options.OnlyLoadAppFromAsar]: true,
26
+ }),
27
+ ],
28
+ };
@@ -0,0 +1,13 @@
1
+ # dependencies
2
+ node_modules/
3
+
4
+ # Electron Forge output
5
+ out/
6
+
7
+ # logs
8
+ *.log
9
+ npm-debug.log*
10
+
11
+ # misc
12
+ .DS_Store
13
+ .env*
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "electron-jops-app",
3
+ "productName": "electron-jops-app",
4
+ "version": "1.0.0",
5
+ "private": true,
6
+ "description": "An Electron desktop app with a JOPS SPA",
7
+ "main": "src/main.js",
8
+ "scripts": {
9
+ "start": "electron-forge start",
10
+ "package": "electron-forge package",
11
+ "make": "electron-forge make",
12
+ "jops:update": "jops-core update"
13
+ },
14
+ "dependencies": {
15
+ "electron-squirrel-startup": "^1.0.1"
16
+ },
17
+ "devDependencies": {
18
+ "@electron-forge/cli": "^8.0.1",
19
+ "@electron-forge/maker-deb": "^8.0.1",
20
+ "@electron-forge/maker-rpm": "^8.0.1",
21
+ "@electron-forge/maker-squirrel": "^8.0.1",
22
+ "@electron-forge/maker-zip": "^8.0.1",
23
+ "@electron-forge/plugin-auto-unpack-natives": "^8.0.1",
24
+ "@electron-forge/plugin-fuses": "^8.0.1",
25
+ "@electron/fuses": "^2.1.3",
26
+ "electron": "45.0.0",
27
+ "jops-core": "^1.1.6"
28
+ }
29
+ }
@@ -0,0 +1,24 @@
1
+ import { View } from "jops-core";
2
+
3
+ // The root view renders layout/main.html into <body>.
4
+ // Paths are page-relative (no leading ./ or /): the page is loaded from file://.
5
+ export default class App {
6
+ constructor() {
7
+ const main = new View();
8
+ main
9
+ .loadLayout("layout/main.html")
10
+ .then(() => main.render())
11
+ .then(() => {
12
+ markCurrentLink();
13
+ window.addEventListener("hashchange", markCurrentLink);
14
+ });
15
+ }
16
+ }
17
+
18
+ // Highlights the sidebar link of the current route (#home, #files?…).
19
+ function markCurrentLink() {
20
+ const route = location.hash.split("?")[0];
21
+ for (const link of document.querySelectorAll(".nav-link")) {
22
+ link.classList.toggle("active", link.getAttribute("href") === route);
23
+ }
24
+ }
@@ -0,0 +1,48 @@
1
+ import { View, layout } from "jops-core";
2
+
3
+ // Files screen: lists the folder the app was started from (window.api.listFiles()),
4
+ // one FileRow sub-view per entry.
5
+ export default class FilesView extends View {
6
+ onResume() {
7
+ this.showFiles(); // refreshed every time the screen comes to the front
8
+ }
9
+
10
+ async showFiles() {
11
+ if (!window.api) return this.showMessage("Desktop app only: run it with npm start.");
12
+ try {
13
+ const { dir, entries } = await window.api.listFiles();
14
+ this.get("files_dir").set(layout`${dir}`);
15
+ this.removeSubViews();
16
+ this.get("files_list").set("");
17
+ if (entries.length === 0) return this.showMessage("This folder is empty.");
18
+ for (const entry of entries) this.addSubView("files_list", new FileRow(entry));
19
+ } catch (error) {
20
+ this.showMessage(`Could not list the folder: ${error.message}`);
21
+ }
22
+ }
23
+
24
+ // One row across the table (empty folder, error).
25
+ showMessage(text) {
26
+ this.removeSubViews();
27
+ this.get("files_list").set(layout`<tr><td colspan="3" class="muted">${text}</td></tr>`);
28
+ }
29
+ }
30
+
31
+ class FileRow extends View {
32
+ constructor(entry) {
33
+ super();
34
+ this.layout = layout`
35
+ <tr>
36
+ <td>${entry.name}</td>
37
+ <td class="muted">${entry.folder ? "Folder" : "File"}</td>
38
+ <td class="num muted">${entry.folder ? "" : formatSize(entry.size)}</td>
39
+ </tr>`;
40
+ this.inflate();
41
+ }
42
+ }
43
+
44
+ function formatSize(bytes) {
45
+ if (bytes < 1024) return `${bytes} B`;
46
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
47
+ return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
48
+ }
@@ -0,0 +1,20 @@
1
+ import { View, layout } from "jops-core";
2
+
3
+ // Home screen: shows what the main process reports through window.api.appInfo().
4
+ export default class HomeView extends View {
5
+ async onLayout() {
6
+ if (!window.api) {
7
+ this.get("app_info").set(layout`<dt>Desktop app only</dt><dd>Run it with npm start.</dd>`);
8
+ return;
9
+ }
10
+ const info = await window.api.appInfo();
11
+ const rows = [
12
+ ["App", `${info.name} ${info.version}`],
13
+ ["Electron", info.electron],
14
+ ["Chrome", info.chrome],
15
+ ["Node.js", info.node],
16
+ ["Platform", info.platform],
17
+ ];
18
+ this.get("app_info").set(rows.map(([name, value]) => layout`<dt>${name}</dt><dd>${value}</dd>`).join(""));
19
+ }
20
+ }
@@ -0,0 +1,151 @@
1
+ :root {
2
+ --bg: #f5f6f8;
3
+ --sidebar-bg: #eceef2;
4
+ --surface: #ffffff;
5
+ --border: #e1e4ea;
6
+ --text: #1d2330;
7
+ --text-muted: #6b7385;
8
+ --accent: #3b6ef5;
9
+ --hover: rgba(0, 0, 0, 0.05);
10
+ color-scheme: light dark;
11
+ }
12
+
13
+ @media (prefers-color-scheme: dark) {
14
+ :root {
15
+ --bg: #17191e;
16
+ --sidebar-bg: #1f2228;
17
+ --surface: #23262d;
18
+ --border: #33373f;
19
+ --text: #e7e9ee;
20
+ --text-muted: #9097a5;
21
+ --accent: #5b86ff;
22
+ --hover: rgba(255, 255, 255, 0.06);
23
+ }
24
+ }
25
+
26
+ * {
27
+ box-sizing: border-box;
28
+ }
29
+
30
+ html,
31
+ body {
32
+ height: 100%;
33
+ margin: 0;
34
+ }
35
+
36
+ body {
37
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
38
+ font-size: 14px;
39
+ color: var(--text);
40
+ background: var(--bg);
41
+ overflow: hidden;
42
+ }
43
+
44
+ /* App shell: fixed sidebar, the Router fills the rest of the window */
45
+ .app {
46
+ display: flex;
47
+ height: 100vh;
48
+ }
49
+
50
+ .sidebar {
51
+ display: flex;
52
+ flex-direction: column;
53
+ gap: 2px;
54
+ width: 200px;
55
+ padding: 16px 10px;
56
+ background: var(--sidebar-bg);
57
+ border-right: 1px solid var(--border);
58
+ user-select: none;
59
+ }
60
+
61
+ .brand {
62
+ padding: 4px 10px 14px;
63
+ font-weight: 600;
64
+ }
65
+
66
+ .nav-link {
67
+ padding: 7px 10px;
68
+ border-radius: 6px;
69
+ color: var(--text);
70
+ text-decoration: none;
71
+ }
72
+
73
+ .nav-link:hover {
74
+ background: var(--hover);
75
+ }
76
+
77
+ .nav-link.active {
78
+ background: var(--hover);
79
+ font-weight: 600;
80
+ }
81
+
82
+ /* <Router jops screens class="content">: each route is a .jops-screen that fills
83
+ the router and scrolls on its own. */
84
+ .content {
85
+ flex: 1;
86
+ min-width: 0;
87
+ --jops-screen-bg: var(--bg);
88
+ }
89
+
90
+ .page {
91
+ max-width: 760px;
92
+ padding: 24px 28px 32px;
93
+ }
94
+
95
+ h1 {
96
+ margin: 0 0 4px;
97
+ font-size: 20px;
98
+ }
99
+
100
+ .muted {
101
+ color: var(--text-muted);
102
+ }
103
+
104
+ .card {
105
+ margin-top: 16px;
106
+ background: var(--surface);
107
+ border: 1px solid var(--border);
108
+ border-radius: 10px;
109
+ }
110
+
111
+ .info {
112
+ display: grid;
113
+ grid-template-columns: max-content 1fr;
114
+ gap: 8px 24px;
115
+ padding: 16px 20px;
116
+ }
117
+
118
+ .info dt {
119
+ color: var(--text-muted);
120
+ }
121
+
122
+ .info dd {
123
+ margin: 0;
124
+ }
125
+
126
+ .table {
127
+ width: 100%;
128
+ border-collapse: separate;
129
+ border-spacing: 0;
130
+ overflow: hidden;
131
+ }
132
+
133
+ .table th,
134
+ .table td {
135
+ padding: 8px 14px;
136
+ text-align: left;
137
+ border-bottom: 1px solid var(--border);
138
+ }
139
+
140
+ .table tr:last-child td {
141
+ border-bottom: none;
142
+ }
143
+
144
+ .table th {
145
+ font-weight: 600;
146
+ color: var(--text-muted);
147
+ }
148
+
149
+ .table .num {
150
+ text-align: right;
151
+ }
@@ -0,0 +1,20 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <!-- Only the app's own files; 'unsafe-inline' for the import map and the on*
6
+ attributes that JOPS binds, blob: for Thread workers. -->
7
+ <meta
8
+ http-equiv="Content-Security-Policy"
9
+ content="default-src 'self'; script-src 'self' 'unsafe-inline' blob:; style-src 'self' 'unsafe-inline'; img-src 'self' data:"
10
+ />
11
+ <title>Electron JOPS App</title>
12
+ <script type="importmap">
13
+ { "imports": { "jops-core": "./lib/jops-core.min.js" } }
14
+ </script>
15
+ <link rel="stylesheet" href="index.css" />
16
+ </head>
17
+ <body>
18
+ <script type="module" src="lib/jops-core.min.js" data-app="App.js"></script>
19
+ </body>
20
+ </html>
@@ -0,0 +1,12 @@
1
+ <section class="page">
2
+ <h1>Files</h1>
3
+ <p id="files_dir" class="muted"></p>
4
+ <table class="card table">
5
+ <thead>
6
+ <tr><th>Name</th><th>Type</th><th class="num">Size</th></tr>
7
+ </thead>
8
+ <tbody id="files_list">
9
+ <tr><td colspan="3" class="muted">Loading…</td></tr>
10
+ </tbody>
11
+ </table>
12
+ </section>
@@ -0,0 +1,7 @@
1
+ <section class="page">
2
+ <h1>Welcome</h1>
3
+ <p class="muted">A JOPS SPA running as the UI of an Electron app.</p>
4
+ <dl id="app_info" class="card info">
5
+ <dt>Loading…</dt>
6
+ </dl>
7
+ </section>
@@ -0,0 +1,12 @@
1
+ <div class="app">
2
+ <nav class="sidebar">
3
+ <div class="brand">JOPS · Electron</div>
4
+ <a class="nav-link" href="#home">Home</a>
5
+ <a class="nav-link" href="#files">Files</a>
6
+ </nav>
7
+
8
+ <Router jops screens class="content">
9
+ <HomeView jops src="HomeView.js" layout="layout/home_view.html" path="/home"></HomeView>
10
+ <FilesView jops src="FilesView.js" layout="layout/files_view.html" path="/files"></FilesView>
11
+ </Router>
12
+ </div>
@@ -0,0 +1,73 @@
1
+ // Electron main process: creates the window and answers the page's IPC calls.
2
+ const { app, BrowserWindow, ipcMain, nativeTheme } = require("electron");
3
+ const path = require("node:path");
4
+ const fs = require("node:fs/promises");
5
+
6
+ // Windows installer (Squirrel): quit while it creates or removes shortcuts.
7
+ if (require("electron-squirrel-startup")) app.quit();
8
+
9
+ // ─── IPC handlers (exposed to the page by preload.js) ────────────────────────
10
+
11
+ // "app:info": versions and platform of the running app.
12
+ function appInfo() {
13
+ return {
14
+ name: app.getName(),
15
+ version: app.getVersion(),
16
+ electron: process.versions.electron,
17
+ chrome: process.versions.chrome,
18
+ node: process.versions.node,
19
+ platform: `${process.platform} ${process.arch}`,
20
+ };
21
+ }
22
+
23
+ // "files:list": the folder the app was started from. fs.readdir works the
24
+ // same on macOS, Windows and Linux. Hidden and unreadable entries are left out.
25
+ async function listFiles() {
26
+ const dir = process.cwd();
27
+ const names = (await fs.readdir(dir))
28
+ .filter((name) => !name.startsWith("."))
29
+ .sort((a, b) => a.localeCompare(b))
30
+ .slice(0, 500);
31
+ const entries = [];
32
+ for (const name of names) {
33
+ try {
34
+ const stats = await fs.stat(path.join(dir, name));
35
+ entries.push({ name, folder: stats.isDirectory(), size: stats.size });
36
+ } catch {
37
+ // Broken symlink or no permission.
38
+ }
39
+ }
40
+ return { dir, entries };
41
+ }
42
+
43
+ // ─── window ──────────────────────────────────────────────────────────────────
44
+
45
+ function createWindow() {
46
+ const win = new BrowserWindow({
47
+ width: 960,
48
+ height: 640,
49
+ // Same as --bg in index.css, so the window is never white before the first paint.
50
+ backgroundColor: nativeTheme.shouldUseDarkColors ? "#17191e" : "#f5f6f8",
51
+ // Electron's secure defaults stay on: contextIsolation, sandbox, no nodeIntegration.
52
+ webPreferences: {
53
+ preload: path.join(__dirname, "preload.js"),
54
+ },
55
+ });
56
+ // file:// page: every path JOPS resolves is page-relative (no leading ./ or /).
57
+ win.loadFile(path.join(__dirname, "index.html"));
58
+ }
59
+
60
+ app.whenReady().then(() => {
61
+ ipcMain.handle("app:info", appInfo);
62
+ ipcMain.handle("files:list", listFiles);
63
+ createWindow();
64
+ // macOS: re-create the window when the dock icon is clicked.
65
+ app.on("activate", () => {
66
+ if (BrowserWindow.getAllWindows().length === 0) createWindow();
67
+ });
68
+ });
69
+
70
+ // Quit when all windows are closed, except on macOS.
71
+ app.on("window-all-closed", () => {
72
+ if (process.platform !== "darwin") app.quit();
73
+ });
@@ -0,0 +1,10 @@
1
+ // The bridge between the page and the main process. Only these named
2
+ // functions are exposed to the page, as window.api.*.
3
+ const { contextBridge, ipcRenderer } = require("electron");
4
+
5
+ contextBridge.exposeInMainWorld("api", {
6
+ // { name, version, electron, chrome, node, platform }
7
+ appInfo: () => ipcRenderer.invoke("app:info"),
8
+ // { dir, entries: [{ name, folder, size }] }
9
+ listFiles: () => ipcRenderer.invoke("files:list"),
10
+ });