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