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