feat: enhance update dialog with changelog notes

This commit is contained in:
Bohdan Triapitsyn
2025-12-08 01:44:12 +02:00
parent 8192245bc7
commit 2eecaddee1
5 changed files with 118 additions and 40 deletions
+1 -1
View File
@@ -2847,7 +2847,7 @@ dependencies = [
[[package]]
name = "openchamber-desktop"
version = "1.0.7"
version = "1.0.8"
dependencies = [
"anyhow",
"axum",
+44 -4
View File
@@ -30,21 +30,26 @@ let cachedUpdate: Update | null = null;
export async function checkForUpdates(): Promise<UpdateInfo> {
try {
const { check } = await import('@tauri-apps/plugin-updater');
const update = await check();
const [update, currentVersion] = await Promise.all([
check(),
getCurrentVersion(),
]);
cachedUpdate = update;
if (!update) {
return {
available: false,
currentVersion: await getCurrentVersion(),
currentVersion,
};
}
const changelogNotes = await fetchChangelogNotes(currentVersion, update.version);
return {
available: true,
version: update.version,
currentVersion: await getCurrentVersion(),
body: update.body ?? undefined,
currentVersion,
body: changelogNotes ?? update.body ?? undefined,
date: update.date ?? undefined,
};
} catch (error) {
@@ -103,3 +108,38 @@ async function getCurrentVersion(): Promise<string> {
return 'unknown';
}
}
async function fetchChangelogNotes(fromVersion: string, toVersion: string): Promise<string | undefined> {
try {
const response = await fetch(
'https://raw.githubusercontent.com/btriapitsyn/openchamber/main/CHANGELOG.md'
);
if (!response.ok) return undefined;
const changelog = await response.text();
const sections = changelog.split(/^## /m).slice(1);
const fromNum = parseVersion(fromVersion);
const toNum = parseVersion(toVersion);
const relevantSections = sections.filter((section) => {
const match = section.match(/^\[(\d+\.\d+\.\d+)\]/);
if (!match) return false;
const ver = parseVersion(match[1]);
return ver > fromNum && ver <= toNum;
});
if (relevantSections.length === 0) return undefined;
return relevantSections
.map((s) => '## ' + s.trim())
.join('\n\n');
} catch {
return undefined;
}
}
function parseVersion(version: string): number {
const parts = version.split('.').map(Number);
return (parts[0] || 0) * 10000 + (parts[1] || 0) * 100 + (parts[2] || 0);
}
+30 -16
View File
@@ -5,7 +5,8 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { RiDownloadLine, RiExternalLinkLine, RiLoaderLine, RiRestartLine, RiSparklingLine } from '@remixicon/react';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { RiDownloadCloudLine, RiDownloadLine, RiExternalLinkLine, RiLoaderLine, RiRestartLine } from '@remixicon/react';
import { cn } from '@/lib/utils';
import type { UpdateInfo, UpdateProgress } from '@/lib/desktop';
@@ -47,24 +48,38 @@ export const UpdateDialog: React.FC<UpdateDialogProps> = ({
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<RiSparklingLine className="h-5 w-5 text-primary" />
<RiDownloadCloudLine className="h-5 w-5 text-primary" />
Update Available
</DialogTitle>
</DialogHeader>
<div className="space-y-4 mt-2">
{info?.currentVersion && (
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Current version</span>
<span className="font-mono">{info.currentVersion}</span>
{(info?.currentVersion || info?.version) && (
<div className="flex items-center gap-2 text-sm">
{info?.currentVersion && (
<span className="font-mono">{info.currentVersion}</span>
)}
{info?.currentVersion && info?.version && (
<span className="text-muted-foreground"></span>
)}
{info?.version && (
<span className="font-mono text-primary">{info.version}</span>
)}
</div>
)}
{info?.version && (
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">New version</span>
<span className="font-mono text-primary">{info.version}</span>
</div>
{info?.body && (
<ScrollableOverlay
className="max-h-48 rounded-md border border-border bg-muted/30 p-3"
fillContainer={false}
>
<div className="text-sm text-muted-foreground whitespace-pre-wrap pr-3">
{info.body
.replace(/^## \[(\d+\.\d+\.\d+)\] - \d{4}-\d{2}-\d{2}\s*/gm, '— v$1 —\n')
.replace(/^- /gm, '• ')
.trim()}
</div>
</ScrollableOverlay>
)}
{downloading && (
@@ -94,15 +109,14 @@ export const UpdateDialog: React.FC<UpdateDialogProps> = ({
target="_blank"
rel="noopener noreferrer"
className={cn(
'flex-1 flex items-center justify-center gap-2 px-3 py-1.5 rounded-md',
'text-sm font-medium',
'border border-border',
'hover:bg-accent hover:text-accent-foreground',
'flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-md',
'text-sm text-muted-foreground',
'hover:text-foreground hover:bg-accent',
'transition-colors'
)}
>
<RiExternalLinkLine className="h-4 w-4" />
View Release Notes
GitHub
</a>
{!downloaded && !downloading && (
+42 -19
View File
@@ -25,31 +25,43 @@ export type UseUpdateCheckReturn = UpdateState & {
dismiss: () => void;
};
const MOCK_UPDATE: UpdateState = {
interface MockUpdateConfig {
currentVersion?: string;
newVersion?: string;
}
// Set window.__OPENCHAMBER_MOCK_UPDATE__ = { currentVersion: '1.0.3', newVersion: '1.0.8' } to test with real changelog
declare global {
interface Window {
__OPENCHAMBER_MOCK_UPDATE__?: boolean | MockUpdateConfig;
}
}
const getMockConfig = (): MockUpdateConfig | null => {
if (typeof window === 'undefined') return null;
const mock = window.__OPENCHAMBER_MOCK_UPDATE__;
if (!mock) return null;
if (mock === true) return { currentVersion: '1.0.3', newVersion: '1.0.8' };
return mock;
};
const createMockUpdate = (config: MockUpdateConfig): UpdateState => ({
checking: false,
available: true,
downloading: false,
downloaded: false,
info: {
available: true,
version: '99.0.0-test',
currentVersion: '0.0.0',
body: 'Test update for UI development',
version: config.newVersion ?? '99.0.0-test',
currentVersion: config.currentVersion ?? '0.0.0',
body: undefined,
},
progress: null,
error: null,
};
// Set window.__OPENCHAMBER_MOCK_UPDATE__ = true in console to test UI
declare global {
interface Window {
__OPENCHAMBER_MOCK_UPDATE__?: boolean;
}
}
});
const shouldMockUpdate = (): boolean => {
if (typeof window === 'undefined') return false;
return window.__OPENCHAMBER_MOCK_UPDATE__ === true;
return getMockConfig() !== null;
};
export const useUpdateCheck = (checkOnMount = true): UseUpdateCheckReturn => {
@@ -64,7 +76,7 @@ export const useUpdateCheck = (checkOnMount = true): UseUpdateCheckReturn => {
});
const [mockMode, setMockMode] = useState(shouldMockUpdate);
const [mockState, setMockState] = useState<UpdateState>(MOCK_UPDATE);
const [mockState, setMockState] = useState<UpdateState | null>(null);
// Check for mock mode changes (for console toggling)
useEffect(() => {
@@ -73,7 +85,10 @@ export const useUpdateCheck = (checkOnMount = true): UseUpdateCheckReturn => {
if (shouldMock !== mockMode) {
setMockMode(shouldMock);
if (shouldMock) {
setMockState(MOCK_UPDATE);
const config = getMockConfig();
if (config) {
setMockState(createMockUpdate(config));
}
}
}
}, 500);
@@ -82,7 +97,15 @@ export const useUpdateCheck = (checkOnMount = true): UseUpdateCheckReturn => {
const checkForUpdates = useCallback(async () => {
if (mockMode) {
setMockState(MOCK_UPDATE);
const config = getMockConfig();
if (config) {
const mockUpdate = createMockUpdate(config);
mockUpdate.info = {
...mockUpdate.info!,
body: `## [${config.newVersion}] - 2025-01-01\n- Mock update for UI testing`,
};
setMockState(mockUpdate);
}
return;
}
if (!isDesktopRuntime()) {
@@ -173,7 +196,7 @@ export const useUpdateCheck = (checkOnMount = true): UseUpdateCheckReturn => {
const dismiss = useCallback(() => {
if (mockMode) {
setMockState(MOCK_UPDATE);
setMockState(null);
return;
}
setState((prev) => ({ ...prev, available: false, downloaded: false, info: null }));
@@ -189,7 +212,7 @@ export const useUpdateCheck = (checkOnMount = true): UseUpdateCheckReturn => {
}
}, [checkOnMount, checkForUpdates, mockMode]);
const currentState = mockMode ? mockState : state;
const currentState = (mockMode && mockState) ? mockState : state;
return {
...currentState,
+1
View File
@@ -274,3 +274,4 @@ export const restartToApplyUpdate = async (): Promise<boolean> => {
return false;
}
};