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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user