@thunderclap-engine/host-electron 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +137 -2
- package/dist/bridge.d.ts +53 -0
- package/dist/bridge.js +26 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +6 -0
- package/dist/renderer.d.ts +27 -0
- package/dist/renderer.js +102 -0
- package/out/main.mjs +381 -0
- package/out/preload.cjs +48 -0
- package/package.json +75 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mandalorian007 / ThunderClap Gaming
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,138 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @thunderclap-engine/host-electron
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The ThunderClap desktop shell. A game's web build runs unchanged inside Electron, and the `Host`
|
|
4
|
+
interface (`@thunderclap-engine/engine/host`) is the only seam between them (D-041, Phase 4). The package
|
|
5
|
+
contains no game nouns. The engine never imports it, and it reaches the engine only through the
|
|
6
|
+
engine's exports map (`pnpm check:boundaries` enforces both).
|
|
7
|
+
|
|
8
|
+
## Layout
|
|
9
|
+
|
|
10
|
+
| Path | What it is |
|
|
11
|
+
|---|---|
|
|
12
|
+
| `src/index.ts` | What a game imports: `createElectronHost`, `electronBridge`, `inElectronShell`, `ThunderclapBridge` |
|
|
13
|
+
| `src/renderer.ts` | `createElectronHost()`, which satisfies `Host` over the bridge |
|
|
14
|
+
| `src/bridge.ts` | The bridge type and its IPC channel names, shared by all three sides and importing nothing |
|
|
15
|
+
| `src/preload/preload.ts` | The sandboxed preload that exposes `window.thunderclap`; built to `out/preload.cjs` |
|
|
16
|
+
| `src/main/main.ts` | The main process: window, WebGPU switches, `app://` protocol, network block, IPC; built to `out/main.mjs` |
|
|
17
|
+
| `src/main/policy.ts`, `switches.ts` | The main process's rules as pure, unit-tested functions |
|
|
18
|
+
| `harness/` | A noun-free page: boots WebGPU, clears each frame, publishes `window.__desktop`. It is the default renderer |
|
|
19
|
+
| `e2e/shell.spec.ts` | The Playwright Electron-driver smoke on the built shell |
|
|
20
|
+
| `scripts/build.mjs`, `dev.mjs`, `dist.mjs` | Build `out/`, the dev loop, and packaging |
|
|
21
|
+
| `electron-builder.config.mjs` | Unsigned Windows, macOS and Linux packaging into `dist/desktop/` at the repo root (gitignored) |
|
|
22
|
+
|
|
23
|
+
## Using it from a game
|
|
24
|
+
|
|
25
|
+
```ts
|
|
26
|
+
import { createWebHost, setDefaultHost } from '@thunderclap-engine/engine/host';
|
|
27
|
+
import { createElectronHost, inElectronShell } from '@thunderclap-engine/host-electron';
|
|
28
|
+
|
|
29
|
+
const host = inElectronShell() ? createElectronHost() : createWebHost();
|
|
30
|
+
setDefaultHost(host); // or pass it to createGame / the game's ctx
|
|
31
|
+
host.window?.close(); // a desktop-only Quit entry
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
A game does this in one boot file with a dynamic import, so its web build never loads this package, and shows Quit only when `host.window` exists. The Last Rune does exactly this.
|
|
35
|
+
|
|
36
|
+
## The bridge: `window.thunderclap`
|
|
37
|
+
|
|
38
|
+
The preload runs with `sandbox: true`, `contextIsolation: true` and `nodeIntegration: false`. The page gets no Node, no `require` and no `ipcRenderer`. It gets only these members:
|
|
39
|
+
|
|
40
|
+
| Member | Main process |
|
|
41
|
+
|---|---|
|
|
42
|
+
| `version` (1), `platform` | Read-only values |
|
|
43
|
+
| `openExternal(url): Promise<boolean>` | `shell.openExternal`, only after `safeExternalHttps` passes: plain `https:`, no credentials, no whitespace. This is the same rule as the engine's `safeExternalUrl` (a unit test keeps them equal) |
|
|
44
|
+
| `copyText(text): Promise<boolean>` | `clipboard.writeText` |
|
|
45
|
+
| `fullscreen.get()`, `set(on)`, `onChange(fn)` | `BrowserWindow` fullscreen; `set` waits (up to 2 s) for macOS's animation |
|
|
46
|
+
| `window.minimize()`, `toggleMaximize()`, `close()`, `setTitle(t)` | The caller's `BrowserWindow` |
|
|
47
|
+
|
|
48
|
+
Every handler checks that the call came from the main frame of the shell's window on the page's own origin, and it checks every argument's type.
|
|
49
|
+
|
|
50
|
+
`createElectronHost()` maps the bridge onto `Host`:
|
|
51
|
+
|
|
52
|
+
- `kind: 'electron'`; `embed` is detected, so it is `top` in the shell.
|
|
53
|
+
- `openExternal` returns `invalid` for a bad URL without calling the bridge. Otherwise it returns `opened` for every activation, because a desktop window has no popup blocker.
|
|
54
|
+
- `copyText` returns `copied` when the bridge succeeds, else `manual`. `clipboardWriteAllowed()` is `true`.
|
|
55
|
+
- `fullscreen.active` is a synchronous getter and kept up to date by the main process's change events.
|
|
56
|
+
- `window` exposes `close`, `minimize`, `toggleMaximize` and `setTitle`. `HostWindow.setTitle?` was added to the engine for it.
|
|
57
|
+
|
|
58
|
+
## WebGPU switches
|
|
59
|
+
|
|
60
|
+
These are set with `app.commandLine.appendSwitch` before `ready`, so the build does not depend on what this Electron's Chromium enables by default (`src/main/switches.ts`):
|
|
61
|
+
|
|
62
|
+
| OS | Switches | Dawn backend |
|
|
63
|
+
|---|---|---|
|
|
64
|
+
| macOS | `--enable-unsafe-webgpu` | Metal |
|
|
65
|
+
| Windows | `--enable-unsafe-webgpu` | D3D12 |
|
|
66
|
+
| Linux | `--enable-unsafe-webgpu`, `--enable-features=Vulkan` | Vulkan |
|
|
67
|
+
|
|
68
|
+
`enable-unsafe-webgpu` turns WebGPU on where Chromium has not shipped it (Linux) and skips Chromium's WebGPU adapter blocklist. The game is WebGPU-only, so on a blocklisted adapter the alternative is no game at all.
|
|
69
|
+
|
|
70
|
+
For unpackaged runs only, `TC_GPU_SWITCHES="--a --b=c"` replaces the switches, for example to use SwiftShader on a headless Linux runner.
|
|
71
|
+
|
|
72
|
+
Electron 45.2.2 (Chromium 156) was verified on macOS arm64: the e2e and the packaged app both reach `gpu: ok` with `bgra8unorm`. Windows and Linux are built in CI but have not been run on hardware yet.
|
|
73
|
+
|
|
74
|
+
## Storage
|
|
75
|
+
|
|
76
|
+
Storage is the renderer's `localStorage`: the same backend, probe (`probeStorage`) and save format as the web host. This was a PM call, because the user wants browser-only storage for now.
|
|
77
|
+
|
|
78
|
+
The shell serves the page from one fixed origin, `app://bundle` (a privileged `standard` and `secure` scheme), so saves stay put across launches and updates. The data lives in Electron's `userData` folder, which is named from the product name (`extraMetadata.productName`). For that reason the scheme, the host and the product name must never change after a release.
|
|
79
|
+
|
|
80
|
+
There is one instance per `userData` (`requestSingleInstanceLock`), because a second instance would find `localStorage` locked.
|
|
81
|
+
|
|
82
|
+
**Open question (for the user): a file backend.** A `StorageLike` over a JSON file in `app.getPath('userData')` would make saves visible, easy to back up and independent of Chromium's LevelDB. A design for it:
|
|
83
|
+
|
|
84
|
+
- Main process: one file per save key under `userData/saves/`, written atomically (write a temp file, then rename). Synchronous reads at start through a preloaded snapshot.
|
|
85
|
+
- Bridge: `storage.snapshot()` once at boot, then `storage.set(key, value)` and `storage.remove(key)` as fire-and-forget IPC.
|
|
86
|
+
- Renderer: an in-memory `StorageLike` seeded from the snapshot and written through. `kind: 'file'`.
|
|
87
|
+
|
|
88
|
+
The versioning stays `engine/save`'s, unchanged. Moving existing saves would be a one-time copy out of `localStorage` on first launch. Nothing of this is built. Decide before a desktop release whether saves should follow the player outside the app.
|
|
89
|
+
|
|
90
|
+
## Security and network
|
|
91
|
+
|
|
92
|
+
- **No network at runtime.** The session cancels every request that is not the page's own origin (`app://bundle`, or the local dev server and its HMR socket in `desktop:dev`), `data:`, `blob:` or `devtools:`. Every `app://` response also carries a CSP with `connect-src 'self'`, `script-src 'self'` and no inline script. Spellcheck is off, so no dictionaries are downloaded.
|
|
93
|
+
- **Local files only.** `app://bundle/<path>` serves files from `out/renderer` (inside the asar), with a path guard (`bundleFile`). Unknown routes fall back to `index.html`.
|
|
94
|
+
- **No new windows, webviews, or navigation away.** A new-window request for a plain https URL from the page itself opens in the system browser through `shell.openExternal`, under the same rule as the bridge. That covers a `target="_blank"` link, such as the engine's link widget on a click or Enter. Every other new-window request is refused. Permission prompts are denied, except HTML fullscreen.
|
|
95
|
+
- **DevTools only when unpackaged.** On macOS the app has a minimal menu (app, edit, window). Elsewhere there is no menu bar.
|
|
96
|
+
- `TC_RENDERER_URL`, `TC_RENDERER_DIR`, `TC_USER_DATA` and `TC_GPU_SWITCHES` are honoured only when unpackaged.
|
|
97
|
+
|
|
98
|
+
## Scripts
|
|
99
|
+
|
|
100
|
+
From the repo root:
|
|
101
|
+
|
|
102
|
+
| Command | What it does |
|
|
103
|
+
|---|---|
|
|
104
|
+
| `pnpm desktop:dev` | Builds main and preload, serves `harness/` with Vite on 127.0.0.1:5199, and opens it in Electron with hot reload. `pnpm desktop:dev --url http://localhost:5173` uses a dev server you already run instead |
|
|
105
|
+
| `pnpm desktop:build` | This package's `dist`: builds `out/` and packages for this OS into `dist/desktop/` at the repo root (gitignored). Flags: `--mac`, `--win`, `--linux`; `--dir` (unpacked only); `--renderer <dir>` (package another built web app, such as a game's `dist/`, instead of the harness; any `dist/desktop/` inside it is skipped) |
|
|
106
|
+
| `pnpm test:e2e:desktop` | Builds `out/` and runs `e2e/shell.spec.ts` with Playwright's Electron driver, a Playwright run of the shell on the desktop harness page |
|
|
107
|
+
| `pnpm check:host-electron` | Typecheck, lint and unit tests (also part of `pnpm check`) |
|
|
108
|
+
|
|
109
|
+
Electron downloads its binary on first use, not at install. electron-builder downloads its own copy when packaging. Both downloads happen at build time only.
|
|
110
|
+
|
|
111
|
+
## Packaging
|
|
112
|
+
|
|
113
|
+
The build is unsigned: no certificate, key or notarisation credential is used or read, and `CSC_IDENTITY_AUTO_DISCOVERY` is forced off.
|
|
114
|
+
|
|
115
|
+
| OS | Outputs |
|
|
116
|
+
|---|---|
|
|
117
|
+
| macOS | `.dmg` and `.zip` for the host arch. Ad-hoc signed (`identity: '-'`), so Apple silicon runs it. Gatekeeper still warns on first open |
|
|
118
|
+
| Windows | NSIS installer (`.exe`) and `.zip` (x64). Unsigned, so SmartScreen warns |
|
|
119
|
+
| Linux | `.AppImage` and `.tar.gz` (x64) |
|
|
120
|
+
|
|
121
|
+
The identity defaults to `ThunderClap` / `dev.thunderclap.shell`, with Electron's default icon. A game sets its own identity with these variables:
|
|
122
|
+
|
|
123
|
+
- `TC_PRODUCT_NAME` and `TC_APP_ID`;
|
|
124
|
+
- `TC_APP_VERSION`;
|
|
125
|
+
- `TC_ICON`: an absolute path to a PNG of at least 512x512. electron-builder makes the `.icns` and `.ico` from it.
|
|
126
|
+
|
|
127
|
+
Artefacts are named `<product name, spaces as dashes>-<version>-<os>-<arch>.<ext>`.
|
|
128
|
+
|
|
129
|
+
A game's own repo sets these when it calls `dist` with `--renderer`; this repo only builds the harness shell.
|
|
130
|
+
|
|
131
|
+
The CI job `desktop` in `.github/workflows/ci.yml` builds the harness shell for all three OSes and uploads the artefacts. It is non-blocking.
|
|
132
|
+
|
|
133
|
+
## Open questions
|
|
134
|
+
|
|
135
|
+
- **Code signing and notarisation** (macOS Developer ID, Windows Authenticode). These wait until the user supplies certificates. The config has the hooks, but no material belongs in the repo; it would go in CI secrets.
|
|
136
|
+
- **A file storage backend** (see Storage).
|
|
137
|
+
- **Linux AppImage on Ubuntu 24.04+**, where AppArmor restricts unprivileged user namespaces that Chromium's sandbox uses. It may need `--no-sandbox` or a `.deb` with an AppArmor profile. Not yet tested on a machine.
|
|
138
|
+
- **Auto-update.** There is none, and it would mean a runtime network call. It is out of scope unless the user asks for it.
|
package/dist/bridge.d.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The preload bridge: what the Electron shell exposes to the page as `window.thunderclap`
|
|
3
|
+
* (src/preload/preload.ts), and the IPC channel names behind it. Shared by the main process,
|
|
4
|
+
* the preload and the renderer, so it imports nothing.
|
|
5
|
+
*
|
|
6
|
+
* The page never gets Node, `ipcRenderer` or any Electron module, only these functions. Every
|
|
7
|
+
* call is checked again in the main process (src/main/ipc.ts): links must be plain `https:`,
|
|
8
|
+
* text must be a string, and only the shell's own window may call.
|
|
9
|
+
*/
|
|
10
|
+
/** Bumped when a member is removed or changes meaning; adding one does not bump it. */
|
|
11
|
+
export declare const BRIDGE_VERSION = 1;
|
|
12
|
+
/** The global the preload defines. */
|
|
13
|
+
export declare const BRIDGE_GLOBAL = "thunderclap";
|
|
14
|
+
export interface ThunderclapBridge {
|
|
15
|
+
readonly version: number;
|
|
16
|
+
/** `process.platform` of the shell: `darwin`, `win32` or `linux`. */
|
|
17
|
+
readonly platform: string;
|
|
18
|
+
/**
|
|
19
|
+
* Opens a plain `https:` URL in the user's browser (`shell.openExternal`). Resolves false
|
|
20
|
+
* when the main process refused it (not https, credentials, whitespace) or the OS failed.
|
|
21
|
+
*/
|
|
22
|
+
openExternal(url: string): Promise<boolean>;
|
|
23
|
+
/** Writes `text` to the system clipboard (`clipboard.writeText`). */
|
|
24
|
+
copyText(text: string): Promise<boolean>;
|
|
25
|
+
readonly fullscreen: {
|
|
26
|
+
/** Whether the window is fullscreen now. */
|
|
27
|
+
get(): Promise<boolean>;
|
|
28
|
+
/** Enters or leaves fullscreen; resolves to the state afterwards. */
|
|
29
|
+
set(on: boolean): Promise<boolean>;
|
|
30
|
+
/** Calls `fn` whenever the window enters or leaves fullscreen. Returns unsubscribe. */
|
|
31
|
+
onChange(fn: (on: boolean) => void): () => void;
|
|
32
|
+
};
|
|
33
|
+
readonly window: {
|
|
34
|
+
minimize(): void;
|
|
35
|
+
toggleMaximize(): void;
|
|
36
|
+
/** Closes the window; the shell quits when it was the last one. */
|
|
37
|
+
close(): void;
|
|
38
|
+
setTitle(title: string): void;
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
/** IPC channels, all prefixed `tc:`. `invoke` channels answer; `send` channels do not. */
|
|
42
|
+
export declare const CH: {
|
|
43
|
+
readonly openExternal: 'tc:open-external';
|
|
44
|
+
readonly copyText: 'tc:copy-text';
|
|
45
|
+
readonly fullscreenGet: 'tc:fullscreen-get';
|
|
46
|
+
readonly fullscreenSet: 'tc:fullscreen-set';
|
|
47
|
+
/** main → renderer: the new fullscreen state. */
|
|
48
|
+
readonly fullscreenChanged: 'tc:fullscreen-changed';
|
|
49
|
+
readonly minimize: 'tc:window-minimize';
|
|
50
|
+
readonly toggleMaximize: 'tc:window-toggle-maximize';
|
|
51
|
+
readonly close: 'tc:window-close';
|
|
52
|
+
readonly setTitle: 'tc:window-set-title';
|
|
53
|
+
};
|
package/dist/bridge.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The preload bridge: what the Electron shell exposes to the page as `window.thunderclap`
|
|
3
|
+
* (src/preload/preload.ts), and the IPC channel names behind it. Shared by the main process,
|
|
4
|
+
* the preload and the renderer, so it imports nothing.
|
|
5
|
+
*
|
|
6
|
+
* The page never gets Node, `ipcRenderer` or any Electron module, only these functions. Every
|
|
7
|
+
* call is checked again in the main process (src/main/ipc.ts): links must be plain `https:`,
|
|
8
|
+
* text must be a string, and only the shell's own window may call.
|
|
9
|
+
*/
|
|
10
|
+
/** Bumped when a member is removed or changes meaning; adding one does not bump it. */
|
|
11
|
+
export const BRIDGE_VERSION = 1;
|
|
12
|
+
/** The global the preload defines. */
|
|
13
|
+
export const BRIDGE_GLOBAL = 'thunderclap';
|
|
14
|
+
/** IPC channels, all prefixed `tc:`. `invoke` channels answer; `send` channels do not. */
|
|
15
|
+
export const CH = {
|
|
16
|
+
openExternal: 'tc:open-external',
|
|
17
|
+
copyText: 'tc:copy-text',
|
|
18
|
+
fullscreenGet: 'tc:fullscreen-get',
|
|
19
|
+
fullscreenSet: 'tc:fullscreen-set',
|
|
20
|
+
/** main → renderer: the new fullscreen state. */
|
|
21
|
+
fullscreenChanged: 'tc:fullscreen-changed',
|
|
22
|
+
minimize: 'tc:window-minimize',
|
|
23
|
+
toggleMaximize: 'tc:window-toggle-maximize',
|
|
24
|
+
close: 'tc:window-close',
|
|
25
|
+
setTitle: 'tc:window-set-title',
|
|
26
|
+
};
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@thunderclap-engine/host-electron`, renderer side: what a game imports. The main process and the
|
|
3
|
+
* preload are built into `out/` by scripts/build.mjs and are not imported by games.
|
|
4
|
+
*/
|
|
5
|
+
export { BRIDGE_VERSION, type ThunderclapBridge } from './bridge.js';
|
|
6
|
+
export { createElectronHost, type ElectronHostOptions, electronBridge, inElectronShell, } from './renderer.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@thunderclap-engine/host-electron`, renderer side: what a game imports. The main process and the
|
|
3
|
+
* preload are built into `out/` by scripts/build.mjs and are not imported by games.
|
|
4
|
+
*/
|
|
5
|
+
export { BRIDGE_VERSION } from './bridge.js';
|
|
6
|
+
export { createElectronHost, electronBridge, inElectronShell, } from './renderer.js';
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Electron host for the renderer: `Host` (engine/host, D-041) over the preload bridge
|
|
3
|
+
* `window.thunderclap` (src/bridge.ts). Links and the clipboard go through the main process;
|
|
4
|
+
* fullscreen and the window controls act on the shell's `BrowserWindow`.
|
|
5
|
+
*
|
|
6
|
+
* Storage is the renderer's `localStorage`, the same backend and probe as the web host (PM call,
|
|
7
|
+
* 2026-11-03: browser storage only for now). The shell serves the page from one fixed origin
|
|
8
|
+
* (`app://bundle`), so saves stay put across launches and updates. A file backend in `userData`
|
|
9
|
+
* is an open question; see README.md.
|
|
10
|
+
*/
|
|
11
|
+
import type { Host } from '@thunderclap-engine/engine/host';
|
|
12
|
+
import { type ThunderclapBridge } from './bridge.js';
|
|
13
|
+
export interface ElectronHostOptions {
|
|
14
|
+
/** The bridge. Default: `window.thunderclap`. Tests pass a fake. */
|
|
15
|
+
bridge?: ThunderclapBridge;
|
|
16
|
+
/** The window whose `localStorage` holds saves. Default: the global `window`. */
|
|
17
|
+
win?: Window;
|
|
18
|
+
}
|
|
19
|
+
/** The preload bridge when this page runs inside the ThunderClap shell, else null. */
|
|
20
|
+
export declare function electronBridge(win?: Window): ThunderclapBridge | null;
|
|
21
|
+
/** True when the page runs inside the ThunderClap Electron shell. */
|
|
22
|
+
export declare function inElectronShell(win?: Window): boolean;
|
|
23
|
+
/**
|
|
24
|
+
* Builds the Electron host. Throws when there is no bridge (the page is not in the shell);
|
|
25
|
+
* check `inElectronShell()` first and fall back to `createWebHost()`.
|
|
26
|
+
*/
|
|
27
|
+
export declare function createElectronHost(opts?: ElectronHostOptions): Host;
|
package/dist/renderer.js
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { embedContext, safeExternalUrl } from '@thunderclap-engine/engine/platform';
|
|
2
|
+
import { probeStorage } from '@thunderclap-engine/engine/save';
|
|
3
|
+
import { BRIDGE_GLOBAL } from './bridge.js';
|
|
4
|
+
/** The preload bridge when this page runs inside the ThunderClap shell, else null. */
|
|
5
|
+
export function electronBridge(win = window) {
|
|
6
|
+
const b = win[BRIDGE_GLOBAL];
|
|
7
|
+
return b && typeof b.openExternal === 'function' ? b : null;
|
|
8
|
+
}
|
|
9
|
+
/** True when the page runs inside the ThunderClap Electron shell. */
|
|
10
|
+
export function inElectronShell(win = window) {
|
|
11
|
+
return electronBridge(win) !== null;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Builds the Electron host. Throws when there is no bridge (the page is not in the shell);
|
|
15
|
+
* check `inElectronShell()` first and fall back to `createWebHost()`.
|
|
16
|
+
*/
|
|
17
|
+
export function createElectronHost(opts = {}) {
|
|
18
|
+
const win = opts.win ?? window;
|
|
19
|
+
const bridge = opts.bridge ?? electronBridge(win);
|
|
20
|
+
if (!bridge) {
|
|
21
|
+
throw new Error('createElectronHost: no window.thunderclap bridge (not in the Electron shell)');
|
|
22
|
+
}
|
|
23
|
+
const fullscreen = createFullscreen(bridge);
|
|
24
|
+
return {
|
|
25
|
+
kind: 'electron',
|
|
26
|
+
// The shell's window is a Chromium page: frames come from its requestAnimationFrame.
|
|
27
|
+
requestFrame: (cb) => win.requestAnimationFrame(cb),
|
|
28
|
+
cancelFrame: (handle) => win.cancelAnimationFrame(handle),
|
|
29
|
+
embed: embedContext(win),
|
|
30
|
+
openExternal(url) {
|
|
31
|
+
// Checked here so a bad link is `invalid` at once, and again in the main process.
|
|
32
|
+
const href = safeExternalUrl(url);
|
|
33
|
+
if (!href)
|
|
34
|
+
return 'invalid';
|
|
35
|
+
// A desktop window has no popup blocker, so every activation (pad and script too) opens.
|
|
36
|
+
// The OS answer comes later; a failure there is logged by the main process.
|
|
37
|
+
void bridge.openExternal(href).catch(() => false);
|
|
38
|
+
return 'opened';
|
|
39
|
+
},
|
|
40
|
+
async copyText(text) {
|
|
41
|
+
try {
|
|
42
|
+
return (await bridge.copyText(String(text))) ? 'copied' : 'manual';
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return 'manual';
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
clipboardWriteAllowed: () => true,
|
|
49
|
+
fullscreen,
|
|
50
|
+
storage: {
|
|
51
|
+
kind: 'local',
|
|
52
|
+
// The same guarded probe as the web host: null when missing or failing a round trip.
|
|
53
|
+
open: () => probeStorage(() => win.localStorage),
|
|
54
|
+
},
|
|
55
|
+
window: {
|
|
56
|
+
close: () => bridge.window.close(),
|
|
57
|
+
minimize: () => bridge.window.minimize(),
|
|
58
|
+
toggleMaximize: () => bridge.window.toggleMaximize(),
|
|
59
|
+
setTitle: (title) => bridge.window.setTitle(String(title)),
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Fullscreen of the shell's window. `active` is a synchronous getter in `Host`, so the state is kept
|
|
65
|
+
* here: read once at start, then updated by the main process's change events and by `toggle`.
|
|
66
|
+
*/
|
|
67
|
+
function createFullscreen(bridge) {
|
|
68
|
+
let on = false;
|
|
69
|
+
const listeners = new Set();
|
|
70
|
+
const update = (next) => {
|
|
71
|
+
if (next === on)
|
|
72
|
+
return;
|
|
73
|
+
on = next;
|
|
74
|
+
for (const fn of [...listeners])
|
|
75
|
+
fn(on);
|
|
76
|
+
};
|
|
77
|
+
bridge.fullscreen.onChange((next) => update(!!next));
|
|
78
|
+
bridge.fullscreen
|
|
79
|
+
.get()
|
|
80
|
+
.then((v) => update(!!v))
|
|
81
|
+
.catch(() => { });
|
|
82
|
+
return {
|
|
83
|
+
available: () => true,
|
|
84
|
+
get active() {
|
|
85
|
+
return on;
|
|
86
|
+
},
|
|
87
|
+
async toggle() {
|
|
88
|
+
// `lockLandscape` has no meaning for a desktop window.
|
|
89
|
+
try {
|
|
90
|
+
update(!!(await bridge.fullscreen.set(!on)));
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
// Refused: leave things as they were.
|
|
94
|
+
}
|
|
95
|
+
return on;
|
|
96
|
+
},
|
|
97
|
+
watch(fn) {
|
|
98
|
+
listeners.add(fn);
|
|
99
|
+
return () => listeners.delete(fn);
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
}
|
package/out/main.mjs
ADDED
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { BrowserWindow, Menu, app, clipboard, ipcMain, protocol, session, shell } from "electron";
|
|
5
|
+
// packages/host-electron/src/bridge.ts
|
|
6
|
+
/** IPC channels, all prefixed `tc:`. `invoke` channels answer; `send` channels do not. */
|
|
7
|
+
var CH = {
|
|
8
|
+
openExternal: "tc:open-external",
|
|
9
|
+
copyText: "tc:copy-text",
|
|
10
|
+
fullscreenGet: "tc:fullscreen-get",
|
|
11
|
+
fullscreenSet: "tc:fullscreen-set",
|
|
12
|
+
/** main → renderer: the new fullscreen state. */
|
|
13
|
+
fullscreenChanged: "tc:fullscreen-changed",
|
|
14
|
+
minimize: "tc:window-minimize",
|
|
15
|
+
toggleMaximize: "tc:window-toggle-maximize",
|
|
16
|
+
close: "tc:window-close",
|
|
17
|
+
setTitle: "tc:window-set-title"
|
|
18
|
+
};
|
|
19
|
+
var APP_HOST = "bundle";
|
|
20
|
+
var APP_ORIGIN = `app://${APP_HOST}`;
|
|
21
|
+
/**
|
|
22
|
+
* The main process's copy of the engine's `safeExternalUrl` (platform/links.ts): the URL when
|
|
23
|
+
* it is a plain absolute `https:` link with no credentials and no whitespace, else null. It is
|
|
24
|
+
* checked again here because the bridge can be called with anything.
|
|
25
|
+
*/
|
|
26
|
+
function safeExternalHttps(url) {
|
|
27
|
+
if (typeof url !== "string") return null;
|
|
28
|
+
const t = url.trim();
|
|
29
|
+
if (!t || /\s/.test(t) || !/^https:\/\//i.test(t)) return null;
|
|
30
|
+
let u;
|
|
31
|
+
try {
|
|
32
|
+
u = new URL(t);
|
|
33
|
+
} catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
if (u.protocol !== "https:" || !u.hostname || u.username || u.password) return null;
|
|
37
|
+
return t;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* The file under `root` that `app://bundle/<path>` names, or null when the path leaves `root`
|
|
41
|
+
* (`..`, encoded or not) or is malformed. `/` and paths without an extension serve index.html,
|
|
42
|
+
* so a single-page app's routes resolve.
|
|
43
|
+
*/
|
|
44
|
+
function bundleFile(root, requestUrl) {
|
|
45
|
+
let u;
|
|
46
|
+
try {
|
|
47
|
+
u = new URL(requestUrl);
|
|
48
|
+
} catch {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
if (u.protocol !== `app:` || u.host !== "bundle") return null;
|
|
52
|
+
let path;
|
|
53
|
+
try {
|
|
54
|
+
path = decodeURIComponent(u.pathname);
|
|
55
|
+
} catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
if (path.includes("\0")) return null;
|
|
59
|
+
if (path === "/" || path === "" || !extname(path)) path = "/index.html";
|
|
60
|
+
const base = resolve(root);
|
|
61
|
+
const file = resolve(base, `.${path}`);
|
|
62
|
+
const rel = relative(base, file);
|
|
63
|
+
if (!rel || rel.startsWith(`..${sep}`) || rel === ".." || isAbsolute(rel)) return null;
|
|
64
|
+
return file;
|
|
65
|
+
}
|
|
66
|
+
var MIME = {
|
|
67
|
+
".html": "text/html; charset=utf-8",
|
|
68
|
+
".js": "text/javascript; charset=utf-8",
|
|
69
|
+
".mjs": "text/javascript; charset=utf-8",
|
|
70
|
+
".css": "text/css; charset=utf-8",
|
|
71
|
+
".json": "application/json; charset=utf-8",
|
|
72
|
+
".map": "application/json; charset=utf-8",
|
|
73
|
+
".wgsl": "text/plain; charset=utf-8",
|
|
74
|
+
".txt": "text/plain; charset=utf-8",
|
|
75
|
+
".svg": "image/svg+xml",
|
|
76
|
+
".png": "image/png",
|
|
77
|
+
".jpg": "image/jpeg",
|
|
78
|
+
".jpeg": "image/jpeg",
|
|
79
|
+
".webp": "image/webp",
|
|
80
|
+
".gif": "image/gif",
|
|
81
|
+
".ico": "image/x-icon",
|
|
82
|
+
".wasm": "application/wasm",
|
|
83
|
+
".woff": "font/woff",
|
|
84
|
+
".woff2": "font/woff2",
|
|
85
|
+
".ogg": "audio/ogg",
|
|
86
|
+
".mp3": "audio/mpeg",
|
|
87
|
+
".wav": "audio/wav",
|
|
88
|
+
".webmanifest": "application/manifest+json"
|
|
89
|
+
};
|
|
90
|
+
function mimeType(file) {
|
|
91
|
+
return MIME[extname(file).toLowerCase()] ?? "application/octet-stream";
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* The Content-Security-Policy on every `app://` response. Same-origin only: no request can
|
|
95
|
+
* leave the machine from the page, and no inline script runs. Inline styles are allowed
|
|
96
|
+
* because engine widgets set them.
|
|
97
|
+
*/
|
|
98
|
+
var CSP = [
|
|
99
|
+
"default-src 'self'",
|
|
100
|
+
"script-src 'self'",
|
|
101
|
+
"style-src 'self' 'unsafe-inline'",
|
|
102
|
+
"img-src 'self' data: blob:",
|
|
103
|
+
"font-src 'self' data:",
|
|
104
|
+
"media-src 'self' data: blob:",
|
|
105
|
+
"connect-src 'self'",
|
|
106
|
+
"worker-src 'self' blob:",
|
|
107
|
+
"object-src 'none'",
|
|
108
|
+
"base-uri 'self'",
|
|
109
|
+
"form-action 'none'",
|
|
110
|
+
"frame-ancestors 'none'"
|
|
111
|
+
].join("; ");
|
|
112
|
+
/**
|
|
113
|
+
* The dev server URL from `TC_RENDERER_URL`, when it is http(s) on this machine
|
|
114
|
+
* (`localhost`, `127.0.0.1` or `[::1]`), else null.
|
|
115
|
+
*/
|
|
116
|
+
function localDevUrl(url) {
|
|
117
|
+
if (!url) return null;
|
|
118
|
+
let u;
|
|
119
|
+
try {
|
|
120
|
+
u = new URL(url);
|
|
121
|
+
} catch {
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
if (u.protocol !== "http:" && u.protocol !== "https:") return null;
|
|
125
|
+
if (![
|
|
126
|
+
"localhost",
|
|
127
|
+
"127.0.0.1",
|
|
128
|
+
"[::1]"
|
|
129
|
+
].includes(u.hostname)) return null;
|
|
130
|
+
return u;
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Whether the renderer may make this request. Only the page's own origin (`app://bundle`, or
|
|
134
|
+
* the local dev server and its HMR socket) and in-memory URLs; everything that would reach
|
|
135
|
+
* the network is cancelled.
|
|
136
|
+
*/
|
|
137
|
+
function requestAllowed(url, dev) {
|
|
138
|
+
let u;
|
|
139
|
+
try {
|
|
140
|
+
u = new URL(url);
|
|
141
|
+
} catch {
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
switch (u.protocol) {
|
|
145
|
+
case `app:`: return u.host === APP_HOST;
|
|
146
|
+
case "data:":
|
|
147
|
+
case "blob:":
|
|
148
|
+
case "devtools:": return true;
|
|
149
|
+
case "http:":
|
|
150
|
+
case "https:":
|
|
151
|
+
case "ws:":
|
|
152
|
+
case "wss:": return !!dev && u.hostname === dev.hostname && u.port === dev.port;
|
|
153
|
+
default: return false;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Whether a top-level navigation to `url` stays on the page's own origin. Compared by scheme
|
|
158
|
+
* and host because `URL.origin` is the string "null" for every custom scheme such as `app:`.
|
|
159
|
+
*/
|
|
160
|
+
function navigationAllowed(url, start) {
|
|
161
|
+
try {
|
|
162
|
+
const a = new URL(url);
|
|
163
|
+
const b = new URL(start);
|
|
164
|
+
return a.protocol === b.protocol && a.host === b.host && !!a.host;
|
|
165
|
+
} catch {
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
// packages/host-electron/src/main/switches.ts
|
|
170
|
+
function webgpuSwitches(platform) {
|
|
171
|
+
const out = [["enable-unsafe-webgpu"]];
|
|
172
|
+
if (platform === "linux") out.push(["enable-features", "Vulkan"]);
|
|
173
|
+
return out;
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* `TC_GPU_SWITCHES` (unpackaged runs only, e.g. CI on SwiftShader): space-separated
|
|
177
|
+
* `--name` or `--name=value`, used instead of the defaults.
|
|
178
|
+
*/
|
|
179
|
+
function parseSwitches(text) {
|
|
180
|
+
const out = [];
|
|
181
|
+
for (const raw of text.split(/\s+/)) {
|
|
182
|
+
const t = raw.replace(/^--/, "");
|
|
183
|
+
if (!t) continue;
|
|
184
|
+
const eq = t.indexOf("=");
|
|
185
|
+
out.push(eq < 0 ? [t] : [t.slice(0, eq), t.slice(eq + 1)]);
|
|
186
|
+
}
|
|
187
|
+
return out;
|
|
188
|
+
}
|
|
189
|
+
// packages/host-electron/src/main/main.ts
|
|
190
|
+
/**
|
|
191
|
+
* The ThunderClap desktop shell's main process (D-041, Phase 4). It:
|
|
192
|
+
* - turns WebGPU on explicitly (switches.ts) before the app is ready;
|
|
193
|
+
* - serves the renderer bundle from `app://bundle/` (a fixed, secure origin, so `localStorage`
|
|
194
|
+
* saves stay put), or the local Vite dev server when `TC_RENDERER_URL` is set (unpackaged);
|
|
195
|
+
* - cancels every request that would leave the machine, denies new windows, navigation away,
|
|
196
|
+
* webviews and permission prompts;
|
|
197
|
+
* - answers the preload bridge (src/bridge.ts) after checking who called and with what.
|
|
198
|
+
*
|
|
199
|
+
* Unpackaged-only environment (ignored in a packaged build):
|
|
200
|
+
* - `TC_RENDERER_URL`: load this local dev server instead of the bundle (`pnpm desktop:dev`).
|
|
201
|
+
* - `TC_RENDERER_DIR`: serve this directory instead of `out/renderer`.
|
|
202
|
+
* - `TC_USER_DATA`: use this `userData` directory (tests isolate saves with it).
|
|
203
|
+
* - `TC_GPU_SWITCHES`: replace the WebGPU switches (e.g. SwiftShader on a headless runner).
|
|
204
|
+
*/
|
|
205
|
+
var here = dirname(fileURLToPath(import.meta.url));
|
|
206
|
+
var unpackaged = !app.isPackaged;
|
|
207
|
+
var env = (name) => unpackaged ? process.env[name] : void 0;
|
|
208
|
+
var devUrl = localDevUrl(env("TC_RENDERER_URL"));
|
|
209
|
+
var rendererDir = env("TC_RENDERER_DIR") ?? join(here, "renderer");
|
|
210
|
+
var startUrl = devUrl ? devUrl.href : `${APP_ORIGIN}/`;
|
|
211
|
+
var gpuOverride = env("TC_GPU_SWITCHES");
|
|
212
|
+
for (const [name, value] of gpuOverride ? parseSwitches(gpuOverride) : webgpuSwitches(process.platform)) if (value === void 0) app.commandLine.appendSwitch(name);
|
|
213
|
+
else app.commandLine.appendSwitch(name, value);
|
|
214
|
+
var userData = env("TC_USER_DATA");
|
|
215
|
+
if (userData) app.setPath("userData", userData);
|
|
216
|
+
protocol.registerSchemesAsPrivileged([{
|
|
217
|
+
scheme: "app",
|
|
218
|
+
privileges: {
|
|
219
|
+
standard: true,
|
|
220
|
+
secure: true,
|
|
221
|
+
supportFetchAPI: true,
|
|
222
|
+
stream: true
|
|
223
|
+
}
|
|
224
|
+
}]);
|
|
225
|
+
if (!app.requestSingleInstanceLock()) app.quit();
|
|
226
|
+
else {
|
|
227
|
+
app.on("second-instance", () => {
|
|
228
|
+
const w = BrowserWindow.getAllWindows()[0];
|
|
229
|
+
if (!w) return;
|
|
230
|
+
if (w.isMinimized()) w.restore();
|
|
231
|
+
w.focus();
|
|
232
|
+
});
|
|
233
|
+
app.whenReady().then(start);
|
|
234
|
+
}
|
|
235
|
+
app.on("window-all-closed", () => app.quit());
|
|
236
|
+
app.on("web-contents-created", (_e, contents) => {
|
|
237
|
+
contents.on("will-attach-webview", (e) => e.preventDefault());
|
|
238
|
+
contents.setWindowOpenHandler(({ url }) => {
|
|
239
|
+
const href = safeExternalHttps(url);
|
|
240
|
+
if (href && navigationAllowed(contents.getURL(), startUrl)) shell.openExternal(href).catch((err) => {
|
|
241
|
+
console.error("[thunderclap] openExternal failed:", err);
|
|
242
|
+
});
|
|
243
|
+
return { action: "deny" };
|
|
244
|
+
});
|
|
245
|
+
contents.on("will-navigate", (e, url) => {
|
|
246
|
+
if (!navigationAllowed(url, startUrl)) e.preventDefault();
|
|
247
|
+
});
|
|
248
|
+
contents.on("will-redirect", (e, url) => {
|
|
249
|
+
if (!navigationAllowed(url, startUrl)) e.preventDefault();
|
|
250
|
+
});
|
|
251
|
+
});
|
|
252
|
+
function start() {
|
|
253
|
+
const ses = session.defaultSession;
|
|
254
|
+
protocol.handle("app", async (req) => {
|
|
255
|
+
const file = bundleFile(rendererDir, req.url);
|
|
256
|
+
if (!file) return new Response("Not found", { status: 404 });
|
|
257
|
+
try {
|
|
258
|
+
const body = await readFile(file);
|
|
259
|
+
return new Response(body, { headers: {
|
|
260
|
+
"content-type": mimeType(file),
|
|
261
|
+
"content-security-policy": CSP,
|
|
262
|
+
"x-content-type-options": "nosniff"
|
|
263
|
+
} });
|
|
264
|
+
} catch {
|
|
265
|
+
return new Response("Not found", { status: 404 });
|
|
266
|
+
}
|
|
267
|
+
});
|
|
268
|
+
ses.webRequest.onBeforeRequest((details, done) => {
|
|
269
|
+
done({ cancel: !requestAllowed(details.url, devUrl) });
|
|
270
|
+
});
|
|
271
|
+
ses.setPermissionRequestHandler((_wc, permission, done) => done(permission === "fullscreen"));
|
|
272
|
+
ses.setPermissionCheckHandler((_wc, permission) => permission === "fullscreen");
|
|
273
|
+
installMenu();
|
|
274
|
+
registerBridge();
|
|
275
|
+
createWindow();
|
|
276
|
+
}
|
|
277
|
+
function createWindow() {
|
|
278
|
+
const win = new BrowserWindow({
|
|
279
|
+
width: 1280,
|
|
280
|
+
height: 720,
|
|
281
|
+
minWidth: 640,
|
|
282
|
+
minHeight: 360,
|
|
283
|
+
useContentSize: true,
|
|
284
|
+
show: false,
|
|
285
|
+
backgroundColor: "#000000",
|
|
286
|
+
title: app.getName(),
|
|
287
|
+
autoHideMenuBar: true,
|
|
288
|
+
webPreferences: {
|
|
289
|
+
preload: join(here, "preload.cjs"),
|
|
290
|
+
contextIsolation: true,
|
|
291
|
+
nodeIntegration: false,
|
|
292
|
+
sandbox: true,
|
|
293
|
+
webSecurity: true,
|
|
294
|
+
spellcheck: false,
|
|
295
|
+
devTools: unpackaged
|
|
296
|
+
}
|
|
297
|
+
});
|
|
298
|
+
win.once("ready-to-show", () => win.show());
|
|
299
|
+
const report = (on) => {
|
|
300
|
+
if (!win.isDestroyed()) win.webContents.send(CH.fullscreenChanged, on);
|
|
301
|
+
};
|
|
302
|
+
win.on("enter-full-screen", () => report(true));
|
|
303
|
+
win.on("leave-full-screen", () => report(false));
|
|
304
|
+
win.loadURL(startUrl);
|
|
305
|
+
return win;
|
|
306
|
+
}
|
|
307
|
+
/** macOS needs an app menu for Cmd+Q, copy and paste; elsewhere a game window has none. */
|
|
308
|
+
function installMenu() {
|
|
309
|
+
if (process.platform !== "darwin") {
|
|
310
|
+
Menu.setApplicationMenu(null);
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
Menu.setApplicationMenu(Menu.buildFromTemplate([
|
|
314
|
+
{ role: "appMenu" },
|
|
315
|
+
{ role: "editMenu" },
|
|
316
|
+
{
|
|
317
|
+
role: "windowMenu",
|
|
318
|
+
submenu: [
|
|
319
|
+
{ role: "minimize" },
|
|
320
|
+
{ role: "togglefullscreen" },
|
|
321
|
+
{ role: "close" }
|
|
322
|
+
]
|
|
323
|
+
}
|
|
324
|
+
]));
|
|
325
|
+
}
|
|
326
|
+
/** The window behind a bridge call, if the call came from our page's main frame. */
|
|
327
|
+
function callerWindow(e) {
|
|
328
|
+
const win = BrowserWindow.fromWebContents(e.sender);
|
|
329
|
+
const frame = e.senderFrame;
|
|
330
|
+
if (!win || !frame || frame !== e.sender.mainFrame) return null;
|
|
331
|
+
return navigationAllowed(frame.url, startUrl) ? win : null;
|
|
332
|
+
}
|
|
333
|
+
function registerBridge() {
|
|
334
|
+
ipcMain.handle(CH.openExternal, async (e, url) => {
|
|
335
|
+
const href = safeExternalHttps(url);
|
|
336
|
+
if (!callerWindow(e) || !href) return false;
|
|
337
|
+
try {
|
|
338
|
+
await shell.openExternal(href);
|
|
339
|
+
return true;
|
|
340
|
+
} catch (err) {
|
|
341
|
+
console.error("[thunderclap] openExternal failed:", err);
|
|
342
|
+
return false;
|
|
343
|
+
}
|
|
344
|
+
});
|
|
345
|
+
ipcMain.handle(CH.copyText, (e, text) => {
|
|
346
|
+
if (!callerWindow(e) || typeof text !== "string") return false;
|
|
347
|
+
clipboard.writeText(text);
|
|
348
|
+
return true;
|
|
349
|
+
});
|
|
350
|
+
ipcMain.handle(CH.fullscreenGet, (e) => callerWindow(e)?.isFullScreen() ?? false);
|
|
351
|
+
ipcMain.handle(CH.fullscreenSet, async (e, on) => {
|
|
352
|
+
const win = callerWindow(e);
|
|
353
|
+
if (!win) return false;
|
|
354
|
+
const want = on === true;
|
|
355
|
+
if (win.isFullScreen() === want) return want;
|
|
356
|
+
const done = new Promise((res) => {
|
|
357
|
+
const t = setTimeout(res, 2e3);
|
|
358
|
+
const settle = () => {
|
|
359
|
+
clearTimeout(t);
|
|
360
|
+
res();
|
|
361
|
+
};
|
|
362
|
+
if (want) win.once("enter-full-screen", settle);
|
|
363
|
+
else win.once("leave-full-screen", settle);
|
|
364
|
+
});
|
|
365
|
+
win.setFullScreen(want);
|
|
366
|
+
await done;
|
|
367
|
+
return win.isFullScreen();
|
|
368
|
+
});
|
|
369
|
+
ipcMain.on(CH.minimize, (e) => callerWindow(e)?.minimize());
|
|
370
|
+
ipcMain.on(CH.toggleMaximize, (e) => {
|
|
371
|
+
const win = callerWindow(e);
|
|
372
|
+
if (!win) return;
|
|
373
|
+
if (win.isMaximized()) win.unmaximize();
|
|
374
|
+
else win.maximize();
|
|
375
|
+
});
|
|
376
|
+
ipcMain.on(CH.close, (e) => callerWindow(e)?.close());
|
|
377
|
+
ipcMain.on(CH.setTitle, (e, title) => {
|
|
378
|
+
if (typeof title === "string") callerWindow(e)?.setTitle(title.slice(0, 200));
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
export {};
|
package/out/preload.cjs
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
let electron = require("electron");
|
|
2
|
+
// packages/host-electron/src/bridge.ts
|
|
3
|
+
/** The global the preload defines. */
|
|
4
|
+
var BRIDGE_GLOBAL = "thunderclap";
|
|
5
|
+
/** IPC channels, all prefixed `tc:`. `invoke` channels answer; `send` channels do not. */
|
|
6
|
+
var CH = {
|
|
7
|
+
openExternal: "tc:open-external",
|
|
8
|
+
copyText: "tc:copy-text",
|
|
9
|
+
fullscreenGet: "tc:fullscreen-get",
|
|
10
|
+
fullscreenSet: "tc:fullscreen-set",
|
|
11
|
+
/** main → renderer: the new fullscreen state. */
|
|
12
|
+
fullscreenChanged: "tc:fullscreen-changed",
|
|
13
|
+
minimize: "tc:window-minimize",
|
|
14
|
+
toggleMaximize: "tc:window-toggle-maximize",
|
|
15
|
+
close: "tc:window-close",
|
|
16
|
+
setTitle: "tc:window-set-title"
|
|
17
|
+
};
|
|
18
|
+
// packages/host-electron/src/preload/preload.ts
|
|
19
|
+
/**
|
|
20
|
+
* The preload: runs sandboxed (`sandbox: true`, `contextIsolation: true`, no Node in the page)
|
|
21
|
+
* and exposes `window.thunderclap` (src/bridge.ts) with `contextBridge`. Each member is a thin
|
|
22
|
+
* IPC call; the main process checks every argument and the caller (src/main/main.ts).
|
|
23
|
+
* Built to a single CommonJS file (out/preload.cjs), as sandboxed preloads require.
|
|
24
|
+
*/
|
|
25
|
+
var bridge = {
|
|
26
|
+
version: 1,
|
|
27
|
+
platform: process.platform,
|
|
28
|
+
openExternal: (url) => electron.ipcRenderer.invoke(CH.openExternal, String(url)),
|
|
29
|
+
copyText: (text) => electron.ipcRenderer.invoke(CH.copyText, String(text)),
|
|
30
|
+
fullscreen: {
|
|
31
|
+
get: () => electron.ipcRenderer.invoke(CH.fullscreenGet),
|
|
32
|
+
set: (on) => electron.ipcRenderer.invoke(CH.fullscreenSet, on === true),
|
|
33
|
+
onChange(fn) {
|
|
34
|
+
const listener = (_e, on) => fn(on === true);
|
|
35
|
+
electron.ipcRenderer.on(CH.fullscreenChanged, listener);
|
|
36
|
+
return () => {
|
|
37
|
+
electron.ipcRenderer.removeListener(CH.fullscreenChanged, listener);
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
window: {
|
|
42
|
+
minimize: () => electron.ipcRenderer.send(CH.minimize),
|
|
43
|
+
toggleMaximize: () => electron.ipcRenderer.send(CH.toggleMaximize),
|
|
44
|
+
close: () => electron.ipcRenderer.send(CH.close),
|
|
45
|
+
setTitle: (title) => electron.ipcRenderer.send(CH.setTitle, String(title))
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
electron.contextBridge.exposeInMainWorld(BRIDGE_GLOBAL, bridge);
|
package/package.json
CHANGED
|
@@ -1,6 +1,78 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@thunderclap-engine/host-electron",
|
|
3
|
-
"
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"productName": "ThunderClap",
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"description": "ThunderClap's Electron host: a desktop shell (main process and preload bridge) and createElectronHost() for the renderer. It knows nothing about any game. Games depend on @thunderclap-engine/engine themselves; everything the shell runs is bundled into out/, so it has no runtime dependencies.",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"author": "ThunderClap",
|
|
9
|
+
"homepage": "https://github.com/Mandalorian007/thunderclap-engine/tree/main/packages/host-electron#readme",
|
|
10
|
+
"bugs": {
|
|
11
|
+
"url": "https://github.com/Mandalorian007/thunderclap-engine/issues"
|
|
12
|
+
},
|
|
13
|
+
"repository": {
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "git+https://github.com/Mandalorian007/thunderclap-engine.git",
|
|
16
|
+
"directory": "packages/host-electron"
|
|
17
|
+
},
|
|
18
|
+
"keywords": [
|
|
19
|
+
"thunderclap",
|
|
20
|
+
"electron",
|
|
21
|
+
"desktop",
|
|
22
|
+
"webgpu",
|
|
23
|
+
"game-engine"
|
|
24
|
+
],
|
|
25
|
+
"sideEffects": false,
|
|
26
|
+
"main": "out/main.mjs",
|
|
27
|
+
"exports": {
|
|
28
|
+
".": {
|
|
29
|
+
"types": "./dist/index.d.ts",
|
|
30
|
+
"import": "./dist/index.js"
|
|
31
|
+
},
|
|
32
|
+
"./package.json": "./package.json"
|
|
33
|
+
},
|
|
34
|
+
"files": [
|
|
35
|
+
"dist",
|
|
36
|
+
"out/main.mjs",
|
|
37
|
+
"out/preload.cjs",
|
|
38
|
+
"README.md",
|
|
39
|
+
"LICENSE"
|
|
40
|
+
],
|
|
41
|
+
"publishConfig": {
|
|
42
|
+
"access": "public",
|
|
43
|
+
"provenance": true
|
|
44
|
+
},
|
|
45
|
+
"peerDependencies": {
|
|
46
|
+
"@thunderclap-engine/engine": "^1.0.0",
|
|
47
|
+
"electron": ">=45.0.0"
|
|
48
|
+
},
|
|
49
|
+
"peerDependenciesMeta": {
|
|
50
|
+
"electron": {
|
|
51
|
+
"optional": true
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
"devDependencies": {
|
|
55
|
+
"@biomejs/biome": "^2.6.4",
|
|
56
|
+
"@playwright/test": "^1.64.1",
|
|
57
|
+
"@types/node": "^26.6.6",
|
|
58
|
+
"@webgpu/types": "^0.1.74",
|
|
59
|
+
"electron": "^45.2.2",
|
|
60
|
+
"electron-builder": "^26.18.0",
|
|
61
|
+
"happy-dom": "^21.0.4",
|
|
62
|
+
"typescript": "^7.0.2",
|
|
63
|
+
"vite": "^8.3.5",
|
|
64
|
+
"vitest": "^5.0.5",
|
|
65
|
+
"@thunderclap-engine/engine": "1.1.0"
|
|
66
|
+
},
|
|
67
|
+
"scripts": {
|
|
68
|
+
"typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.node.json",
|
|
69
|
+
"lint": "biome check .",
|
|
70
|
+
"test": "vitest run",
|
|
71
|
+
"check": "pnpm typecheck && pnpm lint && pnpm test",
|
|
72
|
+
"build": "node scripts/build.mjs",
|
|
73
|
+
"dev": "node scripts/dev.mjs",
|
|
74
|
+
"dist": "node scripts/dist.mjs",
|
|
75
|
+
"test:e2e": "node scripts/build.mjs && playwright test"
|
|
76
|
+
},
|
|
77
|
+
"types": "./dist/index.d.ts"
|
|
6
78
|
}
|