Remove OTA update functionality
This commit is contained in:
4
test-results/.last-run.json
Normal file
4
test-results/.last-run.json
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"status": "failed",
|
||||||
|
"failedTests": []
|
||||||
|
}
|
||||||
@@ -18,7 +18,6 @@ import { MultiRunLauncher } from '@/components/multirun';
|
|||||||
import { DrawerProvider } from '@/contexts/DrawerContext';
|
import { DrawerProvider } from '@/contexts/DrawerContext';
|
||||||
|
|
||||||
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 { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
|
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
@@ -144,26 +143,6 @@ export const MainLayout: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}, [isRightSidebarOpen, isMobile]);
|
}, [isRightSidebarOpen, isMobile]);
|
||||||
|
|
||||||
// Trigger initial update check shortly after mount, then every hour.
|
|
||||||
const checkForUpdates = useUpdateStore((state) => state.checkForUpdates);
|
|
||||||
React.useEffect(() => {
|
|
||||||
const initialDelayMs = 3000;
|
|
||||||
const periodicIntervalMs = 60 * 60 * 1000;
|
|
||||||
|
|
||||||
const timer = window.setTimeout(() => {
|
|
||||||
checkForUpdates();
|
|
||||||
}, initialDelayMs);
|
|
||||||
|
|
||||||
const interval = window.setInterval(() => {
|
|
||||||
checkForUpdates();
|
|
||||||
}, periodicIntervalMs);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window.clearTimeout(timer);
|
|
||||||
window.clearInterval(interval);
|
|
||||||
};
|
|
||||||
}, [checkForUpdates]);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const previous = useUIStore.getState().isMobile;
|
const previous = useUIStore.getState().isMobile;
|
||||||
if (previous !== isMobile) {
|
if (previous !== isMobile) {
|
||||||
|
|||||||
@@ -34,13 +34,11 @@ import {
|
|||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
import { toast } from '@/components/ui';
|
import { toast } from '@/components/ui';
|
||||||
|
|
||||||
import { UpdateDialog } from '@/components/ui/UpdateDialog';
|
|
||||||
import { ProjectEditDialog } from '@/components/layout/ProjectEditDialog';
|
import { ProjectEditDialog } from '@/components/layout/ProjectEditDialog';
|
||||||
import { useUIStore } from '@/stores/useUIStore';
|
import { useUIStore } from '@/stores/useUIStore';
|
||||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||||
import { useSessionStore } from '@/stores/useSessionStore';
|
import { useSessionStore } from '@/stores/useSessionStore';
|
||||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||||
import { useUpdateStore } from '@/stores/useUpdateStore';
|
|
||||||
import { cn, formatDirectoryName, hasModifier } from '@/lib/utils';
|
import { cn, formatDirectoryName, hasModifier } from '@/lib/utils';
|
||||||
import { PROJECT_ICON_MAP, PROJECT_COLOR_MAP, getProjectIconImageUrl } from '@/lib/projectMeta';
|
import { PROJECT_ICON_MAP, PROJECT_COLOR_MAP, getProjectIconImageUrl } from '@/lib/projectMeta';
|
||||||
import { isDesktopLocalOriginActive, isTauriShell, requestDirectoryAccess } from '@/lib/desktop';
|
import { isDesktopLocalOriginActive, isTauriShell, requestDirectoryAccess } from '@/lib/desktop';
|
||||||
@@ -534,10 +532,7 @@ export const NavRail: React.FC<NavRailProps> = ({ className, mobile }) => {
|
|||||||
const currentSessionId = useSessionStore((s) => s.currentSessionId);
|
const currentSessionId = useSessionStore((s) => s.currentSessionId);
|
||||||
const availableWorktreesByProject = useSessionStore((s) => s.availableWorktreesByProject);
|
const availableWorktreesByProject = useSessionStore((s) => s.availableWorktreesByProject);
|
||||||
|
|
||||||
const updateStore = useUpdateStore();
|
const tauriIpcAvailable = React.useMemo(() => isTauriShell(), []);
|
||||||
const { available: updateAvailable, downloaded: updateDownloaded } = updateStore;
|
|
||||||
const [updateDialogOpen, setUpdateDialogOpen] = React.useState(false);
|
|
||||||
const navRailInteractionBlocked = isOverlayBlockingNavRailActions || updateDialogOpen;
|
|
||||||
|
|
||||||
const [editingProject, setEditingProject] = React.useState<{
|
const [editingProject, setEditingProject] = React.useState<{
|
||||||
id: string;
|
id: string;
|
||||||
@@ -548,8 +543,6 @@ export const NavRail: React.FC<NavRailProps> = ({ className, mobile }) => {
|
|||||||
iconBackground?: string | null;
|
iconBackground?: string | null;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
const tauriIpcAvailable = React.useMemo(() => isTauriShell(), []);
|
|
||||||
|
|
||||||
const formatLabel = React.useCallback(
|
const formatLabel = React.useCallback(
|
||||||
(project: ProjectEntry): string => {
|
(project: ProjectEntry): string => {
|
||||||
return (
|
return (
|
||||||
@@ -740,7 +733,6 @@ export const NavRail: React.FC<NavRailProps> = ({ className, mobile }) => {
|
|||||||
className={cn(
|
className={cn(
|
||||||
'flex h-full shrink-0 flex-col bg-[var(--surface-background)] overflow-hidden',
|
'flex h-full shrink-0 flex-col bg-[var(--surface-background)] overflow-hidden',
|
||||||
showExpandedContent ? 'items-stretch' : 'items-center',
|
showExpandedContent ? 'items-stretch' : 'items-center',
|
||||||
navRailInteractionBlocked && 'pointer-events-none',
|
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
style={{ width: expanded ? NAV_RAIL_EXPANDED_WIDTH : NAV_RAIL_WIDTH }}
|
style={{ width: expanded ? NAV_RAIL_EXPANDED_WIDTH : NAV_RAIL_WIDTH }}
|
||||||
@@ -789,7 +781,6 @@ export const NavRail: React.FC<NavRailProps> = ({ className, mobile }) => {
|
|||||||
<div className={cn('flex flex-col pb-3', showExpandedContent ? 'items-stretch px-1' : 'items-center px-1')}>
|
<div className={cn('flex flex-col pb-3', showExpandedContent ? 'items-stretch px-1' : 'items-center px-1')}>
|
||||||
<NavRailActionButton
|
<NavRailActionButton
|
||||||
onClick={handleAddProject}
|
onClick={handleAddProject}
|
||||||
disabled={navRailInteractionBlocked}
|
|
||||||
ariaLabel="Add project"
|
ariaLabel="Add project"
|
||||||
icon={<RiFolderAddLine className={navRailActionIconClass} />}
|
icon={<RiFolderAddLine className={navRailActionIconClass} />}
|
||||||
tooltipLabel="Add project"
|
tooltipLabel="Add project"
|
||||||
@@ -805,23 +796,9 @@ export const NavRail: React.FC<NavRailProps> = ({ className, mobile }) => {
|
|||||||
'shrink-0 w-full pt-3 pb-4 flex flex-col gap-1',
|
'shrink-0 w-full pt-3 pb-4 flex flex-col gap-1',
|
||||||
showExpandedContent ? 'items-stretch px-1' : 'items-center',
|
showExpandedContent ? 'items-stretch px-1' : 'items-center',
|
||||||
)}>
|
)}>
|
||||||
{(updateAvailable || updateDownloaded) && (
|
|
||||||
<NavRailActionButton
|
|
||||||
onClick={() => setUpdateDialogOpen(true)}
|
|
||||||
disabled={navRailInteractionBlocked}
|
|
||||||
ariaLabel="Update available"
|
|
||||||
icon={<RiDownloadLine className={navRailActionIconClass} />}
|
|
||||||
tooltipLabel="Update available"
|
|
||||||
buttonClassName={navRailActionButtonClass}
|
|
||||||
showExpandedContent={showExpandedContent}
|
|
||||||
actionTextVisible={actionTextVisible}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!mobile && (
|
{!mobile && (
|
||||||
<NavRailActionButton
|
<NavRailActionButton
|
||||||
onClick={toggleHelpDialog}
|
onClick={toggleHelpDialog}
|
||||||
disabled={navRailInteractionBlocked}
|
|
||||||
ariaLabel="Keyboard shortcuts"
|
ariaLabel="Keyboard shortcuts"
|
||||||
icon={<RiQuestionLine className={navRailActionIconClass} />}
|
icon={<RiQuestionLine className={navRailActionIconClass} />}
|
||||||
tooltipLabel="Shortcuts"
|
tooltipLabel="Shortcuts"
|
||||||
@@ -835,7 +812,6 @@ export const NavRail: React.FC<NavRailProps> = ({ className, mobile }) => {
|
|||||||
|
|
||||||
<NavRailActionButton
|
<NavRailActionButton
|
||||||
onClick={() => setSettingsDialogOpen(true)}
|
onClick={() => setSettingsDialogOpen(true)}
|
||||||
disabled={navRailInteractionBlocked}
|
|
||||||
ariaLabel="Settings"
|
ariaLabel="Settings"
|
||||||
icon={<RiSettings3Line className={navRailActionIconClass} />}
|
icon={<RiSettings3Line className={navRailActionIconClass} />}
|
||||||
tooltipLabel="Settings"
|
tooltipLabel="Settings"
|
||||||
@@ -850,7 +826,6 @@ export const NavRail: React.FC<NavRailProps> = ({ className, mobile }) => {
|
|||||||
{!mobile && (
|
{!mobile && (
|
||||||
<NavRailActionButton
|
<NavRailActionButton
|
||||||
onClick={toggleNavRail}
|
onClick={toggleNavRail}
|
||||||
disabled={navRailInteractionBlocked}
|
|
||||||
ariaLabel={expanded ? 'Collapse sidebar' : 'Expand sidebar'}
|
ariaLabel={expanded ? 'Collapse sidebar' : 'Expand sidebar'}
|
||||||
icon={expanded
|
icon={expanded
|
||||||
? <RiMenuFoldLine className={navRailActionIconClass} />
|
? <RiMenuFoldLine className={navRailActionIconClass} />
|
||||||
@@ -883,19 +858,6 @@ export const NavRail: React.FC<NavRailProps> = ({ className, mobile }) => {
|
|||||||
onSave={handleSaveProjectEdit}
|
onSave={handleSaveProjectEdit}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<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}
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,224 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { RiDiscordFill, RiDownloadLine, RiGithubFill, RiLoaderLine, RiTwitterXFill } from '@remixicon/react';
|
|
||||||
import { useUpdateStore } from '@/stores/useUpdateStore';
|
|
||||||
import { UpdateDialog } from '@/components/ui/UpdateDialog';
|
|
||||||
import { useDeviceInfo } from '@/lib/device';
|
|
||||||
import { toast } from '@/components/ui';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
import { ButtonSmall } from '@/components/ui/button-small';
|
|
||||||
|
|
||||||
const GITHUB_URL = 'https://github.com/btriapitsyn/openchamber';
|
|
||||||
|
|
||||||
const MIN_CHECKING_DURATION = 800; // ms
|
|
||||||
|
|
||||||
export const AboutSettings: React.FC = () => {
|
|
||||||
const [updateDialogOpen, setUpdateDialogOpen] = React.useState(false);
|
|
||||||
const [showChecking, setShowChecking] = React.useState(false);
|
|
||||||
const updateStore = useUpdateStore();
|
|
||||||
const { isMobile } = useDeviceInfo();
|
|
||||||
|
|
||||||
const currentVersion = updateStore.info?.currentVersion || 'unknown';
|
|
||||||
|
|
||||||
// Track if we initiated a check to show toast on completion
|
|
||||||
const didInitiateCheck = React.useRef(false);
|
|
||||||
|
|
||||||
// Ensure minimum visible duration for checking animation
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (updateStore.checking) {
|
|
||||||
setShowChecking(true);
|
|
||||||
didInitiateCheck.current = true;
|
|
||||||
} else if (showChecking) {
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
setShowChecking(false);
|
|
||||||
// Show toast if check completed with no update available
|
|
||||||
if (didInitiateCheck.current && !updateStore.available && !updateStore.error) {
|
|
||||||
toast.success('You are on the latest version');
|
|
||||||
didInitiateCheck.current = false;
|
|
||||||
}
|
|
||||||
}, MIN_CHECKING_DURATION);
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}
|
|
||||||
}, [updateStore.checking, showChecking, updateStore.available, updateStore.error]);
|
|
||||||
|
|
||||||
const isChecking = updateStore.checking || showChecking;
|
|
||||||
|
|
||||||
// Compact mobile layout for sidebar footer
|
|
||||||
if (isMobile) {
|
|
||||||
return (
|
|
||||||
<div className="w-full space-y-2">
|
|
||||||
{/* Version row with update status */}
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="typography-meta text-muted-foreground">
|
|
||||||
v{currentVersion}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
{!updateStore.available && !updateStore.error && (
|
|
||||||
<button
|
|
||||||
onClick={() => updateStore.checkForUpdates()}
|
|
||||||
disabled={isChecking}
|
|
||||||
className={cn(
|
|
||||||
'typography-meta text-muted-foreground/60 hover:text-muted-foreground disabled:cursor-default',
|
|
||||||
isChecking && 'animate-pulse [animation-duration:1s]'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
Check updates
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!isChecking && updateStore.available && (
|
|
||||||
<button
|
|
||||||
onClick={() => setUpdateDialogOpen(true)}
|
|
||||||
className="flex items-center gap-1 typography-meta text-[var(--primary-base)] hover:underline"
|
|
||||||
>
|
|
||||||
<RiDownloadLine className="h-3.5 w-3.5" />
|
|
||||||
Update
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{updateStore.error && (
|
|
||||||
<p className="typography-micro text-[var(--status-error)] truncate">{updateStore.error}</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Links row */}
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<a
|
|
||||||
href={GITHUB_URL}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="flex items-center gap-1 typography-meta text-muted-foreground hover:text-foreground transition-colors"
|
|
||||||
>
|
|
||||||
<RiGithubFill className="h-3.5 w-3.5" />
|
|
||||||
<span>GitHub</span>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<a
|
|
||||||
href="https://discord.gg/ZYRSdnwwKA"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="flex items-center gap-1 typography-meta text-muted-foreground hover:text-foreground transition-colors"
|
|
||||||
>
|
|
||||||
<RiDiscordFill className="h-3.5 w-3.5" />
|
|
||||||
<span>Discord</span>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<a
|
|
||||||
href="https://x.com/btriapitsyn"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="flex items-center gap-1 typography-meta text-muted-foreground hover:text-foreground transition-colors"
|
|
||||||
>
|
|
||||||
<RiTwitterXFill className="h-3.5 w-3.5" />
|
|
||||||
<span>@btriapitsyn</span>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// Desktop layout (redesigned)
|
|
||||||
return (
|
|
||||||
<div className="mb-8">
|
|
||||||
<div className="mb-3 px-1">
|
|
||||||
<h3 className="typography-ui-header font-semibold text-foreground">
|
|
||||||
About OpenChamber
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-lg bg-[var(--surface-elevated)]/70 overflow-hidden flex flex-col">
|
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 px-4 py-3 border-b border-[var(--surface-subtle)]">
|
|
||||||
<div className="flex min-w-0 flex-col">
|
|
||||||
<span className="typography-ui-label text-foreground">Version</span>
|
|
||||||
<span className="typography-meta text-muted-foreground font-mono">{currentVersion}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
{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 && (
|
|
||||||
<ButtonSmall
|
|
||||||
variant="default"
|
|
||||||
onClick={() => setUpdateDialogOpen(true)}
|
|
||||||
>
|
|
||||||
<RiDownloadLine className="h-4 w-4 mr-1" />
|
|
||||||
Update to {updateStore.info?.version}
|
|
||||||
</ButtonSmall>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!updateStore.checking && !updateStore.available && !updateStore.error && (
|
|
||||||
<span className="typography-meta text-muted-foreground">Up to date</span>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<ButtonSmall
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => updateStore.checkForUpdates()}
|
|
||||||
disabled={updateStore.checking}
|
|
||||||
>
|
|
||||||
Check for updates
|
|
||||||
</ButtonSmall>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{updateStore.error && (
|
|
||||||
<div className="px-3 py-2 border-b border-[var(--surface-subtle)]">
|
|
||||||
<p className="typography-meta text-[var(--status-error)]">{updateStore.error}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex items-center gap-4 px-4 py-4">
|
|
||||||
<a
|
|
||||||
href={GITHUB_URL}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="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="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>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<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,6 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { OpenChamberVisualSettings } from './OpenChamberVisualSettings';
|
import { OpenChamberVisualSettings } from './OpenChamberVisualSettings';
|
||||||
import { AboutSettings } from './AboutSettings';
|
|
||||||
import { SessionRetentionSettings } from './SessionRetentionSettings';
|
import { SessionRetentionSettings } from './SessionRetentionSettings';
|
||||||
import { MemoryLimitsSettings } from './MemoryLimitsSettings';
|
import { MemoryLimitsSettings } from './MemoryLimitsSettings';
|
||||||
import { DefaultsSettings } from './DefaultsSettings';
|
import { DefaultsSettings } from './DefaultsSettings';
|
||||||
@@ -11,7 +10,7 @@ import { OpenCodeCliSettings } from './OpenCodeCliSettings';
|
|||||||
import { KeyboardShortcutsSettings } from './KeyboardShortcutsSettings';
|
import { KeyboardShortcutsSettings } from './KeyboardShortcutsSettings';
|
||||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||||
import { useDeviceInfo } from '@/lib/device';
|
import { useDeviceInfo } from '@/lib/device';
|
||||||
import { isVSCodeRuntime, isWebRuntime } from '@/lib/desktop';
|
import { isVSCodeRuntime } from '@/lib/desktop';
|
||||||
import type { OpenChamberSection } from './types';
|
import type { OpenChamberSection } from './types';
|
||||||
|
|
||||||
interface OpenChamberPageProps {
|
interface OpenChamberPageProps {
|
||||||
@@ -21,7 +20,6 @@ interface OpenChamberPageProps {
|
|||||||
|
|
||||||
export const OpenChamberPage: React.FC<OpenChamberPageProps> = ({ section }) => {
|
export const OpenChamberPage: React.FC<OpenChamberPageProps> = ({ section }) => {
|
||||||
const { isMobile } = useDeviceInfo();
|
const { isMobile } = useDeviceInfo();
|
||||||
const showAbout = isMobile && isWebRuntime();
|
|
||||||
const isVSCode = isVSCodeRuntime();
|
const isVSCode = isVSCodeRuntime();
|
||||||
|
|
||||||
// If no section specified, show all (mobile/legacy behavior)
|
// If no section specified, show all (mobile/legacy behavior)
|
||||||
@@ -45,11 +43,6 @@ export const OpenChamberPage: React.FC<OpenChamberPageProps> = ({ section }) =>
|
|||||||
<div className="border-t border-border/40 pt-6">
|
<div className="border-t border-border/40 pt-6">
|
||||||
<SessionRetentionSettings />
|
<SessionRetentionSettings />
|
||||||
</div>
|
</div>
|
||||||
{showAbout && (
|
|
||||||
<div className="border-t border-border/40 pt-6">
|
|
||||||
<AboutSettings />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</ScrollableOverlay>
|
</ScrollableOverlay>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,547 +0,0 @@
|
|||||||
import React, { useState, useCallback, useEffect, useMemo } from 'react';
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogTitle,
|
|
||||||
} from '@/components/ui/dialog';
|
|
||||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
|
||||||
import { SimpleMarkdownRenderer } from '@/components/chat/MarkdownRenderer';
|
|
||||||
import { RiCheckLine, RiClipboardLine, RiDownloadCloudLine, RiDownloadLine, RiExternalLinkLine, RiLoaderLine, RiRestartLine, RiTerminalLine } from '@remixicon/react';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
import type { UpdateInfo, UpdateProgress } from '@/lib/desktop';
|
|
||||||
import { copyTextToClipboard } from '@/lib/clipboard';
|
|
||||||
|
|
||||||
type WebUpdateState = 'idle' | 'updating' | 'restarting' | 'reconnecting' | 'error';
|
|
||||||
|
|
||||||
interface UpdateDialogProps {
|
|
||||||
open: boolean;
|
|
||||||
onOpenChange: (open: boolean) => void;
|
|
||||||
info: UpdateInfo | null;
|
|
||||||
downloading: boolean;
|
|
||||||
downloaded: boolean;
|
|
||||||
progress: UpdateProgress | null;
|
|
||||||
error: string | null;
|
|
||||||
onDownload: () => void;
|
|
||||||
onRestart: () => void;
|
|
||||||
/** Runtime type to show different UI for desktop vs web */
|
|
||||||
runtimeType?: 'desktop' | 'web' | 'vscode' | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const GITHUB_RELEASES_URL = 'https://github.com/btriapitsyn/openchamber/releases';
|
|
||||||
|
|
||||||
type ChangelogSection = {
|
|
||||||
version: string;
|
|
||||||
date: string;
|
|
||||||
start: number;
|
|
||||||
end: number;
|
|
||||||
raw: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ParsedChangelog =
|
|
||||||
| {
|
|
||||||
kind: 'raw';
|
|
||||||
title: string;
|
|
||||||
content: string;
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
kind: 'sections';
|
|
||||||
title: string;
|
|
||||||
sections: Array<{ version: string; dateLabel: string; content: string }>;
|
|
||||||
};
|
|
||||||
|
|
||||||
function formatIsoDateForUI(isoDate: string): string {
|
|
||||||
const d = new Date(`${isoDate}T00:00:00`);
|
|
||||||
if (Number.isNaN(d.getTime())) {
|
|
||||||
return isoDate;
|
|
||||||
}
|
|
||||||
return new Intl.DateTimeFormat(undefined, {
|
|
||||||
year: 'numeric',
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
}).format(d);
|
|
||||||
}
|
|
||||||
|
|
||||||
function stripChangelogHeading(sectionRaw: string): string {
|
|
||||||
return sectionRaw.replace(/^## \[[^\]]+\] - \d{4}-\d{2}-\d{2}\s*\n?/, '').trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
function processChangelogMentions(content: string): string {
|
|
||||||
// Convert @username to markdown links so they can be styled via css
|
|
||||||
return content.replace(/(^|[^a-zA-Z0-9])@([a-zA-Z0-9-]+)/g, '$1[@$2](https://github.com/$2)');
|
|
||||||
}
|
|
||||||
|
|
||||||
function compareSemverDesc(a: string, b: string): number {
|
|
||||||
const pa = a.split('.').map((v) => Number.parseInt(v, 10));
|
|
||||||
const pb = b.split('.').map((v) => Number.parseInt(v, 10));
|
|
||||||
for (let i = 0; i < 3; i += 1) {
|
|
||||||
const da = Number.isFinite(pa[i]) ? (pa[i] as number) : 0;
|
|
||||||
const db = Number.isFinite(pb[i]) ? (pb[i] as number) : 0;
|
|
||||||
if (da !== db) {
|
|
||||||
return db - da;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseChangelogSections(body: string): ChangelogSection[] {
|
|
||||||
const re = /^## \[(\d+\.\d+\.\d+)\] - (\d{4}-\d{2}-\d{2})\s*$/gm;
|
|
||||||
const matches: Array<{ version: string; date: string; start: number }> = [];
|
|
||||||
|
|
||||||
let m: RegExpExecArray | null;
|
|
||||||
while ((m = re.exec(body)) !== null) {
|
|
||||||
matches.push({
|
|
||||||
version: m[1] ?? '',
|
|
||||||
date: m[2] ?? '',
|
|
||||||
start: m.index,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (matches.length === 0) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return matches.map((match, idx) => {
|
|
||||||
const end = matches[idx + 1]?.start ?? body.length;
|
|
||||||
const raw = body.slice(match.start, end).trim();
|
|
||||||
return { version: match.version, date: match.date, start: match.start, end, raw };
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
type InstallWebUpdateResult = {
|
|
||||||
success: boolean;
|
|
||||||
error?: string;
|
|
||||||
autoRestart?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
const WEB_UPDATE_POLL_INTERVAL_MS = 2000;
|
|
||||||
const WEB_UPDATE_MAX_WAIT_MS = 10 * 60 * 1000;
|
|
||||||
|
|
||||||
async function installWebUpdate(): Promise<InstallWebUpdateResult> {
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/openchamber/update-install', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const data = await response.json().catch(() => ({}));
|
|
||||||
return { success: false, error: data.error || `Server error: ${response.status}` };
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json().catch(() => ({}));
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
autoRestart: data.autoRestart !== false,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
return { success: false, error: error instanceof Error ? error.message : 'Failed to install update' };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function isServerReachable(): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
const response = await fetch('/health', {
|
|
||||||
method: 'GET',
|
|
||||||
headers: { Accept: 'application/json' },
|
|
||||||
});
|
|
||||||
return response.ok;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function waitForUpdateApplied(
|
|
||||||
previousVersion?: string,
|
|
||||||
maxAttempts = Math.ceil(WEB_UPDATE_MAX_WAIT_MS / WEB_UPDATE_POLL_INTERVAL_MS),
|
|
||||||
intervalMs = WEB_UPDATE_POLL_INTERVAL_MS,
|
|
||||||
): Promise<boolean> {
|
|
||||||
for (let i = 0; i < maxAttempts; i++) {
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/openchamber/update-check', {
|
|
||||||
method: 'GET',
|
|
||||||
headers: { Accept: 'application/json' },
|
|
||||||
});
|
|
||||||
if (response.ok) {
|
|
||||||
const data = await response.json().catch(() => null);
|
|
||||||
if (data && data.available === false) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
data &&
|
|
||||||
typeof data.currentVersion === 'string' &&
|
|
||||||
typeof previousVersion === 'string' &&
|
|
||||||
data.currentVersion !== previousVersion
|
|
||||||
) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
} else if ((response.status === 401 || response.status === 403) && await isServerReachable()) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Server may be restarting
|
|
||||||
}
|
|
||||||
await new Promise(resolve => setTimeout(resolve, intervalMs));
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const UpdateDialog: React.FC<UpdateDialogProps> = ({
|
|
||||||
open,
|
|
||||||
onOpenChange,
|
|
||||||
info,
|
|
||||||
downloading,
|
|
||||||
downloaded,
|
|
||||||
progress,
|
|
||||||
error,
|
|
||||||
onDownload,
|
|
||||||
onRestart,
|
|
||||||
runtimeType = 'desktop',
|
|
||||||
}) => {
|
|
||||||
const [copied, setCopied] = useState(false);
|
|
||||||
const [webUpdateState, setWebUpdateState] = useState<WebUpdateState>('idle');
|
|
||||||
const [webError, setWebError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const releaseUrl = info?.version
|
|
||||||
? `${GITHUB_RELEASES_URL}/tag/v${info.version}`
|
|
||||||
: GITHUB_RELEASES_URL;
|
|
||||||
|
|
||||||
const progressPercent = progress?.total
|
|
||||||
? Math.round((progress.downloaded / progress.total) * 100)
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
const isWebRuntime = runtimeType === 'web';
|
|
||||||
const updateCommand = info?.updateCommand || 'openchamber update';
|
|
||||||
|
|
||||||
// Reset state when dialog closes
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) {
|
|
||||||
setWebUpdateState('idle');
|
|
||||||
setWebError(null);
|
|
||||||
}
|
|
||||||
}, [open]);
|
|
||||||
|
|
||||||
const handleCopyCommand = async () => {
|
|
||||||
const result = await copyTextToClipboard(updateCommand);
|
|
||||||
if (result.ok) {
|
|
||||||
setCopied(true);
|
|
||||||
setTimeout(() => setCopied(false), 2000);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleOpenExternal = useCallback(async (url: string) => {
|
|
||||||
if (typeof window === 'undefined') return;
|
|
||||||
|
|
||||||
// Try Tauri backend
|
|
||||||
type TauriShell = { shell?: { open?: (url: string) => Promise<unknown> } };
|
|
||||||
const tauri = (window as unknown as { __TAURI__?: TauriShell }).__TAURI__;
|
|
||||||
if (tauri?.shell?.open) {
|
|
||||||
try {
|
|
||||||
await tauri.shell.open(url);
|
|
||||||
return;
|
|
||||||
} catch {
|
|
||||||
// fall through to window.open
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
window.open(url, '_blank', 'noopener,noreferrer');
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
const handleWebUpdate = useCallback(async () => {
|
|
||||||
setWebUpdateState('updating');
|
|
||||||
setWebError(null);
|
|
||||||
|
|
||||||
const result = await installWebUpdate();
|
|
||||||
|
|
||||||
if (!result.success) {
|
|
||||||
setWebUpdateState('error');
|
|
||||||
setWebError(result.error || 'Update failed');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result.autoRestart) {
|
|
||||||
setWebUpdateState('restarting');
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
|
||||||
}
|
|
||||||
|
|
||||||
setWebUpdateState('reconnecting');
|
|
||||||
|
|
||||||
const applied = await waitForUpdateApplied(info?.currentVersion);
|
|
||||||
|
|
||||||
if (applied) {
|
|
||||||
window.location.reload();
|
|
||||||
} else {
|
|
||||||
setWebUpdateState('error');
|
|
||||||
setWebError('Update is taking longer than expected. Wait a bit and refresh, or run: openchamber update');
|
|
||||||
}
|
|
||||||
}, [info?.currentVersion]);
|
|
||||||
|
|
||||||
const isWebUpdating = webUpdateState !== 'idle' && webUpdateState !== 'error';
|
|
||||||
|
|
||||||
const changelog = useMemo<ParsedChangelog | null>(() => {
|
|
||||||
if (!info?.body) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = info.body.trim();
|
|
||||||
if (!body) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const sections = parseChangelogSections(body);
|
|
||||||
|
|
||||||
if (sections.length === 0) {
|
|
||||||
return {
|
|
||||||
kind: 'raw',
|
|
||||||
title: "What's new",
|
|
||||||
content: processChangelogMentions(body),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const sorted = [...sections].sort((a, b) => compareSemverDesc(a.version, b.version));
|
|
||||||
return {
|
|
||||||
kind: 'sections',
|
|
||||||
title: "What's new",
|
|
||||||
sections: sorted.map((section) => ({
|
|
||||||
version: section.version,
|
|
||||||
dateLabel: formatIsoDateForUI(section.date),
|
|
||||||
content: processChangelogMentions(stripChangelogHeading(section.raw) || body),
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
}, [info?.body]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={isWebUpdating ? undefined : onOpenChange}>
|
|
||||||
<DialogContent className="max-w-4xl p-5 bg-background border-[var(--interactive-border)]" showCloseButton={true}>
|
|
||||||
|
|
||||||
{/* Header Section */}
|
|
||||||
<div className="flex items-center mb-1">
|
|
||||||
<DialogTitle className="flex items-center gap-2.5">
|
|
||||||
<RiDownloadCloudLine className="h-5 w-5 text-[var(--primary-base)]" />
|
|
||||||
<span className="text-lg font-semibold text-foreground">
|
|
||||||
{webUpdateState === 'restarting' || webUpdateState === 'reconnecting'
|
|
||||||
? 'Updating OpenChamber...'
|
|
||||||
: 'Update Available'}
|
|
||||||
</span>
|
|
||||||
</DialogTitle>
|
|
||||||
|
|
||||||
{/* Version Diff */}
|
|
||||||
{(info?.currentVersion || info?.version) && (
|
|
||||||
<div className="flex items-center gap-2 font-mono text-sm ml-3">
|
|
||||||
{info?.currentVersion && (
|
|
||||||
<span className="text-muted-foreground">{info.currentVersion}</span>
|
|
||||||
)}
|
|
||||||
{info?.currentVersion && info?.version && (
|
|
||||||
<span className="text-muted-foreground/50">→</span>
|
|
||||||
)}
|
|
||||||
{info?.version && (
|
|
||||||
<span className="text-[var(--primary-base)] font-medium">{info.version}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content Body */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
|
|
||||||
{/* Web update progress */}
|
|
||||||
{isWebRuntime && isWebUpdating && (
|
|
||||||
<div className="rounded-lg bg-[var(--surface-elevated)]/30 p-5 border border-[var(--surface-subtle)]">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<RiLoaderLine className="h-5 w-5 animate-spin text-[var(--primary-base)]" />
|
|
||||||
<div className="typography-ui-label text-foreground">
|
|
||||||
{webUpdateState === 'updating' && 'Installing update...'}
|
|
||||||
{webUpdateState === 'restarting' && 'Server restarting...'}
|
|
||||||
{webUpdateState === 'reconnecting' && 'Waiting for server...'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p className="mt-2 text-xs text-muted-foreground">
|
|
||||||
The page will reload automatically when the update is complete.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Changelog Rendering */}
|
|
||||||
{changelog && !isWebUpdating && (
|
|
||||||
<div className="rounded-lg border border-[var(--surface-subtle)] bg-[var(--surface-elevated)]/20 overflow-hidden">
|
|
||||||
<ScrollableOverlay
|
|
||||||
className="max-h-[400px] p-0"
|
|
||||||
fillContainer={false}
|
|
||||||
>
|
|
||||||
{changelog.kind === 'raw' ? (
|
|
||||||
<div
|
|
||||||
className="p-4 typography-markdown-body text-foreground leading-relaxed break-words [&_a]:!text-[var(--primary-base)] [&_a]:!no-underline hover:[&_a]:!underline"
|
|
||||||
onClickCapture={(e) => {
|
|
||||||
const target = e.target as HTMLElement;
|
|
||||||
const a = target.closest('a');
|
|
||||||
if (a && a.href) {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
void handleOpenExternal(a.href);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<SimpleMarkdownRenderer content={changelog.content} disableLinkSafety={true} />
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="divide-y divide-[var(--surface-subtle)]">
|
|
||||||
{changelog.sections.map((section) => (
|
|
||||||
<div key={section.version} className="p-4 hover:bg-background/40 transition-colors">
|
|
||||||
<div className="flex items-center gap-3 mb-3">
|
|
||||||
<span className="typography-ui-label font-mono text-[var(--primary-base)] bg-[var(--primary-base)]/10 px-1.5 py-0.5 rounded">
|
|
||||||
v{section.version}
|
|
||||||
</span>
|
|
||||||
<span className="text-sm font-medium text-muted-foreground">
|
|
||||||
{section.dateLabel}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className="typography-markdown-body text-foreground leading-relaxed break-words [&_a]:!text-[var(--primary-base)] [&_a]:!no-underline hover:[&_a]:!underline"
|
|
||||||
onClickCapture={(e) => {
|
|
||||||
const target = e.target as HTMLElement;
|
|
||||||
const a = target.closest('a');
|
|
||||||
if (a && a.href) {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
void handleOpenExternal(a.href);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<SimpleMarkdownRenderer content={section.content} disableLinkSafety={true} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</ScrollableOverlay>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Web runtime fallback command */}
|
|
||||||
{isWebRuntime && webUpdateState === 'error' && (
|
|
||||||
<div className="space-y-2 mt-4">
|
|
||||||
<div className="flex items-center gap-2 typography-meta text-muted-foreground">
|
|
||||||
<RiTerminalLine className="h-4 w-4" />
|
|
||||||
<span>Or update via terminal:</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 p-1 pl-3 bg-[var(--surface-elevated)]/50 rounded-md border border-[var(--surface-subtle)]">
|
|
||||||
<code className="flex-1 font-mono text-sm text-foreground overflow-x-auto whitespace-nowrap">
|
|
||||||
{updateCommand}
|
|
||||||
</code>
|
|
||||||
<button
|
|
||||||
onClick={handleCopyCommand}
|
|
||||||
className={cn(
|
|
||||||
'flex items-center justify-center p-2 rounded',
|
|
||||||
'text-muted-foreground hover:text-foreground hover:bg-[var(--interactive-hover)]',
|
|
||||||
'transition-colors',
|
|
||||||
copied && 'text-[var(--status-success)]'
|
|
||||||
)}
|
|
||||||
title={copied ? 'Copied!' : 'Copy command'}
|
|
||||||
>
|
|
||||||
{copied ? (
|
|
||||||
<RiCheckLine className="h-4 w-4" />
|
|
||||||
) : (
|
|
||||||
<RiClipboardLine className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Desktop progress bar */}
|
|
||||||
{!isWebRuntime && downloading && (
|
|
||||||
<div className="space-y-2 mt-4">
|
|
||||||
<div className="flex items-center justify-between text-sm">
|
|
||||||
<span className="text-muted-foreground">Downloading update payload...</span>
|
|
||||||
<span className="font-mono text-foreground">{progressPercent}%</span>
|
|
||||||
</div>
|
|
||||||
<div className="h-1.5 bg-[var(--surface-subtle)] rounded-full overflow-hidden">
|
|
||||||
<div
|
|
||||||
className="h-full bg-[var(--primary-base)] transition-all duration-300"
|
|
||||||
style={{ width: `${progressPercent}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Error display */}
|
|
||||||
{(error || webError) && (
|
|
||||||
<div className="p-3 mt-4 bg-[var(--status-error-background)] border border-[var(--status-error-border)] rounded-lg">
|
|
||||||
<p className="text-sm text-[var(--status-error)]">{error || webError}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Action Footer */}
|
|
||||||
<div className="mt-4 flex items-center justify-between gap-4">
|
|
||||||
<a
|
|
||||||
href={releaseUrl}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors shrink-0"
|
|
||||||
>
|
|
||||||
<RiExternalLinkLine className="h-4 w-4" />
|
|
||||||
GitHub
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<div className="flex-1 flex justify-end">
|
|
||||||
{/* Desktop Buttons */}
|
|
||||||
{!isWebRuntime && !downloaded && !downloading && (
|
|
||||||
<button
|
|
||||||
onClick={onDownload}
|
|
||||||
className="flex items-center justify-center gap-2 px-5 py-2 rounded-md text-sm font-medium bg-[var(--primary-base)] text-[var(--primary-foreground)] hover:opacity-90 transition-opacity"
|
|
||||||
>
|
|
||||||
<RiDownloadLine className="h-4 w-4" />
|
|
||||||
Download Update
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!isWebRuntime && downloading && (
|
|
||||||
<button
|
|
||||||
disabled
|
|
||||||
className="flex items-center justify-center gap-2 px-5 py-2 rounded-md text-sm font-medium bg-[var(--primary-base)]/50 text-[var(--primary-foreground)] cursor-not-allowed"
|
|
||||||
>
|
|
||||||
<RiLoaderLine className="h-4 w-4 animate-spin" />
|
|
||||||
Downloading...
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!isWebRuntime && downloaded && (
|
|
||||||
<button
|
|
||||||
onClick={onRestart}
|
|
||||||
className="flex items-center justify-center gap-2 px-5 py-2 rounded-md text-sm font-medium bg-[var(--status-success)] text-white hover:opacity-90 transition-opacity"
|
|
||||||
>
|
|
||||||
<RiRestartLine className="h-4 w-4" />
|
|
||||||
Restart to Update
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Web Buttons */}
|
|
||||||
{isWebRuntime && !isWebUpdating && (
|
|
||||||
<button
|
|
||||||
onClick={handleWebUpdate}
|
|
||||||
className="flex items-center justify-center gap-2 px-5 py-2 rounded-md text-sm font-medium bg-[var(--primary-base)] text-[var(--primary-foreground)] hover:opacity-90 transition-opacity"
|
|
||||||
>
|
|
||||||
<RiDownloadLine className="h-4 w-4" />
|
|
||||||
Update Now
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isWebRuntime && isWebUpdating && (
|
|
||||||
<button
|
|
||||||
disabled
|
|
||||||
className="flex items-center justify-center gap-2 px-5 py-2 rounded-md text-sm font-medium bg-[var(--primary-base)]/50 text-[var(--primary-foreground)] cursor-not-allowed"
|
|
||||||
>
|
|
||||||
<RiLoaderLine className="h-4 w-4 animate-spin" />
|
|
||||||
Updating...
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -49,7 +49,6 @@ import { UsagePage } from '@/components/sections/usage/UsagePage';
|
|||||||
import { GitPage } from '@/components/sections/git-identities/GitPage';
|
import { GitPage } from '@/components/sections/git-identities/GitPage';
|
||||||
import type { OpenChamberSection } from '@/components/sections/openchamber/types';
|
import type { OpenChamberSection } from '@/components/sections/openchamber/types';
|
||||||
import { OpenChamberPage } from '@/components/sections/openchamber/OpenChamberPage';
|
import { OpenChamberPage } from '@/components/sections/openchamber/OpenChamberPage';
|
||||||
import { AboutSettings } from '@/components/sections/openchamber/AboutSettings';
|
|
||||||
import { McpIcon } from '@/components/icons/McpIcon';
|
import { McpIcon } from '@/components/icons/McpIcon';
|
||||||
import { useDeviceInfo } from '@/lib/device';
|
import { useDeviceInfo } from '@/lib/device';
|
||||||
import { isDesktopShell, isVSCodeRuntime, isWebRuntime } from '@/lib/desktop';
|
import { isDesktopShell, isVSCodeRuntime, isWebRuntime } from '@/lib/desktop';
|
||||||
@@ -548,11 +547,6 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isMobile && runtimeCtx.isWeb && (
|
|
||||||
<div className="px-1.5 pt-2">
|
|
||||||
<AboutSettings />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { toast } from '@/components/ui';
|
|||||||
import { useSessionStore } from '@/stores/useSessionStore';
|
import { useSessionStore } from '@/stores/useSessionStore';
|
||||||
import { useUIStore } from '@/stores/useUIStore';
|
import { useUIStore } from '@/stores/useUIStore';
|
||||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||||
import { useUpdateStore } from '@/stores/useUpdateStore';
|
|
||||||
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
||||||
import { sessionEvents } from '@/lib/sessionEvents';
|
import { sessionEvents } from '@/lib/sessionEvents';
|
||||||
import { isTauriShell } from '@/lib/desktop';
|
import { isTauriShell } from '@/lib/desktop';
|
||||||
@@ -12,7 +11,6 @@ import { createWorktreeSession } from '@/lib/worktreeSessionCreator';
|
|||||||
import { showOpenCodeStatus } from '@/lib/openCodeStatus';
|
import { showOpenCodeStatus } from '@/lib/openCodeStatus';
|
||||||
|
|
||||||
const MENU_ACTION_EVENT = 'openchamber:menu-action';
|
const MENU_ACTION_EVENT = 'openchamber:menu-action';
|
||||||
const CHECK_FOR_UPDATES_EVENT = 'openchamber:check-for-updates';
|
|
||||||
|
|
||||||
type TauriEventApi = {
|
type TauriEventApi = {
|
||||||
listen?: (
|
listen?: (
|
||||||
@@ -59,35 +57,8 @@ export const useMenuActions = (
|
|||||||
setAboutDialogOpen,
|
setAboutDialogOpen,
|
||||||
} = useUIStore();
|
} = useUIStore();
|
||||||
const { addProject } = useProjectsStore();
|
const { addProject } = useProjectsStore();
|
||||||
const checkForUpdates = useUpdateStore((state) => state.checkForUpdates);
|
|
||||||
const { requestAccess, startAccessing } = useFileSystemAccess();
|
const { requestAccess, startAccessing } = useFileSystemAccess();
|
||||||
const { setThemeMode } = useThemeSystem();
|
const { setThemeMode } = useThemeSystem();
|
||||||
const checkUpdatesInFlightRef = React.useRef(false);
|
|
||||||
|
|
||||||
const handleCheckForUpdates = React.useCallback(() => {
|
|
||||||
if (checkUpdatesInFlightRef.current) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
checkUpdatesInFlightRef.current = true;
|
|
||||||
|
|
||||||
void checkForUpdates()
|
|
||||||
.then(() => {
|
|
||||||
const { available, error } = useUpdateStore.getState();
|
|
||||||
if (error) {
|
|
||||||
toast.error('Failed to check for updates', {
|
|
||||||
description: error,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!available) {
|
|
||||||
toast.success('You are on the latest version');
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
checkUpdatesInFlightRef.current = false;
|
|
||||||
});
|
|
||||||
}, [checkForUpdates]);
|
|
||||||
|
|
||||||
const handleChangeWorkspace = React.useCallback(() => {
|
const handleChangeWorkspace = React.useCallback(() => {
|
||||||
if (isTauriShell()) {
|
if (isTauriShell()) {
|
||||||
@@ -244,17 +215,11 @@ export const useMenuActions = (
|
|||||||
handleAction(action);
|
handleAction(action);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCheckForUpdatesEvent = () => {
|
|
||||||
handleCheckForUpdates();
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener(MENU_ACTION_EVENT, handleMenuAction);
|
window.addEventListener(MENU_ACTION_EVENT, handleMenuAction);
|
||||||
window.addEventListener(CHECK_FOR_UPDATES_EVENT, handleCheckForUpdatesEvent);
|
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener(MENU_ACTION_EVENT, handleMenuAction);
|
window.removeEventListener(MENU_ACTION_EVENT, handleMenuAction);
|
||||||
window.removeEventListener(CHECK_FOR_UPDATES_EVENT, handleCheckForUpdatesEvent);
|
|
||||||
};
|
};
|
||||||
}, [handleAction, handleCheckForUpdates]);
|
}, [handleAction]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (typeof window === 'undefined') return;
|
if (typeof window === 'undefined') return;
|
||||||
@@ -263,7 +228,6 @@ export const useMenuActions = (
|
|||||||
if (typeof listen !== 'function') return;
|
if (typeof listen !== 'function') return;
|
||||||
|
|
||||||
let unlistenMenu: null | (() => void | Promise<void>) = null;
|
let unlistenMenu: null | (() => void | Promise<void>) = null;
|
||||||
let unlistenUpdate: null | (() => void | Promise<void>) = null;
|
|
||||||
|
|
||||||
listen('openchamber:menu-action', (evt) => {
|
listen('openchamber:menu-action', (evt) => {
|
||||||
const action = evt?.payload;
|
const action = evt?.payload;
|
||||||
@@ -277,16 +241,6 @@ export const useMenuActions = (
|
|||||||
// ignore
|
// ignore
|
||||||
});
|
});
|
||||||
|
|
||||||
listen('openchamber:check-for-updates', () => {
|
|
||||||
window.dispatchEvent(new Event(CHECK_FOR_UPDATES_EVENT));
|
|
||||||
})
|
|
||||||
.then((fn) => {
|
|
||||||
unlistenUpdate = fn;
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
// ignore
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
const cleanup = async () => {
|
const cleanup = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -295,12 +249,6 @@ export const useMenuActions = (
|
|||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
try {
|
|
||||||
const b = unlistenUpdate?.();
|
|
||||||
if (b instanceof Promise) await b;
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
void cleanup();
|
void cleanup();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,22 +5,6 @@ export type AssistantNotificationPayload = {
|
|||||||
body?: string;
|
body?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type UpdateInfo = {
|
|
||||||
available: boolean;
|
|
||||||
version?: string;
|
|
||||||
currentVersion: string;
|
|
||||||
body?: string;
|
|
||||||
date?: string;
|
|
||||||
// Web-specific fields
|
|
||||||
packageManager?: string;
|
|
||||||
updateCommand?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type UpdateProgress = {
|
|
||||||
downloaded: number;
|
|
||||||
total?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type SkillCatalogConfig = {
|
export type SkillCatalogConfig = {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -309,98 +293,6 @@ export const sendAssistantCompletionNotification = async (
|
|||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const checkForDesktopUpdates = async (): Promise<UpdateInfo | null> => {
|
|
||||||
if (!isTauriShell() || !isDesktopLocalOriginActive()) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const tauri = (window as unknown as { __TAURI__?: TauriGlobal }).__TAURI__;
|
|
||||||
const info = await tauri?.core?.invoke?.('desktop_check_for_updates');
|
|
||||||
return info as UpdateInfo;
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('Failed to check for updates (tauri)', error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const downloadDesktopUpdate = async (
|
|
||||||
onProgress?: (progress: UpdateProgress) => void
|
|
||||||
): Promise<boolean> => {
|
|
||||||
if (!isTauriShell() || !isDesktopLocalOriginActive()) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const tauri = (window as unknown as { __TAURI__?: TauriGlobal }).__TAURI__;
|
|
||||||
let unlisten: null | (() => void | Promise<void>) = null;
|
|
||||||
let downloaded = 0;
|
|
||||||
let total: number | undefined;
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (typeof onProgress === 'function' && tauri?.event?.listen) {
|
|
||||||
unlisten = await tauri.event.listen('openchamber:update-progress', (evt) => {
|
|
||||||
const payload = evt?.payload;
|
|
||||||
if (!payload || typeof payload !== 'object') return;
|
|
||||||
const data = payload as { event?: unknown; data?: unknown };
|
|
||||||
const eventName = typeof data.event === 'string' ? data.event : null;
|
|
||||||
const eventData = data.data && typeof data.data === 'object' ? (data.data as Record<string, unknown>) : null;
|
|
||||||
|
|
||||||
if (eventName === 'Started') {
|
|
||||||
downloaded = 0;
|
|
||||||
total = typeof eventData?.contentLength === 'number' ? (eventData.contentLength as number) : undefined;
|
|
||||||
onProgress({ downloaded, total });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (eventName === 'Progress') {
|
|
||||||
const d = eventData?.downloaded;
|
|
||||||
const t = eventData?.total;
|
|
||||||
if (typeof d === 'number') downloaded = d;
|
|
||||||
if (typeof t === 'number') total = t;
|
|
||||||
onProgress({ downloaded, total });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (eventName === 'Finished') {
|
|
||||||
onProgress({ downloaded, total });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
await tauri?.core?.invoke?.('desktop_download_and_install_update');
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('Failed to download update (tauri)', error);
|
|
||||||
return false;
|
|
||||||
} finally {
|
|
||||||
if (unlisten) {
|
|
||||||
try {
|
|
||||||
const result = unlisten();
|
|
||||||
if (result instanceof Promise) {
|
|
||||||
await result;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// ignored
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const restartToApplyUpdate = async (): Promise<boolean> => {
|
|
||||||
if (!isTauriShell() || !isDesktopLocalOriginActive()) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const tauri = (window as unknown as { __TAURI__?: TauriGlobal }).__TAURI__;
|
|
||||||
await tauri?.core?.invoke?.('desktop_restart');
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('Failed to restart for update (tauri)', error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const openDesktopPath = async (path: string, app?: string | null): Promise<boolean> => {
|
export const openDesktopPath = async (path: string, app?: string | null): Promise<boolean> => {
|
||||||
if (!isTauriShell() || !isDesktopLocalOriginActive()) {
|
if (!isTauriShell() || !isDesktopLocalOriginActive()) {
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -1,164 +0,0 @@
|
|||||||
import { create } from 'zustand';
|
|
||||||
import type { UpdateInfo, UpdateProgress } from '@/lib/desktop';
|
|
||||||
import {
|
|
||||||
checkForDesktopUpdates,
|
|
||||||
downloadDesktopUpdate,
|
|
||||||
restartToApplyUpdate,
|
|
||||||
isDesktopLocalOriginActive,
|
|
||||||
isTauriShell,
|
|
||||||
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 (isTauriShell()) {
|
|
||||||
// Only use Tauri updater when we're on the local instance.
|
|
||||||
// When viewing a remote host inside the desktop shell, treat update as web update.
|
|
||||||
return isDesktopLocalOriginActive() ? 'desktop' : 'web';
|
|
||||||
}
|
|
||||||
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 {
|
|
||||||
const ok = await downloadDesktopUpdate((progress) => {
|
|
||||||
set({ progress });
|
|
||||||
});
|
|
||||||
if (!ok) {
|
|
||||||
throw new Error('Desktop update only works on Local instance');
|
|
||||||
}
|
|
||||||
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 {
|
|
||||||
const ok = await restartToApplyUpdate();
|
|
||||||
if (!ok) {
|
|
||||||
throw new Error('Desktop restart only works on Local instance');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
set({
|
|
||||||
error: error instanceof Error ? error.message : 'Failed to restart',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
dismiss: () => {
|
|
||||||
set({ available: false, downloaded: false, info: null });
|
|
||||||
},
|
|
||||||
|
|
||||||
reset: () => {
|
|
||||||
set(initialState);
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
128
web/bin/cli.js
128
web/bin/cli.js
@@ -138,7 +138,6 @@ COMMANDS:
|
|||||||
stop Stop running instance(s)
|
stop Stop running instance(s)
|
||||||
restart Stop and start the server
|
restart Stop and start the server
|
||||||
status Show server status
|
status Show server status
|
||||||
update Check for and install updates
|
|
||||||
|
|
||||||
OPTIONS:
|
OPTIONS:
|
||||||
-p, --port Web server port (default: ${DEFAULT_PORT})
|
-p, --port Web server port (default: ${DEFAULT_PORT})
|
||||||
@@ -160,7 +159,6 @@ EXAMPLES:
|
|||||||
openchamber stop # Stop all running instances
|
openchamber stop # Stop all running instances
|
||||||
openchamber stop --port 3000 # Stop specific instance
|
openchamber stop --port 3000 # Stop specific instance
|
||||||
openchamber status # Check status
|
openchamber status # Check status
|
||||||
openchamber update # Update to latest version
|
|
||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -862,132 +860,6 @@ const commands = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async update() {
|
|
||||||
const os = await import('os');
|
|
||||||
const tmpDir = os.tmpdir();
|
|
||||||
const packageManagerPath = path.join(__dirname, '..', 'server', 'lib', 'package-manager.js');
|
|
||||||
const {
|
|
||||||
checkForUpdates,
|
|
||||||
executeUpdate,
|
|
||||||
detectPackageManager,
|
|
||||||
getCurrentVersion,
|
|
||||||
} = await importFromFilePath(packageManagerPath);
|
|
||||||
|
|
||||||
// Check for running instances before update
|
|
||||||
let runningInstances = [];
|
|
||||||
try {
|
|
||||||
const files = fs.readdirSync(tmpDir);
|
|
||||||
const pidFiles = files.filter(file => file.startsWith('openchamber-') && file.endsWith('.pid'));
|
|
||||||
|
|
||||||
for (const file of pidFiles) {
|
|
||||||
const port = parseInt(file.replace('openchamber-', '').replace('.pid', ''));
|
|
||||||
if (!isNaN(port)) {
|
|
||||||
const pidFilePath = path.join(tmpDir, file);
|
|
||||||
const instanceFilePath = path.join(tmpDir, `openchamber-${port}.json`);
|
|
||||||
const pid = readPidFile(pidFilePath);
|
|
||||||
|
|
||||||
if (pid && isProcessRunning(pid)) {
|
|
||||||
const storedOptions = readInstanceOptions(instanceFilePath);
|
|
||||||
runningInstances.push({
|
|
||||||
port,
|
|
||||||
pid,
|
|
||||||
pidFilePath,
|
|
||||||
instanceFilePath,
|
|
||||||
storedOptions: storedOptions || { port, daemon: true },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// Ignore
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('Checking for updates...');
|
|
||||||
console.log(`Current version: ${getCurrentVersion()}`);
|
|
||||||
|
|
||||||
const updateInfo = await checkForUpdates();
|
|
||||||
|
|
||||||
if (updateInfo.error) {
|
|
||||||
console.error(`Error: ${updateInfo.error}`);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!updateInfo.available) {
|
|
||||||
console.log('\nYou are running the latest version.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`\nNew version available: ${updateInfo.version}`);
|
|
||||||
|
|
||||||
if (updateInfo.body) {
|
|
||||||
console.log('\nChangelog:');
|
|
||||||
console.log('─'.repeat(40));
|
|
||||||
// Simple formatting for CLI
|
|
||||||
const formatted = updateInfo.body
|
|
||||||
.replace(/^## \[(\d+\.\d+\.\d+)\] - \d{4}-\d{2}-\d{2}/gm, '\nv$1')
|
|
||||||
.replace(/^### /gm, '\n')
|
|
||||||
.replace(/^- /gm, ' • ');
|
|
||||||
console.log(formatted);
|
|
||||||
console.log('─'.repeat(40));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stop running instances before update
|
|
||||||
if (runningInstances.length > 0) {
|
|
||||||
console.log(`\nStopping ${runningInstances.length} running instance(s) before update...`);
|
|
||||||
for (const instance of runningInstances) {
|
|
||||||
try {
|
|
||||||
await requestServerShutdown(instance.port);
|
|
||||||
process.kill(instance.pid, 'SIGTERM');
|
|
||||||
let attempts = 0;
|
|
||||||
while (isProcessRunning(instance.pid) && attempts < 20) {
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 250));
|
|
||||||
attempts++;
|
|
||||||
}
|
|
||||||
if (isProcessRunning(instance.pid)) {
|
|
||||||
process.kill(instance.pid, 'SIGKILL');
|
|
||||||
}
|
|
||||||
removePidFile(instance.pidFilePath);
|
|
||||||
console.log(` Stopped instance on port ${instance.port}`);
|
|
||||||
} catch (error) {
|
|
||||||
console.warn(` Warning: Could not stop instance on port ${instance.port}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const pm = detectPackageManager();
|
|
||||||
console.log(`\nDetected package manager: ${pm}`);
|
|
||||||
console.log('Installing update...\n');
|
|
||||||
|
|
||||||
const result = executeUpdate(pm);
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
console.log('\nUpdate successful!');
|
|
||||||
|
|
||||||
// Restart previously running instances
|
|
||||||
if (runningInstances.length > 0) {
|
|
||||||
console.log(`\nRestarting ${runningInstances.length} instance(s)...`);
|
|
||||||
for (const instance of runningInstances) {
|
|
||||||
try {
|
|
||||||
// Force daemon mode for restart after update
|
|
||||||
const restartOptions = {
|
|
||||||
...instance.storedOptions,
|
|
||||||
daemon: true,
|
|
||||||
};
|
|
||||||
await commands.serve(restartOptions);
|
|
||||||
console.log(` Restarted instance on port ${instance.port}`);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(` Failed to restart instance on port ${instance.port}: ${error.message}`);
|
|
||||||
console.log(` Run manually: openchamber serve --port ${instance.port} --daemon`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.error('\nUpdate failed.');
|
|
||||||
console.error(`Exit code: ${result.exitCode}`);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
|
|||||||
@@ -6675,203 +6675,6 @@ async function main(options = {}) {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/openchamber/update-check', async (_req, res) => {
|
|
||||||
try {
|
|
||||||
const { checkForUpdates } = await import('./lib/package-manager.js');
|
|
||||||
const updateInfo = await checkForUpdates();
|
|
||||||
res.json(updateInfo);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to check for updates:', error);
|
|
||||||
res.status(500).json({
|
|
||||||
available: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Failed to check for updates',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
app.post('/api/openchamber/update-install', async (_req, res) => {
|
|
||||||
try {
|
|
||||||
const { spawn: spawnChild } = await import('child_process');
|
|
||||||
const {
|
|
||||||
checkForUpdates,
|
|
||||||
getUpdateCommand,
|
|
||||||
detectPackageManager,
|
|
||||||
} = await import('./lib/package-manager.js');
|
|
||||||
|
|
||||||
// Verify update is available
|
|
||||||
const updateInfo = await checkForUpdates();
|
|
||||||
if (!updateInfo.available) {
|
|
||||||
return res.status(400).json({ error: 'No update available' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const pm = detectPackageManager();
|
|
||||||
const updateCmd = getUpdateCommand(pm);
|
|
||||||
const isContainer =
|
|
||||||
fs.existsSync('/.dockerenv') ||
|
|
||||||
Boolean(process.env.CONTAINER) ||
|
|
||||||
process.env.container === 'docker';
|
|
||||||
|
|
||||||
if (isContainer) {
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
message: 'Update starting, server will stay online',
|
|
||||||
version: updateInfo.version,
|
|
||||||
packageManager: pm,
|
|
||||||
autoRestart: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
console.log(`\nInstalling update using ${pm} (container mode)...`);
|
|
||||||
console.log(`Running: ${updateCmd}`);
|
|
||||||
|
|
||||||
const shell = process.platform === 'win32' ? (process.env.ComSpec || 'cmd.exe') : 'sh';
|
|
||||||
const shellFlag = process.platform === 'win32' ? '/c' : '-c';
|
|
||||||
const child = spawnChild(shell, [shellFlag, updateCmd], {
|
|
||||||
detached: true,
|
|
||||||
stdio: 'ignore',
|
|
||||||
env: process.env,
|
|
||||||
});
|
|
||||||
child.unref();
|
|
||||||
}, 500);
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get current server port for restart
|
|
||||||
const currentPort = server.address()?.port || 3000;
|
|
||||||
|
|
||||||
// Try to read stored instance options for restart
|
|
||||||
const tmpDir = os.tmpdir();
|
|
||||||
const instanceFilePath = path.join(tmpDir, `openchamber-${currentPort}.json`);
|
|
||||||
let storedOptions = { port: currentPort, daemon: true };
|
|
||||||
try {
|
|
||||||
const content = await fs.promises.readFile(instanceFilePath, 'utf8');
|
|
||||||
storedOptions = JSON.parse(content);
|
|
||||||
} catch {
|
|
||||||
// Use defaults
|
|
||||||
}
|
|
||||||
|
|
||||||
const isWindows = process.platform === 'win32';
|
|
||||||
|
|
||||||
const quotePosix = (value) => `'${String(value).replace(/'/g, "'\\''")}'`;
|
|
||||||
const quoteCmd = (value) => {
|
|
||||||
const stringValue = String(value);
|
|
||||||
return `"${stringValue.replace(/"/g, '""')}"`;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Build restart command using explicit runtime + CLI path.
|
|
||||||
// Avoids relying on `openchamber` being in PATH for service environments.
|
|
||||||
const cliPath = path.resolve(__dirname, '..', 'bin', 'cli.js');
|
|
||||||
const restartParts = [
|
|
||||||
isWindows ? quoteCmd(process.execPath) : quotePosix(process.execPath),
|
|
||||||
isWindows ? quoteCmd(cliPath) : quotePosix(cliPath),
|
|
||||||
'serve',
|
|
||||||
'--port',
|
|
||||||
String(storedOptions.port),
|
|
||||||
'--daemon',
|
|
||||||
];
|
|
||||||
let restartCmdPrimary = restartParts.join(' ');
|
|
||||||
let restartCmdFallback = `openchamber serve --port ${storedOptions.port} --daemon`;
|
|
||||||
if (storedOptions.uiPassword) {
|
|
||||||
if (isWindows) {
|
|
||||||
// Escape for cmd.exe quoted argument
|
|
||||||
const escapedPw = storedOptions.uiPassword.replace(/"/g, '""');
|
|
||||||
restartCmdPrimary += ` --ui-password "${escapedPw}"`;
|
|
||||||
restartCmdFallback += ` --ui-password "${escapedPw}"`;
|
|
||||||
} else {
|
|
||||||
// Escape for POSIX single-quoted argument
|
|
||||||
const escapedPw = storedOptions.uiPassword.replace(/'/g, "'\\''");
|
|
||||||
restartCmdPrimary += ` --ui-password '${escapedPw}'`;
|
|
||||||
restartCmdFallback += ` --ui-password '${escapedPw}'`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const restartCmd = `(${restartCmdPrimary}) || (${restartCmdFallback})`;
|
|
||||||
|
|
||||||
// Respond immediately - update will happen after response
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
message: 'Update starting, server will restart shortly',
|
|
||||||
version: updateInfo.version,
|
|
||||||
packageManager: pm,
|
|
||||||
autoRestart: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Give time for response to be sent
|
|
||||||
setTimeout(() => {
|
|
||||||
console.log(`\nInstalling update using ${pm}...`);
|
|
||||||
console.log(`Running: ${updateCmd}`);
|
|
||||||
|
|
||||||
// Create a script that will:
|
|
||||||
// 1. Wait for current process to exit
|
|
||||||
// 2. Run the update
|
|
||||||
// 3. Restart the server with original options
|
|
||||||
const shell = isWindows ? (process.env.ComSpec || 'cmd.exe') : 'sh';
|
|
||||||
const shellFlag = isWindows ? '/c' : '-c';
|
|
||||||
const script = isWindows
|
|
||||||
? `
|
|
||||||
timeout /t 2 /nobreak >nul
|
|
||||||
${updateCmd}
|
|
||||||
if %ERRORLEVEL% EQU 0 (
|
|
||||||
echo Update successful, restarting OpenChamber...
|
|
||||||
${restartCmd}
|
|
||||||
) else (
|
|
||||||
echo Update failed
|
|
||||||
exit /b 1
|
|
||||||
)
|
|
||||||
`
|
|
||||||
: `
|
|
||||||
sleep 2
|
|
||||||
${updateCmd}
|
|
||||||
if [ $? -eq 0 ]; then
|
|
||||||
echo "Update successful, restarting OpenChamber..."
|
|
||||||
${restartCmd}
|
|
||||||
else
|
|
||||||
echo "Update failed"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
`;
|
|
||||||
|
|
||||||
// Spawn detached shell to run update after we exit.
|
|
||||||
// Capture output to disk so restart failures are diagnosable.
|
|
||||||
const updateLogPath = path.join(OPENCHAMBER_DATA_DIR, 'update-install.log');
|
|
||||||
let logFd = null;
|
|
||||||
try {
|
|
||||||
fs.mkdirSync(path.dirname(updateLogPath), { recursive: true });
|
|
||||||
logFd = fs.openSync(updateLogPath, 'a');
|
|
||||||
} catch (logError) {
|
|
||||||
console.warn('Failed to open update log file, continuing without log capture:', logError);
|
|
||||||
}
|
|
||||||
|
|
||||||
const child = spawnChild(shell, [shellFlag, script], {
|
|
||||||
detached: true,
|
|
||||||
stdio: logFd !== null ? ['ignore', logFd, logFd] : 'ignore',
|
|
||||||
env: process.env,
|
|
||||||
});
|
|
||||||
child.unref();
|
|
||||||
|
|
||||||
if (logFd !== null) {
|
|
||||||
try {
|
|
||||||
fs.closeSync(logFd);
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('Update process spawned, shutting down server...');
|
|
||||||
|
|
||||||
// Give child process time to start, then exit
|
|
||||||
setTimeout(() => {
|
|
||||||
process.exit(0);
|
|
||||||
}, 500);
|
|
||||||
}, 500);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to install update:', error);
|
|
||||||
res.status(500).json({
|
|
||||||
error: error instanceof Error ? error.message : 'Failed to install update',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
app.get('/api/openchamber/models-metadata', async (req, res) => {
|
app.get('/api/openchamber/models-metadata', async (req, res) => {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user