fix(mac): updater progress UI and skip redundant download

Show update stage in the modal, forward electron-updater progress over IPC,
and install immediately when the build is already cached. Rename window titles to TTRPG - *.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
     Фонтош Иван Сергеевич
2026-05-19 11:07:19 +08:00
parent b017155eaf
commit 80103a00e7
10 changed files with 225 additions and 52 deletions
+64 -8
View File
@@ -1,7 +1,11 @@
import React, { startTransition, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { ipcChannels, type UpdaterCheckResponse } from '../../shared/ipc/contracts';
import {
ipcChannels,
type UpdaterCheckResponse,
type UpdaterProgressEvent,
} from '../../shared/ipc/contracts';
import { EULA_CURRENT_VERSION } from '../../shared/license/eulaVersion';
import type { LicenseSnapshot } from '../../shared/license/licenseSnapshot';
import { PROJECT_ZIP_EXTENSION } from '../../shared/project/projectZipExtension';
@@ -965,31 +969,76 @@ type CheckUpdatesModalProps = {
onClose: () => void;
};
function formatUpdaterStageLabel(
t: (key: string, vars?: Record<string, string | number>) => string,
ev: UpdaterProgressEvent | null,
): string {
if (!ev) return t('updates.stage.checking');
const percentSuffix =
ev.phase === 'downloading' && ev.percent !== undefined
? t('updates.stagePercent', { percent: ev.percent })
: '';
switch (ev.phase) {
case 'checking':
return t('updates.stage.checking');
case 'available':
return t('updates.stage.available', { version: ev.version ?? '?' });
case 'not-available':
return t('updates.stage.not-available');
case 'downloading':
return t('updates.stage.downloading', { percent: percentSuffix });
case 'installing':
return t('updates.stage.installing');
case 'error':
return ev.message ? `${t('updates.stage.error')}: ${ev.message}` : t('updates.stage.error');
default:
return t('updates.stage.checking');
}
}
function CheckUpdatesModal({ open, onClose }: CheckUpdatesModalProps) {
const { t } = useEditorI18n();
const [phase, setPhase] = useState<'idle' | 'checking' | 'done'>('idle');
const [res, setRes] = useState<UpdaterCheckResponse | null>(null);
const [downloadBusy, setDownloadBusy] = useState(false);
const [progress, setProgress] = useState<UpdaterProgressEvent | null>(null);
useEffect(() => {
if (!open) return;
startTransition(() => {
setPhase('checking');
setRes(null);
setProgress({ phase: 'checking' });
setDownloadBusy(false);
});
void getDndApi()
.invoke(ipcChannels.updater.check, {})
.then((r) => {
setRes(r);
setPhase('done');
if (r.outcome === 'available') {
setProgress({ phase: 'available', version: r.version });
} else if (r.outcome === 'current') {
setProgress({ phase: 'not-available', version: r.currentVersion });
} else if (r.outcome === 'error') {
setProgress({ phase: 'error', message: r.message });
}
})
.catch((e: unknown) => {
const message = e instanceof Error ? e.message : String(e);
setRes({ outcome: 'error', message });
setPhase('done');
setProgress({ phase: 'error', message });
});
}, [open]);
useEffect(() => {
if (!open) return;
return getDndApi().on(ipcChannels.updater.progress, (ev) => {
setProgress(ev);
});
}, [open]);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
@@ -1001,6 +1050,8 @@ function CheckUpdatesModal({ open, onClose }: CheckUpdatesModalProps) {
if (!open) return null;
const stageLine = t('updates.stageLine', { stage: formatUpdaterStageLabel(t, progress) });
const body =
phase === 'checking' || res === null ? (
<div className={styles.muted}>{t('updates.checking')}</div>
@@ -1042,7 +1093,10 @@ function CheckUpdatesModal({ open, onClose }: CheckUpdatesModalProps) {
×
</button>
</div>
<div className={styles.fieldGrid}>{body}</div>
<div className={styles.fieldGrid}>
{body}
<div className={styles.muted}>{stageLine}</div>
</div>
<div className={styles.modalFooter}>
{showUpdateIdle ? (
<>
@@ -1053,21 +1107,23 @@ function CheckUpdatesModal({ open, onClose }: CheckUpdatesModalProps) {
variant="primary"
disabled={downloadBusy}
onClick={() => {
if (res?.outcome !== 'available') return;
setDownloadBusy(true);
setProgress({ phase: 'downloading', version: res.version, percent: 0 });
void getDndApi()
.invoke(ipcChannels.updater.downloadAndRestart, {})
.invoke(ipcChannels.updater.downloadAndRestart, { version: res.version })
.then((r) => {
if (!r.ok) {
setDownloadBusy(false);
setRes({ outcome: 'error', message: r.message });
setProgress({ phase: 'error', message: r.message });
}
})
.catch((e: unknown) => {
const message = e instanceof Error ? e.message : String(e);
setDownloadBusy(false);
setRes({
outcome: 'error',
message: e instanceof Error ? e.message : String(e),
});
setRes({ outcome: 'error', message });
setProgress({ phase: 'error', message });
});
}}
>
@@ -1076,7 +1132,7 @@ function CheckUpdatesModal({ open, onClose }: CheckUpdatesModalProps) {
</>
) : showUpdateBusy ? (
<Button variant="primary" disabled>
{t('updates.downloading')}
{progress?.phase === 'installing' ? t('updates.stage.installing') : t('updates.downloading')}
</Button>
) : (
<Button variant="primary" disabled={downloadBusy} onClick={onClose}>