build: generate desktop application release artifacts and update project configuration

This commit is contained in:
Firman Ramdhani
2026-04-05 22:33:08 +07:00
parent 9b7e027024
commit a13feb1c51
20 changed files with 3183 additions and 31 deletions
+6 -1
View File
@@ -12,4 +12,9 @@ public/dist
# storybook # storybook
*storybook.log *storybook.log
storybook-static storybook-static
# Electron
out/
release/
web-dist/
+66
View File
@@ -0,0 +1,66 @@
appId: com.eigen.desktop
productName: EigenDesktop
copyright: Copyright © 2026 Eigen
directories:
buildResources: build
output: release
# Include compiled electron-vite output + embedded web app build
files:
- out/**/*
- web-dist/**/*
# Copy web-dist into the app's resources folder at runtime
extraResources:
- from: web-dist
to: web-dist
filter:
- "**/*"
# --- Auto-Update Provider (GitHub Releases) ---
# Switch to { provider: s3, bucket: ..., region: ... } for private S3
publish:
provider: github
owner: YOUR_GITHUB_ORG
repo: YOUR_REPO_NAME
# --- Windows ---
win:
target:
- target: nsis
arch:
- x64
- arm64
icon: build/icon.ico
nsis:
oneClick: false
allowToChangeInstallationDirectory: true
differentialPackage: true
# --- macOS ---
mac:
target:
- target: dmg
arch:
- x64
- arm64
- target: zip
arch:
- x64
- arm64
icon: build/icon.icns
hardenedRuntime: true
gatekeeperAssess: false
entitlements: build/entitlements.mac.plist
entitlementsInherit: build/entitlements.mac.plist
# --- Linux ---
linux:
target:
- target: AppImage
arch:
- x64
icon: build/icons
category: Office
+37
View File
@@ -0,0 +1,37 @@
import { defineConfig, externalizeDepsPlugin } from 'electron-vite';
import { resolve } from 'path';
export default defineConfig({
main: {
plugins: [externalizeDepsPlugin()],
build: {
outDir: 'out/main',
rollupOptions: {
input: {
index: resolve(__dirname, 'src/main/index.ts'),
},
},
},
},
preload: {
plugins: [externalizeDepsPlugin()],
build: {
outDir: 'out/preload',
rollupOptions: {
input: {
index: resolve(__dirname, 'src/preload/index.ts'),
},
},
},
},
renderer: {
build: {
outDir: 'out/renderer',
rollupOptions: {
input: {
index: resolve(__dirname, 'src/renderer/index.html'),
},
},
},
},
});
+29
View File
@@ -0,0 +1,29 @@
{
"name": "desktop",
"version": "0.1.0",
"private": true,
"main": "./out/main/index.js",
"scripts": {
"dev": "electron-vite dev",
"prebuild": "node --import tsx scripts/copy-web-dist.ts",
"build": "electron-vite build",
"preview": "electron-vite preview",
"package": "pnpm run build && electron-builder --config electron-builder.yml",
"package:win": "pnpm run build && electron-builder --win --config electron-builder.yml",
"package:mac": "pnpm run build && electron-builder --mac --config electron-builder.yml",
"package:linux": "pnpm run build && electron-builder --linux --config electron-builder.yml"
},
"dependencies": {
"electron-updater": "^6.3.9"
},
"devDependencies": {
"@electron-toolkit/preload": "^3.0.1",
"@electron-toolkit/utils": "^3.0.0",
"@types/node": "^22.13.0",
"electron": "^33.3.1",
"electron-builder": "^25.1.8",
"electron-vite": "^2.3.0",
"tsx": "^4.19.0",
"typescript": "5.5.4"
}
}
+70
View File
@@ -0,0 +1,70 @@
/**
* copy-web-dist.ts
*
* Prebuild script for apps/desktop.
* Copies the build output of the target web app into ./web-dist/
* so electron-builder can bundle it into the packaged application.
*
* Reads DESKTOP_TARGET_APP from .env (default: "web").
*
* Directory layout:
* monorepo-root/
* apps/
* desktop/
* scripts/ ← this file lives here
* web-dist/ ← destination
* web/
* dist/ ← source
*/
import { cpSync, existsSync, mkdirSync, rmSync, readFileSync } from 'fs';
import { resolve } from 'path';
import { fileURLToPath } from 'url';
// ── Resolve paths ──────────────────────────────────────────────
// Use import.meta.url for ESM compatibility (works across Node versions)
const SCRIPT_DIR = resolve(fileURLToPath(import.meta.url), '..');
const DESKTOP_DIR = resolve(SCRIPT_DIR, '..');
const MONOREPO_ROOT = resolve(DESKTOP_DIR, '..', '..');
const DEST_DIR = resolve(DESKTOP_DIR, 'web-dist');
// ── Read .env for target app name ──────────────────────────────
function loadTargetApp(): string {
const envPath = resolve(DESKTOP_DIR, '.env');
if (existsSync(envPath)) {
const content = readFileSync(envPath, 'utf-8');
const match = content.match(/^DESKTOP_TARGET_APP=(.+)$/m);
if (match) return match[1].trim();
}
return 'web';
}
const targetApp = process.env.DESKTOP_TARGET_APP || loadTargetApp();
const SOURCE_DIR = resolve(MONOREPO_ROOT, 'apps', targetApp, 'dist');
// ── Debug: print resolved paths ────────────────────────────────
console.log(`\n📦 copy-web-dist`);
console.log(` Target app : ${targetApp}`);
console.log(` Monorepo root: ${MONOREPO_ROOT}`);
console.log(` Source : ${SOURCE_DIR}`);
console.log(` Destination : ${DEST_DIR}`);
// ── Validate ───────────────────────────────────────────────────
if (!existsSync(SOURCE_DIR)) {
console.error(
`\n❌ Build output not found at: ${SOURCE_DIR}\n` +
` Run "pnpm build --filter=${targetApp}" first.\n`,
);
process.exit(1);
}
// ── Clean destination ──────────────────────────────────────────
if (existsSync(DEST_DIR)) {
rmSync(DEST_DIR, { recursive: true, force: true });
}
mkdirSync(DEST_DIR, { recursive: true });
// ── Copy ───────────────────────────────────────────────────────
cpSync(SOURCE_DIR, DEST_DIR, { recursive: true });
console.log(`✅ Copied ${targetApp} build → web-dist/\n`);
+417
View File
@@ -0,0 +1,417 @@
import {
app,
shell,
BrowserWindow,
ipcMain,
protocol,
session,
} from 'electron';
import { autoUpdater } from 'electron-updater';
import { join, extname, normalize } from 'path';
import { readFileSync, existsSync, statSync } from 'fs';
// ─── Configuration ──────────────────────────────────────────────
const DEV_SERVER_URL = process.env.DESKTOP_DEV_SERVER_URL || 'http://localhost:5173';
const IS_DEV = !app.isPackaged;
/**
* Resolve the directory containing the embedded web app's static build.
*
* Development: __dirname = apps/desktop/out/main
* → ../../ = apps/desktop → ../web/dist is WRONG
* → We need: apps/desktop/out/main → apps/desktop → apps → apps/web/dist
* So: join(__dirname, '..', '..', '..', 'web', 'dist')
*
* Production: process.resourcesPath = <app>/Contents/Resources (macOS)
* electron-builder extraResources copies web-dist/ there.
*/
function getWebDistPath(): string {
if (IS_DEV) {
// __dirname = apps/desktop/out/main → go up to apps/, then into web/dist
return join(__dirname, '..', '..', '..', 'web', 'dist');
}
return join(process.resourcesPath, 'web-dist');
}
// ─── MIME type map for custom protocol ──────────────────────────
const MIME_TYPES: Record<string, string> = {
'.html': 'text/html',
'.js': 'application/javascript',
'.mjs': 'application/javascript',
'.css': 'text/css',
'.json': 'application/json',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.svg': 'image/svg+xml',
'.ico': 'image/x-icon',
'.webp': 'image/webp',
'.avif': 'image/avif',
'.woff': 'font/woff',
'.woff2': 'font/woff2',
'.ttf': 'font/ttf',
'.eot': 'application/vnd.ms-fontobject',
'.otf': 'font/otf',
'.wasm': 'application/wasm',
'.map': 'application/json',
'.txt': 'text/plain',
'.xml': 'application/xml',
};
function getMimeType(filePath: string): string {
const ext = extname(filePath).toLowerCase();
return MIME_TYPES[ext] || 'application/octet-stream';
}
// ─── CSP header for production ──────────────────────────────────
// Applied via the custom protocol response headers since the
// src/renderer/index.html is never loaded (we load web app's HTML).
const PRODUCTION_CSP = [
"default-src 'self' app:",
"script-src 'self' app:",
"style-src 'self' 'unsafe-inline' app:",
"connect-src 'self' app: https:",
"img-src 'self' app: data: https:",
"font-src 'self' app: data: https:",
"media-src 'self' app:",
"worker-src 'self' app: blob:",
].join('; ');
// ─── 1. Register custom scheme BEFORE app is ready ──────────────
// This must happen synchronously at module load time.
protocol.registerSchemesAsPrivileged([
{
scheme: 'app',
privileges: {
standard: true,
secure: true,
supportFetchAPI: true,
corsEnabled: true,
stream: true,
},
},
]);
// ─── Keep a global reference to avoid GC ────────────────────────
let mainWindow: BrowserWindow | null = null;
// ─── 2. Create the main browser window ──────────────────────────
function createWindow(): void {
mainWindow = new BrowserWindow({
width: 1280,
height: 800,
minWidth: 800,
minHeight: 600,
show: false, // Show after ready-to-show to avoid flash
webPreferences: {
preload: join(__dirname, '..', 'preload', 'index.js'),
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
webSecurity: true,
},
});
// Show window when content is painted (avoids white flash)
mainWindow.on('ready-to-show', () => {
mainWindow?.show();
});
// Open external links in the default browser
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
if (url.startsWith('https:') || url.startsWith('http:')) {
shell.openExternal(url);
}
return { action: 'deny' };
});
// ── Load content ──────────────────────────────────────────────
if (IS_DEV) {
mainWindow.loadURL(DEV_SERVER_URL);
mainWindow.webContents.openDevTools({ mode: 'detach' });
} else {
mainWindow.loadURL('app://-/index.html');
}
mainWindow.on('closed', () => {
mainWindow = null;
});
}
// ─── 3. Register custom app:// protocol handler ────────────────
// Intercepts all requests to app://-/... and serves files from
// the embedded web-dist directory. Falls back to index.html for
// any path that doesn't match a real file (SPA client-side routing).
function registerAppProtocol(): void {
const webDistPath = getWebDistPath();
protocol.handle('app', (request) => {
try {
const url = new URL(request.url);
let filePath = decodeURIComponent(url.pathname);
// Remove leading "/" for file resolution
if (filePath.startsWith('/')) {
filePath = filePath.slice(1);
}
// Default to index.html for root
if (!filePath || filePath === '') {
filePath = 'index.html';
}
// ── Security: prevent path traversal attacks ──────────────
const absolutePath = normalize(join(webDistPath, filePath));
if (!absolutePath.startsWith(normalize(webDistPath))) {
return new Response('Forbidden', { status: 403 });
}
// If the file exists and is a file (not directory), serve it
if (existsSync(absolutePath) && statSync(absolutePath).isFile()) {
const mimeType = getMimeType(absolutePath);
const fileBuffer = readFileSync(absolutePath);
const headers: Record<string, string> = {
'Content-Type': mimeType,
'Cache-Control': 'no-cache',
};
// Apply CSP only to HTML responses
if (mimeType === 'text/html') {
headers['Content-Security-Policy'] = PRODUCTION_CSP;
}
return new Response(fileBuffer, { status: 200, headers });
}
// ── SPA Fallback ──────────────────────────────────────────
// If the requested file doesn't exist, serve index.html
// so React Router can handle the route client-side.
const indexPath = join(webDistPath, 'index.html');
if (existsSync(indexPath)) {
const indexBuffer = readFileSync(indexPath);
return new Response(indexBuffer, {
status: 200,
headers: {
'Content-Type': 'text/html',
'Content-Security-Policy': PRODUCTION_CSP,
'Cache-Control': 'no-cache',
},
});
}
return new Response('Not Found', { status: 404 });
} catch (err) {
console.error('[app:// protocol] Error serving request:', request.url, err);
return new Response('Internal Server Error', { status: 500 });
}
});
}
// ─── 4. CORS Bypass for cloud API calls ─────────────────────────
// When running via app:// or file://, the Origin header will be
// non-standard. We strip/rewrite it on outgoing requests and
// inject permissive CORS headers on incoming responses.
//
// FIX: Electron webRequest filter requires full URL patterns
// with a path component, e.g. "https://*/*" not "https://*".
function setupCorsBypass(): void {
const filter = { urls: ['https://*/*', 'http://*/*'] };
// Rewrite the Origin header on outgoing requests
session.defaultSession.webRequest.onBeforeSendHeaders(filter, (details, callback) => {
const { requestHeaders } = details;
// Remove the app:// or file:// origin so the server sees
// a "normal" request or a null origin
if (
requestHeaders['Origin'] &&
(requestHeaders['Origin'].startsWith('app://') ||
requestHeaders['Origin'].startsWith('file://'))
) {
delete requestHeaders['Origin'];
}
callback({ requestHeaders });
});
// Inject CORS headers on incoming responses
session.defaultSession.webRequest.onHeadersReceived(filter, (details, callback) => {
const responseHeaders = { ...details.responseHeaders };
// Only inject if the server didn't already set them
if (!responseHeaders['Access-Control-Allow-Origin']) {
responseHeaders['Access-Control-Allow-Origin'] = ['*'];
}
if (!responseHeaders['Access-Control-Allow-Headers']) {
responseHeaders['Access-Control-Allow-Headers'] = ['*'];
}
if (!responseHeaders['Access-Control-Allow-Methods']) {
responseHeaders['Access-Control-Allow-Methods'] = ['GET, POST, PUT, DELETE, PATCH, OPTIONS'];
}
callback({ responseHeaders });
});
}
// ─── 5. IPC Handlers: Printing ──────────────────────────────────
function setupPrinterIPC(): void {
// Get list of available printers
ipcMain.handle('printer:get-list', async () => {
if (!mainWindow) return [];
try {
return await mainWindow.webContents.getPrintersAsync();
} catch (err) {
console.error('[Printer IPC] Failed to get printers:', err);
return [];
}
});
// Print the current page with given options
ipcMain.handle(
'printer:print',
async (
_event,
options?: Electron.WebContentsPrintOptions,
): Promise<{ success: boolean; failureReason?: string }> => {
if (!mainWindow) {
return { success: false, failureReason: 'No active window' };
}
return new Promise((resolve) => {
mainWindow!.webContents.print(options || {}, (success, failureReason) => {
resolve({
success,
failureReason: failureReason || undefined,
});
});
});
},
);
}
// ─── 6. IPC Handlers: Auto-Update ──────────────────────────────
function setupAutoUpdaterIPC(): void {
// Manual check from renderer
ipcMain.handle('updater:check', async () => {
try {
return await autoUpdater.checkForUpdates();
} catch (err) {
console.error('[AutoUpdater] Manual check failed:', err);
sendToRenderer('updater:error', (err as Error).message || 'Update check failed');
return null;
}
});
// Quit and install after download completes
ipcMain.on('updater:install', () => {
autoUpdater.quitAndInstall(false, true);
});
}
// Forward auto-updater events to the renderer process
function setupAutoUpdaterEvents(): void {
autoUpdater.autoDownload = true;
autoUpdater.autoInstallOnAppQuit = true;
// In dev mode: completely skip auto-updater config to avoid
// crashes from missing dev-app-update.yml
if (IS_DEV) {
autoUpdater.autoDownload = false;
// Do NOT set forceDevUpdateConfig — it requires a
// dev-app-update.yml file that we don't ship.
return; // Skip event registration in dev
}
autoUpdater.on('checking-for-update', () => {
sendToRenderer('updater:checking');
});
autoUpdater.on('update-available', (info) => {
sendToRenderer('updater:available', info);
});
autoUpdater.on('update-not-available', (info) => {
sendToRenderer('updater:not-available', info);
});
autoUpdater.on('download-progress', (progress) => {
sendToRenderer('updater:progress', progress);
});
autoUpdater.on('update-downloaded', (info) => {
sendToRenderer('updater:downloaded', info);
});
autoUpdater.on('error', (error) => {
console.error('[AutoUpdater] Error:', error);
sendToRenderer('updater:error', error.message);
});
}
function sendToRenderer(channel: string, ...args: unknown[]): void {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send(channel, ...args);
}
}
// ─── 7. App Lifecycle ───────────────────────────────────────────
app.whenReady().then(() => {
// Register the custom protocol before creating the window
registerAppProtocol();
// Setup CORS bypass for API calls
setupCorsBypass();
// Setup IPC handlers
setupPrinterIPC();
setupAutoUpdaterIPC();
setupAutoUpdaterEvents();
// Create the main window
createWindow();
// Check for updates on startup (production only)
if (!IS_DEV) {
setTimeout(async () => {
try {
await autoUpdater.checkForUpdatesAndNotify();
} catch (err) {
// Gracefully handle offline or network errors
console.error('[AutoUpdater] Startup check failed (possibly offline):', err);
}
}, 3000);
}
// macOS: re-create window when dock icon is clicked
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow();
}
});
});
// Quit when all windows are closed (except macOS)
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit();
}
});
// Security: prevent navigation to unexpected URLs
app.on('web-contents-created', (_event, contents) => {
contents.on('will-navigate', (event, url) => {
// Allow navigation within the app protocol and dev server
if (
url.startsWith('app://') ||
(IS_DEV && url.startsWith(DEV_SERVER_URL))
) {
return;
}
event.preventDefault();
});
});
+81
View File
@@ -0,0 +1,81 @@
import { contextBridge, ipcRenderer } from 'electron';
// ─── Type definitions for the exposed API ───────────────────────
// These mirror the types in apps/web/src/types/electron.d.ts
export interface PrintResult {
success: boolean;
failureReason?: string;
}
export interface ProgressInfo {
total: number;
delta: number;
transferred: number;
percent: number;
bytesPerSecond: number;
}
export interface UpdateInfo {
version: string;
releaseDate: string;
releaseName?: string | null;
releaseNotes?: string | null;
}
// ─── Helper: create a one-way event listener with cleanup ───────
function createEventSubscription<T>(channel: string) {
return (callback: (data: T) => void): (() => void) => {
const handler = (_event: Electron.IpcRendererEvent, data: T) => callback(data);
ipcRenderer.on(channel, handler);
// Return an unsubscribe function
return () => {
ipcRenderer.removeListener(channel, handler);
};
};
}
// ─── Exposed API ────────────────────────────────────────────────
// SECURITY: Only expose specific methods. Never expose ipcRenderer directly.
const electronAPI = {
// ── Printing ────────────────────────────────────────────────
getPrinters: (): Promise<Electron.PrinterInfo[]> => {
return ipcRenderer.invoke('printer:get-list');
},
print: (options?: Electron.WebContentsPrintOptions): Promise<PrintResult> => {
return ipcRenderer.invoke('printer:print', options);
},
// ── Auto-Update: Commands ───────────────────────────────────
checkForUpdates: (): void => {
ipcRenderer.invoke('updater:check');
},
installUpdate: (): void => {
ipcRenderer.send('updater:install');
},
// ── Auto-Update: Event Subscriptions ────────────────────────
// Each returns an unsubscribe function for cleanup in useEffect.
onUpdateChecking: createEventSubscription<void>('updater:checking'),
onUpdateAvailable: createEventSubscription<UpdateInfo>('updater:available'),
onUpdateNotAvailable: createEventSubscription<UpdateInfo>('updater:not-available'),
onDownloadProgress: createEventSubscription<ProgressInfo>('updater:progress'),
onUpdateDownloaded: createEventSubscription<UpdateInfo>('updater:downloaded'),
onUpdateError: createEventSubscription<string>('updater:error'),
};
// ─── Expose to renderer via contextBridge ───────────────────────
contextBridge.exposeInMainWorld('electronAPI', electronAPI);
// Export the type for reference (used by electron.d.ts in apps/web)
export type ElectronAPI = typeof electronAPI;
+24
View File
@@ -0,0 +1,24 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta
http-equiv="Content-Security-Policy"
content="
default-src 'self' app:;
script-src 'self' app:;
style-src 'self' 'unsafe-inline' app:;
connect-src 'self' app: https: http://localhost:*;
img-src 'self' app: data: https:;
font-src 'self' app: data: https:;
media-src 'self' app:;
worker-src 'self' app: blob:;
"
/>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Eigen Desktop</title>
</head>
<body>
<div id="app"></div>
</body>
</html>
+11
View File
@@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true
},
"references": [
{ "path": "./tsconfig.main.json" },
{ "path": "./tsconfig.preload.json" },
{ "path": "./tsconfig.renderer.json" }
],
"include": []
}
+19
View File
@@ -0,0 +1,19 @@
{
"compilerOptions": {
"composite": true,
"outDir": "./out/main",
"module": "ESNext",
"moduleResolution": "bundler",
"target": "ES2022",
"lib": ["ES2022"],
"types": ["node"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"sourceMap": true
},
"include": ["src/main/**/*"]
}
+18
View File
@@ -0,0 +1,18 @@
{
"compilerOptions": {
"composite": true,
"outDir": "./out/preload",
"module": "ESNext",
"moduleResolution": "bundler",
"target": "ES2022",
"lib": ["ES2022"],
"types": ["node"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"sourceMap": true
},
"include": ["src/preload/**/*"]
}
+17
View File
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"composite": true,
"outDir": "./out/renderer",
"module": "ESNext",
"moduleResolution": "bundler",
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"sourceMap": true
},
"include": ["src/renderer/**/*"]
}
@@ -0,0 +1,35 @@
// 1. Import hooks yang baru saja dibuat Opus
import { Button } from '@repo/ui/components';
import { useElectronPrinter } from '../../hooks/use-electron-printer';
import { useElectronUpdater } from '../../hooks/use-electron-updater';
export default function App() {
// 2. Panggil hooks-nya
const { printers, refreshPrinters } = useElectronPrinter();
const { status } = useElectronUpdater();
return (
<div style={{ padding: '20px', border: '2px solid blue', margin: '20px' }}>
<h2>🧪 Test Integrasi Electron</h2>
<p><strong>Status Auto-Update:</strong> {status}</p>
<Button variant="filled" color="brand" onClick={refreshPrinters}>
Refresh Printer
</Button>
<h3>🖨 Daftar Printer di Komputer Ini:</h3>
<ul>
{printers.length === 0 ? (
<li>Mencari printer... (Atau tidak ada printer terdeteksi)</li>
) : (
printers.map((printer, index) => (
<li key={index}>
{printer.name} {printer.isDefault ? '(Default)' : ''}
</li>
))
)}
</ul>
</div>
);
}
@@ -19,6 +19,7 @@ import {
Badge, Badge,
Divider, Divider,
} from '@repo/ui/components'; } from '@repo/ui/components';
import PrinterList from './printer-list'
interface ShowcaseViewProps { interface ShowcaseViewProps {
colorScheme: ColorSchemeType; colorScheme: ColorSchemeType;
@@ -204,6 +205,9 @@ export default function ShowcaseView({ colorScheme, setColorScheme, density, set
</Table.Tbody> </Table.Tbody>
</Table> </Table>
</Card> </Card>
<Card withBorder shadow="sm" radius="md" p="md">
<PrinterList />
</Card>
</Stack> </Stack>
</Container> </Container>
); );
+109
View File
@@ -0,0 +1,109 @@
import { useState, useCallback } from 'react';
// ─── Types ──────────────────────────────────────────────────────
export interface UseElectronPrinterReturn {
/** List of available printers (populated after calling `refreshPrinters`) */
printers: ElectronPrinterInfo[];
/** Whether a printer operation is in progress */
loading: boolean;
/** Last error message, if any */
error: string | null;
/** Whether the app is running inside Electron */
isElectron: boolean;
/** Fetch the current list of available printers */
refreshPrinters: () => Promise<ElectronPrinterInfo[]>;
/** Print with the given options. Returns success/failure. */
print: (options?: ElectronPrintOptions) => Promise<ElectronPrintResult>;
}
/**
* React hook for Electron printer integration.
*
* Provides methods to list available printers and trigger print jobs
* via the secure `window.electronAPI` bridge.
*
* Safe to use in both Electron and browser environments.
*
* @example
* ```tsx
* function PrintButton() {
* const { printers, refreshPrinters, print, loading } = useElectronPrinter();
*
* useEffect(() => { refreshPrinters(); }, []);
*
* const handlePrint = async () => {
* const result = await print({ silent: true, deviceName: printers[0]?.name });
* if (!result.success) alert(`Print failed: ${result.failureReason}`);
* };
*
* return (
* <button onClick={handlePrint} disabled={loading || printers.length === 0}>
* Print
* </button>
* );
* }
* ```
*/
export function useElectronPrinter(): UseElectronPrinterReturn {
const [printers, setPrinters] = useState<ElectronPrinterInfo[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const isElectron = typeof window !== 'undefined' && !!window.electronAPI;
const refreshPrinters = useCallback(async (): Promise<ElectronPrinterInfo[]> => {
if (!window.electronAPI) {
setError('Not running in Electron');
return [];
}
setLoading(true);
setError(null);
try {
const result = await window.electronAPI.getPrinters();
setPrinters(result);
return result;
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to get printers';
setError(message);
return [];
} finally {
setLoading(false);
}
}, []);
const print = useCallback(
async (options?: ElectronPrintOptions): Promise<ElectronPrintResult> => {
if (!window.electronAPI) {
return { success: false, failureReason: 'Not running in Electron' };
}
setLoading(true);
setError(null);
try {
const result = await window.electronAPI.print(options);
if (!result.success && result.failureReason) {
setError(result.failureReason);
}
return result;
} catch (err) {
const message = err instanceof Error ? err.message : 'Print failed';
setError(message);
return { success: false, failureReason: message };
} finally {
setLoading(false);
}
},
[],
);
return {
printers,
loading,
error,
isElectron,
refreshPrinters,
print,
};
}
+139
View File
@@ -0,0 +1,139 @@
import { useState, useEffect, useCallback } from 'react';
// ─── Types ──────────────────────────────────────────────────────
export type UpdateStatus =
| 'idle'
| 'checking'
| 'available'
| 'not-available'
| 'downloading'
| 'ready'
| 'error';
export interface UseElectronUpdaterReturn {
/** Current status of the auto-updater lifecycle */
status: UpdateStatus;
/** Download progress percentage (0100) */
progress: number;
/** Download speed in bytes per second */
bytesPerSecond: number;
/** Information about the available/downloaded update */
updateInfo: ElectronUpdateInfo | null;
/** Error message if the updater encountered an issue */
errorMessage: string | null;
/** Whether the app is running inside Electron */
isElectron: boolean;
/** Trigger a manual update check */
checkForUpdates: () => void;
/** Quit the app and install the downloaded update */
installUpdate: () => void;
}
/**
* React hook for the Electron auto-updater.
*
* Subscribes to all update lifecycle events via `window.electronAPI`
* and provides reactive state for building an update notification UI.
*
* Safe to use in both Electron and browser environments — all
* Electron-specific calls are gated behind `window.electronAPI` checks.
*
* @example
* ```tsx
* function UpdateBanner() {
* const { status, progress, updateInfo, checkForUpdates, installUpdate } = useElectronUpdater();
*
* if (status === 'available') {
* return <div>Update {updateInfo?.version} available! Downloading...</div>;
* }
* if (status === 'downloading') {
* return <div>Downloading... {progress.toFixed(0)}%</div>;
* }
* if (status === 'ready') {
* return <button onClick={installUpdate}>Restart to update</button>;
* }
* return <button onClick={checkForUpdates}>Check for updates</button>;
* }
* ```
*/
export function useElectronUpdater(): UseElectronUpdaterReturn {
const [status, setStatus] = useState<UpdateStatus>('idle');
const [progress, setProgress] = useState(0);
const [bytesPerSecond, setBytesPerSecond] = useState(0);
const [updateInfo, setUpdateInfo] = useState<ElectronUpdateInfo | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const isElectron = typeof window !== 'undefined' && !!window.electronAPI;
useEffect(() => {
if (!window.electronAPI) return;
const api = window.electronAPI;
const unsubChecking = api.onUpdateChecking(() => {
setStatus('checking');
setErrorMessage(null);
});
const unsubAvailable = api.onUpdateAvailable((info) => {
setStatus('available');
setUpdateInfo(info);
});
const unsubNotAvailable = api.onUpdateNotAvailable((info) => {
setStatus('not-available');
setUpdateInfo(info);
});
const unsubProgress = api.onDownloadProgress((progressInfo) => {
setStatus('downloading');
setProgress(progressInfo.percent);
setBytesPerSecond(progressInfo.bytesPerSecond);
});
const unsubDownloaded = api.onUpdateDownloaded((info) => {
setStatus('ready');
setProgress(100);
setUpdateInfo(info);
});
const unsubError = api.onUpdateError((error) => {
setStatus('error');
setErrorMessage(error);
});
// Cleanup all listeners on unmount
return () => {
unsubChecking();
unsubAvailable();
unsubNotAvailable();
unsubProgress();
unsubDownloaded();
unsubError();
};
}, []);
const checkForUpdates = useCallback(() => {
if (!window.electronAPI) return;
setStatus('checking');
setErrorMessage(null);
window.electronAPI.checkForUpdates();
}, []);
const installUpdate = useCallback(() => {
if (!window.electronAPI) return;
window.electronAPI.installUpdate();
}, []);
return {
status,
progress,
bytesPerSecond,
updateInfo,
errorMessage,
isElectron,
checkForUpdates,
installUpdate,
};
}
+98
View File
@@ -0,0 +1,98 @@
/**
* Type declarations for the Electron preload API.
*
* When running inside Electron, `window.electronAPI` is defined.
* When running in a regular browser, it is `undefined`.
*
* Usage:
* if (window.electronAPI) {
* const printers = await window.electronAPI.getPrinters();
* }
*/
// ─── Printer types ──────────────────────────────────────────────
interface ElectronPrinterInfo {
name: string;
displayName: string;
description: string;
status: number;
isDefault: boolean;
options?: Record<string, string>;
}
interface ElectronPrintOptions {
silent?: boolean;
printBackground?: boolean;
deviceName?: string;
color?: boolean;
margins?: {
marginType?: 'default' | 'none' | 'printableArea' | 'custom';
top?: number;
bottom?: number;
left?: number;
right?: number;
};
landscape?: boolean;
scaleFactor?: number;
pagesPerSheet?: number;
collate?: boolean;
copies?: number;
pageRanges?: Array<{ from: number; to: number }>;
duplexMode?: 'simplex' | 'shortEdge' | 'longEdge';
header?: string;
footer?: string;
}
interface ElectronPrintResult {
success: boolean;
failureReason?: string;
}
// ─── Auto-Update types ──────────────────────────────────────────
interface ElectronUpdateInfo {
version: string;
releaseDate: string;
releaseName?: string | null;
releaseNotes?: string | null;
}
interface ElectronProgressInfo {
total: number;
delta: number;
transferred: number;
percent: number;
bytesPerSecond: number;
}
// ─── ElectronAPI interface ──────────────────────────────────────
interface ElectronAPI {
// Printing
getPrinters: () => Promise<ElectronPrinterInfo[]>;
print: (options?: ElectronPrintOptions) => Promise<ElectronPrintResult>;
// Auto-Update: Commands
checkForUpdates: () => void;
installUpdate: () => void;
// Auto-Update: Event Subscriptions
// Each returns an unsubscribe function.
onUpdateChecking: (callback: () => void) => () => void;
onUpdateAvailable: (callback: (info: ElectronUpdateInfo) => void) => () => void;
onUpdateNotAvailable: (callback: (info: ElectronUpdateInfo) => void) => () => void;
onDownloadProgress: (callback: (progress: ElectronProgressInfo) => void) => () => void;
onUpdateDownloaded: (callback: (info: ElectronUpdateInfo) => void) => () => void;
onUpdateError: (callback: (error: string) => void) => () => void;
}
// ─── Augment the global Window interface ────────────────────────
interface Window {
/**
* Available only when running inside Electron.
* Always check `if (window.electronAPI)` before use.
*/
electronAPI?: ElectronAPI;
}
+3
View File
@@ -6,9 +6,12 @@
"build": "turbo run build", "build": "turbo run build",
"build:web": "turbo run build --filter=web", "build:web": "turbo run build --filter=web",
"build:docs-dev": "turbo run build --filter=docs-dev", "build:docs-dev": "turbo run build --filter=docs-dev",
"build:desktop": "turbo run build --filter=web && turbo run build --filter=desktop",
"dev": "turbo run dev", "dev": "turbo run dev",
"dev:web": "turbo run dev --filter=web", "dev:web": "turbo run dev --filter=web",
"dev:docs-dev": "turbo run dev --filter=docs-dev", "dev:docs-dev": "turbo run dev --filter=docs-dev",
"dev:desktop": "turbo run dev --filter=web --filter=desktop --parallel",
"package:desktop": "pnpm run build:desktop && cd apps/desktop && pnpm run package",
"lint": "turbo run lint", "lint": "turbo run lint",
"format": "prettier --write \"**/*.{ts,tsx,md}\"", "format": "prettier --write \"**/*.{ts,tsx,md}\"",
"test": "turbo run test" "test": "turbo run test"
+1992 -29
View File
File diff suppressed because it is too large Load Diff
+8 -1
View File
@@ -13,7 +13,10 @@
"outputs": [ "outputs": [
"dist/**", "dist/**",
".next/**", ".next/**",
"!.next/cache/**" "!.next/cache/**",
"out/**",
"release/**",
"web-dist/**"
] ]
}, },
"lint": { "lint": {
@@ -30,6 +33,10 @@
"cache": false, "cache": false,
"persistent": true "persistent": true
}, },
"dev:desktop": {
"cache": false,
"persistent": true
},
"test": { "test": {
"dependsOn": [ "dependsOn": [
"^test" "^test"