mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-23 05:02:21 +02:00
[eric] dashboard: Applications launcher window (real /Applications via scan, electron icons+launch, category chips) + dock tile
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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<DashboardCanvasProps> = ({
|
||||
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<DashboardCanvasProps> = ({
|
||||
canvas.actions.fitToCards([rect], 1.15, true);
|
||||
onHighlightCard?.(cardId);
|
||||
}}
|
||||
onApplications={() => setAppsWindowOpen((v) => !v)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{appsWindowOpen && !fullscreenCardId && (
|
||||
<ApplicationsWindow onClose={() => setAppsWindowOpen(false)} />
|
||||
)}
|
||||
|
||||
{/* Canvas viewport */}
|
||||
<Box
|
||||
ref={canvas.viewportRef}
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
import { API_BASE } from '@/shared/config';
|
||||
|
||||
interface ApplicationsWindowProps {
|
||||
onClose: () => 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 (
|
||||
<Box
|
||||
sx={{
|
||||
width: 52,
|
||||
height: 52,
|
||||
borderRadius: '12px',
|
||||
background: 'linear-gradient(135deg, rgba(255,255,255,0.22), rgba(255,255,255,0.08))',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '1.3rem',
|
||||
fontWeight: 700,
|
||||
color: 'rgba(255,255,255,0.85)',
|
||||
}}
|
||||
>
|
||||
{letter}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/** Launchpad-style window over the canvas: the user's real /Applications, categorized. */
|
||||
function ApplicationsWindow({ onClose }: ApplicationsWindowProps): React.ReactElement {
|
||||
const [apps, setApps] = useState<string[] | null>(null);
|
||||
const [error, setError] = useState(false);
|
||||
const [icons, setIcons] = useState<Record<string, string>>({});
|
||||
const [category, setCategory] = useState<string>('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<string | null> } }).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<boolean> } }).openswarm?.openApplication;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box onClick={onClose} sx={{ position: 'absolute', inset: 0, zIndex: 19 }} />
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
left: '50%',
|
||||
top: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
zIndex: 20,
|
||||
width: 620,
|
||||
maxWidth: 'calc(100% - 80px)',
|
||||
maxHeight: 'calc(100% - 120px)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
borderRadius: '18px',
|
||||
background: 'rgba(22,12,34,0.82)',
|
||||
backdropFilter: 'blur(28px) saturate(160%)',
|
||||
WebkitBackdropFilter: 'blur(28px) saturate(160%)',
|
||||
boxShadow: '0 24px 64px rgba(0,0,0,0.5)',
|
||||
p: 2.5,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.25, mb: 1.5 }}>
|
||||
<Typography sx={{ fontSize: '1.15rem' }}>🐙</Typography>
|
||||
<Typography sx={{ fontSize: '1.05rem', fontWeight: 600, color: 'rgba(255,255,255,0.75)' }}>
|
||||
Applications
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{categories.length > 1 && (
|
||||
<Box sx={{ display: 'flex', gap: 0.75, mb: 2, overflowX: 'auto', pb: 0.5, scrollbarWidth: 'none', '&::-webkit-scrollbar': { display: 'none' } }}>
|
||||
{categories.map((cat) => (
|
||||
<Box
|
||||
key={cat}
|
||||
onClick={() => 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}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box sx={{ overflowY: 'auto', flex: 1, minHeight: 120 }}>
|
||||
{!apps && !error && (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }}>
|
||||
<CircularProgress size={22} sx={{ color: 'rgba(255,255,255,0.5)' }} />
|
||||
</Box>
|
||||
)}
|
||||
{error && (
|
||||
<Typography sx={{ color: 'rgba(255,255,255,0.55)', fontSize: '0.85rem', textAlign: 'center', py: 5 }}>
|
||||
Could not read /Applications.
|
||||
</Typography>
|
||||
)}
|
||||
{apps && (
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(78px, 1fr))', gap: 1.5 }}>
|
||||
{visible.map((name) => (
|
||||
<Box
|
||||
key={name}
|
||||
onClick={() => { 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] ? (
|
||||
<Box component="img" src={icons[name]} alt="" sx={{ width: 52, height: 52, borderRadius: '12px' }} />
|
||||
) : (
|
||||
<LetterTile name={name} />
|
||||
)}
|
||||
<Typography sx={{ fontSize: '0.66rem', color: 'rgba(255,255,255,0.82)', textAlign: 'center', maxWidth: '100%', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{name}
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default ApplicationsWindow;
|
||||
@@ -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<string, Output>;
|
||||
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({
|
||||
);
|
||||
})}
|
||||
|
||||
<Box sx={{ width: TILE - 8, height: '1px', background: 'rgba(255,255,255,0.14)' }} />
|
||||
{entries.length > 0 && (
|
||||
<Box sx={{ width: TILE - 8, height: '1px', background: 'rgba(255,255,255,0.14)' }} />
|
||||
)}
|
||||
<Box
|
||||
onClick={() => dispatch(openSettingsModal(undefined))}
|
||||
onMouseEnter={endHover}
|
||||
@@ -246,6 +249,25 @@ function DesktopDock({
|
||||
>
|
||||
<SettingsIcon sx={{ fontSize: 18, color: '#e8e8ee' }} />
|
||||
</Box>
|
||||
<Box
|
||||
onClick={onApplications}
|
||||
onMouseEnter={endHover}
|
||||
sx={{
|
||||
width: TILE,
|
||||
height: TILE,
|
||||
borderRadius: '9px',
|
||||
background: 'linear-gradient(135deg, #3d3d46, #232329)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
flexShrink: 0,
|
||||
transition: 'transform 0.15s ease',
|
||||
'&:hover': { transform: 'scale(1.12)' },
|
||||
}}
|
||||
>
|
||||
<AppsRoundedIcon sx={{ fontSize: 18, color: '#e8e8ee' }} />
|
||||
</Box>
|
||||
|
||||
{hoveredEntry && (
|
||||
<Box
|
||||
|
||||
Reference in New Issue
Block a user