Merge pull request 'electron' (#4) from electron into main
Reviewed-on: eigen/fe-monorepo-template#4
This commit is contained in:
@@ -13,3 +13,8 @@ public/dist
|
||||
# storybook
|
||||
*storybook.log
|
||||
storybook-static
|
||||
|
||||
# Electron
|
||||
out/
|
||||
release/
|
||||
web-dist/
|
||||
@@ -5,15 +5,17 @@
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
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`)
|
||||
|
||||
@@ -69,22 +72,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`) |
|
||||
| `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 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.
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 📦 Packages Overview
|
||||
@@ -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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -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 |
|
||||
@@ -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. |
|
||||
@@ -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 |
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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'),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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`);
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true
|
||||
},
|
||||
"references": [
|
||||
{ "path": "./tsconfig.main.json" },
|
||||
{ "path": "./tsconfig.preload.json" },
|
||||
{ "path": "./tsconfig.renderer.json" }
|
||||
],
|
||||
"include": []
|
||||
}
|
||||
@@ -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/**/*"]
|
||||
}
|
||||
@@ -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/**/*"]
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 (0–100) */
|
||||
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,
|
||||
};
|
||||
}
|
||||
Vendored
+98
@@ -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,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"
|
||||
|
||||
Generated
+1992
-29
File diff suppressed because it is too large
Load Diff
+8
-1
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user