feat: implement update management system with new update store and UI components
This commit is contained in:
Generated
+1
-1
@@ -2847,7 +2847,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openchamber-desktop"
|
name = "openchamber-desktop"
|
||||||
version = "1.2.7"
|
version = "1.2.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"axum",
|
"axum",
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
|
|
||||||
import { RiChat4Line, RiCodeLine, RiCommandLine, RiGitBranchLine, RiLayoutLeftLine, RiPlayListAddLine, RiQuestionLine, RiSettings3Line, RiTerminalBoxLine, type RemixiconComponentType } from '@remixicon/react';
|
import { RiChat4Line, RiCodeLine, RiCommandLine, RiGitBranchLine, RiLayoutLeftLine, RiPlayListAddLine, RiQuestionLine, RiSettings3Line, RiTerminalBoxLine, type RemixiconComponentType } from '@remixicon/react';
|
||||||
import { useUIStore, type MainTab } from '@/stores/useUIStore';
|
import { useUIStore, type MainTab } from '@/stores/useUIStore';
|
||||||
|
import { useUpdateStore } from '@/stores/useUpdateStore';
|
||||||
import { useConfigStore } from '@/stores/useConfigStore';
|
import { useConfigStore } from '@/stores/useConfigStore';
|
||||||
import { useSessionStore } from '@/stores/useSessionStore';
|
import { useSessionStore } from '@/stores/useSessionStore';
|
||||||
import { ContextUsageDisplay } from '@/components/ui/ContextUsageDisplay';
|
import { ContextUsageDisplay } from '@/components/ui/ContextUsageDisplay';
|
||||||
@@ -95,6 +96,7 @@ export const Header: React.FC = () => {
|
|||||||
const getContextUsage = useSessionStore((state) => state.getContextUsage);
|
const getContextUsage = useSessionStore((state) => state.getContextUsage);
|
||||||
const { isMobile } = useDeviceInfo();
|
const { isMobile } = useDeviceInfo();
|
||||||
const diffFileCount = useDiffFileCount();
|
const diffFileCount = useDiffFileCount();
|
||||||
|
const updateAvailable = useUpdateStore((state) => state.available);
|
||||||
|
|
||||||
const headerRef = React.useRef<HTMLElement | null>(null);
|
const headerRef = React.useRef<HTMLElement | null>(null);
|
||||||
|
|
||||||
@@ -491,13 +493,19 @@ export const Header: React.FC = () => {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={handleOpenSettings}
|
onClick={handleOpenSettings}
|
||||||
aria-label="Open settings"
|
aria-label="Open settings"
|
||||||
className={headerIconButtonClass}
|
className={cn(headerIconButtonClass, 'relative')}
|
||||||
>
|
>
|
||||||
<RiSettings3Line className="h-5 w-5" />
|
<RiSettings3Line className="h-5 w-5" />
|
||||||
|
{updateAvailable && (
|
||||||
|
<span
|
||||||
|
className="absolute top-1.5 right-1.5 h-2 w-2 rounded-full bg-primary"
|
||||||
|
aria-label="Update available"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
<p>Settings</p>
|
<p>{updateAvailable ? 'Settings (Update available)' : 'Settings'}</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
|
|||||||
import { DiffWorkerProvider } from '@/contexts/DiffWorkerProvider';
|
import { DiffWorkerProvider } from '@/contexts/DiffWorkerProvider';
|
||||||
|
|
||||||
import { useUIStore } from '@/stores/useUIStore';
|
import { useUIStore } from '@/stores/useUIStore';
|
||||||
|
import { useUpdateStore } from '@/stores/useUpdateStore';
|
||||||
import { useDeviceInfo } from '@/lib/device';
|
import { useDeviceInfo } from '@/lib/device';
|
||||||
import { useEdgeSwipe } from '@/hooks/useEdgeSwipe';
|
import { useEdgeSwipe } from '@/hooks/useEdgeSwipe';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
@@ -44,6 +45,15 @@ export const MainLayout: React.FC = () => {
|
|||||||
|
|
||||||
useEdgeSwipe({ enabled: true });
|
useEdgeSwipe({ enabled: true });
|
||||||
|
|
||||||
|
// Trigger update check 3 seconds after mount (for both mobile and desktop)
|
||||||
|
const checkForUpdates = useUpdateStore((state) => state.checkForUpdates);
|
||||||
|
React.useEffect(() => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
checkForUpdates();
|
||||||
|
}, 3000);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [checkForUpdates]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const previous = useUIStore.getState().isMobile;
|
const previous = useUIStore.getState().isMobile;
|
||||||
if (previous !== isMobile) {
|
if (previous !== isMobile) {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { toast } from 'sonner';
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { ErrorBoundary } from '../ui/ErrorBoundary';
|
import { ErrorBoundary } from '../ui/ErrorBoundary';
|
||||||
import { useUIStore } from '@/stores/useUIStore';
|
import { useUIStore } from '@/stores/useUIStore';
|
||||||
import { useUpdateCheck } from '@/hooks/useUpdateCheck';
|
import { useUpdateStore } from '@/stores/useUpdateStore';
|
||||||
import { UpdateDialog } from '../ui/UpdateDialog';
|
import { UpdateDialog } from '../ui/UpdateDialog';
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
|
||||||
|
|
||||||
@@ -27,11 +27,11 @@ export const Sidebar: React.FC<SidebarProps> = ({ isOpen, isMobile, children })
|
|||||||
const startWidthRef = React.useRef(sidebarWidth || SIDEBAR_CONTENT_WIDTH);
|
const startWidthRef = React.useRef(sidebarWidth || SIDEBAR_CONTENT_WIDTH);
|
||||||
const [updateDialogOpen, setUpdateDialogOpen] = React.useState(false);
|
const [updateDialogOpen, setUpdateDialogOpen] = React.useState(false);
|
||||||
|
|
||||||
const update = useUpdateCheck();
|
const updateStore = useUpdateStore();
|
||||||
const pendingMenuUpdateCheckRef = React.useRef(false);
|
const pendingMenuUpdateCheckRef = React.useRef(false);
|
||||||
|
|
||||||
const checkForUpdates = update.checkForUpdates;
|
const checkForUpdates = updateStore.checkForUpdates;
|
||||||
const { available, downloaded, checking } = update;
|
const { available, downloaded, checking } = updateStore;
|
||||||
|
|
||||||
const [isDesktopApp, setIsDesktopApp] = React.useState<boolean>(() => {
|
const [isDesktopApp, setIsDesktopApp] = React.useState<boolean>(() => {
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
@@ -267,14 +267,14 @@ export const Sidebar: React.FC<SidebarProps> = ({ isOpen, isMobile, children })
|
|||||||
<UpdateDialog
|
<UpdateDialog
|
||||||
open={updateDialogOpen}
|
open={updateDialogOpen}
|
||||||
onOpenChange={setUpdateDialogOpen}
|
onOpenChange={setUpdateDialogOpen}
|
||||||
info={update.info}
|
info={updateStore.info}
|
||||||
downloading={update.downloading}
|
downloading={updateStore.downloading}
|
||||||
downloaded={update.downloaded}
|
downloaded={updateStore.downloaded}
|
||||||
progress={update.progress}
|
progress={updateStore.progress}
|
||||||
error={update.error}
|
error={updateStore.error}
|
||||||
onDownload={update.downloadUpdate}
|
onDownload={updateStore.downloadUpdate}
|
||||||
onRestart={update.restartToUpdate}
|
onRestart={updateStore.restartToUpdate}
|
||||||
runtimeType={update.runtimeType}
|
runtimeType={updateStore.runtimeType}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { RiDownloadLine, RiGithubFill, RiLoaderLine, RiTwitterXFill } from '@remixicon/react';
|
||||||
|
import { useUpdateStore } from '@/stores/useUpdateStore';
|
||||||
|
import { UpdateDialog } from '@/components/ui/UpdateDialog';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const GITHUB_URL = 'https://github.com/btriapitsyn/openchamber';
|
||||||
|
|
||||||
|
export const AboutSettings: React.FC = () => {
|
||||||
|
const [updateDialogOpen, setUpdateDialogOpen] = React.useState(false);
|
||||||
|
const updateStore = useUpdateStore();
|
||||||
|
|
||||||
|
const currentVersion = updateStore.info?.currentVersion || 'unknown';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full space-y-6">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h3 className="typography-ui-header font-semibold text-foreground">
|
||||||
|
About OpenChamber
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Version and Update */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<div className="typography-ui-label text-muted-foreground">Version</div>
|
||||||
|
<div className="typography-ui-header font-mono">{currentVersion}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{updateStore.checking && (
|
||||||
|
<div className="flex items-center gap-2 text-muted-foreground">
|
||||||
|
<RiLoaderLine className="h-4 w-4 animate-spin" />
|
||||||
|
<span className="typography-meta">Checking...</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!updateStore.checking && updateStore.available && (
|
||||||
|
<button
|
||||||
|
onClick={() => setUpdateDialogOpen(true)}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-2 px-3 py-1.5 rounded-md',
|
||||||
|
'text-sm font-medium',
|
||||||
|
'bg-primary text-primary-foreground',
|
||||||
|
'hover:bg-primary/90',
|
||||||
|
'transition-colors'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<RiDownloadLine className="h-4 w-4" />
|
||||||
|
Update to {updateStore.info?.version}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!updateStore.checking && !updateStore.available && !updateStore.error && (
|
||||||
|
<span className="typography-meta text-muted-foreground">Up to date</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{updateStore.error && (
|
||||||
|
<p className="typography-meta text-destructive">{updateStore.error}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => updateStore.checkForUpdates()}
|
||||||
|
disabled={updateStore.checking}
|
||||||
|
className={cn(
|
||||||
|
'typography-meta text-muted-foreground hover:text-foreground',
|
||||||
|
'underline-offset-2 hover:underline',
|
||||||
|
'disabled:opacity-50 disabled:cursor-not-allowed'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Check for updates
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Links */}
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<a
|
||||||
|
href={GITHUB_URL}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-1.5 text-muted-foreground hover:text-foreground',
|
||||||
|
'typography-meta transition-colors'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<RiGithubFill className="h-4 w-4" />
|
||||||
|
<span>GitHub</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href="https://x.com/btriapitsyn"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-1.5 text-muted-foreground hover:text-foreground',
|
||||||
|
'typography-meta transition-colors'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<RiTwitterXFill className="h-4 w-4" />
|
||||||
|
<span>@btriapitsyn</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Update Dialog */}
|
||||||
|
<UpdateDialog
|
||||||
|
open={updateDialogOpen}
|
||||||
|
onOpenChange={setUpdateDialogOpen}
|
||||||
|
info={updateStore.info}
|
||||||
|
downloading={updateStore.downloading}
|
||||||
|
downloaded={updateStore.downloaded}
|
||||||
|
progress={updateStore.progress}
|
||||||
|
error={updateStore.error}
|
||||||
|
onDownload={updateStore.downloadUpdate}
|
||||||
|
onRestart={updateStore.restartToUpdate}
|
||||||
|
runtimeType={updateStore.runtimeType}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,14 +1,25 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { AppearanceSettings } from './AppearanceSettings';
|
import { AppearanceSettings } from './AppearanceSettings';
|
||||||
|
import { AboutSettings } from './AboutSettings';
|
||||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||||
|
import { useDeviceInfo } from '@/lib/device';
|
||||||
|
import { isWebRuntime } from '@/lib/desktop';
|
||||||
|
|
||||||
export const SettingsPage: React.FC = () => {
|
export const SettingsPage: React.FC = () => {
|
||||||
|
const { isMobile } = useDeviceInfo();
|
||||||
|
const showAbout = isMobile && isWebRuntime();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollableOverlay
|
<ScrollableOverlay
|
||||||
outerClassName="h-full"
|
outerClassName="h-full"
|
||||||
className="settings-page-body mx-auto max-w-3xl space-y-3 p-3 sm:space-y-6 sm:p-6"
|
className="settings-page-body mx-auto max-w-3xl space-y-3 p-3 sm:space-y-6 sm:p-6"
|
||||||
>
|
>
|
||||||
<AppearanceSettings />
|
<AppearanceSettings />
|
||||||
|
{showAbout && (
|
||||||
|
<div className="border-t border-border/40 pt-6">
|
||||||
|
<AboutSettings />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</ScrollableOverlay>
|
</ScrollableOverlay>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,273 +0,0 @@
|
|||||||
import { useEffect, useState, useCallback } from 'react';
|
|
||||||
import {
|
|
||||||
checkForDesktopUpdates,
|
|
||||||
downloadDesktopUpdate,
|
|
||||||
restartToApplyUpdate,
|
|
||||||
isDesktopRuntime,
|
|
||||||
isWebRuntime,
|
|
||||||
type UpdateInfo,
|
|
||||||
type UpdateProgress,
|
|
||||||
} from '@/lib/desktop';
|
|
||||||
|
|
||||||
export type UpdateState = {
|
|
||||||
checking: boolean;
|
|
||||||
available: boolean;
|
|
||||||
downloading: boolean;
|
|
||||||
downloaded: boolean;
|
|
||||||
info: UpdateInfo | null;
|
|
||||||
progress: UpdateProgress | null;
|
|
||||||
error: string | null;
|
|
||||||
/** Runtime type for conditional UI rendering */
|
|
||||||
runtimeType: 'desktop' | 'web' | 'vscode' | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type UseUpdateCheckReturn = UpdateState & {
|
|
||||||
checkForUpdates: () => Promise<void>;
|
|
||||||
downloadUpdate: () => Promise<void>;
|
|
||||||
restartToUpdate: () => Promise<void>;
|
|
||||||
dismiss: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
interface MockUpdateConfig {
|
|
||||||
currentVersion?: string;
|
|
||||||
newVersion?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set window.__OPENCHAMBER_MOCK_UPDATE__ = { currentVersion: '1.0.3', newVersion: '1.0.8' } to test with real changelog
|
|
||||||
declare global {
|
|
||||||
interface Window {
|
|
||||||
__OPENCHAMBER_MOCK_UPDATE__?: boolean | MockUpdateConfig;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const getMockConfig = (): MockUpdateConfig | null => {
|
|
||||||
if (typeof window === 'undefined') return null;
|
|
||||||
const mock = window.__OPENCHAMBER_MOCK_UPDATE__;
|
|
||||||
if (!mock) return null;
|
|
||||||
if (mock === true) return { currentVersion: '1.0.3', newVersion: '1.0.8' };
|
|
||||||
return mock;
|
|
||||||
};
|
|
||||||
|
|
||||||
const createMockUpdate = (config: MockUpdateConfig): UpdateState => ({
|
|
||||||
checking: false,
|
|
||||||
available: true,
|
|
||||||
downloading: false,
|
|
||||||
downloaded: false,
|
|
||||||
info: {
|
|
||||||
available: true,
|
|
||||||
version: config.newVersion ?? '99.0.0-test',
|
|
||||||
currentVersion: config.currentVersion ?? '0.0.0',
|
|
||||||
body: undefined,
|
|
||||||
},
|
|
||||||
progress: null,
|
|
||||||
error: null,
|
|
||||||
runtimeType: 'desktop',
|
|
||||||
});
|
|
||||||
|
|
||||||
const shouldMockUpdate = (): boolean => {
|
|
||||||
return getMockConfig() !== null;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check for web updates via server API
|
|
||||||
*/
|
|
||||||
async function checkForWebUpdates(): Promise<UpdateInfo | null> {
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/openchamber/update-check', {
|
|
||||||
method: 'GET',
|
|
||||||
headers: { Accept: 'application/json' },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Server responded with ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
return {
|
|
||||||
available: data.available ?? false,
|
|
||||||
version: data.version,
|
|
||||||
currentVersion: data.currentVersion ?? 'unknown',
|
|
||||||
body: data.body,
|
|
||||||
packageManager: data.packageManager,
|
|
||||||
updateCommand: data.updateCommand,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('Failed to check for web updates:', error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function detectRuntimeType(): 'desktop' | 'web' | 'vscode' | null {
|
|
||||||
if (isDesktopRuntime()) return 'desktop';
|
|
||||||
if (isWebRuntime()) return 'web';
|
|
||||||
// VSCode doesn't support updates through this mechanism
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useUpdateCheck = (checkOnMount = true): UseUpdateCheckReturn => {
|
|
||||||
const [state, setState] = useState<UpdateState>({
|
|
||||||
checking: false,
|
|
||||||
available: false,
|
|
||||||
downloading: false,
|
|
||||||
downloaded: false,
|
|
||||||
info: null,
|
|
||||||
progress: null,
|
|
||||||
error: null,
|
|
||||||
runtimeType: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Only check mock mode once at startup - no polling
|
|
||||||
const [mockMode] = useState(shouldMockUpdate);
|
|
||||||
const [mockState, setMockState] = useState<UpdateState | null>(() => {
|
|
||||||
if (shouldMockUpdate()) {
|
|
||||||
const config = getMockConfig();
|
|
||||||
return config ? createMockUpdate(config) : null;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Detect runtime type on mount
|
|
||||||
useEffect(() => {
|
|
||||||
const runtime = detectRuntimeType();
|
|
||||||
setState((prev) => ({ ...prev, runtimeType: runtime }));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const checkForUpdates = useCallback(async () => {
|
|
||||||
if (mockMode) {
|
|
||||||
const config = getMockConfig();
|
|
||||||
if (config) {
|
|
||||||
const mockUpdate = createMockUpdate(config);
|
|
||||||
mockUpdate.info = {
|
|
||||||
...mockUpdate.info!,
|
|
||||||
body: `## [${config.newVersion}] - 2025-01-01\n- Mock update for UI testing`,
|
|
||||||
};
|
|
||||||
setMockState(mockUpdate);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const runtime = detectRuntimeType();
|
|
||||||
if (!runtime) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setState((prev) => ({ ...prev, checking: true, error: null, runtimeType: runtime }));
|
|
||||||
|
|
||||||
try {
|
|
||||||
let info: UpdateInfo | null = null;
|
|
||||||
|
|
||||||
if (runtime === 'desktop') {
|
|
||||||
info = await checkForDesktopUpdates();
|
|
||||||
} else if (runtime === 'web') {
|
|
||||||
info = await checkForWebUpdates();
|
|
||||||
}
|
|
||||||
|
|
||||||
setState((prev) => ({
|
|
||||||
...prev,
|
|
||||||
checking: false,
|
|
||||||
available: info?.available ?? false,
|
|
||||||
info,
|
|
||||||
}));
|
|
||||||
} catch (error) {
|
|
||||||
setState((prev) => ({
|
|
||||||
...prev,
|
|
||||||
checking: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Failed to check for updates',
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}, [mockMode]);
|
|
||||||
|
|
||||||
const downloadUpdate = useCallback(async () => {
|
|
||||||
if (mockMode) {
|
|
||||||
setMockState((prev) => prev ? { ...prev, downloading: true } : prev);
|
|
||||||
let progress = 0;
|
|
||||||
const interval = setInterval(() => {
|
|
||||||
progress += 20;
|
|
||||||
setMockState((prev) => prev ? {
|
|
||||||
...prev,
|
|
||||||
progress: { downloaded: progress * 1000, total: 100000 }
|
|
||||||
} : prev);
|
|
||||||
if (progress >= 100) {
|
|
||||||
clearInterval(interval);
|
|
||||||
setMockState((prev) => prev ? {
|
|
||||||
...prev,
|
|
||||||
downloading: false,
|
|
||||||
downloaded: true,
|
|
||||||
progress: null,
|
|
||||||
} : prev);
|
|
||||||
}
|
|
||||||
}, 500);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// For web runtime, there's no download - user runs CLI command
|
|
||||||
// This is only applicable to desktop
|
|
||||||
if (!isDesktopRuntime() || !state.available) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setState((prev) => ({ ...prev, downloading: true, error: null, progress: null }));
|
|
||||||
|
|
||||||
try {
|
|
||||||
await downloadDesktopUpdate((progress) => {
|
|
||||||
setState((prev) => ({ ...prev, progress }));
|
|
||||||
});
|
|
||||||
setState((prev) => ({ ...prev, downloading: false, downloaded: true }));
|
|
||||||
} catch (error) {
|
|
||||||
setState((prev) => ({
|
|
||||||
...prev,
|
|
||||||
downloading: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Failed to download update',
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}, [mockMode, state.available]);
|
|
||||||
|
|
||||||
const restartToUpdate = useCallback(async () => {
|
|
||||||
if (mockMode) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only applicable to desktop
|
|
||||||
if (!isDesktopRuntime() || !state.downloaded) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await restartToApplyUpdate();
|
|
||||||
} catch (error) {
|
|
||||||
setState((prev) => ({
|
|
||||||
...prev,
|
|
||||||
error: error instanceof Error ? error.message : 'Failed to restart',
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}, [mockMode, state.downloaded]);
|
|
||||||
|
|
||||||
const dismiss = useCallback(() => {
|
|
||||||
if (mockMode) {
|
|
||||||
setMockState(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setState((prev) => ({ ...prev, available: false, downloaded: false, info: null }));
|
|
||||||
}, [mockMode]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const runtime = detectRuntimeType();
|
|
||||||
if (checkOnMount && (runtime === 'desktop' || runtime === 'web' || mockMode)) {
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
checkForUpdates();
|
|
||||||
}, 3000);
|
|
||||||
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}
|
|
||||||
}, [checkOnMount, checkForUpdates, mockMode]);
|
|
||||||
|
|
||||||
const currentState = (mockMode && mockState) ? mockState : state;
|
|
||||||
|
|
||||||
return {
|
|
||||||
...currentState,
|
|
||||||
checkForUpdates,
|
|
||||||
downloadUpdate,
|
|
||||||
restartToUpdate,
|
|
||||||
dismiss,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import type { UpdateInfo, UpdateProgress } from '@/lib/desktop';
|
||||||
|
import {
|
||||||
|
checkForDesktopUpdates,
|
||||||
|
downloadDesktopUpdate,
|
||||||
|
restartToApplyUpdate,
|
||||||
|
isDesktopRuntime,
|
||||||
|
isWebRuntime,
|
||||||
|
} from '@/lib/desktop';
|
||||||
|
|
||||||
|
export type UpdateState = {
|
||||||
|
checking: boolean;
|
||||||
|
available: boolean;
|
||||||
|
downloading: boolean;
|
||||||
|
downloaded: boolean;
|
||||||
|
info: UpdateInfo | null;
|
||||||
|
progress: UpdateProgress | null;
|
||||||
|
error: string | null;
|
||||||
|
runtimeType: 'desktop' | 'web' | 'vscode' | null;
|
||||||
|
lastChecked: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface UpdateStore extends UpdateState {
|
||||||
|
checkForUpdates: () => Promise<void>;
|
||||||
|
downloadUpdate: () => Promise<void>;
|
||||||
|
restartToUpdate: () => Promise<void>;
|
||||||
|
dismiss: () => void;
|
||||||
|
reset: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkForWebUpdates(): Promise<UpdateInfo | null> {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/openchamber/update-check', {
|
||||||
|
method: 'GET',
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Server responded with ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return {
|
||||||
|
available: data.available ?? false,
|
||||||
|
version: data.version,
|
||||||
|
currentVersion: data.currentVersion ?? 'unknown',
|
||||||
|
body: data.body,
|
||||||
|
packageManager: data.packageManager,
|
||||||
|
updateCommand: data.updateCommand,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to check for web updates:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectRuntimeType(): 'desktop' | 'web' | 'vscode' | null {
|
||||||
|
if (isDesktopRuntime()) return 'desktop';
|
||||||
|
if (isWebRuntime()) return 'web';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialState: UpdateState = {
|
||||||
|
checking: false,
|
||||||
|
available: false,
|
||||||
|
downloading: false,
|
||||||
|
downloaded: false,
|
||||||
|
info: null,
|
||||||
|
progress: null,
|
||||||
|
error: null,
|
||||||
|
runtimeType: null,
|
||||||
|
lastChecked: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useUpdateStore = create<UpdateStore>()((set, get) => ({
|
||||||
|
...initialState,
|
||||||
|
|
||||||
|
checkForUpdates: async () => {
|
||||||
|
const runtime = detectRuntimeType();
|
||||||
|
if (!runtime) return;
|
||||||
|
|
||||||
|
set({ checking: true, error: null, runtimeType: runtime });
|
||||||
|
|
||||||
|
try {
|
||||||
|
let info: UpdateInfo | null = null;
|
||||||
|
|
||||||
|
if (runtime === 'desktop') {
|
||||||
|
info = await checkForDesktopUpdates();
|
||||||
|
} else if (runtime === 'web') {
|
||||||
|
info = await checkForWebUpdates();
|
||||||
|
}
|
||||||
|
|
||||||
|
set({
|
||||||
|
checking: false,
|
||||||
|
available: info?.available ?? false,
|
||||||
|
info,
|
||||||
|
lastChecked: Date.now(),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
set({
|
||||||
|
checking: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Failed to check for updates',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
downloadUpdate: async () => {
|
||||||
|
const { available, runtimeType } = get();
|
||||||
|
|
||||||
|
// For web runtime, there's no download - user uses in-app update or CLI
|
||||||
|
if (runtimeType !== 'desktop' || !available) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
set({ downloading: true, error: null, progress: null });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await downloadDesktopUpdate((progress) => {
|
||||||
|
set({ progress });
|
||||||
|
});
|
||||||
|
set({ downloading: false, downloaded: true });
|
||||||
|
} catch (error) {
|
||||||
|
set({
|
||||||
|
downloading: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Failed to download update',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
restartToUpdate: async () => {
|
||||||
|
const { downloaded, runtimeType } = get();
|
||||||
|
|
||||||
|
if (runtimeType !== 'desktop' || !downloaded) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await restartToApplyUpdate();
|
||||||
|
} catch (error) {
|
||||||
|
set({
|
||||||
|
error: error instanceof Error ? error.message : 'Failed to restart',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
dismiss: () => {
|
||||||
|
set({ available: false, downloaded: false, info: null });
|
||||||
|
},
|
||||||
|
|
||||||
|
reset: () => {
|
||||||
|
set(initialState);
|
||||||
|
},
|
||||||
|
}));
|
||||||
Reference in New Issue
Block a user