380 lines
14 KiB
TypeScript
380 lines
14 KiB
TypeScript
import { check } from '@tauri-apps/plugin-updater';
|
|
import { relaunch } from '@tauri-apps/plugin-process';
|
|
import { getVersion } from '@tauri-apps/api/app';
|
|
|
|
const UPDATE_LOOP_GUARD_KEY = 'goldenchart:startup-update-guard';
|
|
const UPDATE_LOOP_MAX_ATTEMPTS = 2;
|
|
const UPDATE_LOOP_WINDOW_MS = 15 * 60 * 1000;
|
|
|
|
function cmpSemver(a: string, b: string): number {
|
|
const pa = a.split('.').map(Number);
|
|
const pb = b.split('.').map(Number);
|
|
for (let i = 0; i < 3; i++) {
|
|
const d = (pa[i] || 0) - (pb[i] || 0);
|
|
if (d !== 0) return d;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
interface UpdateLoopGuard {
|
|
targetVersion: string;
|
|
fromVersion: string;
|
|
attempts: number;
|
|
lastAt: number;
|
|
}
|
|
|
|
function readUpdateLoopGuard(): UpdateLoopGuard | null {
|
|
try {
|
|
const raw = localStorage.getItem(UPDATE_LOOP_GUARD_KEY);
|
|
if (!raw) return null;
|
|
return JSON.parse(raw) as UpdateLoopGuard;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function writeUpdateLoopGuard(guard: UpdateLoopGuard) {
|
|
try {
|
|
localStorage.setItem(UPDATE_LOOP_GUARD_KEY, JSON.stringify(guard));
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
|
|
export function clearUpdateLoopGuard() {
|
|
try {
|
|
localStorage.removeItem(UPDATE_LOOP_GUARD_KEY);
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
|
|
export interface DesktopUpdateOptions {
|
|
/** 수동 업데이트·재시도 시 반복 실패 가드 무시 */
|
|
bypassLoopGuard?: boolean;
|
|
}
|
|
|
|
const RELAUNCH_TIMEOUT_MS = 12_000;
|
|
|
|
async function relaunchWithTimeout(): Promise<boolean> {
|
|
try {
|
|
await Promise.race([
|
|
relaunch(),
|
|
new Promise<never>((_, reject) => {
|
|
window.setTimeout(() => reject(new Error('relaunch timeout')), RELAUNCH_TIMEOUT_MS);
|
|
}),
|
|
]);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function shouldBlockUpdateLoop(currentVersion: string, targetVersion: string): boolean {
|
|
const guard = readUpdateLoopGuard();
|
|
if (!guard) return false;
|
|
if (guard.targetVersion !== targetVersion) return false;
|
|
if (currentVersion === targetVersion) return false;
|
|
if (Date.now() - guard.lastAt > UPDATE_LOOP_WINDOW_MS) return false;
|
|
return guard.attempts >= UPDATE_LOOP_MAX_ATTEMPTS && guard.fromVersion === currentVersion;
|
|
}
|
|
|
|
function recordUpdateLoopAttempt(currentVersion: string, targetVersion: string) {
|
|
const guard = readUpdateLoopGuard();
|
|
const now = Date.now();
|
|
if (
|
|
guard
|
|
&& guard.targetVersion === targetVersion
|
|
&& guard.fromVersion === currentVersion
|
|
&& now - guard.lastAt < UPDATE_LOOP_WINDOW_MS
|
|
) {
|
|
writeUpdateLoopGuard({ ...guard, attempts: guard.attempts + 1, lastAt: now });
|
|
return;
|
|
}
|
|
writeUpdateLoopGuard({
|
|
targetVersion,
|
|
fromVersion: currentVersion,
|
|
attempts: 1,
|
|
lastAt: now,
|
|
});
|
|
}
|
|
|
|
export type DesktopUpdatePhase =
|
|
| 'checking'
|
|
| 'uptodate'
|
|
| 'available'
|
|
| 'downloading'
|
|
| 'installing'
|
|
| 'relaunching'
|
|
| 'error';
|
|
|
|
export interface DesktopUpdateProgress {
|
|
phase: DesktopUpdatePhase;
|
|
message: string;
|
|
currentVersion?: string;
|
|
newVersion?: string;
|
|
downloadPercent?: number;
|
|
downloadedBytes?: number;
|
|
totalBytes?: number;
|
|
}
|
|
|
|
export interface DesktopUpdateResult {
|
|
ok: boolean;
|
|
phase: DesktopUpdatePhase;
|
|
message: string;
|
|
currentVersion?: string;
|
|
newVersion?: string;
|
|
}
|
|
|
|
function emit(onProgress: ((p: DesktopUpdateProgress) => void) | undefined, p: DesktopUpdateProgress) {
|
|
onProgress?.(p);
|
|
}
|
|
|
|
function isMacOs(): boolean {
|
|
if (typeof navigator !== 'undefined' && /mac/i.test(navigator.userAgent)) return true;
|
|
return false;
|
|
}
|
|
|
|
function isWindowsOs(): boolean {
|
|
if (typeof navigator !== 'undefined' && /win/i.test(navigator.userAgent)) return true;
|
|
return false;
|
|
}
|
|
|
|
async function formatUpdateError(e: unknown): Promise<string> {
|
|
const msg = e instanceof Error ? e.message : String(e ?? '');
|
|
const lower = msg.toLowerCase();
|
|
const onMac = isMacOs();
|
|
const onWin = isWindowsOs();
|
|
|
|
if (!msg.trim()) return '업데이트 확인에 실패했습니다.';
|
|
if (/unexpected end of json|eof|empty|content-length.*0|^$/.test(lower) && msg.trim().length < 80) {
|
|
return onMac
|
|
? '서버 latest.json 이 비어 있습니다. PC 프로그램 탭에서 dmg를 받아 다시 설치해 주세요. (관리자: Desktop Jenkins publish 필요)'
|
|
: '서버 latest.json 이 비어 있습니다. PC 프로그램 탭에서 exe를 받아 다시 설치해 주세요. (관리자: Desktop Jenkins publish 필요)';
|
|
}
|
|
if (/could not fetch a valid release json|release not found/.test(lower)) {
|
|
return onMac
|
|
? '업데이트 서버 응답을 처리하지 못했습니다. PC 프로그램 탭에서 dmg를 새로 설치해 주세요.'
|
|
: '업데이트 서버 응답을 처리하지 못했습니다. PC 프로그램 탭에서 exe를 새로 설치해 주세요.';
|
|
}
|
|
if (/json|parse|unexpected end|eof|empty|deserialize|invalid value for/.test(lower)) {
|
|
return onMac
|
|
? '서버 업데이트 정보(latest.json) 형식 오류입니다. PC 프로그램 탭에서 dmg를 받아 주세요.'
|
|
: '서버 업데이트 정보(latest.json) 형식 오류입니다. PC 프로그램 탭에서 exe를 받아 주세요.';
|
|
}
|
|
if (/insecuretransport|insecure transport|http url|https/.test(lower)) {
|
|
return '업데이트 URL 보안(HTTPS) 설정 오류입니다. 잠시 후 다시 시도해 주세요.';
|
|
}
|
|
if (/signature|pubkey|verify|minisign|invalid key/.test(lower)) {
|
|
return onMac
|
|
? '업데이트 서명 검증에 실패했습니다. PC 프로그램 탭에서 dmg를 다시 받아 주세요.'
|
|
: '업데이트 서명 검증에 실패했습니다. PC 프로그램 탭에서 exe를 다시 받아 주세요.';
|
|
}
|
|
if (/invalid symbol|offset \d+|replace_with/.test(lower)) {
|
|
if (onWin) {
|
|
return '이 PC에 updater 공개키가 포함되지 않은 빌드입니다(로컬 빌드 시 키 미적용). 웹 PC 프로그램 탭에서 최신 exe를 받아 다시 설치하면 이후 자동 업데이트가 됩니다.';
|
|
}
|
|
return '이 PC에 updater 공개키가 포함되지 않은 빌드입니다(로컬 build-desktop.sh 시 키 미적용). 웹 PC 프로그램 탭에서 최신 dmg를 Applications에 설치하면 이후 자동 업데이트가 됩니다.';
|
|
}
|
|
if (/404|not found/.test(lower)) {
|
|
return onMac
|
|
? 'Mac용 업데이트 파일을 찾을 수 없습니다. PC 프로그램 탭에서 dmg를 직접 다운로드해 주세요.'
|
|
: 'Windows용 업데이트 파일을 찾을 수 없습니다. PC 프로그램 탭에서 exe를 직접 다운로드해 주세요.';
|
|
}
|
|
if (/network|fetch|connect|timeout|dns|offline|failed to send/.test(lower)) {
|
|
return `네트워크 오류로 업데이트를 확인하지 못했습니다. (${msg})`;
|
|
}
|
|
if (/darwin-x86_64|x86_64.*darwin|intel mac/.test(lower) && onMac) {
|
|
return 'Intel Mac용 업데이트 패키지(darwin-x86_64)가 서버에 없습니다. PC 프로그램 탭에서 dmg를 받아 다시 설치해 주세요.';
|
|
}
|
|
if (/darwin-aarch64|aarch64.*darwin/.test(lower) && onMac) {
|
|
return 'Apple Silicon Mac용 업데이트 패키지(darwin-aarch64)가 서버에 없습니다. PC 프로그램 탭에서 dmg를 받아 다시 설치해 주세요.';
|
|
}
|
|
if (/windows-x86_64/.test(lower) && onWin) {
|
|
return 'Windows용 업데이트 패키지가 서버에 없습니다. PC 프로그램 탭에서 exe 설치 파일을 받아 다시 설치해 주세요.';
|
|
}
|
|
if (/fallback platforms|platforms object|platform.*not found|missing platform/.test(lower)) {
|
|
if (onMac) {
|
|
const hint = /darwin-x86_64|x86_64/.test(lower)
|
|
? 'Intel Mac(darwin-x86_64)'
|
|
: 'Apple Silicon Mac(darwin-aarch64)';
|
|
return `Mac용 업데이트 패키지가 서버에 없습니다 (${hint}). PC 프로그램 탭에서 dmg를 받아 다시 설치해 주세요.`;
|
|
}
|
|
if (onWin) {
|
|
return 'Windows용 업데이트 패키지가 서버에 없습니다. PC 프로그램 탭에서 exe 설치 파일을 받아 다시 설치해 주세요.';
|
|
}
|
|
}
|
|
if (/platform|darwin|arch|target/.test(lower)) {
|
|
return onMac
|
|
? 'Mac용 업데이트 패키지가 없습니다. PC 프로그램 탭에서 dmg를 받아 다시 설치해 주세요.'
|
|
: '이 PC용 업데이트 패키지가 없습니다. PC 프로그램 탭에서 설치 파일을 다시 받아 주세요.';
|
|
}
|
|
return msg;
|
|
}
|
|
|
|
export async function getAppVersion(): Promise<string> {
|
|
return getVersion();
|
|
}
|
|
|
|
/** 서버에 새 버전이 있는지만 확인 (다운로드·설치 없음) */
|
|
export async function checkForUpdates(): Promise<{
|
|
available: boolean;
|
|
version?: string;
|
|
message?: string;
|
|
}> {
|
|
try {
|
|
const currentVersion = await getVersion();
|
|
const update = await check();
|
|
if (!update) {
|
|
return { available: false, message: `최신 버전입니다. (v${currentVersion})` };
|
|
}
|
|
return {
|
|
available: true,
|
|
version: update.version,
|
|
message: `새 버전 v${update.version}을(를) 사용할 수 있습니다. (현재 v${currentVersion})`,
|
|
};
|
|
} catch (e) {
|
|
return { available: false, message: await formatUpdateError(e) };
|
|
}
|
|
}
|
|
|
|
export interface StartupAutoUpdateResult {
|
|
/** false면 relaunch 중 — 메인 UI를 띄우지 않음 */
|
|
shouldContinue: boolean;
|
|
result: DesktopUpdateResult;
|
|
}
|
|
|
|
/** 앱 시작 시 — 새 버전이 있으면 자동 다운로드·설치·재시작 */
|
|
export async function runStartupAutoUpdate(
|
|
onProgress?: (p: DesktopUpdateProgress) => void,
|
|
): Promise<StartupAutoUpdateResult> {
|
|
const result = await runDesktopUpdate(onProgress);
|
|
return {
|
|
shouldContinue: result.phase !== 'relaunching',
|
|
result,
|
|
};
|
|
}
|
|
|
|
export async function runDesktopUpdate(
|
|
onProgress?: (p: DesktopUpdateProgress) => void,
|
|
options?: DesktopUpdateOptions,
|
|
): Promise<DesktopUpdateResult> {
|
|
let currentVersion = '';
|
|
try {
|
|
currentVersion = await getVersion();
|
|
} catch {
|
|
currentVersion = '—';
|
|
}
|
|
|
|
emit(onProgress, {
|
|
phase: 'checking',
|
|
message: '서버에서 새 버전을 확인하는 중…',
|
|
currentVersion,
|
|
});
|
|
|
|
try {
|
|
const update = await check();
|
|
if (!update) {
|
|
clearUpdateLoopGuard();
|
|
const message = `최신 버전입니다. (v${currentVersion})`;
|
|
emit(onProgress, { phase: 'uptodate', message, currentVersion });
|
|
return { ok: true, phase: 'uptodate', message, currentVersion };
|
|
}
|
|
|
|
if (cmpSemver(update.version, currentVersion) <= 0) {
|
|
clearUpdateLoopGuard();
|
|
const message = `최신 버전입니다. (v${currentVersion})`;
|
|
emit(onProgress, { phase: 'uptodate', message, currentVersion });
|
|
return { ok: true, phase: 'uptodate', message, currentVersion };
|
|
}
|
|
|
|
if (!options?.bypassLoopGuard && shouldBlockUpdateLoop(currentVersion, update.version)) {
|
|
const message =
|
|
`v${update.version} 업데이트가 반복 실패했습니다. 서버 패키지와 버전이 맞지 않을 수 있습니다. ` +
|
|
'PC 프로그램 탭에서 설치 파일을 받아 다시 설치해 주세요.';
|
|
emit(onProgress, { phase: 'error', message, currentVersion, newVersion: update.version });
|
|
return { ok: false, phase: 'error', message, currentVersion, newVersion: update.version };
|
|
}
|
|
|
|
if (options?.bypassLoopGuard) {
|
|
clearUpdateLoopGuard();
|
|
}
|
|
|
|
recordUpdateLoopAttempt(currentVersion, update.version);
|
|
|
|
emit(onProgress, {
|
|
phase: 'available',
|
|
message: `새 버전 v${update.version}을(를) 받습니다. (현재 v${currentVersion})`,
|
|
currentVersion,
|
|
newVersion: update.version,
|
|
});
|
|
|
|
let downloaded = 0;
|
|
let total: number | undefined;
|
|
|
|
await update.downloadAndInstall(event => {
|
|
if (event.event === 'Started') {
|
|
total = event.data.contentLength;
|
|
downloaded = 0;
|
|
emit(onProgress, {
|
|
phase: 'downloading',
|
|
message: total ? '다운로드 중… (0%)' : '다운로드 중…',
|
|
currentVersion,
|
|
newVersion: update.version,
|
|
downloadPercent: 0,
|
|
downloadedBytes: 0,
|
|
totalBytes: total,
|
|
});
|
|
} else if (event.event === 'Progress') {
|
|
downloaded += event.data.chunkLength;
|
|
const pct =
|
|
total && total > 0 ? Math.min(99, Math.round((downloaded / total) * 100)) : undefined;
|
|
emit(onProgress, {
|
|
phase: 'downloading',
|
|
message: pct != null ? `다운로드 중… (${pct}%)` : '다운로드 중…',
|
|
currentVersion,
|
|
newVersion: update.version,
|
|
downloadPercent: pct,
|
|
downloadedBytes: downloaded,
|
|
totalBytes: total,
|
|
});
|
|
} else if (event.event === 'Finished') {
|
|
emit(onProgress, {
|
|
phase: 'installing',
|
|
message: '업데이트 패키지 설치 중…',
|
|
currentVersion,
|
|
newVersion: update.version,
|
|
});
|
|
}
|
|
});
|
|
|
|
emit(onProgress, {
|
|
phase: 'relaunching',
|
|
message: '설치 완료. 앱을 다시 시작합니다…',
|
|
currentVersion,
|
|
newVersion: update.version,
|
|
});
|
|
|
|
const relaunched = await relaunchWithTimeout();
|
|
if (!relaunched) {
|
|
clearUpdateLoopGuard();
|
|
const message =
|
|
'업데이트는 설치됐지만 앱을 자동으로 다시 시작하지 못했습니다. GoldenChart를 직접 종료한 뒤 다시 실행해 주세요.';
|
|
emit(onProgress, { phase: 'error', message, currentVersion, newVersion: update.version });
|
|
return { ok: false, phase: 'error', message, currentVersion, newVersion: update.version };
|
|
}
|
|
|
|
return {
|
|
ok: true,
|
|
phase: 'relaunching',
|
|
message: '재시작 중…',
|
|
currentVersion,
|
|
newVersion: update.version,
|
|
};
|
|
} catch (e) {
|
|
const message = await formatUpdateError(e);
|
|
emit(onProgress, { phase: 'error', message, currentVersion });
|
|
return { ok: false, phase: 'error', message, currentVersion };
|
|
}
|
|
}
|