mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 06:25:41 +00:00
refactor(app-updater): split AppUpdater.tsx into focused modules (#681)
Extract the GitHub release probe, download/relaunch state and the markdown changelog renderer out of AppUpdater.tsx: - utils/appUpdaterHelpers.ts isNewer, fmtBytes, pickAsset, types - hooks/useAppUpdater.ts release probe + download/relaunch handlers - components/appUpdater/Changelog.tsx Pure code-move, no behaviour change. The AppShell call site is unchanged.
This commit is contained in:
committed by
GitHub
parent
cca000d3af
commit
b89b5d7c94
+10
-278
@@ -1,291 +1,23 @@
|
|||||||
import React, { useEffect, useState, useRef } from 'react';
|
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { open } from '@tauri-apps/plugin-shell';
|
import { open } from '@tauri-apps/plugin-shell';
|
||||||
import { listen } from '@tauri-apps/api/event';
|
|
||||||
import { dirname } from '@tauri-apps/api/path';
|
|
||||||
import { invoke } from '@tauri-apps/api/core';
|
|
||||||
import { ArrowUpCircle, CheckCircle2, ChevronDown, Download, FolderOpen, RefreshCw, ShieldCheck, X } from 'lucide-react';
|
import { ArrowUpCircle, CheckCircle2, ChevronDown, Download, FolderOpen, RefreshCw, ShieldCheck, X } from 'lucide-react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { version as currentVersion } from '../../package.json';
|
import { version as currentVersion } from '../../package.json';
|
||||||
import { IS_LINUX, IS_MACOS, IS_WINDOWS } from '../utils/platform';
|
import { fmtBytes } from '../utils/appUpdaterHelpers';
|
||||||
|
import { useAppUpdater } from '../hooks/useAppUpdater';
|
||||||
const SKIP_KEY = 'psysonic_skipped_update_version';
|
import Changelog from './appUpdater/Changelog';
|
||||||
|
|
||||||
// Semver comparison: returns true if `a` is newer than `b`
|
|
||||||
function isNewer(a: string, b: string): boolean {
|
|
||||||
const pa = a.replace(/^[^0-9]*/, '').split('.').map(Number);
|
|
||||||
const pb = b.replace(/^[^0-9]*/, '').split('.').map(Number);
|
|
||||||
for (let i = 0; i < 3; i++) {
|
|
||||||
if ((pa[i] ?? 0) > (pb[i] ?? 0)) return true;
|
|
||||||
if ((pa[i] ?? 0) < (pb[i] ?? 0)) return false;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function fmtBytes(n: number): string {
|
|
||||||
if (n < 1024 * 1024) return `${(n / 1024).toFixed(0)} KB`;
|
|
||||||
return `${(n / 1024 / 1024).toFixed(1)} MB`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Minimal inline-markdown renderer (bold, italic, code)
|
|
||||||
// IMPORTANT: regex must have NO nested capture groups — split() includes captured
|
|
||||||
// groups in the result, and nested groups produce undefined entries that crash on .startsWith()
|
|
||||||
function renderInline(text: string): React.ReactNode[] {
|
|
||||||
const parts = text.split(/(\*\*[^*]+\*\*|\*[^*]+\*|`[^`]+`)/g);
|
|
||||||
return parts.map((part, i) => {
|
|
||||||
if (!part) return null;
|
|
||||||
if (part.startsWith('**') && part.endsWith('**'))
|
|
||||||
return <strong key={i}>{part.slice(2, -2)}</strong>;
|
|
||||||
if (part.startsWith('*') && part.endsWith('*'))
|
|
||||||
return <em key={i}>{part.slice(1, -1)}</em>;
|
|
||||||
if (part.startsWith('`') && part.endsWith('`'))
|
|
||||||
return <code key={i} className="changelog-code">{part.slice(1, -1)}</code>;
|
|
||||||
return part;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderChangelog(body: string) {
|
|
||||||
return body.split('\n').map((line, i) => {
|
|
||||||
if (line.startsWith('### '))
|
|
||||||
return <div key={i} className="changelog-h3">{renderInline(line.slice(4))}</div>;
|
|
||||||
if (line.startsWith('#### '))
|
|
||||||
return <div key={i} className="changelog-h4">{renderInline(line.slice(5))}</div>;
|
|
||||||
if (line.startsWith('## '))
|
|
||||||
return null; // skip nested release headers in body
|
|
||||||
if (line.startsWith('- '))
|
|
||||||
return <div key={i} className="changelog-item">{renderInline(line.slice(2))}</div>;
|
|
||||||
if (line.trim() === '') return null;
|
|
||||||
return <div key={i} className="changelog-text">{renderInline(line)}</div>;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
interface GithubAsset {
|
|
||||||
name: string;
|
|
||||||
browser_download_url: string;
|
|
||||||
size: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ReleaseData {
|
|
||||||
version: string;
|
|
||||||
tag: string;
|
|
||||||
body: string;
|
|
||||||
assets: GithubAsset[];
|
|
||||||
}
|
|
||||||
|
|
||||||
function pickAsset(assets: GithubAsset[]): GithubAsset | undefined {
|
|
||||||
if (IS_WINDOWS) {
|
|
||||||
return assets.find(a => a.name.endsWith('-setup.exe'))
|
|
||||||
?? assets.find(a => a.name.endsWith('.exe'));
|
|
||||||
}
|
|
||||||
if (IS_MACOS) {
|
|
||||||
// Prefer Apple Silicon, fall back to Intel
|
|
||||||
return assets.find(a => a.name.endsWith('.dmg') && a.name.includes('aarch64'))
|
|
||||||
?? assets.find(a => a.name.endsWith('.dmg'));
|
|
||||||
}
|
|
||||||
if (IS_LINUX) {
|
|
||||||
// AppImage > deb > rpm
|
|
||||||
return assets.find(a => a.name.endsWith('.AppImage'))
|
|
||||||
?? assets.find(a => a.name.endsWith('.deb'))
|
|
||||||
?? assets.find(a => a.name.endsWith('.rpm'));
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
type DlState = 'idle' | 'downloading' | 'done' | 'error';
|
|
||||||
|
|
||||||
export default function AppUpdater() {
|
export default function AppUpdater() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [release, setRelease] = useState<ReleaseData | null>(null);
|
const {
|
||||||
const [dismissed, setDismissed] = useState(false);
|
release, dismissed, setDismissed, changelogOpen, setChangelogOpen,
|
||||||
const [changelogOpen, setChangelogOpen] = useState(false);
|
dlState, dlProgress, dlError, countdown,
|
||||||
const [isArch, setIsArch] = useState(false);
|
asset, showAurHint, useTauriUpdater, showInstallBtn, pct,
|
||||||
const [dlState, setDlState] = useState<DlState>('idle');
|
handleSkip, handleRestartNow, handleDownload, handleShowFolder,
|
||||||
const [dlProgress, setDlProgress] = useState({ bytes: 0, total: 0 });
|
} = useAppUpdater();
|
||||||
const [dlPath, setDlPath] = useState('');
|
|
||||||
const [dlError, setDlError] = useState('');
|
|
||||||
const [countdown, setCountdown] = useState<number | null>(null);
|
|
||||||
const unlistenRef = useRef<(() => void) | null>(null);
|
|
||||||
const countdownRef = useRef<number | null>(null);
|
|
||||||
const relaunchFnRef = useRef<(() => Promise<void>) | null>(null);
|
|
||||||
|
|
||||||
const fetchRelease = async (preview = false) => {
|
|
||||||
try {
|
|
||||||
const res = await fetch('https://api.github.com/repos/Psychotoxical/psysonic/releases/latest');
|
|
||||||
if (!res.ok) return;
|
|
||||||
const data = await res.json();
|
|
||||||
const tag: string = data.tag_name ?? '';
|
|
||||||
const version = tag.replace(/^[^0-9]*/, '');
|
|
||||||
if (!version) return;
|
|
||||||
if (!preview) {
|
|
||||||
if (!isNewer(version, currentVersion)) return;
|
|
||||||
const skipped = localStorage.getItem(SKIP_KEY);
|
|
||||||
if (skipped === version) return;
|
|
||||||
}
|
|
||||||
setDismissed(false);
|
|
||||||
setDlState('idle');
|
|
||||||
setRelease({
|
|
||||||
version,
|
|
||||||
tag,
|
|
||||||
body: (data.body ?? '').trim(),
|
|
||||||
assets: data.assets ?? [],
|
|
||||||
});
|
|
||||||
if (IS_LINUX) {
|
|
||||||
const arch = await invoke<boolean>('check_arch_linux');
|
|
||||||
setIsArch(arch);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// No network or rate-limited — stay idle
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let cancelled = false;
|
|
||||||
const timer = setTimeout(() => { if (!cancelled) fetchRelease(); }, 4000);
|
|
||||||
|
|
||||||
const handler = () => fetchRelease(true);
|
|
||||||
window.addEventListener('psysonic:preview-update', handler);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
clearTimeout(timer);
|
|
||||||
window.removeEventListener('psysonic:preview-update', handler);
|
|
||||||
};
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Clean up download listener when component unmounts
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
unlistenRef.current?.();
|
|
||||||
if (countdownRef.current) window.clearInterval(countdownRef.current);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (!release || dismissed) return null;
|
if (!release || dismissed) return null;
|
||||||
|
|
||||||
const asset = pickAsset(release.assets);
|
|
||||||
const showAurHint = IS_LINUX && isArch;
|
|
||||||
// On macOS the Tauri Updater handles architecture, signature verification
|
|
||||||
// and in-place install — we don't need (and should not show) a DMG asset.
|
|
||||||
const useTauriUpdater = IS_MACOS;
|
|
||||||
const showInstallBtn = !showAurHint && (useTauriUpdater || !!asset);
|
|
||||||
|
|
||||||
const handleSkip = () => {
|
|
||||||
localStorage.setItem(SKIP_KEY, release.version);
|
|
||||||
setDismissed(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const startRestartCountdown = (seconds: number) => {
|
|
||||||
let remaining = seconds;
|
|
||||||
setCountdown(remaining);
|
|
||||||
countdownRef.current = window.setInterval(() => {
|
|
||||||
remaining -= 1;
|
|
||||||
if (remaining <= 0) {
|
|
||||||
if (countdownRef.current) window.clearInterval(countdownRef.current);
|
|
||||||
countdownRef.current = null;
|
|
||||||
setCountdown(null);
|
|
||||||
relaunchFnRef.current?.();
|
|
||||||
} else {
|
|
||||||
setCountdown(remaining);
|
|
||||||
}
|
|
||||||
}, 1000);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRestartNow = async () => {
|
|
||||||
if (countdownRef.current) {
|
|
||||||
window.clearInterval(countdownRef.current);
|
|
||||||
countdownRef.current = null;
|
|
||||||
}
|
|
||||||
setCountdown(null);
|
|
||||||
await relaunchFnRef.current?.();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDownload = async () => {
|
|
||||||
// On macOS: use the Tauri Updater plugin — downloads .app.tar.gz, verifies
|
|
||||||
// the minisign signature against the bundled pubkey, replaces the .app, and
|
|
||||||
// relaunches. No manual "open the DMG" step needed.
|
|
||||||
if (IS_MACOS) {
|
|
||||||
setDlState('downloading');
|
|
||||||
setDlProgress({ bytes: 0, total: 0 });
|
|
||||||
setDlError('');
|
|
||||||
try {
|
|
||||||
const { check } = await import('@tauri-apps/plugin-updater');
|
|
||||||
const { relaunch } = await import('@tauri-apps/plugin-process');
|
|
||||||
relaunchFnRef.current = relaunch;
|
|
||||||
const update = await check();
|
|
||||||
if (!update) {
|
|
||||||
setDlError(t('common.updaterErrorMsg'));
|
|
||||||
setDlState('error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let downloaded = 0;
|
|
||||||
let total = 0;
|
|
||||||
await update.downloadAndInstall(event => {
|
|
||||||
if (event.event === 'Started') {
|
|
||||||
total = event.data.contentLength ?? 0;
|
|
||||||
setDlProgress({ bytes: 0, total });
|
|
||||||
} else if (event.event === 'Progress') {
|
|
||||||
downloaded += event.data.chunkLength;
|
|
||||||
setDlProgress({ bytes: downloaded, total });
|
|
||||||
} else if (event.event === 'Finished') {
|
|
||||||
setDlState('done');
|
|
||||||
// downloadAndInstall replaces the .app in place but does not exit
|
|
||||||
// the running process. Give the user a 3s countdown (with a manual
|
|
||||||
// "Restart now" button) before auto-relaunch.
|
|
||||||
startRestartCountdown(3);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
setDlError(String(e));
|
|
||||||
setDlState('error');
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!asset) return;
|
|
||||||
setDlState('downloading');
|
|
||||||
setDlProgress({ bytes: 0, total: asset.size });
|
|
||||||
setDlError('');
|
|
||||||
|
|
||||||
const unlisten = await listen<{ bytes: number; total: number | null }>(
|
|
||||||
'update:download:progress',
|
|
||||||
e => {
|
|
||||||
setDlProgress({
|
|
||||||
bytes: e.payload.bytes,
|
|
||||||
total: e.payload.total ?? asset.size,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
);
|
|
||||||
unlistenRef.current = unlisten;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const finalPath = await invoke<string>('download_update', {
|
|
||||||
url: asset.browser_download_url,
|
|
||||||
filename: asset.name,
|
|
||||||
});
|
|
||||||
unlisten();
|
|
||||||
unlistenRef.current = null;
|
|
||||||
setDlPath(finalPath);
|
|
||||||
setDlState('done');
|
|
||||||
} catch (e) {
|
|
||||||
unlisten();
|
|
||||||
unlistenRef.current = null;
|
|
||||||
setDlError(String(e));
|
|
||||||
setDlState('error');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleShowFolder = async () => {
|
|
||||||
// tauri-plugin-shell's open() only allows https:// per capability scope —
|
|
||||||
// local paths are blocked and fail silently. Delegate to Rust instead.
|
|
||||||
const dir = await dirname(dlPath);
|
|
||||||
await invoke('open_folder', { path: dir });
|
|
||||||
};
|
|
||||||
|
|
||||||
const pct = dlProgress.total > 0
|
|
||||||
? Math.min(100, Math.round((dlProgress.bytes / dlProgress.total) * 100))
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<>
|
<>
|
||||||
<div className="eq-popup-backdrop" onClick={() => setDismissed(true)} style={{ zIndex: 3000 }} />
|
<div className="eq-popup-backdrop" onClick={() => setDismissed(true)} style={{ zIndex: 3000 }} />
|
||||||
@@ -335,7 +67,7 @@ export default function AppUpdater() {
|
|||||||
</button>
|
</button>
|
||||||
{changelogOpen && (
|
{changelogOpen && (
|
||||||
<div className="update-modal-changelog-body">
|
<div className="update-modal-changelog-body">
|
||||||
{renderChangelog(release.body)}
|
<Changelog body={release.body} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
// Minimal inline-markdown renderer (bold, italic, code)
|
||||||
|
// IMPORTANT: regex must have NO nested capture groups — split() includes captured
|
||||||
|
// groups in the result, and nested groups produce undefined entries that crash on .startsWith()
|
||||||
|
function renderInline(text: string): React.ReactNode[] {
|
||||||
|
const parts = text.split(/(\*\*[^*]+\*\*|\*[^*]+\*|`[^`]+`)/g);
|
||||||
|
return parts.map((part, i) => {
|
||||||
|
if (!part) return null;
|
||||||
|
if (part.startsWith('**') && part.endsWith('**'))
|
||||||
|
return <strong key={i}>{part.slice(2, -2)}</strong>;
|
||||||
|
if (part.startsWith('*') && part.endsWith('*'))
|
||||||
|
return <em key={i}>{part.slice(1, -1)}</em>;
|
||||||
|
if (part.startsWith('`') && part.endsWith('`'))
|
||||||
|
return <code key={i} className="changelog-code">{part.slice(1, -1)}</code>;
|
||||||
|
return part;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Renders a GitHub release body as themed changelog markup. */
|
||||||
|
export default function Changelog({ body }: { body: string }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{body.split('\n').map((line, i) => {
|
||||||
|
if (line.startsWith('### '))
|
||||||
|
return <div key={i} className="changelog-h3">{renderInline(line.slice(4))}</div>;
|
||||||
|
if (line.startsWith('#### '))
|
||||||
|
return <div key={i} className="changelog-h4">{renderInline(line.slice(5))}</div>;
|
||||||
|
if (line.startsWith('## '))
|
||||||
|
return null; // skip nested release headers in body
|
||||||
|
if (line.startsWith('- '))
|
||||||
|
return <div key={i} className="changelog-item">{renderInline(line.slice(2))}</div>;
|
||||||
|
if (line.trim() === '') return null;
|
||||||
|
return <div key={i} className="changelog-text">{renderInline(line)}</div>;
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
import { useEffect, useState, useRef } from 'react';
|
||||||
|
import { listen } from '@tauri-apps/api/event';
|
||||||
|
import { dirname } from '@tauri-apps/api/path';
|
||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { version as currentVersion } from '../../package.json';
|
||||||
|
import { IS_LINUX, IS_MACOS } from '../utils/platform';
|
||||||
|
import { SKIP_KEY, isNewer, pickAsset, type ReleaseData, type DlState } from '../utils/appUpdaterHelpers';
|
||||||
|
|
||||||
|
/** All update-modal state, the GitHub release probe and the download/relaunch
|
||||||
|
* handlers. The component owns only the early-return guard and the JSX. */
|
||||||
|
export function useAppUpdater() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [release, setRelease] = useState<ReleaseData | null>(null);
|
||||||
|
const [dismissed, setDismissed] = useState(false);
|
||||||
|
const [changelogOpen, setChangelogOpen] = useState(false);
|
||||||
|
const [isArch, setIsArch] = useState(false);
|
||||||
|
const [dlState, setDlState] = useState<DlState>('idle');
|
||||||
|
const [dlProgress, setDlProgress] = useState({ bytes: 0, total: 0 });
|
||||||
|
const [dlPath, setDlPath] = useState('');
|
||||||
|
const [dlError, setDlError] = useState('');
|
||||||
|
const [countdown, setCountdown] = useState<number | null>(null);
|
||||||
|
const unlistenRef = useRef<(() => void) | null>(null);
|
||||||
|
const countdownRef = useRef<number | null>(null);
|
||||||
|
const relaunchFnRef = useRef<(() => Promise<void>) | null>(null);
|
||||||
|
|
||||||
|
const fetchRelease = async (preview = false) => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('https://api.github.com/repos/Psychotoxical/psysonic/releases/latest');
|
||||||
|
if (!res.ok) return;
|
||||||
|
const data = await res.json();
|
||||||
|
const tag: string = data.tag_name ?? '';
|
||||||
|
const version = tag.replace(/^[^0-9]*/, '');
|
||||||
|
if (!version) return;
|
||||||
|
if (!preview) {
|
||||||
|
if (!isNewer(version, currentVersion)) return;
|
||||||
|
const skipped = localStorage.getItem(SKIP_KEY);
|
||||||
|
if (skipped === version) return;
|
||||||
|
}
|
||||||
|
setDismissed(false);
|
||||||
|
setDlState('idle');
|
||||||
|
setRelease({
|
||||||
|
version,
|
||||||
|
tag,
|
||||||
|
body: (data.body ?? '').trim(),
|
||||||
|
assets: data.assets ?? [],
|
||||||
|
});
|
||||||
|
if (IS_LINUX) {
|
||||||
|
const arch = await invoke<boolean>('check_arch_linux');
|
||||||
|
setIsArch(arch);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// No network or rate-limited — stay idle
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
const timer = setTimeout(() => { if (!cancelled) fetchRelease(); }, 4000);
|
||||||
|
|
||||||
|
const handler = () => fetchRelease(true);
|
||||||
|
window.addEventListener('psysonic:preview-update', handler);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
clearTimeout(timer);
|
||||||
|
window.removeEventListener('psysonic:preview-update', handler);
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Clean up download listener when component unmounts
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
unlistenRef.current?.();
|
||||||
|
if (countdownRef.current) window.clearInterval(countdownRef.current);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSkip = () => {
|
||||||
|
if (!release) return;
|
||||||
|
localStorage.setItem(SKIP_KEY, release.version);
|
||||||
|
setDismissed(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const startRestartCountdown = (seconds: number) => {
|
||||||
|
let remaining = seconds;
|
||||||
|
setCountdown(remaining);
|
||||||
|
countdownRef.current = window.setInterval(() => {
|
||||||
|
remaining -= 1;
|
||||||
|
if (remaining <= 0) {
|
||||||
|
if (countdownRef.current) window.clearInterval(countdownRef.current);
|
||||||
|
countdownRef.current = null;
|
||||||
|
setCountdown(null);
|
||||||
|
relaunchFnRef.current?.();
|
||||||
|
} else {
|
||||||
|
setCountdown(remaining);
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRestartNow = async () => {
|
||||||
|
if (countdownRef.current) {
|
||||||
|
window.clearInterval(countdownRef.current);
|
||||||
|
countdownRef.current = null;
|
||||||
|
}
|
||||||
|
setCountdown(null);
|
||||||
|
await relaunchFnRef.current?.();
|
||||||
|
};
|
||||||
|
|
||||||
|
const asset = pickAsset(release?.assets ?? []);
|
||||||
|
|
||||||
|
const handleDownload = async () => {
|
||||||
|
// On macOS: use the Tauri Updater plugin — downloads .app.tar.gz, verifies
|
||||||
|
// the minisign signature against the bundled pubkey, replaces the .app, and
|
||||||
|
// relaunches. No manual "open the DMG" step needed.
|
||||||
|
if (IS_MACOS) {
|
||||||
|
setDlState('downloading');
|
||||||
|
setDlProgress({ bytes: 0, total: 0 });
|
||||||
|
setDlError('');
|
||||||
|
try {
|
||||||
|
const { check } = await import('@tauri-apps/plugin-updater');
|
||||||
|
const { relaunch } = await import('@tauri-apps/plugin-process');
|
||||||
|
relaunchFnRef.current = relaunch;
|
||||||
|
const update = await check();
|
||||||
|
if (!update) {
|
||||||
|
setDlError(t('common.updaterErrorMsg'));
|
||||||
|
setDlState('error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let downloaded = 0;
|
||||||
|
let total = 0;
|
||||||
|
await update.downloadAndInstall(event => {
|
||||||
|
if (event.event === 'Started') {
|
||||||
|
total = event.data.contentLength ?? 0;
|
||||||
|
setDlProgress({ bytes: 0, total });
|
||||||
|
} else if (event.event === 'Progress') {
|
||||||
|
downloaded += event.data.chunkLength;
|
||||||
|
setDlProgress({ bytes: downloaded, total });
|
||||||
|
} else if (event.event === 'Finished') {
|
||||||
|
setDlState('done');
|
||||||
|
// downloadAndInstall replaces the .app in place but does not exit
|
||||||
|
// the running process. Give the user a 3s countdown (with a manual
|
||||||
|
// "Restart now" button) before auto-relaunch.
|
||||||
|
startRestartCountdown(3);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
setDlError(String(e));
|
||||||
|
setDlState('error');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!asset) return;
|
||||||
|
setDlState('downloading');
|
||||||
|
setDlProgress({ bytes: 0, total: asset.size });
|
||||||
|
setDlError('');
|
||||||
|
|
||||||
|
const unlisten = await listen<{ bytes: number; total: number | null }>(
|
||||||
|
'update:download:progress',
|
||||||
|
e => {
|
||||||
|
setDlProgress({
|
||||||
|
bytes: e.payload.bytes,
|
||||||
|
total: e.payload.total ?? asset.size,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
unlistenRef.current = unlisten;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const finalPath = await invoke<string>('download_update', {
|
||||||
|
url: asset.browser_download_url,
|
||||||
|
filename: asset.name,
|
||||||
|
});
|
||||||
|
unlisten();
|
||||||
|
unlistenRef.current = null;
|
||||||
|
setDlPath(finalPath);
|
||||||
|
setDlState('done');
|
||||||
|
} catch (e) {
|
||||||
|
unlisten();
|
||||||
|
unlistenRef.current = null;
|
||||||
|
setDlError(String(e));
|
||||||
|
setDlState('error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleShowFolder = async () => {
|
||||||
|
// tauri-plugin-shell's open() only allows https:// per capability scope —
|
||||||
|
// local paths are blocked and fail silently. Delegate to Rust instead.
|
||||||
|
const dir = await dirname(dlPath);
|
||||||
|
await invoke('open_folder', { path: dir });
|
||||||
|
};
|
||||||
|
|
||||||
|
const showAurHint = IS_LINUX && isArch;
|
||||||
|
// On macOS the Tauri Updater handles architecture, signature verification
|
||||||
|
// and in-place install — we don't need (and should not show) a DMG asset.
|
||||||
|
const useTauriUpdater = IS_MACOS;
|
||||||
|
const showInstallBtn = !showAurHint && (useTauriUpdater || !!asset);
|
||||||
|
|
||||||
|
const pct = dlProgress.total > 0
|
||||||
|
? Math.min(100, Math.round((dlProgress.bytes / dlProgress.total) * 100))
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
release, dismissed, setDismissed, changelogOpen, setChangelogOpen,
|
||||||
|
dlState, dlProgress, dlError, countdown,
|
||||||
|
asset, showAurHint, useTauriUpdater, showInstallBtn, pct,
|
||||||
|
handleSkip, handleRestartNow, handleDownload, handleShowFolder,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { IS_LINUX, IS_MACOS, IS_WINDOWS } from './platform';
|
||||||
|
|
||||||
|
export const SKIP_KEY = 'psysonic_skipped_update_version';
|
||||||
|
|
||||||
|
// Semver comparison: returns true if `a` is newer than `b`
|
||||||
|
export function isNewer(a: string, b: string): boolean {
|
||||||
|
const pa = a.replace(/^[^0-9]*/, '').split('.').map(Number);
|
||||||
|
const pb = b.replace(/^[^0-9]*/, '').split('.').map(Number);
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
if ((pa[i] ?? 0) > (pb[i] ?? 0)) return true;
|
||||||
|
if ((pa[i] ?? 0) < (pb[i] ?? 0)) return false;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fmtBytes(n: number): string {
|
||||||
|
if (n < 1024 * 1024) return `${(n / 1024).toFixed(0)} KB`;
|
||||||
|
return `${(n / 1024 / 1024).toFixed(1)} MB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GithubAsset {
|
||||||
|
name: string;
|
||||||
|
browser_download_url: string;
|
||||||
|
size: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReleaseData {
|
||||||
|
version: string;
|
||||||
|
tag: string;
|
||||||
|
body: string;
|
||||||
|
assets: GithubAsset[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DlState = 'idle' | 'downloading' | 'done' | 'error';
|
||||||
|
|
||||||
|
export function pickAsset(assets: GithubAsset[]): GithubAsset | undefined {
|
||||||
|
if (IS_WINDOWS) {
|
||||||
|
return assets.find(a => a.name.endsWith('-setup.exe'))
|
||||||
|
?? assets.find(a => a.name.endsWith('.exe'));
|
||||||
|
}
|
||||||
|
if (IS_MACOS) {
|
||||||
|
// Prefer Apple Silicon, fall back to Intel
|
||||||
|
return assets.find(a => a.name.endsWith('.dmg') && a.name.includes('aarch64'))
|
||||||
|
?? assets.find(a => a.name.endsWith('.dmg'));
|
||||||
|
}
|
||||||
|
if (IS_LINUX) {
|
||||||
|
// AppImage > deb > rpm
|
||||||
|
return assets.find(a => a.name.endsWith('.AppImage'))
|
||||||
|
?? assets.find(a => a.name.endsWith('.deb'))
|
||||||
|
?? assets.find(a => a.name.endsWith('.rpm'));
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user