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
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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue