Remove OTA update functionality

This commit is contained in:
2026-03-15 01:45:06 +08:00
parent 6bdb788e01
commit 92eea398ea
12 changed files with 7 additions and 1495 deletions

View File

@@ -0,0 +1,4 @@
{
"status": "failed",
"failedTests": []
}

View File

@@ -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) {

View File

@@ -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}
/>
</> </>
); );
}; };

View File

@@ -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>
);
};

View File

@@ -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>
); );

View File

@@ -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>
);
};

View File

@@ -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>

View File

@@ -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();
}; };

View File

@@ -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;

View File

@@ -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);
},
}));

View File

@@ -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() {

View File

@@ -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();