build: generate desktop application release artifacts and update project configuration
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user