diff --git a/backend/apps/tools_lib/models.py b/backend/apps/tools_lib/models.py
index 6ab129db..3c0cc24e 100644
--- a/backend/apps/tools_lib/models.py
+++ b/backend/apps/tools_lib/models.py
@@ -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
diff --git a/backend/apps/tools_lib/tools_lib.py b/backend/apps/tools_lib/tools_lib.py
index 4dec259e..b923c218 100644
--- a/backend/apps/tools_lib/tools_lib.py
+++ b/backend/apps/tools_lib/tools_lib.py
@@ -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)
diff --git a/electron/main.js b/electron/main.js
index 7d4168a1..8f9e1806 100644
--- a/electron/main.js
+++ b/electron/main.js
@@ -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);
+});
diff --git a/electron/package.json b/electron/package.json
index 3c31d9a1..758801b3 100644
--- a/electron/package.json
+++ b/electron/package.json
@@ -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"
}
diff --git a/electron/preload.js b/electron/preload.js
index e523d844..d222dadb 100644
--- a/electron/preload.js
+++ b/electron/preload.js
@@ -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);
+ },
});
})();
diff --git a/frontend/src/app/Main.tsx b/frontend/src/app/Main.tsx
index 7c213e95..b0027e5e 100644
--- a/frontend/src/app/Main.tsx
+++ b/frontend/src/app/Main.tsx
@@ -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 = () => {
-
- }>
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
-
-
+
+
+ }>
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+
+
+
diff --git a/frontend/src/app/components/Layout/AppShell.tsx b/frontend/src/app/components/Layout/AppShell.tsx
index 8319ed75..a507caab 100644
--- a/frontend/src/app/components/Layout/AppShell.tsx
+++ b/frontend/src/app/components/Layout/AppShell.tsx
@@ -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 && (
-
-
-
-
-
-
+ {/* Dashboards section */}
+
+
-
-
-
-
-
- {dashboardList.length > 0 && (
-
- )}
-
-
- 0} timeout={200}>
-
- {dashboardList.map((entry) => {
- const isActive = activeDashboardId === entry.id;
- return (
- 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 && (
-
- )}
-
+
+
+
+
+
+
+
+
+
+ {dashboardList.length > 0 && (
+
+ )}
+
+
+ 0} timeout={200}>
+
+ {dashboardList.map((entry) => {
+ const isActive = activeDashboardId === entry.id;
+ return (
+ 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',
+ }}
+ >
{entry.name}
-
- );
- })}
-
-
+ );
+ })}
+
+
+
-
+ {/* Divider */}
+
- {NAV_ITEMS.map((item) => (
-
- {({ isActive }) => (
-
-
- {item.icon}
-
-
+ {NAV_ITEMS.map((item) => (
+
+ {({ isActive }) => (
+
-
- )}
-
- ))}
-
+ >
+
+ {React.cloneElement(item.icon, { sx: { fontSize: 20 } })}
+
+
+
+ )}
+
+ ))}
+
+
{/* Settings */}
-
- Settings
-
-
- dispatch(openSettingsModal())}
- size="small"
+ dispatch(openSettingsModal())}
+ sx={{
+ borderRadius: 1.5,
+ py: 0.6,
+ px: 1.25,
+ '&:hover': { bgcolor: `${c.text.tertiary}0A` },
+ transition: 'background-color 0.15s',
+ }}
+ >
+
+
+ {showUpdateDot && (
+
+ )}
+
+
-
-
-
+ />
+
)}
@@ -377,6 +412,51 @@ const AppShell: React.FC = () => {
+
+
+ }
+ action={
+
+
+
+
+ }
+ 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
+
+
);
};
diff --git a/frontend/src/app/pages/Settings/Settings.tsx b/frontend/src/app/pages/Settings/Settings.tsx
index 667f48ff..6ffd5ff0 100644
--- a/frontend/src/app/pages/Settings/Settings.tsx
+++ b/frontend/src/app/pages/Settings/Settings.tsx
@@ -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({ ...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 = () => {
/>
+ {/* ── About ── */}
+ About
+
+
+
+
+ Version
+
+ {appVersion ?? '—'}
+
+
+
+
+
+
+
+
+ Software update
+
+ {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.'}
+
+
+
+ {updateStatus === 'checking' && (
+
+ )}
+ {updateStatus === 'not-available' && (
+
+ )}
+ {updateStatus === 'error' && (
+
+ )}
+ {(updateStatus === 'idle' || updateStatus === 'not-available' || updateStatus === 'error') && (
+ }
+ 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
+
+ )}
+ {updateStatus === 'available' && (
+ }
+ sx={{
+ color: c.accent.primary,
+ borderColor: c.accent.primary,
+ textTransform: 'none',
+ fontSize: '0.8rem',
+ whiteSpace: 'nowrap',
+ '&:hover': { bgcolor: `${c.accent.primary}10` },
+ }}
+ >
+ Download
+
+ )}
+ {updateStatus === 'downloaded' && (
+ }
+ sx={{
+ bgcolor: c.accent.primary,
+ '&:hover': { bgcolor: c.accent.pressed },
+ textTransform: 'none',
+ fontSize: '0.8rem',
+ whiteSpace: 'nowrap',
+ borderRadius: 1.5,
+ }}
+ >
+ Restart & Update
+
+ )}
+
+
+ {updateStatus === 'downloading' && (
+
+ )}
+
+
diff --git a/frontend/src/app/pages/Tools/Tools.tsx b/frontend/src/app/pages/Tools/Tools.tsx
index be5d842f..b0851ded 100644
--- a/frontend/src/app/pages/Tools/Tools.tsx
+++ b/frontend/src/app/pages/Tools/Tools.tsx
@@ -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 = () => {
)}
{!isDisabled && ig && tool.auth_status === 'connected' && (
-
+
}
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 }}
/>
diff --git a/frontend/src/shared/state/store.ts b/frontend/src/shared/state/store.ts
index 27cae9c0..a1424e45 100644
--- a/frontend/src/shared/state/store.ts
+++ b/frontend/src/shared/state/store.ts
@@ -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,
},
});
diff --git a/frontend/src/shared/state/toolsSlice.ts b/frontend/src/shared/state/toolsSlice.ts
index 26a2e61a..6ef5fd61 100644
--- a/frontend/src/shared/state/toolsSlice.ts
+++ b/frontend/src/shared/state/toolsSlice.ts
@@ -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; })
diff --git a/frontend/src/shared/state/updateSlice.ts b/frontend/src/shared/state/updateSlice.ts
new file mode 100644
index 00000000..a1244132
--- /dev/null
+++ b/frontend/src/shared/state/updateSlice.ts
@@ -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) {
+ state.appVersion = action.payload;
+ },
+ setChecking(state) {
+ state.status = 'checking';
+ state.error = null;
+ },
+ setUpdateAvailable(state, action: PayloadAction) {
+ state.status = 'available';
+ state.availableVersion = action.payload;
+ state.error = null;
+ },
+ setUpdateNotAvailable(state) {
+ state.status = 'not-available';
+ state.error = null;
+ },
+ setDownloading(state, action: PayloadAction) {
+ state.status = 'downloading';
+ state.downloadPercent = action.payload;
+ },
+ setUpdateDownloaded(state) {
+ state.status = 'downloaded';
+ state.downloadPercent = 100;
+ },
+ setUpdateError(state, action: PayloadAction) {
+ 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;
diff --git a/frontend/src/types/electron.d.ts b/frontend/src/types/electron.d.ts
index b98e79e4..a9f3557b 100644
--- a/frontend/src/types/electron.d.ts
+++ b/frontend/src/types/electron.d.ts
@@ -1,14 +1,49 @@
-declare namespace JSX {
- interface IntrinsicElements {
- webview: React.DetailedHTMLProps<
- React.HTMLAttributes & {
- src?: string;
- preload?: string;
- partition?: string;
- allowpopups?: string;
- nodeintegration?: string;
- },
- HTMLElement
- >;
+export {};
+
+declare global {
+ namespace JSX {
+ interface IntrinsicElements {
+ webview: React.DetailedHTMLProps<
+ React.HTMLAttributes & {
+ 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;
+ checkForUpdates: () => Promise<{ success: boolean; version?: string; error?: string }>;
+ downloadUpdate: () => Promise<{ success: boolean; error?: string }>;
+ installUpdate: () => Promise;
+ 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;
}
}
diff --git a/publish.sh b/publish.sh
new file mode 100755
index 00000000..fccaa935
--- /dev/null
+++ b/publish.sh
@@ -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 -
\ No newline at end of file
diff --git a/scripts/build-app.sh b/scripts/build-app.sh
index 4453332d..7f5ed284 100755
--- a/scripts/build-app.sh
+++ b/scripts/build-app.sh
@@ -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 ""