diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 6ac61ac..766e532 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -40,10 +40,12 @@ "vitest": "^2.1.0" }, "dependencies": { + "@d3ro/api-client": "*", "@d3ro/core": "*", "@d3ro/i18n": "*", "@d3ro/ui": "*", "@electron-toolkit/preload": "^3.0.2", + "@supabase/supabase-js": "^2.45.0", "@electron-toolkit/utils": "^4.0.0", "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.0", diff --git a/apps/desktop/src/main/bootstrap.ts b/apps/desktop/src/main/bootstrap.ts index 262de2b..9b491af 100644 --- a/apps/desktop/src/main/bootstrap.ts +++ b/apps/desktop/src/main/bootstrap.ts @@ -59,6 +59,7 @@ export async function bootstrap(): Promise { { name: 'llm-polling', critical: false, fn: initLLMPolling }, { name: 'meeting-summary-wiring', critical: false, fn: initMeetingSummaryWiring }, { name: 'meeting-mode', critical: false, fn: initMeetingMode }, + { name: 'cloud-sync', critical: false, fn: initCloudSync }, ] for (const step of steps) { @@ -268,6 +269,12 @@ async function initLLMPolling(): Promise { llm.startPolling() } +async function initCloudSync(): Promise { + const { getCloudSyncService } = await import('./services/CloudSyncService') + const sync = getCloudSyncService() + await sync.init() +} + async function initMeetingSummaryWiring(): Promise { try { const { getCaptionService } = await import('./services/CaptionService') diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index d635699..d9c839c 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -1,6 +1,7 @@ // src/main/index.ts — 앱 진입점 import { app } from 'electron' +import path from 'path' import { bootstrap } from './bootstrap' import { setupLifecycle } from './lifecycle' import { getMainWindow } from './windows/WindowManager' @@ -14,19 +15,59 @@ process.on('uncaughtException', (err) => { 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 { + 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() if (!gotTheLock) { app.quit() } else { - app.on('second-instance', () => { + app.on('second-instance', (_event, argv) => { // 기존 인스턴스의 메인 윈도우를 활성화 const mainWindow = getMainWindow() if (mainWindow) { if (mainWindow.isMinimized()) mainWindow.restore() 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 () => { diff --git a/apps/desktop/src/main/ipc/cloud-sync-handlers.ts b/apps/desktop/src/main/ipc/cloud-sync-handlers.ts new file mode 100644 index 0000000..c4a02f7 --- /dev/null +++ b/apps/desktop/src/main/ipc/cloud-sync-handlers.ts @@ -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(data: T): IPCResult { + return { success: true, data } +} + +function fail(error: unknown): IPCResult { + 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') +} diff --git a/apps/desktop/src/main/ipc/index.ts b/apps/desktop/src/main/ipc/index.ts index ab73907..2df5241 100644 --- a/apps/desktop/src/main/ipc/index.ts +++ b/apps/desktop/src/main/ipc/index.ts @@ -25,6 +25,7 @@ import { registerRAGHandlers } from './rag-handlers' import { registerVoiceActionHandlers } from './voice-action-handlers' import { registerMeetingModeHandlers } from './meeting-mode-handlers' import { registerMeetingDocTemplateHandlers } from './meeting-doc-template-handlers' +import { registerCloudSyncHandlers } from './cloud-sync-handlers' import { getLogger } from '../services/LoggerService' const logger = getLogger('ipc') @@ -55,5 +56,6 @@ export function registerAllIpcHandlers(): void { registerVoiceActionHandlers() registerMeetingModeHandlers() registerMeetingDocTemplateHandlers() + registerCloudSyncHandlers() logger.info('All IPC handlers registered') } diff --git a/apps/desktop/src/main/services/CloudSyncService.ts b/apps/desktop/src/main/services/CloudSyncService.ts new file mode 100644 index 0000000..c3ad490 --- /dev/null +++ b/apps/desktop/src/main/services/CloudSyncService.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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, + result: SyncResult + ): Promise { + 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(column: T, values: string[]): ReturnType { + 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(event: K, listener: CloudSyncEvents[K]): this { + return super.on(event, listener) + } + + override emit( + event: K, + ...args: Parameters + ): boolean { + return super.emit(event, ...args) + } +} + +// ── 싱글톤 ───────────────────────────────────────────── + +let instance: CloudSyncService | null = null + +export function getCloudSyncService(): CloudSyncService { + if (!instance) { + instance = new CloudSyncService() + } + return instance +} diff --git a/apps/desktop/src/main/services/ConfigService.ts b/apps/desktop/src/main/services/ConfigService.ts index 4f6c750..05cefb1 100644 --- a/apps/desktop/src/main/services/ConfigService.ts +++ b/apps/desktop/src/main/services/ConfigService.ts @@ -68,6 +68,11 @@ const CONFIG_DEFAULTS: AppConfig = { agentModeEnabled: false, handsFreeEnabled: false, screenContextEnabled: false, + hfToken: '', + diarizationEnabled: false, + supabaseUrl: '', + supabaseAnonKey: '', + cloudSyncLastAt: null, } let store: ElectronStore | null = null diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index bb69760..9d64556 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -676,6 +676,37 @@ const electronAPI = { delete: (params: DeleteMeetingDocTemplateParams) => invoke(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(IPC_CHANNELS.CLOUD_SYNC.CONFIGURE, params), + signIn: (params: { provider: 'google' | 'github' }) => + invoke<{ started: boolean }>(IPC_CHANNELS.CLOUD_SYNC.SIGN_IN, params), + signOut: () => invoke(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 contextBridge.exposeInMainWorld('electronAPI', electronAPI) diff --git a/apps/desktop/src/renderer/components/CloudSyncSection.tsx b/apps/desktop/src/renderer/components/CloudSyncSection.tsx new file mode 100644 index 0000000..7ee9b7f --- /dev/null +++ b/apps/desktop/src/renderer/components/CloudSyncSection.tsx @@ -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({ + authenticated: false, + userEmail: null, + lastSyncAt: null, + syncing: false + }) + const [supabaseUrl, setSupabaseUrl] = useState('') + const [anonKey, setAnonKey] = useState('') + const [progress, setProgress] = useState(null) + const [error, setError] = useState(null) + const [info, setInfo] = useState(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 { + 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 { + 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 { + setBusy(true) + try { + await window.electronAPI.cloudSync.signOut() + setInfo('로그아웃되었습니다') + } finally { + setBusy(false) + } + } + + async function handleSync(): Promise { + 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 ( + + + {state.authenticated ? ( + + ) : ( + + )} + Cloud Sync + + + {!state.authenticated && ( + + + SUPABASE 설정 (env 미설정 시 직접 입력) + + setSupabaseUrl(e.target.value)} + placeholder="https://your-project.supabase.co" + fullWidth + /> + setAnonKey(e.target.value)} + placeholder="eyJ..." + type="password" + fullWidth + /> + + + + OAuth 로그인 + + + + + + + )} + + {state.authenticated && ( + + + 로그인됨: {state.userEmail ?? '(이메일 없음)'} + + + 마지막 동기화: {lastSyncText} + + + {progress && ( + + + {progress.table}: {progress.current}/{progress.total} + + + 0 ? (progress.current / progress.total) * 100 : 0}%`, + bgcolor: d3roPalette.accent.amber, + transition: 'width 200ms' + }} + /> + + + )} + + + + + + + )} + + {error && ( + + {error} + + )} + {info && !error && ( + + {info} + + )} + + ) +} diff --git a/docs/v2/phase-V2-4.md b/docs/v2/phase-V2-4.md new file mode 100644 index 0000000..57789fa --- /dev/null +++ b/docs/v2/phase-V2-4.md @@ -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 + + // 인증 상태 + 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 + + // 로그아웃 + async signOut(): Promise + + // 동기화: 마지막 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 diff --git a/package-lock.json b/package-lock.json index dde989b..f236496 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,6 +26,7 @@ "version": "1.0.0", "license": "MIT", "dependencies": { + "@d3ro/api-client": "*", "@d3ro/core": "*", "@d3ro/i18n": "*", "@d3ro/ui": "*", @@ -38,6 +39,7 @@ "@mui/material": "^7.0.0", "@nut-tree-fork/nut-js": "^4.2.6", "@rollup/rollup-win32-x64-msvc": "^4.60.1", + "@supabase/supabase-js": "^2.45.0", "better-sqlite3": "^12.8.0", "docx": "^9.6.1", "drizzle-orm": "^0.45.2", diff --git a/packages/core/src/ipc-channels.ts b/packages/core/src/ipc-channels.ts index f2f7a6e..2b2ef9c 100644 --- a/packages/core/src/ipc-channels.ts +++ b/packages/core/src/ipc-channels.ts @@ -369,6 +369,20 @@ export const IPC_CHANNELS = { UPGRADE_PROMPT: 'license:upgradePrompt', 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 // 타입 유틸리티: 채널명 유니온 추출 diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 0ddd430..32b95f8 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -388,6 +388,12 @@ export interface AppConfig { hfToken: string /** Phase 15.5: 화자 구분 활성화 */ 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 {