diff --git a/electron/main.js b/electron/main.js index d89f755e..80d3fa55 100644 --- a/electron/main.js +++ b/electron/main.js @@ -3054,6 +3054,41 @@ ipcMain.handle('open-external', (_event, url) => { } }); +// Applications launcher support. Names are bare .app basenames from the local scan; both +// handlers hard-validate the name and resolve strictly inside /Applications so a hostile +// renderer string can't traverse anywhere else. +const APP_NAME_RE = /^[\w .&'()+-]{1,80}$/; +const appIconCache = new Map(); +function resolveApplicationPath(name) { + if (typeof name !== 'string' || !APP_NAME_RE.test(name) || name.includes('..')) return null; + const path = require('path'); + const resolved = path.join('/Applications', `${name}.app`); + if (path.dirname(resolved) !== '/Applications') return null; + return resolved; +} + +ipcMain.handle('get-app-icon', async (_event, name) => { + const target = resolveApplicationPath(name); + if (!target) return null; + if (appIconCache.has(name)) return appIconCache.get(name); + try { + const icon = await app.getFileIcon(target, { size: 'large' }); + const dataUrl = icon && !icon.isEmpty() ? icon.toDataURL() : null; + appIconCache.set(name, dataUrl); + return dataUrl; + } catch (_) { + appIconCache.set(name, null); + return null; + } +}); + +ipcMain.handle('open-application', (_event, name) => { + const target = resolveApplicationPath(name); + if (!target) return false; + shell.openPath(target); + return true; +}); + // Affiliate install state. Returns the persisted install.json contents so // the renderer can attach the referral code to authenticated cloud calls // (Stripe checkout, sign-in events) for downstream attribution. diff --git a/electron/preload.js b/electron/preload.js index e82275ca..b34f065a 100644 --- a/electron/preload.js +++ b/electron/preload.js @@ -75,6 +75,8 @@ contextBridge.exposeInMainWorld('openswarm', { cdpRoutesGet: (wcId, originFilter) => ipcRenderer.invoke('cdp-routes-get', wcId, originFilter), getWebviewConsole: (wcId) => ipcRenderer.invoke('get-webview-console', wcId), capturePage: (rect) => ipcRenderer.invoke('capture-page', rect), + getAppIcon: (name) => ipcRenderer.invoke('get-app-icon', name), + openApplication: (name) => ipcRenderer.invoke('open-application', name), getUpdateStatus: () => ipcRenderer.invoke('get-update-status'), getCrashRecoveryInfo: () => ipcRenderer.invoke('get-crash-recovery-info'), checkForUpdates: () => ipcRenderer.invoke('check-for-updates'), diff --git a/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx b/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx index cd4296ae..5a58c40f 100644 --- a/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx +++ b/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx @@ -10,6 +10,7 @@ import DashboardEmptyState from './DashboardEmptyState'; import DesktopWallpaper from '../desktop/DesktopWallpaper'; import DesktopDock from '../desktop/DesktopDock'; import MinimizedStack from '../desktop/MinimizedStack'; +import ApplicationsWindow from '../desktop/ApplicationsWindow'; import type { ClaudeTokens } from '@/shared/styles/claudeTokens'; import { useThemeAccent, useThemeWash } from '@/shared/styles/ThemeContext'; import { GRAIN_URL } from '@/shared/styles/grainTexture'; @@ -172,6 +173,7 @@ const DashboardCanvas: React.FC = ({ const dispatch = useAppDispatch(); const fullscreenCardId = useAppSelector(selectFullscreenCardId); const [headerRevealed, setHeaderRevealed] = React.useState(false); + const [appsWindowOpen, setAppsWindowOpen] = React.useState(false); useEffect(() => { if (!fullscreenCardId) return undefined; const onKey = (e: KeyboardEvent): void => { @@ -255,9 +257,14 @@ const DashboardCanvas: React.FC = ({ canvas.actions.fitToCards([rect], 1.15, true); onHighlightCard?.(cardId); }} + onApplications={() => setAppsWindowOpen((v) => !v)} /> )} + {appsWindowOpen && !fullscreenCardId && ( + setAppsWindowOpen(false)} /> + )} + {/* Canvas viewport */} void; +} + +const CATEGORY_RULES: Array<{ label: string; re: RegExp }> = [ + { label: 'Developer Tools', re: /code|cursor|docker|terminal|xcode|git|iterm|studio|postman|figma|utm|dev/i }, + { label: 'Productivity & Finance', re: /notion|calendar|mail|numbers|pages|keynote|excel|word|slides|office|linear|wallet|slack|zoom|meet|drive|todo|remind/i }, + { label: 'Social', re: /message|discord|telegram|whatsapp|signal|wechat|facetime|x\b|instagram/i }, + { label: 'Entertainment', re: /spotify|music|tv|netflix|youtube|game|steam|chess|vlc|iina|podcast/i }, + { label: 'Utilities', re: /calculator|clock|settings|finder|preview|utility|cleaner|monitor|keychain|archive|font/i }, + { label: 'Travel', re: /maps|weather|flight|uber|airbnb/i }, + { label: 'Creativity', re: /photo|imovie|garageband|final cut|logic|premiere|illustrator|sketch|blender|procreate|paint|davinci/i }, + { label: 'Information', re: /news|books|stocks|dictionary|wiki|safari|chrome|edge|firefox|arc|browser/i }, +]; + +function categorize(name: string): string { + for (const rule of CATEGORY_RULES) if (rule.re.test(name)) return rule.label; + return 'Other'; +} + +function LetterTile({ name }: { name: string }): React.ReactElement { + const letter = name.match(/[a-z0-9]/i)?.[0]?.toUpperCase() || '?'; + return ( + + {letter} + + ); +} + +/** Launchpad-style window over the canvas: the user's real /Applications, categorized. */ +function ApplicationsWindow({ onClose }: ApplicationsWindowProps): React.ReactElement { + const [apps, setApps] = useState(null); + const [error, setError] = useState(false); + const [icons, setIcons] = useState>({}); + const [category, setCategory] = useState('All'); + + useEffect(() => { + let cancelled = false; + fetch(`${API_BASE}/onboarding/scan`, { method: 'POST' }) + .then((r) => r.json()) + .then((d) => { + if (cancelled) return; + const names: string[] = Array.isArray(d?.apps) ? d.apps : []; + setApps(names); + }) + .catch(() => { if (!cancelled) setError(true); }); + return () => { cancelled = true; }; + }, []); + + const getIcon = (window as unknown as { openswarm?: { getAppIcon?: (n: string) => Promise } }).openswarm?.getAppIcon; + useEffect(() => { + if (!apps || !getIcon) return; + let cancelled = false; + (async () => { + for (const name of apps.slice(0, 60)) { + if (cancelled) return; + try { + const dataUrl = await getIcon(name); + if (cancelled) return; + if (dataUrl) setIcons((prev) => (prev[name] ? prev : { ...prev, [name]: dataUrl })); + } catch { + /* icon-less tile falls back to the letter */ + } + } + })(); + return () => { cancelled = true; }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [apps]); + + const categories = useMemo(() => { + if (!apps) return []; + const present = new Set(apps.map(categorize)); + return ['All', ...CATEGORY_RULES.map((r) => r.label).filter((l) => present.has(l)), ...(present.has('Other') ? ['Other'] : [])]; + }, [apps]); + + const visible = useMemo(() => { + if (!apps) return []; + return category === 'All' ? apps : apps.filter((a) => categorize(a) === category); + }, [apps, category]); + + const openApp = (window as unknown as { openswarm?: { openApplication?: (n: string) => Promise } }).openswarm?.openApplication; + + return ( + <> + + + + 🐙 + + Applications + + + + {categories.length > 1 && ( + + {categories.map((cat) => ( + setCategory(cat)} + sx={{ + px: 1.25, + py: 0.4, + borderRadius: 999, + flexShrink: 0, + cursor: 'pointer', + fontSize: '0.72rem', + fontWeight: 500, + color: category === cat ? '#fff' : 'rgba(255,255,255,0.6)', + background: category === cat ? 'rgba(255,255,255,0.18)' : 'rgba(255,255,255,0.08)', + '&:hover': { background: 'rgba(255,255,255,0.16)' }, + }} + > + {cat} + + ))} + + )} + + + {!apps && !error && ( + + + + )} + {error && ( + + Could not read /Applications. + + )} + {apps && ( + + {visible.map((name) => ( + { if (openApp) void openApp(name); }} + title={name} + sx={{ + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + gap: 0.75, + py: 0.75, + borderRadius: '10px', + cursor: openApp ? 'pointer' : 'default', + '&:hover': openApp ? { background: 'rgba(255,255,255,0.08)' } : undefined, + }} + > + {icons[name] ? ( + + ) : ( + + )} + + {name} + + + ))} + + )} + + + + ); +} + +export default ApplicationsWindow; diff --git a/frontend/src/app/pages/Dashboard/desktop/DesktopDock.tsx b/frontend/src/app/pages/Dashboard/desktop/DesktopDock.tsx index f4e90965..d720d29d 100644 --- a/frontend/src/app/pages/Dashboard/desktop/DesktopDock.tsx +++ b/frontend/src/app/pages/Dashboard/desktop/DesktopDock.tsx @@ -4,6 +4,7 @@ import Typography from '@mui/material/Typography'; import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome'; import LanguageIcon from '@mui/icons-material/Language'; import SettingsIcon from '@mui/icons-material/Settings'; +import AppsRoundedIcon from '@mui/icons-material/AppsRounded'; import EditNoteIcon from '@mui/icons-material/EditNote'; import CalendarMonthIcon from '@mui/icons-material/CalendarMonth'; import CoPresentIcon from '@mui/icons-material/CoPresent'; @@ -50,6 +51,7 @@ interface DesktopDockProps { outputs: Record; selectedIds: string[]; onFocusCard: (id: string, rect: CardRect) => void; + onApplications: () => void; } const TILE = 30; @@ -66,6 +68,7 @@ function DesktopDock({ outputs, selectedIds, onFocusCard, + onApplications, }: DesktopDockProps): React.ReactElement | null { const dispatch = useAppDispatch(); const [hovered, setHovered] = useState<{ id: string; top: number } | null>(null); @@ -158,8 +161,6 @@ function DesktopDock({ setLiveShot(null); }, []); - if (entries.length === 0) return null; - const hoveredEntry = hovered ? entries.find((e) => e.id === hovered.id) : undefined; const previewImage = hoveredEntry ? (liveShot?.id === hoveredEntry.id ? liveShot.dataUrl : hoveredEntry.thumbnail || undefined) @@ -226,7 +227,9 @@ function DesktopDock({ ); })} - + {entries.length > 0 && ( + + )} dispatch(openSettingsModal(undefined))} onMouseEnter={endHover} @@ -246,6 +249,25 @@ function DesktopDock({ > + + + {hoveredEntry && (