From 383f62c852c1544af4ad15a8190d46ca10462943 Mon Sep 17 00:00:00 2001 From: haikdc Date: Sun, 15 Mar 2026 23:12:29 -0700 Subject: [PATCH 1/2] [Haik]: minor style tweaks and auto update functionality added (yet to be tested) --- backend/apps/tools_lib/models.py | 2 + backend/apps/tools_lib/tools_lib.py | 24 ++ electron/main.js | 62 ++- electron/package-lock.json | 4 +- electron/package.json | 18 +- electron/preload.js | 31 ++ frontend/src/app/Main.tsx | 80 +++- .../src/app/components/Layout/AppShell.tsx | 390 +++++++++++------- frontend/src/app/pages/Settings/Settings.tsx | 149 +++++++ frontend/src/app/pages/Tools/Tools.tsx | 36 +- frontend/src/shared/state/store.ts | 2 + frontend/src/shared/state/toolsSlice.ts | 11 + frontend/src/shared/state/updateSlice.ts | 79 ++++ frontend/src/types/electron.d.ts | 59 ++- scripts/build-app.sh | 11 +- 15 files changed, 739 insertions(+), 219 deletions(-) create mode 100644 frontend/src/shared/state/updateSlice.ts 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-lock.json b/electron/package-lock.json index 5ec26fd8..639f27d5 100644 --- a/electron/package-lock.json +++ b/electron/package-lock.json @@ -1,12 +1,12 @@ { "name": "openswarm", - "version": "1.0.0", + "version": "1.0.11", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "openswarm", - "version": "1.0.0", + "version": "1.0.11", "dependencies": { "electron-updater": "^6.3.0", "get-port": "^5.1.1" diff --git a/electron/package.json b/electron/package.json index 3c31d9a1..1928f04b 100644 --- a/electron/package.json +++ b/electron/package.json @@ -1,6 +1,6 @@ { "name": "openswarm", - "version": "1.0.0", + "version": "1.0.11", "description": "OpenSwarm — AI Agent Orchestrator", "main": "main.js", "scripts": { @@ -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, 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') && ( + + )} + {updateStatus === 'available' && ( + + )} + {updateStatus === 'downloaded' && ( + + )} + + + {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/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 "" From 61755e60eb06dd2f79d05e9fce9a3e3a9d4d28ba Mon Sep 17 00:00:00 2001 From: haikdc Date: Sun, 15 Mar 2026 23:39:45 -0700 Subject: [PATCH 2/2] [Haik]: ok now the release is in the same repo as the code. Now onto browser revamps --- electron/package-lock.json | 4 ++-- electron/package.json | 4 ++-- publish.sh | 17 +++++++++++++++++ 3 files changed, 21 insertions(+), 4 deletions(-) create mode 100755 publish.sh diff --git a/electron/package-lock.json b/electron/package-lock.json index 639f27d5..5ec26fd8 100644 --- a/electron/package-lock.json +++ b/electron/package-lock.json @@ -1,12 +1,12 @@ { "name": "openswarm", - "version": "1.0.11", + "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "openswarm", - "version": "1.0.11", + "version": "1.0.0", "dependencies": { "electron-updater": "^6.3.0", "get-port": "^5.1.1" diff --git a/electron/package.json b/electron/package.json index 1928f04b..758801b3 100644 --- a/electron/package.json +++ b/electron/package.json @@ -1,6 +1,6 @@ { "name": "openswarm", - "version": "1.0.11", + "version": "1.0.0", "description": "OpenSwarm — AI Agent Orchestrator", "main": "main.js", "scripts": { @@ -95,7 +95,7 @@ "publish": { "provider": "github", "owner": "openswarm-ai", - "repo": "production" + "repo": "openswarm" }, "afterSign": "scripts/notarize.js" } 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