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