release: 4.2.3 improve updates and startup UX

This commit is contained in:
xRangerDE 2026-03-06 02:34:16 +01:00
parent 1005b583bd
commit b7cd8fbec2
12 changed files with 745 additions and 64 deletions

4
package-lock.json generated
View File

@ -1,12 +1,12 @@
{
"name": "twitch-vod-manager",
"version": "4.2.2",
"version": "4.2.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "twitch-vod-manager",
"version": "4.2.2",
"version": "4.2.3",
"license": "MIT",
"dependencies": {
"axios": "^1.6.0",

View File

@ -1,6 +1,6 @@
{
"name": "twitch-vod-manager",
"version": "4.2.2",
"version": "4.2.3",
"description": "Twitch VOD Manager - Download Twitch VODs easily",
"main": "dist/main.js",
"author": "xRangerDE",

View File

@ -18,6 +18,32 @@
<button id="updateButton" onclick="downloadUpdate()">Jetzt herunterladen</button>
</div>
<div class="modal-overlay" id="updateModal" onclick="handleUpdateModalOverlayClick(event)">
<div class="modal update-modal">
<button class="modal-close" onclick="dismissUpdateModal()">x</button>
<div class="update-modal-eyebrow" id="updateModalEyebrow">Updates</div>
<h2 id="updateModalTitle">Update verfugbar</h2>
<p class="update-modal-message" id="updateModalMessage">Version 0.0.0 ist verfugbar. Jetzt herunterladen?</p>
<div class="update-modal-meta" id="updateModalMeta" style="display:none;"></div>
<div class="update-changelog-card" id="updateChangelogCard" style="display:none;">
<div class="update-changelog-header">
<span class="update-changelog-label" id="updateChangelogLabel">Changelog</span>
<button type="button" class="update-changelog-toggle" id="updateChangelogToggle" onclick="toggleUpdateChangelog()">Changelog anzeigen</button>
</div>
<div class="update-changelog-panel" id="updateChangelogPanel" hidden>
<div class="update-changelog-content" id="updateChangelogContent"></div>
<p class="update-changelog-empty" id="updateChangelogEmpty" hidden>Kein Changelog verfugbar.</p>
</div>
</div>
<div class="modal-actions update-modal-actions">
<button class="btn-secondary" id="updateModalDismissBtn" type="button" onclick="dismissUpdateModal()">Nein</button>
<button class="btn-primary" id="updateModalConfirmBtn" type="button" onclick="confirmUpdateModal()">Ja, herunterladen</button>
</div>
</div>
</div>
<!-- Clip Dialog Modal -->
<div class="modal-overlay" id="clipModal">
<div class="modal" style="background: #2b2b2b; max-width: 500px;">
@ -182,7 +208,6 @@
<div class="queue-section">
<div class="queue-header">
<span class="queue-title" id="queueTitleText">Warteschlange</span>
<span class="health-badge unknown" id="healthBadge">System: Unbekannt</span>
<span class="queue-count" id="queueCount">0</span>
</div>
<div class="queue-list" id="queueList"></div>
@ -462,7 +487,10 @@
</div>
<div class="settings-card">
<h3 id="preflightTitle">System-Check</h3>
<div class="form-row" style="align-items:center; justify-content:space-between; margin-bottom: 10px;">
<h3 id="preflightTitle" style="margin: 0;">System-Check</h3>
<span class="health-badge unknown" id="healthBadge">System: Unbekannt</span>
</div>
<div class="form-row" style="margin-bottom: 10px;">
<button class="btn-secondary" id="btnPreflightRun" onclick="runPreflight(false)">Check ausfuhren</button>
<button class="btn-secondary" id="btnPreflightFix" onclick="runPreflight(true)">Auto-Fix Tools</button>

View File

@ -42,7 +42,7 @@ const DEBUG_LOG_TRIM_TO_BYTES = 4 * 1024 * 1024;
const AUTO_UPDATE_CHECK_INTERVAL_MS = 10 * 60 * 1000;
const AUTO_UPDATE_STARTUP_CHECK_DELAY_MS = 5000;
const AUTO_UPDATE_MIN_CHECK_GAP_MS = 45 * 1000;
const AUTO_UPDATE_AUTO_DOWNLOAD = true;
const AUTO_UPDATE_AUTO_DOWNLOAD = false;
const AUTO_UPDATE_CHECK_TIMEOUT_MS = 30 * 1000;
const CACHE_CLEANUP_INTERVAL_MS = 60 * 1000;
const MAX_LOGIN_TO_USER_ID_CACHE_ENTRIES = 4096;
@ -212,6 +212,14 @@ interface VideoInfo {
fps: number;
}
interface ReleaseUpdateInfo {
tagName?: string;
version?: string;
releaseDate?: string;
releaseName?: string;
releaseNotes?: string;
}
// ==========================================
// CONFIG MANAGEMENT
// ==========================================
@ -398,6 +406,8 @@ let bundledFFprobePath: string | null = null;
let streamlinkPathCache: string | null = null;
let ffmpegPathCache: string | null = null;
let ffprobePathCache: string | null = null;
let verifiedStreamlinkCommandKey: string | null = null;
let verifiedFfmpegCommandKey: string | null = null;
let bundledToolPathSignature = '';
let bundledToolPathRefreshedAt = 0;
let debugLogFlushTimer: NodeJS.Timeout | null = null;
@ -411,6 +421,7 @@ let autoUpdateDownloadInProgress = false;
let lastAutoUpdateCheckAt = 0;
let latestKnownUpdateVersion: string | null = null;
let downloadedUpdateVersion: string | null = null;
let latestReleaseUpdateInfo: ReleaseUpdateInfo | null = null;
let twitchLoginInFlight: Promise<boolean> | null = null;
// ==========================================
@ -510,11 +521,17 @@ async function runPreflight(autoFix = false): Promise<PreflightResult> {
const streamlinkCmd = getStreamlinkCommand();
checks.streamlink = canExecuteCommand(streamlinkCmd.command, [...streamlinkCmd.prefixArgs, '--version']);
if (checks.streamlink) {
cacheVerifiedStreamlinkCommand(streamlinkCmd.command, [...streamlinkCmd.prefixArgs, '--version']);
}
const ffmpegPath = getFFmpegPath();
const ffprobePath = getFFprobePath();
checks.ffmpeg = canExecuteCommand(ffmpegPath, ['-version']);
checks.ffprobe = canExecuteCommand(ffprobePath, ['-version']);
if (checks.ffmpeg && checks.ffprobe) {
cacheVerifiedFfmpegCommands(ffmpegPath, ffprobePath);
}
const messages: string[] = [];
if (!checks.internet) messages.push('Keine Internetverbindung erkannt.');
@ -673,6 +690,31 @@ function canExecuteCommand(command: string, args: string[]): boolean {
}
}
function getCommandCacheKey(command: string, args: string[]): string {
return [command, ...args].join('\u0000');
}
function cacheVerifiedStreamlinkCommand(command: string, args: string[]): void {
verifiedStreamlinkCommandKey = getCommandCacheKey(command, args);
}
function isVerifiedStreamlinkCommand(command: string, args: string[]): boolean {
return verifiedStreamlinkCommandKey === getCommandCacheKey(command, args);
}
function cacheVerifiedFfmpegCommands(ffmpegCommand: string, ffprobeCommand: string): void {
verifiedFfmpegCommandKey = getCommandCacheKey(ffmpegCommand, [ffprobeCommand]);
}
function isVerifiedFfmpegCommands(ffmpegCommand: string, ffprobeCommand: string): boolean {
return verifiedFfmpegCommandKey === getCommandCacheKey(ffmpegCommand, [ffprobeCommand]);
}
function invalidateVerifiedToolCaches(): void {
verifiedStreamlinkCommandKey = null;
verifiedFfmpegCommandKey = null;
}
function findFileRecursive(rootDir: string, fileName: string): string | null {
if (!fs.existsSync(rootDir)) return null;
@ -729,6 +771,7 @@ function refreshBundledToolPaths(force = false): void {
ffmpegPathCache = null;
ffprobePathCache = null;
streamlinkCommandCache = null;
invalidateVerifiedToolCaches();
}
}
@ -791,7 +834,13 @@ async function ensureStreamlinkInstalled(): Promise<boolean> {
refreshBundledToolPaths();
const current = getStreamlinkCommand();
if (canExecuteCommand(current.command, [...current.prefixArgs, '--version'])) {
const versionArgs = [...current.prefixArgs, '--version'];
if (isVerifiedStreamlinkCommand(current.command, versionArgs)) {
return true;
}
if (canExecuteCommand(current.command, versionArgs)) {
cacheVerifiedStreamlinkCommand(current.command, versionArgs);
return true;
}
@ -833,7 +882,11 @@ async function ensureStreamlinkInstalled(): Promise<boolean> {
streamlinkCommandCache = null;
const cmd = getStreamlinkCommand();
const works = canExecuteCommand(cmd.command, [...cmd.prefixArgs, '--version']);
const installedVersionArgs = [...cmd.prefixArgs, '--version'];
const works = canExecuteCommand(cmd.command, installedVersionArgs);
if (works) {
cacheVerifiedStreamlinkCommand(cmd.command, installedVersionArgs);
}
appendDebugLog('streamlink-install-finished', { works, command: cmd.command, prefixArgs: cmd.prefixArgs });
return works;
} catch (e) {
@ -847,7 +900,12 @@ async function ensureFfmpegInstalled(): Promise<boolean> {
const ffmpegPath = getFFmpegPath();
const ffprobePath = getFFprobePath();
if (isVerifiedFfmpegCommands(ffmpegPath, ffprobePath)) {
return true;
}
if (canExecuteCommand(ffmpegPath, ['-version']) && canExecuteCommand(ffprobePath, ['-version'])) {
cacheVerifiedFfmpegCommands(ffmpegPath, ffprobePath);
return true;
}
@ -875,6 +933,9 @@ async function ensureFfmpegInstalled(): Promise<boolean> {
const newFfmpegPath = getFFmpegPath();
const newFfprobePath = getFFprobePath();
const works = canExecuteCommand(newFfmpegPath, ['-version']) && canExecuteCommand(newFfprobePath, ['-version']);
if (works) {
cacheVerifiedFfmpegCommands(newFfmpegPath, newFfprobePath);
}
appendDebugLog('ffmpeg-install-finished', { works, ffmpeg: newFfmpegPath, ffprobe: newFfprobePath });
return works;
} catch (e) {
@ -2554,15 +2615,21 @@ async function downloadVOD(
};
}
onProgress({
id: item.id,
progress: -1,
speed: '',
eta: '',
status: 'Prufe Download-Tools...',
currentPart: 0,
totalParts: 0
});
const streamlinkCmd = getStreamlinkCommand();
const streamlinkVersionArgs = [...streamlinkCmd.prefixArgs, '--version'];
const streamlinkAlreadyVerified = isVerifiedStreamlinkCommand(streamlinkCmd.command, streamlinkVersionArgs);
if (!streamlinkAlreadyVerified) {
onProgress({
id: item.id,
progress: -1,
speed: '',
eta: '',
status: 'Prufe Download-Tools...',
currentPart: 0,
totalParts: 0
});
}
const streamlinkReady = await ensureStreamlinkInstalled();
if (!streamlinkReady) {
@ -2931,9 +2998,7 @@ function createWindow(): void {
}
if (autoUpdateReadyToInstall && downloadedUpdateVersion) {
mainWindow?.webContents.send('update-downloaded', {
version: downloadedUpdateVersion
});
mainWindow?.webContents.send('update-downloaded', buildUpdateInfoPayload(downloadedUpdateVersion));
}
});
@ -2958,6 +3023,62 @@ function hasNewerKnownUpdateThanDownloaded(): boolean {
return isNewerUpdateVersion(latestKnownUpdateVersion, downloadedUpdateVersion);
}
function normalizeReleaseVersionCandidate(value: unknown): string | undefined {
if (typeof value !== 'string') {
return undefined;
}
const trimmed = value.trim();
if (!trimmed) {
return undefined;
}
return normalizeUpdateVersion(trimmed) || trimmed.replace(/^v/i, '');
}
function cacheLatestReleaseUpdateInfo(releaseData: any): void {
if (!releaseData || typeof releaseData !== 'object') {
return;
}
const tagName = typeof releaseData.tag_name === 'string' ? releaseData.tag_name.trim() : '';
const version = normalizeReleaseVersionCandidate(tagName)
|| normalizeReleaseVersionCandidate(releaseData.name);
const releaseName = typeof releaseData.name === 'string' ? releaseData.name.trim() : '';
const releaseNotes = typeof releaseData.body === 'string' ? releaseData.body : '';
const releaseDate = typeof releaseData.published_at === 'string'
? releaseData.published_at
: (typeof releaseData.created_at === 'string' ? releaseData.created_at : undefined);
latestReleaseUpdateInfo = {
tagName: tagName || undefined,
version,
releaseDate,
releaseName: releaseName || undefined,
releaseNotes: releaseNotes.trim() ? releaseNotes : undefined
};
}
function buildUpdateInfoPayload(version: string, releaseDate?: string): {
version: string;
releaseDate?: string;
releaseName?: string;
releaseNotes?: string;
} {
const normalizedVersion = normalizeReleaseVersionCandidate(version) || version;
const cachedVersion = latestReleaseUpdateInfo?.version
? (normalizeReleaseVersionCandidate(latestReleaseUpdateInfo.version) || latestReleaseUpdateInfo.version)
: undefined;
const hasMatchingReleaseInfo = !cachedVersion || cachedVersion === normalizedVersion;
return {
version: normalizedVersion,
releaseDate: releaseDate || (hasMatchingReleaseInfo ? latestReleaseUpdateInfo?.releaseDate : undefined),
releaseName: hasMatchingReleaseInfo ? latestReleaseUpdateInfo?.releaseName : undefined,
releaseNotes: hasMatchingReleaseInfo ? latestReleaseUpdateInfo?.releaseNotes : undefined
};
}
async function requestUpdateCheck(source: UpdateCheckSource, force = false): Promise<{ started: boolean; reason?: string }> {
if (autoUpdateCheckInProgress) {
return { started: false, reason: 'in-progress' };
@ -2981,7 +3102,8 @@ async function requestUpdateCheck(source: UpdateCheckSource, force = false): Pro
'User-Agent': 'Twitch-VOD-Manager'
}
});
const tagName = giteaRes.data?.tag_name;
cacheLatestReleaseUpdateInfo(giteaRes.data);
const tagName = latestReleaseUpdateInfo?.tagName || giteaRes.data?.tag_name;
if (tagName) {
autoUpdater.setFeedURL({
provider: 'generic',
@ -3120,18 +3242,13 @@ function setupAutoUpdater() {
if (hasAlreadyDownloadedThisVersion) {
if (mainWindow) {
mainWindow.webContents.send('update-downloaded', {
version: displayVersion
});
mainWindow.webContents.send('update-downloaded', buildUpdateInfoPayload(displayVersion, info.releaseDate));
}
return;
}
if (mainWindow) {
mainWindow.webContents.send('update-available', {
version: displayVersion,
releaseDate: info.releaseDate
});
mainWindow.webContents.send('update-available', buildUpdateInfoPayload(displayVersion, info.releaseDate));
}
if (AUTO_UPDATE_AUTO_DOWNLOAD) {
@ -3166,9 +3283,7 @@ function setupAutoUpdater() {
latestKnownUpdateVersion = downloadedVersion;
}
if (mainWindow) {
mainWindow.webContents.send('update-downloaded', {
version: downloadedVersion
});
mainWindow.webContents.send('update-downloaded', buildUpdateInfoPayload(downloadedVersion, info.releaseDate));
}
});

View File

@ -168,7 +168,7 @@ contextBridge.exposeInMainWorld('api', {
onUpdateChecking: (callback: () => void) => {
ipcRenderer.on('update-checking', () => callback());
},
onUpdateAvailable: (callback: (info: { version: string; releaseDate?: string }) => void) => {
onUpdateAvailable: (callback: (info: { version: string; releaseDate?: string; releaseName?: string; releaseNotes?: string }) => void) => {
ipcRenderer.on('update-available', (_, info) => callback(info));
},
onUpdateNotAvailable: (callback: () => void) => {
@ -177,7 +177,7 @@ contextBridge.exposeInMainWorld('api', {
onUpdateDownloadProgress: (callback: (progress: { percent: number; bytesPerSecond: number; transferred: number; total: number }) => void) => {
ipcRenderer.on('update-download-progress', (_, progress) => callback(progress));
},
onUpdateDownloaded: (callback: (info: { version: string }) => void) => {
onUpdateDownloaded: (callback: (info: { version: string; releaseDate?: string; releaseName?: string; releaseNotes?: string }) => void) => {
ipcRenderer.on('update-downloaded', (_, info) => callback(info));
},
onUpdateError: (callback: (payload: { message: string }) => void) => {

View File

@ -126,6 +126,8 @@ interface ClipDialogData {
interface UpdateInfo {
version: string;
releaseDate?: string;
releaseName?: string;
releaseNotes?: string;
}
interface UpdateDownloadProgress {

View File

@ -208,6 +208,18 @@ const UI_TEXT_DE = {
downloadNow: 'Jetzt herunterladen',
downloadLabel: 'Download',
ready: 'bereit zur Installation!',
installNow: 'Jetzt installieren'
installNow: 'Jetzt installieren',
modalAvailableTitle: 'Update verfugbar',
modalAvailableMessage: 'Version {version} ist verfugbar. Jetzt herunterladen?',
modalReadyTitle: 'Update bereit',
modalReadyMessage: 'Version {version} wurde heruntergeladen. Jetzt installieren?',
modalDismiss: 'Nein',
modalDownloadConfirm: 'Ja, herunterladen',
modalInstallConfirm: 'Ja, installieren',
changelogLabel: 'Changelog',
showChangelog: 'Changelog anzeigen',
hideChangelog: 'Changelog ausblenden',
noChangelog: 'Kein Changelog verfugbar.',
releasedLabel: 'Release'
}
} as const;

View File

@ -208,6 +208,18 @@ const UI_TEXT_EN = {
downloadNow: 'Download now',
downloadLabel: 'Download',
ready: 'ready to install!',
installNow: 'Install now'
installNow: 'Install now',
modalAvailableTitle: 'Update available',
modalAvailableMessage: 'Version {version} is available. Download it now?',
modalReadyTitle: 'Update ready',
modalReadyMessage: 'Version {version} has been downloaded. Install it now?',
modalDismiss: 'No',
modalDownloadConfirm: 'Yes, download',
modalInstallConfirm: 'Yes, install',
changelogLabel: 'Changelog',
showChangelog: 'Show changelog',
hideChangelog: 'Hide changelog',
noChangelog: 'No changelog available.',
releasedLabel: 'Release'
}
} as const;

View File

@ -300,6 +300,17 @@ function collectCredentialsPayload(): Partial<AppConfig> {
};
}
function syncPartMinutesFieldState(): void {
const downloadMode = byId<HTMLSelectElement>('downloadMode').value;
const partMinutes = byId<HTMLInputElement>('partMinutes');
const label = byId<HTMLElement>('partMinutesLabel');
const isSplitMode = downloadMode === 'parts';
partMinutes.disabled = !isSplitMode;
partMinutes.setAttribute('aria-disabled', String(!isSplitMode));
label.classList.toggle('input-disabled', !isSplitMode);
}
function collectDownloadSettingsPayload(): Partial<AppConfig> {
return {
download_mode: byId<HTMLSelectElement>('downloadMode').value as 'parts' | 'full',
@ -366,6 +377,7 @@ function syncSettingsFormFromConfig(): void {
byId<HTMLInputElement>('vodFilenameTemplate').value = (config.filename_template_vod as string) || '{title}.mp4';
byId<HTMLInputElement>('partsFilenameTemplate').value = (config.filename_template_parts as string) || '{date}_Part{part_padded}.mp4';
byId<HTMLInputElement>('defaultClipFilenameTemplate').value = (config.filename_template_clip as string) || '{date}_{part}.mp4';
syncPartMinutesFieldState();
validateFilenameTemplates();
lastPersistedSettingsFingerprint = getSettingsFingerprint({});
}
@ -489,6 +501,8 @@ function initSettingsAutoSave(): void {
void flushSettingsAutoSave(false);
};
byId<HTMLSelectElement>('downloadMode').addEventListener('change', syncPartMinutesFieldState);
for (const id of immediateSaveIds) {
const element = byId<HTMLInputElement | HTMLSelectElement>(id);
element.addEventListener('change', triggerImmediateSave);

View File

@ -130,6 +130,13 @@ function applyLanguageToStaticUI(): void {
setText('runtimeMetricsOutput', UI_TEXT.static.runtimeMetricsLoading);
setText('updateText', UI_TEXT.updates.bannerDefault);
setText('updateButton', UI_TEXT.updates.downloadNow);
setText('updateModalEyebrow', UI_TEXT.static.updateTitle);
setText('updateModalTitle', UI_TEXT.updates.modalAvailableTitle);
setText('updateModalDismissBtn', UI_TEXT.updates.modalDismiss);
setText('updateModalConfirmBtn', UI_TEXT.updates.modalDownloadConfirm);
setText('updateChangelogLabel', UI_TEXT.updates.changelogLabel);
setText('updateChangelogToggle', UI_TEXT.updates.showChangelog);
setText('updateChangelogEmpty', UI_TEXT.updates.noChangelog);
setPlaceholder('newStreamer', UI_TEXT.static.streamerPlaceholder);
const status = document.getElementById('statusText')?.textContent?.trim() || '';
@ -141,6 +148,11 @@ function applyLanguageToStaticUI(): void {
if (typeof guideRefresh === 'function') {
guideRefresh();
}
const updateRefresh = (window as unknown as { refreshUpdateUiTexts?: () => void }).refreshUpdateUiTexts;
if (typeof updateRefresh === 'function') {
updateRefresh();
}
}
function localizeCurrentStatusText(current: string): string {

View File

@ -1,7 +1,13 @@
let updateCheckInProgress = false;
let updateDownloadInProgress = false;
let manualUpdateCheckPending = false;
let manualUpdateOutcomeHandled = false;
let latestUpdateVersion = '';
let latestUpdateInfo: UpdateInfo | null = null;
let latestDownloadProgress: UpdateDownloadProgress | null = null;
let updateBannerState: 'idle' | 'available' | 'downloading' | 'ready' = 'idle';
let updateChangelogExpanded = false;
let shouldOpenUpdateModalOnAvailable = false;
function notifyUpdate(message: string, type: 'info' | 'warn' = 'info'): void {
const toastFn = (window as unknown as { showAppToast?: (msg: string, kind?: 'info' | 'warn') => void }).showAppToast;
@ -12,6 +18,55 @@ function notifyUpdate(message: string, type: 'info' | 'warn' = 'info'): void {
}
}
function rememberUpdateInfo(info?: UpdateInfo | null): UpdateInfo {
const version = info?.version || latestUpdateVersion || latestUpdateInfo?.version || '?';
latestUpdateVersion = version;
latestUpdateInfo = {
...(latestUpdateInfo || { version }),
...(info || {}),
version
};
return latestUpdateInfo;
}
function getActiveUpdateInfo(): UpdateInfo {
return rememberUpdateInfo();
}
function formatUpdateTemplate(template: string, version: string): string {
return template.replace(/\{version\}/g, version);
}
function formatReleaseDate(dateValue?: string): string {
if (!dateValue) {
return '';
}
const parsed = new Date(dateValue);
if (Number.isNaN(parsed.getTime())) {
return '';
}
return new Intl.DateTimeFormat(getIntlLocale(), { dateStyle: 'medium' }).format(parsed);
}
function getUpdateModalMetaText(info: UpdateInfo): string {
const parts: string[] = [];
const releaseName = (info.releaseName || '').trim();
const canonicalNames = new Set([info.version, `v${info.version}`]);
if (releaseName && !canonicalNames.has(releaseName)) {
parts.push(`${UI_TEXT.updates.releasedLabel}: ${releaseName}`);
}
const formattedDate = formatReleaseDate(info.releaseDate);
if (formattedDate) {
parts.push(formattedDate);
}
return parts.join(' | ');
}
function setCheckButtonCheckingState(enabled: boolean): void {
const btn = byId<HTMLButtonElement>('checkUpdateBtn');
btn.disabled = enabled;
@ -22,33 +77,293 @@ function showUpdateBanner(): void {
byId('updateBanner').style.display = 'flex';
}
function hideUpdateBanner(): void {
byId('updateBanner').style.display = 'none';
}
function setUpdateBannerAvailableUi(info: UpdateInfo): void {
const activeInfo = rememberUpdateInfo(info);
updateReady = false;
updateDownloadInProgress = false;
latestDownloadProgress = null;
updateBannerState = 'available';
showUpdateBanner();
byId('updateProgress').style.display = 'none';
const bar = byId('updateProgressBar');
bar.classList.remove('downloading');
bar.style.width = '0%';
byId('updateText').textContent = `Version ${activeInfo.version} ${UI_TEXT.updates.available}`;
const button = byId<HTMLButtonElement>('updateButton');
button.textContent = UI_TEXT.updates.downloadNow;
button.disabled = false;
}
function setDownloadPendingUi(): void {
updateReady = false;
updateBannerState = 'downloading';
showUpdateBanner();
const button = byId<HTMLButtonElement>('updateButton');
button.textContent = UI_TEXT.updates.downloading;
button.disabled = true;
byId('updateProgress').style.display = 'block';
const bar = byId('updateProgressBar');
bar.classList.add('downloading');
bar.style.width = '30%';
bar.style.width = latestDownloadProgress ? `${latestDownloadProgress.percent}%` : '30%';
if (!latestDownloadProgress) {
byId('updateText').textContent = `Version ${latestUpdateVersion || '?'} ${UI_TEXT.updates.downloading}`;
}
}
function setDownloadReadyUi(version: string): void {
function setDownloadReadyUi(info?: UpdateInfo): void {
const activeInfo = rememberUpdateInfo(info);
showUpdateBanner();
updateReady = true;
updateDownloadInProgress = false;
latestUpdateVersion = version || latestUpdateVersion;
updateBannerState = 'ready';
latestDownloadProgress = null;
const bar = byId('updateProgressBar');
bar.classList.remove('downloading');
bar.style.width = '100%';
byId('updateText').textContent = `Version ${latestUpdateVersion || '?'} ${UI_TEXT.updates.ready}`;
byId('updateProgress').style.display = 'block';
byId('updateText').textContent = `Version ${activeInfo.version} ${UI_TEXT.updates.ready}`;
const button = byId<HTMLButtonElement>('updateButton');
button.textContent = UI_TEXT.updates.installNow;
button.disabled = false;
}
function appendInlineMarkdown(target: HTMLElement, text: string): void {
const parts = text.split(/(\*\*[^*]+\*\*)/g);
for (const part of parts) {
if (!part) {
continue;
}
const strongMatch = part.match(/^\*\*(.+)\*\*$/);
if (strongMatch) {
const strong = document.createElement('strong');
strong.textContent = strongMatch[1].trim();
target.appendChild(strong);
continue;
}
target.appendChild(document.createTextNode(part));
}
}
function renderUpdateChangelog(notes?: string): void {
const card = byId<HTMLElement>('updateChangelogCard');
const panel = byId<HTMLElement>('updateChangelogPanel');
const content = byId<HTMLElement>('updateChangelogContent');
const empty = byId<HTMLElement>('updateChangelogEmpty');
const normalized = (notes || '').replace(/\r/g, '').trim();
content.innerHTML = '';
empty.hidden = true;
if (!normalized) {
card.style.display = 'none';
panel.hidden = true;
updateChangelogExpanded = false;
return;
}
card.style.display = 'block';
const fragment = document.createDocumentFragment();
let currentList: HTMLUListElement | null = null;
let lastBlockWasHeading = false;
const flushList = (): void => {
currentList = null;
};
const ensureList = (): HTMLUListElement => {
if (currentList) {
return currentList;
}
currentList = document.createElement('ul');
currentList.className = 'update-changelog-list';
fragment.appendChild(currentList);
return currentList;
};
const appendListItem = (line: string): void => {
const item = document.createElement('li');
appendInlineMarkdown(item, line);
ensureList().appendChild(item);
};
for (const rawLine of normalized.split('\n')) {
const line = rawLine.trim();
if (!line) {
flushList();
lastBlockWasHeading = false;
continue;
}
const boldHeadingMatch = line.match(/^\*\*(.+?)\*\*:?$/);
const markdownHeadingMatch = line.match(/^#{1,6}\s+(.+)$/);
if (boldHeadingMatch || markdownHeadingMatch) {
flushList();
const heading = document.createElement('h4');
heading.className = 'update-changelog-heading';
heading.textContent = (boldHeadingMatch?.[1] || markdownHeadingMatch?.[1] || '').trim();
fragment.appendChild(heading);
lastBlockWasHeading = true;
continue;
}
const listMatch = line.match(/^(?:[-*+]\s+|\d+\.\s+)(.+)$/);
if (listMatch) {
appendListItem(listMatch[1].trim());
lastBlockWasHeading = false;
continue;
}
if (lastBlockWasHeading) {
appendListItem(line);
lastBlockWasHeading = false;
continue;
}
flushList();
const paragraph = document.createElement('p');
paragraph.className = 'update-changelog-paragraph';
appendInlineMarkdown(paragraph, line);
fragment.appendChild(paragraph);
lastBlockWasHeading = false;
}
if (!fragment.childNodes.length) {
empty.hidden = false;
} else {
content.appendChild(fragment);
}
panel.hidden = !updateChangelogExpanded;
}
function refreshUpdateChangelogToggleText(): void {
const toggle = byId<HTMLButtonElement>('updateChangelogToggle');
const card = byId<HTMLElement>('updateChangelogCard');
if (card.style.display === 'none') {
return;
}
toggle.textContent = updateChangelogExpanded ? UI_TEXT.updates.hideChangelog : UI_TEXT.updates.showChangelog;
}
function refreshUpdateModalTexts(): void {
const info = getActiveUpdateInfo();
const isReady = updateReady;
byId('updateModalTitle').textContent = isReady
? UI_TEXT.updates.modalReadyTitle
: UI_TEXT.updates.modalAvailableTitle;
byId('updateModalMessage').textContent = formatUpdateTemplate(
isReady ? UI_TEXT.updates.modalReadyMessage : UI_TEXT.updates.modalAvailableMessage,
info.version
);
byId('updateModalDismissBtn').textContent = UI_TEXT.updates.modalDismiss;
byId('updateModalConfirmBtn').textContent = isReady
? UI_TEXT.updates.modalInstallConfirm
: UI_TEXT.updates.modalDownloadConfirm;
byId('updateChangelogLabel').textContent = UI_TEXT.updates.changelogLabel;
byId('updateChangelogEmpty').textContent = UI_TEXT.updates.noChangelog;
const metaText = getUpdateModalMetaText(info);
const meta = byId('updateModalMeta');
meta.textContent = metaText;
meta.style.display = metaText ? 'block' : 'none';
renderUpdateChangelog(info.releaseNotes);
refreshUpdateChangelogToggleText();
}
function openUpdateModal(info?: UpdateInfo): void {
rememberUpdateInfo(info);
updateChangelogExpanded = false;
byId('updateModal').classList.add('show');
refreshUpdateModalTexts();
}
function dismissUpdateModal(): void {
byId('updateModal').classList.remove('show');
}
function confirmUpdateModal(): void {
dismissUpdateModal();
if (updateReady) {
void window.api.installUpdate();
return;
}
downloadUpdate();
}
function toggleUpdateChangelog(): void {
const card = byId<HTMLElement>('updateChangelogCard');
if (card.style.display === 'none') {
return;
}
updateChangelogExpanded = !updateChangelogExpanded;
byId<HTMLElement>('updateChangelogPanel').hidden = !updateChangelogExpanded;
refreshUpdateChangelogToggleText();
}
function handleUpdateModalOverlayClick(event: MouseEvent): void {
if (event.target === byId('updateModal')) {
dismissUpdateModal();
}
}
function refreshUpdateUiTexts(): void {
const button = byId<HTMLButtonElement>('updateButton');
const progress = byId('updateProgress');
const bar = byId('updateProgressBar');
if (updateBannerState === 'available' && latestUpdateInfo) {
setUpdateBannerAvailableUi(latestUpdateInfo);
} else if (updateBannerState === 'downloading') {
button.textContent = UI_TEXT.updates.downloading;
button.disabled = true;
progress.style.display = 'block';
if (latestDownloadProgress) {
bar.classList.remove('downloading');
bar.style.width = `${latestDownloadProgress.percent}%`;
const mb = (latestDownloadProgress.transferred / 1024 / 1024).toFixed(1);
const totalMb = (latestDownloadProgress.total / 1024 / 1024).toFixed(1);
byId('updateText').textContent = `${UI_TEXT.updates.downloadLabel}: ${mb} / ${totalMb} MB (${latestDownloadProgress.percent.toFixed(0)}%)`;
} else {
setDownloadPendingUi();
}
} else if (updateBannerState === 'ready' && latestUpdateInfo) {
setDownloadReadyUi(latestUpdateInfo);
} else {
hideUpdateBanner();
progress.style.display = 'none';
bar.classList.remove('downloading');
bar.style.width = '0%';
byId('updateText').textContent = UI_TEXT.updates.bannerDefault;
button.textContent = UI_TEXT.updates.downloadNow;
button.disabled = false;
}
refreshUpdateModalTexts();
}
async function checkUpdateSilent(): Promise<void> {
try {
await window.api.checkUpdate();
@ -59,12 +374,16 @@ async function checkUpdateSilent(): Promise<void> {
async function checkUpdate(): Promise<void> {
manualUpdateCheckPending = true;
manualUpdateOutcomeHandled = false;
shouldOpenUpdateModalOnAvailable = true;
setCheckButtonCheckingState(true);
try {
const result = await window.api.checkUpdate();
if (result?.error) {
shouldOpenUpdateModalOnAvailable = false;
manualUpdateOutcomeHandled = true;
manualUpdateCheckPending = false;
updateCheckInProgress = false;
setCheckButtonCheckingState(false);
@ -74,14 +393,22 @@ async function checkUpdate(): Promise<void> {
const skippedReason = result?.skipped;
if (skippedReason === 'ready-to-install') {
shouldOpenUpdateModalOnAvailable = false;
manualUpdateOutcomeHandled = true;
manualUpdateCheckPending = false;
updateCheckInProgress = false;
setCheckButtonCheckingState(false);
notifyUpdate(UI_TEXT.updates.readyToInstall, 'info');
if (latestUpdateInfo || updateReady) {
openUpdateModal(getActiveUpdateInfo());
} else {
notifyUpdate(UI_TEXT.updates.readyToInstall, 'info');
}
return;
}
if (skippedReason === 'in-progress' || skippedReason === 'throttled') {
shouldOpenUpdateModalOnAvailable = false;
manualUpdateOutcomeHandled = true;
manualUpdateCheckPending = false;
updateCheckInProgress = false;
setCheckButtonCheckingState(false);
@ -94,11 +421,14 @@ async function checkUpdate(): Promise<void> {
setCheckButtonCheckingState(false);
window.setTimeout(() => {
if (!updateReady && byId('updateBanner').style.display !== 'flex') {
if (!manualUpdateOutcomeHandled && !updateReady && byId('updateBanner').style.display !== 'flex') {
shouldOpenUpdateModalOnAvailable = false;
notifyUpdate(UI_TEXT.updates.latest, 'info');
}
}, 2500);
} catch {
shouldOpenUpdateModalOnAvailable = false;
manualUpdateOutcomeHandled = true;
manualUpdateCheckPending = false;
updateCheckInProgress = false;
setCheckButtonCheckingState(false);
@ -108,6 +438,7 @@ async function checkUpdate(): Promise<void> {
function downloadUpdate(): void {
if (updateReady) {
dismissUpdateModal();
void window.api.installUpdate();
return;
}
@ -118,21 +449,23 @@ function downloadUpdate(): void {
}
updateDownloadInProgress = true;
latestDownloadProgress = null;
dismissUpdateModal();
setDownloadPendingUi();
void window.api.downloadUpdate().then((result) => {
if (result?.error) {
updateDownloadInProgress = false;
const button = byId<HTMLButtonElement>('updateButton');
button.textContent = UI_TEXT.updates.downloadNow;
button.disabled = false;
byId('updateProgressBar').classList.remove('downloading');
if (latestUpdateInfo) {
setUpdateBannerAvailableUi(latestUpdateInfo);
}
notifyUpdate(UI_TEXT.updates.downloadFailed, 'warn');
return;
}
if (result?.skipped === 'ready-to-install') {
setDownloadReadyUi(latestUpdateVersion);
setDownloadReadyUi(getActiveUpdateInfo());
openUpdateModal(getActiveUpdateInfo());
return;
}
@ -141,10 +474,9 @@ function downloadUpdate(): void {
}
}).catch(() => {
updateDownloadInProgress = false;
const button = byId<HTMLButtonElement>('updateButton');
button.textContent = UI_TEXT.updates.downloadNow;
button.disabled = false;
byId('updateProgressBar').classList.remove('downloading');
if (latestUpdateInfo) {
setUpdateBannerAvailableUi(latestUpdateInfo);
}
notifyUpdate(UI_TEXT.updates.downloadFailed, 'warn');
});
}
@ -157,45 +489,58 @@ window.api.onUpdateChecking(() => {
});
window.api.onUpdateAvailable((info: UpdateInfo) => {
const activeInfo = rememberUpdateInfo(info);
updateCheckInProgress = false;
updateReady = false;
updateDownloadInProgress = true;
updateDownloadInProgress = false;
manualUpdateCheckPending = false;
latestUpdateVersion = info.version;
manualUpdateOutcomeHandled = true;
latestDownloadProgress = null;
setCheckButtonCheckingState(false);
showUpdateBanner();
byId('updateText').textContent = `Version ${info.version} ${UI_TEXT.updates.available}`;
byId('updateButton').textContent = UI_TEXT.updates.downloading;
byId<HTMLButtonElement>('updateButton').disabled = true;
byId('updateProgress').style.display = 'block';
byId('updateProgressBar').classList.add('downloading');
setUpdateBannerAvailableUi(activeInfo);
if (shouldOpenUpdateModalOnAvailable) {
openUpdateModal(activeInfo);
}
shouldOpenUpdateModalOnAvailable = false;
});
window.api.onUpdateNotAvailable(() => {
updateCheckInProgress = false;
setCheckButtonCheckingState(false);
manualUpdateOutcomeHandled = true;
if (manualUpdateCheckPending) {
notifyUpdate(UI_TEXT.updates.latest, 'info');
}
shouldOpenUpdateModalOnAvailable = false;
manualUpdateCheckPending = false;
});
window.api.onUpdateDownloadProgress((progress: UpdateDownloadProgress) => {
updateDownloadInProgress = true;
updateBannerState = 'downloading';
latestDownloadProgress = progress;
const bar = byId('updateProgressBar');
bar.classList.remove('downloading');
bar.style.width = progress.percent + '%';
showUpdateBanner();
byId('updateProgress').style.display = 'block';
const mb = (progress.transferred / 1024 / 1024).toFixed(1);
const totalMb = (progress.total / 1024 / 1024).toFixed(1);
byId('updateText').textContent = `${UI_TEXT.updates.downloadLabel}: ${mb} / ${totalMb} MB (${progress.percent.toFixed(0)}%)`;
});
window.api.onUpdateDownloaded((info: UpdateInfo) => {
setDownloadReadyUi(info.version);
const activeInfo = rememberUpdateInfo(info);
setDownloadReadyUi(activeInfo);
openUpdateModal(activeInfo);
});
window.api.onUpdateError(() => {
@ -203,14 +548,19 @@ window.api.onUpdateError(() => {
const wasDownloading = updateDownloadInProgress;
updateDownloadInProgress = false;
manualUpdateCheckPending = false;
manualUpdateOutcomeHandled = true;
shouldOpenUpdateModalOnAvailable = false;
setCheckButtonCheckingState(false);
const button = byId<HTMLButtonElement>('updateButton');
if (!updateReady) {
button.textContent = UI_TEXT.updates.downloadNow;
button.disabled = false;
byId('updateProgressBar').classList.remove('downloading');
if (!updateReady && latestUpdateInfo) {
setUpdateBannerAvailableUi(latestUpdateInfo);
}
notifyUpdate(wasDownloading ? UI_TEXT.updates.downloadFailed : UI_TEXT.updates.checkFailed, 'warn');
});
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape' && byId('updateModal').classList.contains('show')) {
dismissUpdateModal();
}
});

View File

@ -606,6 +606,17 @@ body {
border-color: var(--accent);
}
.form-group input:not([type="checkbox"]):not([type="radio"]):disabled,
.form-group select:disabled {
opacity: 0.55;
cursor: not-allowed;
color: rgba(239, 239, 241, 0.7);
}
.input-disabled {
opacity: 0.65;
}
.form-group input[type="checkbox"],
.form-group input[type="radio"] {
width: auto;
@ -848,6 +859,131 @@ body {
cursor: not-allowed;
}
.update-modal {
max-width: 680px;
border: 1px solid rgba(255, 255, 255, 0.08);
background:
linear-gradient(180deg, rgba(145, 70, 255, 0.18) 0%, rgba(145, 70, 255, 0.05) 24%, rgba(14, 14, 16, 0.98) 100%),
var(--bg-card);
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.48);
}
.update-modal-eyebrow {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
border-radius: 999px;
background: rgba(145, 70, 255, 0.16);
color: #f1e7ff;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
margin-bottom: 14px;
}
.update-modal-message {
color: var(--text);
line-height: 1.6;
margin: -8px 0 12px;
}
.update-modal-meta {
color: var(--text-secondary);
font-size: 12px;
margin-bottom: 16px;
}
.update-modal-actions {
justify-content: flex-end;
}
.update-changelog-card {
margin-top: 10px;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 10px;
background: rgba(7, 7, 10, 0.42);
overflow: hidden;
}
.update-changelog-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
padding: 12px 14px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
.update-changelog-label {
font-size: 12px;
color: var(--text-secondary);
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.update-changelog-toggle {
background: transparent;
border: none;
color: #f3ecff;
cursor: pointer;
font-size: 13px;
font-weight: 600;
}
.update-changelog-toggle:hover {
color: white;
}
.update-changelog-panel {
max-height: 320px;
overflow: auto;
padding: 14px;
}
.update-changelog-content {
display: grid;
gap: 12px;
}
.update-changelog-heading {
font-size: 17px;
line-height: 1.25;
color: #ffffff;
margin: 0;
}
.update-changelog-paragraph {
margin: 0;
color: var(--text);
line-height: 1.6;
}
.update-changelog-list {
margin: 0;
padding-left: 18px;
color: var(--text);
display: grid;
gap: 8px;
}
.update-changelog-list li {
line-height: 1.5;
}
.update-changelog-content strong {
color: #ffffff;
font-weight: 700;
}
.update-changelog-empty {
margin: 0;
color: var(--text-secondary);
font-size: 13px;
}
#updateProgressBar.downloading {
width: 30% !important;
animation: indeterminate 1.5s ease-in-out infinite;