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.
997 lines
36 KiB
TypeScript
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
|
|
}
|