jops-core 1.1.4 → 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 +130 -6
- package/bin/init.js +342 -52
- package/package.json +4 -2
- package/templates/electron-jops-app/README.md +27 -0
- package/templates/electron-jops-app/forge.config.js +28 -0
- package/templates/electron-jops-app/gitignore +13 -0
- package/templates/electron-jops-app/package.json +29 -0
- package/templates/electron-jops-app/src/App.js +24 -0
- package/templates/electron-jops-app/src/FilesView.js +48 -0
- package/templates/electron-jops-app/src/HomeView.js +20 -0
- package/templates/electron-jops-app/src/index.css +151 -0
- package/templates/electron-jops-app/src/index.html +20 -0
- package/templates/electron-jops-app/src/layout/files_view.html +12 -0
- package/templates/electron-jops-app/src/layout/home_view.html +7 -0
- package/templates/electron-jops-app/src/layout/main.html +12 -0
- package/templates/electron-jops-app/src/main.js +73 -0
- package/templates/electron-jops-app/src/preload.js +10 -0
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,
|
|
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 |
|
|
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:
|
|
@@ -842,6 +965,7 @@ Add the `screens` attribute to give the Router a container of its own, with ever
|
|
|
842
965
|
- The `<Router>` tag is replaced by `<div class="jops-router">` (plus the tag's own `id` and `class`), styled `position: relative; overflow: hidden`. Its routes are placed inside it, not in the tag's parent element.
|
|
843
966
|
- **Give the container its size in your CSS**, e.g. fill the rest of a flex column: `.jops-router { flex: 1; min-height: 0; }`. Without a height it has none, and nothing is visible.
|
|
844
967
|
- Each route and each view opened with `navigate()` or `replace()` is wrapped in a `<div class="jops-screen">` that fills the container (`position: absolute; inset: 0`), **scrolls on its own** (`overflow: auto`) and has an opaque background. Set the background with the `--jops-screen-bg` CSS variable (default `Canvas`, the browser's page colour in light and dark mode): `.jops-router { --jops-screen-bg: var(--bg); }`.
|
|
968
|
+
- **Styling screens:** `.jops-screen` and `.jops-router` are hooks for your own CSS, e.g. padding inside every screen: `.jops-screen { padding: 0 24px 32px; }`. The properties the Router sets inline (`position`, `inset`, `overflow` and `background` on screens; `position` and `overflow` on the container) take precedence over your stylesheet; use `--jops-screen-bg` for the background.
|
|
845
969
|
- A new view **covers** the current one, which stays alive underneath. During the transition both are visible; afterwards the covered screen is `visibility: hidden` and `inert` (not painted, not focusable), so it keeps its scroll position and is shown again unchanged on Back. Inactive flat routes are hidden the same way.
|
|
846
970
|
- `slide` and `fade` move only the upper screen (the new one forward, the leaving one on Back), and also work for views with several top-level elements.
|
|
847
971
|
- Without `screens` (the default), routes are placed in the parent element and scroll with the page, as before.
|
|
@@ -986,12 +1110,12 @@ t.terminate();
|
|
|
986
1110
|
|
|
987
1111
|
| Command | Description |
|
|
988
1112
|
| ---------------------- | -------------------------------------------------------------------------- |
|
|
989
|
-
| `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
|
|
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 |
|
|
990
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)) |
|
|
991
|
-
| `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 |
|
|
992
|
-
| `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) |
|
|
993
1117
|
|
|
994
|
-
`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`).
|
|
995
1119
|
|
|
996
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.
|
|
997
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
|
|
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
|
-
|
|
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(
|
|
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.
|
|
293
|
-
"
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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,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,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
|
+
});
|