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:
@@ -5,22 +5,30 @@ directories:
|
||||
files:
|
||||
- dist/**/*
|
||||
- "!node_modules"
|
||||
publish:
|
||||
- provider: generic
|
||||
url: "${BACKSPACE_UPDATE_URL}"
|
||||
useMultipleRangeRequest: false
|
||||
protocols:
|
||||
- name: Backspace
|
||||
schemes:
|
||||
- backspace
|
||||
mac:
|
||||
category: public.app-category.social-networking
|
||||
target:
|
||||
- dmg
|
||||
- zip
|
||||
icon: build/icon.icns
|
||||
# icon: build/icon.icns # Uncomment when final icon is designed
|
||||
win:
|
||||
target:
|
||||
- nsis
|
||||
icon: build/icon.ico
|
||||
# icon: build/icon.ico # Uncomment when final icon is designed
|
||||
linux:
|
||||
target:
|
||||
- AppImage
|
||||
- deb
|
||||
category: Network
|
||||
icon: build/icons
|
||||
# icon: build/icons # Uncomment when final icons are designed
|
||||
nsis:
|
||||
oneClick: false
|
||||
allowToChangeInstallationDirectory: true
|
||||
|
||||
@@ -6,11 +6,12 @@
|
||||
"scripts": {
|
||||
"build:ts": "tsc",
|
||||
"dev": "tsc && electron .",
|
||||
"prebuild": "mkdir -p build && cp ../web/public/icons/icon-512.png build/icon.png 2>/dev/null || true",
|
||||
"build": "tsc && electron-builder",
|
||||
"clean": "rm -rf dist dist-electron"
|
||||
},
|
||||
"dependencies": {
|
||||
"electron-is-dev": "^3.0.1"
|
||||
"electron-updater": "^6.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"electron": "^33.2.0",
|
||||
|
||||
+320
-61
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user