Remove OTA update functionality
This commit is contained in:
4
test-results/.last-run.json
Normal file
4
test-results/.last-run.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"status": "failed",
|
||||
"failedTests": []
|
||||
}
|
||||
@@ -18,7 +18,6 @@ import { MultiRunLauncher } from '@/components/multirun';
|
||||
import { DrawerProvider } from '@/contexts/DrawerContext';
|
||||
|
||||
import { 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) {
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,224 +0,0 @@
|
||||
import React from 'react';
|
||||
import { RiDiscordFill, RiDownloadLine, RiGithubFill, RiLoaderLine, RiTwitterXFill } from '@remixicon/react';
|
||||
import { useUpdateStore } from '@/stores/useUpdateStore';
|
||||
import { UpdateDialog } from '@/components/ui/UpdateDialog';
|
||||
import { useDeviceInfo } from '@/lib/device';
|
||||
import { toast } from '@/components/ui';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { ButtonSmall } from '@/components/ui/button-small';
|
||||
|
||||
const GITHUB_URL = 'https://github.com/btriapitsyn/openchamber';
|
||||
|
||||
const MIN_CHECKING_DURATION = 800; // ms
|
||||
|
||||
export const AboutSettings: React.FC = () => {
|
||||
const [updateDialogOpen, setUpdateDialogOpen] = React.useState(false);
|
||||
const [showChecking, setShowChecking] = React.useState(false);
|
||||
const updateStore = useUpdateStore();
|
||||
const { isMobile } = useDeviceInfo();
|
||||
|
||||
const currentVersion = updateStore.info?.currentVersion || 'unknown';
|
||||
|
||||
// Track if we initiated a check to show toast on completion
|
||||
const didInitiateCheck = React.useRef(false);
|
||||
|
||||
// Ensure minimum visible duration for checking animation
|
||||
React.useEffect(() => {
|
||||
if (updateStore.checking) {
|
||||
setShowChecking(true);
|
||||
didInitiateCheck.current = true;
|
||||
} else if (showChecking) {
|
||||
const timer = setTimeout(() => {
|
||||
setShowChecking(false);
|
||||
// Show toast if check completed with no update available
|
||||
if (didInitiateCheck.current && !updateStore.available && !updateStore.error) {
|
||||
toast.success('You are on the latest version');
|
||||
didInitiateCheck.current = false;
|
||||
}
|
||||
}, MIN_CHECKING_DURATION);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [updateStore.checking, showChecking, updateStore.available, updateStore.error]);
|
||||
|
||||
const isChecking = updateStore.checking || showChecking;
|
||||
|
||||
// Compact mobile layout for sidebar footer
|
||||
if (isMobile) {
|
||||
return (
|
||||
<div className="w-full space-y-2">
|
||||
{/* Version row with update status */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="typography-meta text-muted-foreground">
|
||||
v{currentVersion}
|
||||
</span>
|
||||
|
||||
{!updateStore.available && !updateStore.error && (
|
||||
<button
|
||||
onClick={() => updateStore.checkForUpdates()}
|
||||
disabled={isChecking}
|
||||
className={cn(
|
||||
'typography-meta text-muted-foreground/60 hover:text-muted-foreground disabled:cursor-default',
|
||||
isChecking && 'animate-pulse [animation-duration:1s]'
|
||||
)}
|
||||
>
|
||||
Check updates
|
||||
</button>
|
||||
)}
|
||||
|
||||
{!isChecking && updateStore.available && (
|
||||
<button
|
||||
onClick={() => setUpdateDialogOpen(true)}
|
||||
className="flex items-center gap-1 typography-meta text-[var(--primary-base)] hover:underline"
|
||||
>
|
||||
<RiDownloadLine className="h-3.5 w-3.5" />
|
||||
Update
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{updateStore.error && (
|
||||
<p className="typography-micro text-[var(--status-error)] truncate">{updateStore.error}</p>
|
||||
)}
|
||||
|
||||
{/* Links row */}
|
||||
<div className="flex items-center gap-3">
|
||||
<a
|
||||
href={GITHUB_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 typography-meta text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<RiGithubFill className="h-3.5 w-3.5" />
|
||||
<span>GitHub</span>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="https://discord.gg/ZYRSdnwwKA"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 typography-meta text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<RiDiscordFill className="h-3.5 w-3.5" />
|
||||
<span>Discord</span>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="https://x.com/btriapitsyn"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 typography-meta text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<RiTwitterXFill className="h-3.5 w-3.5" />
|
||||
<span>@btriapitsyn</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<UpdateDialog
|
||||
open={updateDialogOpen}
|
||||
onOpenChange={setUpdateDialogOpen}
|
||||
info={updateStore.info}
|
||||
downloading={updateStore.downloading}
|
||||
downloaded={updateStore.downloaded}
|
||||
progress={updateStore.progress}
|
||||
error={updateStore.error}
|
||||
onDownload={updateStore.downloadUpdate}
|
||||
onRestart={updateStore.restartToUpdate}
|
||||
runtimeType={updateStore.runtimeType}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// Desktop layout (redesigned)
|
||||
return (
|
||||
<div className="mb-8">
|
||||
<div className="mb-3 px-1">
|
||||
<h3 className="typography-ui-header font-semibold text-foreground">
|
||||
About OpenChamber
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg bg-[var(--surface-elevated)]/70 overflow-hidden flex flex-col">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 px-4 py-3 border-b border-[var(--surface-subtle)]">
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<span className="typography-ui-label text-foreground">Version</span>
|
||||
<span className="typography-meta text-muted-foreground font-mono">{currentVersion}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{updateStore.checking && (
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<RiLoaderLine className="h-4 w-4 animate-spin" />
|
||||
<span className="typography-meta">Checking...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!updateStore.checking && updateStore.available && (
|
||||
<ButtonSmall
|
||||
variant="default"
|
||||
onClick={() => setUpdateDialogOpen(true)}
|
||||
>
|
||||
<RiDownloadLine className="h-4 w-4 mr-1" />
|
||||
Update to {updateStore.info?.version}
|
||||
</ButtonSmall>
|
||||
)}
|
||||
|
||||
{!updateStore.checking && !updateStore.available && !updateStore.error && (
|
||||
<span className="typography-meta text-muted-foreground">Up to date</span>
|
||||
)}
|
||||
|
||||
<ButtonSmall
|
||||
variant="outline"
|
||||
onClick={() => updateStore.checkForUpdates()}
|
||||
disabled={updateStore.checking}
|
||||
>
|
||||
Check for updates
|
||||
</ButtonSmall>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{updateStore.error && (
|
||||
<div className="px-3 py-2 border-b border-[var(--surface-subtle)]">
|
||||
<p className="typography-meta text-[var(--status-error)]">{updateStore.error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-4 px-4 py-4">
|
||||
<a
|
||||
href={GITHUB_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1.5 text-muted-foreground hover:text-foreground typography-meta transition-colors"
|
||||
>
|
||||
<RiGithubFill className="h-4 w-4" />
|
||||
<span>GitHub</span>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="https://x.com/btriapitsyn"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1.5 text-muted-foreground hover:text-foreground typography-meta transition-colors"
|
||||
>
|
||||
<RiTwitterXFill className="h-4 w-4" />
|
||||
<span>@btriapitsyn</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UpdateDialog
|
||||
open={updateDialogOpen}
|
||||
onOpenChange={setUpdateDialogOpen}
|
||||
info={updateStore.info}
|
||||
downloading={updateStore.downloading}
|
||||
downloaded={updateStore.downloaded}
|
||||
progress={updateStore.progress}
|
||||
error={updateStore.error}
|
||||
onDownload={updateStore.downloadUpdate}
|
||||
onRestart={updateStore.restartToUpdate}
|
||||
runtimeType={updateStore.runtimeType}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,6 +1,5 @@
|
||||
import React from 'react';
|
||||
import { 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>
|
||||
);
|
||||
|
||||
@@ -1,547 +0,0 @@
|
||||
import React, { useState, useCallback, useEffect, useMemo } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||
import { SimpleMarkdownRenderer } from '@/components/chat/MarkdownRenderer';
|
||||
import { RiCheckLine, RiClipboardLine, RiDownloadCloudLine, RiDownloadLine, RiExternalLinkLine, RiLoaderLine, RiRestartLine, RiTerminalLine } from '@remixicon/react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { UpdateInfo, UpdateProgress } from '@/lib/desktop';
|
||||
import { copyTextToClipboard } from '@/lib/clipboard';
|
||||
|
||||
type WebUpdateState = 'idle' | 'updating' | 'restarting' | 'reconnecting' | 'error';
|
||||
|
||||
interface UpdateDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
info: UpdateInfo | null;
|
||||
downloading: boolean;
|
||||
downloaded: boolean;
|
||||
progress: UpdateProgress | null;
|
||||
error: string | null;
|
||||
onDownload: () => void;
|
||||
onRestart: () => void;
|
||||
/** Runtime type to show different UI for desktop vs web */
|
||||
runtimeType?: 'desktop' | 'web' | 'vscode' | null;
|
||||
}
|
||||
|
||||
const GITHUB_RELEASES_URL = 'https://github.com/btriapitsyn/openchamber/releases';
|
||||
|
||||
type ChangelogSection = {
|
||||
version: string;
|
||||
date: string;
|
||||
start: number;
|
||||
end: number;
|
||||
raw: string;
|
||||
};
|
||||
|
||||
type ParsedChangelog =
|
||||
| {
|
||||
kind: 'raw';
|
||||
title: string;
|
||||
content: string;
|
||||
}
|
||||
| {
|
||||
kind: 'sections';
|
||||
title: string;
|
||||
sections: Array<{ version: string; dateLabel: string; content: string }>;
|
||||
};
|
||||
|
||||
function formatIsoDateForUI(isoDate: string): string {
|
||||
const d = new Date(`${isoDate}T00:00:00`);
|
||||
if (Number.isNaN(d.getTime())) {
|
||||
return isoDate;
|
||||
}
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
}).format(d);
|
||||
}
|
||||
|
||||
function stripChangelogHeading(sectionRaw: string): string {
|
||||
return sectionRaw.replace(/^## \[[^\]]+\] - \d{4}-\d{2}-\d{2}\s*\n?/, '').trim();
|
||||
}
|
||||
|
||||
function processChangelogMentions(content: string): string {
|
||||
// Convert @username to markdown links so they can be styled via css
|
||||
return content.replace(/(^|[^a-zA-Z0-9])@([a-zA-Z0-9-]+)/g, '$1[@$2](https://github.com/$2)');
|
||||
}
|
||||
|
||||
function compareSemverDesc(a: string, b: string): number {
|
||||
const pa = a.split('.').map((v) => Number.parseInt(v, 10));
|
||||
const pb = b.split('.').map((v) => Number.parseInt(v, 10));
|
||||
for (let i = 0; i < 3; i += 1) {
|
||||
const da = Number.isFinite(pa[i]) ? (pa[i] as number) : 0;
|
||||
const db = Number.isFinite(pb[i]) ? (pb[i] as number) : 0;
|
||||
if (da !== db) {
|
||||
return db - da;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function parseChangelogSections(body: string): ChangelogSection[] {
|
||||
const re = /^## \[(\d+\.\d+\.\d+)\] - (\d{4}-\d{2}-\d{2})\s*$/gm;
|
||||
const matches: Array<{ version: string; date: string; start: number }> = [];
|
||||
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(body)) !== null) {
|
||||
matches.push({
|
||||
version: m[1] ?? '',
|
||||
date: m[2] ?? '',
|
||||
start: m.index,
|
||||
});
|
||||
}
|
||||
|
||||
if (matches.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return matches.map((match, idx) => {
|
||||
const end = matches[idx + 1]?.start ?? body.length;
|
||||
const raw = body.slice(match.start, end).trim();
|
||||
return { version: match.version, date: match.date, start: match.start, end, raw };
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
type InstallWebUpdateResult = {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
autoRestart?: boolean;
|
||||
};
|
||||
|
||||
const WEB_UPDATE_POLL_INTERVAL_MS = 2000;
|
||||
const WEB_UPDATE_MAX_WAIT_MS = 10 * 60 * 1000;
|
||||
|
||||
async function installWebUpdate(): Promise<InstallWebUpdateResult> {
|
||||
try {
|
||||
const response = await fetch('/api/openchamber/update-install', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json().catch(() => ({}));
|
||||
return { success: false, error: data.error || `Server error: ${response.status}` };
|
||||
}
|
||||
|
||||
const data = await response.json().catch(() => ({}));
|
||||
return {
|
||||
success: true,
|
||||
autoRestart: data.autoRestart !== false,
|
||||
};
|
||||
} catch (error) {
|
||||
return { success: false, error: error instanceof Error ? error.message : 'Failed to install update' };
|
||||
}
|
||||
}
|
||||
|
||||
async function isServerReachable(): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch('/health', {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
return response.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForUpdateApplied(
|
||||
previousVersion?: string,
|
||||
maxAttempts = Math.ceil(WEB_UPDATE_MAX_WAIT_MS / WEB_UPDATE_POLL_INTERVAL_MS),
|
||||
intervalMs = WEB_UPDATE_POLL_INTERVAL_MS,
|
||||
): Promise<boolean> {
|
||||
for (let i = 0; i < maxAttempts; i++) {
|
||||
try {
|
||||
const response = await fetch('/api/openchamber/update-check', {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = await response.json().catch(() => null);
|
||||
if (data && data.available === false) {
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
data &&
|
||||
typeof data.currentVersion === 'string' &&
|
||||
typeof previousVersion === 'string' &&
|
||||
data.currentVersion !== previousVersion
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
} else if ((response.status === 401 || response.status === 403) && await isServerReachable()) {
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
// Server may be restarting
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, intervalMs));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export const UpdateDialog: React.FC<UpdateDialogProps> = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
info,
|
||||
downloading,
|
||||
downloaded,
|
||||
progress,
|
||||
error,
|
||||
onDownload,
|
||||
onRestart,
|
||||
runtimeType = 'desktop',
|
||||
}) => {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [webUpdateState, setWebUpdateState] = useState<WebUpdateState>('idle');
|
||||
const [webError, setWebError] = useState<string | null>(null);
|
||||
|
||||
const releaseUrl = info?.version
|
||||
? `${GITHUB_RELEASES_URL}/tag/v${info.version}`
|
||||
: GITHUB_RELEASES_URL;
|
||||
|
||||
const progressPercent = progress?.total
|
||||
? Math.round((progress.downloaded / progress.total) * 100)
|
||||
: 0;
|
||||
|
||||
const isWebRuntime = runtimeType === 'web';
|
||||
const updateCommand = info?.updateCommand || 'openchamber update';
|
||||
|
||||
// Reset state when dialog closes
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setWebUpdateState('idle');
|
||||
setWebError(null);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const handleCopyCommand = async () => {
|
||||
const result = await copyTextToClipboard(updateCommand);
|
||||
if (result.ok) {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenExternal = useCallback(async (url: string) => {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
// Try Tauri backend
|
||||
type TauriShell = { shell?: { open?: (url: string) => Promise<unknown> } };
|
||||
const tauri = (window as unknown as { __TAURI__?: TauriShell }).__TAURI__;
|
||||
if (tauri?.shell?.open) {
|
||||
try {
|
||||
await tauri.shell.open(url);
|
||||
return;
|
||||
} catch {
|
||||
// fall through to window.open
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
window.open(url, '_blank', 'noopener,noreferrer');
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, []);
|
||||
const handleWebUpdate = useCallback(async () => {
|
||||
setWebUpdateState('updating');
|
||||
setWebError(null);
|
||||
|
||||
const result = await installWebUpdate();
|
||||
|
||||
if (!result.success) {
|
||||
setWebUpdateState('error');
|
||||
setWebError(result.error || 'Update failed');
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.autoRestart) {
|
||||
setWebUpdateState('restarting');
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
}
|
||||
|
||||
setWebUpdateState('reconnecting');
|
||||
|
||||
const applied = await waitForUpdateApplied(info?.currentVersion);
|
||||
|
||||
if (applied) {
|
||||
window.location.reload();
|
||||
} else {
|
||||
setWebUpdateState('error');
|
||||
setWebError('Update is taking longer than expected. Wait a bit and refresh, or run: openchamber update');
|
||||
}
|
||||
}, [info?.currentVersion]);
|
||||
|
||||
const isWebUpdating = webUpdateState !== 'idle' && webUpdateState !== 'error';
|
||||
|
||||
const changelog = useMemo<ParsedChangelog | null>(() => {
|
||||
if (!info?.body) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const body = info.body.trim();
|
||||
if (!body) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sections = parseChangelogSections(body);
|
||||
|
||||
if (sections.length === 0) {
|
||||
return {
|
||||
kind: 'raw',
|
||||
title: "What's new",
|
||||
content: processChangelogMentions(body),
|
||||
};
|
||||
}
|
||||
|
||||
const sorted = [...sections].sort((a, b) => compareSemverDesc(a.version, b.version));
|
||||
return {
|
||||
kind: 'sections',
|
||||
title: "What's new",
|
||||
sections: sorted.map((section) => ({
|
||||
version: section.version,
|
||||
dateLabel: formatIsoDateForUI(section.date),
|
||||
content: processChangelogMentions(stripChangelogHeading(section.raw) || body),
|
||||
})),
|
||||
};
|
||||
}, [info?.body]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={isWebUpdating ? undefined : onOpenChange}>
|
||||
<DialogContent className="max-w-4xl p-5 bg-background border-[var(--interactive-border)]" showCloseButton={true}>
|
||||
|
||||
{/* Header Section */}
|
||||
<div className="flex items-center mb-1">
|
||||
<DialogTitle className="flex items-center gap-2.5">
|
||||
<RiDownloadCloudLine className="h-5 w-5 text-[var(--primary-base)]" />
|
||||
<span className="text-lg font-semibold text-foreground">
|
||||
{webUpdateState === 'restarting' || webUpdateState === 'reconnecting'
|
||||
? 'Updating OpenChamber...'
|
||||
: 'Update Available'}
|
||||
</span>
|
||||
</DialogTitle>
|
||||
|
||||
{/* Version Diff */}
|
||||
{(info?.currentVersion || info?.version) && (
|
||||
<div className="flex items-center gap-2 font-mono text-sm ml-3">
|
||||
{info?.currentVersion && (
|
||||
<span className="text-muted-foreground">{info.currentVersion}</span>
|
||||
)}
|
||||
{info?.currentVersion && info?.version && (
|
||||
<span className="text-muted-foreground/50">→</span>
|
||||
)}
|
||||
{info?.version && (
|
||||
<span className="text-[var(--primary-base)] font-medium">{info.version}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content Body */}
|
||||
<div className="space-y-2">
|
||||
|
||||
{/* Web update progress */}
|
||||
{isWebRuntime && isWebUpdating && (
|
||||
<div className="rounded-lg bg-[var(--surface-elevated)]/30 p-5 border border-[var(--surface-subtle)]">
|
||||
<div className="flex items-center gap-3">
|
||||
<RiLoaderLine className="h-5 w-5 animate-spin text-[var(--primary-base)]" />
|
||||
<div className="typography-ui-label text-foreground">
|
||||
{webUpdateState === 'updating' && 'Installing update...'}
|
||||
{webUpdateState === 'restarting' && 'Server restarting...'}
|
||||
{webUpdateState === 'reconnecting' && 'Waiting for server...'}
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
The page will reload automatically when the update is complete.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Changelog Rendering */}
|
||||
{changelog && !isWebUpdating && (
|
||||
<div className="rounded-lg border border-[var(--surface-subtle)] bg-[var(--surface-elevated)]/20 overflow-hidden">
|
||||
<ScrollableOverlay
|
||||
className="max-h-[400px] p-0"
|
||||
fillContainer={false}
|
||||
>
|
||||
{changelog.kind === 'raw' ? (
|
||||
<div
|
||||
className="p-4 typography-markdown-body text-foreground leading-relaxed break-words [&_a]:!text-[var(--primary-base)] [&_a]:!no-underline hover:[&_a]:!underline"
|
||||
onClickCapture={(e) => {
|
||||
const target = e.target as HTMLElement;
|
||||
const a = target.closest('a');
|
||||
if (a && a.href) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
void handleOpenExternal(a.href);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SimpleMarkdownRenderer content={changelog.content} disableLinkSafety={true} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-[var(--surface-subtle)]">
|
||||
{changelog.sections.map((section) => (
|
||||
<div key={section.version} className="p-4 hover:bg-background/40 transition-colors">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<span className="typography-ui-label font-mono text-[var(--primary-base)] bg-[var(--primary-base)]/10 px-1.5 py-0.5 rounded">
|
||||
v{section.version}
|
||||
</span>
|
||||
<span className="text-sm font-medium text-muted-foreground">
|
||||
{section.dateLabel}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className="typography-markdown-body text-foreground leading-relaxed break-words [&_a]:!text-[var(--primary-base)] [&_a]:!no-underline hover:[&_a]:!underline"
|
||||
onClickCapture={(e) => {
|
||||
const target = e.target as HTMLElement;
|
||||
const a = target.closest('a');
|
||||
if (a && a.href) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
void handleOpenExternal(a.href);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SimpleMarkdownRenderer content={section.content} disableLinkSafety={true} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</ScrollableOverlay>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Web runtime fallback command */}
|
||||
{isWebRuntime && webUpdateState === 'error' && (
|
||||
<div className="space-y-2 mt-4">
|
||||
<div className="flex items-center gap-2 typography-meta text-muted-foreground">
|
||||
<RiTerminalLine className="h-4 w-4" />
|
||||
<span>Or update via terminal:</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 p-1 pl-3 bg-[var(--surface-elevated)]/50 rounded-md border border-[var(--surface-subtle)]">
|
||||
<code className="flex-1 font-mono text-sm text-foreground overflow-x-auto whitespace-nowrap">
|
||||
{updateCommand}
|
||||
</code>
|
||||
<button
|
||||
onClick={handleCopyCommand}
|
||||
className={cn(
|
||||
'flex items-center justify-center p-2 rounded',
|
||||
'text-muted-foreground hover:text-foreground hover:bg-[var(--interactive-hover)]',
|
||||
'transition-colors',
|
||||
copied && 'text-[var(--status-success)]'
|
||||
)}
|
||||
title={copied ? 'Copied!' : 'Copy command'}
|
||||
>
|
||||
{copied ? (
|
||||
<RiCheckLine className="h-4 w-4" />
|
||||
) : (
|
||||
<RiClipboardLine className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Desktop progress bar */}
|
||||
{!isWebRuntime && downloading && (
|
||||
<div className="space-y-2 mt-4">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Downloading update payload...</span>
|
||||
<span className="font-mono text-foreground">{progressPercent}%</span>
|
||||
</div>
|
||||
<div className="h-1.5 bg-[var(--surface-subtle)] rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-[var(--primary-base)] transition-all duration-300"
|
||||
style={{ width: `${progressPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error display */}
|
||||
{(error || webError) && (
|
||||
<div className="p-3 mt-4 bg-[var(--status-error-background)] border border-[var(--status-error-border)] rounded-lg">
|
||||
<p className="text-sm text-[var(--status-error)]">{error || webError}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action Footer */}
|
||||
<div className="mt-4 flex items-center justify-between gap-4">
|
||||
<a
|
||||
href={releaseUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors shrink-0"
|
||||
>
|
||||
<RiExternalLinkLine className="h-4 w-4" />
|
||||
GitHub
|
||||
</a>
|
||||
|
||||
<div className="flex-1 flex justify-end">
|
||||
{/* Desktop Buttons */}
|
||||
{!isWebRuntime && !downloaded && !downloading && (
|
||||
<button
|
||||
onClick={onDownload}
|
||||
className="flex items-center justify-center gap-2 px-5 py-2 rounded-md text-sm font-medium bg-[var(--primary-base)] text-[var(--primary-foreground)] hover:opacity-90 transition-opacity"
|
||||
>
|
||||
<RiDownloadLine className="h-4 w-4" />
|
||||
Download Update
|
||||
</button>
|
||||
)}
|
||||
|
||||
{!isWebRuntime && downloading && (
|
||||
<button
|
||||
disabled
|
||||
className="flex items-center justify-center gap-2 px-5 py-2 rounded-md text-sm font-medium bg-[var(--primary-base)]/50 text-[var(--primary-foreground)] cursor-not-allowed"
|
||||
>
|
||||
<RiLoaderLine className="h-4 w-4 animate-spin" />
|
||||
Downloading...
|
||||
</button>
|
||||
)}
|
||||
|
||||
{!isWebRuntime && downloaded && (
|
||||
<button
|
||||
onClick={onRestart}
|
||||
className="flex items-center justify-center gap-2 px-5 py-2 rounded-md text-sm font-medium bg-[var(--status-success)] text-white hover:opacity-90 transition-opacity"
|
||||
>
|
||||
<RiRestartLine className="h-4 w-4" />
|
||||
Restart to Update
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Web Buttons */}
|
||||
{isWebRuntime && !isWebUpdating && (
|
||||
<button
|
||||
onClick={handleWebUpdate}
|
||||
className="flex items-center justify-center gap-2 px-5 py-2 rounded-md text-sm font-medium bg-[var(--primary-base)] text-[var(--primary-foreground)] hover:opacity-90 transition-opacity"
|
||||
>
|
||||
<RiDownloadLine className="h-4 w-4" />
|
||||
Update Now
|
||||
</button>
|
||||
)}
|
||||
|
||||
{isWebRuntime && isWebUpdating && (
|
||||
<button
|
||||
disabled
|
||||
className="flex items-center justify-center gap-2 px-5 py-2 rounded-md text-sm font-medium bg-[var(--primary-base)]/50 text-[var(--primary-foreground)] cursor-not-allowed"
|
||||
>
|
||||
<RiLoaderLine className="h-4 w-4 animate-spin" />
|
||||
Updating...
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -49,7 +49,6 @@ import { UsagePage } from '@/components/sections/usage/UsagePage';
|
||||
import { GitPage } from '@/components/sections/git-identities/GitPage';
|
||||
import 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>
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,164 +0,0 @@
|
||||
import { create } from 'zustand';
|
||||
import type { UpdateInfo, UpdateProgress } from '@/lib/desktop';
|
||||
import {
|
||||
checkForDesktopUpdates,
|
||||
downloadDesktopUpdate,
|
||||
restartToApplyUpdate,
|
||||
isDesktopLocalOriginActive,
|
||||
isTauriShell,
|
||||
isWebRuntime,
|
||||
} from '@/lib/desktop';
|
||||
|
||||
export type UpdateState = {
|
||||
checking: boolean;
|
||||
available: boolean;
|
||||
downloading: boolean;
|
||||
downloaded: boolean;
|
||||
info: UpdateInfo | null;
|
||||
progress: UpdateProgress | null;
|
||||
error: string | null;
|
||||
runtimeType: 'desktop' | 'web' | 'vscode' | null;
|
||||
lastChecked: number | null;
|
||||
};
|
||||
|
||||
interface UpdateStore extends UpdateState {
|
||||
checkForUpdates: () => Promise<void>;
|
||||
downloadUpdate: () => Promise<void>;
|
||||
restartToUpdate: () => Promise<void>;
|
||||
dismiss: () => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
async function checkForWebUpdates(): Promise<UpdateInfo | null> {
|
||||
try {
|
||||
const response = await fetch('/api/openchamber/update-check', {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Server responded with ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return {
|
||||
available: data.available ?? false,
|
||||
version: data.version,
|
||||
currentVersion: data.currentVersion ?? 'unknown',
|
||||
body: data.body,
|
||||
packageManager: data.packageManager,
|
||||
updateCommand: data.updateCommand,
|
||||
};
|
||||
} catch (error) {
|
||||
console.warn('Failed to check for web updates:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function detectRuntimeType(): 'desktop' | 'web' | 'vscode' | null {
|
||||
if (isTauriShell()) {
|
||||
// Only use Tauri updater when we're on the local instance.
|
||||
// When viewing a remote host inside the desktop shell, treat update as web update.
|
||||
return isDesktopLocalOriginActive() ? 'desktop' : 'web';
|
||||
}
|
||||
if (isWebRuntime()) return 'web';
|
||||
return null;
|
||||
}
|
||||
|
||||
const initialState: UpdateState = {
|
||||
checking: false,
|
||||
available: false,
|
||||
downloading: false,
|
||||
downloaded: false,
|
||||
info: null,
|
||||
progress: null,
|
||||
error: null,
|
||||
runtimeType: null,
|
||||
lastChecked: null,
|
||||
};
|
||||
|
||||
export const useUpdateStore = create<UpdateStore>()((set, get) => ({
|
||||
...initialState,
|
||||
|
||||
checkForUpdates: async () => {
|
||||
const runtime = detectRuntimeType();
|
||||
if (!runtime) return;
|
||||
|
||||
set({ checking: true, error: null, runtimeType: runtime });
|
||||
|
||||
try {
|
||||
let info: UpdateInfo | null = null;
|
||||
|
||||
if (runtime === 'desktop') {
|
||||
info = await checkForDesktopUpdates();
|
||||
} else if (runtime === 'web') {
|
||||
info = await checkForWebUpdates();
|
||||
}
|
||||
|
||||
set({
|
||||
checking: false,
|
||||
available: info?.available ?? false,
|
||||
info,
|
||||
lastChecked: Date.now(),
|
||||
});
|
||||
} catch (error) {
|
||||
set({
|
||||
checking: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to check for updates',
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
downloadUpdate: async () => {
|
||||
const { available, runtimeType } = get();
|
||||
|
||||
// For web runtime, there's no download - user uses in-app update or CLI
|
||||
if (runtimeType !== 'desktop' || !available) {
|
||||
return;
|
||||
}
|
||||
|
||||
set({ downloading: true, error: null, progress: null });
|
||||
|
||||
try {
|
||||
const ok = await downloadDesktopUpdate((progress) => {
|
||||
set({ progress });
|
||||
});
|
||||
if (!ok) {
|
||||
throw new Error('Desktop update only works on Local instance');
|
||||
}
|
||||
set({ downloading: false, downloaded: true });
|
||||
} catch (error) {
|
||||
set({
|
||||
downloading: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to download update',
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
restartToUpdate: async () => {
|
||||
const { downloaded, runtimeType } = get();
|
||||
|
||||
if (runtimeType !== 'desktop' || !downloaded) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const ok = await restartToApplyUpdate();
|
||||
if (!ok) {
|
||||
throw new Error('Desktop restart only works on Local instance');
|
||||
}
|
||||
} catch (error) {
|
||||
set({
|
||||
error: error instanceof Error ? error.message : 'Failed to restart',
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
dismiss: () => {
|
||||
set({ available: false, downloaded: false, info: null });
|
||||
},
|
||||
|
||||
reset: () => {
|
||||
set(initialState);
|
||||
},
|
||||
}));
|
||||
128
web/bin/cli.js
128
web/bin/cli.js
@@ -138,7 +138,6 @@ COMMANDS:
|
||||
stop Stop running instance(s)
|
||||
restart Stop and start the server
|
||||
status Show server status
|
||||
update Check for and install updates
|
||||
|
||||
OPTIONS:
|
||||
-p, --port Web server port (default: ${DEFAULT_PORT})
|
||||
@@ -160,7 +159,6 @@ EXAMPLES:
|
||||
openchamber stop # Stop all running instances
|
||||
openchamber stop --port 3000 # Stop specific instance
|
||||
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() {
|
||||
|
||||
@@ -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) => {
|
||||
const now = Date.now();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user