d3ro-voice/apps/desktop/src/main/services/CloudSyncService.ts
Yun Chan 0a4f5aee64 feat(desktop): two-way cloud sync with mobile and web
Rewrites the desktop mirror as services/sync/SyncEngine: a persistent
outbox, per-account server-clock keyset cursors with paging, pulls that never
overwrite unsent local edits, deletions both ways through sync_tombstones and
per-row failure isolation. It now covers history titles and favorites,
dictionary, every meeting's memos and documents, memo tags, user commands and
dictation/meeting templates, and registers the desktop as a device that the
phone can disconnect.

Fixes shipped defects: the first pull after sign-in fetched nothing, only
the first meeting's children were pushed, team meetings leaked into the
personal database and lost team_id on re-push, and Realtime never connected
because Electron's Node 20 has no global WebSocket (ws is now the transport).
Anonymous local-mode records are imported into the first account that signs
in. The settings sync section is translated and shows pending/rejected
changes; synced screens reload on app:dataChanged.
2026-09-27 14:04:49 +09:00

997 lines
36 KiB
TypeScript

// src/main/services/CloudSyncService.ts
// Phase V2-4: Supabase 동기화 — OAuth 로그인, 세션 영속화, 기기 간 양방향 동기화
// Local-first: 데스크톱은 SQLite에서 바로 동작하고, Supabase(모바일·웹의 정본)와 양방향으로 맞춘다.
// 실제 동기화는 services/sync/SyncEngine 이 한다 — 이 서비스는 인증·수명주기·트리거를 맡는다.
import { EventEmitter } from 'events'
import { shell, app, safeStorage } from 'electron'
import {
createClient,
type SupabaseClient,
type Session,
type User,
type RealtimeChannel
} from '@supabase/supabase-js'
import { getLogger } from './LoggerService'
import { configGet, configSet } from './ConfigService'
import { SUPABASE_URL, SUPABASE_ANON_KEY } from '@d3ro/core/supabase-config'
import { openForUser, openLocal, closeCurrent, getCurrentUserId, importLocalModeData } from '../db'
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
import type { LicenseTier } from '@d3ro/core/types'
import { SyncEngine, type SyncStatus } from './sync/SyncEngine'
import { SupabaseSyncRemote } from './sync/supabase-sync-remote'
import { enqueueChange, getSyncState, setSyncState } from './sync/sync-outbox'
import { checkInDesktopDevice, currentDeviceInfo, unregisterDesktopDevice } from './sync/device-registration'
import type { SyncEntity, SyncRunResult } from './sync/sync-types'
import { nodeRealtimeTransport } from './sync/realtime-transport'
const logger = getLogger('CloudSyncService')
/** 로컬 변경을 모아서 보내는 지연 */
const FLUSH_DEBOUNCE_MS = 1_500
/** Realtime 이벤트가 몰릴 때 한 번만 가져오는 지연 */
const PULL_DEBOUNCE_MS = 1_500
/** 기기 생존 신호 + Realtime 누락 대비 전체 동기화 주기 */
const HEARTBEAT_MS = 5 * 60_000
/** sync_state 에 저장하는 이 사용자 DB의 등록 기기 id */
const DEVICE_STATE_KEY = 'device:id'
/** Realtime 변경 감시 대상. sync_tombstones = 다른 기기의 삭제 */
const REALTIME_TABLES = [
'history',
'dictionary',
'meetings',
'meeting_memos',
'meeting_documents',
'memo_tags',
'custom_instructions',
'user_templates',
'sync_tombstones',
] as const
// ============================================================
// 타입
// ============================================================
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
/** 아직 서버에 올라가지 않은 로컬 변경 수 */
pendingChanges: number
/** 서버가 거부해 자동 재시도를 멈춘 변경 수 (수동 업로드로 재시도) */
failedChanges: number
}
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
/** 다른 기기의 변경이 로컬에 반영됨 — 화면 새로고침 신호 */
'data-changed': (payload: { entities: SyncEntity[] }) => void
}
// ============================================================
// CloudSyncService
// ============================================================
class CloudSyncService extends EventEmitter {
private _client: SupabaseClient | null = null
private _session: Session | null = null
private _lastSyncAt: number | null = null
private _syncing = false
/** Realtime 재구독 지수 백오프 상태 (U1) */
private _realtimeRetryCount = 0
private _realtimeRetryTimer: NodeJS.Timeout | null = null
private _initialized = false
private _realtimeChannel: RealtimeChannel | null = null
private _engine: SyncEngine | null = null
private _flushTimer: NodeJS.Timeout | null = null
private _pullTimer: NodeJS.Timeout | null = null
private _heartbeatTimer: NodeJS.Timeout | null = null
/**
* 초기화 — Supabase 클라이언트 생성, 저장된 세션 복원.
* Supabase URL/KEY가 설정되지 않았으면 disabled 상태로 남는다.
*/
async init(): Promise<void> {
if (this._initialized) return
this._initialized = true
this._client = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
auth: {
persistSession: false, // 직접 관리
autoRefreshToken: true,
detectSessionInUrl: false
},
// Electron 33 메인 프로세스(Node 20)에는 전역 WebSocket이 없다. 주입하지 않으면 Realtime이
// 연결조차 못 하고 TIMED_OUT/CLOSED만 반복한다(2026-09-16 설치본 로그) — 다른 기기의 변경이
// 실시간으로 오지 않던 원인.
realtime: { transport: nodeRealtimeTransport }
})
this._lastSyncAt = (configGet('cloudSyncLastAt') as number | undefined) ?? null
// 저장된 refresh token 복원
const stored = this._loadStoredRefreshToken()
if (stored) {
try {
const { data, error } = await this._client.auth.refreshSession({ refresh_token: stored })
if (error) {
// U3: 장시간 idle 후 refresh token 소진 시 사용자에게 재인증 필요를
// 알리기 위해 error 로깅 승격 + sync-error + auth-changed(user:null) emit.
// 기존에는 조용히 토큰만 삭제해서 사용자가 "왜 로그아웃됐지?" 상태로 방치됐음.
logger.error(
`세션 복원 실패 — 재로그인 필요: ${error.message} (토큰 만료 또는 서버 세션 무효화)`
)
this._clearStoredRefreshToken()
this.emit('sync-error', {
error: `Sign-in session expired. Please sign in again. (${error.message})`,
})
this.emit('auth-changed', { user: null })
} else if (data.session) {
this._saveRefreshToken(data.session.refresh_token)
logger.info(
`Restored session for user: ${data.session.user.email ?? data.session.user.id}`
)
// 단일 진입점으로 수렴 — DB open + auth-changed emit + Realtime 시작
await this._onAuthenticated(data.session, { reason: 'restore' })
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
logger.error(`세션 복원 예외 — 재로그인 필요: ${message}`)
this._clearStoredRefreshToken()
this.emit('sync-error', {
error: `Sign-in session restore failed. Please sign in again. (${message})`,
})
this.emit('auth-changed', { user: null })
}
} else {
// 저장된 토큰 없음 → 미인증 상태를 렌더러에 명시 전달
this.emit('auth-changed', { user: null })
}
logger.info('CloudSyncService initialized')
}
// ── 인증 lifecycle 단일 진입점 ─────────────────────────
/**
* 세션이 인증된 직후 호출되는 단일 진입점.
* - 사용자별 DB 오픈
* - auth-changed emit (renderer 게이트 해제)
* - Realtime 구독 시작
* init()의 세션 복원과 handleAuthCallback()의 신규 로그인 모두 여기로 수렴한다.
*/
private async _onAuthenticated(
session: Session,
opts: { reason: 'restore' | 'signin' }
): Promise<void> {
this._session = session
const userId = session.user.id
// 1) 사용자별 DB 열기 (Phase 1 — 사용자별 SQLite 격리)
try {
const { created, dbPath } = openForUser(userId)
logger.info(
`[auth:${opts.reason}] DB opened for ${userId}: ${dbPath} (created=${created})`
)
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
logger.error(`[auth:${opts.reason}] DB open failed for ${userId}: ${message}`)
// DB를 열지 못하면 로그인 상태를 유지해서는 안 된다 — 즉시 signOut
this._session = null
this._clearStoredRefreshToken()
this.emit('sync-error', { error: `Database open failed: ${message}` })
this.emit('auth-changed', { user: null })
return
}
// 2) Supabase `subscriptions` 테이블에서 현재 티어 가져와 LicenseService에 동기화
// (빅뱅 Phase 4 — 티어 enforcement)
try {
const tier = await this._fetchSubscriptionTier(userId)
const { getLicenseService } = await import('./LicenseService')
getLicenseService().syncFromCloud(tier)
} catch (err) {
logger.warn(
`[auth:${opts.reason}] Subscription tier sync failed (falling back to free): ${
err instanceof Error ? err.message : String(err)
}`
)
}
// 3) 로그인 전 익명 로컬 모드에서 쌓인 기록을 이 계정 DB로 가져온다(최초 1회).
// 가져온 행은 아래 최초 대조가 서버로 올려 모바일에서도 보인다.
try {
importLocalModeData()
} catch (err) {
logger.warn(
`[auth:${opts.reason}] Local-mode import failed: ${err instanceof Error ? err.message : String(err)}`
)
}
// 4) 동기화 엔진 — 사용자 DB가 열린 뒤, 이 사용자 전용으로 만든다.
this._engine?.dispose()
this._engine = null
if (this._client) {
const engine = new SyncEngine({ remote: new SupabaseSyncRemote(this._client), userId })
engine.on('progress', (payload) => this.emit('sync-progress', payload))
this._engine = engine
}
// 5) renderer 게이트 해제 — DB가 열리고 티어가 반영된 뒤에 emit
this.emit('auth-changed', { user: session.user })
// 6) Realtime 구독 자동 시작
void this.startRealtime().catch((err) => {
logger.warn(
`Realtime 자동 시작 실패: ${err instanceof Error ? err.message : String(err)}`
)
})
// 7) 이 데스크톱을 계정의 기기 목록에 등록(모바일 "연결된 기기"), 주기 점검 시작
void this._checkInDevice(opts.reason)
this._startHeartbeat()
// 8) 최초 동기화: 대조(1회) → 로컬 변경 push → 원격 변경·삭제 pull.
// fire-and-forget — UI는 이미 해제됨. 실패해도 outbox에 남아 다음 트리거에 재시도된다.
void this._runEngine('full')
.then((result) => {
logger.info(
`[auth:${opts.reason}] Initial sync done: pushed=${result.pushed} pulled=${result.pulled} deleted=${result.deleted} errors=${result.errors.length}`
)
})
.catch((err) => {
logger.warn(
`[auth:${opts.reason}] Initial sync failed: ${err instanceof Error ? err.message : String(err)}`
)
})
}
/**
* 빅뱅 Phase 4: Supabase `subscriptions` 테이블에서 사용자의 현재 티어를 조회.
* - row 없음 → 'free' (auth trigger가 가입 시 자동 생성하므로 거의 발생 안 함)
* - tier 값 비정상 → 'free'
* - 네트워크 실패 → throw (caller가 warn + 현재 티어 유지)
*/
private async _fetchSubscriptionTier(userId: string): Promise<LicenseTier> {
if (!this._client) {
throw new D3ROError(ErrorCode.LLMServerUnreachable, 'Supabase client not initialized')
}
const { data, error } = await this._client
.from('subscriptions')
.select('tier, status')
.eq('user_id', userId)
.eq('status', 'active')
.maybeSingle()
if (error) {
throw new Error(error.message)
}
if (!data) return 'free'
const tier = data.tier as LicenseTier
if (tier === 'pro' || tier === 'pro_plus' || tier === 'team' || tier === 'enterprise' || tier === 'free') {
return tier
}
return 'free'
}
/**
* signOut 전 in-flight 세션/녹음을 안전하게 중단시키는 단일 진입점.
* VoiceMode/MeetingMode/Caption 진행 중이면 먼저 멈추고, 그 다음 Realtime stop,
* 그 다음 auth-changed(null) emit, 마지막으로 DB close.
*/
private async _onSignOut(options: { unregister: boolean } = { unregister: true }): Promise<void> {
// 1) 활성 녹음/세션 강제 종료 (DB close 전에 flush 기회 제공)
try {
const { getVoiceModeService } = await import('./VoiceModeService')
const voiceMode = getVoiceModeService()
if (voiceMode.isActive) {
voiceMode.cancelSession()
logger.info('[signOut] VoiceMode active session cancelled')
}
} catch (err) {
logger.warn(
`[signOut] VoiceMode cancel failed: ${err instanceof Error ? err.message : String(err)}`
)
}
try {
const { getMeetingModeService } = await import('./MeetingModeService')
const meetingMode = getMeetingModeService()
if (meetingMode.isMeetingModeActive()) {
await meetingMode.stopRecording()
logger.info('[signOut] MeetingMode recording stopped')
}
} catch (err) {
logger.warn(
`[signOut] MeetingMode stop failed: ${err instanceof Error ? err.message : String(err)}`
)
}
try {
const { getCaptionService } = await import('./CaptionService')
const captionService = getCaptionService()
await captionService.stop()
} catch (err) {
logger.warn(
`[signOut] Caption stop failed: ${err instanceof Error ? err.message : String(err)}`
)
}
// 2) Realtime 구독·주기 작업 종료, 엔진 해제
await this.stopRealtime()
this._stopHeartbeat()
this._engine?.dispose()
this._engine = null
// 3) 사용자가 직접 로그아웃하면 기기 목록에서도 빠진다(원격 해제로 로그아웃될 때는 이미 해제됨).
if (options.unregister && this._client && this._session) {
await unregisterDesktopDevice(this._client).catch(() => undefined)
}
// 4) Supabase 세션 무효화
if (this._client && this._session) {
try {
await this._client.auth.signOut()
} catch (err) {
logger.warn(`signOut warning: ${err instanceof Error ? err.message : String(err)}`)
}
}
// 4) in-memory 상태 + 저장된 토큰 clear
this._session = null
this._clearStoredRefreshToken()
// 5) LicenseService 티어를 free로 리셋 (빅뱅 Phase 4 — 익명 로컬 모드)
// pro/pro_plus 캐시가 남아있으면 premium feature gate가 이상하게 동작
try {
const { getLicenseService } = await import('./LicenseService')
getLicenseService().resetToFree()
} catch (err) {
logger.warn(
`[signOut] License reset failed: ${err instanceof Error ? err.message : String(err)}`
)
}
// 6) 사용자 DB 닫고 로컬 모드 DB로 복귀
// — 로그아웃 후에도 앱은 익명 로컬 모드로 계속 동작 (entry point 철학)
closeCurrent()
try {
const { dbPath } = openLocal()
logger.info(`[signOut] Reverted to local DB: ${dbPath}`)
} catch (err) {
logger.error(
`[signOut] Local DB reopen failed: ${err instanceof Error ? err.message : String(err)}`
)
}
// 7) renderer에 auth-changed(null) emit — Settings는 다시 로그인 화면으로,
// 메인 UI는 로컬 모드로 계속 동작
this.emit('auth-changed', { user: null })
logger.info('Signed out — continuing in local mode')
}
// ── 상태 조회 ──────────────────────────────────────────
isEnabled(): boolean {
return this._client !== null
}
isAuthenticated(): boolean {
return this._session !== null
}
getUser(): User | null {
return this._session?.user ?? null
}
/**
* Phase 3.2: Premium LLM proxy 호출 시 Supabase Edge Function Authorization
* 헤더에 사용할 JWT access token 반환. null이면 비로그인 상태.
*
* 중요: 캐시된 _session이 아닌 Supabase 클라이언트에서 최신 세션을 직접 가져옴.
* _session은 로그인 시점에만 설정되지만, Supabase 클라이언트 내부에서 자동
* refresh된 토큰은 _session에 반영되지 않아 stale JWT가 될 수 있음.
*/
async getAccessToken(): Promise<string | null> {
if (!this._client) return null
const { data } = await this._client.auth.getSession()
return data.session?.access_token ?? null
}
/**
* Phase 3.2: Edge Function 호출을 위한 Supabase 프로젝트 URL.
* 없으면 SaaS 미설정 상태.
*/
getSupabaseUrl(): string | null {
const url = configGet('supabaseUrl') as string | undefined
return url ?? null
}
/**
* Phase 3.2: Edge Function 호출 시 apikey 헤더에 필요한 anon key.
*/
getAnonKey(): string | null {
const key = configGet('supabaseAnonKey') as string | undefined
return key ?? null
}
/**
* Phase 3.2: Supabase Edge Function 호출 — 클라이언트가 auth 헤더를 올바르게 처리.
* raw fetch 대신 이걸 사용해야 gateway 레벨 401 방지.
*/
async invokeFunction(
name: string,
body: Record<string, unknown> | FormData,
options?: { signal?: AbortSignal; timeoutMs?: number }
): Promise<{ data: unknown; error: { message: string } | null }> {
if (!this._client) {
return { data: null, error: { message: 'Supabase client not initialized' } }
}
// 최신 세션 확보 (auto-refresh 보장)
const { data: sessionData } = await this._client.auth.getSession()
const token = sessionData.session?.access_token
if (!token) {
return { data: null, error: { message: 'No active session — 로그인 필요' } }
}
const { data, error } = await this._client.functions.invoke(name, {
body,
headers: { Authorization: `Bearer ${token}` },
signal: options?.signal,
timeout: options?.timeoutMs
})
if (error) {
let detail = error.message ?? String(error)
try {
if ('context' in error && error.context instanceof Response) {
const respBody = await (error.context as Response).json()
detail = JSON.stringify(respBody)
}
} catch {
// body 파싱 실패 시 기본 메시지 사용
}
return { data: null, error: { message: detail } }
}
return { data, error: null }
}
/**
* Edge Function 스트리밍 호출 — SSE 응답의 ReadableStream 반환.
* Supabase JS의 functions.invoke()는 JSON 자동 파싱하므로 SSE에 부적합.
* raw fetch + auth 토큰으로 직접 호출.
*/
async invokeFunctionStream(
name: string,
body: Record<string, unknown>,
signal?: AbortSignal,
): Promise<{ stream: ReadableStream<Uint8Array> | null; error: { message: string } | null }> {
if (!this._client) {
return { stream: null, error: { message: 'Supabase client not initialized' } }
}
const { data: sessionData } = await this._client.auth.getSession()
const token = sessionData.session?.access_token
if (!token) {
return { stream: null, error: { message: 'No active session — 로그인 필요' } }
}
const url = configGet('supabaseUrl') as string | undefined
const anonKey = configGet('supabaseAnonKey') as string | undefined
if (!url || !anonKey) {
return { stream: null, error: { message: 'Supabase URL/key not configured' } }
}
try {
const response = await fetch(`${url}/functions/v1/${name}`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
apikey: anonKey,
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
signal,
})
if (!response.ok) {
const text = await response.text()
return { stream: null, error: { message: `${response.status}: ${text}` } }
}
if (!response.body) {
return { stream: null, error: { message: 'No response body' } }
}
return { stream: response.body, error: null }
} catch (err) {
if ((err as Error).name === 'AbortError') {
return { stream: null, error: { message: 'Request aborted' } }
}
return { stream: null, error: { message: err instanceof Error ? err.message : String(err) } }
}
}
getState(): CloudSyncState {
const status = this.getSyncStatus()
return {
authenticated: this.isAuthenticated(),
userEmail: this._session?.user?.email ?? null,
lastSyncAt: this._lastSyncAt,
syncing: this._syncing,
pendingChanges: status?.pending ?? 0,
failedChanges: status?.parked ?? 0,
}
}
getLastSyncAt(): Date | null {
return this._lastSyncAt ? new Date(this._lastSyncAt) : null
}
// ── Realtime 구독 ──────────────────────────────────────
/**
* 원격 변경사항을 실시간으로 구독한다.
* 동기화 대상 테이블·삭제 기록(sync_tombstones)에 변경이 오면 debounce 후 pull,
* devices 변경이 오면 이 기기의 연결 해제 여부를 확인한다.
* 중복 호출 방지를 위해 기존 채널이 있으면 먼저 해제.
*/
async startRealtime(): Promise<void> {
if (!this._client || !this._session) {
logger.warn('Realtime 시작 불가 — 로그인 필요')
return
}
if (this._realtimeChannel) {
await this._realtimeChannel.unsubscribe()
this._realtimeChannel = null
}
const userId = this._session.user.id
// persistSession: false 에서는 Supabase realtime 클라이언트가 auth state change를
// 자동 추적하지 않는다. access_token을 명시적으로 realtime에 주입해서
// postgres_changes 채널이 RLS를 통과하도록 한다. (TIMED_OUT 버그 픽스)
try {
this._client.realtime.setAuth(this._session.access_token)
} catch (err) {
logger.warn(
`Realtime setAuth 실패 (계속 진행): ${err instanceof Error ? err.message : String(err)}`
)
}
let channel = this._client.channel(`cloud-sync:${userId}`)
for (const table of REALTIME_TABLES) {
channel = channel.on(
'postgres_changes',
{ event: '*', schema: 'public', table, filter: `user_id=eq.${userId}` },
() => this._schedulePull()
)
}
// 이 계정의 기기 목록이 바뀌면(다른 기기에서 연결 해제 포함) 등록 상태를 다시 확인한다.
channel = channel.on(
'postgres_changes',
{ event: '*', schema: 'public', table: 'devices', filter: `user_id=eq.${userId}` },
() => void this._checkInDevice('heartbeat')
)
this._realtimeChannel = channel
.subscribe((status) => {
logger.info(`Realtime 채널 상태: ${status}`)
// U1: TIMED_OUT / CHANNEL_ERROR / CLOSED 시 지수 백오프 자동 재구독.
// Supabase realtime은 네트워크 jitter로 간헐적 TIMED_OUT이 발생할 수 있고,
// 이때 기본 fallback(Phase 3.3 auto push)은 동작하지만 다른 기기 변경은 놓친다.
// 1s → 3s → 10s 3회 시도 후 포기. 성공(SUBSCRIBED) 시 카운터 리셋.
if (status === 'SUBSCRIBED') {
this._realtimeRetryCount = 0
if (this._realtimeRetryTimer) {
clearTimeout(this._realtimeRetryTimer)
this._realtimeRetryTimer = null
}
return
}
if (status === 'TIMED_OUT' || status === 'CHANNEL_ERROR' || status === 'CLOSED') {
this._scheduleRealtimeRetry()
}
})
}
/**
* Realtime 재구독 지수 백오프 스케줄러 (U1).
* - 1차: 1s, 2차: 3s, 3차: 10s
* - 3회 초과 시 포기 (Auto push fallback만으로 동작)
* - 이미 스케줄돼 있으면 no-op (중복 방지)
*/
private _scheduleRealtimeRetry(): void {
if (this._realtimeRetryTimer) return
if (!this._client || !this._session) return
const MAX_RETRIES = 3
const DELAYS_MS = [1000, 3000, 10000]
if (this._realtimeRetryCount >= MAX_RETRIES) {
logger.warn(
`Realtime 재구독 ${MAX_RETRIES}회 실패 — Auto push fallback으로만 동작. 수동 재시도: cloudSync.startRealtime()`
)
return
}
const delay = DELAYS_MS[this._realtimeRetryCount]
this._realtimeRetryCount++
const attempt = this._realtimeRetryCount
logger.info(`Realtime 재구독 예약 (${attempt}/${MAX_RETRIES}): ${delay}ms 후`)
this._realtimeRetryTimer = setTimeout(() => {
this._realtimeRetryTimer = null
if (!this._session) return
void this.startRealtime().catch((err) => {
logger.warn(
`Realtime 재구독 실패 (${attempt}/${MAX_RETRIES}): ${err instanceof Error ? err.message : String(err)}`
)
})
}, delay)
}
async stopRealtime(): Promise<void> {
if (this._realtimeRetryTimer) {
clearTimeout(this._realtimeRetryTimer)
this._realtimeRetryTimer = null
}
this._realtimeRetryCount = 0
if (this._realtimeChannel) {
await this._realtimeChannel.unsubscribe()
this._realtimeChannel = null
logger.info('Realtime 채널 종료')
}
}
// ── 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 콜백 처리 — PKCE flow code를 session으로 교환.
* 성공 시 _onAuthenticated(단일 진입점)로 수렴.
*/
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._saveRefreshToken(data.session.refresh_token)
logger.info(`Signed in: ${data.session.user.email ?? data.session.user.id}`)
await this._onAuthenticated(data.session, { reason: 'signin' })
}
/**
* Deep link 콜백 처리 — Implicit flow로 받은 access/refresh token을
* client.auth.setSession()에 주입하여 세션 복원.
*
* supabase-js가 `persistSession: false`로 떠 있을 때 PKCE code_verifier 저장이
* 없어서 PKCE가 제대로 동작하지 않는다. Supabase가 implicit flow로 떨어져
* fragment(#access_token=...&refresh_token=...)에 토큰을 담아 보내는데, 이때
* 이 메서드를 사용한다.
*/
async handleAuthTokens(params: {
accessToken: string
refreshToken: string
}): Promise<void> {
if (!this._client) {
throw new D3ROError(ErrorCode.LLMServerUnreachable, 'Cloud Sync가 설정되지 않았습니다')
}
const { data, error } = await this._client.auth.setSession({
access_token: params.accessToken,
refresh_token: params.refreshToken,
})
if (error || !data.session) {
throw new D3ROError(
ErrorCode.LLMProcessingFailed,
`세션 설정 실패: ${error?.message ?? 'unknown'}`
)
}
this._saveRefreshToken(data.session.refresh_token)
logger.info(`Signed in (implicit): ${data.session.user.email ?? data.session.user.id}`)
await this._onAuthenticated(data.session, { reason: 'signin' })
}
/**
* 로그아웃 — 단일 진입점 _onSignOut에서 세션/DB/녹음 모두 안전하게 정리.
*/
async signOut(): Promise<void> {
await this._onSignOut()
}
// ── 동기화 ─────────────────────────────────────────────
/**
* 로컬 변경 기록(outbox) + 짧은 지연 뒤 push.
* 로그인 상태가 아니면(익명 로컬 모드) 기록하지 않는다 — 로그인 시 최초 대조가 올린다.
*/
pushOne(entity: SyncEntity, id: string): void {
this._recordChange(entity, id, 'upsert')
}
/** 로컬 삭제를 다른 기기로 전파한다. */
pushDelete(entity: SyncEntity, id: string): void {
this._recordChange(entity, id, 'delete')
}
private _recordChange(entity: SyncEntity, id: string, op: 'upsert' | 'delete'): void {
if (!this._session || !this._engine || getCurrentUserId() !== this._session.user.id) return
try {
enqueueChange(entity, id, op)
} catch (err) {
// 로컬 write는 절대 막지 않는다 — 다음 최초 대조가 없는 행을 다시 찾는다.
logger.warn(`Sync enqueue ${entity}/${id} failed: ${err instanceof Error ? err.message : String(err)}`)
return
}
this._scheduleFlush()
}
private _scheduleFlush(delayMs = FLUSH_DEBOUNCE_MS): void {
if (this._flushTimer) return
this._flushTimer = setTimeout(() => {
this._flushTimer = null
void this._runEngine('flush').catch(() => undefined)
}, delayMs)
}
private _schedulePull(delayMs = PULL_DEBOUNCE_MS): void {
if (this._pullTimer) return
this._pullTimer = setTimeout(() => {
this._pullTimer = null
void this._runEngine('pull').catch(() => undefined)
}, delayMs)
}
/** 수동 "업로드": 보관된 실패 항목도 다시 시도한다. */
async pushAll(): Promise<SyncResult> {
const result = await this._runEngine('flush', { releaseParked: true })
return { pushed: result.pushed, errors: result.errors }
}
/** 수동 "다운로드". */
async pullAll(): Promise<SyncResult> {
const result = await this._runEngine('pull')
return { pushed: result.pulled + result.deleted, errors: result.errors }
}
private async _runEngine(
kind: 'full' | 'flush' | 'pull',
options: { releaseParked?: boolean } = {}
): Promise<SyncRunResult> {
const engine = this._engine
if (!engine || !this._session) {
throw new D3ROError(ErrorCode.LLMServerUnreachable, '로그인이 필요합니다')
}
this._syncing = true
try {
const result =
kind === 'full'
? await engine.runFullSync()
: kind === 'flush'
? await engine.flush(options)
: await engine.pull()
if (engine !== this._engine) return result
if (kind !== 'flush') {
this._lastSyncAt = Date.now()
configSet('cloudSyncLastAt', this._lastSyncAt)
}
if (result.changed.length > 0) this.emit('data-changed', { entities: result.changed })
this.emit('sync-complete', { pushed: result.pushed + result.pulled + result.deleted, errors: result.errors })
if (result.errors.length > 0) {
logger.warn(`Sync ${kind} finished with ${result.errors.length} error(s): ${result.errors.slice(0, 3).join(' | ')}`)
}
return result
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
logger.error(`Sync ${kind} failed: ${message}`)
this.emit('sync-error', { error: message })
throw err
} finally {
this._syncing = false
}
}
// ── 기기 등록 ──────────────────────────────────────────
private async _checkInDevice(reason: 'signin' | 'restore' | 'heartbeat'): Promise<void> {
const client = this._client
const session = this._session
if (!client || !session) return
let known: string | null = null
try {
known = getSyncState(DEVICE_STATE_KEY)
} catch {
known = null
}
try {
const check = await checkInDesktopDevice(
client,
session.user.id,
reason,
currentDeviceInfo(app.getVersion()),
reason === 'signin' ? null : known
)
if (check.status === 'active') {
setSyncState(DEVICE_STATE_KEY, check.deviceId)
} else if (check.status === 'revoked' && this._session === session) {
logger.warn('This desktop was disconnected from another device — signing out')
await this._onSignOut({ unregister: false })
this.emit('sync-error', {
error: 'This computer was disconnected from your account on another device. Sign in again to keep syncing.',
})
}
} catch (err) {
logger.warn(`Device check-in (${reason}) failed: ${err instanceof Error ? err.message : String(err)}`)
}
}
private _startHeartbeat(): void {
this._stopHeartbeat()
this._heartbeatTimer = setInterval(() => {
if (!this._session) return
void this._checkInDevice('heartbeat')
// 재구독을 포기한 채널(3회 실패)을 되살린다 — 네트워크가 돌아온 뒤에도 실시간이 끊겨 있지 않게.
if (this._realtimeChannel?.state !== 'joined') {
this._realtimeRetryCount = 0
void this.startRealtime().catch(() => undefined)
}
// Realtime 이벤트를 놓쳤을 때의 안전망
void this._runEngine('full').catch(() => undefined)
}, HEARTBEAT_MS)
}
private _stopHeartbeat(): void {
if (this._heartbeatTimer) {
clearInterval(this._heartbeatTimer)
this._heartbeatTimer = null
}
if (this._flushTimer) {
clearTimeout(this._flushTimer)
this._flushTimer = null
}
if (this._pullTimer) {
clearTimeout(this._pullTimer)
this._pullTimer = null
}
}
getSyncStatus(): SyncStatus | null {
if (!this._engine) return null
try {
return this._engine.getStatus()
} catch {
return null
}
}
// ── 토큰 영속화 (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 resetCloudSyncServiceForTests(): void {
if (instance) instance.removeAllListeners()
instance = null
}
export function getCloudSyncService(): CloudSyncService {
if (!instance) {
instance = new CloudSyncService()
}
return instance
}