diff --git a/.github/workflows/build-electron.yml b/.github/workflows/build-electron.yml new file mode 100644 index 000000000..79ca22ac0 --- /dev/null +++ b/.github/workflows/build-electron.yml @@ -0,0 +1,240 @@ +name: Electron desktop + +on: + push: + branches: + - main + - 'release/**' + - 'long_lived/**' + release: + types: [published] + pull_request: + branches: + - '**' + workflow_dispatch: + +concurrency: + group: ${{ github.ref }}-${{ github.workflow }}-${{ github.event_name }} + cancel-in-progress: true + +permissions: + contents: read + id-token: write + +env: + RUST_VERSION: stable + +jobs: + build: + name: Build Electron (${{ matrix.platform }}) + strategy: + fail-fast: false + matrix: + include: + - platform: mac + os: macos-13-arm64 + artifact: chia-gaming-electron-macos + - platform: win + os: windows-latest + artifact: chia-gaming-electron-windows + environment: windows-code-signing + - platform: linux + os: ubuntu-22.04 + artifact: chia-gaming-electron-linux + runs-on: ${{ matrix.os }} + environment: ${{ matrix.environment }} + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install Rust + uses: dtolnay/rust-toolchain@master + with: + toolchain: ${{ env.RUST_VERSION }} + targets: wasm32-unknown-unknown + + - name: Install wasm-pack + run: cargo install wasm-pack --version 0.15.0 + + - name: Install WASI SDK + if: runner.os == 'macOS' + env: + WASI_SDK_VERSION: '33' + WASI_SDK_SHA256: 85c997a2665ead91673b5bb88b7d0df3fc8900df3bfa244f720d478187bbdc78 + run: | + archive="$RUNNER_TEMP/wasi-sdk.tar.gz" + install_dir="$RUNNER_TEMP/wasi-sdk" + curl --fail --location --retry 3 \ + --output "$archive" \ + "https://github.com/WebAssembly/wasi-sdk/releases/download/wasi-sdk-${WASI_SDK_VERSION}/wasi-sdk-${WASI_SDK_VERSION}.0-arm64-macos.tar.gz" + echo "${WASI_SDK_SHA256} ${archive}" | shasum -a 256 --check + mkdir -p "$install_dir" + tar -xzf "$archive" --strip-components=1 -C "$install_dir" + echo "CC_wasm32_unknown_unknown=$install_dir/bin/clang" >> "$GITHUB_ENV" + echo "AR_wasm32_unknown_unknown=$install_dir/bin/llvm-ar" >> "$GITHUB_ENV" + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Setup pnpm + shell: bash + run: | + corepack enable + corepack prepare pnpm@10.33.0 --activate + + - name: Check for Apple signing credentials + if: matrix.platform == 'mac' && github.event_name != 'pull_request' + id: apple-signing + shell: bash + env: + APPLE_DEV_ID_APP: ${{ secrets.APPLE_DEV_ID_APP }} + APPLE_DEV_ID_APP_PASS: ${{ secrets.APPLE_DEV_ID_APP_PASS }} + APPLE_NOTARIZE_USERNAME: ${{ secrets.APPLE_NOTARIZE_USERNAME }} + APPLE_NOTARIZE_PASSWORD: ${{ secrets.APPLE_NOTARIZE_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + run: | + configured=0 + for value in \ + "$APPLE_DEV_ID_APP" \ + "$APPLE_DEV_ID_APP_PASS" \ + "$APPLE_NOTARIZE_USERNAME" \ + "$APPLE_NOTARIZE_PASSWORD" \ + "$APPLE_TEAM_ID"; do + if [[ -n "$value" ]]; then + configured=$((configured + 1)) + fi + done + + if [[ "$configured" -eq 5 ]]; then + echo "available=true" >> "$GITHUB_OUTPUT" + elif [[ "$configured" -eq 0 ]]; then + echo "available=false" >> "$GITHUB_OUTPUT" + else + echo "::error::Apple signing is partially configured; all five Apple secrets are required" + exit 1 + fi + + - name: Import Apple app signing certificate + if: steps.apple-signing.outputs.available == 'true' + uses: Apple-Actions/import-codesign-certs@v7 + with: + p12-file-base64: ${{ secrets.APPLE_DEV_ID_APP }} + p12-password: ${{ secrets.APPLE_DEV_ID_APP_PASS }} + + - name: Check for Windows signing credentials + if: matrix.platform == 'win' && github.event_name != 'pull_request' + id: windows-signing + shell: bash + env: + AZURE_SIGNING_CLIENT_ID: ${{ secrets.AZURE_SIGNING_CLIENT_ID }} + AZURE_SIGNING_TENANT_ID: ${{ secrets.AZURE_SIGNING_TENANT_ID }} + run: | + configured=0 + for value in \ + "$AZURE_SIGNING_CLIENT_ID" \ + "$AZURE_SIGNING_TENANT_ID"; do + if [[ -n "$value" ]]; then + configured=$((configured + 1)) + fi + done + + if [[ "$configured" -eq 2 ]]; then + echo "available=true" >> "$GITHUB_OUTPUT" + elif [[ "$configured" -eq 0 ]]; then + echo "available=false" >> "$GITHUB_OUTPUT" + else + echo "::error::Windows signing is partially configured; both Azure signing secrets are required" + exit 1 + fi + + - name: Install Azure Artifact Signing client + if: steps.windows-signing.outputs.available == 'true' + shell: pwsh + run: | + $toolsDir = Join-Path $env:RUNNER_TEMP "artifact-signing-client" + New-Item -ItemType Directory -Path $toolsDir -Force | Out-Null + Push-Location $toolsDir + + Invoke-WebRequest -Uri "https://dist.nuget.org/win-x86-commandline/latest/nuget.exe" -OutFile nuget.exe + .\nuget.exe install Microsoft.ArtifactSigning.Client -x -OutputDirectory . + + $dlib = Get-ChildItem -Recurse -Filter "Azure.CodeSigning.Dlib.dll" | + Where-Object { $_.FullName -match '[\\/]x64[\\/]' } | + Select-Object -First 1 + if (-not $dlib) { + throw "Azure.CodeSigning.Dlib.dll (x64) not found after installing Microsoft.ArtifactSigning.Client" + } + + $metadataPath = Join-Path $toolsDir "metadata.json" + @{ + Endpoint = "https://wus2.codesigning.azure.net/" + CodeSigningAccountName = "ChiaNetworkInc" + CertificateProfileName = "ChiaNetworkInc" + CorrelationId = "github-actions-$env:GITHUB_RUN_ID" + } | ConvertTo-Json | Set-Content -Path $metadataPath + + Add-Content -Path $env:GITHUB_ENV -Value "AZURE_CODE_SIGNING_DLIB=$($dlib.FullName)" + Add-Content -Path $env:GITHUB_ENV -Value "AZURE_CODE_SIGNING_METADATA=$metadataPath" + Add-Content -Path $env:GITHUB_PATH -Value "C:\Program Files (x86)\Windows Kits\10\App Certification Kit" + Pop-Location + + - name: Build signed Electron installer + if: steps.apple-signing.outputs.available == 'true' + shell: bash + env: + CSC_LINK: ${{ secrets.APPLE_DEV_ID_APP }} + CSC_KEY_PASSWORD: ${{ secrets.APPLE_DEV_ID_APP_PASS }} + run: tools/build-electron.sh --platform=${{ matrix.platform }} + + - name: Build signed Windows Electron installer + if: steps.windows-signing.outputs.available == 'true' + shell: bash + env: + HAS_SIGNING_SECRET: 'true' + CSC_IDENTITY_AUTO_DISCOVERY: 'false' + AZURE_TOKEN_CREDENTIALS: prod + AZURE_TENANT_ID: ${{ secrets.AZURE_SIGNING_TENANT_ID }} + AZURE_CLIENT_ID: ${{ secrets.AZURE_SIGNING_CLIENT_ID }} + run: tools/build-electron.sh --platform=${{ matrix.platform }} + + - name: Build unsigned Electron installer + if: steps.apple-signing.outputs.available != 'true' && steps.windows-signing.outputs.available != 'true' + shell: bash + env: + CSC_IDENTITY_AUTO_DISCOVERY: 'false' + run: tools/build-electron.sh --platform=${{ matrix.platform }} + + - name: Notarize and staple macOS installers + if: steps.apple-signing.outputs.available == 'true' + shell: bash + env: + APPLE_NOTARIZE_USERNAME: ${{ secrets.APPLE_NOTARIZE_USERNAME }} + APPLE_NOTARIZE_PASSWORD: ${{ secrets.APPLE_NOTARIZE_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + run: | + for dmg in desktop/release/*.dmg; do + xcrun notarytool submit \ + --wait \ + --apple-id "$APPLE_NOTARIZE_USERNAME" \ + --password "$APPLE_NOTARIZE_PASSWORD" \ + --team-id "$APPLE_TEAM_ID" \ + "$dmg" + xcrun stapler staple "$dmg" + xcrun stapler validate "$dmg" + done + + - name: Upload installers + uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.artifact }} + if-no-files-found: error + retention-days: 14 + path: | + desktop/release/*.dmg + desktop/release/*.zip + desktop/release/*.exe + desktop/release/*.AppImage + desktop/release/*.deb 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/build.rs b/build.rs index 84fa99d22..ed2e3e644 100644 --- a/build.rs +++ b/build.rs @@ -11,6 +11,8 @@ use chialisp::classic::platform::argparse::ArgumentValue; use chialisp::compiler::comptypes::CompileErr; use chialisp::compiler::srcloc::Srcloc; +const CHIALISP_COMPILER_STACK_SIZE: usize = 128 * 1024 * 1024; + fn do_compile(title: &str, filename: &str) -> Result<(), CompileError> { let mut allocator = Allocator::new(); let mut arguments: HashMap = HashMap::new(); @@ -71,6 +73,22 @@ fn compile_chialisp() -> Result<(), CompileError> { Ok(()) } +fn compile_chialisp_with_large_stack() { + let compiler = std::thread::Builder::new() + .name("chialisp-compiler".to_string()) + .stack_size(CHIALISP_COMPILER_STACK_SIZE) + .spawn(|| { + if let Err(e) = compile_chialisp() { + panic!("error compiling chialisp: {e:?}"); + } + }) + .expect("failed to start Chialisp compiler thread"); + + if let Err(payload) = compiler.join() { + std::panic::resume_unwind(payload); + } +} + fn emit_rerun_directives(dir: &Path) { if let Ok(entries) = fs::read_dir(dir) { for entry in entries.flatten() { @@ -92,8 +110,6 @@ fn main() { println!("cargo:rerun-if-env-changed=CHIALISP_COMPILE"); if std::env::var("CHIALISP_COMPILE").is_ok() { - if let Err(e) = compile_chialisp() { - panic!("error compiling chialisp: {e:?}"); - } + compile_chialisp_with_large_stack(); } } diff --git a/desktop/README.md b/desktop/README.md new file mode 100644 index 000000000..f2b558441 --- /dev/null +++ b/desktop/README.md @@ -0,0 +1,233 @@ +# 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 | + +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. It +is a starting point rather than a fixed set: a hub typed into the in-app picker +is added to it at runtime and written back to the file. See +[Hub trust](#hub-trust). + +## Security posture + +### Process isolation + +The renderer has no Node.js reachable from it at all, and the IPC surface is a +single channel described under [Hub trust](#hub-trust). + +| 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` exposes two things and nothing else: `__chiaDistribution`, +a string the front end reads during the first render to drop web-only +affordances, and `__chiaHub.requestTrust`, the app's single IPC channel. The +string has to be a preload global rather than anything asynchronous because it is +needed before 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 ` + + + +
+
+ +
+ +

Chia Gaming

+

Two-player games secured by Chia state channels.

+ +
+
+
Version
+
+
+
+
Runtime
+
+
+
+ + + More information + + + +
+ Apache-2.0 + Chia Network +
+
+ + + diff --git a/desktop/renderer/about.mjs b/desktop/renderer/about.mjs new file mode 100644 index 000000000..fc9d112c2 --- /dev/null +++ b/desktop/renderer/about.mjs @@ -0,0 +1,13 @@ +const params = new URLSearchParams(window.location.search); + +const version = params.get('version'); +const electron = params.get('electron'); +const chromium = params.get('chromium'); + +document.querySelector('#app-version').textContent = version ?? 'Unknown'; +document.querySelector('#runtime-version').textContent = + electron !== null && chromium !== null + ? `Electron ${electron.split('.').slice(0, 2).join('.')} · Chromium ${chromium.split('.')[0]}` + : 'Unknown'; +document.querySelector('#copyright').textContent = + `Copyright © ${new Date().getFullYear()} Chia Network`; diff --git a/desktop/renderer/bootstrap.mjs b/desktop/renderer/bootstrap.mjs new file mode 100644 index 000000000..40a1b4949 --- /dev/null +++ b/desktop/renderer/bootstrap.mjs @@ -0,0 +1,13 @@ +// Hands the wasm-bindgen glue to the player app, which registers +// `window.loadWasm` from WasmStateInit and announces itself with a +// 'chia-gaming-wasm-loader-ready' event. Either module may evaluate first, so +// both orders are handled. +import * as cg from './chia_gaming_wasm.js'; + +const deliver = () => window.loadWasm(cg.default, cg); + +if (window.loadWasm) { + deliver(); +} else { + window.addEventListener('chia-gaming-wasm-loader-ready', deliver, { once: true }); +} diff --git a/desktop/renderer/index.html b/desktop/renderer/index.html new file mode 100644 index 000000000..aed21cac4 --- /dev/null +++ b/desktop/renderer/index.html @@ -0,0 +1,26 @@ + + + + + + + + Chia Gaming + + + + + + +
+ + + + diff --git a/desktop/scripts/package-app.mjs b/desktop/scripts/package-app.mjs new file mode 100644 index 000000000..710729086 --- /dev/null +++ b/desktop/scripts/package-app.mjs @@ -0,0 +1,150 @@ +// 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, writeFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +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 ELECTRON_BUILDER_CLI = createRequire(import.meta.url).resolve('electron-builder/cli.js'); + +const INSTALLER_EXTENSIONS = ['.dmg', '.zip', '.exe', '.AppImage', '.deb']; +const WINDOWS_SIGNING = process.platform === 'win32' && process.env.HAS_SIGNING_SECRET === 'true'; + +rmSync(BUILD_DIR, { recursive: true, force: true }); +mkdirSync(BUILD_DIR, { recursive: true }); + +function run(command, args) { + const result = spawnSync(command, args, { + cwd: DESKTOP, + stdio: 'inherit', + shell: false, + }); + if (result.error) { + throw result.error; + } + if (result.status !== 0) { + process.exit(result.status ?? 1); + } +} + +function electronBuilder(args) { + run(process.execPath, [ + ELECTRON_BUILDER_CLI, + '--config', + 'electron-builder.config.cjs', + `-c.directories.output=${BUILD_DIR}`, + ...args, + ]); +} + +function executablePaths(directory) { + return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const path = join(directory, entry.name); + if (entry.isDirectory()) { + return executablePaths(path); + } + return entry.isFile() && entry.name.endsWith('.exe') ? [path] : []; + }); +} + +function signAndVerify(path) { + const dlib = process.env.AZURE_CODE_SIGNING_DLIB; + const metadata = process.env.AZURE_CODE_SIGNING_METADATA; + if (!dlib || !metadata) { + throw new Error('Azure Artifact Signing client and metadata are required for Windows signing'); + } + run('signtool.exe', [ + 'sign', + '/v', + '/fd', + 'SHA256', + '/tr', + 'http://timestamp.acs.microsoft.com', + '/td', + 'SHA256', + '/dlib', + dlib, + '/dmdf', + metadata, + path, + ]); + run('signtool.exe', ['verify', '/v', '/pa', path]); +} + +async function requestAzureFederatedToken() { + const requestUrl = process.env.ACTIONS_ID_TOKEN_REQUEST_URL; + const requestToken = process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN; + if (!requestUrl || !requestToken) { + throw new Error('GitHub OIDC token is unavailable; permissions.id-token must be write'); + } + if (!process.env.AZURE_TENANT_ID || !process.env.AZURE_CLIENT_ID) { + throw new Error('AZURE_TENANT_ID and AZURE_CLIENT_ID are required for Windows signing'); + } + + const tokenUrl = new URL(requestUrl); + tokenUrl.searchParams.set('audience', 'api://AzureADTokenExchange'); + const response = await fetch(tokenUrl, { + headers: { Authorization: `Bearer ${requestToken}` }, + }); + if (!response.ok) { + throw new Error(`GitHub OIDC token request failed with status ${response.status}`); + } + + const body = await response.json(); + if (!body || typeof body !== 'object' || typeof body.value !== 'string' || !body.value) { + throw new Error('GitHub OIDC token response did not contain a token'); + } + + const tokenFile = join(tmpdir(), 'azure-federated-token'); + writeFileSync(tokenFile, body.value, { mode: 0o600 }); + process.env.AZURE_FEDERATED_TOKEN_FILE = tokenFile; +} + +const builderArgs = process.argv.slice(2); +if (WINDOWS_SIGNING) { + electronBuilder([...builderArgs, '--dir']); + const unpackedDirectory = join(BUILD_DIR, 'win-unpacked'); + await requestAzureFederatedToken(); + for (const path of executablePaths(unpackedDirectory)) { + signAndVerify(path); + } + electronBuilder([...builderArgs, '--prepackaged', unpackedDirectory]); + await requestAzureFederatedToken(); + for (const path of executablePaths(BUILD_DIR)) { + if (dirname(path) === BUILD_DIR) { + signAndVerify(path); + } + } +} else { + electronBuilder(builderArgs); +} + +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..7c96fdf92 --- /dev/null +++ b/desktop/scripts/stage-renderer.mjs @@ -0,0 +1,74 @@ +// 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', + 'about.html', + 'about.css', + 'about.mjs', + 'about-theme.js', +]) { + copyFileSync(join(DESKTOP, 'renderer', file), join(OUT, file)); +} + +copyFileSync(join(DESKTOP, 'packaging', 'icon.svg'), join(OUT, 'about-icon.svg')); + +const favicon = join(REPO, 'front-end', 'public', 'favicon.svg'); +if (existsSync(favicon)) { + copyFileSync(favicon, join(OUT, 'favicon.svg')); +} + +const errors = [ + 'index.html', + 'bootstrap.mjs', + 'about.html', + 'about.css', + 'about.mjs', + 'about-theme.js', + 'about-icon.svg', + '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/aboutWindow.ts b/desktop/src/main/aboutWindow.ts new file mode 100644 index 000000000..9e957f1f6 --- /dev/null +++ b/desktop/src/main/aboutWindow.ts @@ -0,0 +1,68 @@ +import { BrowserWindow, app, shell } from 'electron'; + +import { APP_ORIGIN } from './appProtocol'; +import { log } from './log'; + +const PROJECT_URL = 'https://github.com/Chia-Network/chia-gaming'; + +let aboutWindow: BrowserWindow | null = null; + +export function showAboutWindow(): void { + if (aboutWindow !== null && !aboutWindow.isDestroyed()) { + aboutWindow.show(); + aboutWindow.focus(); + return; + } + + const parent = BrowserWindow.getFocusedWindow() ?? BrowserWindow.getAllWindows()[0]; + const window = new BrowserWindow({ + width: 520, + height: 590, + resizable: false, + maximizable: false, + minimizable: false, + fullscreenable: false, + show: false, + parent, + title: 'About Chia Gaming', + backgroundColor: '#0d120f', + webPreferences: { + 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, + }, + }); + + aboutWindow = window; + window.setMenuBarVisibility(false); + window.webContents.setWindowOpenHandler(({ url }) => { + if (url === PROJECT_URL) { + void shell.openExternal(url).catch((error: unknown) => { + log.error(`failed to open project website: ${String(error)}`); + }); + } else { + log.warn(`blocked About window link: ${url}`); + } + return { action: 'deny' }; + }); + window.once('ready-to-show', () => window.show()); + window.on('closed', () => { + aboutWindow = null; + }); + + const query = new URLSearchParams({ + version: app.getVersion(), + electron: process.versions.electron, + chromium: process.versions.chrome, + }); + void window.loadURL(`${APP_ORIGIN}/about.html?${query.toString()}`); +} diff --git a/desktop/src/main/appProtocol.ts b/desktop/src/main/appProtocol.ts new file mode 100644 index 000000000..e5f3eeca2 --- /dev/null +++ b/desktop/src/main/appProtocol.ts @@ -0,0 +1,154 @@ +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; + +import { protocol } from 'electron'; + +import { log } from './log'; +import type { PolicyRef } from './networkPolicy'; + +/** + * 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. + */ +const APP_SCHEME = 'chiagaming'; +const APP_HOST = 'app'; +export const APP_ORIGIN = `${APP_SCHEME}://${APP_HOST}`; + +/** + * True for URLs this app serves itself. + * + * Matched on scheme and host rather than compared against `APP_ORIGIN`, because + * `URL.origin` is unusable for a scheme the URL standard does not consider + * special: Node's parser reports the origin as `"null"`, and Chromium + * serialises it as `chiagaming://app/` with a trailing slash. Neither form ever + * equals `APP_ORIGIN`, so comparing origins silently denies the app itself. + */ +export function isAppUrl(value: string): boolean { + try { + const url = new URL(value); + return url.protocol === `${APP_SCHEME}:` && url.host === APP_HOST; + } catch { + return false; + } +} + +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, policy: PolicyRef): 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. Read per document, so reloading is all it takes to apply a hub + // the user approved since this document was loaded. + if (filePath.endsWith('.html')) { + headers.set('content-security-policy', policy.current.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..5e5d328f9 --- /dev/null +++ b/desktop/src/main/config.ts @@ -0,0 +1,90 @@ +import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } 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. + * 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']; + +export const hubOriginSchema = 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(hubOriginSchema).min(1).optional(), +}); + +function configFilePath(): string { + return path.join(app.getPath('userData'), 'config.json'); +} + +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; +} + +/** + * Write the hub allowlist back to the config file, so a hub the user approved + * at runtime is still trusted next launch. Written via a temporary file and a + * rename: a crash mid-write would otherwise leave config.json truncated, and + * the app refuses to start on malformed config. + */ +export function persistHubOrigins(hubOrigins: readonly string[]): void { + const target = configFilePath(); + const contents = `${JSON.stringify({ hubOrigins }, null, 2)}\n`; + const temporary = `${target}.tmp`; + + mkdirSync(path.dirname(target), { recursive: true }); + writeFileSync(temporary, contents, { encoding: 'utf8', mode: 0o600 }); + renameSync(temporary, target); + log.info(`wrote ${hubOrigins.length} hub origin(s) to ${target}`); +} + +export function loadDesktopConfig(): DesktopConfig { + const candidate = readConfigFile(configFilePath()); + + 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()}:\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/hubTrust.ts b/desktop/src/main/hubTrust.ts new file mode 100644 index 000000000..1d716bf8b --- /dev/null +++ b/desktop/src/main/hubTrust.ts @@ -0,0 +1,59 @@ +import { ipcMain } from 'electron'; + +import { isAppUrl } from './appProtocol'; +import { hubOriginSchema, persistHubOrigins, type DesktopConfig } from './config'; +import { log } from './log'; +import { buildNetworkPolicy, type PolicyRef } from './networkPolicy'; +import { HUB_TRUST_CHANNEL, type HubTrustOutcome } from '../shared/ipc'; + +/** + * A hub is third-party infrastructure anyone can run, and players are meant to + * be able to choose one freely, so the allowlist is extensible at runtime rather + * than fixed at build time. + * + * The allowlist stays in the main process, and what that buys is that a hub + * cannot widen it. The preload exposes no bridge to sub-frames, a hub can never + * navigate the top frame, and the sender is checked below as well: the only + * caller that can reach this is the player document. + * + * That document's request is then taken at face value. It is our own bundle, + * served from `chiagaming://` under a CSP that permits no inline, remote or + * `eval`-able script, so a native prompt here would only guard against a + * compromised bundle, which could equally well fake the prompt's own UI or leave + * through the hub already on the allowlist. What a hub can see is disclosed in + * the picker instead, where the user is actually choosing. + */ +export function installHubTrustHandler(config: DesktopConfig, policy: PolicyRef): void { + ipcMain.handle(HUB_TRUST_CHANNEL, (event, rawOrigin: unknown): HubTrustOutcome => { + const frame = event.senderFrame; + if (frame === null || frame !== event.sender.mainFrame || !isAppUrl(frame.url)) { + log.warn(`ignored hub trust request from ${frame === null ? 'a gone frame' : frame.url}`); + return 'invalid'; + } + + const parsed = hubOriginSchema.safeParse(rawOrigin); + if (!parsed.success) { + log.warn(`ignored hub trust request for a malformed origin: ${String(rawOrigin)}`); + return 'invalid'; + } + const origin = parsed.data; + + if (config.hubOrigins.includes(origin)) { + return 'trusted'; + } + + const hubOrigins = [...config.hubOrigins, origin]; + try { + persistHubOrigins(hubOrigins); + } catch (error) { + log.error(`could not persist hub origins: ${(error as Error).message}`); + return 'persist-failed'; + } + + config.hubOrigins = hubOrigins; + policy.current = buildNetworkPolicy(config); + log.info(`trusted hub origin ${origin}`); + + return 'granted'; + }); +} diff --git a/desktop/src/main/index.ts b/desktop/src/main/index.ts new file mode 100644 index 000000000..3d2771555 --- /dev/null +++ b/desktop/src/main/index.ts @@ -0,0 +1,114 @@ +import path from 'node:path'; + +import { + BrowserWindow, + Menu, + app, + dialog, + session, + type MenuItemConstructorOptions, +} from 'electron'; + +import { showAboutWindow } from './aboutWindow'; +import { registerAppSchemeAsPrivileged, serveAppScheme } from './appProtocol'; +import { loadDesktopConfig, type DesktopConfig } from './config'; +import { installHubTrustHandler } from './hubTrust'; +import { log } from './log'; +import { createMainWindow } from './mainWindow'; +import { buildNetworkPolicy, type PolicyRef } from './networkPolicy'; +import { installSessionSecurity, installWebContentsSecurity } from './security'; + +// Both of these have to happen before the 'ready' event. +registerAppSchemeAsPrivileged(); +app.enableSandbox(); +app.setName('Chia Gaming'); + +function installApplicationMenu(): void { + const aboutItem: MenuItemConstructorOptions = { + label: 'About Chia Gaming', + click: showAboutWindow, + }; + const macAppMenu: MenuItemConstructorOptions = { + label: 'Chia Gaming', + submenu: [ + aboutItem, + { type: 'separator' }, + { role: 'services' }, + { type: 'separator' }, + { label: 'Hide Chia Gaming', role: 'hide' }, + { label: 'Hide Others', role: 'hideOthers' }, + { label: 'Show All', role: 'unhide' }, + { type: 'separator' }, + { label: 'Quit Chia Gaming', role: 'quit' }, + ], + }; + const helpMenu: MenuItemConstructorOptions = { + label: 'Help', + submenu: [aboutItem], + }; + const template: MenuItemConstructorOptions[] = [ + ...(process.platform === 'darwin' ? [macAppMenu] : []), + { role: 'fileMenu' }, + { role: 'editMenu' }, + { role: 'viewMenu' }, + { role: 'windowMenu' }, + ...(process.platform === 'darwin' ? [] : [helpMenu]), + ]; + + Menu.setApplicationMenu(Menu.buildFromTemplate(template)); +} + +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: PolicyRef = { current: buildNetworkPolicy(config) }; + const rendererRoot = path.join(app.getAppPath(), 'dist', 'renderer'); + + installWebContentsSecurity(policy); + installHubTrustHandler(config, policy); + + // The window is looked up in the live list rather than held in a variable: a + // saved handle would be a destroyed BrowserWindow after a close, and every + // method on one of those throws. The list is empty while startup has not + // created the window yet and while the app is shutting down after a close. + const focusMainWindow = (): void => { + const [existing] = BrowserWindow.getAllWindows(); + if (existing === undefined) { + return; + } + if (existing.isMinimized()) { + existing.restore(); + } + existing.focus(); + }; + + app.on('second-instance', focusMainWindow); + + // Closing the window quits on macOS too, unlike the platform convention: this + // is a single-window app with no document model and no tray presence, so a + // process with no window left would offer the player nothing. + app.on('window-all-closed', () => { + app.quit(); + }); + + void app.whenReady().then(() => { + installApplicationMenu(); + installSessionSecurity(session.defaultSession, policy); + serveAppScheme(rendererRoot, policy); + createMainWindow(); + + app.on('activate', focusMainWindow); + }); +} 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..d1af2fb74 --- /dev/null +++ b/desktop/src/main/mainWindow.ts @@ -0,0 +1,57 @@ +import path from 'node:path'; + +import { BrowserWindow, app, dialog } 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.on('close', (event) => { + const response = dialog.showMessageBoxSync(window, { + type: 'question', + buttons: ['Cancel', 'Quit'], + defaultId: 0, + cancelId: 0, + title: 'Quit Chia Gaming?', + message: 'Are you sure you want to quit Chia Gaming?', + }); + + if (response === 0) { + event.preventDefault(); + } + }); + + 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..9836b3567 --- /dev/null +++ b/desktop/src/main/networkPolicy.ts @@ -0,0 +1,95 @@ +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; +}; + +/** + * The policy is held behind a mutable reference because approving a hub at + * runtime widens it. Every consumer reads `current` at the moment it makes a + * decision — per request, per navigation, per document served — so a newly + * trusted origin takes effect without restarting the app. + */ +export type PolicyRef = { current: NetworkPolicy }; + +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