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
+5
View File
@@ -13,3 +13,8 @@ public/dist
# storybook
*storybook.log
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)
![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)
![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)
![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:
* Shared logic and UI libraries
* Centralized tooling configuration
* Turbo-powered task orchestration and caching
* Native desktop distribution with auto-updates
* Dedicated documentation & component playground using Storybook
---
@@ -26,6 +28,7 @@ The monorepo is organized into **Apps** (deployable applications) and **Packages
.
├── apps/
│ ├── web/ # Main React Application (Vite + TypeScript)
│ ├── desktop/ # Electron Desktop Wrapper (electron-vite)
│ └── docs-dev/ # Component Documentation & Playground (Storybook)
├── 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:
* **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`
(Enforced via the `packageManager` field in `package.json`)
@@ -68,22 +71,51 @@ This repository uses **Turborepo** to orchestrate tasks efficiently. All command
### Development
| Command | Description |
| ------------------- | --------------------------------------------------------------------------- |
| `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:docs-dev` | Start **Storybook** for UI development (usually at `http://localhost:6006`) |
| Command | Description |
| -------------------- | --------------------------------------------------------------------------- |
| `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: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
| Command | Description |
| --------------------- | --------------------------------------------- |
| `pnpm build` | Build all apps and packages using Turbo cache |
| `pnpm build:web` | Build only the web application |
| `pnpm build:docs-dev` | Build only the docs-dev application |
| `pnpm test` | Run unit tests (Vitest) across all packages |
| `pnpm lint` | Run ESLint across the workspace |
| `pnpm format` | Format code using Prettier |
| Command | Description |
| --------------------- | ------------------------------------------------------- |
| `pnpm build` | Build all apps and packages using Turbo cache |
| `pnpm build:web` | Build only the web application |
| `pnpm build:docs-dev` | Build only the docs-dev application |
| `pnpm build:desktop` | Build the web app, then compile the Electron app |
| `pnpm test` | Run unit tests (Vitest) across all packages |
| `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.
@@ -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.
@@ -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).
@@ -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.
@@ -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):
```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,
Divider,
} from '@repo/ui/components';
import PrinterList from './printer-list'
interface ShowcaseViewProps {
colorScheme: ColorSchemeType;
@@ -204,6 +205,9 @@ export default function ShowcaseView({ colorScheme, setColorScheme, density, set
</Table.Tbody>
</Table>
</Card>
<Card withBorder shadow="sm" radius="md" p="md">
<PrinterList />
</Card>
</Stack>
</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:web": "turbo run build --filter=web",
"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:web": "turbo run dev --filter=web",
"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",
"format": "prettier --write \"**/*.{ts,tsx,md}\"",
"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": [
"dist/**",
".next/**",
"!.next/cache/**"
"!.next/cache/**",
"out/**",
"release/**",
"web-dist/**"
]
},
"lint": {
@@ -30,6 +33,10 @@
"cache": false,
"persistent": true
},
"dev:desktop": {
"cache": false,
"persistent": true
},
"test": {
"dependsOn": [
"^test"