diff --git a/packages/desktop/electron-builder.yml b/packages/desktop/electron-builder.yml index 4e35d797..033ac68f 100644 --- a/packages/desktop/electron-builder.yml +++ b/packages/desktop/electron-builder.yml @@ -9,7 +9,7 @@ files: - "!node_modules" publish: - provider: generic - url: "${BACKSPACE_UPDATE_URL}" + url: "${env.BACKSPACE_UPDATE_URL}" useMultipleRangeRequest: false protocols: - name: Backspace @@ -24,6 +24,7 @@ mac: - dmg - zip win: + icon: ./build/icon.ico target: - nsis linux: diff --git a/packages/desktop/resources/tray-icon.png b/packages/desktop/resources/tray-icon.png new file mode 100644 index 00000000..38d1d76c Binary files /dev/null and b/packages/desktop/resources/tray-icon.png differ diff --git a/packages/desktop/resources/tray-icon@2x.png b/packages/desktop/resources/tray-icon@2x.png new file mode 100644 index 00000000..c919d386 Binary files /dev/null and b/packages/desktop/resources/tray-icon@2x.png differ diff --git a/packages/desktop/resources/tray-iconTemplate.png b/packages/desktop/resources/tray-iconTemplate.png new file mode 100644 index 00000000..88eecd75 Binary files /dev/null and b/packages/desktop/resources/tray-iconTemplate.png differ diff --git a/packages/desktop/resources/tray-iconTemplate@2x.png b/packages/desktop/resources/tray-iconTemplate@2x.png new file mode 100644 index 00000000..b4e5b410 Binary files /dev/null and b/packages/desktop/resources/tray-iconTemplate@2x.png differ diff --git a/packages/desktop/src/main.ts b/packages/desktop/src/main.ts index 910ff081..726e50bb 100644 --- a/packages/desktop/src/main.ts +++ b/packages/desktop/src/main.ts @@ -126,6 +126,67 @@ function saveWindowState(win: BrowserWindow): void { } } +// ─── Auto-Launch Settings ──────────────────────────────────────────────────── + +interface AutoLaunchSettings { + openAtLogin: boolean; + startMinimized: boolean; +} + +const DEFAULT_AUTO_LAUNCH: AutoLaunchSettings = { + openAtLogin: false, + startMinimized: true, +}; + +function getAutoLaunchSettingsPath(): string { + return path.join(app.getPath('userData'), 'auto-launch.json'); +} + +function loadAutoLaunchSettings(): AutoLaunchSettings { + try { + const raw = fs.readFileSync(getAutoLaunchSettingsPath(), 'utf-8'); + const parsed = JSON.parse(raw) as Partial; + return { + openAtLogin: typeof parsed.openAtLogin === 'boolean' ? parsed.openAtLogin : DEFAULT_AUTO_LAUNCH.openAtLogin, + startMinimized: typeof parsed.startMinimized === 'boolean' ? parsed.startMinimized : DEFAULT_AUTO_LAUNCH.startMinimized, + }; + } catch { + return { ...DEFAULT_AUTO_LAUNCH }; + } +} + +function saveAutoLaunchSettings(settings: AutoLaunchSettings): void { + fs.writeFileSync(getAutoLaunchSettingsPath(), JSON.stringify(settings)); +} + +function applyLoginItemSettings(openAtLogin: boolean, startMinimized: boolean): void { + if (process.platform === 'darwin') { + app.setLoginItemSettings({ + openAtLogin, + openAsHidden: startMinimized, + }); + } else if (process.platform === 'win32') { + app.setLoginItemSettings({ + openAtLogin, + args: startMinimized ? ['--hidden'] : [], + name: 'Backspace', + }); + } else { + // Linux: setLoginItemSettings creates a .desktop file in ~/.config/autostart/ + // For AppImage, the path changes on update — we pass it explicitly. + // Electron's Linux impl doesn't support `path`/`args` in types, + // but the runtime does accept them in the options object. + const opts: Record = { openAtLogin }; + if (process.env.APPIMAGE) { + opts.path = process.env.APPIMAGE; + } + if (startMinimized) { + opts.args = ['--hidden']; + } + app.setLoginItemSettings(opts as Electron.Settings); + } +} + // ─── Tray Icon ────────────────────────────────────────────────────────────── function generateFallbackTrayIcon(): Electron.NativeImage { @@ -139,9 +200,10 @@ function generateFallbackTrayIcon(): Electron.NativeImage { const idx = (y * size + x) * 4; const dist = Math.sqrt((x - cx) ** 2 + (y - cy) ** 2); if (dist <= r) { - canvas[idx] = 0x58; // R (blurple) + // NativeImage raw buffer uses BGRA on most platforms + canvas[idx] = 0xf2; // B (blurple #5865f2) canvas[idx + 1] = 0x65; // G - canvas[idx + 2] = 0xf2; // B + canvas[idx + 2] = 0x58; // R canvas[idx + 3] = 0xff; // A } else { canvas[idx] = 0; @@ -155,15 +217,24 @@ function generateFallbackTrayIcon(): Electron.NativeImage { } function loadTrayIcon(): Electron.NativeImage { - const iconPath = path.join(__dirname, '..', 'build', 'tray-icon.png'); + const resourcesDir = path.join(__dirname, '..', 'resources'); try { - const icon = nativeImage.createFromPath(iconPath); - if (!icon.isEmpty()) { - const resized = icon.resize({ width: 16, height: 16 }); - if (process.platform === 'darwin') { - resized.setTemplateImage(true); + if (process.platform === 'darwin') { + // macOS template image: Electron auto-resolves @2x from the base path. + // Template images adapt to light/dark menu bar automatically. + const templatePath = path.join(resourcesDir, 'tray-iconTemplate.png'); + const icon = nativeImage.createFromPath(templatePath); + if (!icon.isEmpty()) { + icon.setTemplateImage(true); + return icon; + } + } else { + // Windows/Linux: colored icon + const iconPath = path.join(resourcesDir, 'tray-icon.png'); + const icon = nativeImage.createFromPath(iconPath); + if (!icon.isEmpty()) { + return icon.resize({ width: 16, height: 16 }); } - return resized; } } catch { // Fall through to generated icon @@ -225,7 +296,14 @@ function createWindow(): void { } mainWindow.once('ready-to-show', () => { - mainWindow?.show(); + // Check if launched minimized (auto-start to tray) + const launchedHidden = + process.argv.includes('--hidden') || + (process.platform === 'darwin' && app.getLoginItemSettings().wasOpenedAsHidden); + + if (!launchedHidden) { + mainWindow?.show(); + } // Send any pending deep link that launched the app if (pendingDeepLink && mainWindow) { @@ -436,6 +514,34 @@ function registerIpcHandlers(): void { // Handled via ipcMain.once in the display media handler — this is just // a safety net to prevent unhandled-message warnings }); + + // Auto-launch settings + ipcMain.handle('get-auto-launch-settings', (): { openAtLogin: boolean; startMinimized: boolean } => { + const saved = loadAutoLaunchSettings(); + const osState = app.getLoginItemSettings(); + return { + openAtLogin: osState.openAtLogin, + startMinimized: saved.startMinimized, + }; + }); + + ipcMain.handle('set-auto-launch-settings', (_event, settings: { openAtLogin?: boolean; startMinimized?: boolean }) => { + const current = loadAutoLaunchSettings(); + const osState = app.getLoginItemSettings(); + + const newOpenAtLogin = settings.openAtLogin ?? osState.openAtLogin; + const newStartMinimized = settings.startMinimized ?? current.startMinimized; + + const updated: AutoLaunchSettings = { + openAtLogin: newOpenAtLogin, + startMinimized: newStartMinimized, + }; + + saveAutoLaunchSettings(updated); + applyLoginItemSettings(newOpenAtLogin, newStartMinimized); + + return updated; + }); } // ─── Auto-Update ──────────────────────────────────────────────────────────── @@ -663,6 +769,10 @@ if (!gotTheLock) { createTray(); initAutoUpdater(); + // Sync auto-launch settings with OS on startup (refreshes login item path for AppImage updates) + const autoLaunchSettings = loadAutoLaunchSettings(); + applyLoginItemSettings(autoLaunchSettings.openAtLogin, autoLaunchSettings.startMinimized); + // Check if the app was launched with a deep link (Windows/Linux) const launchArg = process.argv.find((arg) => arg.startsWith('backspace://')); if (launchArg) { diff --git a/packages/desktop/src/preload.ts b/packages/desktop/src/preload.ts index a22e652d..236a45e2 100644 --- a/packages/desktop/src/preload.ts +++ b/packages/desktop/src/preload.ts @@ -62,4 +62,9 @@ contextBridge.exposeInMainWorld('backspace', { getInstanceUrl: () => ipcRenderer.invoke('get-instance-url'), setInstanceUrl: (url: string) => ipcRenderer.invoke('set-instance-url', url), clearInstanceUrl: () => ipcRenderer.invoke('clear-instance-url'), + + // Auto-launch settings + getAutoLaunchSettings: () => ipcRenderer.invoke('get-auto-launch-settings'), + setAutoLaunchSettings: (settings: { openAtLogin?: boolean; startMinimized?: boolean }) => + ipcRenderer.invoke('set-auto-launch-settings', settings), }); diff --git a/packages/web/src/components/modals/settingsPanels/AccountPanel.tsx b/packages/web/src/components/modals/settingsPanels/AccountPanel.tsx index fe3c446d..e5b4e9f2 100644 --- a/packages/web/src/components/modals/settingsPanels/AccountPanel.tsx +++ b/packages/web/src/components/modals/settingsPanels/AccountPanel.tsx @@ -10,8 +10,70 @@ import { AVATAR_COLORS } from '@backspace/shared'; import type { User, UserStatus, AvatarColor } from '@backspace/shared'; import type { FederationOpResult } from '../../../utils/federationOps'; import { isElectron } from '../../../platform/platform'; +import { Toggle } from '../../ui/Toggle'; +function AutoLaunchSettings() { + const [openAtLogin, setOpenAtLogin] = useState(false); + const [startMinimized, setStartMinimized] = useState(true); + const [loading, setLoading] = useState(true); + + useEffect(() => { + window.backspace?.getAutoLaunchSettings().then((settings) => { + setOpenAtLogin(settings.openAtLogin); + setStartMinimized(settings.startMinimized); + setLoading(false); + }).catch(() => setLoading(false)); + }, []); + + const handleOpenAtLoginChange = async (enabled: boolean) => { + setOpenAtLogin(enabled); + try { + const result = await window.backspace!.setAutoLaunchSettings({ openAtLogin: enabled }); + setOpenAtLogin(result.openAtLogin); + setStartMinimized(result.startMinimized); + } catch { + setOpenAtLogin(!enabled); + } + }; + + const handleStartMinimizedChange = async (enabled: boolean) => { + setStartMinimized(enabled); + try { + const result = await window.backspace!.setAutoLaunchSettings({ startMinimized: enabled }); + setOpenAtLogin(result.openAtLogin); + setStartMinimized(result.startMinimized); + } catch { + setStartMinimized(!enabled); + } + }; + + if (loading) return null; + + return ( + <> +
+
+
Start at boot
+
+ Automatically launch Backspace when you log in +
+
+ +
+
+
+
Start minimized
+
+ Start hidden in the system tray instead of showing the window +
+
+ +
+ + ); +} + export function AccountPanel() { const user = useAuthStore((s) => s.user); const updateProfile = useAuthStore((s) => s.updateProfile); @@ -688,11 +750,15 @@ export function AccountPanel() { - {/* ── Connected Instance (Electron only) ── */} + {/* ── Desktop (Electron only) ── */} {isElectron() && (
-
Connected Instance
-
+
Desktop
+
+ + +
+
{window.location.origin}
diff --git a/packages/web/src/platform/electron.d.ts b/packages/web/src/platform/electron.d.ts index 6bfd1a4c..d8da18f4 100644 --- a/packages/web/src/platform/electron.d.ts +++ b/packages/web/src/platform/electron.d.ts @@ -42,6 +42,11 @@ interface BackspaceElectronAPI { getInstanceUrl: () => Promise; setInstanceUrl: (url: string) => Promise; clearInstanceUrl: () => Promise; + + // Auto-launch settings + getAutoLaunchSettings: () => Promise<{ openAtLogin: boolean; startMinimized: boolean }>; + setAutoLaunchSettings: (settings: { openAtLogin?: boolean; startMinimized?: boolean }) => + Promise<{ openAtLogin: boolean; startMinimized: boolean }>; } interface Window {