Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { listTerminals } from './lib/terminals'
import { checkForUpdate } from './lib/updater'
import {
checkViaAutoUpdater,
downloadUpdate,
initAutoUpdate,
installUpdate,
isAutoUpdateSupported
Expand Down Expand Up @@ -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 }) => {
Expand Down
141 changes: 141 additions & 0 deletions src/main/lib/autoUpdate.test.ts
Original file line number Diff line number Diff line change
@@ -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<string[]> 를 돌려주고 실패하면 거부한다
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<typeof vi.fn>
}

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')
})
})
39 changes: 33 additions & 6 deletions src/main/lib/autoUpdate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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 })
})
}
Expand All @@ -60,11 +71,27 @@ export async function checkViaAutoUpdater(): Promise<UpdateInfo> {
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<void> {
try {
await autoUpdater.downloadUpdate()
} catch (error) {
// 취소는 'error' 이벤트를 내지 않는다. 삼키면 배너가 승인 상태에 갇히므로 렌더러까지 올린다
console.error('[update] 다운로드 실패', error)
throw error
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

export function installUpdate(): void {
autoUpdater.quitAndInstall()
}
1 change: 1 addition & 0 deletions src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
}

Expand Down
61 changes: 43 additions & 18 deletions src/renderer/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -112,6 +109,15 @@ export default function App(): ReactElement {
translateRef.current = t
}, [t])

// 이벤트 콜백이 직전 배너를 봐야 하는데, 실패 토스트 판단이 걸려 있어 setUpdate(prev=>…) 안에
// 넣을 수 없다(StrictMode 에서 updater 는 순수해야 한다). 시작 효과에 update 를 묶을 수도 없어
// ref 로 읽는다. 이펙트로 미러링하면 연달아 오는 이벤트가 낡은 값을 보므로 여기서 함께 쓴다
const updateRef = useRef<UpdateBannerState | null>(null)
const applyUpdate = useCallback((next: UpdateBannerState | null) => {
updateRef.current = next
setUpdate(next)
}, [])

const loadSessions = useCallback(
async (projectId: string) => {
if (loadedProjects.current.has(projectId)) return
Expand Down Expand Up @@ -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) => {
Expand All @@ -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 })
}
})
}
Expand All @@ -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)), [])
Expand Down Expand Up @@ -504,10 +508,12 @@ export default function App(): ReactElement {
/>
)}
{showSettings && <SettingsDialog onClose={() => setShowSettings(false)} />}
{update && (
{update && update.mode !== 'dismissed' && (
<div className="update-banner" role="status">
<span className="update-banner__text">
{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}` })}
Expand All @@ -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')}
</button>
)}
{update.mode === 'available' && (
<button
className="btn btn--primary"
onClick={() => {
// 첫 진행률은 빨라야 1초 뒤다. 그 전에 끊기면 화면이 전혀 안 바뀌므로 먼저 표시한다
const version = update.version
applyUpdate({ mode: 'requested', version })
window.api.downloadUpdate().catch(() => {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
// 'error' 이벤트가 이미 되돌렸으면 두 번 알리지 않는다. 취소처럼 이벤트가
// 오지 않는 실패만 여기서 수습한다
if (updateRef.current?.mode !== 'requested') return
applyUpdate({ mode: 'available', version })
showToast(t('update.failed'))
})
}}
>
{t('update.download')}
Expand All @@ -530,7 +555,7 @@ export default function App(): ReactElement {
)}
<button
className="update-banner__close"
onClick={() => setUpdate(null)}
onClick={() => applyUpdate({ mode: 'dismissed' })}
aria-label={t('common.close')}
>
×
Expand Down
4 changes: 4 additions & 0 deletions src/renderer/src/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,9 +73,11 @@ const ko = {
'toast.unknownError': '알 수 없는 오류',
'update.banner': '새 버전 {v} 이 나왔습니다',
'update.download': '다운로드',
'update.requested': '새 버전 {v} 준비 중…',
'update.downloading': '새 버전 {v} 다운로드 중… {p}%',
'update.ready': '새 버전 {v} 준비 완료 — 재시작하면 적용됩니다',
'update.restart': '지금 재시작',
'update.failed': '업데이트를 받지 못했습니다. 잠시 뒤 다시 시도해 주세요',
'settings.title': '설정',
'settings.general': '일반',
'settings.language': '언어',
Expand Down Expand Up @@ -182,9 +184,11 @@ const en: Record<MessageKey, string> = {
'toast.unknownError': 'unknown error',
'update.banner': 'New version {v} is available',
'update.download': 'Download',
'update.requested': 'Preparing {v}…',
'update.downloading': 'Downloading {v}… {p}%',
'update.ready': 'Version {v} downloaded — restart to apply',
'update.restart': 'Restart now',
'update.failed': "Couldn't download the update. Try again in a moment",
'settings.title': 'Settings',
'settings.general': 'General',
'settings.language': 'Language',
Expand Down
Loading
Loading