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
+7
View File
@@ -25,6 +25,13 @@ LIVEKIT_URL=
LIVEKIT_API_KEY=
LIVEKIT_API_SECRET=
# ─── Desktop App (Electron) ───────────────────────────────
# Override the URL that the desktop app loads (auto-detects in dev/prod)
# BACKSPACE_URL=https://my-instance.com
# Auto-update server URL for electron-updater (optional)
# BACKSPACE_UPDATE_URL=https://releases.example.com
# ─── Docker Compose ────────────────────────────────────────
# Uncomment to enable the LiveKit service:
# COMPOSE_PROFILES=voice
+4 -2
View File
@@ -10,10 +10,12 @@
"build:shared": "pnpm --filter @backspace/shared build",
"build:server": "pnpm --filter @backspace/server build",
"build:web": "pnpm --filter @backspace/web build",
"build": "pnpm --filter @backspace/shared build && pnpm --filter @backspace/server build & pnpm --filter @backspace/web build"
"build": "pnpm --filter @backspace/shared build && pnpm --filter @backspace/server build & pnpm --filter @backspace/web build",
"dev:desktop": "pnpm --filter @backspace/desktop dev",
"build:desktop": "pnpm --filter @backspace/desktop build"
},
"pnpm": {
"onlyBuiltDependencies": ["better-sqlite3", "esbuild", "sharp"]
"onlyBuiltDependencies": ["better-sqlite3", "esbuild", "electron", "sharp"]
},
"engines": {
"node": ">=20.0.0",
+11 -3
View File
@@ -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
+2 -1
View File
@@ -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
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));
},
});
@@ -0,0 +1,99 @@
import { useEffect, useRef } from 'react';
import { useChatStore } from '../stores/chatStore';
import { useVoiceStore } from '../stores/voiceStore';
import { useAuthStore } from '../stores/authStore';
import { isElectron } from '../platform/platform';
import { sendNotification, updateBadgeCount } from '../platform/notifications';
/**
* Headless component that bridges store events to native OS notifications and badge counts.
* Renders nothing — lives alongside SoundController in AppLayout.
*/
export function NotificationController() {
const currentUser = useAuthStore((s) => s.user);
const isInitialMount = useRef(true);
const windowFocused = useRef(true);
// Track window focus state
useEffect(() => {
if (isElectron() && window.backspace) {
window.backspace.onWindowFocusChange((focused) => {
windowFocused.current = focused;
});
}
// Browser fallback focus tracking
const onFocus = () => { windowFocused.current = true; };
const onBlur = () => { windowFocused.current = false; };
const onVisibility = () => {
windowFocused.current = document.visibilityState === 'visible' && document.hasFocus();
};
window.addEventListener('focus', onFocus);
window.addEventListener('blur', onBlur);
document.addEventListener('visibilitychange', onVisibility);
// Sync initial state
windowFocused.current = document.hasFocus();
return () => {
window.removeEventListener('focus', onFocus);
window.removeEventListener('blur', onBlur);
document.removeEventListener('visibilitychange', onVisibility);
};
}, []);
// Message notifications
useEffect(() => {
const timer = setTimeout(() => {
isInitialMount.current = false;
}, 1000);
const unsubscribeChat = useChatStore.subscribe((state, prevState) => {
if (isInitialMount.current) return;
if (windowFocused.current) return;
if (state.realtimeMessageEvents.length > prevState.realtimeMessageEvents.length) {
const newEvents = state.realtimeMessageEvents.slice(prevState.realtimeMessageEvents.length);
for (const { message } of newEvents) {
if (message.userId !== currentUser?.id) {
const displayName = message.user?.displayName || message.user?.username || 'Someone';
const body = message.content
? message.content.replace(/[*_~`>#\-\[\]]/g, '').slice(0, 100)
: 'Sent an attachment';
sendNotification(displayName, body);
break; // one notification per batch
}
}
}
});
return () => {
clearTimeout(timer);
unsubscribeChat();
};
}, [currentUser?.id]);
// Badge count (Electron only)
useEffect(() => {
const unsubscribe = useChatStore.subscribe((state) => {
updateBadgeCount(state.unreadChannels.size);
});
return unsubscribe;
}, []);
// DM call notification
useEffect(() => {
let prevIncoming: { dmChannelId: string; callerId: string; callerName: string } | null = null;
const unsubscribe = useVoiceStore.subscribe((state) => {
if (state.incomingCall && !prevIncoming && !windowFocused.current) {
sendNotification('Incoming Call', `${state.incomingCall.callerName} is calling you`);
}
prevIncoming = state.incomingCall;
});
return unsubscribe;
}, []);
return null;
}
@@ -21,12 +21,15 @@ import { IncomingCallModal } from '../voice/IncomingCallModal';
import { PictureInPicture } from '../voice/PictureInPicture';
import { SoundController } from '../voice/SoundController';
import { GlobalAudioRenderer } from '../voice/GlobalAudioRenderer';
import { NotificationController } from '../NotificationController';
import { UserProfilePopout } from '../ui/UserProfilePopout';
import { ToastContainer } from '../ui/ToastContainer';
import { UpdateToast } from '../ui/UpdateToast';
import { useAuth } from '../../hooks/useAuth';
import { useWebSocket } from '../../hooks/useWebSocket';
import { useFederationToasts } from '../../hooks/useFederationToasts';
import { useLiveKit } from '../../hooks/useLiveKit';
import { useDeepLinkHandler } from '../../platform/deepLink';
import { useSpaceStore } from '../../stores/spaceStore';
import { useChatStore } from '../../stores/chatStore';
import { useUIStore } from '../../stores/uiStore';
@@ -115,6 +118,9 @@ export function AppLayout() {
// Federation toast notifications for remote instance connection state changes
useFederationToasts();
// Deep link handler for Electron (backspace:// protocol)
useDeepLinkHandler();
// Track the last channel we attempted to connect to, to prevent effect loops
const lastAttemptedRef = React.useRef<string | null>(null);
@@ -292,6 +298,8 @@ export function AppLayout() {
<PictureInPicture />
<SoundController />
<GlobalAudioRenderer />
<NotificationController />
<UpdateToast />
{/* User Profile Popout */}
{userProfilePopout.user && userProfilePopout.position && (
@@ -2,10 +2,12 @@ import React, { useState, useEffect } from 'react';
import { Modal } from '../ui/Modal';
import { useUIStore } from '../../stores/uiStore';
import { useSpaceStore } from '../../stores/spaceStore';
import { isElectron } from '../../platform/platform';
export function InviteModal() {
const [inviteCode, setInviteCode] = useState('');
const [copied, setCopied] = useState(false);
const [copiedDeepLink, setCopiedDeepLink] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
const activeModal = useUIStore((s) => s.activeModal);
@@ -19,6 +21,13 @@ export function InviteModal() {
const isOpen = activeModal === 'invite';
const inviteUrl = inviteCode ? `${instanceOrigin || window.location.origin}/join/${inviteCode}` : '';
// Deep link for Electron desktop app
const deepLinkUrl = inviteCode
? instanceOrigin
? `backspace://join/${inviteCode}@${new URL(instanceOrigin).host}`
: `backspace://join/${inviteCode}`
: '';
useEffect(() => {
if (isOpen && currentSpaceId) {
setIsLoading(true);
@@ -52,6 +61,17 @@ export function InviteModal() {
}
};
const handleCopyDeepLink = async () => {
if (!deepLinkUrl) return;
try {
await navigator.clipboard.writeText(deepLinkUrl);
setCopiedDeepLink(true);
setTimeout(() => setCopiedDeepLink(false), 2000);
} catch {
// silently fail
}
};
return (
<Modal isOpen={isOpen} onClose={closeModal} title="Invite Friends">
<p className="text-txt-secondary text-sm mb-4">
@@ -81,6 +101,26 @@ export function InviteModal() {
{copied ? 'Copied!' : 'Copy'}
</button>
</div>
{isElectron() && deepLinkUrl && (
<div className="mt-3 flex items-center gap-2">
<input
type="text"
value={deepLinkUrl}
readOnly
className="input-standard flex-1 font-mono text-xs"
/>
<button
onClick={handleCopyDeepLink}
className={`px-4 py-2 text-sm font-medium rounded transition-colors ${
copiedDeepLink
? 'bg-status-online text-white'
: 'bg-surface-elevated hover:bg-surface-elevated/80 text-txt-secondary'
}`}
>
{copiedDeepLink ? 'Copied!' : 'Copy'}
</button>
</div>
)}
</Modal>
);
}
@@ -1,10 +1,13 @@
import { useRegisterSW } from 'virtual:pwa-register/react';
import { useEffect } from 'react';
import { isElectron } from '../../platform/platform';
export function SwAutoUpdate() {
const inElectron = isElectron();
useRegisterSW({
onRegisteredSW(_swUrl, registration) {
if (!registration) return;
if (!registration || inElectron) return;
setInterval(() => {
registration.update();
}, 60_000);
@@ -12,11 +15,12 @@ export function SwAutoUpdate() {
});
useEffect(() => {
if (inElectron) return;
if (!navigator.serviceWorker) return;
const onControllerChange = () => window.location.reload();
navigator.serviceWorker.addEventListener('controllerchange', onControllerChange);
return () => navigator.serviceWorker.removeEventListener('controllerchange', onControllerChange);
}, []);
}, [inElectron]);
return null;
}
@@ -0,0 +1,52 @@
import React, { useState, useEffect } from 'react';
import { isElectron } from '../../platform/platform';
/**
* Persistent toast shown when an Electron auto-update has been downloaded.
* Renders nothing in browser environments.
*/
export function UpdateToast() {
const [downloadedVersion, setDownloadedVersion] = useState<string | null>(null);
useEffect(() => {
if (!isElectron() || !window.backspace) return;
window.backspace.onUpdateDownloaded((info) => {
setDownloadedVersion(info.version);
});
}, []);
if (!downloadedVersion) return null;
const handleRestart = () => {
window.backspace?.installUpdate();
};
return (
<div className="fixed bottom-6 left-6 z-[300] animate-slide-up">
<div className="glass-pill rounded-xl px-4 py-3 flex items-center gap-3 max-w-[340px]">
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-txt-primary">Update ready</p>
<p className="text-xs text-txt-secondary truncate">
Version {downloadedVersion} has been downloaded
</p>
</div>
<button
onClick={handleRestart}
className="shrink-0 px-3 py-1.5 text-xs font-medium rounded-lg bg-accent-primary hover:bg-accent-primary/80 text-white transition-colors"
>
Restart
</button>
<button
onClick={() => setDownloadedVersion(null)}
className="shrink-0 p-1 text-txt-tertiary hover:text-txt-secondary transition-colors"
aria-label="Dismiss"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
);
}
+49
View File
@@ -0,0 +1,49 @@
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { isElectron } from './platform';
/**
* Listens for deep link events from the Electron main process and navigates accordingly.
*
* Supported routes:
* backspace://join/{code} → /join/{code}
* backspace://join/{code}@{host} → /join/{code}@{host}
* backspace://channel/{spaceId}/{channelId} → /channels/{spaceId}/{channelId}
*/
export function useDeepLinkHandler(): void {
const navigate = useNavigate();
useEffect(() => {
if (!isElectron()) return;
const api = window.backspace!;
api.onDeepLink((url: string) => {
let parsed: URL;
try {
parsed = new URL(url);
} catch {
console.warn('[DeepLink] Invalid URL:', url);
return;
}
if (parsed.protocol !== 'backspace:') return;
// URL host + pathname gives us the route
// backspace://join/code → host="join", pathname="/code"
// backspace://channel/spaceId/channelId → host="channel", pathname="/spaceId/channelId"
const host = parsed.hostname;
const pathParts = parsed.pathname.split('/').filter(Boolean);
if (host === 'join' && pathParts.length >= 1) {
const code = pathParts[0]!;
navigate(`/join/${code}`);
} else if (host === 'channel' && pathParts.length >= 2) {
const spaceId = pathParts[0]!;
const channelId = pathParts[1]!;
navigate(`/channels/${spaceId}/${channelId}`);
} else {
console.warn('[DeepLink] Unknown route:', url);
}
});
}, [navigate]);
}
+32
View File
@@ -0,0 +1,32 @@
/** Type augmentation for the Electron IPC bridge exposed by preload.ts */
interface BackspaceElectronAPI {
// Platform info
platform: NodeJS.Platform;
// Window controls
minimize: () => void;
maximize: () => void;
close: () => void;
// Notifications & badge
showNotification: (title: string, body: string) => void;
setBadgeCount: (count: number) => void;
// Auto-update (Task 2.1)
onUpdateAvailable: (callback: (info: { version: string }) => void) => void;
onUpdateDownloaded: (callback: (info: { version: string }) => void) => void;
onUpdateError: (callback: (error: string) => void) => void;
installUpdate: () => void;
checkForUpdates: () => void;
// Window focus (Task 2.2)
onWindowFocusChange: (callback: (focused: boolean) => void) => void;
// Deep linking (Task 2.3)
onDeepLink: (callback: (url: string) => void) => void;
}
interface Window {
backspace?: BackspaceElectronAPI;
}
@@ -0,0 +1,21 @@
import { isElectron } from './platform';
export function sendNotification(title: string, body: string): void {
if (isElectron()) {
window.backspace!.showNotification(title, body);
} else if ('Notification' in window && Notification.permission === 'granted') {
new Notification(title, { body, icon: '/icons/icon-192.png' });
}
}
export function requestNotificationPermission(): Promise<boolean> {
if (isElectron()) return Promise.resolve(true);
if (!('Notification' in window)) return Promise.resolve(false);
return Notification.requestPermission().then((p) => p === 'granted');
}
export function updateBadgeCount(count: number): void {
if (isElectron()) {
window.backspace!.setBadgeCount(count);
}
}
+7
View File
@@ -0,0 +1,7 @@
export function isElectron(): boolean {
return typeof window !== 'undefined' && typeof window.backspace !== 'undefined';
}
export function getElectronAPI(): BackspaceElectronAPI | null {
return window.backspace ?? null;
}
+1 -1
View File
@@ -20,6 +20,6 @@
"@/*": ["./src/*"]
}
},
"include": ["src/**/*", "node_modules/vite-plugin-pwa/client.d.ts"],
"include": ["src/**/*", "src/platform/electron.d.ts", "node_modules/vite-plugin-pwa/client.d.ts"],
"exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"]
}
+46 -9
View File
@@ -10,9 +10,9 @@ importers:
packages/desktop:
dependencies:
electron-is-dev:
specifier: ^3.0.1
version: 3.0.1
electron-updater:
specifier: ^6.3.0
version: 6.8.3
devDependencies:
electron:
specifier: ^33.2.0
@@ -2354,6 +2354,10 @@ packages:
resolution: {integrity: sha512-6p/gfG1RJSQeIbz8TK5aPNkoztgY1q5TgmGFMAXcY8itsGW6Y2ld1ALsZ5UJn8rog7hKF3zHx5iQbNQ8uLcRlw==}
engines: {node: '>=12.0.0'}
builder-util-runtime@9.5.1:
resolution: {integrity: sha512-qt41tMfgHTllhResqM5DcnHyDIWNgzHvuY2jDcYP9iaGpkWxTUzV6GQjDeLnlR1/DtdlcsWQbA7sByMpmJFTLQ==}
engines: {node: '>=12.0.0'}
builder-util@25.1.7:
resolution: {integrity: sha512-7jPjzBwEGRbwNcep0gGNpLXG9P94VA3CPAZQCzxkFXiV2GMQKlziMbY//rXPI7WKfhsvGgFXjTcXdBEwgXw9ww==}
@@ -2845,16 +2849,15 @@ packages:
engines: {node: '>=14.0.0'}
hasBin: true
electron-is-dev@3.0.1:
resolution: {integrity: sha512-8TjjAh8Ec51hUi3o4TaU0mD3GMTOESi866oRNavj9A3IQJ7pmv+MJVmdZBFGw4GFT36X7bkqnuDNYvkQgvyI8Q==}
engines: {node: '>=18'}
electron-publish@25.1.7:
resolution: {integrity: sha512-+jbTkR9m39eDBMP4gfbqglDd6UvBC7RLh5Y0MhFSsc6UkGHj9Vj9TWobxevHYMMqmoujL11ZLjfPpMX+Pt6YEg==}
electron-to-chromium@1.5.286:
resolution: {integrity: sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==}
electron-updater@6.8.3:
resolution: {integrity: sha512-Z6sgw3jgbikWKXei1ENdqFOxBP0WlXg3TtKfz0rgw2vIZFJUyI4pD7ZN7jrkm7EoMK+tcm/qTnPUdqfZukBlBQ==}
electron@33.4.11:
resolution: {integrity: sha512-xmdAs5QWRkInC7TpXGNvzo/7exojubk+72jn1oJL7keNeIlw7xNglf8TGtJtkR4rWC5FJq0oXiIXPS9BcK2Irg==}
engines: {node: '>= 12.20.55'}
@@ -3658,6 +3661,9 @@ packages:
lodash.difference@4.5.0:
resolution: {integrity: sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==}
lodash.escaperegexp@4.1.2:
resolution: {integrity: sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==}
lodash.flatten@4.4.0:
resolution: {integrity: sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==}
@@ -3667,6 +3673,10 @@ packages:
lodash.isboolean@3.0.3:
resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==}
lodash.isequal@4.5.0:
resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==}
deprecated: This package is deprecated. Use require('node:util').isDeepStrictEqual instead.
lodash.isinteger@4.0.4:
resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==}
@@ -4859,6 +4869,9 @@ packages:
thread-stream@3.1.0:
resolution: {integrity: sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==}
tiny-typed-emitter@2.1.0:
resolution: {integrity: sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA==}
tinybench@2.9.0:
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
@@ -7435,6 +7448,13 @@ snapshots:
transitivePeerDependencies:
- supports-color
builder-util-runtime@9.5.1:
dependencies:
debug: 4.4.3
sax: 1.4.4
transitivePeerDependencies:
- supports-color
builder-util@25.1.7:
dependencies:
7zip-bin: 5.2.0
@@ -7933,8 +7953,6 @@ snapshots:
- electron-builder-squirrel-windows
- supports-color
electron-is-dev@3.0.1: {}
electron-publish@25.1.7:
dependencies:
'@types/fs-extra': 9.0.13
@@ -7949,6 +7967,19 @@ snapshots:
electron-to-chromium@1.5.286: {}
electron-updater@6.8.3:
dependencies:
builder-util-runtime: 9.5.1
fs-extra: 10.1.0
js-yaml: 4.1.1
lazy-val: 1.0.5
lodash.escaperegexp: 4.1.2
lodash.isequal: 4.5.0
semver: 7.7.4
tiny-typed-emitter: 2.1.0
transitivePeerDependencies:
- supports-color
electron@33.4.11:
dependencies:
'@electron/get': 2.0.3
@@ -8986,12 +9017,16 @@ snapshots:
lodash.difference@4.5.0: {}
lodash.escaperegexp@4.1.2: {}
lodash.flatten@4.4.0: {}
lodash.includes@4.3.0: {}
lodash.isboolean@3.0.3: {}
lodash.isequal@4.5.0: {}
lodash.isinteger@4.0.4: {}
lodash.isnumber@3.0.3: {}
@@ -10556,6 +10591,8 @@ snapshots:
dependencies:
real-require: 0.2.0
tiny-typed-emitter@2.1.0: {}
tinybench@2.9.0: {}
tinyexec@1.0.2: {}