feat(release): switchboard 릴리스 체계 이식 — Mac 배포 + Registry 게시 + 자동 업데이트
- package-macos CI job: arm64 무서명(ad-hoc) dmg+zip, D3RO_MAC_RUNNER 변수 게이트 - sync-version.mjs: 태그 → package.json 버전 동기화 - publish-gitlab-release.mjs: Generic Package Registry(버전별+latest) 업로드 + Release 생성 - electron-builder: publish generic(latest.yml 생성), 공백 없는 artifactName, mac arm64 단일화 - UpdateService: electron-updater 4h 주기 체크 + 재시작 다이얼로그 (update-feed.ts SSOT) - @rollup/rollup-win32-x64-msvc → optionalDependencies (mac npm ci EBADPLATFORM 해소) - docs/deployment/release-guide.md: runner 등록·feed 설정·파일명 규칙 가이드 리뷰 워크플로(3관점 적대적 검증)로 확정된 결함 8건 반영: publish 부재로 latest.yml 미생성, 파일명 공백 vs 레지스트리 404, mac EBADPLATFORM, needs optional의 실패 미커버, DELETE 권한 의존, CHANGELOG regex 경계, --arm64 무시, 다이얼로그 parent 부재
This commit is contained in:
parent
8f7d300b89
commit
3beba99668
11 changed files with 776 additions and 2291 deletions
|
|
@ -15,6 +15,18 @@ files:
|
|||
- out/**/*
|
||||
- "!out/**/*.map"
|
||||
|
||||
# ────────────────────────────────────────────────────────────────────
|
||||
# 자동 업데이트 feed — 이 설정이 있어야 electron-builder가
|
||||
# latest.yml / latest-mac.yml (update info)를 생성한다.
|
||||
# generic provider는 파일 생성만 트리거하고 직접 업로드는 하지 않음
|
||||
# (업로드는 scripts/ci/publish-gitlab-release.mjs 담당).
|
||||
# 첫 릴리스 후 projects/0을 실제 프로젝트 ID로 교체할 것 —
|
||||
# src/main/update-feed.ts의 UPDATE_FEED_URL과 반드시 동일 값 유지.
|
||||
# ────────────────────────────────────────────────────────────────────
|
||||
publish:
|
||||
provider: generic
|
||||
url: "https://gitlab.twentyoz.kr:8443/api/v4/projects/0/packages/generic/d3ro-voice/latest"
|
||||
|
||||
# native 모듈은 asar 외부에 배치
|
||||
asarUnpack:
|
||||
- "node_modules/better-sqlite3/**"
|
||||
|
|
@ -37,15 +49,18 @@ win:
|
|||
# ────────────────────────────────────────────────────────────────────
|
||||
mac:
|
||||
category: public.app-category.productivity
|
||||
# 파일명 공백 금지 — GitLab Generic Package Registry가 공백을 불허해
|
||||
# latest-mac.yml의 url과 레지스트리 업로드명이 어긋나면 업데이트 다운로드 404.
|
||||
artifactName: "D3RO-Voice-${version}-${arch}.${ext}"
|
||||
# x64 제외 — 사이드카(PyInstaller)가 arm64 전용이라 x64 산출물은 깨진 빌드가 됨.
|
||||
# Intel Mac 수요 발생 시 x64 runner에서 별도 빌드.
|
||||
target:
|
||||
- target: dmg
|
||||
arch:
|
||||
- arm64
|
||||
- x64
|
||||
- target: zip
|
||||
arch:
|
||||
- arm64
|
||||
- x64
|
||||
hardenedRuntime: true
|
||||
gatekeeperAssess: false
|
||||
entitlements: build/entitlements.mac.plist
|
||||
|
|
@ -104,6 +119,8 @@ extraResources:
|
|||
- "**/*"
|
||||
|
||||
nsis:
|
||||
# 파일명 공백 금지 — mac.artifactName 주석과 동일한 이유 (latest.yml url 정합)
|
||||
artifactName: "D3RO-Voice-Setup-${version}-${arch}.${ext}"
|
||||
# oneClick=false + perMachine=false 조합이 System.dll 0xc0000005 크래시 사례(#7921).
|
||||
# 사내 테스트 단계에선 oneClick=true로 전환하여 크래시 경로 회피.
|
||||
oneClick: true
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@
|
|||
"test:watch": "vitest --config vitest.config.ts",
|
||||
"pack": "electron-vite build && electron-builder --dir",
|
||||
"dist": "electron-vite build && electron-builder --config electron-builder.yml",
|
||||
"dist:win": "electron-vite build && electron-builder --win --config electron-builder.yml --publish never",
|
||||
"dist:mac": "electron-vite build && electron-builder --mac --config electron-builder.yml --publish never -c.mac.identity=- -c.mac.hardenedRuntime=false",
|
||||
"build:sidecar": "python scripts/build-sidecar.py",
|
||||
"setup:sox": "powershell -ExecutionPolicy Bypass -File scripts/download-sox.ps1"
|
||||
},
|
||||
|
|
@ -49,13 +51,13 @@
|
|||
"@mui/icons-material": "^7.0.0",
|
||||
"@mui/material": "^7.0.0",
|
||||
"@nut-tree-fork/nut-js": "^4.2.6",
|
||||
"@rollup/rollup-win32-x64-msvc": "^4.60.1",
|
||||
"better-sqlite3": "^12.8.0",
|
||||
"docx": "^9.6.1",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"electron-audio-loopback": "^1.0.6",
|
||||
"electron-log": "^5.2.0",
|
||||
"electron-store": "^10.0.0",
|
||||
"electron-updater": "^6.3.9",
|
||||
"fluent-ffmpeg": "^2.1.3",
|
||||
"node-record-lpcm16": "^1.0.1",
|
||||
"pdf-parse": "^2.4.5",
|
||||
|
|
@ -64,5 +66,8 @@
|
|||
"react-markdown": "^10.1.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"uiohook-napi": "^1.5.5"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rollup/rollup-win32-x64-msvc": "^4.60.1"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -66,6 +66,7 @@ export async function bootstrap(): Promise<void> {
|
|||
{ name: 'meeting-mode', critical: false, fn: initMeetingMode },
|
||||
// cloud-sync는 가장 마지막에 — 세션 복원 성공 시 DB를 열고 auth-changed emit.
|
||||
{ name: 'cloud-sync', critical: false, fn: initCloudSync },
|
||||
{ name: 'auto-update', critical: false, fn: initAutoUpdate },
|
||||
]
|
||||
|
||||
for (const step of steps) {
|
||||
|
|
@ -139,6 +140,11 @@ async function initAutoLaunch(): Promise<void> {
|
|||
getAutoLaunchService().syncWithConfig()
|
||||
}
|
||||
|
||||
async function initAutoUpdate(): Promise<void> {
|
||||
const { getUpdateService } = await import('./services/UpdateService')
|
||||
getUpdateService().initialize()
|
||||
}
|
||||
|
||||
async function initPopupWindows(): Promise<void> {
|
||||
preloadPopupWindows()
|
||||
setupHistoryPopupIPC()
|
||||
|
|
|
|||
173
apps/desktop/src/main/services/UpdateService.ts
Normal file
173
apps/desktop/src/main/services/UpdateService.ts
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
// src/main/services/UpdateService.ts
|
||||
// electron-updater 기반 자동 업데이트. 싱글톤 + EventEmitter.
|
||||
//
|
||||
// feed: GitLab Generic Package Registry `d3ro-voice/latest` (update-feed.ts SSOT,
|
||||
// release-create CI 잡이 갱신). 다음 조건이면 조용히 비활성:
|
||||
// - dev 실행 (app.isPackaged=false)
|
||||
// - Windows가 아닌 플랫폼 (macOS 무서명 빌드는 Squirrel.Mac 서명 요구로 미지원)
|
||||
// - UPDATE_FEED_URL 미설정
|
||||
|
||||
import { EventEmitter } from 'events'
|
||||
import { app, dialog } from 'electron'
|
||||
import { getLogger } from './LoggerService'
|
||||
import { configGet } from './ConfigService'
|
||||
import { getMainWindow } from '../windows/WindowManager'
|
||||
import { UPDATE_FEED_URL } from '../update-feed'
|
||||
|
||||
const logger = getLogger('UpdateService')
|
||||
|
||||
/** 앱 시작 후 첫 체크까지 지연 — 초기화 경합(모델 로딩 등) 회피 */
|
||||
const INITIAL_CHECK_DELAY_MS = 30_000
|
||||
/** 주기 체크 간격 */
|
||||
const CHECK_INTERVAL_MS = 4 * 60 * 60 * 1000
|
||||
|
||||
export interface UpdateServiceEvents {
|
||||
'update-available': { version: string }
|
||||
'update-downloaded': { version: string }
|
||||
'update-error': { message: string }
|
||||
}
|
||||
|
||||
class UpdateService extends EventEmitter {
|
||||
private _initialized = false
|
||||
private _initialTimer: NodeJS.Timeout | null = null
|
||||
private _intervalTimer: NodeJS.Timeout | null = null
|
||||
private _promptShown = false
|
||||
|
||||
/** 자동 업데이트 시작. 비활성 조건이면 로그만 남기고 no-op. */
|
||||
initialize(): void {
|
||||
if (this._initialized) return
|
||||
this._initialized = true
|
||||
|
||||
if (!app.isPackaged) {
|
||||
logger.info('dev 실행 — 자동 업데이트 비활성')
|
||||
return
|
||||
}
|
||||
if (process.platform !== 'win32') {
|
||||
logger.info(`플랫폼 ${process.platform} — 자동 업데이트 미지원 (서명 필요)`)
|
||||
return
|
||||
}
|
||||
if (!UPDATE_FEED_URL) {
|
||||
logger.info('UPDATE_FEED_URL 미설정 — 자동 업데이트 비활성')
|
||||
return
|
||||
}
|
||||
|
||||
// electron-updater는 CommonJS — 동적 require로 로드 (미설치/로드 실패 시 비활성)
|
||||
let autoUpdater: import('electron-updater').AppUpdater
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
autoUpdater = (require('electron-updater') as typeof import('electron-updater')).autoUpdater
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
`electron-updater 로드 실패 — 자동 업데이트 비활성: ${err instanceof Error ? err.message : String(err)}`,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
autoUpdater.setFeedURL({ provider: 'generic', url: UPDATE_FEED_URL })
|
||||
autoUpdater.autoDownload = true
|
||||
autoUpdater.autoInstallOnAppQuit = true
|
||||
autoUpdater.logger = {
|
||||
info: (msg: unknown) => logger.info(String(msg)),
|
||||
warn: (msg: unknown) => logger.warn(String(msg)),
|
||||
error: (msg: unknown) => logger.error(String(msg)),
|
||||
debug: (msg: unknown) => logger.debug(String(msg)),
|
||||
}
|
||||
|
||||
autoUpdater.on('update-available', (info) => {
|
||||
logger.info(`업데이트 발견: ${info.version}`)
|
||||
this.emit('update-available', { version: info.version })
|
||||
})
|
||||
|
||||
autoUpdater.on('update-downloaded', (info) => {
|
||||
logger.info(`업데이트 다운로드 완료: ${info.version}`)
|
||||
this.emit('update-downloaded', { version: info.version })
|
||||
void this._promptRestart(autoUpdater, info.version)
|
||||
})
|
||||
|
||||
autoUpdater.on('error', (err) => {
|
||||
// 네트워크 오류/401 등은 조용히 로그만 (다음 주기에 재시도)
|
||||
logger.warn(`업데이트 체크 실패: ${err.message}`)
|
||||
this.emit('update-error', { message: err.message })
|
||||
})
|
||||
|
||||
const check = (): void => {
|
||||
autoUpdater.checkForUpdates().catch((err: unknown) => {
|
||||
logger.warn(
|
||||
`checkForUpdates 실패: ${err instanceof Error ? err.message : String(err)}`,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
this._initialTimer = setTimeout(check, INITIAL_CHECK_DELAY_MS)
|
||||
this._intervalTimer = setInterval(check, CHECK_INTERVAL_MS)
|
||||
logger.info(`자동 업데이트 활성 — feed: ${UPDATE_FEED_URL}`)
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
if (this._initialTimer) clearTimeout(this._initialTimer)
|
||||
if (this._intervalTimer) clearInterval(this._intervalTimer)
|
||||
this._initialTimer = null
|
||||
this._intervalTimer = null
|
||||
}
|
||||
|
||||
/** 다운로드 완료 시 재시작 여부 다이얼로그 (세션당 1회) */
|
||||
private async _promptRestart(
|
||||
autoUpdater: import('electron-updater').AppUpdater,
|
||||
version: string,
|
||||
): Promise<void> {
|
||||
if (this._promptShown) return
|
||||
this._promptShown = true
|
||||
|
||||
const isKo = (configGet('language') as string | undefined)?.startsWith('ko') ?? true
|
||||
const options = {
|
||||
type: 'info' as const,
|
||||
title: isKo ? '업데이트 준비 완료' : 'Update Ready',
|
||||
message: isKo
|
||||
? `D3RO Voice ${version} 업데이트가 다운로드되었습니다. 지금 재시작하여 적용할까요?`
|
||||
: `D3RO Voice ${version} has been downloaded. Restart now to apply?`,
|
||||
buttons: isKo ? ['지금 재시작', '나중에'] : ['Restart Now', 'Later'],
|
||||
defaultId: 0,
|
||||
cancelId: 1,
|
||||
}
|
||||
// 메인 윈도우가 살아있으면 parent로 붙여 포커스 스틸 방지 (트레이 상태면 독립 표시)
|
||||
const win = getMainWindow()
|
||||
const { response } =
|
||||
win && !win.isDestroyed()
|
||||
? await dialog.showMessageBox(win, options)
|
||||
: await dialog.showMessageBox(options)
|
||||
|
||||
if (response === 0) {
|
||||
autoUpdater.quitAndInstall()
|
||||
}
|
||||
// '나중에' — autoInstallOnAppQuit=true라 종료 시 자동 적용
|
||||
}
|
||||
|
||||
// ── 타입 안전한 이벤트 메서드 오버라이드 ──
|
||||
|
||||
override emit<K extends keyof UpdateServiceEvents>(
|
||||
event: K,
|
||||
payload: UpdateServiceEvents[K],
|
||||
): boolean {
|
||||
return super.emit(event, payload)
|
||||
}
|
||||
|
||||
override on<K extends keyof UpdateServiceEvents>(
|
||||
event: K,
|
||||
listener: (payload: UpdateServiceEvents[K]) => void,
|
||||
): this {
|
||||
return super.on(event, listener)
|
||||
}
|
||||
}
|
||||
|
||||
// ── 싱글톤 ──
|
||||
|
||||
let instance: UpdateService | null = null
|
||||
|
||||
export function getUpdateService(): UpdateService {
|
||||
if (!instance) {
|
||||
instance = new UpdateService()
|
||||
}
|
||||
return instance
|
||||
}
|
||||
|
||||
export { UpdateService }
|
||||
15
apps/desktop/src/main/update-feed.ts
Normal file
15
apps/desktop/src/main/update-feed.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
// src/main/update-feed.ts
|
||||
// 자동 업데이트 feed URL SSOT.
|
||||
//
|
||||
// release-create CI 잡이 GitLab Generic Package Registry의
|
||||
// `d3ro-voice/latest` 패키지에 latest.yml + 설치파일을 게시하며,
|
||||
// electron-updater가 이 URL에서 latest.yml을 읽어 업데이트를 감지한다.
|
||||
//
|
||||
// 첫 릴리스 후 GitLab 프로젝트 ID를 확인해 기입할 것:
|
||||
// https://gitlab.twentyoz.kr:8443/api/v4/projects/<PROJECT_ID>/packages/generic/d3ro-voice/latest
|
||||
//
|
||||
// 전제: 프로젝트 설정에서 "Allow anyone to pull from Package Registry" 활성화
|
||||
// (비활성 시 무인증 다운로드가 401 — 앱은 조용히 업데이트 체크를 건너뜀).
|
||||
//
|
||||
// 빈 문자열이면 UpdateService가 비활성 상태로 동작한다.
|
||||
export const UPDATE_FEED_URL = ''
|
||||
Loading…
Add table
Add a link
Reference in a new issue