// 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 { 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 { 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 { 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 { // 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 { 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 | 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, signal?: AbortSignal, ): Promise<{ stream: ReadableStream | 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 { 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 { 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 { 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 { 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 { 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 { 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 { const result = await this._runEngine('flush', { releaseParked: true }) return { pushed: result.pushed, errors: result.errors } } /** 수동 "다운로드". */ async pullAll(): Promise { 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 { 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 { 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(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 resetCloudSyncServiceForTests(): void { if (instance) instance.removeAllListeners() instance = null } export function getCloudSyncService(): CloudSyncService { if (!instance) { instance = new CloudSyncService() } return instance }