mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-11 12:17:45 +02:00
Merge pull request #2 from openswarm-ai/haik/updates-v1
Haik/updates v1
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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 & 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>
|
||||
|
||||
|
||||
@@ -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 }}
|
||||
/>
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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; })
|
||||
|
||||
@@ -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;
|
||||
Vendored
+47
-12
@@ -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
@@ -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 -
|
||||
@@ -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 ""
|
||||
|
||||
Reference in New Issue
Block a user