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
@@ -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>
);
}