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
+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>