build: generate desktop application release artifacts and update project configuration
This commit is contained in:
@@ -13,3 +13,8 @@ public/dist
|
|||||||
# storybook
|
# storybook
|
||||||
*storybook.log
|
*storybook.log
|
||||||
storybook-static
|
storybook-static
|
||||||
|
|
||||||
|
# Electron
|
||||||
|
out/
|
||||||
|
release/
|
||||||
|
web-dist/
|
||||||
@@ -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,
|
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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
|
||||||
|
// ─── Types ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface UseElectronPrinterReturn {
|
||||||
|
/** List of available printers (populated after calling `refreshPrinters`) */
|
||||||
|
printers: ElectronPrinterInfo[];
|
||||||
|
/** Whether a printer operation is in progress */
|
||||||
|
loading: boolean;
|
||||||
|
/** Last error message, if any */
|
||||||
|
error: string | null;
|
||||||
|
/** Whether the app is running inside Electron */
|
||||||
|
isElectron: boolean;
|
||||||
|
/** Fetch the current list of available printers */
|
||||||
|
refreshPrinters: () => Promise<ElectronPrinterInfo[]>;
|
||||||
|
/** Print with the given options. Returns success/failure. */
|
||||||
|
print: (options?: ElectronPrintOptions) => Promise<ElectronPrintResult>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* React hook for Electron printer integration.
|
||||||
|
*
|
||||||
|
* Provides methods to list available printers and trigger print jobs
|
||||||
|
* via the secure `window.electronAPI` bridge.
|
||||||
|
*
|
||||||
|
* Safe to use in both Electron and browser environments.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* function PrintButton() {
|
||||||
|
* const { printers, refreshPrinters, print, loading } = useElectronPrinter();
|
||||||
|
*
|
||||||
|
* useEffect(() => { refreshPrinters(); }, []);
|
||||||
|
*
|
||||||
|
* const handlePrint = async () => {
|
||||||
|
* const result = await print({ silent: true, deviceName: printers[0]?.name });
|
||||||
|
* if (!result.success) alert(`Print failed: ${result.failureReason}`);
|
||||||
|
* };
|
||||||
|
*
|
||||||
|
* return (
|
||||||
|
* <button onClick={handlePrint} disabled={loading || printers.length === 0}>
|
||||||
|
* Print
|
||||||
|
* </button>
|
||||||
|
* );
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function useElectronPrinter(): UseElectronPrinterReturn {
|
||||||
|
const [printers, setPrinters] = useState<ElectronPrinterInfo[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const isElectron = typeof window !== 'undefined' && !!window.electronAPI;
|
||||||
|
|
||||||
|
const refreshPrinters = useCallback(async (): Promise<ElectronPrinterInfo[]> => {
|
||||||
|
if (!window.electronAPI) {
|
||||||
|
setError('Not running in Electron');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const result = await window.electronAPI.getPrinters();
|
||||||
|
setPrinters(result);
|
||||||
|
return result;
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Failed to get printers';
|
||||||
|
setError(message);
|
||||||
|
return [];
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const print = useCallback(
|
||||||
|
async (options?: ElectronPrintOptions): Promise<ElectronPrintResult> => {
|
||||||
|
if (!window.electronAPI) {
|
||||||
|
return { success: false, failureReason: 'Not running in Electron' };
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const result = await window.electronAPI.print(options);
|
||||||
|
if (!result.success && result.failureReason) {
|
||||||
|
setError(result.failureReason);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Print failed';
|
||||||
|
setError(message);
|
||||||
|
return { success: false, failureReason: message };
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
printers,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
isElectron,
|
||||||
|
refreshPrinters,
|
||||||
|
print,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
|
||||||
|
// ─── Types ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type UpdateStatus =
|
||||||
|
| 'idle'
|
||||||
|
| 'checking'
|
||||||
|
| 'available'
|
||||||
|
| 'not-available'
|
||||||
|
| 'downloading'
|
||||||
|
| 'ready'
|
||||||
|
| 'error';
|
||||||
|
|
||||||
|
export interface UseElectronUpdaterReturn {
|
||||||
|
/** Current status of the auto-updater lifecycle */
|
||||||
|
status: UpdateStatus;
|
||||||
|
/** Download progress percentage (0–100) */
|
||||||
|
progress: number;
|
||||||
|
/** Download speed in bytes per second */
|
||||||
|
bytesPerSecond: number;
|
||||||
|
/** Information about the available/downloaded update */
|
||||||
|
updateInfo: ElectronUpdateInfo | null;
|
||||||
|
/** Error message if the updater encountered an issue */
|
||||||
|
errorMessage: string | null;
|
||||||
|
/** Whether the app is running inside Electron */
|
||||||
|
isElectron: boolean;
|
||||||
|
/** Trigger a manual update check */
|
||||||
|
checkForUpdates: () => void;
|
||||||
|
/** Quit the app and install the downloaded update */
|
||||||
|
installUpdate: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* React hook for the Electron auto-updater.
|
||||||
|
*
|
||||||
|
* Subscribes to all update lifecycle events via `window.electronAPI`
|
||||||
|
* and provides reactive state for building an update notification UI.
|
||||||
|
*
|
||||||
|
* Safe to use in both Electron and browser environments — all
|
||||||
|
* Electron-specific calls are gated behind `window.electronAPI` checks.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* function UpdateBanner() {
|
||||||
|
* const { status, progress, updateInfo, checkForUpdates, installUpdate } = useElectronUpdater();
|
||||||
|
*
|
||||||
|
* if (status === 'available') {
|
||||||
|
* return <div>Update {updateInfo?.version} available! Downloading...</div>;
|
||||||
|
* }
|
||||||
|
* if (status === 'downloading') {
|
||||||
|
* return <div>Downloading... {progress.toFixed(0)}%</div>;
|
||||||
|
* }
|
||||||
|
* if (status === 'ready') {
|
||||||
|
* return <button onClick={installUpdate}>Restart to update</button>;
|
||||||
|
* }
|
||||||
|
* return <button onClick={checkForUpdates}>Check for updates</button>;
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function useElectronUpdater(): UseElectronUpdaterReturn {
|
||||||
|
const [status, setStatus] = useState<UpdateStatus>('idle');
|
||||||
|
const [progress, setProgress] = useState(0);
|
||||||
|
const [bytesPerSecond, setBytesPerSecond] = useState(0);
|
||||||
|
const [updateInfo, setUpdateInfo] = useState<ElectronUpdateInfo | null>(null);
|
||||||
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const isElectron = typeof window !== 'undefined' && !!window.electronAPI;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!window.electronAPI) return;
|
||||||
|
|
||||||
|
const api = window.electronAPI;
|
||||||
|
|
||||||
|
const unsubChecking = api.onUpdateChecking(() => {
|
||||||
|
setStatus('checking');
|
||||||
|
setErrorMessage(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
const unsubAvailable = api.onUpdateAvailable((info) => {
|
||||||
|
setStatus('available');
|
||||||
|
setUpdateInfo(info);
|
||||||
|
});
|
||||||
|
|
||||||
|
const unsubNotAvailable = api.onUpdateNotAvailable((info) => {
|
||||||
|
setStatus('not-available');
|
||||||
|
setUpdateInfo(info);
|
||||||
|
});
|
||||||
|
|
||||||
|
const unsubProgress = api.onDownloadProgress((progressInfo) => {
|
||||||
|
setStatus('downloading');
|
||||||
|
setProgress(progressInfo.percent);
|
||||||
|
setBytesPerSecond(progressInfo.bytesPerSecond);
|
||||||
|
});
|
||||||
|
|
||||||
|
const unsubDownloaded = api.onUpdateDownloaded((info) => {
|
||||||
|
setStatus('ready');
|
||||||
|
setProgress(100);
|
||||||
|
setUpdateInfo(info);
|
||||||
|
});
|
||||||
|
|
||||||
|
const unsubError = api.onUpdateError((error) => {
|
||||||
|
setStatus('error');
|
||||||
|
setErrorMessage(error);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cleanup all listeners on unmount
|
||||||
|
return () => {
|
||||||
|
unsubChecking();
|
||||||
|
unsubAvailable();
|
||||||
|
unsubNotAvailable();
|
||||||
|
unsubProgress();
|
||||||
|
unsubDownloaded();
|
||||||
|
unsubError();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const checkForUpdates = useCallback(() => {
|
||||||
|
if (!window.electronAPI) return;
|
||||||
|
setStatus('checking');
|
||||||
|
setErrorMessage(null);
|
||||||
|
window.electronAPI.checkForUpdates();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const installUpdate = useCallback(() => {
|
||||||
|
if (!window.electronAPI) return;
|
||||||
|
window.electronAPI.installUpdate();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
status,
|
||||||
|
progress,
|
||||||
|
bytesPerSecond,
|
||||||
|
updateInfo,
|
||||||
|
errorMessage,
|
||||||
|
isElectron,
|
||||||
|
checkForUpdates,
|
||||||
|
installUpdate,
|
||||||
|
};
|
||||||
|
}
|
||||||
Vendored
+98
@@ -0,0 +1,98 @@
|
|||||||
|
/**
|
||||||
|
* Type declarations for the Electron preload API.
|
||||||
|
*
|
||||||
|
* When running inside Electron, `window.electronAPI` is defined.
|
||||||
|
* When running in a regular browser, it is `undefined`.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* if (window.electronAPI) {
|
||||||
|
* const printers = await window.electronAPI.getPrinters();
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ─── Printer types ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface ElectronPrinterInfo {
|
||||||
|
name: string;
|
||||||
|
displayName: string;
|
||||||
|
description: string;
|
||||||
|
status: number;
|
||||||
|
isDefault: boolean;
|
||||||
|
options?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ElectronPrintOptions {
|
||||||
|
silent?: boolean;
|
||||||
|
printBackground?: boolean;
|
||||||
|
deviceName?: string;
|
||||||
|
color?: boolean;
|
||||||
|
margins?: {
|
||||||
|
marginType?: 'default' | 'none' | 'printableArea' | 'custom';
|
||||||
|
top?: number;
|
||||||
|
bottom?: number;
|
||||||
|
left?: number;
|
||||||
|
right?: number;
|
||||||
|
};
|
||||||
|
landscape?: boolean;
|
||||||
|
scaleFactor?: number;
|
||||||
|
pagesPerSheet?: number;
|
||||||
|
collate?: boolean;
|
||||||
|
copies?: number;
|
||||||
|
pageRanges?: Array<{ from: number; to: number }>;
|
||||||
|
duplexMode?: 'simplex' | 'shortEdge' | 'longEdge';
|
||||||
|
header?: string;
|
||||||
|
footer?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ElectronPrintResult {
|
||||||
|
success: boolean;
|
||||||
|
failureReason?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Auto-Update types ──────────────────────────────────────────
|
||||||
|
|
||||||
|
interface ElectronUpdateInfo {
|
||||||
|
version: string;
|
||||||
|
releaseDate: string;
|
||||||
|
releaseName?: string | null;
|
||||||
|
releaseNotes?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ElectronProgressInfo {
|
||||||
|
total: number;
|
||||||
|
delta: number;
|
||||||
|
transferred: number;
|
||||||
|
percent: number;
|
||||||
|
bytesPerSecond: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── ElectronAPI interface ──────────────────────────────────────
|
||||||
|
|
||||||
|
interface ElectronAPI {
|
||||||
|
// Printing
|
||||||
|
getPrinters: () => Promise<ElectronPrinterInfo[]>;
|
||||||
|
print: (options?: ElectronPrintOptions) => Promise<ElectronPrintResult>;
|
||||||
|
|
||||||
|
// Auto-Update: Commands
|
||||||
|
checkForUpdates: () => void;
|
||||||
|
installUpdate: () => void;
|
||||||
|
|
||||||
|
// Auto-Update: Event Subscriptions
|
||||||
|
// Each returns an unsubscribe function.
|
||||||
|
onUpdateChecking: (callback: () => void) => () => void;
|
||||||
|
onUpdateAvailable: (callback: (info: ElectronUpdateInfo) => void) => () => void;
|
||||||
|
onUpdateNotAvailable: (callback: (info: ElectronUpdateInfo) => void) => () => void;
|
||||||
|
onDownloadProgress: (callback: (progress: ElectronProgressInfo) => void) => () => void;
|
||||||
|
onUpdateDownloaded: (callback: (info: ElectronUpdateInfo) => void) => () => void;
|
||||||
|
onUpdateError: (callback: (error: string) => void) => () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Augment the global Window interface ────────────────────────
|
||||||
|
|
||||||
|
interface Window {
|
||||||
|
/**
|
||||||
|
* Available only when running inside Electron.
|
||||||
|
* Always check `if (window.electronAPI)` before use.
|
||||||
|
*/
|
||||||
|
electronAPI?: ElectronAPI;
|
||||||
|
}
|
||||||
@@ -6,9 +6,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"
|
||||||
|
|||||||
Generated
+1992
-29
File diff suppressed because it is too large
Load Diff
+8
-1
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user