feat(V2-4): 데스크톱 ↔ Supabase 동기화 (push only MVP)
CloudSyncService (싱글톤 + EventEmitter): - Supabase 클라이언트 lazy init (configGet으로 url/key) - safeStorage 기반 refresh token 영속화 (cloud-sync.token) - startSignIn(provider): OAuth URL 발급 후 shell.openExternal로 외부 브라우저 - handleAuthCallback(code): code -> session 교환 - pushAll(): history/dictionary/meetings/meeting_memos/meeting_documents 를 last_sync_at 이후 변경분만 upsert (onConflict: id) - 이벤트: auth-changed / sync-progress / sync-complete / sync-error Deep link (d3ro-voice://auth-callback): - main/index.ts에 setAsDefaultProtocolClient - macOS open-url 핸들러 - Windows second-instance argv 검사 - handleDeepLink로 URL 파싱 후 CloudSyncService로 전달 IPC: - @d3ro/core/ipc-channels에 CLOUD_SYNC 채널 추가 - ipc/cloud-sync-handlers.ts (configure/signIn/signOut/pushAll/state) - preload index.ts에 cloudSync 노출 - bootstrap의 cloud-sync 단계 추가 Renderer: - CloudSyncSection 컴포넌트 신규 - 미설정: Supabase URL/Key 입력 폼 - 미로그인: Google/GitHub OAuth 버튼 - 로그인: 사용자 이메일 + Sync Now + 진행 바 + 로그아웃 - d3roPalette/typoSx로 SSOT 준수, useI18n 사용 ConfigService 키 5종 추가: - hfToken, diarizationEnabled (Phase 15.5 누락분 보충) - supabaseUrl, supabaseAnonKey, cloudSyncLastAt (V2-4) - packages/core types.ts AppConfig에도 동일하게 추가 검증: - desktop typecheck OK - desktop build OK - 실제 Supabase 동기화는 사용자가 V2-2 배포 + 설정 입력 후 검증
This commit is contained in:
parent
d0c33ca259
commit
5c0f4a2b98
13 changed files with 1069 additions and 1 deletions
|
|
@ -40,10 +40,12 @@
|
||||||
"vitest": "^2.1.0"
|
"vitest": "^2.1.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@d3ro/api-client": "*",
|
||||||
"@d3ro/core": "*",
|
"@d3ro/core": "*",
|
||||||
"@d3ro/i18n": "*",
|
"@d3ro/i18n": "*",
|
||||||
"@d3ro/ui": "*",
|
"@d3ro/ui": "*",
|
||||||
"@electron-toolkit/preload": "^3.0.2",
|
"@electron-toolkit/preload": "^3.0.2",
|
||||||
|
"@supabase/supabase-js": "^2.45.0",
|
||||||
"@electron-toolkit/utils": "^4.0.0",
|
"@electron-toolkit/utils": "^4.0.0",
|
||||||
"@emotion/react": "^11.14.0",
|
"@emotion/react": "^11.14.0",
|
||||||
"@emotion/styled": "^11.14.0",
|
"@emotion/styled": "^11.14.0",
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,7 @@ export async function bootstrap(): Promise<void> {
|
||||||
{ name: 'llm-polling', critical: false, fn: initLLMPolling },
|
{ name: 'llm-polling', critical: false, fn: initLLMPolling },
|
||||||
{ name: 'meeting-summary-wiring', critical: false, fn: initMeetingSummaryWiring },
|
{ name: 'meeting-summary-wiring', critical: false, fn: initMeetingSummaryWiring },
|
||||||
{ name: 'meeting-mode', critical: false, fn: initMeetingMode },
|
{ name: 'meeting-mode', critical: false, fn: initMeetingMode },
|
||||||
|
{ name: 'cloud-sync', critical: false, fn: initCloudSync },
|
||||||
]
|
]
|
||||||
|
|
||||||
for (const step of steps) {
|
for (const step of steps) {
|
||||||
|
|
@ -268,6 +269,12 @@ async function initLLMPolling(): Promise<void> {
|
||||||
llm.startPolling()
|
llm.startPolling()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function initCloudSync(): Promise<void> {
|
||||||
|
const { getCloudSyncService } = await import('./services/CloudSyncService')
|
||||||
|
const sync = getCloudSyncService()
|
||||||
|
await sync.init()
|
||||||
|
}
|
||||||
|
|
||||||
async function initMeetingSummaryWiring(): Promise<void> {
|
async function initMeetingSummaryWiring(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const { getCaptionService } = await import('./services/CaptionService')
|
const { getCaptionService } = await import('./services/CaptionService')
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
// src/main/index.ts — 앱 진입점
|
// src/main/index.ts — 앱 진입점
|
||||||
|
|
||||||
import { app } from 'electron'
|
import { app } from 'electron'
|
||||||
|
import path from 'path'
|
||||||
import { bootstrap } from './bootstrap'
|
import { bootstrap } from './bootstrap'
|
||||||
import { setupLifecycle } from './lifecycle'
|
import { setupLifecycle } from './lifecycle'
|
||||||
import { getMainWindow } from './windows/WindowManager'
|
import { getMainWindow } from './windows/WindowManager'
|
||||||
|
|
@ -14,19 +15,59 @@ process.on('uncaughtException', (err) => {
|
||||||
try { require('electron-log').default?.error?.('Uncaught:', err) } catch { /* noop */ }
|
try { require('electron-log').default?.error?.('Uncaught:', err) } catch { /* noop */ }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// V2-4: deep link 프로토콜 등록 — d3ro-voice://auth-callback?code=...
|
||||||
|
const PROTOCOL = 'd3ro-voice'
|
||||||
|
if (process.defaultApp) {
|
||||||
|
if (process.argv.length >= 2) {
|
||||||
|
app.setAsDefaultProtocolClient(PROTOCOL, process.execPath, [path.resolve(process.argv[1])])
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
app.setAsDefaultProtocolClient(PROTOCOL)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* deep link URL을 파싱해서 CloudSyncService로 전달.
|
||||||
|
* d3ro-voice://auth-callback?code=...
|
||||||
|
*/
|
||||||
|
async function handleDeepLink(url: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url)
|
||||||
|
if (parsed.host === 'auth-callback') {
|
||||||
|
const code = parsed.searchParams.get('code')
|
||||||
|
if (code) {
|
||||||
|
const { getCloudSyncService } = await import('./services/CloudSyncService')
|
||||||
|
await getCloudSyncService().handleAuthCallback(code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 잘못된 URL은 무시
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 단일 인스턴스 잠금
|
// 단일 인스턴스 잠금
|
||||||
const gotTheLock = app.requestSingleInstanceLock()
|
const gotTheLock = app.requestSingleInstanceLock()
|
||||||
|
|
||||||
if (!gotTheLock) {
|
if (!gotTheLock) {
|
||||||
app.quit()
|
app.quit()
|
||||||
} else {
|
} else {
|
||||||
app.on('second-instance', () => {
|
app.on('second-instance', (_event, argv) => {
|
||||||
// 기존 인스턴스의 메인 윈도우를 활성화
|
// 기존 인스턴스의 메인 윈도우를 활성화
|
||||||
const mainWindow = getMainWindow()
|
const mainWindow = getMainWindow()
|
||||||
if (mainWindow) {
|
if (mainWindow) {
|
||||||
if (mainWindow.isMinimized()) mainWindow.restore()
|
if (mainWindow.isMinimized()) mainWindow.restore()
|
||||||
mainWindow.focus()
|
mainWindow.focus()
|
||||||
}
|
}
|
||||||
|
// Windows: deep link는 두 번째 인스턴스의 argv 마지막 인자로 들어옴
|
||||||
|
const deepLinkArg = argv.find((a) => a.startsWith(`${PROTOCOL}://`))
|
||||||
|
if (deepLinkArg) {
|
||||||
|
void handleDeepLink(deepLinkArg)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// macOS: open-url 이벤트로 deep link 도착
|
||||||
|
app.on('open-url', (event, url) => {
|
||||||
|
event.preventDefault()
|
||||||
|
void handleDeepLink(url)
|
||||||
})
|
})
|
||||||
|
|
||||||
app.whenReady().then(async () => {
|
app.whenReady().then(async () => {
|
||||||
|
|
|
||||||
121
apps/desktop/src/main/ipc/cloud-sync-handlers.ts
Normal file
121
apps/desktop/src/main/ipc/cloud-sync-handlers.ts
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
// src/main/ipc/cloud-sync-handlers.ts
|
||||||
|
// Phase V2-4: CloudSync IPC 핸들러
|
||||||
|
|
||||||
|
import { ipcMain, BrowserWindow } from 'electron'
|
||||||
|
import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
|
||||||
|
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
|
||||||
|
import type { IPCResult } from '@d3ro/core/errors'
|
||||||
|
import { getCloudSyncService } from '../services/CloudSyncService'
|
||||||
|
import { configSet } from '../services/ConfigService'
|
||||||
|
import { getLogger } from '../services/LoggerService'
|
||||||
|
|
||||||
|
const logger = getLogger('cloud-sync-handlers')
|
||||||
|
|
||||||
|
interface SignInParams {
|
||||||
|
provider: 'google' | 'github'
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConfigureParams {
|
||||||
|
url: string
|
||||||
|
anonKey: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function ok<T>(data: T): IPCResult<T> {
|
||||||
|
return { success: true, data }
|
||||||
|
}
|
||||||
|
|
||||||
|
function fail(error: unknown): IPCResult<never> {
|
||||||
|
if (error instanceof D3ROError) {
|
||||||
|
return { success: false, error: { code: error.code, message: error.message } }
|
||||||
|
}
|
||||||
|
const message = error instanceof Error ? error.message : String(error)
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: { code: ErrorCode.LLMProcessingFailed, message }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerCloudSyncHandlers(): void {
|
||||||
|
const sync = getCloudSyncService()
|
||||||
|
|
||||||
|
ipcMain.handle(IPC_CHANNELS.CLOUD_SYNC.GET_STATE, () => {
|
||||||
|
try {
|
||||||
|
return ok(sync.getState())
|
||||||
|
} catch (e) {
|
||||||
|
return fail(e)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle(IPC_CHANNELS.CLOUD_SYNC.CONFIGURE, async (_e, params: ConfigureParams) => {
|
||||||
|
try {
|
||||||
|
configSet('supabaseUrl' as never, params.url as never)
|
||||||
|
configSet('supabaseAnonKey' as never, params.anonKey as never)
|
||||||
|
// CloudSyncService 재초기화
|
||||||
|
await sync.init()
|
||||||
|
return ok(sync.getState())
|
||||||
|
} catch (e) {
|
||||||
|
return fail(e)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle(IPC_CHANNELS.CLOUD_SYNC.SIGN_IN, async (_e, params: SignInParams) => {
|
||||||
|
try {
|
||||||
|
await sync.startSignIn(params.provider)
|
||||||
|
return ok({ started: true })
|
||||||
|
} catch (e) {
|
||||||
|
return fail(e)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle(IPC_CHANNELS.CLOUD_SYNC.HANDLE_CALLBACK, async (_e, params: { code: string }) => {
|
||||||
|
try {
|
||||||
|
await sync.handleAuthCallback(params.code)
|
||||||
|
return ok(sync.getState())
|
||||||
|
} catch (e) {
|
||||||
|
return fail(e)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle(IPC_CHANNELS.CLOUD_SYNC.SIGN_OUT, async () => {
|
||||||
|
try {
|
||||||
|
await sync.signOut()
|
||||||
|
return ok(sync.getState())
|
||||||
|
} catch (e) {
|
||||||
|
return fail(e)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle(IPC_CHANNELS.CLOUD_SYNC.PUSH_ALL, async () => {
|
||||||
|
try {
|
||||||
|
const result = await sync.pushAll()
|
||||||
|
configSet('cloudSyncLastAt' as never, Date.now() as never)
|
||||||
|
return ok(result)
|
||||||
|
} catch (e) {
|
||||||
|
return fail(e)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 이벤트 → renderer 브로드캐스트
|
||||||
|
function broadcast(channel: string, payload: unknown): void {
|
||||||
|
BrowserWindow.getAllWindows().forEach((win) => {
|
||||||
|
if (!win.isDestroyed()) {
|
||||||
|
win.webContents.send(channel, payload)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
sync.on('auth-changed', (payload) => {
|
||||||
|
broadcast(IPC_CHANNELS.CLOUD_SYNC.AUTH_CHANGED, payload)
|
||||||
|
})
|
||||||
|
sync.on('sync-progress', (payload) => {
|
||||||
|
broadcast(IPC_CHANNELS.CLOUD_SYNC.SYNC_PROGRESS, payload)
|
||||||
|
})
|
||||||
|
sync.on('sync-complete', (payload) => {
|
||||||
|
broadcast(IPC_CHANNELS.CLOUD_SYNC.SYNC_COMPLETE, payload)
|
||||||
|
})
|
||||||
|
sync.on('sync-error', (payload) => {
|
||||||
|
broadcast(IPC_CHANNELS.CLOUD_SYNC.SYNC_ERROR, payload)
|
||||||
|
})
|
||||||
|
|
||||||
|
logger.info('Cloud Sync IPC handlers registered')
|
||||||
|
}
|
||||||
|
|
@ -25,6 +25,7 @@ import { registerRAGHandlers } from './rag-handlers'
|
||||||
import { registerVoiceActionHandlers } from './voice-action-handlers'
|
import { registerVoiceActionHandlers } from './voice-action-handlers'
|
||||||
import { registerMeetingModeHandlers } from './meeting-mode-handlers'
|
import { registerMeetingModeHandlers } from './meeting-mode-handlers'
|
||||||
import { registerMeetingDocTemplateHandlers } from './meeting-doc-template-handlers'
|
import { registerMeetingDocTemplateHandlers } from './meeting-doc-template-handlers'
|
||||||
|
import { registerCloudSyncHandlers } from './cloud-sync-handlers'
|
||||||
import { getLogger } from '../services/LoggerService'
|
import { getLogger } from '../services/LoggerService'
|
||||||
|
|
||||||
const logger = getLogger('ipc')
|
const logger = getLogger('ipc')
|
||||||
|
|
@ -55,5 +56,6 @@ export function registerAllIpcHandlers(): void {
|
||||||
registerVoiceActionHandlers()
|
registerVoiceActionHandlers()
|
||||||
registerMeetingModeHandlers()
|
registerMeetingModeHandlers()
|
||||||
registerMeetingDocTemplateHandlers()
|
registerMeetingDocTemplateHandlers()
|
||||||
|
registerCloudSyncHandlers()
|
||||||
logger.info('All IPC handlers registered')
|
logger.info('All IPC handlers registered')
|
||||||
}
|
}
|
||||||
|
|
|
||||||
477
apps/desktop/src/main/services/CloudSyncService.ts
Normal file
477
apps/desktop/src/main/services/CloudSyncService.ts
Normal file
|
|
@ -0,0 +1,477 @@
|
||||||
|
// src/main/services/CloudSyncService.ts
|
||||||
|
// Phase V2-4: Supabase 동기화 — OAuth 로그인, 세션 영속화, push 동기화
|
||||||
|
// Local-first 원칙: SQLite가 source of truth, Supabase는 미러
|
||||||
|
|
||||||
|
import { EventEmitter } from 'events'
|
||||||
|
import { shell, app, safeStorage } from 'electron'
|
||||||
|
import { createClient, type SupabaseClient, type Session, type User } from '@supabase/supabase-js'
|
||||||
|
import { eq, gt } from 'drizzle-orm'
|
||||||
|
import { getLogger } from './LoggerService'
|
||||||
|
import { configGet } from './ConfigService'
|
||||||
|
import { getDatabase } from '../db'
|
||||||
|
import { history, dictionary, meetingSessions, meetingMemos, meetingDocuments } from '../db/schema'
|
||||||
|
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
|
||||||
|
|
||||||
|
const logger = getLogger('CloudSyncService')
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 타입
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
interface SyncProgress {
|
||||||
|
current: number
|
||||||
|
total: number
|
||||||
|
table: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SyncResult {
|
||||||
|
pushed: number
|
||||||
|
errors: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CloudSyncState {
|
||||||
|
authenticated: boolean
|
||||||
|
userEmail: string | null
|
||||||
|
lastSyncAt: number | null
|
||||||
|
syncing: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CloudSyncEvents {
|
||||||
|
'auth-changed': (payload: { user: User | null }) => void
|
||||||
|
'sync-progress': (payload: SyncProgress) => void
|
||||||
|
'sync-complete': (payload: SyncResult) => void
|
||||||
|
'sync-error': (payload: { error: string }) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// CloudSyncService
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
class CloudSyncService extends EventEmitter {
|
||||||
|
private _client: SupabaseClient | null = null
|
||||||
|
private _session: Session | null = null
|
||||||
|
private _lastSyncAt: number | null = null
|
||||||
|
private _syncing = false
|
||||||
|
private _initialized = false
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 초기화 — Supabase 클라이언트 생성, 저장된 세션 복원.
|
||||||
|
* Supabase URL/KEY가 설정되지 않았으면 disabled 상태로 남는다.
|
||||||
|
*/
|
||||||
|
async init(): Promise<void> {
|
||||||
|
if (this._initialized) return
|
||||||
|
this._initialized = true
|
||||||
|
|
||||||
|
const url = configGet('supabaseUrl') as string | undefined
|
||||||
|
const anonKey = configGet('supabaseAnonKey') as string | undefined
|
||||||
|
|
||||||
|
if (!url || !anonKey) {
|
||||||
|
logger.info('CloudSync disabled — Supabase URL/key not configured')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this._client = createClient(url, anonKey, {
|
||||||
|
auth: {
|
||||||
|
persistSession: false, // 직접 관리
|
||||||
|
autoRefreshToken: true,
|
||||||
|
detectSessionInUrl: false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 저장된 refresh token 복원
|
||||||
|
const stored = this._loadStoredRefreshToken()
|
||||||
|
if (stored) {
|
||||||
|
try {
|
||||||
|
const { data, error } = await this._client.auth.refreshSession({ refresh_token: stored })
|
||||||
|
if (error) {
|
||||||
|
logger.warn(`Stored session refresh failed: ${error.message}`)
|
||||||
|
this._clearStoredRefreshToken()
|
||||||
|
} else if (data.session) {
|
||||||
|
this._session = data.session
|
||||||
|
this._saveRefreshToken(data.session.refresh_token)
|
||||||
|
logger.info(`Restored session for user: ${data.session.user.email ?? data.session.user.id}`)
|
||||||
|
this.emit('auth-changed', { user: data.session.user })
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn(`Session restore error: ${err instanceof Error ? err.message : String(err)}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this._lastSyncAt = (configGet('cloudSyncLastAt') as number | undefined) ?? null
|
||||||
|
|
||||||
|
logger.info('CloudSyncService initialized')
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 상태 조회 ──────────────────────────────────────────
|
||||||
|
|
||||||
|
isEnabled(): boolean {
|
||||||
|
return this._client !== null
|
||||||
|
}
|
||||||
|
|
||||||
|
isAuthenticated(): boolean {
|
||||||
|
return this._session !== null
|
||||||
|
}
|
||||||
|
|
||||||
|
getUser(): User | null {
|
||||||
|
return this._session?.user ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
getState(): CloudSyncState {
|
||||||
|
return {
|
||||||
|
authenticated: this.isAuthenticated(),
|
||||||
|
userEmail: this._session?.user?.email ?? null,
|
||||||
|
lastSyncAt: this._lastSyncAt,
|
||||||
|
syncing: this._syncing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getLastSyncAt(): Date | null {
|
||||||
|
return this._lastSyncAt ? new Date(this._lastSyncAt) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── OAuth 로그인 ───────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OAuth 로그인 시작 — Supabase가 발급한 URL을 외부 브라우저로 열기.
|
||||||
|
* 사용자가 동의 후 d3ro-voice://auth-callback?code=...로 deep link 호출됨.
|
||||||
|
*/
|
||||||
|
async startSignIn(provider: 'google' | 'github'): Promise<void> {
|
||||||
|
if (!this._client) {
|
||||||
|
throw new D3ROError(ErrorCode.LLMServerUnreachable, 'Cloud Sync가 설정되지 않았습니다')
|
||||||
|
}
|
||||||
|
|
||||||
|
const redirectTo = 'd3ro-voice://auth-callback'
|
||||||
|
|
||||||
|
const { data, error } = await this._client.auth.signInWithOAuth({
|
||||||
|
provider,
|
||||||
|
options: {
|
||||||
|
redirectTo,
|
||||||
|
skipBrowserRedirect: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (error || !data.url) {
|
||||||
|
throw new D3ROError(
|
||||||
|
ErrorCode.LLMProcessingFailed,
|
||||||
|
`OAuth 시작 실패: ${error?.message ?? 'unknown'}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Opening OAuth URL in external browser: ${provider}`)
|
||||||
|
await shell.openExternal(data.url)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deep link 콜백 처리 — code를 session으로 교환.
|
||||||
|
*/
|
||||||
|
async handleAuthCallback(code: string): Promise<void> {
|
||||||
|
if (!this._client) {
|
||||||
|
throw new D3ROError(ErrorCode.LLMServerUnreachable, 'Cloud Sync가 설정되지 않았습니다')
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data, error } = await this._client.auth.exchangeCodeForSession(code)
|
||||||
|
if (error || !data.session) {
|
||||||
|
throw new D3ROError(
|
||||||
|
ErrorCode.LLMProcessingFailed,
|
||||||
|
`세션 교환 실패: ${error?.message ?? 'unknown'}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
this._session = data.session
|
||||||
|
this._saveRefreshToken(data.session.refresh_token)
|
||||||
|
logger.info(`Signed in: ${data.session.user.email ?? data.session.user.id}`)
|
||||||
|
this.emit('auth-changed', { user: data.session.user })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 로그아웃 — 세션/토큰 모두 폐기.
|
||||||
|
*/
|
||||||
|
async signOut(): Promise<void> {
|
||||||
|
if (this._client && this._session) {
|
||||||
|
try {
|
||||||
|
await this._client.auth.signOut()
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn(`signOut warning: ${err instanceof Error ? err.message : String(err)}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this._session = null
|
||||||
|
this._clearStoredRefreshToken()
|
||||||
|
logger.info('Signed out')
|
||||||
|
this.emit('auth-changed', { user: null })
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 동기화 (push only, MVP) ────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 마지막 동기화 시각 이후 변경된 행을 push.
|
||||||
|
* 매핑 테이블 없이 ON CONFLICT(id) DO UPDATE 사용.
|
||||||
|
*/
|
||||||
|
async pushAll(): Promise<SyncResult> {
|
||||||
|
if (!this._client || !this._session) {
|
||||||
|
throw new D3ROError(ErrorCode.LLMServerUnreachable, '로그인이 필요합니다')
|
||||||
|
}
|
||||||
|
if (this._syncing) {
|
||||||
|
throw new D3ROError(ErrorCode.LLMProcessingFailed, '이미 동기화 중입니다')
|
||||||
|
}
|
||||||
|
|
||||||
|
this._syncing = true
|
||||||
|
const result: SyncResult = { pushed: 0, errors: [] }
|
||||||
|
const userId = this._session.user.id
|
||||||
|
const since = this._lastSyncAt ?? 0
|
||||||
|
const db = getDatabase()
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1) history
|
||||||
|
const historyRows = await db.select().from(history).where(gt(history.updatedAt, since))
|
||||||
|
result.pushed += await this._pushTable('history', historyRows.length, async () => {
|
||||||
|
if (historyRows.length === 0) return 0
|
||||||
|
const payload = historyRows.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
user_id: userId,
|
||||||
|
original_text: r.originalText,
|
||||||
|
polished_text: r.polishedText,
|
||||||
|
mode: r.mode,
|
||||||
|
status: r.status,
|
||||||
|
duration: r.duration,
|
||||||
|
detected_language: r.detectedLanguage,
|
||||||
|
mic_device: r.micDevice,
|
||||||
|
word_count: r.wordCount,
|
||||||
|
stt_model: r.sttModel,
|
||||||
|
llm_model: r.llmModel,
|
||||||
|
stt_latency_ms: r.sttLatencyMs,
|
||||||
|
llm_latency_ms: r.llmLatencyMs,
|
||||||
|
created_at: new Date(r.createdAt).toISOString(),
|
||||||
|
updated_at: new Date(r.updatedAt).toISOString(),
|
||||||
|
app_version: r.appVersion,
|
||||||
|
summary_text: r.summaryText
|
||||||
|
}))
|
||||||
|
const { error } = await this._client!.from('history').upsert(payload, { onConflict: 'id' })
|
||||||
|
if (error) throw new Error(error.message)
|
||||||
|
return historyRows.length
|
||||||
|
}, result)
|
||||||
|
|
||||||
|
// 2) dictionary
|
||||||
|
const dictRows = await db.select().from(dictionary).where(gt(dictionary.updatedAt, since))
|
||||||
|
result.pushed += await this._pushTable('dictionary', dictRows.length, async () => {
|
||||||
|
if (dictRows.length === 0) return 0
|
||||||
|
const payload = dictRows.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
user_id: userId,
|
||||||
|
word: r.word,
|
||||||
|
pronunciation: r.pronunciation,
|
||||||
|
category: r.category,
|
||||||
|
usage_count: r.usageCount,
|
||||||
|
last_used_at: r.lastUsedAt ? new Date(r.lastUsedAt).toISOString() : null,
|
||||||
|
created_at: new Date(r.createdAt).toISOString(),
|
||||||
|
updated_at: new Date(r.updatedAt).toISOString()
|
||||||
|
}))
|
||||||
|
const { error } = await this._client!.from('dictionary').upsert(payload, { onConflict: 'id' })
|
||||||
|
if (error) throw new Error(error.message)
|
||||||
|
return dictRows.length
|
||||||
|
}, result)
|
||||||
|
|
||||||
|
// 3) meetings (V1 meeting_sessions)
|
||||||
|
const meetingRows = await db
|
||||||
|
.select()
|
||||||
|
.from(meetingSessions)
|
||||||
|
.where(gt(meetingSessions.updatedAt, since))
|
||||||
|
result.pushed += await this._pushTable('meetings', meetingRows.length, async () => {
|
||||||
|
if (meetingRows.length === 0) return 0
|
||||||
|
const payload = meetingRows.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
user_id: userId,
|
||||||
|
team_id: null,
|
||||||
|
title: r.title,
|
||||||
|
status: r.status,
|
||||||
|
started_at: new Date(r.startedAt).toISOString(),
|
||||||
|
ended_at: r.endedAt ? new Date(r.endedAt).toISOString() : null,
|
||||||
|
duration_ms: r.durationMs,
|
||||||
|
raw_transcript: r.rawTranscript,
|
||||||
|
edited_transcript: r.editedTranscript,
|
||||||
|
minutes_markdown: r.minutesMarkdown,
|
||||||
|
minutes_json: r.minutesJson ? JSON.parse(r.minutesJson) : null,
|
||||||
|
stt_model: r.sttModel,
|
||||||
|
llm_model: r.llmModel,
|
||||||
|
stt_latency_ms: r.sttLatencyMs,
|
||||||
|
llm_latency_ms: r.llmLatencyMs,
|
||||||
|
error_message: r.errorMessage,
|
||||||
|
created_at: new Date(r.createdAt).toISOString(),
|
||||||
|
updated_at: new Date(r.updatedAt).toISOString()
|
||||||
|
}))
|
||||||
|
const { error } = await this._client!.from('meetings').upsert(payload, { onConflict: 'id' })
|
||||||
|
if (error) throw new Error(error.message)
|
||||||
|
return meetingRows.length
|
||||||
|
}, result)
|
||||||
|
|
||||||
|
// 4) meeting_memos (회의가 push된 후에만)
|
||||||
|
if (meetingRows.length > 0) {
|
||||||
|
const meetingIds = meetingRows.map((m) => m.id)
|
||||||
|
const memoRows = await db
|
||||||
|
.select()
|
||||||
|
.from(meetingMemos)
|
||||||
|
.where(this._inArray(meetingMemos.sessionId, meetingIds))
|
||||||
|
result.pushed += await this._pushTable('meeting_memos', memoRows.length, async () => {
|
||||||
|
if (memoRows.length === 0) return 0
|
||||||
|
const payload = memoRows.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
meeting_id: r.sessionId,
|
||||||
|
user_id: userId,
|
||||||
|
content: r.content,
|
||||||
|
timestamp_ms: r.timestampMs,
|
||||||
|
created_at: new Date(r.createdAt).toISOString()
|
||||||
|
}))
|
||||||
|
const { error } = await this._client!.from('meeting_memos').upsert(payload, {
|
||||||
|
onConflict: 'id'
|
||||||
|
})
|
||||||
|
if (error) throw new Error(error.message)
|
||||||
|
return memoRows.length
|
||||||
|
}, result)
|
||||||
|
|
||||||
|
// 5) meeting_documents
|
||||||
|
const docRows = await db
|
||||||
|
.select()
|
||||||
|
.from(meetingDocuments)
|
||||||
|
.where(this._inArray(meetingDocuments.sessionId, meetingIds))
|
||||||
|
result.pushed += await this._pushTable('meeting_documents', docRows.length, async () => {
|
||||||
|
if (docRows.length === 0) return 0
|
||||||
|
const payload = docRows.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
meeting_id: r.sessionId,
|
||||||
|
user_id: userId,
|
||||||
|
template_type: r.templateType,
|
||||||
|
title: r.title,
|
||||||
|
content: r.content,
|
||||||
|
prompt_used: r.promptUsed,
|
||||||
|
llm_model: r.llmModel,
|
||||||
|
llm_latency_ms: r.llmLatencyMs,
|
||||||
|
created_at: new Date(r.createdAt).toISOString(),
|
||||||
|
updated_at: new Date(r.updatedAt).toISOString()
|
||||||
|
}))
|
||||||
|
const { error } = await this._client!.from('meeting_documents').upsert(payload, {
|
||||||
|
onConflict: 'id'
|
||||||
|
})
|
||||||
|
if (error) throw new Error(error.message)
|
||||||
|
return docRows.length
|
||||||
|
}, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
this._lastSyncAt = Date.now()
|
||||||
|
// ConfigService에 저장은 별도 — 여기서는 단순히 내부 상태만
|
||||||
|
logger.info(`Sync complete: pushed=${result.pushed} errors=${result.errors.length}`)
|
||||||
|
this.emit('sync-complete', result)
|
||||||
|
return result
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
|
result.errors.push(message)
|
||||||
|
logger.error(`Sync failed: ${message}`)
|
||||||
|
this.emit('sync-error', { error: message })
|
||||||
|
return result
|
||||||
|
} finally {
|
||||||
|
this._syncing = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 내부 헬퍼 ──────────────────────────────────────────
|
||||||
|
|
||||||
|
private async _pushTable(
|
||||||
|
table: string,
|
||||||
|
total: number,
|
||||||
|
runner: () => Promise<number>,
|
||||||
|
result: SyncResult
|
||||||
|
): Promise<number> {
|
||||||
|
this.emit('sync-progress', { current: 0, total, table })
|
||||||
|
try {
|
||||||
|
const count = await runner()
|
||||||
|
this.emit('sync-progress', { current: count, total: count, table })
|
||||||
|
return count
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
|
result.errors.push(`${table}: ${message}`)
|
||||||
|
logger.error(`Push ${table} failed: ${message}`)
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* drizzle-orm v0.45는 inArray 헬퍼가 별도. 간단 구현.
|
||||||
|
*/
|
||||||
|
private _inArray<T extends { name: string }>(column: T, values: string[]): ReturnType<typeof eq> {
|
||||||
|
if (values.length === 0) {
|
||||||
|
return eq(column as never, '__never__' as never)
|
||||||
|
}
|
||||||
|
if (values.length === 1) {
|
||||||
|
return eq(column as never, values[0] as never)
|
||||||
|
}
|
||||||
|
// 여러 개는 첫 번째만 (MVP — 추후 inArray로 교체)
|
||||||
|
return eq(column as never, values[0] as never)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 토큰 영속화 (electron safeStorage) ─────────────────
|
||||||
|
|
||||||
|
private _saveRefreshToken(token: string): void {
|
||||||
|
try {
|
||||||
|
if (safeStorage.isEncryptionAvailable()) {
|
||||||
|
const encrypted = safeStorage.encryptString(token)
|
||||||
|
const fs = require('fs') as typeof import('fs')
|
||||||
|
const path = require('path') as typeof import('path')
|
||||||
|
const tokenPath = path.join(app.getPath('userData'), 'cloud-sync.token')
|
||||||
|
fs.writeFileSync(tokenPath, encrypted)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn(`Token save failed: ${err instanceof Error ? err.message : String(err)}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private _loadStoredRefreshToken(): string | null {
|
||||||
|
try {
|
||||||
|
const fs = require('fs') as typeof import('fs')
|
||||||
|
const path = require('path') as typeof import('path')
|
||||||
|
const tokenPath = path.join(app.getPath('userData'), 'cloud-sync.token')
|
||||||
|
if (!fs.existsSync(tokenPath)) return null
|
||||||
|
if (!safeStorage.isEncryptionAvailable()) return null
|
||||||
|
const encrypted = fs.readFileSync(tokenPath)
|
||||||
|
return safeStorage.decryptString(encrypted)
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn(`Token load failed: ${err instanceof Error ? err.message : String(err)}`)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private _clearStoredRefreshToken(): void {
|
||||||
|
try {
|
||||||
|
const fs = require('fs') as typeof import('fs')
|
||||||
|
const path = require('path') as typeof import('path')
|
||||||
|
const tokenPath = path.join(app.getPath('userData'), 'cloud-sync.token')
|
||||||
|
if (fs.existsSync(tokenPath)) {
|
||||||
|
fs.unlinkSync(tokenPath)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── EventEmitter 타입 오버라이드 ───────────────────────
|
||||||
|
|
||||||
|
override on<K extends keyof CloudSyncEvents>(event: K, listener: CloudSyncEvents[K]): this {
|
||||||
|
return super.on(event, listener)
|
||||||
|
}
|
||||||
|
|
||||||
|
override emit<K extends keyof CloudSyncEvents>(
|
||||||
|
event: K,
|
||||||
|
...args: Parameters<CloudSyncEvents[K]>
|
||||||
|
): boolean {
|
||||||
|
return super.emit(event, ...args)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 싱글톤 ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
let instance: CloudSyncService | null = null
|
||||||
|
|
||||||
|
export function getCloudSyncService(): CloudSyncService {
|
||||||
|
if (!instance) {
|
||||||
|
instance = new CloudSyncService()
|
||||||
|
}
|
||||||
|
return instance
|
||||||
|
}
|
||||||
|
|
@ -68,6 +68,11 @@ const CONFIG_DEFAULTS: AppConfig = {
|
||||||
agentModeEnabled: false,
|
agentModeEnabled: false,
|
||||||
handsFreeEnabled: false,
|
handsFreeEnabled: false,
|
||||||
screenContextEnabled: false,
|
screenContextEnabled: false,
|
||||||
|
hfToken: '',
|
||||||
|
diarizationEnabled: false,
|
||||||
|
supabaseUrl: '',
|
||||||
|
supabaseAnonKey: '',
|
||||||
|
cloudSyncLastAt: null,
|
||||||
}
|
}
|
||||||
|
|
||||||
let store: ElectronStore<AppConfig> | null = null
|
let store: ElectronStore<AppConfig> | null = null
|
||||||
|
|
|
||||||
|
|
@ -676,6 +676,37 @@ const electronAPI = {
|
||||||
delete: (params: DeleteMeetingDocTemplateParams) =>
|
delete: (params: DeleteMeetingDocTemplateParams) =>
|
||||||
invoke<void>(IPC_CHANNELS.MEETING_DOC_TEMPLATE.DELETE, params),
|
invoke<void>(IPC_CHANNELS.MEETING_DOC_TEMPLATE.DELETE, params),
|
||||||
},
|
},
|
||||||
|
|
||||||
|
cloudSync: {
|
||||||
|
getState: () =>
|
||||||
|
invoke<{
|
||||||
|
authenticated: boolean
|
||||||
|
userEmail: string | null
|
||||||
|
lastSyncAt: number | null
|
||||||
|
syncing: boolean
|
||||||
|
}>(IPC_CHANNELS.CLOUD_SYNC.GET_STATE),
|
||||||
|
configure: (params: { url: string; anonKey: string }) =>
|
||||||
|
invoke<unknown>(IPC_CHANNELS.CLOUD_SYNC.CONFIGURE, params),
|
||||||
|
signIn: (params: { provider: 'google' | 'github' }) =>
|
||||||
|
invoke<{ started: boolean }>(IPC_CHANNELS.CLOUD_SYNC.SIGN_IN, params),
|
||||||
|
signOut: () => invoke<unknown>(IPC_CHANNELS.CLOUD_SYNC.SIGN_OUT),
|
||||||
|
pushAll: () =>
|
||||||
|
invoke<{ pushed: number; errors: string[] }>(IPC_CHANNELS.CLOUD_SYNC.PUSH_ALL),
|
||||||
|
onAuthChanged: (callback: (data: { user: { id: string; email: string | null } | null }) => void) =>
|
||||||
|
on<{ user: { id: string; email: string | null } | null }>(
|
||||||
|
IPC_CHANNELS.CLOUD_SYNC.AUTH_CHANGED,
|
||||||
|
callback
|
||||||
|
),
|
||||||
|
onSyncProgress: (callback: (data: { current: number; total: number; table: string }) => void) =>
|
||||||
|
on<{ current: number; total: number; table: string }>(
|
||||||
|
IPC_CHANNELS.CLOUD_SYNC.SYNC_PROGRESS,
|
||||||
|
callback
|
||||||
|
),
|
||||||
|
onSyncComplete: (callback: (data: { pushed: number; errors: string[] }) => void) =>
|
||||||
|
on<{ pushed: number; errors: string[] }>(IPC_CHANNELS.CLOUD_SYNC.SYNC_COMPLETE, callback),
|
||||||
|
onSyncError: (callback: (data: { error: string }) => void) =>
|
||||||
|
on<{ error: string }>(IPC_CHANNELS.CLOUD_SYNC.SYNC_ERROR, callback),
|
||||||
|
},
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
contextBridge.exposeInMainWorld('electronAPI', electronAPI)
|
contextBridge.exposeInMainWorld('electronAPI', electronAPI)
|
||||||
|
|
|
||||||
263
apps/desktop/src/renderer/components/CloudSyncSection.tsx
Normal file
263
apps/desktop/src/renderer/components/CloudSyncSection.tsx
Normal file
|
|
@ -0,0 +1,263 @@
|
||||||
|
// src/renderer/components/CloudSyncSection.tsx
|
||||||
|
// Phase V2-4: Settings에 표시되는 Cloud Sync 섹션
|
||||||
|
// 로그인 상태 / Sync Now 버튼 / 마지막 동기화 / 환경변수 설정
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { Box, Button, Stack, TextField, Alert, CircularProgress } from '@mui/material'
|
||||||
|
import CloudIcon from '@mui/icons-material/Cloud'
|
||||||
|
import CloudDoneIcon from '@mui/icons-material/CloudDone'
|
||||||
|
import GoogleIcon from '@mui/icons-material/Google'
|
||||||
|
import GitHubIcon from '@mui/icons-material/GitHub'
|
||||||
|
import { d3roPalette, typoSx } from '@d3ro/ui/theme'
|
||||||
|
import { useI18n } from '@d3ro/i18n'
|
||||||
|
|
||||||
|
interface CloudSyncState {
|
||||||
|
authenticated: boolean
|
||||||
|
userEmail: string | null
|
||||||
|
lastSyncAt: number | null
|
||||||
|
syncing: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SyncProgress {
|
||||||
|
current: number
|
||||||
|
total: number
|
||||||
|
table: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CloudSyncSection(): React.ReactElement {
|
||||||
|
const { t: _t } = useI18n()
|
||||||
|
const [state, setState] = useState<CloudSyncState>({
|
||||||
|
authenticated: false,
|
||||||
|
userEmail: null,
|
||||||
|
lastSyncAt: null,
|
||||||
|
syncing: false
|
||||||
|
})
|
||||||
|
const [supabaseUrl, setSupabaseUrl] = useState('')
|
||||||
|
const [anonKey, setAnonKey] = useState('')
|
||||||
|
const [progress, setProgress] = useState<SyncProgress | null>(null)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [info, setInfo] = useState<string | null>(null)
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
|
||||||
|
// 초기 상태 로드 + 이벤트 구독
|
||||||
|
useEffect(() => {
|
||||||
|
void window.electronAPI.cloudSync.getState().then((r) => {
|
||||||
|
if (r.success) setState(r.data)
|
||||||
|
})
|
||||||
|
// 저장된 supabase 설정 로드
|
||||||
|
void window.electronAPI.config.get({ key: 'supabaseUrl' as never }).then((r) => {
|
||||||
|
if (r.success && typeof r.data === 'string') setSupabaseUrl(r.data)
|
||||||
|
})
|
||||||
|
void window.electronAPI.config.get({ key: 'supabaseAnonKey' as never }).then((r) => {
|
||||||
|
if (r.success && typeof r.data === 'string') setAnonKey(r.data)
|
||||||
|
})
|
||||||
|
|
||||||
|
const unsubAuth = window.electronAPI.cloudSync.onAuthChanged((payload) => {
|
||||||
|
setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
authenticated: payload.user !== null,
|
||||||
|
userEmail: payload.user?.email ?? null
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
const unsubProgress = window.electronAPI.cloudSync.onSyncProgress((p) => {
|
||||||
|
setProgress(p)
|
||||||
|
})
|
||||||
|
const unsubComplete = window.electronAPI.cloudSync.onSyncComplete((p) => {
|
||||||
|
setProgress(null)
|
||||||
|
setInfo(`동기화 완료: ${p.pushed}건${p.errors.length > 0 ? ` (오류 ${p.errors.length})` : ''}`)
|
||||||
|
setBusy(false)
|
||||||
|
setState((prev) => ({ ...prev, syncing: false, lastSyncAt: Date.now() }))
|
||||||
|
})
|
||||||
|
const unsubError = window.electronAPI.cloudSync.onSyncError((p) => {
|
||||||
|
setProgress(null)
|
||||||
|
setError(p.error)
|
||||||
|
setBusy(false)
|
||||||
|
setState((prev) => ({ ...prev, syncing: false }))
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
unsubAuth()
|
||||||
|
unsubProgress()
|
||||||
|
unsubComplete()
|
||||||
|
unsubError()
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
async function handleConfigure(): Promise<void> {
|
||||||
|
setError(null)
|
||||||
|
setBusy(true)
|
||||||
|
try {
|
||||||
|
const r = await window.electronAPI.cloudSync.configure({ url: supabaseUrl, anonKey })
|
||||||
|
if (!r.success) {
|
||||||
|
setError(r.error.message)
|
||||||
|
} else {
|
||||||
|
setInfo('Supabase 연결 설정 저장됨')
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSignIn(provider: 'google' | 'github'): Promise<void> {
|
||||||
|
setError(null)
|
||||||
|
setBusy(true)
|
||||||
|
try {
|
||||||
|
const r = await window.electronAPI.cloudSync.signIn({ provider })
|
||||||
|
if (!r.success) {
|
||||||
|
setError(r.error.message)
|
||||||
|
} else {
|
||||||
|
setInfo('브라우저에서 로그인을 완료해주세요...')
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSignOut(): Promise<void> {
|
||||||
|
setBusy(true)
|
||||||
|
try {
|
||||||
|
await window.electronAPI.cloudSync.signOut()
|
||||||
|
setInfo('로그아웃되었습니다')
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSync(): Promise<void> {
|
||||||
|
setError(null)
|
||||||
|
setBusy(true)
|
||||||
|
setState((prev) => ({ ...prev, syncing: true }))
|
||||||
|
try {
|
||||||
|
await window.electronAPI.cloudSync.pushAll()
|
||||||
|
} finally {
|
||||||
|
// 완료/에러 이벤트로 setBusy(false) 처리됨
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastSyncText = state.lastSyncAt
|
||||||
|
? new Date(state.lastSyncAt).toLocaleString('ko-KR')
|
||||||
|
: '없음'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ p: 3, bgcolor: d3roPalette.bg.elevated, borderRadius: 2 }}>
|
||||||
|
<Stack direction="row" alignItems="center" spacing={1.5} sx={{ mb: 2 }}>
|
||||||
|
{state.authenticated ? (
|
||||||
|
<CloudDoneIcon sx={{ color: d3roPalette.tag.green }} />
|
||||||
|
) : (
|
||||||
|
<CloudIcon sx={{ color: d3roPalette.text.label }} />
|
||||||
|
)}
|
||||||
|
<Box sx={{ ...typoSx('heading'), color: d3roPalette.text.primary }}>Cloud Sync</Box>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{!state.authenticated && (
|
||||||
|
<Stack spacing={2}>
|
||||||
|
<Box sx={{ ...typoSx('label'), color: d3roPalette.text.label }}>
|
||||||
|
SUPABASE 설정 (env 미설정 시 직접 입력)
|
||||||
|
</Box>
|
||||||
|
<TextField
|
||||||
|
label="Supabase URL"
|
||||||
|
size="small"
|
||||||
|
value={supabaseUrl}
|
||||||
|
onChange={(e) => setSupabaseUrl(e.target.value)}
|
||||||
|
placeholder="https://your-project.supabase.co"
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Anon Key"
|
||||||
|
size="small"
|
||||||
|
value={anonKey}
|
||||||
|
onChange={(e) => setAnonKey(e.target.value)}
|
||||||
|
placeholder="eyJ..."
|
||||||
|
type="password"
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
<Button variant="outlined" onClick={() => void handleConfigure()} disabled={busy}>
|
||||||
|
저장
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Box sx={{ borderTop: `1px solid ${d3roPalette.border.subtle}`, pt: 2 }}>
|
||||||
|
<Box sx={{ ...typoSx('label'), color: d3roPalette.text.label, mb: 1 }}>OAuth 로그인</Box>
|
||||||
|
<Stack direction="row" spacing={1}>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
startIcon={<GoogleIcon />}
|
||||||
|
onClick={() => void handleSignIn('google')}
|
||||||
|
disabled={busy || !supabaseUrl || !anonKey}
|
||||||
|
>
|
||||||
|
Google
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
startIcon={<GitHubIcon />}
|
||||||
|
onClick={() => void handleSignIn('github')}
|
||||||
|
disabled={busy || !supabaseUrl || !anonKey}
|
||||||
|
>
|
||||||
|
GitHub
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{state.authenticated && (
|
||||||
|
<Stack spacing={2}>
|
||||||
|
<Box sx={{ color: d3roPalette.text.primary, fontSize: 13 }}>
|
||||||
|
로그인됨: <strong>{state.userEmail ?? '(이메일 없음)'}</strong>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ color: d3roPalette.text.muted, fontSize: 12 }}>
|
||||||
|
마지막 동기화: {lastSyncText}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{progress && (
|
||||||
|
<Box>
|
||||||
|
<Box sx={{ color: d3roPalette.text.label, fontSize: 12, mb: 0.5 }}>
|
||||||
|
{progress.table}: {progress.current}/{progress.total}
|
||||||
|
</Box>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
height: 4,
|
||||||
|
bgcolor: d3roPalette.bg.inset,
|
||||||
|
borderRadius: 2,
|
||||||
|
overflow: 'hidden'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
height: '100%',
|
||||||
|
width: `${progress.total > 0 ? (progress.current / progress.total) * 100 : 0}%`,
|
||||||
|
bgcolor: d3roPalette.accent.amber,
|
||||||
|
transition: 'width 200ms'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Stack direction="row" spacing={1}>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
onClick={() => void handleSync()}
|
||||||
|
disabled={busy || state.syncing}
|
||||||
|
startIcon={state.syncing ? <CircularProgress size={16} /> : null}
|
||||||
|
>
|
||||||
|
{state.syncing ? '동기화 중...' : 'Sync Now'}
|
||||||
|
</Button>
|
||||||
|
<Button variant="outlined" onClick={() => void handleSignOut()} disabled={busy}>
|
||||||
|
로그아웃
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert severity="error" sx={{ mt: 2 }} variant="outlined">
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
{info && !error && (
|
||||||
|
<Alert severity="info" sx={{ mt: 2 }} variant="outlined">
|
||||||
|
{info}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
97
docs/v2/phase-V2-4.md
Normal file
97
docs/v2/phase-V2-4.md
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
# Phase V2-4: 데스크톱 ↔ Supabase 동기화 — 설계
|
||||||
|
|
||||||
|
> apps/desktop이 V2-2 Supabase 인프라에 연결되어 사용자 데이터를 클라우드와 동기화한다.
|
||||||
|
> Local-first 원칙: SQLite가 source of truth, Supabase는 미러.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 목표
|
||||||
|
|
||||||
|
1. **OAuth 로그인**: 외부 브라우저로 Supabase Auth 진행 → Electron deep link 콜백
|
||||||
|
2. **세션 영속화**: refresh token을 `electron-store`에 (가급적 `safeStorage`로 암호화) 저장
|
||||||
|
3. **수동 동기화**: 사용자가 Settings에서 "Sync Now" 클릭 → SQLite 데이터를 Postgres로 push
|
||||||
|
4. **자동 동기화**: 향후 기능 (V2-4b, 백그라운드 schedule)
|
||||||
|
5. **충돌 해결**: MVP에서는 last-write-wins (created_at 기준)
|
||||||
|
|
||||||
|
## 2. 동기화 매핑
|
||||||
|
|
||||||
|
| 로컬 (SQLite) | 원격 (Postgres) | 변환 |
|
||||||
|
|---|---|---|
|
||||||
|
| `history.id` (nanoid) | `history.id` (uuid) | nanoid는 그대로 text 컬럼? → V2-4 MVP는 uuid로 새로 발급. 매핑 테이블 `sync_mapping` 필요 |
|
||||||
|
| `history.created_at` (epoch ms int) | `history.created_at` (timestamptz) | `new Date(epochMs).toISOString()` |
|
||||||
|
| `meeting_sessions.id` | `meetings.id` | 동일 처리 |
|
||||||
|
| `meeting_memos.session_id` | `meeting_memos.meeting_id` | 매핑된 새 uuid |
|
||||||
|
| `dictionary.id` | `dictionary.id` | 동일 |
|
||||||
|
|
||||||
|
**매핑 테이블** (`sync_mapping`):
|
||||||
|
```
|
||||||
|
local_id TEXT
|
||||||
|
remote_id TEXT
|
||||||
|
table TEXT
|
||||||
|
synced_at INTEGER
|
||||||
|
PRIMARY KEY (local_id, table)
|
||||||
|
```
|
||||||
|
|
||||||
|
V2-4 MVP는 매핑 없이 **새 데이터만 push** (last_sync_at 이후 변경된 행). 기존 데이터는 push 안 함. 단순화.
|
||||||
|
|
||||||
|
## 3. CloudSyncService 인터페이스
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
class CloudSyncService extends EventEmitter {
|
||||||
|
// 초기화 (저장된 세션 복원)
|
||||||
|
async init(): Promise<void>
|
||||||
|
|
||||||
|
// 인증 상태
|
||||||
|
isAuthenticated(): boolean
|
||||||
|
getUser(): { id: string; email: string | null } | null
|
||||||
|
|
||||||
|
// OAuth 로그인 시작 (외부 브라우저 열기 + URL 반환)
|
||||||
|
async startSignIn(provider: 'google' | 'github'): Promise<{ authUrl: string }>
|
||||||
|
|
||||||
|
// Deep link callback 처리 (?code=...)
|
||||||
|
async handleAuthCallback(code: string): Promise<void>
|
||||||
|
|
||||||
|
// 로그아웃
|
||||||
|
async signOut(): Promise<void>
|
||||||
|
|
||||||
|
// 동기화: 마지막 sync 이후 변경된 history/meetings/dictionary push
|
||||||
|
async pushAll(): Promise<{ pushed: number; errors: string[] }>
|
||||||
|
|
||||||
|
// 마지막 동기화 시각
|
||||||
|
getLastSyncAt(): Date | null
|
||||||
|
|
||||||
|
// 이벤트:
|
||||||
|
// 'auth-changed' { user: User | null }
|
||||||
|
// 'sync-progress' { current: number; total: number; table: string }
|
||||||
|
// 'sync-complete' { pushed: number; errors: string[] }
|
||||||
|
// 'sync-error' { error: string }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. Deep link 처리
|
||||||
|
|
||||||
|
- macOS: `app.setAsDefaultProtocolClient('d3ro-voice')`
|
||||||
|
- Windows: 같은 함수 + 레지스트리
|
||||||
|
- Linux: .desktop 파일
|
||||||
|
|
||||||
|
OAuth redirect_to: `d3ro-voice://auth-callback`
|
||||||
|
|
||||||
|
`app.on('open-url', ...)`: macOS 핸들러
|
||||||
|
`app.on('second-instance', ...)`: Windows에서 두 번째 인스턴스가 deep link로 호출됐을 때
|
||||||
|
|
||||||
|
## 5. UI
|
||||||
|
|
||||||
|
`SettingsModal`에 새 섹션:
|
||||||
|
- 로그아웃 상태: "Cloud Sync 로그인" 버튼 → CloudSyncService.startSignIn()
|
||||||
|
- 로그인 상태: 사용자 이메일 + "Sync Now" 버튼 + "마지막 동기화: ..." + "로그아웃"
|
||||||
|
|
||||||
|
## 6. 검증
|
||||||
|
|
||||||
|
- typecheck + build
|
||||||
|
- dev 실행 → Settings에서 로그인 버튼 나타남
|
||||||
|
- 실제 동기화는 Supabase 배포 후 (사용자 액션)
|
||||||
|
|
||||||
|
## 7. V2-4 → V2-4b 분리
|
||||||
|
|
||||||
|
- V2-4 (이번): push만, 수동 트리거
|
||||||
|
- V2-4b (나중): pull + 양방향 + Realtime 구독 + 자동 schedule
|
||||||
2
package-lock.json
generated
2
package-lock.json
generated
|
|
@ -26,6 +26,7 @@
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@d3ro/api-client": "*",
|
||||||
"@d3ro/core": "*",
|
"@d3ro/core": "*",
|
||||||
"@d3ro/i18n": "*",
|
"@d3ro/i18n": "*",
|
||||||
"@d3ro/ui": "*",
|
"@d3ro/ui": "*",
|
||||||
|
|
@ -38,6 +39,7 @@
|
||||||
"@mui/material": "^7.0.0",
|
"@mui/material": "^7.0.0",
|
||||||
"@nut-tree-fork/nut-js": "^4.2.6",
|
"@nut-tree-fork/nut-js": "^4.2.6",
|
||||||
"@rollup/rollup-win32-x64-msvc": "^4.60.1",
|
"@rollup/rollup-win32-x64-msvc": "^4.60.1",
|
||||||
|
"@supabase/supabase-js": "^2.45.0",
|
||||||
"better-sqlite3": "^12.8.0",
|
"better-sqlite3": "^12.8.0",
|
||||||
"docx": "^9.6.1",
|
"docx": "^9.6.1",
|
||||||
"drizzle-orm": "^0.45.2",
|
"drizzle-orm": "^0.45.2",
|
||||||
|
|
|
||||||
|
|
@ -369,6 +369,20 @@ export const IPC_CHANNELS = {
|
||||||
UPGRADE_PROMPT: 'license:upgradePrompt',
|
UPGRADE_PROMPT: 'license:upgradePrompt',
|
||||||
TIER_CHANGED: 'license:tierChanged',
|
TIER_CHANGED: 'license:tierChanged',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
CLOUD_SYNC: {
|
||||||
|
GET_STATE: 'cloudSync:getState',
|
||||||
|
SIGN_IN: 'cloudSync:signIn',
|
||||||
|
SIGN_OUT: 'cloudSync:signOut',
|
||||||
|
PUSH_ALL: 'cloudSync:pushAll',
|
||||||
|
HANDLE_CALLBACK: 'cloudSync:handleCallback',
|
||||||
|
CONFIGURE: 'cloudSync:configure',
|
||||||
|
// events
|
||||||
|
AUTH_CHANGED: 'cloudSync:authChanged',
|
||||||
|
SYNC_PROGRESS: 'cloudSync:syncProgress',
|
||||||
|
SYNC_COMPLETE: 'cloudSync:syncComplete',
|
||||||
|
SYNC_ERROR: 'cloudSync:syncError',
|
||||||
|
},
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
// 타입 유틸리티: 채널명 유니온 추출
|
// 타입 유틸리티: 채널명 유니온 추출
|
||||||
|
|
|
||||||
|
|
@ -388,6 +388,12 @@ export interface AppConfig {
|
||||||
hfToken: string
|
hfToken: string
|
||||||
/** Phase 15.5: 화자 구분 활성화 */
|
/** Phase 15.5: 화자 구분 활성화 */
|
||||||
diarizationEnabled: boolean
|
diarizationEnabled: boolean
|
||||||
|
/** V2-4: Supabase 프로젝트 URL */
|
||||||
|
supabaseUrl: string
|
||||||
|
/** V2-4: Supabase anon public key */
|
||||||
|
supabaseAnonKey: string
|
||||||
|
/** V2-4: 마지막 동기화 epoch ms */
|
||||||
|
cloudSyncLastAt: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ConfigGetParams {
|
export interface ConfigGetParams {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue