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