feat: Electron desktop app — hardening, IPC bridge, and dev launch fixes

- Dev/prod URL auto-detection (Vite 5173 in dev, server 3000 in prod)
- Typed IPC bridge via preload (notifications, badge, window controls, updates, deep links)
- Native OS notifications via NotificationController with window focus suppression
- Auto-update via electron-updater with UpdateToast UI
- Deep linking (backspace:// protocol) for macOS and Windows/Linux
- Window state persistence (position, size, maximize across restarts)
- Tray icon with graceful fallback when icon asset missing
- Suppress PWA service worker polling/reloads inside Electron
- Platform detection layer (isElectron, getElectronAPI)
- Root workspace scripts (dev:desktop, build:desktop)
- Document BACKSPACE_URL and BACKSPACE_UPDATE_URL env vars
This commit is contained in:
Jannis Braun
2026-03-16 00:10:47 +01:00
parent 56811b9333
commit 8ae3ffc912
17 changed files with 743 additions and 85 deletions
+320 -61
View File
@@ -7,61 +7,99 @@ import {
nativeImage,
ipcMain,
shell,
screen,
} from 'electron';
import path from 'path';
import fs from 'fs';
let mainWindow: BrowserWindow | null = null;
let tray: Tray | null = null;
let isQuitting = false;
let pendingDeepLink: string | null = null;
const SERVER_URL = process.env.BACKSPACE_URL || 'http://localhost:3000';
const DEV_URL = 'http://localhost:5173';
const PROD_URL = 'http://localhost:3000';
const SERVER_URL = process.env.BACKSPACE_URL || (app.isPackaged ? PROD_URL : DEV_URL);
function createWindow(): void {
mainWindow = new BrowserWindow({
width: 1280,
height: 800,
minWidth: 940,
minHeight: 500,
title: 'Backspace',
titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default',
backgroundColor: '#313338',
show: false,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
},
});
// ─── Window State Persistence ───────────────────────────────────────────────
mainWindow.loadURL(SERVER_URL);
mainWindow.once('ready-to-show', () => {
mainWindow?.show();
});
mainWindow.on('close', (event) => {
if (!isQuitting) {
event.preventDefault();
mainWindow?.hide();
}
});
mainWindow.on('closed', () => {
mainWindow = null;
});
// Open external links in default browser
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
if (url.startsWith('http://') || url.startsWith('https://')) {
shell.openExternal(url);
}
return { action: 'deny' };
});
interface WindowState {
width: number;
height: number;
x?: number;
y?: number;
isMaximized: boolean;
}
function createTray(): void {
// Create a 16x16 tray icon (blue circle for Backspace)
const DEFAULT_WINDOW_STATE: WindowState = {
width: 1280,
height: 800,
isMaximized: false,
};
function getWindowStatePath(): string {
return path.join(app.getPath('userData'), 'window-state.json');
}
function loadWindowState(): WindowState {
try {
const raw = fs.readFileSync(getWindowStatePath(), 'utf-8');
const parsed = JSON.parse(raw) as Partial<WindowState>;
return {
width: typeof parsed.width === 'number' ? parsed.width : DEFAULT_WINDOW_STATE.width,
height: typeof parsed.height === 'number' ? parsed.height : DEFAULT_WINDOW_STATE.height,
x: typeof parsed.x === 'number' ? parsed.x : undefined,
y: typeof parsed.y === 'number' ? parsed.y : undefined,
isMaximized: typeof parsed.isMaximized === 'boolean' ? parsed.isMaximized : false,
};
} catch {
return { ...DEFAULT_WINDOW_STATE };
}
}
function validateWindowBounds(state: WindowState): WindowState {
if (state.x === undefined || state.y === undefined) return state;
const bounds = { x: state.x, y: state.y, width: state.width, height: state.height };
const display = screen.getDisplayMatching(bounds);
const { x, y, width, height } = display.workArea;
// Check if window is at least partially visible on the display
const visible =
bounds.x + bounds.width > x &&
bounds.x < x + width &&
bounds.y + bounds.height > y &&
bounds.y < y + height;
if (!visible) {
// Strip position — let Electron auto-center
return { width: state.width, height: state.height, isMaximized: state.isMaximized };
}
return state;
}
function saveWindowState(win: BrowserWindow): void {
try {
const isMaximized = win.isMaximized();
// Use the bounds from before maximize, to restore the un-maximized size
const bounds = isMaximized ? win.getNormalBounds() : win.getBounds();
const state: WindowState = {
width: bounds.width,
height: bounds.height,
x: bounds.x,
y: bounds.y,
isMaximized,
};
fs.writeFileSync(getWindowStatePath(), JSON.stringify(state));
} catch {
// Non-critical — silently ignore write failures
}
}
// ─── Tray Icon ──────────────────────────────────────────────────────────────
function generateFallbackTrayIcon(): Electron.NativeImage {
const size = 16;
const canvas = Buffer.alloc(size * size * 4);
const cx = size / 2;
@@ -84,7 +122,116 @@ function createTray(): void {
}
}
}
const icon = nativeImage.createFromBuffer(canvas, { width: size, height: size });
return nativeImage.createFromBuffer(canvas, { width: size, height: size });
}
function loadTrayIcon(): Electron.NativeImage {
const iconPath = path.join(__dirname, '..', 'build', 'tray-icon.png');
try {
const icon = nativeImage.createFromPath(iconPath);
if (!icon.isEmpty()) {
const resized = icon.resize({ width: 16, height: 16 });
if (process.platform === 'darwin') {
resized.setTemplateImage(true);
}
return resized;
}
} catch {
// Fall through to generated icon
}
return generateFallbackTrayIcon();
}
// ─── Window & Tray Creation ─────────────────────────────────────────────────
function createWindow(): void {
const savedState = validateWindowBounds(loadWindowState());
mainWindow = new BrowserWindow({
width: savedState.width,
height: savedState.height,
...(savedState.x !== undefined && savedState.y !== undefined
? { x: savedState.x, y: savedState.y }
: {}),
minWidth: 940,
minHeight: 500,
title: 'Backspace',
titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default',
backgroundColor: '#313338',
show: false,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
},
});
if (savedState.isMaximized) {
mainWindow.maximize();
}
mainWindow.loadURL(SERVER_URL);
mainWindow.once('ready-to-show', () => {
mainWindow?.show();
// Send any pending deep link that launched the app
if (pendingDeepLink && mainWindow) {
mainWindow.webContents.send('deep-link', pendingDeepLink);
pendingDeepLink = null;
}
});
// Window state persistence — debounced save on resize/move
let saveTimeout: ReturnType<typeof setTimeout> | null = null;
const debouncedSave = () => {
if (saveTimeout) clearTimeout(saveTimeout);
saveTimeout = setTimeout(() => {
if (mainWindow && !mainWindow.isDestroyed()) {
saveWindowState(mainWindow);
}
}, 300);
};
mainWindow.on('resize', debouncedSave);
mainWindow.on('move', debouncedSave);
mainWindow.on('close', (event) => {
// Save state before close
if (mainWindow && !mainWindow.isDestroyed()) {
saveWindowState(mainWindow);
}
if (!isQuitting) {
event.preventDefault();
mainWindow?.hide();
}
});
mainWindow.on('closed', () => {
mainWindow = null;
});
// Window focus IPC for notification suppression
mainWindow.on('focus', () => {
mainWindow?.webContents.send('window-focus-changed', true);
});
mainWindow.on('blur', () => {
mainWindow?.webContents.send('window-focus-changed', false);
});
// Open external links in default browser
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
if (url.startsWith('http://') || url.startsWith('https://')) {
shell.openExternal(url);
}
return { action: 'deny' };
});
}
function createTray(): void {
const icon = loadTrayIcon();
tray = new Tray(icon);
const contextMenu = Menu.buildFromTemplate([
@@ -124,6 +271,8 @@ function createTray(): void {
});
}
// ─── Notifications ──────────────────────────────────────────────────────────
function showNotification(title: string, body: string): void {
if (Notification.isSupported()) {
const notification = new Notification({
@@ -141,6 +290,8 @@ function showNotification(title: string, body: string): void {
}
}
// ─── IPC Handlers ───────────────────────────────────────────────────────────
function registerIpcHandlers(): void {
ipcMain.on('show-notification', (_event, data: { title: string; body: string }) => {
showNotification(data.title, data.body);
@@ -167,28 +318,136 @@ function registerIpcHandlers(): void {
ipcMain.on('close-window', () => {
mainWindow?.close();
});
// Auto-update IPC
ipcMain.on('install-update', () => {
try {
const { autoUpdater } = require('electron-updater');
autoUpdater.quitAndInstall();
} catch {
// Auto-updater not available
}
});
ipcMain.on('check-for-updates', () => {
try {
const { autoUpdater } = require('electron-updater');
autoUpdater.checkForUpdates().catch(() => {});
} catch {
// Auto-updater not available
}
});
}
app.on('ready', () => {
registerIpcHandlers();
createWindow();
createTray();
});
// ─── Auto-Update ────────────────────────────────────────────────────────────
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit();
function initAutoUpdater(): void {
try {
const { autoUpdater } = require('electron-updater');
autoUpdater.autoDownload = true;
autoUpdater.autoInstallOnAppQuit = true;
autoUpdater.on('update-available', (info: { version: string }) => {
mainWindow?.webContents.send('update-available', { version: info.version });
});
autoUpdater.on('update-downloaded', (info: { version: string }) => {
mainWindow?.webContents.send('update-downloaded', { version: info.version });
});
autoUpdater.on('error', (err: Error) => {
mainWindow?.webContents.send('update-error', err.message);
});
// Initial check with 10s delay
setTimeout(() => {
autoUpdater.checkForUpdates().catch(() => {});
}, 10_000);
// Periodic check every 4 hours
setInterval(() => {
autoUpdater.checkForUpdates().catch(() => {});
}, 4 * 60 * 60 * 1000);
} catch {
// Graceful degradation — no update URL configured or electron-updater not available
}
});
}
app.on('activate', () => {
if (mainWindow === null) {
createWindow();
} else {
// ─── Deep Linking ───────────────────────────────────────────────────────────
function handleDeepLink(url: string): void {
if (!url.startsWith('backspace://')) return;
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('deep-link', url);
mainWindow.show();
mainWindow.focus();
} else {
// App not ready yet — store for later
pendingDeepLink = url;
}
}
// Set as default protocol handler
app.setAsDefaultProtocolClient('backspace');
// macOS: open-url event
app.on('open-url', (event, url) => {
event.preventDefault();
handleDeepLink(url);
});
app.on('before-quit', () => {
isQuitting = true;
});
// Windows/Linux: single instance lock — deep links come as second-instance args
const gotTheLock = app.requestSingleInstanceLock();
if (!gotTheLock) {
app.quit();
} else {
app.on('second-instance', (_event, commandLine) => {
// Find the deep link URL in the command line args
const deepLinkArg = commandLine.find((arg) => arg.startsWith('backspace://'));
if (deepLinkArg) {
handleDeepLink(deepLinkArg);
}
// Focus the existing window
if (mainWindow) {
if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.show();
mainWindow.focus();
}
});
// ─── App Lifecycle ──────────────────────────────────────────────────────────
app.whenReady().then(() => {
registerIpcHandlers();
createWindow();
createTray();
initAutoUpdater();
// Check if the app was launched with a deep link (Windows/Linux)
const launchArg = process.argv.find((arg) => arg.startsWith('backspace://'));
if (launchArg) {
pendingDeepLink = launchArg;
}
});
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit();
}
});
app.on('activate', () => {
if (mainWindow === null) {
createWindow();
} else {
mainWindow.show();
}
});
app.on('before-quit', () => {
isQuitting = true;
});
}
+38 -6
View File
@@ -1,13 +1,10 @@
import { contextBridge, ipcRenderer } from 'electron';
contextBridge.exposeInMainWorld('backspace', {
// Platform info
platform: process.platform,
showNotification: (title: string, body: string) => {
ipcRenderer.send('show-notification', { title, body });
},
setBadgeCount: (count: number) => {
ipcRenderer.send('set-badge-count', count);
},
// Window controls
minimize: () => {
ipcRenderer.send('minimize-window');
},
@@ -17,4 +14,39 @@ contextBridge.exposeInMainWorld('backspace', {
close: () => {
ipcRenderer.send('close-window');
},
// Notifications & badge
showNotification: (title: string, body: string) => {
ipcRenderer.send('show-notification', { title, body });
},
setBadgeCount: (count: number) => {
ipcRenderer.send('set-badge-count', count);
},
// Auto-update
onUpdateAvailable: (callback: (info: { version: string }) => void) => {
ipcRenderer.on('update-available', (_event, info) => callback(info));
},
onUpdateDownloaded: (callback: (info: { version: string }) => void) => {
ipcRenderer.on('update-downloaded', (_event, info) => callback(info));
},
onUpdateError: (callback: (error: string) => void) => {
ipcRenderer.on('update-error', (_event, error) => callback(error));
},
installUpdate: () => {
ipcRenderer.send('install-update');
},
checkForUpdates: () => {
ipcRenderer.send('check-for-updates');
},
// Window focus
onWindowFocusChange: (callback: (focused: boolean) => void) => {
ipcRenderer.on('window-focus-changed', (_event, focused) => callback(focused));
},
// Deep linking
onDeepLink: (callback: (url: string) => void) => {
ipcRenderer.on('deep-link', (_event, url) => callback(url));
},
});