diff --git a/src/main/index.ts b/src/main/index.ts index a135047..de44642 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -12,6 +12,7 @@ import { listTerminals } from './lib/terminals' import { checkForUpdate } from './lib/updater' import { checkViaAutoUpdater, + downloadUpdate, initAutoUpdate, installUpdate, isAutoUpdateSupported @@ -46,6 +47,7 @@ function registerIpcHandlers(): void { ipcMain.handle('update:check', (_event, force?: boolean) => isAutoUpdateSupported() ? checkViaAutoUpdater() : checkForUpdate(force === true) ) + ipcMain.handle('update:download', () => downloadUpdate()) ipcMain.handle('update:install', () => installUpdate()) // 세션 우클릭 컨텍스트 메뉴 — 선택 결과('reveal'|'delete')를, 그냥 닫히면 null을 돌려준다 ipcMain.handle('session:menu', (event, labels: { reveal: string; delete: string }) => { diff --git a/src/main/lib/autoUpdate.test.ts b/src/main/lib/autoUpdate.test.ts new file mode 100644 index 0000000..027452b --- /dev/null +++ b/src/main/lib/autoUpdate.test.ts @@ -0,0 +1,141 @@ +import { EventEmitter } from 'events' +import type { BrowserWindow } from 'electron' +import { autoUpdater } from 'electron-updater' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { downloadUpdate, initAutoUpdate } from './autoUpdate' + +vi.mock('electron', () => ({ app: { isPackaged: true, getVersion: () => '0.9.0' } })) + +vi.mock('electron-updater', async () => { + const { EventEmitter } = await import('events') + const fake = Object.assign(new EventEmitter(), { + autoDownload: true, // 기본값 — initAutoUpdate 가 이걸 끄는지 본다 + autoInstallOnAppQuit: true, + // 회귀 방지용 — 진행률이 이 값으로 되돌아가면 아래 버전 테스트가 깨진다 + currentVersion: { version: '0.9.0' }, + // 실제 downloadUpdate 는 Promise 를 돌려주고 실패하면 거부한다 + downloadUpdate: vi.fn(() => Promise.resolve([])), + quitAndInstall: vi.fn() + }) + return { autoUpdater: fake } +}) + +/** initAutoUpdate 가 쓰는 만큼만 갖춘 창 대역 */ +function fakeWindow(destroyed = false): { window: BrowserWindow; sent: unknown[] } { + const sent: unknown[] = [] + const window = { + isDestroyed: () => destroyed, + webContents: { + send: (channel: string, payload: unknown) => { + if (channel === 'update:event') sent.push(payload) + } + } + } + return { window: window as unknown as BrowserWindow, sent } +} + +const updater = autoUpdater as unknown as EventEmitter & { + autoDownload: boolean + downloadUpdate: ReturnType +} + +beforeEach(() => { + updater.removeAllListeners() + updater.autoDownload = true + vi.clearAllMocks() + updater.downloadUpdate.mockImplementation(() => Promise.resolve([])) +}) + +describe('initAutoUpdate', () => { + it('새 버전을 찾으면 다운로드하지 않고 확인 이벤트를 보낸다', () => { + const { window, sent } = fakeWindow() + initAutoUpdate(window) + + updater.emit('update-available', { version: '0.9.1' }) + + expect(sent).toEqual([{ type: 'available', version: '0.9.1' }]) + expect(updater.downloadUpdate).not.toHaveBeenCalled() + }) + + it('autoDownload 를 꺼서 라이브러리가 스스로 받지 않게 한다', () => { + initAutoUpdate(fakeWindow().window) + + expect(updater.autoDownload).toBe(false) + }) + + it('진행률은 설치된 버전이 아니라 받고 있는 버전을 알린다', () => { + const { window, sent } = fakeWindow() + initAutoUpdate(window) + + updater.emit('update-available', { version: '0.9.1' }) + updater.emit('download-progress', { percent: 41.6 }) + + expect(sent.at(-1)).toEqual({ type: 'downloading', version: '0.9.1', percent: 42 }) + }) + + it('어느 버전을 받는지 모르면 진행률을 알리지 않는다', () => { + const { window, sent } = fakeWindow() + initAutoUpdate(window) + + updater.emit('download-progress', { percent: 30 }) + + expect(sent).toEqual([]) + }) + + it('다 받으면 설치 준비를 알린다', () => { + const { window, sent } = fakeWindow() + initAutoUpdate(window) + + updater.emit('update-downloaded', { version: '0.9.1' }) + + expect(sent).toEqual([{ type: 'ready', version: '0.9.1' }]) + }) + + it('오류를 렌더러까지 올린다', () => { + const { window, sent } = fakeWindow() + initAutoUpdate(window) + + // electron-updater 는 error 를 (에러, 스택) 두 인자로 발행한다 + updater.emit('error', new Error('ENOTFOUND'), 'stack') + + expect(sent).toEqual([{ type: 'error', message: 'ENOTFOUND' }]) + }) + + it('창이 이미 닫혔으면 아무것도 보내지 않는다', () => { + const { window, sent } = fakeWindow(true) + initAutoUpdate(window) + + updater.emit('update-available', { version: '0.9.1' }) + + expect(sent).toEqual([]) + }) + + // autoUpdater 는 모듈 싱글턴이라, 창을 닫았다 다시 열면 리스너가 쌓이고 죽은 창의 클로저가 남는다 + it('창을 다시 열면 이전 창의 리스너를 남기지 않는다', () => { + const first = fakeWindow() + initAutoUpdate(first.window) + const second = fakeWindow() + initAutoUpdate(second.window) + + updater.emit('update-available', { version: '0.9.1' }) + + expect(first.sent).toEqual([]) + expect(second.sent).toEqual([{ type: 'available', version: '0.9.1' }]) + }) +}) + +describe('downloadUpdate', () => { + it('라이브러리에 다운로드를 시작시킨다', async () => { + await downloadUpdate() + + expect(updater.downloadUpdate).toHaveBeenCalledOnce() + }) + + // 취소는 electron-updater 가 'error' 이벤트를 내지 않는다. 여기서 삼키면 배너가 승인 상태에 + // 갇혀 다시 누를 수도 없다 + it('실패를 삼키지 않고 호출자에게 올린다', async () => { + updater.downloadUpdate.mockImplementation(() => Promise.reject(new Error('cancelled'))) + + await expect(downloadUpdate()).rejects.toThrow('cancelled') + }) +}) diff --git a/src/main/lib/autoUpdate.ts b/src/main/lib/autoUpdate.ts index 2ccae5a..6bbe3a3 100644 --- a/src/main/lib/autoUpdate.ts +++ b/src/main/lib/autoUpdate.ts @@ -21,7 +21,7 @@ export function isNewer(latest: string, current: string): boolean { return false } -/** 서명된 패키지 앱에서만 동작한다. dev 모드는 legacy 확인(updater.ts)을 쓴다. */ +/** app.isPackaged 만 본다 — 서명이 없으면 설치 단계에서 error 이벤트로 드러난다. dev 는 legacy 확인(updater.ts) */ export function isAutoUpdateSupported(): boolean { return app.isPackaged } @@ -33,17 +33,28 @@ export function initAutoUpdate(window: BrowserWindow): void { if (!window.isDestroyed()) window.webContents.send('update:event', event) } - autoUpdater.autoDownload = true + // autoUpdater 는 모듈 싱글턴이다. 창을 다시 열 때 리스너가 쌓이고 죽은 창의 클로저가 남는다 + autoUpdater.removeAllListeners() + + // 다운로드는 사용자가 배너에서 승인해야 시작한다 (downloadUpdate) + autoUpdater.autoDownload = false autoUpdater.autoInstallOnAppQuit = true + // autoUpdater.currentVersion 은 설치된 버전이라 진행률에 쓸 수 없다. 받고 있는 버전을 기억해 둔다 + let pendingVersion: string | null = null + autoUpdater.on('update-available', (info) => { + pendingVersion = info.version + send({ type: 'available', version: info.version }) + }) autoUpdater.on('download-progress', (progress) => { - const version = autoUpdater.currentVersion?.version ?? '' - send({ type: 'downloading', version, percent: Math.round(progress.percent) }) + // 어느 버전을 받는지 모르면 "새 버전 v 다운로드 중" 같은 빈 안내가 된다. 차라리 침묵한다 + if (pendingVersion === null) return + send({ type: 'downloading', version: pendingVersion, percent: Math.round(progress.percent) }) }) autoUpdater.on('update-downloaded', (info) => { send({ type: 'ready', version: info.version }) }) autoUpdater.on('error', (error) => { - // 오프라인 등은 조용히 넘어가되 렌더러가 상태를 정리할 수 있게 알린다 + // 오류는 막지 않고 그대로 올린다. 사용자에게 보일지와 배너를 어떻게 되돌릴지는 렌더러가 정한다 send({ type: 'error', message: error.message }) }) } @@ -60,11 +71,27 @@ export async function checkViaAutoUpdater(): Promise { url: RELEASES_PAGE, auto: true } - } catch { + } catch (error) { + // 확인 실패와 "최신입니다"가 구분되지 않는다. 최소한 흔적은 남긴다 + console.error('[update] 업데이트 확인 실패', error) return { currentVersion, latestVersion: null, hasUpdate: false, url: RELEASES_PAGE, auto: true } } } +/** + * 사용자가 배너에서 승인했을 때만 호출한다. update-available 이 뜬 뒤라야 하고, 그 전이면 + * electron-updater 가 거부한다. 실패는 'error' 이벤트로 렌더러에 전달된다 + */ +export async function downloadUpdate(): Promise { + try { + await autoUpdater.downloadUpdate() + } catch (error) { + // 취소는 'error' 이벤트를 내지 않는다. 삼키면 배너가 승인 상태에 갇히므로 렌더러까지 올린다 + console.error('[update] 다운로드 실패', error) + throw error + } +} + export function installUpdate(): void { autoUpdater.quitAndInstall() } diff --git a/src/preload/index.ts b/src/preload/index.ts index 461c8e5..e45ac80 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -32,6 +32,7 @@ const api: ClaudeHistoryApi = { return () => ipcRenderer.removeListener('search:progress', listener) }, showSessionMenu: (labels) => ipcRenderer.invoke('session:menu', labels), + downloadUpdate: () => ipcRenderer.invoke('update:download'), installUpdate: () => ipcRenderer.invoke('update:install') } diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index a1fe68c..172b37f 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -18,13 +18,10 @@ import type { UpdateInfo } from '../../shared/types' -type UpdateBannerState = - | { mode: 'link'; version: string; url: string } - | { mode: 'downloading'; version: string; percent: number } - | { mode: 'ready'; version: string } import { makeTranslate, resolveLanguage } from './i18n' import { DEFAULT_SETTINGS, PrefsContext, type Prefs } from './prefs' import { buildGroups, type SubGroup } from './lib/groups' +import { isDownloadFailure, nextBanner, type UpdateBannerState } from './lib/updateBanner' import { shortcut } from './lib/platform' import { Sidebar } from './components/Sidebar' import { ConversationView } from './components/ConversationView' @@ -112,6 +109,15 @@ export default function App(): ReactElement { translateRef.current = t }, [t]) + // 이벤트 콜백이 직전 배너를 봐야 하는데, 실패 토스트 판단이 걸려 있어 setUpdate(prev=>…) 안에 + // 넣을 수 없다(StrictMode 에서 updater 는 순수해야 한다). 시작 효과에 update 를 묶을 수도 없어 + // ref 로 읽는다. 이펙트로 미러링하면 연달아 오는 이벤트가 낡은 값을 보므로 여기서 함께 쓴다 + const updateRef = useRef(null) + const applyUpdate = useCallback((next: UpdateBannerState | null) => { + updateRef.current = next + setUpdate(next) + }, []) + const loadSessions = useCallback( async (projectId: string) => { if (loadedProjects.current.has(projectId)) return @@ -146,15 +152,13 @@ export default function App(): ReactElement { ) useEffect(() => { - // electron-updater 이벤트(다운로드 진행·완료)가 배너 상태를 구동한다 + // 새 버전 확인·진행·완료·실패가 전부 이 이벤트로 온다. 전이는 nextBanner 가 맡는다 const unsubscribe = window.api.onUpdateEvent((event) => { - if (event.type === 'downloading') { - setUpdate({ mode: 'downloading', version: event.version, percent: event.percent }) - } else if (event.type === 'ready') { - setUpdate({ mode: 'ready', version: event.version }) - } else if (event.type === 'error') { - setUpdate((prev) => (prev?.mode === 'ready' ? prev : null)) - } + const prev = updateRef.current + const next = nextBanner(prev, event) + // 승인하고 기다리던 다운로드가 끊긴 것은 조용히 넘기지 않는다 + if (isDownloadFailure(prev, next)) showToast(translateRef.current('update.failed')) + applyUpdate(next) }) // 첫 그룹 자동 펼침이 설정에 좌우되므로 설정을 먼저 받고 프로젝트를 읽는다 window.api.getSettings().then((info) => { @@ -163,7 +167,7 @@ export default function App(): ReactElement { window.api.checkForUpdate().then((updateInfo: UpdateInfo) => { // auto 모드는 이벤트가 배너를 그리므로 링크 배너는 legacy(dev) 경로에서만 띄운다 if (updateInfo.hasUpdate && !updateInfo.auto && updateInfo.latestVersion) { - setUpdate({ mode: 'link', version: updateInfo.latestVersion, url: updateInfo.url }) + applyUpdate({ mode: 'link', version: updateInfo.latestVersion, url: updateInfo.url }) } }) } @@ -182,7 +186,7 @@ export default function App(): ReactElement { .catch((error) => console.error('[startup] 프로젝트 목록 조회 실패', error)) }) return unsubscribe - }, [loadSessions]) + }, [applyUpdate, loadSessions, showToast]) // 애플리케이션 메뉴(wake > 설정…)에서 설정 열기 useEffect(() => window.api.onOpenSettings(() => setShowSettings(true)), []) @@ -504,10 +508,12 @@ export default function App(): ReactElement { /> )} {showSettings && setShowSettings(false)} />} - {update && ( + {update && update.mode !== 'dismissed' && (
- {update.mode === 'link' && t('update.banner', { v: `v${update.version}` })} + {(update.mode === 'link' || update.mode === 'available') && + t('update.banner', { v: `v${update.version}` })} + {update.mode === 'requested' && t('update.requested', { v: `v${update.version}` })} {update.mode === 'downloading' && t('update.downloading', { v: `v${update.version}`, p: update.percent })} {update.mode === 'ready' && t('update.ready', { v: `v${update.version}` })} @@ -517,7 +523,26 @@ export default function App(): ReactElement { className="btn btn--primary" onClick={() => { window.api.openExternal(update.url) - setUpdate(null) + applyUpdate({ mode: 'dismissed' }) + }} + > + {t('update.download')} + + )} + {update.mode === 'available' && ( +