From 1c7a97f52cd8889ef39aca896bafd5498f3c5ad4 Mon Sep 17 00:00:00 2001 From: JeongUk Park Date: Fri, 4 Sep 2026 00:54:20 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20=EC=83=88=20=EB=B2=84=EC=A0=84=20?= =?UTF-8?q?=EB=8B=A4=EC=9A=B4=EB=A1=9C=EB=93=9C=20=EC=A0=84=EC=97=90=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=EC=9E=90=20=EC=8A=B9=EC=9D=B8=EC=9D=84=20?= =?UTF-8?q?=EB=B0=9B=EB=8A=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit autoDownload 를 끄고 update-available 이벤트를 배너로 올린다. 사용자가 다운로드를 누르면 그때 downloadUpdate 가 시작된다. 곁들여 두 가지를 고친다. - 진행률 배너가 autoUpdater.currentVersion(설치된 버전)을 표시하던 문제. 받고 있는 버전을 기억해 쓴다. - 승인 뒤 다운로드가 실패하면 배너가 조용히 사라지던 문제. 다시 누를 수 있게 승인 단계로 되돌리고 토스트로 알린다. 배너 상태 전이는 updateBanner.ts 로 분리했다. --- src/main/index.ts | 2 + src/main/lib/autoUpdate.test.ts | 68 +++++++++++++++++++++++ src/main/lib/autoUpdate.ts | 17 +++++- src/preload/index.ts | 1 + src/renderer/src/App.tsx | 32 +++++++---- src/renderer/src/i18n.ts | 2 + src/renderer/src/lib/updateBanner.test.ts | 28 ++++++++++ src/renderer/src/lib/updateBanner.ts | 29 ++++++++++ src/shared/types.ts | 3 + 9 files changed, 167 insertions(+), 15 deletions(-) create mode 100644 src/main/lib/autoUpdate.test.ts create mode 100644 src/renderer/src/lib/updateBanner.test.ts create mode 100644 src/renderer/src/lib/updateBanner.ts 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..1bc67e2 --- /dev/null +++ b/src/main/lib/autoUpdate.test.ts @@ -0,0 +1,68 @@ +import { EventEmitter } from 'events' +import type { BrowserWindow } from 'electron' +import { autoUpdater } from 'electron-updater' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { 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(), { + // electron-updater 의 기본값 — 프로덕션 코드가 이걸 바꾸는지 보려고 그대로 둔다 + autoDownload: true, + autoInstallOnAppQuit: false, + currentVersion: { version: '0.9.0' }, + downloadUpdate: vi.fn(), + quitAndInstall: vi.fn() + }) + return { autoUpdater: fake } +}) + +/** initAutoUpdate 가 쓰는 만큼만 갖춘 창 대역 */ +function fakeWindow(): { window: BrowserWindow; sent: unknown[] } { + const sent: unknown[] = [] + const window = { + isDestroyed: () => false, + 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 } + +beforeEach(() => { + updater.removeAllListeners() + updater.autoDownload = true +}) + +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' }]) + }) + + it('확인 단계에서 다운로드를 자동으로 시작하지 않는다', () => { + 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 }) + }) +}) diff --git a/src/main/lib/autoUpdate.ts b/src/main/lib/autoUpdate.ts index 2ccae5a..28bd43e 100644 --- a/src/main/lib/autoUpdate.ts +++ b/src/main/lib/autoUpdate.ts @@ -33,11 +33,17 @@ export function initAutoUpdate(window: BrowserWindow): void { if (!window.isDestroyed()) window.webContents.send('update:event', event) } - autoUpdater.autoDownload = true + // 다운로드는 사용자가 배너에서 승인해야 시작한다 (downloadUpdate) + autoUpdater.autoDownload = false autoUpdater.autoInstallOnAppQuit = true + // autoUpdater.currentVersion 은 설치된 버전이라 진행률에 쓸 수 없다. 받고 있는 버전을 기억해 둔다 + let pendingVersion = '' + 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) }) + send({ type: 'downloading', version: pendingVersion, percent: Math.round(progress.percent) }) }) autoUpdater.on('update-downloaded', (info) => { send({ type: 'ready', version: info.version }) @@ -65,6 +71,11 @@ export async function checkViaAutoUpdater(): Promise { } } +/** 사용자가 배너에서 승인했을 때만 호출한다 */ +export function downloadUpdate(): void { + void autoUpdater.downloadUpdate() +} + 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..c08f1b7 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 { nextBanner, type UpdateBannerState } from './lib/updateBanner' import { shortcut } from './lib/platform' import { Sidebar } from './components/Sidebar' import { ConversationView } from './components/ConversationView' @@ -112,6 +109,12 @@ export default function App(): ReactElement { translateRef.current = t }, [t]) + // 업데이트 이벤트 콜백이 직전 배너 상태를 봐야 한다. 시작 효과에 update 를 묶으면 안 되므로 ref 로 읽는다 + const updateRef = useRef(null) + useEffect(() => { + updateRef.current = update + }, [update]) + const loadSessions = useCallback( async (projectId: string) => { if (loadedProjects.current.has(projectId)) return @@ -148,13 +151,12 @@ export default function App(): ReactElement { useEffect(() => { // electron-updater 이벤트(다운로드 진행·완료)가 배너 상태를 구동한다 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 + // 승인해서 받던 중에 끊긴 것은 사용자가 기다리던 일이라 조용히 넘기지 않는다 + if (event.type === 'error' && prev?.mode === 'downloading') { + showToast(translateRef.current('update.failed')) } + setUpdate(nextBanner(prev, event)) }) // 첫 그룹 자동 펼침이 설정에 좌우되므로 설정을 먼저 받고 프로젝트를 읽는다 window.api.getSettings().then((info) => { @@ -182,7 +184,7 @@ export default function App(): ReactElement { .catch((error) => console.error('[startup] 프로젝트 목록 조회 실패', error)) }) return unsubscribe - }, [loadSessions]) + }, [loadSessions, showToast]) // 애플리케이션 메뉴(wake > 설정…)에서 설정 열기 useEffect(() => window.api.onOpenSettings(() => setShowSettings(true)), []) @@ -507,7 +509,8 @@ export default function App(): ReactElement { {update && (
- {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 === 'downloading' && t('update.downloading', { v: `v${update.version}`, p: update.percent })} {update.mode === 'ready' && t('update.ready', { v: `v${update.version}` })} @@ -523,6 +526,11 @@ export default function App(): ReactElement { {t('update.download')} )} + {update.mode === 'available' && ( + + )} {update.mode === 'ready' && ( )} {update.mode === 'available' && ( - )} @@ -538,7 +552,7 @@ export default function App(): ReactElement { )}