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

@@ -18,7 +18,6 @@ import { MultiRunLauncher } from '@/components/multirun';
import { DrawerProvider } from '@/contexts/DrawerContext';
import { useUIStore } from '@/stores/useUIStore';
import { useUpdateStore } from '@/stores/useUpdateStore';
import { useDeviceInfo } from '@/lib/device';
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
import { cn } from '@/lib/utils';
@@ -144,26 +143,6 @@ export const MainLayout: React.FC = () => {
}
}, [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(() => {
const previous = useUIStore.getState().isMobile;
if (previous !== isMobile) {

View File

@@ -34,13 +34,11 @@ import {
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { toast } from '@/components/ui';
import { UpdateDialog } from '@/components/ui/UpdateDialog';
import { ProjectEditDialog } from '@/components/layout/ProjectEditDialog';
import { useUIStore } from '@/stores/useUIStore';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { useSessionStore } from '@/stores/useSessionStore';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { useUpdateStore } from '@/stores/useUpdateStore';
import { cn, formatDirectoryName, hasModifier } from '@/lib/utils';
import { PROJECT_ICON_MAP, PROJECT_COLOR_MAP, getProjectIconImageUrl } from '@/lib/projectMeta';
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 availableWorktreesByProject = useSessionStore((s) => s.availableWorktreesByProject);
const updateStore = useUpdateStore();
const { available: updateAvailable, downloaded: updateDownloaded } = updateStore;
const [updateDialogOpen, setUpdateDialogOpen] = React.useState(false);
const navRailInteractionBlocked = isOverlayBlockingNavRailActions || updateDialogOpen;
const tauriIpcAvailable = React.useMemo(() => isTauriShell(), []);
const [editingProject, setEditingProject] = React.useState<{
id: string;
@@ -548,8 +543,6 @@ export const NavRail: React.FC<NavRailProps> = ({ className, mobile }) => {
iconBackground?: string | null;
} | null>(null);
const tauriIpcAvailable = React.useMemo(() => isTauriShell(), []);
const formatLabel = React.useCallback(
(project: ProjectEntry): string => {
return (
@@ -740,7 +733,6 @@ export const NavRail: React.FC<NavRailProps> = ({ className, mobile }) => {
className={cn(
'flex h-full shrink-0 flex-col bg-[var(--surface-background)] overflow-hidden',
showExpandedContent ? 'items-stretch' : 'items-center',
navRailInteractionBlocked && 'pointer-events-none',
className,
)}
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')}>
<NavRailActionButton
onClick={handleAddProject}
disabled={navRailInteractionBlocked}
ariaLabel="Add project"
icon={<RiFolderAddLine className={navRailActionIconClass} />}
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',
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 && (
<NavRailActionButton
onClick={toggleHelpDialog}
disabled={navRailInteractionBlocked}
ariaLabel="Keyboard shortcuts"
icon={<RiQuestionLine className={navRailActionIconClass} />}
tooltipLabel="Shortcuts"
@@ -835,7 +812,6 @@ export const NavRail: React.FC<NavRailProps> = ({ className, mobile }) => {
<NavRailActionButton
onClick={() => setSettingsDialogOpen(true)}
disabled={navRailInteractionBlocked}
ariaLabel="Settings"
icon={<RiSettings3Line className={navRailActionIconClass} />}
tooltipLabel="Settings"
@@ -850,7 +826,6 @@ export const NavRail: React.FC<NavRailProps> = ({ className, mobile }) => {
{!mobile && (
<NavRailActionButton
onClick={toggleNavRail}
disabled={navRailInteractionBlocked}
ariaLabel={expanded ? 'Collapse sidebar' : 'Expand sidebar'}
icon={expanded
? <RiMenuFoldLine className={navRailActionIconClass} />
@@ -883,19 +858,6 @@ export const NavRail: React.FC<NavRailProps> = ({ className, mobile }) => {
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 { OpenChamberVisualSettings } from './OpenChamberVisualSettings';
import { AboutSettings } from './AboutSettings';
import { SessionRetentionSettings } from './SessionRetentionSettings';
import { MemoryLimitsSettings } from './MemoryLimitsSettings';
import { DefaultsSettings } from './DefaultsSettings';
@@ -11,7 +10,7 @@ import { OpenCodeCliSettings } from './OpenCodeCliSettings';
import { KeyboardShortcutsSettings } from './KeyboardShortcutsSettings';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { useDeviceInfo } from '@/lib/device';
import { isVSCodeRuntime, isWebRuntime } from '@/lib/desktop';
import { isVSCodeRuntime } from '@/lib/desktop';
import type { OpenChamberSection } from './types';
interface OpenChamberPageProps {
@@ -21,7 +20,6 @@ interface OpenChamberPageProps {
export const OpenChamberPage: React.FC<OpenChamberPageProps> = ({ section }) => {
const { isMobile } = useDeviceInfo();
const showAbout = isMobile && isWebRuntime();
const isVSCode = isVSCodeRuntime();
// 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">
<SessionRetentionSettings />
</div>
{showAbout && (
<div className="border-t border-border/40 pt-6">
<AboutSettings />
</div>
)}
</div>
</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 type { OpenChamberSection } from '@/components/sections/openchamber/types';
import { OpenChamberPage } from '@/components/sections/openchamber/OpenChamberPage';
import { AboutSettings } from '@/components/sections/openchamber/AboutSettings';
import { McpIcon } from '@/components/icons/McpIcon';
import { useDeviceInfo } from '@/lib/device';
import { isDesktopShell, isVSCodeRuntime, isWebRuntime } from '@/lib/desktop';
@@ -548,11 +547,6 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
</Tooltip>
)}
{isMobile && runtimeCtx.isWeb && (
<div className="px-1.5 pt-2">
<AboutSettings />
</div>
)}
</div>
</div>
</div>

View File

@@ -3,7 +3,6 @@ import { toast } from '@/components/ui';
import { useSessionStore } from '@/stores/useSessionStore';
import { useUIStore } from '@/stores/useUIStore';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { useUpdateStore } from '@/stores/useUpdateStore';
import { useThemeSystem } from '@/contexts/useThemeSystem';
import { sessionEvents } from '@/lib/sessionEvents';
import { isTauriShell } from '@/lib/desktop';
@@ -12,7 +11,6 @@ import { createWorktreeSession } from '@/lib/worktreeSessionCreator';
import { showOpenCodeStatus } from '@/lib/openCodeStatus';
const MENU_ACTION_EVENT = 'openchamber:menu-action';
const CHECK_FOR_UPDATES_EVENT = 'openchamber:check-for-updates';
type TauriEventApi = {
listen?: (
@@ -59,35 +57,8 @@ export const useMenuActions = (
setAboutDialogOpen,
} = useUIStore();
const { addProject } = useProjectsStore();
const checkForUpdates = useUpdateStore((state) => state.checkForUpdates);
const { requestAccess, startAccessing } = useFileSystemAccess();
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(() => {
if (isTauriShell()) {
@@ -244,17 +215,11 @@ export const useMenuActions = (
handleAction(action);
};
const handleCheckForUpdatesEvent = () => {
handleCheckForUpdates();
};
window.addEventListener(MENU_ACTION_EVENT, handleMenuAction);
window.addEventListener(CHECK_FOR_UPDATES_EVENT, handleCheckForUpdatesEvent);
return () => {
window.removeEventListener(MENU_ACTION_EVENT, handleMenuAction);
window.removeEventListener(CHECK_FOR_UPDATES_EVENT, handleCheckForUpdatesEvent);
};
}, [handleAction, handleCheckForUpdates]);
}, [handleAction]);
React.useEffect(() => {
if (typeof window === 'undefined') return;
@@ -263,7 +228,6 @@ export const useMenuActions = (
if (typeof listen !== 'function') return;
let unlistenMenu: null | (() => void | Promise<void>) = null;
let unlistenUpdate: null | (() => void | Promise<void>) = null;
listen('openchamber:menu-action', (evt) => {
const action = evt?.payload;
@@ -277,16 +241,6 @@ export const useMenuActions = (
// ignore
});
listen('openchamber:check-for-updates', () => {
window.dispatchEvent(new Event(CHECK_FOR_UPDATES_EVENT));
})
.then((fn) => {
unlistenUpdate = fn;
})
.catch(() => {
// ignore
});
return () => {
const cleanup = async () => {
try {
@@ -295,12 +249,6 @@ export const useMenuActions = (
} catch {
// ignore
}
try {
const b = unlistenUpdate?.();
if (b instanceof Promise) await b;
} catch {
// ignore
}
};
void cleanup();
};

View File

@@ -5,22 +5,6 @@ export type AssistantNotificationPayload = {
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 = {
id: string;
label: string;
@@ -309,98 +293,6 @@ export const sendAssistantCompletionNotification = async (
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> => {
if (!isTauriShell() || !isDesktopLocalOriginActive()) {
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);
},
}));