Merge pull request 'electron' (#4) from electron into main

Reviewed-on: eigen/fe-monorepo-template#4
This commit is contained in:
2026-04-06 03:17:59 +00:00
25 changed files with 4519 additions and 51 deletions
+6 -1
View File
@@ -12,4 +12,9 @@ public/dist
# storybook # storybook
*storybook.log *storybook.log
storybook-static storybook-static
# Electron
out/
release/
web-dist/
+87 -20
View File
@@ -5,15 +5,17 @@
![Node.js](https://img.shields.io/badge/Node.js-v24.11.1-green?style=flat\&logo=nodedotjs) ![Node.js](https://img.shields.io/badge/Node.js-v24.11.1-green?style=flat\&logo=nodedotjs)
![Vite](https://img.shields.io/badge/Vite-Bundler-blue?style=flat\&logo=vite) ![Vite](https://img.shields.io/badge/Vite-Bundler-blue?style=flat\&logo=vite)
![React](https://img.shields.io/badge/React-Framework-cyan?style=flat\&logo=react) ![React](https://img.shields.io/badge/React-Framework-cyan?style=flat\&logo=react)
![Electron](https://img.shields.io/badge/Electron-33.x-47848F?style=flat\&logo=electron)
![TypeScript](https://img.shields.io/badge/TypeScript-Language-blue?style=flat\&logo=typescript) ![TypeScript](https://img.shields.io/badge/TypeScript-Language-blue?style=flat\&logo=typescript)
![Vitest](https://img.shields.io/badge/Vitest-Testing-green?style=flat\&logo=vitest) ![Vitest](https://img.shields.io/badge/Vitest-Testing-green?style=flat\&logo=vitest)
A **scalable, enterprise-ready frontend monorepo template** built with **Turborepo**, **pnpm**, and **Vite**. A **scalable, enterprise-ready Web & Desktop monorepo** built with **Turborepo**, **pnpm**, **Vite**, and **Electron**.
This repository is designed for long-term maintainability, featuring: This repository is designed for long-term maintainability, featuring:
* Shared logic and UI libraries * Shared logic and UI libraries
* Centralized tooling configuration * Centralized tooling configuration
* Turbo-powered task orchestration and caching * Turbo-powered task orchestration and caching
* Native desktop distribution with auto-updates
* Dedicated documentation & component playground using Storybook * Dedicated documentation & component playground using Storybook
--- ---
@@ -26,6 +28,7 @@ The monorepo is organized into **Apps** (deployable applications) and **Packages
. .
├── apps/ ├── apps/
│ ├── web/ # Main React Application (Vite + TypeScript) │ ├── web/ # Main React Application (Vite + TypeScript)
│ ├── desktop/ # Electron Desktop Wrapper (electron-vite)
│ └── docs-dev/ # Component Documentation & Playground (Storybook) │ └── docs-dev/ # Component Documentation & Playground (Storybook)
├── packages/ ├── packages/
@@ -48,7 +51,7 @@ The monorepo is organized into **Apps** (deployable applications) and **Packages
Ensure your local environment matches the following versions to avoid compatibility issues: Ensure your local environment matches the following versions to avoid compatibility issues:
* **Node.js**: `v24.11.1` * **Node.js**: `v20+` (tested with `v24.11.1`) — required for the `--import tsx` flag used by the desktop prebuild script
* **pnpm**: `v8.15.6` * **pnpm**: `v8.15.6`
(Enforced via the `packageManager` field in `package.json`) (Enforced via the `packageManager` field in `package.json`)
@@ -68,22 +71,51 @@ This repository uses **Turborepo** to orchestrate tasks efficiently. All command
### Development ### Development
| Command | Description | | Command | Description |
| ------------------- | --------------------------------------------------------------------------- | | -------------------- | --------------------------------------------------------------------------- |
| `pnpm dev` | Start **all applications** (`web` and `docs-dev`) in parallel | | `pnpm dev` | Start **all applications** (`web` and `docs-dev`) in parallel |
| `pnpm dev:web` | Start only the **Main Web App** (usually at `http://localhost:5173`) | | `pnpm dev:web` | Start only the **Main Web App** (usually at `http://localhost:5173`) |
| `pnpm dev:docs-dev` | Start **Storybook** for UI development (usually at `http://localhost:6006`) | | `pnpm dev:docs-dev` | Start **Storybook** for UI development (usually at `http://localhost:6006`) |
| `pnpm dev:desktop` | Start the **Web App + Electron** in parallel for desktop development |
### Building & Quality ### Building & Quality
| Command | Description | | Command | Description |
| --------------------- | --------------------------------------------- | | --------------------- | ------------------------------------------------------- |
| `pnpm build` | Build all apps and packages using Turbo cache | | `pnpm build` | Build all apps and packages using Turbo cache |
| `pnpm build:web` | Build only the web application | | `pnpm build:web` | Build only the web application |
| `pnpm build:docs-dev` | Build only the docs-dev application | | `pnpm build:docs-dev` | Build only the docs-dev application |
| `pnpm test` | Run unit tests (Vitest) across all packages | | `pnpm build:desktop` | Build the web app, then compile the Electron app |
| `pnpm lint` | Run ESLint across the workspace | | `pnpm test` | Run unit tests (Vitest) across all packages |
| `pnpm format` | Format code using Prettier | | `pnpm lint` | Run ESLint across the workspace |
| `pnpm format` | Format code using Prettier |
### 🚀 Desktop Packaging & Distribution
To package the application into a production-ready installer, use the following commands from the **root directory**:
| Command | Platform | Output Artifact |
| ---------------------- | ----------- | ------------------------------------------ |
| `pnpm package:desktop` | Current OS | Detects host OS and builds accordingly |
| `pnpm package:mac` | macOS | `.dmg` and `.zip` (supports x64 & arm64) |
| `pnpm package:win` | Windows | `.exe` (NSIS Installer) |
| `pnpm package:linux` | Linux | `.AppImage` |
> [!IMPORTANT]
> **Build Sequence**: All `package:*` commands execute the following pipeline automatically:
>
> 1. **`turbo run build --filter=web`** — Compiles the React SPA into `apps/web/dist/`.
> 2. **`prebuild` hook** — Runs `node --import tsx scripts/copy-web-dist.ts`, which copies `apps/web/dist/` → `apps/desktop/web-dist/`.
> 3. **`electron-builder`** — Bundles `web-dist/` into the packaged app via the `files` and `extraResources` blocks in `electron-builder.yml`.
>
> You do not need to run these steps manually — they are chained via npm scripts.
> [!WARNING]
> **macOS Code Signing**: To build a distributable macOS app with Auto-Update support, you **must** have an Apple Developer Certificate and provide `CSC_LINK` and `CSC_KEY_PASSWORD` in your environment. Without code signing, macOS Gatekeeper will block the app and auto-updates will fail. See [AUTO_UPDATER.md](apps/desktop/docs/AUTO_UPDATER.md) for details.
> [!NOTE]
> **Cross-Compilation**: It is highly recommended to build for Windows on a Windows machine and for macOS on a Mac. Cross-compilation (e.g., building `.dmg` on Linux) may fail due to platform-specific toolchain dependencies. Use a CI matrix strategy (e.g., GitHub Actions with `runs-on: [macos-latest, windows-latest, ubuntu-latest]`) for multi-platform releases.
--- ---
@@ -105,7 +137,42 @@ The main consumer-facing application.
--- ---
### 2. `apps/docs-dev` (Storybook) ### 2. `apps/desktop`
The **Electron desktop wrapper** that embeds `apps/web` for native desktop experiences.
* In **development**: loads the Vite dev server with full hot reload
* In **production**: serves the static web build via a secure custom `app://` protocol
* Configurable target app via `.env` (can wrap `apps/web`, `apps/docs-dev`, or any future app)
**Tech Stack**:
* Electron 33.x
* electron-vite
* electron-builder
* electron-updater
**Key Capabilities**:
| Feature | Description |
|---|---|
| 🖨️ Native Printing | Silent and direct printing via secure IPC bridge |
| 🔄 Auto-Updates | Background downloads via GitHub Releases (switchable to S3) |
| 🔒 Secure IPC Bridge | `contextIsolation: true`, `nodeIntegration: false`, `sandbox: true` |
| 🌐 Custom Protocol | `app://` serves static files with SPA routing fallback to `index.html` |
| 🛡️ CORS Bypass | Transparent Origin header rewriting for cloud API calls |
**Desktop Documentation**:
| Document | Contents |
|---|---|
| [CONFIGURATION.md](apps/desktop/docs/CONFIGURATION.md) | Target app switching, `app://` protocol internals, HashRouter fallback |
| [AUTO_UPDATER.md](apps/desktop/docs/AUTO_UPDATER.md) | Release workflow, CI/CD variables, provider switching, code signing |
| [IPC_ARCHITECTURE.md](apps/desktop/docs/IPC_ARCHITECTURE.md) | Security model, Three-Step Bridge pattern, extending native features |
---
### 3. `apps/docs-dev` (Storybook)
An isolated environment for developing and documenting UI components. An isolated environment for developing and documenting UI components.
@@ -114,7 +181,7 @@ An isolated environment for developing and documenting UI components.
--- ---
### 3. `packages/utils` ### 4. `packages/utils`
Shared business logic and reusable utility modules that can be consumed across multiple applications. Fully tested using Vitest. Shared business logic and reusable utility modules that can be consumed across multiple applications. Fully tested using Vitest.
@@ -122,7 +189,7 @@ This package is intended to hold non-UI, cross-cutting logic such as date/time h
--- ---
### 4. `packages/ui` ### 5. `packages/ui`
Shared UI component library (Buttons, Inputs, Cards, Layouts). Shared UI component library (Buttons, Inputs, Cards, Layouts).
@@ -131,7 +198,7 @@ Shared UI component library (Buttons, Inputs, Cards, Layouts).
--- ---
### 5. `packages/configs` ### 6. `packages/configs`
Single source of truth for tooling configuration. Single source of truth for tooling configuration.
@@ -150,7 +217,7 @@ This repository uses **Turborepo caching** for builds, tests, and other artifact
To fully clean the workspace (dependencies, build outputs, and Turbo cache): To fully clean the workspace (dependencies, build outputs, and Turbo cache):
```bash ```bash
rm -rf node_modules **/*/node_modules .turbo **/*/.turbo dist **/*/dist rm -rf node_modules **/*/node_modules .turbo **/*/.turbo dist **/*/dist out **/*/out web-dist **/*/web-dist release **/*/release
``` ```
--- ---
+179
View File
@@ -0,0 +1,179 @@
# Desktop
The native gateway for our monorepo applications.
> This package serves as a secure, high-performance Electron wrapper that transforms our web-based assets into first-class desktop experiences. Built on top of **electron-vite** for near-instant development cycles and **electron-builder** for seamless cross-platform distribution.
---
## Quick Start
```bash
# From the monorepo root
# Install dependencies
pnpm install
# Development (starts both the web dev server and Electron)
pnpm dev:desktop
# Build for production
pnpm build:desktop
# Package for distribution
pnpm package:desktop
```
---
## How It Works
| Environment | Operational Logic |
|---|---|
| **Development** | Bridges the Electron shell with the Vite Dev Server, enabling Hot Module Replacement (HMR) and real-time UI synchronization at `http://localhost:5173`. |
| **Production** | Orchestrates a Secure Custom Protocol (`app://`) to serve optimized static assets, ensuring seamless SPA client-side routing via an intelligent `index.html` fallback mechanism. |
---
## Project Structure
```
apps/desktop/
├── docs/ # Architecture & Operations Documentation
│ ├── CONFIGURATION.md # Target app switching, routing fallback procedures
│ ├── AUTO_UPDATER.md # Release lifecycle, CI/CD, code signing
│ └── IPC_ARCHITECTURE.md # Security model, extensibility patterns
├── scripts/
│ └── copy-web-dist.ts # Prebuild bridge: syncs web build → web-dist/
├── src/
│ ├── main/
│ │ └── index.ts # Main process: protocol, CORS, IPC, updater
│ ├── preload/
│ │ └── index.ts # Secure contextBridge API surface
│ └── renderer/
│ └── index.html # Renderer shell
├── .env # Runtime configuration
├── electron-builder.yml # Packaging & auto-update provider config
├── electron-vite.config.ts # Three-target build config (main, preload, renderer)
├── package.json
├── tsconfig.json
├── tsconfig.main.json
├── tsconfig.preload.json
└── tsconfig.renderer.json
```
---
## Scripts
### Development & Build
| Script | Description |
|---|---|
| `pnpm dev` | Launch the electron-vite development server with live reload |
| `pnpm build` | Compile main, preload, and renderer TypeScript modules → `out/` |
| `pnpm prebuild` | Synchronize the target web app's build output via `scripts/copy-web-dist.ts` — copies `apps/<DESKTOP_TARGET_APP>/dist/``web-dist/`. Invoked automatically before `pnpm build`. |
| `pnpm preview` | Preview the compiled Electron app locally without generating a distributable |
### 🚀 Packaging & Distribution
To generate a production-ready installer, execute from **within `apps/desktop/`** or use the root-level `pnpm package:*` commands, which orchestrate the full pipeline automatically:
| Command | Platform | Output Artifact |
|---|---|---|
| `pnpm package` | Current OS | Detects host OS and builds accordingly |
| `pnpm package:mac` | macOS | `.dmg` and `.zip` (supports x64 & arm64) |
| `pnpm package:win` | Windows | `.exe` (NSIS Installer) |
| `pnpm package:linux` | Linux | `.AppImage` |
All artifacts are emitted to the `release/` directory.
> [!IMPORTANT]
> **Deterministic Build Pipeline**: All `package:*` commands strictly enforce a deterministic build pipeline: compiling web assets via Turborepo, synchronizing the output via the `prebuild` bridge (`node --import tsx scripts/copy-web-dist.ts`), and finally generating the native binary through `electron-builder`.
>
> **Running locally within `apps/desktop/`**: These scripts assume the web app has already been compiled. Either run `pnpm build --filter=web` beforehand, or use the root-level `pnpm package:*` commands which handle the complete orchestration.
> [!WARNING]
> **macOS Code Signing**: Distributable macOS builds with Auto-Update capability **require** an Apple Developer Certificate. Provide the following environment variables:
> ```bash
> CSC_LINK=<base64-encoded .p12 certificate>
> CSC_KEY_PASSWORD=<certificate password>
> APPLE_ID=<your apple id>
> APPLE_APP_SPECIFIC_PASSWORD=<app-specific password>
> APPLE_TEAM_ID=<team id>
> ```
> Without valid code signing, macOS Gatekeeper will quarantine the application and `electron-updater` will reject update payloads. See [docs/AUTO_UPDATER.md](docs/AUTO_UPDATER.md) for the complete requirements.
> [!NOTE]
> **Cross-Compilation Advisory**: It is strongly recommended to build for each platform on its native OS. Cross-compilation (e.g., producing `.dmg` on Linux) may fail due to platform-specific toolchain dependencies. For CI, leverage a matrix strategy:
> ```yaml
> strategy:
> matrix:
> os: [macos-latest, windows-latest, ubuntu-latest]
> runs-on: ${{ matrix.os }}
> ```
---
## Configuration
The target web application is configured via `.env`:
```env
DESKTOP_TARGET_APP=web
DESKTOP_DEV_SERVER_URL=http://localhost:5173
```
See [docs/CONFIGURATION.md](docs/CONFIGURATION.md) for comprehensive guidance on target app switching, protocol internals, and the HashRouter fallback procedure.
---
## Core Capabilities
### 🌐 Custom `app://` Protocol
Provides a secure file-serving layer with built-in **Path Traversal Protection** and automated **CSP Header Injection**. All requests to unknown paths are intelligently rerouted to `index.html`, enabling React Router to resolve routes client-side without blank screens or 404 errors.
### 🖨️ Hardware Bridge
Enables granular control over system peripherals — such as printers — through an asynchronous IPC communication layer. The React app can enumerate connected printers and dispatch print jobs via `window.electronAPI.getPrinters()` and `window.electronAPI.print()`, all without exposing native APIs to the renderer.
### 🔄 Auto-Update Engine
A fully managed update lifecycle powered by `electron-updater`. Background download progress is forwarded in real-time to the React UI via IPC event subscriptions, enabling rich notification experiences. See [docs/AUTO_UPDATER.md](docs/AUTO_UPDATER.md).
### 🛡️ CORS Bypass Proxy
A transparent proxy mechanism that handles cross-origin requests by sanitizing non-standard `app://` and `file://` Origin headers on outgoing requests and injecting permissive CORS response headers on incoming responses — allowing seamless integration with cloud APIs without server-side configuration changes.
---
## Hardened Security Perimeter
The Desktop Wrapper enforces a **hardened security perimeter**, strictly isolating the Node.js Main Process from the Renderer Context. Our architecture is built upon the principle of **Least Privilege**, ensuring that the web application only interacts with system hardware through a verified, secure IPC bridge.
| Setting | Value | Purpose |
|---|---|---|
| `contextIsolation` | `true` | Preload executes in a hermetically sealed JavaScript context |
| `nodeIntegration` | `false` | Zero Node.js API surface exposed to the renderer |
| `sandbox` | `true` | Chromium OS-level sandbox enforced |
| `webSecurity` | `true` | Same-origin policy strictly upheld |
**Defense-in-depth protections in `src/main/index.ts`**:
- **Path Traversal Guard** — The `app://` protocol handler validates all resolved file paths remain within the `web-dist/` boundary using `normalize()` + `startsWith()`. Traversal attempts like `app://-/../../etc/passwd` are met with `403 Forbidden`.
- **CSP Header Injection** — Content-Security-Policy headers are injected as HTTP response headers on every HTML response served by the custom protocol — not via a `<meta>` tag — ensuring they cannot be stripped or bypassed by injected scripts.
- **Origin Sanitization** — `session.defaultSession.webRequest` intercepts all outgoing requests, stripping `app://` / `file://` Origin headers to prevent backend CORS rejections.
- **Navigation Guard** — The `will-navigate` event intercepts and blocks all navigation attempts to URLs outside the `app://` protocol and the authorized dev server origin.
See [docs/IPC_ARCHITECTURE.md](docs/IPC_ARCHITECTURE.md) for the full security model, the Three-Step Bridge pattern, and guidance on safely extending the app with new native features.
---
## Documentation
| Document | Scope |
|---|---|
| [CONFIGURATION.md](docs/CONFIGURATION.md) | Target app switching, `app://` protocol internals, HashRouter fallback procedure |
| [AUTO_UPDATER.md](docs/AUTO_UPDATER.md) | Release lifecycle, CI/CD variables, provider switching, code signing |
| [IPC_ARCHITECTURE.md](docs/IPC_ARCHITECTURE.md) | Security model, Three-Step Bridge pattern, existing IPC channels, extensibility guide |
+398
View File
@@ -0,0 +1,398 @@
# Auto-Update System
The Unified Update Lifecycle.
> This document defines the strategic implementation of our cross-platform auto-update system. Powered by **electron-updater**, this architecture ensures seamless, background delivery of application patches, maintaining version parity across our global user base.
---
## Table of Contents
- [Reactive Update Flow](#reactive-update-flow)
- [Current Provider: GitHub Releases](#current-provider-github-releases)
- [Release Workflow: The Deterministic Pipeline](#release-workflow-the-deterministic-pipeline)
- [CI/CD Environment Variables](#cicd-environment-variables)
- [Deployment Strategies](#deployment-strategies)
- [Code Signing: The Trust Boundary](#code-signing-the-trust-boundary)
- [Testing Updates in Development](#testing-updates-in-development)
- [Diagnostic Runbook](#diagnostic-runbook)
---
## Reactive Update Flow
The following diagram illustrates the **Reactive Update Flow**, bridging the Node.js Main Process with the React UI layer through a secure IPC event stream.
```
┌──────────────────────────────────────────────────────────────────┐
│ Main Process (src/main/index.ts) │
│ │
│ autoUpdater.checkForUpdatesAndNotify() │
│ │ │
│ ├─→ 'checking-for-update' │
│ ├─→ 'update-available' → { version, releaseDate } │
│ ├─→ 'download-progress' → { percent, bytesPerSecond } │
│ ├─→ 'update-downloaded' → { version, releaseNotes } │
│ └─→ 'error' → { message } │
│ │
│ sendToRenderer('updater:*', payload) │
├──────────────────────── IPC Bridge ──────────────────────────────┤
│ Preload (src/preload/index.ts) │
│ │
│ contextBridge.exposeInMainWorld('electronAPI', { │
│ onUpdateAvailable, onDownloadProgress, │
│ onUpdateDownloaded, onUpdateError, │
│ checkForUpdates, installUpdate │
│ }) │
├──────────────────────── Renderer ────────────────────────────────┤
│ React App (apps/web) │
│ │
│ useElectronUpdater() hook │
│ → Reactive state: status, progress, updateInfo, errorMessage │
│ → Actions: checkForUpdates(), installUpdate() │
└──────────────────────────────────────────────────────────────────┘
```
### Lifecycle Sequence
1. **App launch** → After a 3-second initialization delay, `autoUpdater.checkForUpdatesAndNotify()` is invoked.
2. **Update detected** → If `autoDownload` is `true` (default), the binary payload downloads in the background.
3. **Progress streaming**`download-progress` events are forwarded to the renderer via IPC in real-time.
4. **Download complete** → The renderer surfaces a "Restart to Update" prompt to the user.
5. **User-initiated install**`autoUpdater.quitAndInstall()` terminates the current process and launches the updated binary.
---
## Current Provider: GitHub Releases
The update provider is declared in `electron-builder.yml`:
```yaml
publish:
provider: github
owner: YOUR_GITHUB_ORG
repo: YOUR_REPO_NAME
```
### Operational Mechanics
1. When `electron-builder --publish always` executes, it:
- Compiles the application for the target platform.
- Uploads the installer(s) to a **GitHub Release** tagged with the version from `package.json`.
- Generates and uploads the platform-specific manifest: `latest.yml` (Windows), `latest-mac.yml` (macOS), or `latest-linux.yml` (Linux).
2. When the packaged application calls `checkForUpdates()`, `electron-updater`:
- Reads `app-update.yml` from the app's `resources/` directory (auto-generated during build — never manually created).
- Fetches the appropriate `latest*.yml` manifest from the configured release endpoint.
- Performs a semantic version comparison and initiates the download if a newer version exists.
---
## Release Workflow: The Deterministic Pipeline
To maintain release integrity, follow this deterministic pipeline to synchronize web assets and native binaries.
### Manual Release
```bash
# 1. Version bump — semver discipline
cd apps/desktop
npm version patch # or: minor, major
# 2. Compile web assets
cd ../..
pnpm build --filter=web
# 3. Synchronize, compile, and publish
cd apps/desktop
pnpm run prebuild
GH_TOKEN=your_token electron-builder --publish always --config electron-builder.yml
```
> [!CAUTION]
> **Treat `GH_TOKEN` as a critical secret.** It grants write access to your repository's release assets. Never commit it to version control, never log it in CI output, and always inject it via encrypted secrets or a vault.
### Automated Release (GitHub Actions)
```yaml
name: Release Desktop
on:
push:
tags:
- 'desktop-v*'
jobs:
release:
strategy:
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install
- run: pnpm build --filter=web
- run: cd apps/desktop && pnpm run prebuild
- run: cd apps/desktop && pnpm run build
- name: Publish
run: cd apps/desktop && npx electron-builder --publish always --config electron-builder.yml
env:
GH_TOKEN: ${{ secrets.GH_TOKEN }}
CSC_LINK: ${{ secrets.CSC_LINK }}
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
```
---
## CI/CD Environment Variables
| Variable | Required | Platform | Description |
|---|---|---|---|
| `GH_TOKEN` | Yes | All | GitHub personal access token with `repo` scope. Authorizes release artifact uploads. |
| `CSC_LINK` | macOS/Windows | macOS, Windows | Base64-encoded `.p12` code signing certificate. Generate with: `base64 -i cert.p12 \| pbcopy` |
| `CSC_KEY_PASSWORD` | macOS/Windows | macOS, Windows | Passphrase for the `.p12` certificate. |
| `APPLE_ID` | macOS only | macOS | Apple ID email for notarization submission. |
| `APPLE_APP_SPECIFIC_PASSWORD` | macOS only | macOS | App-specific password generated at [appleid.apple.com](https://appleid.apple.com). |
| `APPLE_TEAM_ID` | macOS only | macOS | Your Apple Developer Team ID. |
| `WIN_CSC_LINK` | Windows only | Windows | Separate Windows code signing certificate (if different from `CSC_LINK`). |
| `WIN_CSC_KEY_PASSWORD` | Windows only | Windows | Passphrase for the Windows certificate. |
### Configuring Secrets
1. Navigate to **Settings → Secrets and variables → Actions** in your GitHub repository.
2. Add each variable as a **Repository secret**.
3. Reference them in workflow files as `${{ secrets.VARIABLE_NAME }}`.
---
## Deployment Strategies
### AWS S3 (Private Infrastructure)
For enterprise environments requiring private infrastructure, the system can be reconfigured to target an **AWS S3 Bucket** or a **CloudFront Distribution**.
Update `electron-builder.yml`:
```yaml
publish:
provider: s3
bucket: your-bucket-name
region: ap-southeast-1
path: /desktop-releases
acl: private
```
**Additional environment variables:**
| Variable | Description |
|---|---|
| `AWS_ACCESS_KEY_ID` | IAM access key with S3 `PutObject` and `GetObject` permissions |
| `AWS_SECRET_ACCESS_KEY` | IAM secret key |
**Bucket structure:**
```
your-bucket/desktop-releases/
├── latest.yml (Windows manifest)
├── latest-mac.yml (macOS manifest)
├── latest-linux.yml (Linux manifest)
├── EigenDesktop-Setup-0.2.0.exe
├── EigenDesktop-0.2.0.dmg
├── EigenDesktop-0.2.0-mac.zip
└── EigenDesktop-0.2.0.AppImage
```
> [!NOTE]
> The bucket must allow public read access to the manifest files (`latest*.yml`), or you must configure a CloudFront distribution. `electron-updater` performs unauthenticated `GET` requests to resolve the latest version.
### Generic File Server (Self-Hosted)
For self-hosted infrastructure (Nginx, Caddy, etc.):
```yaml
publish:
provider: generic
url: https://updates.your-domain.com/desktop
```
Your server must host the same directory structure as the S3 layout above.
> [!IMPORTANT]
> **MIME Type Configuration**: Ensure your file server correctly serves `.yml` files with `text/yaml` and installer binaries with `application/octet-stream`. Incorrect MIME types will cause download corruption or silent update failures.
**Nginx reference:**
```nginx
server {
listen 443 ssl;
server_name updates.your-domain.com;
location /desktop/ {
alias /var/www/desktop-releases/;
autoindex off;
add_header Cache-Control "no-cache";
# MIME types for update manifests
types {
text/yaml yml;
application/octet-stream exe dmg AppImage zip;
}
}
}
```
---
## Code Signing: The Trust Boundary
> [!WARNING]
> **Code signing is not merely a requirement — it is the Trust Boundary established by the operating system.** macOS Gatekeeper will explicitly terminate unsigned applications or refuse background updates to maintain system integrity. Windows SmartScreen will display alarming warnings to users. Without valid signatures, `electron-updater` will **reject update payloads entirely**.
### macOS
- Requires an **Apple Developer ID Application** certificate ($99/year Apple Developer Program).
- The `electron-builder.yml` is configured with:
```yaml
mac:
hardenedRuntime: true
gatekeeperAssess: false
entitlements: build/entitlements.mac.plist
entitlementsInherit: build/entitlements.mac.plist
```
- You must create `apps/desktop/build/entitlements.mac.plist`:
```xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
<true/>
</dict>
</plist>
```
- **Notarization** is mandatory for macOS 10.15+. Provide `APPLE_ID`, `APPLE_APP_SPECIFIC_PASSWORD`, and `APPLE_TEAM_ID`.
### Windows
- Requires an **EV Code Signing Certificate** or a standard code signing certificate from a trusted CA.
- Without signing, Windows SmartScreen warns users with "Windows protected your PC" — severely impacting adoption.
- **EV certificates** eliminate SmartScreen warnings immediately; standard certificates build trust reputation over time through Microsoft's telemetry.
### Linux
- Code signing is **not enforced** by the OS for AppImage distribution.
- Optional GPG signing is available for package managers that support it.
---
## Testing Updates in Development
> [!NOTE]
> The auto-updater is **intentionally disabled** in development mode to prevent runtime crashes. Setting `forceDevUpdateConfig` requires a `dev-app-update.yml` file, which introduces unnecessary complexity during local development.
### What Happens in Dev Mode
In `src/main/index.ts`, the `setupAutoUpdaterEvents()` function detects `IS_DEV` and returns early:
```typescript
if (IS_DEV) {
autoUpdater.autoDownload = false;
return; // Skip event registration — no update server in dev
}
```
This means:
- No update check is performed on startup.
- No `electron-updater` events are emitted.
- The `useElectronUpdater()` hook will remain in `idle` status.
### How to Test Updates
Auto-update can **only** be fully validated using a **packaged, signed build** distributed through a real update channel:
1. **Publish v0.1.0** → Package and release a signed build.
2. **Bump to v0.2.0** → Increment the version in `package.json`.
3. **Publish v0.2.0** → Package and release the updated build.
4. **Launch v0.1.0** → The app should detect v0.2.0, download it, and prompt the user to restart.
For rapid iteration, use the `generic` provider pointing to a local Nginx or Python HTTP server:
```yaml
# electron-builder.yml (temporary, for testing)
publish:
provider: generic
url: http://localhost:8080/updates
```
```bash
# Serve the release directory locally
cd apps/desktop/release
python3 -m http.server 8080 --directory .
```
---
## Diagnostic Runbook
### Issue: "Update check failed" on startup
| | |
|---|---|
| **Symptom** | Console logs `[AutoUpdater] Startup check failed (possibly offline)` |
| **Root Cause** | The machine is offline, or the update server (GitHub/S3/generic) is unreachable. |
| **Resolution** | No action required. The error is caught in a `try/catch` block, logged to the console, and the application continues to function normally. The next check will occur on the next app launch. |
---
### Issue: `app-update.yml` not found in production build
| | |
|---|---|
| **Symptom** | `electron-updater` throws "Cannot find app-update.yml" immediately after launch. |
| **Root Cause** | The `publish` block in `electron-builder.yml` is missing or misconfigured. `electron-builder` generates `app-update.yml` only when a valid provider is declared. |
| **Resolution** | Verify the `publish` block exists in `electron-builder.yml`. Run `electron-builder --publish never` and inspect `release/*/resources/app-update.yml` to confirm generation. |
---
### Issue: "Cannot update: code signature is invalid" (macOS)
| | |
|---|---|
| **Symptom** | The updater downloads a new version but refuses to apply it, logging a signature validation error. |
| **Root Cause** | The application was not signed, or the signing certificate has expired / been revoked. |
| **Resolution** | Ensure `CSC_LINK` and `CSC_KEY_PASSWORD` are correctly set in CI. Verify the packaged app with: `codesign --verify --deep --strict release/mac*/Desktop.app`. Re-sign and re-publish if the certificate was rotated. |
---
### Issue: Updates work on Windows/Linux but not macOS
| | |
|---|---|
| **Symptom** | Windows and Linux users receive updates, but macOS users see no update prompt. |
| **Root Cause** | macOS requires **both** a valid code signature AND Apple notarization. Without notarization, Gatekeeper silently quarantines the update payload. |
| **Resolution** | Provide all Apple credential environment variables (`APPLE_ID`, `APPLE_APP_SPECIFIC_PASSWORD`, `APPLE_TEAM_ID`) and ensure `hardenedRuntime: true` is set in `electron-builder.yml`. Re-package and re-publish. |
---
### Issue: S3/Generic provider returns corrupted downloads
| | |
|---|---|
| **Symptom** | Users report that the update downloads but fails to install, or the downloaded file is 0 bytes. |
| **Root Cause** | The file server is serving update manifests or binaries with incorrect MIME types, or a CDN is caching stale `latest*.yml` files. |
| **Resolution** | Verify MIME types: `.yml` → `text/yaml`, `.exe`/`.dmg`/`.AppImage`/`.zip` → `application/octet-stream`. Add `Cache-Control: no-cache` headers to `latest*.yml` responses. Invalidate CDN cache after publishing a new release. |
+249
View File
@@ -0,0 +1,249 @@
# Configuration Guide
The Blueprint for Runtime Control.
> This guide defines the operational parameters of the Desktop Wrapper. It governs the orchestration of target applications, encapsulates the mechanics of our proprietary production routing, and provides a fail-safe **Break Glass Procedure** for emergency infrastructure transitions.
---
## Table of Contents
- [Target App Orchestration](#target-app-orchestration)
- [Environment Variables Registry](#environment-variables-registry)
- [Deterministic Path Resolution](#deterministic-path-resolution)
- [Production Routing: Overcoming Protocol Constraints](#production-routing-overcoming-protocol-constraints)
- [Defense-in-Depth: Multi-Layered Protection](#defense-in-depth-multi-layered-protection)
- [Break Glass Procedure: Disaster Recovery Protocol](#break-glass-procedure-disaster-recovery-protocol)
---
## Target App Orchestration
The Desktop Wrapper is architected to embed **any** web application within the monorepo ecosystem. The target application is resolved at build time through a declarative configuration surface in `apps/desktop/.env`.
### `.env` Declaration
```env
# The workspace identifier of the target web application.
# Must correspond to a directory under apps/ (e.g., "web", "docs-dev", "admin").
DESKTOP_TARGET_APP=web
# The Vite development server endpoint for the target application.
DESKTOP_DEV_SERVER_URL=http://localhost:5173
```
### Switching the Target Application
To redirect the wrapper to a different application — for example, `apps/admin` — modify the configuration and re-execute the build pipeline:
1. **Update the `.env` declaration:**
```env
DESKTOP_TARGET_APP=admin
DESKTOP_DEV_SERVER_URL=http://localhost:3001
```
2. **Verify the target app exports a `build` script** that emits static assets to `dist/`.
3. **Execute the deterministic build pipeline:**
```bash
pnpm build --filter=admin && cd apps/desktop && pnpm run build
```
### The Deployment Bridge
The `prebuild` hook invokes `scripts/copy-web-dist.ts`, which serves as the **Deployment Bridge** between the web workspace and the native container. It reads `DESKTOP_TARGET_APP`, resolves the corresponding `apps/<target>/dist/` directory, and synchronizes the contents into `apps/desktop/web-dist/`. This bridge directory is then ingested by `electron-builder` via both the `files` and `extraResources` declarations in `electron-builder.yml`.
```
┌─────────────────────────────┐ Deployment Bridge ┌──────────────────────────┐
│ apps/<target>/dist/ │ ─── copy-web-dist.ts ────────→ │ apps/desktop/web-dist/ │
│ (Vite build output) │ prebuild hook │ (Native container) │
└─────────────────────────────┘ └──────────────────────────┘
electron-builder
files + extraResources
┌──────────────────────┐
│ Packaged .app/.exe │
│ resources/web-dist/ │
└──────────────────────┘
```
---
## Environment Variables Registry
| Variable | Default | Security Scope | Consumer | Description |
|---|---|---|---|---|
| `DESKTOP_TARGET_APP` | `web` | Build-time | `copy-web-dist.ts` | Workspace identifier of the web app to embed |
| `DESKTOP_DEV_SERVER_URL` | `http://localhost:5173` | Runtime (dev) | `src/main/index.ts` | Dev server URL loaded in the Electron window during development |
| `GH_TOKEN` | — | CI/CD | `electron-builder` | GitHub personal access token for publishing releases |
| `CSC_LINK` | — | CI/CD | `electron-builder` | Base64-encoded `.p12` code signing certificate |
| `CSC_KEY_PASSWORD` | — | CI/CD | `electron-builder` | Passphrase for the `.p12` certificate |
| `APPLE_ID` | — | CI/CD (macOS) | `electron-builder` | Apple ID email for notarization submission |
| `APPLE_APP_SPECIFIC_PASSWORD` | — | CI/CD (macOS) | `electron-builder` | App-specific password for notarization |
| `APPLE_TEAM_ID` | — | CI/CD (macOS) | `electron-builder` | Apple Developer Team ID |
> [!IMPORTANT]
> **Build-time** variables are consumed during the `prebuild` phase and baked into the artifact. **Runtime** variables are read by the Electron main process at launch. **CI/CD** variables are secrets injected exclusively in the deployment environment — they must never appear in source control or local `.env` files.
---
## Deterministic Path Resolution
The following matrix defines how the target app's static assets are resolved across every phase of the application lifecycle. Each path is **deterministic** — there is no runtime ambiguity.
| Phase | Resolution Strategy | Resolved Path | Context |
|---|---|---|---|
| **Prebuild** | `copy-web-dist.ts` reads `DESKTOP_TARGET_APP` | `monorepo-root/apps/<target>/dist/` → `apps/desktop/web-dist/` | Deployment Bridge: build-time synchronization |
| **Development** | `__dirname` relative traversal from `out/main/` | `apps/desktop/out/main/` → `../../` → `apps/` → `<target>/dist/` | Direct filesystem access to the web app's build output |
| **Production** | `process.resourcesPath` | `Contents/Resources/web-dist/` (macOS) / `resources/web-dist/` (Windows/Linux) | OS-specific resource directory within the packaged binary |
> [!NOTE]
> The development path relies on `__dirname` pointing to `apps/desktop/out/main/` at runtime. If electron-vite's output directory is ever reconfigured, this traversal must be updated in `getWebDistPath()` within `src/main/index.ts`.
---
## Production Routing: Overcoming Protocol Constraints
### The Constraint
React applications using `BrowserRouter` rely on a fundamental server-side contract: **every URL path must return `index.html`**. Paths like `/dashboard`, `/auth/login`, and `/settings/profile` do not correspond to physical files — they are virtual routes resolved entirely by the client-side router.
Electron's default `file://` protocol breaks this contract. Requesting `file:///app/dashboard` triggers a literal filesystem lookup for a file named `dashboard`, which does not exist, resulting in a blank screen or an OS-level "file not found" error.
### The Solution: A Privileged Virtual File System
The `app://` scheme is a **Privileged Virtual File System** that resolves SPA routing conflicts by implementing a **Heuristic Resource Loader**. It operates as follows:
```
Request: app://-/settings/profile
├─ Decode URI → "settings/profile"
├─ Normalize + validate path (security boundary check)
├─ Does web-dist/settings/profile exist as a file?
│ ├─ YES → Serve with correct MIME type + CSP headers
│ └─ NO → Heuristic Fallback: serve web-dist/index.html
└─ React Router resolves /settings/profile client-side
```
If a requested URI does not map to a physical asset, the handler intelligently intercepts the request to serve the `index.html` entry point, allowing React Router to maintain stateful client-side navigation. This ensures that deep links, page refreshes, and direct URL entry all function without modification to the React app's routing configuration.
### Scheme Registration
The scheme **must** be registered synchronously at module load time, before `app.whenReady()`. This is a Chromium requirement — deferred registration will silently fail:
```typescript
protocol.registerSchemesAsPrivileged([
{
scheme: 'app',
privileges: {
standard: true, // Enables URL parsing (host, path, query)
secure: true, // Treated as a secure origin (HTTPS equivalent)
supportFetchAPI: true, // Allows fetch() from this scheme
corsEnabled: true, // Enables CORS for cross-origin requests
stream: true, // Supports streaming responses
},
},
]);
```
---
## Defense-in-Depth: Multi-Layered Protection
The custom protocol handler enforces a **multi-layered defense perimeter** that goes beyond standard Electron security defaults.
| Layer | Technique | Implementation | Threat Mitigated |
|---|---|---|---|
| **I/O Sanitization** | Path traversal guard | `normalize()` + `startsWith()` validation against `web-dist/` boundary | Directory traversal attacks (`../../etc/passwd`) → `403 Forbidden` |
| **In-Flight Policy Injection** | CSP response headers | `Content-Security-Policy` injected as HTTP response headers on every HTML payload | XSS execution via script injection |
| **Cryptographic Isolation** | Privileged scheme registration | `app` scheme registered with `standard`, `secure`, `supportFetchAPI`, `corsEnabled` | Scheme downgrade attacks; the renderer treats `app://` identically to `https://` |
| **Resource Type Validation** | `statSync.isFile()` check | Only regular files are served; directories return the SPA fallback | Information disclosure via directory listing |
| **Origin Sanitization** | CORS bypass proxy | `webRequest.onBeforeSendHeaders` strips `app://` Origin headers on outgoing requests | Backend CORS rejection of non-standard origins |
| **Navigation Confinement** | `will-navigate` guard | Blocks navigation to URLs outside `app://` and the authorized dev server | Phishing via in-app redirect to malicious sites |
---
## Break Glass Procedure: Disaster Recovery Protocol
> [!CAUTION]
> **This is a formal Disaster Recovery Protocol.** Execute only if the custom `app://` protocol causes an irrecoverable failure — for example, a critical third-party library that refuses to operate under a non-standard URI scheme. This procedure requires coordinated changes across both the React application and the Electron main process. Estimated recovery time: **15 minutes**.
### Step 1: Switch the Router — React Application
In the target web app's entry point (e.g., `apps/web/src/apps/index.tsx`):
```diff
- import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom';
+ import { HashRouter, Navigate, Route, Routes } from 'react-router-dom';
export default function App() {
return (
<ThemeProvider colorScheme={colorScheme} density={density}>
- <BrowserRouter>
+ <HashRouter>
<Suspense fallback={<div>Loading...</div>}>
<Routes>
{/* All route definitions remain unchanged */}
</Routes>
</Suspense>
- </BrowserRouter>
+ </HashRouter>
</ThemeProvider>
);
}
```
All routes transition to hash-based addressing: `#/app/dashboard`, `#/auth/login`, `#/showcase`.
### Step 2: Decommission the Custom Protocol — Main Process
In `apps/desktop/src/main/index.ts`, execute the following surgical removals:
**a)** Remove the scheme registration block at the top of the file:
```diff
- protocol.registerSchemesAsPrivileged([ ... ]);
```
**b)** Delete the entire `registerAppProtocol()` function.
**c)** Remove the `registerAppProtocol()` invocation inside `app.whenReady()`.
**d)** Redirect production content loading in `createWindow()`:
```diff
if (IS_DEV) {
mainWindow.loadURL(DEV_SERVER_URL);
mainWindow.webContents.openDevTools({ mode: 'detach' });
} else {
- mainWindow.loadURL('app://-/index.html');
+ const webDistPath = getWebDistPath();
+ mainWindow.loadFile(join(webDistPath, 'index.html'));
}
```
**e)** Inject a CSP `<meta>` tag into the web app's `index.html`, since the In-Flight Policy Injection layer is no longer available:
```html
<meta http-equiv="Content-Security-Policy"
content="default-src 'self' file:; script-src 'self' file:;
style-src 'self' 'unsafe-inline' file:;
connect-src 'self' https:;
img-src 'self' file: data: https:;
font-src 'self' file: data:;" />
```
### Trade-off Analysis
| Dimension | Custom `app://` Protocol | `file://` + HashRouter |
|---|---|---|
| **Aesthetic Integrity** | Clean URLs: `/app/dashboard` | Hash prefix: `#/app/dashboard` |
| **Router Compatibility** | `BrowserRouter` — zero changes required | Must migrate to `HashRouter` |
| **Deep Linking** | Full, native-style support | Hash-based only |
| **Protocol-Native Compatibility** | Rare edge cases with non-standard scheme detection | Maximum third-party compatibility |
| **Security Delivery Vector** | CSP via response headers (strongest enforcement) | CSP via `<meta>` tag (bypassable by early script execution) |
| **Implementation Complexity** | Higher (custom protocol handler + security layers) | Lower (no custom protocol infrastructure) |
| **Recovery Time** | — | ~15 minutes, 2 files |
+420
View File
@@ -0,0 +1,420 @@
# IPC Architecture & Security Model
The Secure Communication Blueprint.
> This document defines the **Hardened Security Perimeter** and communication topology governing the Desktop Wrapper. Every native capability exposed to the renderer is mediated through a **Non-Bypassable IPC Bridge**, ensuring that the Node.js Main Process remains cryptographically and logically isolated from untrusted web content. Adherence to this document is **mandatory** — deviations constitute security violations subject to immediate remediation.
---
## Table of Contents
- [Privilege Separation Model](#privilege-separation-model)
- [Standard Operating Procedure: The Three-Step Bridge](#standard-operating-procedure-the-three-step-bridge)
- [Verified Channel Manifest](#verified-channel-manifest)
- [Extending the Bridge: Guided Walkthrough](#extending-the-bridge-guided-walkthrough)
- [Critical Audit Checklist: Anti-Patterns](#critical-audit-checklist-anti-patterns)
- [The Gold Standard for Native Integration](#the-gold-standard-for-native-integration)
---
## Privilege Separation Model
The desktop wrapper enforces a **strict privilege separation** between three execution contexts, each operating under fundamentally different trust levels. This architecture ensures that a compromise in any single layer cannot escalate to full system access.
### Trust Level Matrix
| Context | Trust Level | Privilege Scope | Security Guarantee |
|---|---|---|---|
| **Main Process** | Fully Trusted | Unrestricted Node.js access: filesystem, network, printers, OS APIs, child processes | Only code authored by the engineering team executes here |
| **Preload Script** | Controlled | Restricted to `ipcRenderer.invoke()` and `ipcRenderer.send()` — no direct Node.js access | Executes in a **Hermetically Sealed Context** — isolated from both the Main Process globals and the Renderer's DOM |
| **Renderer** | Untrusted | Standard browser sandbox — zero Node.js API surface | Designated as a **Zero-Trust Environment** — may execute third-party code, npm packages, or XSS payloads |
### Process Topology
```
┌──────────────────────────────────────────────────────────────────┐
│ MAIN PROCESS [Fully Trusted] │
│ │
│ UNRESTRICTED PRIVILEGES │
│ Filesystem · Network · Printers · Native APIs · Child Processes │
│ Auto-Updater · OS Integration · System Notifications │
│ │
│ ipcMain.handle('channel', handler) ← Command handlers │
│ ipcMain.on('channel', handler) ← Event listeners │
│ webContents.send('channel', data) ← Downstream push │
├───────────── Non-Bypassable Isolation Boundary ─────────────────┤
│ PRELOAD SCRIPT [Secure Gateway] │
│ │
│ HERMETICALLY SEALED CONTEXT │
│ Performs Interface Narrowing: transforms broad IPC capabilities │
│ into a minimal, auditable API surface. Acts as the sole │
│ authorized mediator between trusted and untrusted contexts. │
│ │
│ contextBridge.exposeInMainWorld('electronAPI', { ... }) │
├───────────── Non-Bypassable Isolation Boundary ─────────────────┤
│ RENDERER [Zero-Trust Environment] │
│ │
│ UNTRUSTED WEB CONTENT │
│ Standard browser sandbox. Zero access to: require, __dirname, │
│ process, fs, child_process, net, os, ipcRenderer. │
│ │
│ ONLY authorized interaction vector: │
│ window.electronAPI.methodName(args) │
└──────────────────────────────────────────────────────────────────┘
```
The Preload Script functions as a **Secure Gateway** that performs **Interface Narrowing** — it transforms the broad, unrestricted IPC capabilities of the Main Process into a deliberately narrow, type-safe API surface. The renderer communicates with native functionality **exclusively** through this gateway. There are no alternative paths, no escape hatches, and no backdoors.
### Enforcement Configuration
These settings are declared in `BrowserWindow.webPreferences` and are **non-negotiable**:
| Setting | Value | Enforcement |
|---|---|---|
| `contextIsolation` | `true` | The Preload executes in a hermetically sealed V8 context. The renderer **cannot** access `require()`, Node.js globals, or any variable from the preload's scope. |
| `nodeIntegration` | `false` | **Zero** Node.js API surface in the renderer. `fs`, `child_process`, `os`, `net`, and all built-in modules are completely unavailable. |
| `sandbox` | `true` | The renderer process runs inside a **Chromium OS-level sandbox**, restricting system calls and file access at the kernel level. |
| `webSecurity` | `true` | The same-origin policy is **strictly enforced**, preventing cross-origin data exfiltration from the renderer. |
---
## Standard Operating Procedure: The Three-Step Bridge
Every native feature in this architecture **must** follow the Three-Step Bridge — a Standard Operating Procedure (SOP) that ensures traceability, type-safety, and auditability across the entire IPC surface.
> [!IMPORTANT]
> **Deterministic Synchronization**: Maintaining parity between the Main Process handler, the Preload Gateway exposure, and the TypeScript interface declaration is **mandatory**. A mismatch between any two of the three layers will result in either a **Type-Safety Gap** (silent failures in development) or a **Runtime Regression** (crashes in production).
### Step 1: Register the Handler — Main Process
**File:** `apps/desktop/src/main/index.ts`
```typescript
// COMMAND PATTERN: Use ipcMain.handle for request/response operations
// The handler returns a value to the renderer via a resolved Promise.
ipcMain.handle('feature:action', async (_event, arg1: string, arg2: number) => {
// Validate inputs. Never trust data from the renderer.
if (typeof arg1 !== 'string' || typeof arg2 !== 'number') {
throw new Error('Invalid arguments');
}
const result = await someNativeAPI(arg1, arg2);
return result;
});
// EVENT PATTERN: Use ipcMain.on for fire-and-forget operations
// No return value — the renderer does not wait for a response.
ipcMain.on('feature:fire', (_event, data: SomeType) => {
performSideEffect(data);
});
```
**Channel naming convention:** `namespace:action` — examples: `printer:get-list`, `updater:check`, `app:get-version`. Namespaces must be unique, descriptive, and never generic.
### Step 2: Expose via contextBridge — Preload Gateway
**File:** `apps/desktop/src/preload/index.ts`
```typescript
const electronAPI = {
// Command pattern exposure
featureAction: (arg1: string, arg2: number): Promise<ResultType> => {
return ipcRenderer.invoke('feature:action', arg1, arg2);
},
// Event pattern exposure
featureFire: (data: SomeType): void => {
ipcRenderer.send('feature:fire', data);
},
// Main→Renderer push events (with automatic lifecycle cleanup)
onFeatureEvent: createEventSubscription<EventDataType>('feature:event'),
};
contextBridge.exposeInMainWorld('electronAPI', electronAPI);
```
**Non-negotiable rules:**
- **Never** expose `ipcRenderer` directly — this is a **Catastrophic Failure** pattern.
- **Never** expose `ipcRenderer.on` without cleanup — use `createEventSubscription()`, which returns an unsubscribe function for React `useEffect` lifecycle management.
- **Always** declare explicit TypeScript types for all function signatures.
### Step 3: Declare the Interface — React Application
**File:** `apps/web/src/types/electron.d.ts`
```typescript
interface ElectronAPI {
// ... existing methods ...
featureAction: (arg1: string, arg2: number) => Promise<ResultType>;
featureFire: (data: SomeType) => void;
onFeatureEvent: (callback: (data: EventDataType) => void) => () => void;
}
```
---
## Verified Channel Manifest
The following is the **complete, authoritative registry** of all authorized IPC channels. These channels are the **only** permitted vectors for native interaction. Any IPC channel not listed here is unauthorized and must be treated as a security anomaly.
### Printer Subsystem
| Channel | Direction | Pattern | Payload | Access Control |
|---|---|---|---|---|
| `printer:get-list` | Renderer → Main → Renderer | `invoke` / `handle` | Returns `ElectronPrinterInfo[]` | Read-only hardware enumeration |
| `printer:print` | Renderer → Main → Renderer | `invoke` / `handle` | Accepts `ElectronPrintOptions`, returns `{ success, failureReason? }` | Controlled hardware invocation |
**Main process handler:** `setupPrinterIPC()` in `src/main/index.ts`
**Preload Gateway surface:**
```typescript
getPrinters: () => ipcRenderer.invoke('printer:get-list')
print: (options?) => ipcRenderer.invoke('printer:print', options)
```
**React consumption hook:** `useElectronPrinter()` in `apps/web/src/hooks/use-electron-printer.ts`
---
### Auto-Updater Subsystem
| Channel | Direction | Pattern | Payload | Access Control |
|---|---|---|---|---|
| `updater:check` | Renderer → Main | `invoke` / `handle` | Returns update check result | Read-only version query |
| `updater:install` | Renderer → Main | `send` / `on` | No payload | Privileged: quits app and installs |
| `updater:checking` | Main → Renderer | `send` | No payload | Status notification |
| `updater:available` | Main → Renderer | `send` | `UpdateInfo { version, releaseDate, releaseNotes }` | Status notification |
| `updater:not-available` | Main → Renderer | `send` | `UpdateInfo` | Status notification |
| `updater:progress` | Main → Renderer | `send` | `ProgressInfo { percent, bytesPerSecond, transferred, total }` | Progress telemetry |
| `updater:downloaded` | Main → Renderer | `send` | `UpdateInfo` | Status notification |
| `updater:error` | Main → Renderer | `send` | Error message string | Error telemetry |
**Main process handlers:** `setupAutoUpdaterIPC()` + `setupAutoUpdaterEvents()` in `src/main/index.ts`
**React consumption hook:** `useElectronUpdater()` in `apps/web/src/hooks/use-electron-updater.ts`
> [!NOTE]
> The `updater:install` channel is the **highest-privilege IPC operation** in the system — it terminates the running process and launches a new binary. It should only be triggered by an explicit user action, never automatically.
---
## Extending the Bridge: Guided Walkthrough
**Scenario:** Expose the application version to the React UI.
### 1. Main Process — Register Handler
```typescript
// In app.whenReady() callback, src/main/index.ts
ipcMain.handle('app:get-version', () => {
return app.getVersion();
});
```
### 2. Preload Gateway — Expose Method
```typescript
// Add to the electronAPI object, src/preload/index.ts
const electronAPI = {
// ... existing methods ...
getAppVersion: (): Promise<string> => {
return ipcRenderer.invoke('app:get-version');
},
};
```
### 3. TypeScript Interface — Declare Type
```typescript
// Add to ElectronAPI interface, apps/web/src/types/electron.d.ts
interface ElectronAPI {
// ... existing methods ...
getAppVersion: () => Promise<string>;
}
```
### 4. React — Consume
```tsx
function VersionBadge() {
const [version, setVersion] = useState('');
useEffect(() => {
if (window.electronAPI) {
window.electronAPI.getAppVersion().then(setVersion);
}
}, []);
if (!version) return null;
return <span className="version-badge">v{version}</span>;
}
```
### 5. Update This Manifest
After implementing a new channel, **add it to the Verified Channel Manifest** in this document. Undocumented channels are unauthorized channels.
---
## Critical Audit Checklist: Anti-Patterns
The following patterns constitute **critical security violations**. Each one expands the attack surface from "browser-level sandboxed web content" to "unrestricted OS-level code execution." Their presence in production code warrants **immediate incident response**.
---
### ❌ Exposing raw `ipcRenderer`
```typescript
// VIOLATION: Catastrophic Failure — Total Attack Surface Expansion
contextBridge.exposeInMainWorld('ipc', ipcRenderer);
```
**Threat:** The renderer gains **unrestricted IPC access** — it can invoke any channel, including channels that were never intended to be callable from the renderer. A single XSS vulnerability escalates to arbitrary native code execution.
**Classification:** **Total System Compromise**
---
### ❌ Exposing `require` or Node.js APIs
```typescript
// VIOLATION: Unauthenticated Code Execution
contextBridge.exposeInMainWorld('require', require);
```
**Threat:** The renderer can `require('child_process').exec('rm -rf /')`. A single XSS vulnerability in *any* dependency — including transitive ones — escalates to **full filesystem access, credential theft, reverse shells, and data exfiltration**.
**Classification:** **Total System Compromise**
---
### ❌ Enabling `nodeIntegration`
```typescript
// VIOLATION: Catastrophic Failure — Complete Boundary Collapse
new BrowserWindow({
webPreferences: { nodeIntegration: true, contextIsolation: false }
});
```
**Threat:** Every `<script>` tag in the renderer — including XSS payloads, compromised npm packages, and injected analytics scripts — gains full Node.js capabilities. The isolation boundary **ceases to exist**.
**Classification:** **Total System Compromise**
---
### ❌ Passing unsanitized IPC data to shell commands
```typescript
// VIOLATION: Command Injection — Unauthenticated Code Execution
ipcMain.handle('run-cmd', (_event, cmd: string) => {
exec(cmd); // The renderer controls the command string
});
```
**Threat:** The renderer can execute **arbitrary system commands** with the privileges of the Electron main process (typically the current user). This is the most direct path from XSS to OS-level compromise.
**Classification:** **Unauthenticated Code Execution**
---
### ❌ Registering overly broad IPC channels
```typescript
// VIOLATION: Attack Surface Expansion — Unrestricted File Read
ipcMain.handle('file:read', (_event, path: string) => {
return readFileSync(path, 'utf-8'); // No validation
});
```
**Threat:** The renderer can read **any file** on the filesystem — SSH keys, environment files, database credentials, browser cookies. Input validation is not optional.
**Classification:** **Sensitive Data Exfiltration**
---
## The Gold Standard for Native Integration
The following patterns represent the **mandatory standard** for all IPC implementations. Adherence is non-negotiable.
### ✅ Validate and constrain all IPC arguments
```typescript
// GOLD STANDARD: Input validation, path confinement, scope restriction
ipcMain.handle('file:read', async (_event, filename: string) => {
// Reject path separators — confine to a single directory
if (filename.includes('/') || filename.includes('\\') || filename.includes('..')) {
throw new Error('Invalid filename');
}
// Resolve within a controlled directory only
const safePath = join(app.getPath('userData'), 'data', filename);
// Verify the resolved path stays within bounds
if (!safePath.startsWith(join(app.getPath('userData'), 'data'))) {
throw new Error('Path traversal detected');
}
return readFileSync(safePath, 'utf-8');
});
```
**Principle:** Never trust data originating from the renderer. Validate types, constrain scope, and verify resolved paths.
---
### ✅ Return unsubscribe functions — Memory Leak Mitigation
```typescript
// GOLD STANDARD: The createEventSubscription helper ensures automatic cleanup
function createEventSubscription<T>(channel: string) {
return (callback: (data: T) => void): (() => void) => {
const handler = (_event: Electron.IpcRendererEvent, data: T) => callback(data);
ipcRenderer.on(channel, handler);
// Return an unsubscribe function — critical for React lifecycle
return () => {
ipcRenderer.removeListener(channel, handler);
};
};
}
```
**In React's `useEffect`:**
```tsx
useEffect(() => {
if (!window.electronAPI) return;
// Subscribe — handler is registered in the Preload's IPC layer
const unsub = window.electronAPI.onSomeEvent((data) => {
setState(data);
});
// Cleanup on unmount — prevents listener accumulation
return () => unsub();
}, []);
```
**Principle:** Without the unsubscribe pattern, every component mount adds a **new IPC listener** that persists after unmount. Over time — especially with React's StrictMode double-mounting in development — this causes **memory leaks**, **duplicate event handling**, and **performance degradation**. The `createEventSubscription` helper enforces automatic, deterministic cleanup tied to React's component lifecycle.
---
### ✅ Gate all Electron calls behind runtime detection
```typescript
// GOLD STANDARD: Environment-safe consumption
function useElectronFeature() {
const isElectron = typeof window !== 'undefined' && !!window.electronAPI;
const doSomething = useCallback(() => {
if (!window.electronAPI) return; // No-op in browser
window.electronAPI.someMethod();
}, []);
return { isElectron, doSomething };
}
```
**Principle:** The React app must run identically in both Electron and standard browser environments. All `window.electronAPI` access must be gated behind a runtime check. Never assume the IPC bridge exists.
+66
View File
@@ -0,0 +1,66 @@
appId: com.eigen.desktop
productName: EigenDesktop
copyright: Copyright © 2026 Eigen
directories:
buildResources: build
output: release
# Include compiled electron-vite output + embedded web app build
files:
- out/**/*
- web-dist/**/*
# Copy web-dist into the app's resources folder at runtime
extraResources:
- from: web-dist
to: web-dist
filter:
- "**/*"
# --- Auto-Update Provider (GitHub Releases) ---
# Switch to { provider: s3, bucket: ..., region: ... } for private S3
publish:
provider: github
owner: YOUR_GITHUB_ORG
repo: YOUR_REPO_NAME
# --- Windows ---
win:
target:
- target: nsis
arch:
- x64
- arm64
icon: build/icon.ico
nsis:
oneClick: false
allowToChangeInstallationDirectory: true
differentialPackage: true
# --- macOS ---
mac:
target:
- target: dmg
arch:
- x64
- arm64
- target: zip
arch:
- x64
- arm64
icon: build/icon.icns
hardenedRuntime: true
gatekeeperAssess: false
entitlements: build/entitlements.mac.plist
entitlementsInherit: build/entitlements.mac.plist
# --- Linux ---
linux:
target:
- target: AppImage
arch:
- x64
icon: build/icons
category: Office
+37
View File
@@ -0,0 +1,37 @@
import { defineConfig, externalizeDepsPlugin } from 'electron-vite';
import { resolve } from 'path';
export default defineConfig({
main: {
plugins: [externalizeDepsPlugin()],
build: {
outDir: 'out/main',
rollupOptions: {
input: {
index: resolve(__dirname, 'src/main/index.ts'),
},
},
},
},
preload: {
plugins: [externalizeDepsPlugin()],
build: {
outDir: 'out/preload',
rollupOptions: {
input: {
index: resolve(__dirname, 'src/preload/index.ts'),
},
},
},
},
renderer: {
build: {
outDir: 'out/renderer',
rollupOptions: {
input: {
index: resolve(__dirname, 'src/renderer/index.html'),
},
},
},
},
});
+29
View File
@@ -0,0 +1,29 @@
{
"name": "desktop",
"version": "0.1.0",
"private": true,
"main": "./out/main/index.js",
"scripts": {
"dev": "electron-vite dev",
"prebuild": "node --import tsx scripts/copy-web-dist.ts",
"build": "electron-vite build",
"preview": "electron-vite preview",
"package": "pnpm run build && electron-builder --config electron-builder.yml",
"package:win": "pnpm run build && electron-builder --win --config electron-builder.yml",
"package:mac": "pnpm run build && electron-builder --mac --config electron-builder.yml",
"package:linux": "pnpm run build && electron-builder --linux --config electron-builder.yml"
},
"dependencies": {
"electron-updater": "^6.3.9"
},
"devDependencies": {
"@electron-toolkit/preload": "^3.0.1",
"@electron-toolkit/utils": "^3.0.0",
"@types/node": "^22.13.0",
"electron": "^33.3.1",
"electron-builder": "^25.1.8",
"electron-vite": "^2.3.0",
"tsx": "^4.19.0",
"typescript": "5.5.4"
}
}
+70
View File
@@ -0,0 +1,70 @@
/**
* copy-web-dist.ts
*
* Prebuild script for apps/desktop.
* Copies the build output of the target web app into ./web-dist/
* so electron-builder can bundle it into the packaged application.
*
* Reads DESKTOP_TARGET_APP from .env (default: "web").
*
* Directory layout:
* monorepo-root/
* apps/
* desktop/
* scripts/ ← this file lives here
* web-dist/ ← destination
* web/
* dist/ ← source
*/
import { cpSync, existsSync, mkdirSync, rmSync, readFileSync } from 'fs';
import { resolve } from 'path';
import { fileURLToPath } from 'url';
// ── Resolve paths ──────────────────────────────────────────────
// Use import.meta.url for ESM compatibility (works across Node versions)
const SCRIPT_DIR = resolve(fileURLToPath(import.meta.url), '..');
const DESKTOP_DIR = resolve(SCRIPT_DIR, '..');
const MONOREPO_ROOT = resolve(DESKTOP_DIR, '..', '..');
const DEST_DIR = resolve(DESKTOP_DIR, 'web-dist');
// ── Read .env for target app name ──────────────────────────────
function loadTargetApp(): string {
const envPath = resolve(DESKTOP_DIR, '.env');
if (existsSync(envPath)) {
const content = readFileSync(envPath, 'utf-8');
const match = content.match(/^DESKTOP_TARGET_APP=(.+)$/m);
if (match) return match[1].trim();
}
return 'web';
}
const targetApp = process.env.DESKTOP_TARGET_APP || loadTargetApp();
const SOURCE_DIR = resolve(MONOREPO_ROOT, 'apps', targetApp, 'dist');
// ── Debug: print resolved paths ────────────────────────────────
console.log(`\n📦 copy-web-dist`);
console.log(` Target app : ${targetApp}`);
console.log(` Monorepo root: ${MONOREPO_ROOT}`);
console.log(` Source : ${SOURCE_DIR}`);
console.log(` Destination : ${DEST_DIR}`);
// ── Validate ───────────────────────────────────────────────────
if (!existsSync(SOURCE_DIR)) {
console.error(
`\n❌ Build output not found at: ${SOURCE_DIR}\n` +
` Run "pnpm build --filter=${targetApp}" first.\n`,
);
process.exit(1);
}
// ── Clean destination ──────────────────────────────────────────
if (existsSync(DEST_DIR)) {
rmSync(DEST_DIR, { recursive: true, force: true });
}
mkdirSync(DEST_DIR, { recursive: true });
// ── Copy ───────────────────────────────────────────────────────
cpSync(SOURCE_DIR, DEST_DIR, { recursive: true });
console.log(`✅ Copied ${targetApp} build → web-dist/\n`);
+417
View File
@@ -0,0 +1,417 @@
import {
app,
shell,
BrowserWindow,
ipcMain,
protocol,
session,
} from 'electron';
import { autoUpdater } from 'electron-updater';
import { join, extname, normalize } from 'path';
import { readFileSync, existsSync, statSync } from 'fs';
// ─── Configuration ──────────────────────────────────────────────
const DEV_SERVER_URL = process.env.DESKTOP_DEV_SERVER_URL || 'http://localhost:5173';
const IS_DEV = !app.isPackaged;
/**
* Resolve the directory containing the embedded web app's static build.
*
* Development: __dirname = apps/desktop/out/main
* → ../../ = apps/desktop → ../web/dist is WRONG
* → We need: apps/desktop/out/main → apps/desktop → apps → apps/web/dist
* So: join(__dirname, '..', '..', '..', 'web', 'dist')
*
* Production: process.resourcesPath = <app>/Contents/Resources (macOS)
* electron-builder extraResources copies web-dist/ there.
*/
function getWebDistPath(): string {
if (IS_DEV) {
// __dirname = apps/desktop/out/main → go up to apps/, then into web/dist
return join(__dirname, '..', '..', '..', 'web', 'dist');
}
return join(process.resourcesPath, 'web-dist');
}
// ─── MIME type map for custom protocol ──────────────────────────
const MIME_TYPES: Record<string, string> = {
'.html': 'text/html',
'.js': 'application/javascript',
'.mjs': 'application/javascript',
'.css': 'text/css',
'.json': 'application/json',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.svg': 'image/svg+xml',
'.ico': 'image/x-icon',
'.webp': 'image/webp',
'.avif': 'image/avif',
'.woff': 'font/woff',
'.woff2': 'font/woff2',
'.ttf': 'font/ttf',
'.eot': 'application/vnd.ms-fontobject',
'.otf': 'font/otf',
'.wasm': 'application/wasm',
'.map': 'application/json',
'.txt': 'text/plain',
'.xml': 'application/xml',
};
function getMimeType(filePath: string): string {
const ext = extname(filePath).toLowerCase();
return MIME_TYPES[ext] || 'application/octet-stream';
}
// ─── CSP header for production ──────────────────────────────────
// Applied via the custom protocol response headers since the
// src/renderer/index.html is never loaded (we load web app's HTML).
const PRODUCTION_CSP = [
"default-src 'self' app:",
"script-src 'self' app:",
"style-src 'self' 'unsafe-inline' app:",
"connect-src 'self' app: https:",
"img-src 'self' app: data: https:",
"font-src 'self' app: data: https:",
"media-src 'self' app:",
"worker-src 'self' app: blob:",
].join('; ');
// ─── 1. Register custom scheme BEFORE app is ready ──────────────
// This must happen synchronously at module load time.
protocol.registerSchemesAsPrivileged([
{
scheme: 'app',
privileges: {
standard: true,
secure: true,
supportFetchAPI: true,
corsEnabled: true,
stream: true,
},
},
]);
// ─── Keep a global reference to avoid GC ────────────────────────
let mainWindow: BrowserWindow | null = null;
// ─── 2. Create the main browser window ──────────────────────────
function createWindow(): void {
mainWindow = new BrowserWindow({
width: 1280,
height: 800,
minWidth: 800,
minHeight: 600,
show: false, // Show after ready-to-show to avoid flash
webPreferences: {
preload: join(__dirname, '..', 'preload', 'index.js'),
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
webSecurity: true,
},
});
// Show window when content is painted (avoids white flash)
mainWindow.on('ready-to-show', () => {
mainWindow?.show();
});
// Open external links in the default browser
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
if (url.startsWith('https:') || url.startsWith('http:')) {
shell.openExternal(url);
}
return { action: 'deny' };
});
// ── Load content ──────────────────────────────────────────────
if (IS_DEV) {
mainWindow.loadURL(DEV_SERVER_URL);
mainWindow.webContents.openDevTools({ mode: 'detach' });
} else {
mainWindow.loadURL('app://-/index.html');
}
mainWindow.on('closed', () => {
mainWindow = null;
});
}
// ─── 3. Register custom app:// protocol handler ────────────────
// Intercepts all requests to app://-/... and serves files from
// the embedded web-dist directory. Falls back to index.html for
// any path that doesn't match a real file (SPA client-side routing).
function registerAppProtocol(): void {
const webDistPath = getWebDistPath();
protocol.handle('app', (request) => {
try {
const url = new URL(request.url);
let filePath = decodeURIComponent(url.pathname);
// Remove leading "/" for file resolution
if (filePath.startsWith('/')) {
filePath = filePath.slice(1);
}
// Default to index.html for root
if (!filePath || filePath === '') {
filePath = 'index.html';
}
// ── Security: prevent path traversal attacks ──────────────
const absolutePath = normalize(join(webDistPath, filePath));
if (!absolutePath.startsWith(normalize(webDistPath))) {
return new Response('Forbidden', { status: 403 });
}
// If the file exists and is a file (not directory), serve it
if (existsSync(absolutePath) && statSync(absolutePath).isFile()) {
const mimeType = getMimeType(absolutePath);
const fileBuffer = readFileSync(absolutePath);
const headers: Record<string, string> = {
'Content-Type': mimeType,
'Cache-Control': 'no-cache',
};
// Apply CSP only to HTML responses
if (mimeType === 'text/html') {
headers['Content-Security-Policy'] = PRODUCTION_CSP;
}
return new Response(fileBuffer, { status: 200, headers });
}
// ── SPA Fallback ──────────────────────────────────────────
// If the requested file doesn't exist, serve index.html
// so React Router can handle the route client-side.
const indexPath = join(webDistPath, 'index.html');
if (existsSync(indexPath)) {
const indexBuffer = readFileSync(indexPath);
return new Response(indexBuffer, {
status: 200,
headers: {
'Content-Type': 'text/html',
'Content-Security-Policy': PRODUCTION_CSP,
'Cache-Control': 'no-cache',
},
});
}
return new Response('Not Found', { status: 404 });
} catch (err) {
console.error('[app:// protocol] Error serving request:', request.url, err);
return new Response('Internal Server Error', { status: 500 });
}
});
}
// ─── 4. CORS Bypass for cloud API calls ─────────────────────────
// When running via app:// or file://, the Origin header will be
// non-standard. We strip/rewrite it on outgoing requests and
// inject permissive CORS headers on incoming responses.
//
// FIX: Electron webRequest filter requires full URL patterns
// with a path component, e.g. "https://*/*" not "https://*".
function setupCorsBypass(): void {
const filter = { urls: ['https://*/*', 'http://*/*'] };
// Rewrite the Origin header on outgoing requests
session.defaultSession.webRequest.onBeforeSendHeaders(filter, (details, callback) => {
const { requestHeaders } = details;
// Remove the app:// or file:// origin so the server sees
// a "normal" request or a null origin
if (
requestHeaders['Origin'] &&
(requestHeaders['Origin'].startsWith('app://') ||
requestHeaders['Origin'].startsWith('file://'))
) {
delete requestHeaders['Origin'];
}
callback({ requestHeaders });
});
// Inject CORS headers on incoming responses
session.defaultSession.webRequest.onHeadersReceived(filter, (details, callback) => {
const responseHeaders = { ...details.responseHeaders };
// Only inject if the server didn't already set them
if (!responseHeaders['Access-Control-Allow-Origin']) {
responseHeaders['Access-Control-Allow-Origin'] = ['*'];
}
if (!responseHeaders['Access-Control-Allow-Headers']) {
responseHeaders['Access-Control-Allow-Headers'] = ['*'];
}
if (!responseHeaders['Access-Control-Allow-Methods']) {
responseHeaders['Access-Control-Allow-Methods'] = ['GET, POST, PUT, DELETE, PATCH, OPTIONS'];
}
callback({ responseHeaders });
});
}
// ─── 5. IPC Handlers: Printing ──────────────────────────────────
function setupPrinterIPC(): void {
// Get list of available printers
ipcMain.handle('printer:get-list', async () => {
if (!mainWindow) return [];
try {
return await mainWindow.webContents.getPrintersAsync();
} catch (err) {
console.error('[Printer IPC] Failed to get printers:', err);
return [];
}
});
// Print the current page with given options
ipcMain.handle(
'printer:print',
async (
_event,
options?: Electron.WebContentsPrintOptions,
): Promise<{ success: boolean; failureReason?: string }> => {
if (!mainWindow) {
return { success: false, failureReason: 'No active window' };
}
return new Promise((resolve) => {
mainWindow!.webContents.print(options || {}, (success, failureReason) => {
resolve({
success,
failureReason: failureReason || undefined,
});
});
});
},
);
}
// ─── 6. IPC Handlers: Auto-Update ──────────────────────────────
function setupAutoUpdaterIPC(): void {
// Manual check from renderer
ipcMain.handle('updater:check', async () => {
try {
return await autoUpdater.checkForUpdates();
} catch (err) {
console.error('[AutoUpdater] Manual check failed:', err);
sendToRenderer('updater:error', (err as Error).message || 'Update check failed');
return null;
}
});
// Quit and install after download completes
ipcMain.on('updater:install', () => {
autoUpdater.quitAndInstall(false, true);
});
}
// Forward auto-updater events to the renderer process
function setupAutoUpdaterEvents(): void {
autoUpdater.autoDownload = true;
autoUpdater.autoInstallOnAppQuit = true;
// In dev mode: completely skip auto-updater config to avoid
// crashes from missing dev-app-update.yml
if (IS_DEV) {
autoUpdater.autoDownload = false;
// Do NOT set forceDevUpdateConfig — it requires a
// dev-app-update.yml file that we don't ship.
return; // Skip event registration in dev
}
autoUpdater.on('checking-for-update', () => {
sendToRenderer('updater:checking');
});
autoUpdater.on('update-available', (info) => {
sendToRenderer('updater:available', info);
});
autoUpdater.on('update-not-available', (info) => {
sendToRenderer('updater:not-available', info);
});
autoUpdater.on('download-progress', (progress) => {
sendToRenderer('updater:progress', progress);
});
autoUpdater.on('update-downloaded', (info) => {
sendToRenderer('updater:downloaded', info);
});
autoUpdater.on('error', (error) => {
console.error('[AutoUpdater] Error:', error);
sendToRenderer('updater:error', error.message);
});
}
function sendToRenderer(channel: string, ...args: unknown[]): void {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send(channel, ...args);
}
}
// ─── 7. App Lifecycle ───────────────────────────────────────────
app.whenReady().then(() => {
// Register the custom protocol before creating the window
registerAppProtocol();
// Setup CORS bypass for API calls
setupCorsBypass();
// Setup IPC handlers
setupPrinterIPC();
setupAutoUpdaterIPC();
setupAutoUpdaterEvents();
// Create the main window
createWindow();
// Check for updates on startup (production only)
if (!IS_DEV) {
setTimeout(async () => {
try {
await autoUpdater.checkForUpdatesAndNotify();
} catch (err) {
// Gracefully handle offline or network errors
console.error('[AutoUpdater] Startup check failed (possibly offline):', err);
}
}, 3000);
}
// macOS: re-create window when dock icon is clicked
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow();
}
});
});
// Quit when all windows are closed (except macOS)
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit();
}
});
// Security: prevent navigation to unexpected URLs
app.on('web-contents-created', (_event, contents) => {
contents.on('will-navigate', (event, url) => {
// Allow navigation within the app protocol and dev server
if (
url.startsWith('app://') ||
(IS_DEV && url.startsWith(DEV_SERVER_URL))
) {
return;
}
event.preventDefault();
});
});
+81
View File
@@ -0,0 +1,81 @@
import { contextBridge, ipcRenderer } from 'electron';
// ─── Type definitions for the exposed API ───────────────────────
// These mirror the types in apps/web/src/types/electron.d.ts
export interface PrintResult {
success: boolean;
failureReason?: string;
}
export interface ProgressInfo {
total: number;
delta: number;
transferred: number;
percent: number;
bytesPerSecond: number;
}
export interface UpdateInfo {
version: string;
releaseDate: string;
releaseName?: string | null;
releaseNotes?: string | null;
}
// ─── Helper: create a one-way event listener with cleanup ───────
function createEventSubscription<T>(channel: string) {
return (callback: (data: T) => void): (() => void) => {
const handler = (_event: Electron.IpcRendererEvent, data: T) => callback(data);
ipcRenderer.on(channel, handler);
// Return an unsubscribe function
return () => {
ipcRenderer.removeListener(channel, handler);
};
};
}
// ─── Exposed API ────────────────────────────────────────────────
// SECURITY: Only expose specific methods. Never expose ipcRenderer directly.
const electronAPI = {
// ── Printing ────────────────────────────────────────────────
getPrinters: (): Promise<Electron.PrinterInfo[]> => {
return ipcRenderer.invoke('printer:get-list');
},
print: (options?: Electron.WebContentsPrintOptions): Promise<PrintResult> => {
return ipcRenderer.invoke('printer:print', options);
},
// ── Auto-Update: Commands ───────────────────────────────────
checkForUpdates: (): void => {
ipcRenderer.invoke('updater:check');
},
installUpdate: (): void => {
ipcRenderer.send('updater:install');
},
// ── Auto-Update: Event Subscriptions ────────────────────────
// Each returns an unsubscribe function for cleanup in useEffect.
onUpdateChecking: createEventSubscription<void>('updater:checking'),
onUpdateAvailable: createEventSubscription<UpdateInfo>('updater:available'),
onUpdateNotAvailable: createEventSubscription<UpdateInfo>('updater:not-available'),
onDownloadProgress: createEventSubscription<ProgressInfo>('updater:progress'),
onUpdateDownloaded: createEventSubscription<UpdateInfo>('updater:downloaded'),
onUpdateError: createEventSubscription<string>('updater:error'),
};
// ─── Expose to renderer via contextBridge ───────────────────────
contextBridge.exposeInMainWorld('electronAPI', electronAPI);
// Export the type for reference (used by electron.d.ts in apps/web)
export type ElectronAPI = typeof electronAPI;
+24
View File
@@ -0,0 +1,24 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta
http-equiv="Content-Security-Policy"
content="
default-src 'self' app:;
script-src 'self' app:;
style-src 'self' 'unsafe-inline' app:;
connect-src 'self' app: https: http://localhost:*;
img-src 'self' app: data: https:;
font-src 'self' app: data: https:;
media-src 'self' app:;
worker-src 'self' app: blob:;
"
/>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Eigen Desktop</title>
</head>
<body>
<div id="app"></div>
</body>
</html>
+11
View File
@@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true
},
"references": [
{ "path": "./tsconfig.main.json" },
{ "path": "./tsconfig.preload.json" },
{ "path": "./tsconfig.renderer.json" }
],
"include": []
}
+19
View File
@@ -0,0 +1,19 @@
{
"compilerOptions": {
"composite": true,
"outDir": "./out/main",
"module": "ESNext",
"moduleResolution": "bundler",
"target": "ES2022",
"lib": ["ES2022"],
"types": ["node"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"sourceMap": true
},
"include": ["src/main/**/*"]
}
+18
View File
@@ -0,0 +1,18 @@
{
"compilerOptions": {
"composite": true,
"outDir": "./out/preload",
"module": "ESNext",
"moduleResolution": "bundler",
"target": "ES2022",
"lib": ["ES2022"],
"types": ["node"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"sourceMap": true
},
"include": ["src/preload/**/*"]
}
+17
View File
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"composite": true,
"outDir": "./out/renderer",
"module": "ESNext",
"moduleResolution": "bundler",
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"sourceMap": true
},
"include": ["src/renderer/**/*"]
}
@@ -0,0 +1,35 @@
// 1. Import hooks yang baru saja dibuat Opus
import { Button } from '@repo/ui/components';
import { useElectronPrinter } from '../../hooks/use-electron-printer';
import { useElectronUpdater } from '../../hooks/use-electron-updater';
export default function App() {
// 2. Panggil hooks-nya
const { printers, refreshPrinters } = useElectronPrinter();
const { status } = useElectronUpdater();
return (
<div style={{ padding: '20px', border: '2px solid blue', margin: '20px' }}>
<h2>🧪 Test Integrasi Electron</h2>
<p><strong>Status Auto-Update:</strong> {status}</p>
<Button variant="filled" color="brand" onClick={refreshPrinters}>
Refresh Printer
</Button>
<h3>🖨 Daftar Printer di Komputer Ini:</h3>
<ul>
{printers.length === 0 ? (
<li>Mencari printer... (Atau tidak ada printer terdeteksi)</li>
) : (
printers.map((printer, index) => (
<li key={index}>
{printer.name} {printer.isDefault ? '(Default)' : ''}
</li>
))
)}
</ul>
</div>
);
}
@@ -19,6 +19,7 @@ import {
Badge, Badge,
Divider, Divider,
} from '@repo/ui/components'; } from '@repo/ui/components';
import PrinterList from './printer-list'
interface ShowcaseViewProps { interface ShowcaseViewProps {
colorScheme: ColorSchemeType; colorScheme: ColorSchemeType;
@@ -204,6 +205,9 @@ export default function ShowcaseView({ colorScheme, setColorScheme, density, set
</Table.Tbody> </Table.Tbody>
</Table> </Table>
</Card> </Card>
<Card withBorder shadow="sm" radius="md" p="md">
<PrinterList />
</Card>
</Stack> </Stack>
</Container> </Container>
); );
+109
View File
@@ -0,0 +1,109 @@
import { useState, useCallback } from 'react';
// ─── Types ──────────────────────────────────────────────────────
export interface UseElectronPrinterReturn {
/** List of available printers (populated after calling `refreshPrinters`) */
printers: ElectronPrinterInfo[];
/** Whether a printer operation is in progress */
loading: boolean;
/** Last error message, if any */
error: string | null;
/** Whether the app is running inside Electron */
isElectron: boolean;
/** Fetch the current list of available printers */
refreshPrinters: () => Promise<ElectronPrinterInfo[]>;
/** Print with the given options. Returns success/failure. */
print: (options?: ElectronPrintOptions) => Promise<ElectronPrintResult>;
}
/**
* React hook for Electron printer integration.
*
* Provides methods to list available printers and trigger print jobs
* via the secure `window.electronAPI` bridge.
*
* Safe to use in both Electron and browser environments.
*
* @example
* ```tsx
* function PrintButton() {
* const { printers, refreshPrinters, print, loading } = useElectronPrinter();
*
* useEffect(() => { refreshPrinters(); }, []);
*
* const handlePrint = async () => {
* const result = await print({ silent: true, deviceName: printers[0]?.name });
* if (!result.success) alert(`Print failed: ${result.failureReason}`);
* };
*
* return (
* <button onClick={handlePrint} disabled={loading || printers.length === 0}>
* Print
* </button>
* );
* }
* ```
*/
export function useElectronPrinter(): UseElectronPrinterReturn {
const [printers, setPrinters] = useState<ElectronPrinterInfo[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const isElectron = typeof window !== 'undefined' && !!window.electronAPI;
const refreshPrinters = useCallback(async (): Promise<ElectronPrinterInfo[]> => {
if (!window.electronAPI) {
setError('Not running in Electron');
return [];
}
setLoading(true);
setError(null);
try {
const result = await window.electronAPI.getPrinters();
setPrinters(result);
return result;
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to get printers';
setError(message);
return [];
} finally {
setLoading(false);
}
}, []);
const print = useCallback(
async (options?: ElectronPrintOptions): Promise<ElectronPrintResult> => {
if (!window.electronAPI) {
return { success: false, failureReason: 'Not running in Electron' };
}
setLoading(true);
setError(null);
try {
const result = await window.electronAPI.print(options);
if (!result.success && result.failureReason) {
setError(result.failureReason);
}
return result;
} catch (err) {
const message = err instanceof Error ? err.message : 'Print failed';
setError(message);
return { success: false, failureReason: message };
} finally {
setLoading(false);
}
},
[],
);
return {
printers,
loading,
error,
isElectron,
refreshPrinters,
print,
};
}
+139
View File
@@ -0,0 +1,139 @@
import { useState, useEffect, useCallback } from 'react';
// ─── Types ──────────────────────────────────────────────────────
export type UpdateStatus =
| 'idle'
| 'checking'
| 'available'
| 'not-available'
| 'downloading'
| 'ready'
| 'error';
export interface UseElectronUpdaterReturn {
/** Current status of the auto-updater lifecycle */
status: UpdateStatus;
/** Download progress percentage (0100) */
progress: number;
/** Download speed in bytes per second */
bytesPerSecond: number;
/** Information about the available/downloaded update */
updateInfo: ElectronUpdateInfo | null;
/** Error message if the updater encountered an issue */
errorMessage: string | null;
/** Whether the app is running inside Electron */
isElectron: boolean;
/** Trigger a manual update check */
checkForUpdates: () => void;
/** Quit the app and install the downloaded update */
installUpdate: () => void;
}
/**
* React hook for the Electron auto-updater.
*
* Subscribes to all update lifecycle events via `window.electronAPI`
* and provides reactive state for building an update notification UI.
*
* Safe to use in both Electron and browser environments — all
* Electron-specific calls are gated behind `window.electronAPI` checks.
*
* @example
* ```tsx
* function UpdateBanner() {
* const { status, progress, updateInfo, checkForUpdates, installUpdate } = useElectronUpdater();
*
* if (status === 'available') {
* return <div>Update {updateInfo?.version} available! Downloading...</div>;
* }
* if (status === 'downloading') {
* return <div>Downloading... {progress.toFixed(0)}%</div>;
* }
* if (status === 'ready') {
* return <button onClick={installUpdate}>Restart to update</button>;
* }
* return <button onClick={checkForUpdates}>Check for updates</button>;
* }
* ```
*/
export function useElectronUpdater(): UseElectronUpdaterReturn {
const [status, setStatus] = useState<UpdateStatus>('idle');
const [progress, setProgress] = useState(0);
const [bytesPerSecond, setBytesPerSecond] = useState(0);
const [updateInfo, setUpdateInfo] = useState<ElectronUpdateInfo | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const isElectron = typeof window !== 'undefined' && !!window.electronAPI;
useEffect(() => {
if (!window.electronAPI) return;
const api = window.electronAPI;
const unsubChecking = api.onUpdateChecking(() => {
setStatus('checking');
setErrorMessage(null);
});
const unsubAvailable = api.onUpdateAvailable((info) => {
setStatus('available');
setUpdateInfo(info);
});
const unsubNotAvailable = api.onUpdateNotAvailable((info) => {
setStatus('not-available');
setUpdateInfo(info);
});
const unsubProgress = api.onDownloadProgress((progressInfo) => {
setStatus('downloading');
setProgress(progressInfo.percent);
setBytesPerSecond(progressInfo.bytesPerSecond);
});
const unsubDownloaded = api.onUpdateDownloaded((info) => {
setStatus('ready');
setProgress(100);
setUpdateInfo(info);
});
const unsubError = api.onUpdateError((error) => {
setStatus('error');
setErrorMessage(error);
});
// Cleanup all listeners on unmount
return () => {
unsubChecking();
unsubAvailable();
unsubNotAvailable();
unsubProgress();
unsubDownloaded();
unsubError();
};
}, []);
const checkForUpdates = useCallback(() => {
if (!window.electronAPI) return;
setStatus('checking');
setErrorMessage(null);
window.electronAPI.checkForUpdates();
}, []);
const installUpdate = useCallback(() => {
if (!window.electronAPI) return;
window.electronAPI.installUpdate();
}, []);
return {
status,
progress,
bytesPerSecond,
updateInfo,
errorMessage,
isElectron,
checkForUpdates,
installUpdate,
};
}
+98
View File
@@ -0,0 +1,98 @@
/**
* Type declarations for the Electron preload API.
*
* When running inside Electron, `window.electronAPI` is defined.
* When running in a regular browser, it is `undefined`.
*
* Usage:
* if (window.electronAPI) {
* const printers = await window.electronAPI.getPrinters();
* }
*/
// ─── Printer types ──────────────────────────────────────────────
interface ElectronPrinterInfo {
name: string;
displayName: string;
description: string;
status: number;
isDefault: boolean;
options?: Record<string, string>;
}
interface ElectronPrintOptions {
silent?: boolean;
printBackground?: boolean;
deviceName?: string;
color?: boolean;
margins?: {
marginType?: 'default' | 'none' | 'printableArea' | 'custom';
top?: number;
bottom?: number;
left?: number;
right?: number;
};
landscape?: boolean;
scaleFactor?: number;
pagesPerSheet?: number;
collate?: boolean;
copies?: number;
pageRanges?: Array<{ from: number; to: number }>;
duplexMode?: 'simplex' | 'shortEdge' | 'longEdge';
header?: string;
footer?: string;
}
interface ElectronPrintResult {
success: boolean;
failureReason?: string;
}
// ─── Auto-Update types ──────────────────────────────────────────
interface ElectronUpdateInfo {
version: string;
releaseDate: string;
releaseName?: string | null;
releaseNotes?: string | null;
}
interface ElectronProgressInfo {
total: number;
delta: number;
transferred: number;
percent: number;
bytesPerSecond: number;
}
// ─── ElectronAPI interface ──────────────────────────────────────
interface ElectronAPI {
// Printing
getPrinters: () => Promise<ElectronPrinterInfo[]>;
print: (options?: ElectronPrintOptions) => Promise<ElectronPrintResult>;
// Auto-Update: Commands
checkForUpdates: () => void;
installUpdate: () => void;
// Auto-Update: Event Subscriptions
// Each returns an unsubscribe function.
onUpdateChecking: (callback: () => void) => () => void;
onUpdateAvailable: (callback: (info: ElectronUpdateInfo) => void) => () => void;
onUpdateNotAvailable: (callback: (info: ElectronUpdateInfo) => void) => () => void;
onDownloadProgress: (callback: (progress: ElectronProgressInfo) => void) => () => void;
onUpdateDownloaded: (callback: (info: ElectronUpdateInfo) => void) => () => void;
onUpdateError: (callback: (error: string) => void) => () => void;
}
// ─── Augment the global Window interface ────────────────────────
interface Window {
/**
* Available only when running inside Electron.
* Always check `if (window.electronAPI)` before use.
*/
electronAPI?: ElectronAPI;
}
+6
View File
@@ -6,9 +6,15 @@
"build": "turbo run build", "build": "turbo run build",
"build:web": "turbo run build --filter=web", "build:web": "turbo run build --filter=web",
"build:docs-dev": "turbo run build --filter=docs-dev", "build:docs-dev": "turbo run build --filter=docs-dev",
"build:desktop": "turbo run build --filter=web && turbo run build --filter=desktop",
"dev": "turbo run dev", "dev": "turbo run dev",
"dev:web": "turbo run dev --filter=web", "dev:web": "turbo run dev --filter=web",
"dev:docs-dev": "turbo run dev --filter=docs-dev", "dev:docs-dev": "turbo run dev --filter=docs-dev",
"dev:desktop": "turbo run dev --filter=web --filter=desktop --parallel",
"package:desktop": "pnpm run build:desktop && cd apps/desktop && pnpm run package",
"package:mac": "pnpm run build:desktop && cd apps/desktop && pnpm run package:mac",
"package:win": "pnpm run build:desktop && cd apps/desktop && pnpm run package:win",
"package:linux": "pnpm run build:desktop && cd apps/desktop && pnpm run package:linux",
"lint": "turbo run lint", "lint": "turbo run lint",
"format": "prettier --write \"**/*.{ts,tsx,md}\"", "format": "prettier --write \"**/*.{ts,tsx,md}\"",
"test": "turbo run test" "test": "turbo run test"
+1992 -29
View File
File diff suppressed because it is too large Load Diff
+8 -1
View File
@@ -13,7 +13,10 @@
"outputs": [ "outputs": [
"dist/**", "dist/**",
".next/**", ".next/**",
"!.next/cache/**" "!.next/cache/**",
"out/**",
"release/**",
"web-dist/**"
] ]
}, },
"lint": { "lint": {
@@ -30,6 +33,10 @@
"cache": false, "cache": false,
"persistent": true "persistent": true
}, },
"dev:desktop": {
"cache": false,
"persistent": true
},
"test": { "test": {
"dependsOn": [ "dependsOn": [
"^test" "^test"