Merge pull request #2 from openswarm-ai/haik/updates-v1

Haik/updates v1
This commit is contained in:
haikdc
2026-03-15 23:40:24 -07:00
committed by GitHub
15 changed files with 754 additions and 217 deletions
+2
View File
@@ -71,5 +71,7 @@ class ToolUpdate(BaseModel):
credentials: Optional[dict[str, str]] = None
auth_type: Optional[str] = None
auth_status: Optional[str] = None
oauth_tokens: Optional[dict[str, Any]] = None
tool_permissions: Optional[dict[str, Any]] = None
connected_account_email: Optional[str] = None
enabled: Optional[bool] = None
+24
View File
@@ -652,6 +652,30 @@ async def discover_tools(tool_id: str):
return {"ok": True, "tool": tool.model_dump()}
@tools_lib.router.post("/{tool_id}/oauth/disconnect")
async def oauth_disconnect(tool_id: str):
"""Clear OAuth tokens and reset auth status so the user can reconnect with a different account."""
tool = _load(tool_id)
access_token = tool.oauth_tokens.get("access_token")
if access_token:
try:
async with httpx.AsyncClient(timeout=10.0) as client:
await client.post(
"https://oauth2.googleapis.com/revoke",
params={"token": access_token},
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
except Exception as e:
logger.warning(f"Failed to revoke Google token for tool {tool.id}: {e}")
tool.oauth_tokens = {}
tool.auth_status = "configured"
tool.connected_account_email = None
_save(tool)
return {"ok": True, "tool": tool.model_dump()}
@tools_lib.router.post("/{tool_id}/oauth/start")
async def oauth_start(tool_id: str):
_load(tool_id)
+50 -12
View File
@@ -141,31 +141,38 @@ function createWindow() {
});
}
function sendToRenderer(channel, ...args) {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send(channel, ...args);
}
}
function setupAutoUpdater() {
autoUpdater.autoDownload = false;
autoUpdater.autoInstallOnAppQuit = true;
autoUpdater.autoInstallOnAppQuit = false;
autoUpdater.on('update-available', (info) => {
console.log(`Update available: ${info.version}`);
if (mainWindow) {
mainWindow.webContents.executeJavaScript(
`window.__OPENSWARM_UPDATE_AVAILABLE__ = ${JSON.stringify(info)};`
);
}
autoUpdater.downloadUpdate();
sendToRenderer('update-available', info);
});
autoUpdater.on('update-not-available', (info) => {
console.log('App is up to date');
sendToRenderer('update-not-available', info);
});
autoUpdater.on('download-progress', (progress) => {
sendToRenderer('download-progress', progress);
});
autoUpdater.on('update-downloaded', (info) => {
console.log(`Update downloaded: ${info.version}`);
if (mainWindow) {
mainWindow.webContents.executeJavaScript(
`window.__OPENSWARM_UPDATE_DOWNLOADED__ = ${JSON.stringify(info)};`
);
}
sendToRenderer('update-downloaded', info);
});
autoUpdater.on('error', (err) => {
console.error('Auto-update error:', err);
sendToRenderer('update-error', err?.message || String(err));
});
autoUpdater.checkForUpdates().catch((err) => {
@@ -224,3 +231,34 @@ app.on('activate', () => {
});
ipcMain.handle('get-backend-port', () => backendPort);
ipcMain.handle('get-app-version', () => app.getVersion());
ipcMain.handle('check-for-updates', async () => {
if (!isPackaged) {
sendToRenderer('update-error', 'Update check is only available in the packaged app.');
return { success: false, error: 'Not packaged' };
}
try {
const result = await autoUpdater.checkForUpdates();
if (!result) {
sendToRenderer('update-error', 'Unable to check for updates.');
return { success: false, error: 'No result from update check' };
}
return { success: true, version: result.updateInfo?.version };
} catch (err) {
return { success: false, error: err?.message || String(err) };
}
});
ipcMain.handle('download-update', async () => {
try {
await autoUpdater.downloadUpdate();
return { success: true };
} catch (err) {
return { success: false, error: err?.message || String(err) };
}
});
ipcMain.handle('install-update', () => {
autoUpdater.quitAndInstall(false, true);
});
+3 -15
View File
@@ -29,20 +29,8 @@
"mac": {
"icon": "build/icon.icns",
"target": [
{
"target": "dmg",
"arch": [
"arm64",
"x64"
]
},
{
"target": "zip",
"arch": [
"arm64",
"x64"
]
}
"dmg",
"zip"
],
"category": "public.app-category.developer-tools",
"hardenedRuntime": true,
@@ -107,7 +95,7 @@
"publish": {
"provider": "github",
"owner": "openswarm-ai",
"repo": "production"
"repo": "openswarm"
},
"afterSign": "scripts/notarize.js"
}
+31
View File
@@ -7,5 +7,36 @@ const { contextBridge, ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld('openswarm', {
getBackendPort: () => port,
getAppVersion: () => ipcRenderer.invoke('get-app-version'),
checkForUpdates: () => ipcRenderer.invoke('check-for-updates'),
downloadUpdate: () => ipcRenderer.invoke('download-update'),
installUpdate: () => ipcRenderer.invoke('install-update'),
onUpdateAvailable: (cb) => {
const listener = (_event, info) => cb(info);
ipcRenderer.on('update-available', listener);
return () => ipcRenderer.removeListener('update-available', listener);
},
onUpdateNotAvailable: (cb) => {
const listener = (_event, info) => cb(info);
ipcRenderer.on('update-not-available', listener);
return () => ipcRenderer.removeListener('update-not-available', listener);
},
onDownloadProgress: (cb) => {
const listener = (_event, progress) => cb(progress);
ipcRenderer.on('download-progress', listener);
return () => ipcRenderer.removeListener('download-progress', listener);
},
onUpdateDownloaded: (cb) => {
const listener = (_event, info) => cb(info);
ipcRenderer.on('update-downloaded', listener);
return () => ipcRenderer.removeListener('update-downloaded', listener);
},
onUpdateError: (cb) => {
const listener = (_event, message) => cb(message);
ipcRenderer.on('update-error', listener);
return () => ipcRenderer.removeListener('update-error', listener);
},
});
})();
+68 -12
View File
@@ -5,6 +5,14 @@ import { ThemeProvider as MuiThemeProvider, createTheme, CssBaseline } from '@mu
import { store } from '../shared/state/store';
import { useAppDispatch } from '@/shared/hooks';
import { fetchSettings } from '@/shared/state/settingsSlice';
import {
setAppVersion,
setUpdateAvailable,
setUpdateNotAvailable,
setDownloading,
setUpdateDownloaded,
setUpdateError,
} from '@/shared/state/updateSlice';
import AppShell from './components/Layout/AppShell';
import Dashboard from './pages/Dashboard/Dashboard';
import DashboardSelection from './pages/DashboardSelection/DashboardSelection';
@@ -61,6 +69,29 @@ function buildMuiTheme(c: ClaudeTokens, mode: 'light' | 'dark') {
body: {
backgroundColor: c.bg.page,
color: c.text.primary,
scrollbarWidth: 'thin',
scrollbarColor: `${c.border.strong} transparent`,
},
'*': {
scrollbarWidth: 'thin',
scrollbarColor: `${c.border.strong} transparent`,
},
'*::-webkit-scrollbar': {
width: '6px',
height: '6px',
},
'*::-webkit-scrollbar-track': {
background: 'transparent',
},
'*::-webkit-scrollbar-thumb': {
background: c.border.strong,
borderRadius: '3px',
},
'*::-webkit-scrollbar-thumb:hover': {
background: c.text.ghost,
},
'*::-webkit-scrollbar-corner': {
background: 'transparent',
},
},
},
@@ -130,6 +161,29 @@ const SettingsLoader: React.FC<{ children: React.ReactNode }> = ({ children }) =
return <>{children}</>;
};
const UpdateListener: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const dispatch = useAppDispatch();
useEffect(() => {
const api = (window as any).openswarm as OpenSwarmAPI | undefined;
if (!api?.getAppVersion) return;
api.getAppVersion().then((v: string) => dispatch(setAppVersion(v)));
const cleanups = [
api.onUpdateAvailable?.((info: OpenSwarmUpdateInfo) => dispatch(setUpdateAvailable(info.version))),
api.onUpdateNotAvailable?.(() => dispatch(setUpdateNotAvailable())),
api.onDownloadProgress?.((p: OpenSwarmDownloadProgress) => dispatch(setDownloading(p.percent))),
api.onUpdateDownloaded?.(() => dispatch(setUpdateDownloaded())),
api.onUpdateError?.((msg: string) => dispatch(setUpdateError(msg))),
];
return () => cleanups.forEach((fn: (() => void) | undefined) => fn?.());
}, [dispatch]);
return <>{children}</>;
};
const ThemedApp: React.FC = () => {
const c = useClaudeTokens();
const { mode } = useThemeMode();
@@ -141,18 +195,20 @@ const ThemedApp: React.FC = () => {
<HashRouter>
<ShortcutsProvider>
<SettingsLoader>
<Routes>
<Route element={<AppShell />}>
<Route path="/" element={<DashboardSelection />} />
<Route path="/dashboard/:id" element={<Dashboard />} />
<Route path="/templates" element={<Templates />} />
<Route path="/skills" element={<Skills />} />
<Route path="/tools" element={<Tools />} />
<Route path="/modes" element={<Modes />} />
<Route path="/commands" element={<Commands />} />
<Route path="/views" element={<Views />} />
</Route>
</Routes>
<UpdateListener>
<Routes>
<Route element={<AppShell />}>
<Route path="/" element={<DashboardSelection />} />
<Route path="/dashboard/:id" element={<Dashboard />} />
<Route path="/templates" element={<Templates />} />
<Route path="/skills" element={<Skills />} />
<Route path="/tools" element={<Tools />} />
<Route path="/modes" element={<Modes />} />
<Route path="/commands" element={<Commands />} />
<Route path="/views" element={<Views />} />
</Route>
</Routes>
</UpdateListener>
</SettingsLoader>
</ShortcutsProvider>
</HashRouter>
+235 -155
View File
@@ -2,7 +2,6 @@ import React, { useState, useEffect } from 'react';
import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom';
import { openSettingsModal } from '@/shared/state/settingsSlice';
import Box from '@mui/material/Box';
import List from '@mui/material/List';
import ListItemButton from '@mui/material/ListItemButton';
import ListItemIcon from '@mui/material/ListItemIcon';
import ListItemText from '@mui/material/ListItemText';
@@ -10,6 +9,9 @@ import Typography from '@mui/material/Typography';
import IconButton from '@mui/material/IconButton';
import Tooltip from '@mui/material/Tooltip';
import Collapse from '@mui/material/Collapse';
import Button from '@mui/material/Button';
import Snackbar from '@mui/material/Snackbar';
import Alert from '@mui/material/Alert';
import DashboardIcon from '@mui/icons-material/Dashboard';
import DescriptionIcon from '@mui/icons-material/Description';
import PsychologyIcon from '@mui/icons-material/Psychology';
@@ -23,6 +25,7 @@ import SettingsIcon from '@mui/icons-material/Settings';
import ViewSidebarOutlinedIcon from '@mui/icons-material/ViewSidebarOutlined';
import ArrowBackOutlinedIcon from '@mui/icons-material/ArrowBackOutlined';
import ArrowForwardOutlinedIcon from '@mui/icons-material/ArrowForwardOutlined';
import RestartAltIcon from '@mui/icons-material/RestartAlt';
import Settings from '@/app/pages/Settings/Settings';
import GlobalApprovalOverlay from '@/app/components/GlobalApprovalOverlay';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
@@ -47,6 +50,13 @@ const AppShell: React.FC = () => {
const [dashboardsExpanded, setDashboardsExpanded] = useState(false);
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const updateStatus = useAppSelector((state) => state.update.status);
const availableVersion = useAppSelector((state) => state.update.availableVersion);
const [updateBannerDismissed, setUpdateBannerDismissed] = useState(false);
const showUpdateDot = updateStatus === 'available' || updateStatus === 'downloaded';
const showUpdateBanner = updateStatus === 'downloaded' && !updateBannerDismissed;
const dashboardItems = useAppSelector((state) => state.dashboards.items);
const dashboardList = Object.values(dashboardItems).sort(
(a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime(),
@@ -180,192 +190,217 @@ const AppShell: React.FC = () => {
{!sidebarCollapsed && (
<Box
sx={{
width: 240,
width: 220,
flexShrink: 0,
bgcolor: c.bg.secondary,
boxShadow: '1px 0 3px rgba(0,0,0,0.04)',
borderRight: `0.5px solid ${c.border.subtle}`,
display: 'flex',
flexDirection: 'column',
}}
>
<List sx={{ pt: 1, px: 1, flex: 1, overflow: 'auto'}}>
<ListItemButton
onClick={handleDashboardsClick}
sx={{
borderRadius: dashboardsExpanded ? '22px 22px 0 0' : 2,
bgcolor: isDashboardRoute ? `${c.accent.primary}0F` : 'transparent',
'&:hover': { bgcolor: `${c.accent.primary}08` },
}}
>
<ListItemIcon sx={{ color: isDashboardRoute ? c.text.primary : c.text.tertiary, minWidth: 40 }}>
<DashboardIcon />
</ListItemIcon>
<ListItemText
primary="Dashboards"
<Box sx={{ flex: 1, overflow: 'auto', pt: 0.5, '&::-webkit-scrollbar': { width: 0 } }}>
{/* Dashboards section */}
<Box sx={{ px: 1, mb: 0.25 }}>
<ListItemButton
onClick={handleDashboardsClick}
sx={{
'& .MuiListItemText-primary': {
color: isDashboardRoute ? c.text.primary : c.text.muted,
fontSize: '0.875rem',
fontWeight: isDashboardRoute ? 500 : 400,
},
}}
/>
<Tooltip title="New dashboard">
<IconButton
size="small"
onClick={handleCreateDashboard}
sx={{
color: c.text.ghost,
p: 0.25,
mr: 0.5,
'&:hover': { color: c.accent.primary },
}}
>
<AddIcon sx={{ fontSize: 16 }} />
</IconButton>
</Tooltip>
{dashboardList.length > 0 && (
<ExpandMoreIcon
sx={{
color: c.text.ghost,
fontSize: 18,
transition: 'transform 0.2s',
transform: dashboardsExpanded ? 'rotate(180deg)' : 'rotate(0deg)',
}}
/>
)}
</ListItemButton>
<Collapse in={dashboardsExpanded && dashboardList.length > 0} timeout={200}>
<Box
sx={{
pl: 0.15,
maxHeight: 300,
overflow: 'auto',
'&::-webkit-scrollbar': { width: 4 },
'&::-webkit-scrollbar-track': { background: 'transparent' },
'&::-webkit-scrollbar-thumb': { background: c.border.medium, borderRadius: 2 },
scrollbarWidth: 'thin',
scrollbarColor: `${c.border.medium} transparent`,
borderRadius: 1.5,
py: 0.6,
px: 1.25,
bgcolor: isDashboardRoute ? `${c.accent.primary}12` : 'transparent',
'&:hover': { bgcolor: isDashboardRoute ? `${c.accent.primary}18` : `${c.text.tertiary}0A` },
transition: 'background-color 0.15s',
}}
>
{dashboardList.map((entry) => {
const isActive = activeDashboardId === entry.id;
return (
<Box
key={entry.id}
onClick={() => handleDashboardItemClick(entry.id)}
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.5,
px: 1.5,
py: 0.75,
borderRadius: 0,
cursor: 'pointer',
bgcolor: isActive ? `${c.accent.primary}08` : 'transparent',
borderLeft: isActive ? `1.5px solid ${c.accent.primary}90` : '1.5px solid transparent',
'&:hover': { bgcolor: `${c.accent.primary}0C` },
transition: 'background-color 0.15s, border-color 0.15s',
}}
>
{isActive && (
<Box
sx={{
width: 5,
height: 5,
borderRadius: '50%',
bgcolor: c.accent.primary,
flexShrink: 0,
opacity: 0.7,
}}
/>
)}
<Box sx={{ flex: 1, minWidth: 0 }}>
<ListItemIcon sx={{ color: isDashboardRoute ? c.accent.primary : c.text.tertiary, minWidth: 32 }}>
<DashboardIcon sx={{ fontSize: 20 }} />
</ListItemIcon>
<ListItemText
primary="Dashboards"
sx={{
'& .MuiListItemText-primary': {
color: isDashboardRoute ? c.text.primary : c.text.muted,
fontSize: '0.82rem',
fontWeight: isDashboardRoute ? 600 : 400,
},
}}
/>
<Tooltip title="New dashboard" placement="right">
<IconButton
size="small"
onClick={handleCreateDashboard}
sx={{
color: c.text.ghost,
p: 0.25,
mr: 0.25,
borderRadius: 1,
'&:hover': { color: c.accent.primary, bgcolor: `${c.accent.primary}14` },
}}
>
<AddIcon sx={{ fontSize: 15 }} />
</IconButton>
</Tooltip>
{dashboardList.length > 0 && (
<ExpandMoreIcon
sx={{
color: c.text.ghost,
fontSize: 16,
transition: 'transform 0.2s',
transform: dashboardsExpanded ? 'rotate(180deg)' : 'rotate(0deg)',
}}
/>
)}
</ListItemButton>
<Collapse in={dashboardsExpanded && dashboardList.length > 0} timeout={200}>
<Box
sx={{
ml: 2,
mt: 0.25,
mb: 0.5,
borderLeft: `1px solid ${c.border.medium}`,
maxHeight: 240,
overflow: 'auto',
'&::-webkit-scrollbar': { width: 3 },
'&::-webkit-scrollbar-track': { background: 'transparent' },
'&::-webkit-scrollbar-thumb': { background: c.border.medium, borderRadius: 4 },
scrollbarWidth: 'thin',
scrollbarColor: `${c.border.medium} transparent`,
}}
>
{dashboardList.map((entry) => {
const isActive = activeDashboardId === entry.id;
return (
<Box
key={entry.id}
onClick={() => handleDashboardItemClick(entry.id)}
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.75,
pl: 1.25,
pr: 1,
py: 0.5,
ml: '-0.5px',
cursor: 'pointer',
borderLeft: isActive ? `1.5px solid ${c.accent.primary}` : '1.5px solid transparent',
bgcolor: isActive ? `${c.accent.primary}0C` : 'transparent',
'&:hover': { bgcolor: `${c.text.tertiary}0A` },
transition: 'background-color 0.12s, border-color 0.12s',
}}
>
<Typography
sx={{
color: isActive ? c.text.secondary : c.text.muted,
fontSize: '0.8rem',
color: isActive ? c.text.secondary : c.text.ghost,
fontSize: '0.78rem',
fontWeight: isActive ? 500 : 400,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
flex: 1,
minWidth: 0,
}}
>
{entry.name}
</Typography>
</Box>
</Box>
);
})}
</Box>
</Collapse>
);
})}
</Box>
</Collapse>
</Box>
<Box sx={{ mb: 1 }} />
{/* Divider */}
<Box sx={{ mx: 1.5, my: 0.5, borderTop: `0.5px solid ${c.border.subtle}` }} />
{NAV_ITEMS.map((item) => (
<NavLink
key={item.path}
to={item.path}
style={{ textDecoration: 'none', color: 'inherit' }}
>
{({ isActive }) => (
<ListItemButton
sx={{
borderRadius: 2,
mb: 1,
bgcolor: isActive ? `${c.accent.primary}0F` : 'transparent',
'&:hover': { bgcolor: `${c.accent.primary}08` },
}}
>
<ListItemIcon
sx={{ color: isActive ? c.text.primary : c.text.tertiary, minWidth: 40 }}
>
{item.icon}
</ListItemIcon>
<ListItemText
primary={item.label}
{/* Nav items */}
<Box sx={{ px: 1 }}>
{NAV_ITEMS.map((item) => (
<NavLink
key={item.path}
to={item.path}
style={{ textDecoration: 'none', color: 'inherit' }}
>
{({ isActive }) => (
<ListItemButton
sx={{
'& .MuiListItemText-primary': {
color: isActive ? c.text.primary : c.text.muted,
fontSize: '0.875rem',
fontWeight: isActive ? 500 : 400,
},
borderRadius: 1.5,
py: 0.6,
px: 1.25,
mb: 0.25,
bgcolor: isActive ? `${c.accent.primary}12` : 'transparent',
'&:hover': { bgcolor: isActive ? `${c.accent.primary}18` : `${c.text.tertiary}0A` },
transition: 'background-color 0.15s',
}}
/>
</ListItemButton>
)}
</NavLink>
))}
</List>
>
<ListItemIcon
sx={{ color: isActive ? c.accent.primary : c.text.tertiary, minWidth: 32 }}
>
{React.cloneElement(item.icon, { sx: { fontSize: 20 } })}
</ListItemIcon>
<ListItemText
primary={item.label}
sx={{
'& .MuiListItemText-primary': {
color: isActive ? c.text.primary : c.text.muted,
fontSize: '0.82rem',
fontWeight: isActive ? 600 : 400,
},
}}
/>
</ListItemButton>
)}
</NavLink>
))}
</Box>
</Box>
{/* Settings */}
<Box
sx={{
px: 2,
py: 1.5,
borderTop: `0.5px solid ${c.border.medium}`,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
px: 1,
py: 1,
borderTop: `0.5px solid ${c.border.subtle}`,
}}
>
<Typography sx={{ color: c.text.tertiary, fontSize: '0.75rem' }}>
Settings
</Typography>
<Tooltip title="Settings">
<IconButton
onClick={() => dispatch(openSettingsModal())}
size="small"
<ListItemButton
onClick={() => dispatch(openSettingsModal())}
sx={{
borderRadius: 1.5,
py: 0.6,
px: 1.25,
'&:hover': { bgcolor: `${c.text.tertiary}0A` },
transition: 'background-color 0.15s',
}}
>
<ListItemIcon sx={{ color: c.text.tertiary, minWidth: 32, position: 'relative' }}>
<SettingsIcon sx={{ fontSize: 20 }} />
{showUpdateDot && (
<Box
sx={{
position: 'absolute',
top: 2,
right: 10,
width: 7,
height: 7,
borderRadius: '50%',
bgcolor: c.accent.primary,
border: `1.5px solid ${c.bg.secondary}`,
}}
/>
)}
</ListItemIcon>
<ListItemText
primary="Settings"
sx={{
color: c.text.tertiary,
'&:hover': { color: c.accent.primary, bgcolor: `${c.accent.primary}0A` },
transition: c.transition,
'& .MuiListItemText-primary': {
color: c.text.muted,
fontSize: '0.82rem',
fontWeight: 400,
},
}}
>
<SettingsIcon sx={{ fontSize: 18 }} />
</IconButton>
</Tooltip>
/>
</ListItemButton>
</Box>
</Box>
)}
@@ -377,6 +412,51 @@ const AppShell: React.FC = () => {
<Settings />
<GlobalApprovalOverlay />
<Snackbar
open={showUpdateBanner}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
>
<Alert
severity="info"
icon={<RestartAltIcon sx={{ fontSize: 18 }} />}
action={
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
<Button
size="small"
onClick={() => setUpdateBannerDismissed(true)}
sx={{ color: c.text.muted, textTransform: 'none', fontSize: '0.8rem', minWidth: 'auto' }}
>
Later
</Button>
<Button
size="small"
variant="contained"
onClick={() => (window as any).openswarm?.installUpdate()}
sx={{
bgcolor: c.accent.primary,
'&:hover': { bgcolor: c.accent.pressed },
textTransform: 'none',
fontSize: '0.8rem',
borderRadius: 1.5,
minWidth: 'auto',
}}
>
Restart
</Button>
</Box>
}
sx={{
bgcolor: c.bg.surface,
color: c.text.primary,
border: `1px solid ${c.border.medium}`,
boxShadow: c.shadow.md,
'& .MuiAlert-icon': { color: c.accent.primary },
}}
>
OpenSwarm {availableVersion} downloaded restart to update
</Alert>
</Snackbar>
</Box>
);
};
@@ -26,8 +26,16 @@ import SaveIcon from '@mui/icons-material/Save';
import CloseIcon from '@mui/icons-material/Close';
import KeyboardIcon from '@mui/icons-material/Keyboard';
import LanguageIcon from '@mui/icons-material/Language';
import SystemUpdateAltIcon from '@mui/icons-material/SystemUpdateAlt';
import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline';
import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline';
import RestartAltIcon from '@mui/icons-material/RestartAlt';
import DownloadIcon from '@mui/icons-material/Download';
import CircularProgress from '@mui/material/CircularProgress';
import LinearProgress from '@mui/material/LinearProgress';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import { updateSettings, closeSettingsModal, AppSettings } from '@/shared/state/settingsSlice';
import { setChecking, setUpdateError } from '@/shared/state/updateSlice';
import { fetchModes } from '@/shared/state/modesSlice';
import { useClaudeTokens, useThemeMode } from '@/shared/styles/ThemeContext';
import DirectoryBrowser from '@/app/components/DirectoryBrowser';
@@ -43,6 +51,12 @@ const Settings: React.FC = () => {
const modesList = useMemo(() => Object.values(modes), [modes]);
const updateStatus = useAppSelector((s) => s.update.status);
const appVersion = useAppSelector((s) => s.update.appVersion);
const availableVersion = useAppSelector((s) => s.update.availableVersion);
const downloadPercent = useAppSelector((s) => s.update.downloadPercent);
const updateError = useAppSelector((s) => s.update.error);
const [form, setForm] = useState<AppSettings>({ ...settings });
const [showApiKey, setShowApiKey] = useState(false);
const [browseOpen, setBrowseOpen] = useState(false);
@@ -60,6 +74,32 @@ const Settings: React.FC = () => {
}
}, [loaded, settings]);
const handleCheckForUpdates = async () => {
dispatch(setChecking());
const timeout = setTimeout(() => {
dispatch(setUpdateError('Update check timed out. Please try again.'));
}, 15000);
try {
await (window as any).openswarm?.checkForUpdates();
} catch {
/* error handled via IPC event listener */
} finally {
clearTimeout(timeout);
}
};
const handleDownloadUpdate = async () => {
try {
await (window as any).openswarm?.downloadUpdate();
} catch {
/* error handled via IPC event listener */
}
};
const handleInstallUpdate = () => {
(window as any).openswarm?.installUpdate();
};
const hasChanges = JSON.stringify(form) !== JSON.stringify(settings);
const handleSave = async () => {
@@ -511,6 +551,115 @@ const Settings: React.FC = () => {
/>
</Box>
{/* ── About ── */}
<Typography sx={{ ...sectionSx, mt: 3 }}>About</Typography>
<Box sx={rowSx}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Box>
<Typography sx={labelSx}>Version</Typography>
<Typography sx={{ ...descSx, fontFamily: c.font.mono }}>
{appVersion ?? '—'}
</Typography>
</Box>
</Box>
</Box>
<Box sx={rowLastSx}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: updateStatus === 'downloading' ? 1 : 0 }}>
<Box>
<Typography sx={labelSx}>Software update</Typography>
<Typography sx={descSx}>
{updateStatus === 'checking' && 'Checking for updates…'}
{updateStatus === 'not-available' && 'You\'re on the latest version.'}
{updateStatus === 'available' && `Version ${availableVersion} is available.`}
{updateStatus === 'downloading' && `Downloading update… ${Math.round(downloadPercent)}%`}
{updateStatus === 'downloaded' && `Version ${availableVersion} is ready to install.`}
{updateStatus === 'error' && (updateError || 'Update check failed.')}
{updateStatus === 'idle' && 'Check for new versions of OpenSwarm.'}
</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexShrink: 0, ml: 2 }}>
{updateStatus === 'checking' && (
<CircularProgress size={18} sx={{ color: c.text.tertiary }} />
)}
{updateStatus === 'not-available' && (
<CheckCircleOutlineIcon sx={{ fontSize: 18, color: c.status.success }} />
)}
{updateStatus === 'error' && (
<ErrorOutlineIcon sx={{ fontSize: 18, color: c.status.error }} />
)}
{(updateStatus === 'idle' || updateStatus === 'not-available' || updateStatus === 'error') && (
<Button
variant="outlined"
size="small"
onClick={handleCheckForUpdates}
disabled={updateStatus === 'checking'}
startIcon={<SystemUpdateAltIcon sx={{ fontSize: 15 }} />}
sx={{
color: c.text.secondary,
borderColor: c.border.medium,
textTransform: 'none',
fontSize: '0.8rem',
whiteSpace: 'nowrap',
'&:hover': { color: c.accent.primary, borderColor: c.accent.primary },
}}
>
Check for Updates
</Button>
)}
{updateStatus === 'available' && (
<Button
variant="outlined"
size="small"
onClick={handleDownloadUpdate}
startIcon={<DownloadIcon sx={{ fontSize: 15 }} />}
sx={{
color: c.accent.primary,
borderColor: c.accent.primary,
textTransform: 'none',
fontSize: '0.8rem',
whiteSpace: 'nowrap',
'&:hover': { bgcolor: `${c.accent.primary}10` },
}}
>
Download
</Button>
)}
{updateStatus === 'downloaded' && (
<Button
variant="contained"
size="small"
onClick={handleInstallUpdate}
startIcon={<RestartAltIcon sx={{ fontSize: 15 }} />}
sx={{
bgcolor: c.accent.primary,
'&:hover': { bgcolor: c.accent.pressed },
textTransform: 'none',
fontSize: '0.8rem',
whiteSpace: 'nowrap',
borderRadius: 1.5,
}}
>
Restart &amp; Update
</Button>
)}
</Box>
</Box>
{updateStatus === 'downloading' && (
<LinearProgress
variant="determinate"
value={downloadPercent}
sx={{
height: 3,
borderRadius: 2,
bgcolor: `${c.accent.primary}20`,
'& .MuiLinearProgress-bar': { bgcolor: c.accent.primary, borderRadius: 2 },
}}
/>
)}
</Box>
</Box>
</DialogContent>
+27 -9
View File
@@ -83,6 +83,7 @@ import {
} from '@/shared/state/outputsSlice';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { API_BASE } from '@/shared/config';
import ViewQuiltIcon from '@mui/icons-material/ViewQuilt';
interface CredentialField {
@@ -822,13 +823,30 @@ const Tools: React.FC = () => {
};
const handleDisconnectIntegration = async (toolId: string, integration: Integration) => {
await dispatch(updateTool({
id: toolId,
credentials: {},
auth_type: 'none',
auth_status: 'configured',
}));
setSnackbar({ open: true, message: `${integration.name} disconnected` });
if (integration.authType === 'oauth2') {
// Revoke the token on Google's side (fire-and-forget)
fetch(`${API_BASE}/tools/${toolId}/oauth/disconnect`, { method: 'POST' }).catch(() => {});
// Clear OAuth state via the existing update endpoint
const result = await dispatch(updateTool({
id: toolId,
oauth_tokens: {},
auth_status: 'configured',
connected_account_email: '',
}));
if (updateTool.fulfilled.match(result)) {
setSnackbar({ open: true, message: `${integration.name} disconnected. You can now connect a different account.` });
} else {
setSnackbar({ open: true, message: `Failed to disconnect ${integration.name}`, severity: 'error' });
}
} else {
await dispatch(updateTool({
id: toolId,
credentials: {},
auth_type: 'none',
auth_status: 'configured',
}));
setSnackbar({ open: true, message: `${integration.name} disconnected` });
}
};
return (
@@ -1212,12 +1230,12 @@ const Tools: React.FC = () => {
</Button>
)}
{!isDisabled && ig && tool.auth_status === 'connected' && (
<Tooltip title={ig.credentialFields ? 'Disconnect' : ''}>
<Tooltip title={ig.credentialFields || ig.authType === 'oauth2' ? 'Disconnect' : ''}>
<Chip
icon={<CheckCircleIcon sx={{ fontSize: 12 }} />}
label={tool.connected_account_email ? `Connected · ${tool.connected_account_email}` : 'Connected'}
size="small"
onDelete={ig.credentialFields ? (e: React.SyntheticEvent) => { e.stopPropagation(); handleDisconnectIntegration(tool.id, ig); } : undefined}
onDelete={(ig.credentialFields || ig.authType === 'oauth2') ? (e: React.SyntheticEvent) => { e.stopPropagation(); handleDisconnectIntegration(tool.id, ig); } : undefined}
onClick={(e) => e.stopPropagation()}
sx={{ bgcolor: c.status.successBg, color: c.status.success, fontSize: '0.7rem', height: 22, '& .MuiChip-icon': { color: c.status.success }, '& .MuiChip-deleteIcon': { color: c.status.success, '&:hover': { color: c.status.error } }, flexShrink: 0 }}
/>
+2
View File
@@ -11,6 +11,7 @@ import skillRegistryReducer from './skillRegistrySlice';
import outputsReducer from './outputsSlice';
import dashboardLayoutReducer from './dashboardLayoutSlice';
import dashboardsReducer from './dashboardsSlice';
import updateReducer from './updateSlice';
export const store = configureStore({
reducer: {
@@ -26,6 +27,7 @@ export const store = configureStore({
outputs: outputsReducer,
dashboardLayout: dashboardLayoutReducer,
dashboards: dashboardsReducer,
update: updateReducer,
},
});
+11
View File
@@ -97,6 +97,16 @@ export const startOAuth = createAsyncThunk(
}
);
export const disconnectOAuth = createAsyncThunk(
'tools/disconnectOAuth',
async (toolId: string) => {
const res = await fetch(`${TOOLS_API}/${toolId}/oauth/disconnect`, { method: 'POST' });
if (!res.ok) throw new Error('Failed to disconnect OAuth');
const data = await res.json();
return data.tool as ToolDefinition;
}
);
export const fetchToolStatus = createAsyncThunk(
'tools/fetchStatus',
async (toolId: string) => {
@@ -156,6 +166,7 @@ const toolsSlice = createSlice({
.addCase(createTool.fulfilled, (state, action) => { state.items[action.payload.id] = action.payload; })
.addCase(updateTool.fulfilled, (state, action) => { state.items[action.payload.id] = action.payload; })
.addCase(deleteTool.fulfilled, (state, action) => { delete state.items[action.payload]; })
.addCase(disconnectOAuth.fulfilled, (state, action) => { state.items[action.payload.id] = action.payload; })
.addCase(fetchToolStatus.fulfilled, (state, action) => { state.items[action.payload.id] = action.payload; })
.addCase(discoverTools.fulfilled, (state, action) => { state.items[action.payload.id] = action.payload; })
.addCase(fetchBuiltinPermissions.fulfilled, (state, action) => { state.builtinPermissions = action.payload; })
+79
View File
@@ -0,0 +1,79 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
export type UpdateStatus =
| 'idle'
| 'checking'
| 'available'
| 'not-available'
| 'downloading'
| 'downloaded'
| 'error';
interface UpdateState {
status: UpdateStatus;
appVersion: string | null;
availableVersion: string | null;
downloadPercent: number;
error: string | null;
}
const initialState: UpdateState = {
status: 'idle',
appVersion: null,
availableVersion: null,
downloadPercent: 0,
error: null,
};
const updateSlice = createSlice({
name: 'update',
initialState,
reducers: {
setAppVersion(state, action: PayloadAction<string>) {
state.appVersion = action.payload;
},
setChecking(state) {
state.status = 'checking';
state.error = null;
},
setUpdateAvailable(state, action: PayloadAction<string>) {
state.status = 'available';
state.availableVersion = action.payload;
state.error = null;
},
setUpdateNotAvailable(state) {
state.status = 'not-available';
state.error = null;
},
setDownloading(state, action: PayloadAction<number>) {
state.status = 'downloading';
state.downloadPercent = action.payload;
},
setUpdateDownloaded(state) {
state.status = 'downloaded';
state.downloadPercent = 100;
},
setUpdateError(state, action: PayloadAction<string>) {
state.status = 'error';
state.error = action.payload;
},
resetUpdateStatus(state) {
state.status = 'idle';
state.error = null;
state.downloadPercent = 0;
},
},
});
export const {
setAppVersion,
setChecking,
setUpdateAvailable,
setUpdateNotAvailable,
setDownloading,
setUpdateDownloaded,
setUpdateError,
resetUpdateStatus,
} = updateSlice.actions;
export default updateSlice.reducer;
+47 -12
View File
@@ -1,14 +1,49 @@
declare namespace JSX {
interface IntrinsicElements {
webview: React.DetailedHTMLProps<
React.HTMLAttributes<HTMLElement> & {
src?: string;
preload?: string;
partition?: string;
allowpopups?: string;
nodeintegration?: string;
},
HTMLElement
>;
export {};
declare global {
namespace JSX {
interface IntrinsicElements {
webview: React.DetailedHTMLProps<
React.HTMLAttributes<HTMLElement> & {
src?: string;
preload?: string;
partition?: string;
allowpopups?: string;
nodeintegration?: string;
},
HTMLElement
>;
}
}
interface OpenSwarmUpdateInfo {
version: string;
releaseDate?: string;
releaseNotes?: string | Array<{ version: string; note: string }>;
}
interface OpenSwarmDownloadProgress {
bytesPerSecond: number;
percent: number;
transferred: number;
total: number;
}
interface OpenSwarmAPI {
getBackendPort: () => number;
getAppVersion: () => Promise<string>;
checkForUpdates: () => Promise<{ success: boolean; version?: string; error?: string }>;
downloadUpdate: () => Promise<{ success: boolean; error?: string }>;
installUpdate: () => Promise<void>;
onUpdateAvailable: (cb: (info: OpenSwarmUpdateInfo) => void) => () => void;
onUpdateNotAvailable: (cb: (info: OpenSwarmUpdateInfo) => void) => () => void;
onDownloadProgress: (cb: (progress: OpenSwarmDownloadProgress) => void) => () => void;
onUpdateDownloaded: (cb: (info: OpenSwarmUpdateInfo) => void) => () => void;
onUpdateError: (cb: (message: string) => void) => () => void;
}
interface Window {
__OPENSWARM_PORT__: number;
openswarm: OpenSwarmAPI;
}
}
Executable
+17
View File
@@ -0,0 +1,17 @@
#!/bin/bash
# The comment above is shebang, DO NOT REMOVE
PUBLISH_ABSPATH="$(readlink -f "${BASH_SOURCE[0]}")"
if [[ "$OSTYPE" == "darwin"* ]]; then
sed -i '' 's/\r//g' "$PUBLISH_ABSPATH"
else
sed -i 's/\r//g' "$PUBLISH_ABSPATH"
fi
chmod +x "$PUBLISH_ABSPATH"
PROJECT_ROOT="$(dirname "$PUBLISH_ABSPATH")"
cd "$PROJECT_ROOT"
echo "Building and deploying to Firebase Hosting..."
bash scripts/build-app.sh --publish
cd -
+9 -2
View File
@@ -77,10 +77,17 @@ cd "$PROJECT_ROOT/electron"
npm install
if $PUBLISH_MODE; then
npx electron-builder --mac --publish always
npx electron-builder --mac --arm64 --x64 --publish always
else
export CSC_IDENTITY_AUTO_DISCOVERY=false
npx electron-builder --mac --publish never
ARCH=$(uname -m)
if [[ "$ARCH" == "arm64" ]]; then
npx electron-builder --mac --arm64 --publish never
elif [[ "$ARCH" == "x86_64" ]]; then
npx electron-builder --mac --x64 --publish never
else
npx electron-builder --mac --publish never
fi
fi
echo ""