From c04a99c1bf8ae9653d3e910bad795c16168db81c Mon Sep 17 00:00:00 2001 From: Zlatko Date: Tue, 4 Aug 2026 17:53:04 +0200 Subject: [PATCH 01/19] first electron version --- .gitignore | 1 + desktop/README.md | 213 ++++++++++++++++++ desktop/electron-builder.config.cjs | 71 ++++++ desktop/package.json | 28 +++ desktop/packaging/afterPack.cjs | 18 ++ desktop/packaging/entitlements.mac.plist | 23 ++ desktop/packaging/icon.png | Bin 0 -> 35987 bytes desktop/renderer/bootstrap.mjs | 13 ++ desktop/renderer/index.html | 26 +++ desktop/scripts/package-app.mjs | 59 +++++ desktop/scripts/stage-renderer.mjs | 60 +++++ desktop/src/main/appProtocol.ts | 133 +++++++++++ desktop/src/main/config.ts | 84 +++++++ desktop/src/main/index.ts | 70 ++++++ desktop/src/main/log.ts | 13 ++ desktop/src/main/mainWindow.ts | 42 ++++ desktop/src/main/networkPolicy.ts | 87 +++++++ desktop/src/main/security.ts | 84 +++++++ desktop/src/preload/index.ts | 20 ++ desktop/tsconfig.json | 19 ++ eslint.config.mjs | 8 +- front-end/src/components/Shell.tsx | 45 ++-- front-end/src/global.d.ts | 1 + front-end/src/hooks/saveCoordination.ts | 17 +- front-end/src/hooks/useWalletConnect.ts | 9 +- .../src/lib/tests/save.maintenance.test.ts | 22 +- .../lib/tests/walletconnect_metadata.test.ts | 27 +++ front-end/src/util/distribution.ts | 10 + front-end/src/util/walletConnectMetadata.ts | 34 +++ package.json | 4 +- pnpm-workspace.yaml | 2 + tools/build-deploy.sh | 39 +--- tools/build-electron.sh | 64 ++++++ tools/build-player-bundle.sh | 83 +++++++ 34 files changed, 1367 insertions(+), 62 deletions(-) create mode 100644 desktop/README.md create mode 100644 desktop/electron-builder.config.cjs create mode 100644 desktop/package.json create mode 100644 desktop/packaging/afterPack.cjs create mode 100644 desktop/packaging/entitlements.mac.plist create mode 100644 desktop/packaging/icon.png create mode 100644 desktop/renderer/bootstrap.mjs create mode 100644 desktop/renderer/index.html create mode 100644 desktop/scripts/package-app.mjs create mode 100644 desktop/scripts/stage-renderer.mjs create mode 100644 desktop/src/main/appProtocol.ts create mode 100644 desktop/src/main/config.ts create mode 100644 desktop/src/main/index.ts create mode 100644 desktop/src/main/log.ts create mode 100644 desktop/src/main/mainWindow.ts create mode 100644 desktop/src/main/networkPolicy.ts create mode 100644 desktop/src/main/security.ts create mode 100644 desktop/src/preload/index.ts create mode 100644 desktop/tsconfig.json create mode 100644 front-end/src/lib/tests/walletconnect_metadata.test.ts create mode 100644 front-end/src/util/distribution.ts create mode 100644 front-end/src/util/walletConnectMetadata.ts create mode 100755 tools/build-electron.sh create mode 100755 tools/build-player-bundle.sh diff --git a/.gitignore b/.gitignore index ff4447f8e..871d167cc 100644 --- a/.gitignore +++ b/.gitignore @@ -40,6 +40,7 @@ pnpm-store/ # Deploy output deploy_player_app/ deploy_hub/ +desktop/release/ # macos .DS_Store diff --git a/desktop/README.md b/desktop/README.md new file mode 100644 index 000000000..d8716c148 --- /dev/null +++ b/desktop/README.md @@ -0,0 +1,213 @@ +# Chia Gaming Desktop + +A hardened Electron shell around the existing player app (`front-end/`). The +renderer runs exactly the same React + WASM bundle the browser deploy serves; +this package supplies the process boundary, the asset origin, and the security +policy around it. + +## Build and run + +One command, from the repository root, builds everything and produces +installers: + +```bash +tools/build-electron.sh --platform=mac # or --platform=win / --platform=linux +``` + +That runs `tools/build-player-bundle.sh` (chialisp, the release WASM engine, and +the bundled React app, shared with `tools/build-deploy.sh`) and then packages the +Electron app. + +For iterating on the desktop shell without repackaging: + +```bash +tools/build-player-bundle.sh # once, or after changing front-end/ +pnpm --filter chia-gaming-desktop start # typecheck, bundle main/preload, stage, launch +``` + +`start` re-runs the whole desktop build each time. After changing only the +player app, re-run `tools/build-player-bundle.sh` and then +`pnpm --filter chia-gaming-desktop run stage`. + +Finished installers land in `desktop/release/`. electron-builder itself runs against a +directory under `$TMPDIR` rather than the repository, because a checkout under +`~/Documents` is managed by the iCloud File Provider, which stamps +`com.apple.FinderInfo` extended attributes that codesign rejects as "detritus". + +The hub service is a separate process, unchanged by this package. Run +`./run-local-demo.sh` for it, then launch the desktop app instead of opening the +browser at `:3002`. + +## Connection modes + +The desktop build is **WalletConnect only**. The preload sets +`window.__chiaDistribution = 'electron'`, and `front-end/src/util/distribution.ts` +uses it to hide the "Continue with Simulator" button and the simulator setup +modal, and to resume a saved session with no recorded `blockchainType` as +WalletConnect rather than simulator. The simulator remains available in the web +build. + +The same flag suppresses the front end's multi-tab lease. That lease records its +owner in `localStorage` but identifies itself from `sessionStorage`, so a quit +orphans it and the next launch would read a dead run as a live peer and open the +"Another tab is active" dialog on every start. `requestSingleInstanceLock` plus a +single window means a foreign owner here is always stale, so +`front-end/src/hooks/save.ts` treats it as no peer at all. + +Because wallets display the dapp `url` to the user and fetch its icon over the +public internet, `front-end/src/util/walletConnectMetadata.ts` substitutes a +public https identity when the page origin is not http(s) — the renderer origin +here is `chiagaming://app`, which no wallet can open or fetch. + +## Configuration + +Optional JSON file at `/config.json`, where `` is +`~/Library/Application Support/Chia Gaming` on macOS, +`%APPDATA%\Chia Gaming` on Windows, and `~/.config/Chia Gaming` on Linux. + +| Key | Default | Meaning | +| ------------ | ---------------------------------------------------- | ------------------------------------------- | +| `hubOrigins` | `["http://localhost:3003", "http://127.0.0.1:3003"]` | Hub origins the app may load and connect to | + +`CHIA_GAMING_HUB_ORIGINS` (comma separated) overrides the file. Anything invalid +is reported in an error dialog and the app exits rather than starting with a +half-applied policy. + +`hubOrigins` feeds both the CSP `frame-src` and the network egress allowlist, +and the CSP is attached to the document at load time. **A hub origin that is not +listed here cannot be connected to, even if it is typed into the in-app hub +picker.** Adding one is a config change plus a restart. See +[Known gaps](#known-gaps). + +## Security posture + +### Process isolation + +The renderer has no Node.js reachable from it at all, and there is no IPC +surface to attack. + +| Setting | Value | +| ----------------------------- | ------- | +| `sandbox` | `true` (also `app.enableSandbox()`, which covers renderers created later) | +| `contextIsolation` | `true` | +| `nodeIntegration` | `false` | +| `nodeIntegrationInWorker` | `false` | +| `nodeIntegrationInSubFrames` | `false` | +| `webSecurity` | `true` | +| `allowRunningInsecureContent` | `false` | +| `experimentalFeatures` | `false` | +| `webviewTag` | `false` | +| `navigateOnDragDrop` | `false` | +| `devTools` | only in unpackaged builds | + +`src/preload/index.ts` does not import `ipcRenderer` and registers no channels. +It exposes one string, `__chiaDistribution`, and nothing else. It has to be a +preload global rather than anything asynchronous because the front end reads it +during the first render. + +The exposure is guarded on `window === window.top`. Sub-frames here are remote +content (the hub lobby UI, the WalletConnect Verify frame) and get nothing. +`process.isMainFrame` is not available to a sandboxed preload and +`webFrame.parent` reports `null` for out-of-process frames, so neither is a +usable guard. + +### Renderer origin + +The renderer is served from `chiagaming://app`, a scheme registered as +`standard` + `secure`, not from `file://`. A real origin is what makes +`localStorage`, IndexedDB, `crypto.subtle` and relative asset URLs behave the +same as in the browser deploy, with `webSecurity` left on and no `file://` +privileges granted to anything. + +`src/main/appProtocol.ts` resolves each request inside the staged renderer +directory and rejects anything that escapes it. It reads through Node's `fs` +rather than `net.fetch(file://…)` because asar support is implemented as an `fs` +shim; that is what lets the renderer stay sealed inside `app.asar`, where the +integrity-validation fuse still covers it, instead of being unpacked beside it. + +### Content Security Policy + +Served with the document by the protocol handler, so there is a single source +of truth: + +``` +default-src 'none'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; +img-src 'self' data: blob:; font-src 'self'; connect-src 'self' ; +frame-src ; worker-src 'none'; media-src 'none'; object-src 'none'; +manifest-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none' +``` + +Three of those need explanation: + +- `'wasm-unsafe-eval'` is what lets the Rust engine compile. JavaScript `eval` + stays blocked. +- No inline script is permitted, which is why `renderer/index.html` exists + instead of reusing `front-end/public/index.html` — the browser entry point + bootstraps through an inline ` + + + diff --git a/desktop/scripts/package-app.mjs b/desktop/scripts/package-app.mjs new file mode 100644 index 000000000..b49bf6c7a --- /dev/null +++ b/desktop/scripts/package-app.mjs @@ -0,0 +1,59 @@ +// Run electron-builder with its output redirected outside the repository. +// +// This repository can live under ~/Documents, which iCloud manages via File +// Provider. File Provider stamps com.apple.FinderInfo extended attributes on +// files it syncs, and codesign rejects those as "resource fork, Finder +// information, or similar detritus not allowed". $TMPDIR (/var/folders/...) is +// never synced, so building there keeps signing clean; the finished installers +// are sealed by the time they are copied back. + +import { spawnSync } from 'node:child_process'; +import { copyFileSync, mkdirSync, readdirSync, rmSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { tmpdir } from 'node:os'; +import { fileURLToPath } from 'node:url'; + +const DESKTOP = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const BUILD_DIR = join(tmpdir(), 'chia-gaming-desktop-build'); +const RELEASE_DIR = join(DESKTOP, 'release'); + +const INSTALLER_EXTENSIONS = ['.dmg', '.zip', '.exe', '.AppImage', '.deb']; + +rmSync(BUILD_DIR, { recursive: true, force: true }); +mkdirSync(BUILD_DIR, { recursive: true }); + +const result = spawnSync( + 'electron-builder', + [ + '--config', + 'electron-builder.config.cjs', + `-c.directories.output=${BUILD_DIR}`, + ...process.argv.slice(2), + ], + { cwd: DESKTOP, stdio: 'inherit', shell: false }, +); + +if (result.error) { + throw result.error; +} +if (result.status !== 0) { + process.exit(result.status ?? 1); +} + +mkdirSync(RELEASE_DIR, { recursive: true }); +const installers = readdirSync(BUILD_DIR).filter((name) => + INSTALLER_EXTENSIONS.some((extension) => name.endsWith(extension)), +); +for (const name of installers) { + copyFileSync(join(BUILD_DIR, name), join(RELEASE_DIR, name)); +} + +console.log(`\nbuild dir (not synced): ${BUILD_DIR}`); +if (installers.length === 0) { + console.log(`no installer artifacts found in ${BUILD_DIR}`); +} else { + console.log(`installers copied to: ${RELEASE_DIR}`); + for (const name of installers) { + console.log(` ${name}`); + } +} diff --git a/desktop/scripts/stage-renderer.mjs b/desktop/scripts/stage-renderer.mjs new file mode 100644 index 000000000..e36c09166 --- /dev/null +++ b/desktop/scripts/stage-renderer.mjs @@ -0,0 +1,60 @@ +// Stages the tree served over the chiagaming:// scheme. +// +// It is the player-app deploy bundle (front-end/dist/app) plus the desktop HTML +// entry, which replaces the browser bootstrap so the document needs no inline +// script. Floor checks at the end fail the build loudly rather than shipping a +// bundle whose wasm or chialisp assets are missing. + +import { copyFileSync, cpSync, existsSync, mkdirSync, readdirSync, rmSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const DESKTOP = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const REPO = resolve(DESKTOP, '..'); +const PLAYER_BUNDLE = process.env.PLAYER_APP_DIR || join(REPO, 'front-end', 'dist', 'app'); +const OUT = join(DESKTOP, 'dist', 'renderer'); + +if (!existsSync(join(PLAYER_BUNDLE, 'index.js'))) { + throw new Error( + `stage-renderer: no player app bundle at ${PLAYER_BUNDLE}\n` + + 'Build it first, from the repository root:\n' + + ' tools/build-player-bundle.sh\n' + + 'Or build and package the desktop app in one step:\n' + + ' tools/build-electron.sh --platform=mac', + ); +} + +rmSync(OUT, { recursive: true, force: true }); +mkdirSync(OUT, { recursive: true }); +cpSync(PLAYER_BUNDLE, OUT, { recursive: true }); + +for (const file of ['index.html', 'bootstrap.mjs']) { + copyFileSync(join(DESKTOP, 'renderer', file), join(OUT, file)); +} + +const favicon = join(REPO, 'front-end', 'public', 'favicon.svg'); +if (existsSync(favicon)) { + copyFileSync(favicon, join(OUT, 'favicon.svg')); +} + +const errors = [ + 'index.html', + 'bootstrap.mjs', + 'index.js', + 'index.css', + 'chia_gaming_wasm.js', + 'chia_gaming_wasm_bg.wasm', +] + .filter((file) => !existsSync(join(OUT, file))) + .map((file) => `missing required file: ${file}`); + +const clsp = join(OUT, 'clsp'); +if (!existsSync(clsp) || readdirSync(clsp).length === 0) { + errors.push('clsp/ is missing or empty (no compiled .hex)'); +} + +if (errors.length) { + throw new Error(`stage-renderer: incomplete renderer in ${OUT}:\n - ${errors.join('\n - ')}`); +} + +console.log(`stage-renderer: ok -> ${OUT}`); diff --git a/desktop/src/main/appProtocol.ts b/desktop/src/main/appProtocol.ts new file mode 100644 index 000000000..a6dbe8976 --- /dev/null +++ b/desktop/src/main/appProtocol.ts @@ -0,0 +1,133 @@ +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; + +import { protocol } from 'electron'; + +import { log } from './log'; + +/** + * The renderer is served from a custom scheme rather than `file://`. + * + * A registered standard+secure scheme gives the player app a real opaque + * origin, which is what makes localStorage, IndexedDB, `crypto.subtle` and + * relative asset URLs behave exactly as they do in the browser deploy — with + * `webSecurity` left on and no `file://` privileges granted to anything. + */ +export const APP_SCHEME = 'chiagaming'; +export const APP_HOST = 'app'; +export const APP_ORIGIN = `${APP_SCHEME}://${APP_HOST}`; + +const MIME_TYPES = new Map([ + ['.html', 'text/html; charset=utf-8'], + ['.js', 'text/javascript; charset=utf-8'], + ['.mjs', 'text/javascript; charset=utf-8'], + ['.css', 'text/css; charset=utf-8'], + ['.json', 'application/json; charset=utf-8'], + ['.map', 'application/json; charset=utf-8'], + ['.wasm', 'application/wasm'], + ['.svg', 'image/svg+xml'], + ['.png', 'image/png'], + ['.jpg', 'image/jpeg'], + ['.jpeg', 'image/jpeg'], + ['.gif', 'image/gif'], + ['.webp', 'image/webp'], + ['.woff2', 'font/woff2'], + ['.ico', 'image/x-icon'], + ['.hex', 'text/plain; charset=utf-8'], +]); + +/** Chialisp `.dat` payloads and anything else are fetched as bytes. */ +const DEFAULT_MIME_TYPE = 'application/octet-stream'; + +/** Must run before the `ready` event: Chromium reads the scheme registry once at startup. */ +export function registerAppSchemeAsPrivileged(): void { + protocol.registerSchemesAsPrivileged([ + { + scheme: APP_SCHEME, + privileges: { + standard: true, + secure: true, + supportFetchAPI: true, + corsEnabled: true, + stream: true, + allowServiceWorkers: false, + }, + }, + ]); +} + +function textResponse(body: string, status: number): Response { + return new Response(body, { status, headers: { 'content-type': 'text/plain; charset=utf-8' } }); +} + +/** + * Map a request pathname onto a file inside `rendererRoot`, or null when the + * request tries to escape it. `path.resolve` normalises `..` segments, so the + * containment check below is what actually enforces the boundary. + */ +function resolveRequestedFile(rendererRoot: string, pathname: string): string | null { + let decoded: string; + try { + decoded = decodeURIComponent(pathname); + } catch { + return null; + } + if (decoded.includes('\0')) { + return null; + } + const relative = decoded.replace(/^\/+/, ''); + const target = path.resolve(rendererRoot, relative === '' ? 'index.html' : relative); + if (target !== rendererRoot && !target.startsWith(rendererRoot + path.sep)) { + return null; + } + return target; +} + +export function serveAppScheme(rendererRoot: string, contentSecurityPolicy: string): void { + log.info(`serving ${APP_ORIGIN} from ${rendererRoot}`); + + protocol.handle(APP_SCHEME, async (request) => { + const url = new URL(request.url); + if (url.host !== APP_HOST) { + log.warn(`rejected request for unknown host: ${url.host}`); + return textResponse('Not found', 404); + } + + const filePath = resolveRequestedFile(rendererRoot, url.pathname); + if (filePath === null) { + log.warn(`rejected out-of-root request: ${url.pathname}`); + return textResponse('Forbidden', 403); + } + // Read through Node's fs rather than `net.fetch(file://…)`: asar support is + // implemented as an fs shim, so this is what lets the renderer stay sealed + // inside app.asar where the integrity-validation fuse still covers it. + let body: ArrayBuffer; + try { + // toArrayBuffer, rather than handing the Buffer straight to Response: + // readFile returns a view onto a pooled allocation, which is neither a + // standalone ArrayBuffer nor a valid BodyInit. + const file = await readFile(filePath); + body = file.buffer.slice(file.byteOffset, file.byteOffset + file.byteLength) as ArrayBuffer; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === 'ENOENT' || code === 'EISDIR') { + log.warn(`no such asset: ${url.pathname}`); + return textResponse('Not found', 404); + } + log.error(`failed to read ${url.pathname}: ${(error as Error).message}`); + return textResponse('Internal error', 500); + } + + const headers = new Headers({ + 'content-type': MIME_TYPES.get(path.extname(filePath).toLowerCase()) ?? DEFAULT_MIME_TYPE, + 'x-content-type-options': 'nosniff', + 'referrer-policy': 'no-referrer', + }); + // The CSP belongs on the document, which is the only thing that can host script. + if (filePath.endsWith('.html')) { + headers.set('content-security-policy', contentSecurityPolicy); + } + + return new Response(body, { status: 200, headers }); + }); +} diff --git a/desktop/src/main/config.ts b/desktop/src/main/config.ts new file mode 100644 index 000000000..770779786 --- /dev/null +++ b/desktop/src/main/config.ts @@ -0,0 +1,84 @@ +import { existsSync, readFileSync } from 'node:fs'; +import path from 'node:path'; + +import { app } from 'electron'; +import { z } from 'zod'; + +import { log } from './log'; + +/** + * Desktop configuration is untrusted input: it comes from a user-editable file + * and from the environment. It is validated and rejected with a readable + * message rather than being allowed to half-apply, because every value here + * ends up in the network egress allowlist and the renderer CSP. + */ +export type DesktopConfig = { + /** Bare http(s) origins the app may load the hub lobby UI from. */ + hubOrigins: string[]; +}; + +const DEFAULT_HUB_ORIGINS = ['http://localhost:3003', 'http://127.0.0.1:3003']; + +const hubOrigin = z.string().refine((value) => { + try { + const url = new URL(value); + return (url.protocol === 'http:' || url.protocol === 'https:') && url.origin === value; + } catch { + return false; + } +}, 'must be a bare http(s) origin with no path, e.g. https://hub.example.com'); + +const configSchema = z.strictObject({ + hubOrigins: z.array(hubOrigin).min(1).optional(), +}); + +function readConfigFile(filePath: string): Record { + if (!existsSync(filePath)) { + return {}; + } + let parsed: unknown; + try { + parsed = JSON.parse(readFileSync(filePath, 'utf8')); + } catch (error) { + throw new Error(`${filePath} is not valid JSON: ${(error as Error).message}`, { + cause: error, + }); + } + if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error(`${filePath} must contain a JSON object`); + } + return parsed as Record; +} + +/** Env wins over the config file. Absent keys are omitted so spreads don't erase file values. */ +function environmentOverrides(): Record { + const overrides: Record = {}; + const { CHIA_GAMING_HUB_ORIGINS } = process.env; + if (CHIA_GAMING_HUB_ORIGINS !== undefined) { + overrides.hubOrigins = CHIA_GAMING_HUB_ORIGINS.split(',') + .map((origin) => origin.trim()) + .filter((origin) => origin !== ''); + } + return overrides; +} + +export function loadDesktopConfig(): DesktopConfig { + const configFilePath = path.join(app.getPath('userData'), 'config.json'); + const candidate = { ...readConfigFile(configFilePath), ...environmentOverrides() }; + + const result = configSchema.safeParse(candidate); + if (!result.success) { + const issues = result.error.issues + .map((issue) => ` ${issue.path.join('.') || '(root)'}: ${issue.message}`) + .join('\n'); + throw new Error( + `Invalid desktop configuration from ${configFilePath} or the environment:\n${issues}`, + ); + } + + const config: DesktopConfig = { + hubOrigins: result.data.hubOrigins ?? [...DEFAULT_HUB_ORIGINS], + }; + log.info(`hub origins: ${config.hubOrigins.join(', ')}`); + return config; +} diff --git a/desktop/src/main/index.ts b/desktop/src/main/index.ts new file mode 100644 index 000000000..5b422cf54 --- /dev/null +++ b/desktop/src/main/index.ts @@ -0,0 +1,70 @@ +import path from 'node:path'; + +import { BrowserWindow, app, dialog, session } from 'electron'; + +import { registerAppSchemeAsPrivileged, serveAppScheme } from './appProtocol'; +import { loadDesktopConfig, type DesktopConfig } from './config'; +import { log } from './log'; +import { createMainWindow } from './mainWindow'; +import { buildNetworkPolicy } from './networkPolicy'; +import { installSessionSecurity, installWebContentsSecurity } from './security'; + +// Both of these have to happen before the 'ready' event. +registerAppSchemeAsPrivileged(); +app.enableSandbox(); + +function loadConfigOrExit(): DesktopConfig { + try { + return loadDesktopConfig(); + } catch (error) { + dialog.showErrorBox('Chia Gaming configuration error', (error as Error).message); + app.exit(1); + throw error; + } +} + +if (!app.requestSingleInstanceLock()) { + log.info('another instance already holds the single-instance lock; exiting'); + app.exit(0); +} else { + const config = loadConfigOrExit(); + const policy = buildNetworkPolicy(config); + const rendererRoot = path.join(app.getAppPath(), 'dist', 'renderer'); + + installWebContentsSecurity(policy); + + // The window is looked up in the live list rather than held in a variable: + // macOS keeps the app running with every window closed, so a saved handle + // would be a destroyed BrowserWindow, and every method on one of those + // throws. `isReady` covers a second launch arriving during our own startup, + // before a window may be created at all. + const focusOrCreateMainWindow = (): void => { + const [existing] = BrowserWindow.getAllWindows(); + if (existing === undefined) { + if (app.isReady()) { + createMainWindow(); + } + return; + } + if (existing.isMinimized()) { + existing.restore(); + } + existing.focus(); + }; + + app.on('second-instance', focusOrCreateMainWindow); + + app.on('window-all-closed', () => { + if (process.platform !== 'darwin') { + app.quit(); + } + }); + + void app.whenReady().then(() => { + installSessionSecurity(session.defaultSession, policy); + serveAppScheme(rendererRoot, policy.contentSecurityPolicy); + createMainWindow(); + + app.on('activate', focusOrCreateMainWindow); + }); +} diff --git a/desktop/src/main/log.ts b/desktop/src/main/log.ts new file mode 100644 index 000000000..8c8114945 --- /dev/null +++ b/desktop/src/main/log.ts @@ -0,0 +1,13 @@ +const PREFIX = '[chia-gaming-desktop]'; + +export const log = { + info(message: string): void { + console.log(`${PREFIX} ${message}`); + }, + warn(message: string): void { + console.warn(`${PREFIX} ${message}`); + }, + error(message: string): void { + console.error(`${PREFIX} ${message}`); + }, +}; diff --git a/desktop/src/main/mainWindow.ts b/desktop/src/main/mainWindow.ts new file mode 100644 index 000000000..75fe61c6e --- /dev/null +++ b/desktop/src/main/mainWindow.ts @@ -0,0 +1,42 @@ +import path from 'node:path'; + +import { BrowserWindow, app } from 'electron'; + +import { APP_ORIGIN } from './appProtocol'; + +export function createMainWindow(): BrowserWindow { + const window = new BrowserWindow({ + width: 1360, + height: 900, + minWidth: 1024, + minHeight: 680, + show: false, + backgroundColor: '#000000', + title: 'Chia Gaming', + webPreferences: { + preload: path.join(app.getAppPath(), 'dist', 'preload', 'index.cjs'), + // The isolation posture. Several of these are already the default; they + // are spelled out so the whole boundary is auditable in one place and a + // future Electron default change cannot quietly widen it. + sandbox: true, + contextIsolation: true, + nodeIntegration: false, + nodeIntegrationInWorker: false, + nodeIntegrationInSubFrames: false, + webSecurity: true, + allowRunningInsecureContent: false, + experimentalFeatures: false, + webviewTag: false, + navigateOnDragDrop: false, + spellcheck: false, + devTools: !app.isPackaged, + // State-channel timeouts and the hub relay socket must keep running while + // the window is in the background, where Chromium throttles timers hard. + backgroundThrottling: false, + }, + }); + + window.once('ready-to-show', () => window.show()); + void window.loadURL(`${APP_ORIGIN}/index.html`); + return window; +} diff --git a/desktop/src/main/networkPolicy.ts b/desktop/src/main/networkPolicy.ts new file mode 100644 index 000000000..1f3a27f37 --- /dev/null +++ b/desktop/src/main/networkPolicy.ts @@ -0,0 +1,87 @@ +import type { DesktopConfig } from './config'; + +/** + * Endpoints reachable by `@walletconnect/sign-client` 2.23. Both hostnames are + * live: `front-end/src/constants/env.ts` pins the `.com` relay while the + * library's own defaults point at `.org`. + */ +const WALLET_CONNECT_REQUEST_ORIGINS = [ + 'wss://relay.walletconnect.com', + 'wss://relay.walletconnect.org', + 'https://verify.walletconnect.com', + 'https://verify.walletconnect.org', + 'https://pulse.walletconnect.org', +]; + +/** The Verify API renders an attestation iframe inside the player document. */ +const WALLET_CONNECT_FRAME_ORIGINS = [ + 'https://verify.walletconnect.com', + 'https://verify.walletconnect.org', +]; + +export type NetworkPolicy = { + /** Origins the app may open network connections to. Everything else is cancelled. */ + allowedRequestOrigins: ReadonlySet; + /** Origins allowed to load as a sub-frame of the player document. */ + allowedFrameOrigins: ReadonlySet; + contentSecurityPolicy: string; +}; + +export function originOfUrl(value: string): string | null { + try { + const origin = new URL(value).origin; + return origin === 'null' ? null : origin; + } catch { + return null; + } +} + +/** `HubConnection` derives its WebSocket URL from the hub origin the same way. */ +function webSocketOrigin(httpOrigin: string): string { + const url = new URL(httpOrigin); + url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'; + return url.origin; +} + +function buildContentSecurityPolicy( + requestOrigins: readonly string[], + frameOrigins: readonly string[], +): string { + return [ + "default-src 'none'", + // 'wasm-unsafe-eval' lets the Rust engine compile. JS eval stays blocked. + "script-src 'self' 'wasm-unsafe-eval'", + // Radix's scroll-lock injects a