fix(sync): bind sync engine to its user DB, scope instructions/templates per account, harden navigation
This commit is contained in:
parent
1b8fe445f3
commit
9aa7302944
30 changed files with 2614 additions and 386 deletions
|
|
@ -14,6 +14,7 @@ import path from 'path'
|
|||
import * as schema from './schema'
|
||||
import { getLogger } from '../services/LoggerService'
|
||||
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
|
||||
import { emitAccountScopeChanged, LOCAL_SCOPE_ID } from '../services/account-scope'
|
||||
|
||||
const logger = getLogger('database')
|
||||
|
||||
|
|
@ -29,7 +30,7 @@ const logger = getLogger('database')
|
|||
* - `_local`: 무료 익명 사용자 entry point (킬러 피처 — 회원가입 0, 로컬 STT/LLM)
|
||||
* - 실제 uuid: OAuth 로그인 후 클라우드 sync + premium 기능 (유료 SaaS 수익화)
|
||||
*/
|
||||
export const LOCAL_USER_ID = '_local'
|
||||
export const LOCAL_USER_ID = LOCAL_SCOPE_ID
|
||||
|
||||
let currentUserId: string | null = null
|
||||
let sqlite: Database.Database | null = null
|
||||
|
|
@ -486,6 +487,8 @@ export function openForUser(userId: string): OpenForUserResult {
|
|||
}
|
||||
|
||||
logger.info(`Database opened for user: ${userId}`)
|
||||
// 계정 범위 저장소(명령·템플릿)도 이 사용자로 바꾼다 — 동기화 엔진이 만들어지기 전에 동기적으로 끝난다.
|
||||
emitAccountScopeChanged(userId)
|
||||
return { created: !existed, dbPath }
|
||||
}
|
||||
|
||||
|
|
@ -650,6 +653,7 @@ export function bindTestDatabase(
|
|||
): void {
|
||||
db = testDb
|
||||
currentUserId = userId
|
||||
emitAccountScopeChanged(userId)
|
||||
}
|
||||
|
||||
/** Vitest 전용: bindTestDatabase 해제. */
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ app.commandLine.appendSwitch('disable-gpu')
|
|||
|
||||
import { bootstrap } from './bootstrap'
|
||||
import { setupLifecycle } from './lifecycle'
|
||||
import { getMainWindow } from './windows/WindowManager'
|
||||
import { showMainWindow } from './windows/WindowManager'
|
||||
import { getLogger } from './services/LoggerService'
|
||||
|
||||
const deepLinkLogger = getLogger('deep-link')
|
||||
|
|
@ -137,11 +137,9 @@ if (!gotTheLock) {
|
|||
} else {
|
||||
app.on('second-instance', (_event, argv) => {
|
||||
// 기존 인스턴스의 메인 윈도우를 최상위로 활성화
|
||||
const mainWindow = getMainWindow()
|
||||
// 창이 닫혀 없으면 다시 만든다 — 창 없는 프로세스로 남지 않게.
|
||||
const mainWindow = showMainWindow()
|
||||
if (mainWindow) {
|
||||
if (mainWindow.isMinimized()) mainWindow.restore()
|
||||
mainWindow.show()
|
||||
mainWindow.focus()
|
||||
mainWindow.setAlwaysOnTop(true)
|
||||
setTimeout(() => {
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
// src/main/ipc/license-handlers.ts
|
||||
// Phase 11: 라이센스 IPC 핸들러
|
||||
|
||||
import { app, ipcMain, BrowserWindow, shell } from 'electron'
|
||||
import { app, ipcMain, BrowserWindow } from 'electron'
|
||||
import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
|
||||
import { ipcSuccess, ipcError, ErrorCode } from '@d3ro/core/errors'
|
||||
import { getLicenseService } from '../services/LicenseService'
|
||||
|
|
@ -12,6 +12,7 @@ import type {
|
|||
} from '@d3ro/core/types'
|
||||
import { Feature } from '@d3ro/core/types'
|
||||
import { billingUrl, PUBLIC_SITE_ORIGIN } from '@d3ro/core/web-urls'
|
||||
import { openExternalSafe } from '../windows/web-contents-hardening'
|
||||
|
||||
/** 개발 빌드는 로컬 웹앱(`npm run dev --workspace=@d3ro/web`)의 결제 페이지를 연다. */
|
||||
const DEV_WEB_ORIGIN = 'http://localhost:3000'
|
||||
|
|
@ -105,7 +106,10 @@ export function registerLicenseHandlers(): void {
|
|||
ipcMain.handle(IPC_CHANNELS.LICENSE.OPEN_BILLING, async (_event, params: { tier: 'pro' | 'pro_plus' }) => {
|
||||
try {
|
||||
const url = billingUrl({ tier: params.tier })
|
||||
await shell.openExternal(app.isPackaged ? url : url.replace(PUBLIC_SITE_ORIGIN, DEV_WEB_ORIGIN))
|
||||
const target = app.isPackaged ? url : url.replace(PUBLIC_SITE_ORIGIN, DEV_WEB_ORIGIN)
|
||||
// 개발 빌드만 로컬 웹앱(http://localhost:3000) 오리진을 추가로 허용한다.
|
||||
const opened = await openExternalSafe(target, app.isPackaged ? {} : { allowOrigins: [DEV_WEB_ORIGIN] })
|
||||
if (!opened) return ipcError(ErrorCode.UnknownError, 'Failed to open billing: URL not allowed')
|
||||
return ipcSuccess(undefined)
|
||||
} catch (err) {
|
||||
return ipcError(ErrorCode.UnknownError, `Failed to open billing: ${err}`)
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
// src/main/ipc/window-handlers.ts
|
||||
|
||||
import { ipcMain, shell } from 'electron'
|
||||
import { ipcMain } from 'electron'
|
||||
import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
|
||||
import { ipcSuccess } from '@d3ro/core/errors'
|
||||
import { ErrorCode, ipcError, ipcSuccess } from '@d3ro/core/errors'
|
||||
import { getMainWindow, hideResultPopup, hideRecordingTip } from '../windows/WindowManager'
|
||||
import { isTrustedIpcSender, openExternalSafe } from '../windows/web-contents-hardening'
|
||||
|
||||
export function registerWindowHandlers(): void {
|
||||
ipcMain.on(IPC_CHANNELS.WINDOW.MINIMIZE, () => {
|
||||
|
|
@ -38,9 +39,15 @@ export function registerWindowHandlers(): void {
|
|||
hideRecordingTip()
|
||||
})
|
||||
|
||||
// 외부 URL 열기
|
||||
ipcMain.handle(IPC_CHANNELS.SYSTEM.OPEN_EXTERNAL, async (_event, params: { url: string }) => {
|
||||
await shell.openExternal(params.url)
|
||||
// 외부 URL 열기 — 앱 페이지가 보낸 요청만, 허용 scheme(https/mailto)만 연다.
|
||||
ipcMain.handle(IPC_CHANNELS.SYSTEM.OPEN_EXTERNAL, async (event, params: { url: string }) => {
|
||||
if (!isTrustedIpcSender(event)) {
|
||||
return ipcError(ErrorCode.ConfigInvalidValue, 'openExternal: untrusted sender')
|
||||
}
|
||||
const url = typeof params?.url === 'string' ? params.url : ''
|
||||
if (!(await openExternalSafe(url))) {
|
||||
return ipcError(ErrorCode.ConfigInvalidValue, 'openExternal: URL scheme not allowed')
|
||||
}
|
||||
return ipcSuccess(undefined)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,9 @@
|
|||
// 실제 동기화는 services/sync/SyncEngine 이 한다 — 이 서비스는 인증·수명주기·트리거를 맡는다.
|
||||
|
||||
import { EventEmitter } from 'events'
|
||||
import { shell, app, safeStorage } from 'electron'
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { app, safeStorage } from 'electron'
|
||||
import {
|
||||
createClient,
|
||||
type SupabaseClient,
|
||||
|
|
@ -19,10 +21,14 @@ import { openForUser, openLocal, closeCurrent, getCurrentUserId, importLocalMode
|
|||
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
|
||||
import type { LicenseTier } from '@d3ro/core/types'
|
||||
import { SyncEngine, type SyncStatus } from './sync/SyncEngine'
|
||||
import { SyncAbortedError } from './sync/sync-adapters'
|
||||
import { SyncScheduler } from './sync/sync-scheduler'
|
||||
import { EncryptedFileTokenStore, bindAuthEvents, type SessionTokenStore } from './sync/session-token-store'
|
||||
import { openExternalSafe } from '../windows/web-contents-hardening'
|
||||
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 { realtimeTables, type SyncEntity, type SyncRunResult } from './sync/sync-types'
|
||||
import { nodeRealtimeTransport } from './sync/realtime-transport'
|
||||
import { SETTINGS_ROW_ID, SYNCED_CONFIG_KEYS, isApplyingRemoteSettings } from './sync/settings-sync'
|
||||
import { AUDIO_BUCKET, listLocalAudioOwners } from './sync/audio-sync'
|
||||
|
|
@ -38,20 +44,12 @@ 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',
|
||||
'knowledge_documents',
|
||||
'user_settings',
|
||||
'sync_tombstones',
|
||||
] as const
|
||||
/** 로그아웃·계정 전환 때 진행 중인 동기화가 멈추기를 기다리는 최대 시간 */
|
||||
const ENGINE_STOP_TIMEOUT_MS = 5_000
|
||||
/** 로그인 버튼을 누른 뒤 deep link 콜백을 받아들이는 시간 — 요청하지 않은 콜백(login CSRF)을 거른다 */
|
||||
const SIGN_IN_WINDOW_MS = 10 * 60_000
|
||||
/** refresh token 파일 이름(userData 아래) */
|
||||
const TOKEN_FILE = 'cloud-sync.token'
|
||||
|
||||
// ============================================================
|
||||
// 타입
|
||||
|
|
@ -103,9 +101,17 @@ class CloudSyncService extends EventEmitter {
|
|||
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
|
||||
/** 로그인 시작(startSignIn) 후 콜백을 받아들이는 기한. null이면 진행 중인 로그인이 없다 */
|
||||
private _pendingSignInUntil: number | null = null
|
||||
private _tokenStore: SessionTokenStore | null = null
|
||||
private _unbindAuthEvents: (() => void) | null = null
|
||||
private readonly _scheduler = new SyncScheduler({
|
||||
flushDelayMs: FLUSH_DEBOUNCE_MS,
|
||||
pullDelayMs: PULL_DEBOUNCE_MS,
|
||||
heartbeatMs: HEARTBEAT_MS,
|
||||
run: (kind) => void this._runEngine(kind).catch(() => undefined),
|
||||
onHeartbeat: () => this._onHeartbeat(),
|
||||
})
|
||||
|
||||
/**
|
||||
* 초기화 — Supabase 클라이언트 생성, 저장된 세션 복원.
|
||||
|
|
@ -129,6 +135,18 @@ class CloudSyncService extends EventEmitter {
|
|||
|
||||
this._lastSyncAt = (configGet('cloudSyncLastAt') as number | undefined) ?? null
|
||||
|
||||
// supabase-js는 메인 프로세스에서 refresh token을 주기적으로 회전시킨다. 회전된 토큰과 access token을
|
||||
// 매번 저장·갱신해야 재시작 복원과 Realtime 재구독이 이미 쓰인/만료된 토큰을 쓰지 않는다.
|
||||
const client = this._client
|
||||
this._unbindAuthEvents = bindAuthEvents<Session>(
|
||||
(callback) => client.auth.onAuthStateChange((event, session) => callback(event, session)).data.subscription,
|
||||
this._tokens(),
|
||||
() => this._session?.user.id ?? null,
|
||||
(session) => {
|
||||
this._session = session
|
||||
}
|
||||
)
|
||||
|
||||
// 언어·테마·자동 다듬기·활성 명령이 바뀌면 모바일 user_settings 로 올린다(원격 반영 중엔 제외).
|
||||
onConfigChanged((event) => {
|
||||
if (isApplyingRemoteSettings()) return
|
||||
|
|
@ -201,9 +219,17 @@ class CloudSyncService extends EventEmitter {
|
|||
session: Session,
|
||||
opts: { reason: 'restore' | 'signin' }
|
||||
): Promise<void> {
|
||||
this._session = session
|
||||
const userId = session.user.id
|
||||
|
||||
// 0) 이전 계정의 동기화를 먼저 멈춘다 — 새 DB가 열린 뒤 이전 엔진이 그 DB에 쓰지 않게.
|
||||
const previous = this._engine
|
||||
this._engine = null
|
||||
if (previous) {
|
||||
previous.dispose()
|
||||
await previous.whenIdle(ENGINE_STOP_TIMEOUT_MS)
|
||||
}
|
||||
this._session = session
|
||||
|
||||
// 1) 사용자별 DB 열기 (Phase 1 — 사용자별 SQLite 격리)
|
||||
try {
|
||||
const { created, dbPath } = openForUser(userId)
|
||||
|
|
@ -245,9 +271,8 @@ class CloudSyncService extends EventEmitter {
|
|||
)
|
||||
}
|
||||
|
||||
// 4) 동기화 엔진 — 사용자 DB가 열린 뒤, 이 사용자 전용으로 만든다.
|
||||
this._engine?.dispose()
|
||||
this._engine = null
|
||||
// 4) 동기화 엔진 — 사용자 DB가 열린 뒤, 이 사용자 전용으로 만든다(엔진은 이 DB에 묶인다).
|
||||
// 이전 엔진은 0)에서 이미 해제했다.
|
||||
if (this._client) {
|
||||
const engine = new SyncEngine({ remote: new SupabaseSyncRemote(this._client), userId })
|
||||
engine.on('progress', (payload) => this.emit('sync-progress', payload))
|
||||
|
|
@ -358,7 +383,8 @@ class CloudSyncService extends EventEmitter {
|
|||
// 2) Realtime 구독·주기 작업 종료, 엔진 해제
|
||||
await this.stopRealtime()
|
||||
this._stopHeartbeat()
|
||||
this._engine?.dispose()
|
||||
const engine = this._engine
|
||||
engine?.dispose()
|
||||
this._engine = null
|
||||
|
||||
// 3) 사용자가 직접 로그아웃하면 기기 목록에서도 빠진다(원격 해제로 로그아웃될 때는 이미 해제됨).
|
||||
|
|
@ -392,6 +418,11 @@ class CloudSyncService extends EventEmitter {
|
|||
|
||||
// 6) 사용자 DB 닫고 로컬 모드 DB로 복귀
|
||||
// — 로그아웃 후에도 앱은 익명 로컬 모드로 계속 동작 (entry point 철학)
|
||||
// 진행 중이던 pull이 끝나기를(최대 ENGINE_STOP_TIMEOUT_MS) 기다린다. 해제된 엔진은 다음 await에서 멈추고,
|
||||
// 시간이 넘어도 사용자 DB 검사가 로컬 모드 DB에 쓰는 것을 막는다.
|
||||
if (engine && !(await engine.whenIdle(ENGINE_STOP_TIMEOUT_MS))) {
|
||||
logger.warn('[signOut] Sync engine did not stop in time — continuing (writes are blocked by user DB guard)')
|
||||
}
|
||||
closeCurrent()
|
||||
try {
|
||||
const { dbPath } = openLocal()
|
||||
|
|
@ -587,19 +618,22 @@ class CloudSyncService extends EventEmitter {
|
|||
|
||||
const userId = this._session.user.id
|
||||
|
||||
// persistSession: false 에서는 Supabase realtime 클라이언트가 auth state change를
|
||||
// 자동 추적하지 않는다. access_token을 명시적으로 realtime에 주입해서
|
||||
// postgres_changes 채널이 RLS를 통과하도록 한다. (TIMED_OUT 버그 픽스)
|
||||
// postgres_changes 채널이 RLS를 통과하도록 access token을 realtime에 넣는다(TIMED_OUT 버그 픽스).
|
||||
// 로그인 시점의 토큰은 1시간 뒤 만료되므로, 클라이언트의 현재 세션(필요하면 갱신)에서 꺼낸다 —
|
||||
// 만료된 토큰으로 재구독하면 CHANNEL_ERROR만 반복한다.
|
||||
try {
|
||||
this._client.realtime.setAuth(this._session.access_token)
|
||||
const { data } = await this._client.auth.getSession()
|
||||
if (!this._session || !this._client) return
|
||||
await this._client.realtime.setAuth(data.session?.access_token ?? this._session.access_token)
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
`Realtime setAuth 실패 (계속 진행): ${err instanceof Error ? err.message : String(err)}`
|
||||
)
|
||||
}
|
||||
if (!this._session || !this._client) return
|
||||
|
||||
let channel = this._client.channel(`cloud-sync:${userId}`)
|
||||
for (const table of REALTIME_TABLES) {
|
||||
for (const table of realtimeTables()) {
|
||||
channel = channel.on(
|
||||
'postgres_changes',
|
||||
{ event: '*', schema: 'public', table, filter: `user_id=eq.${userId}` },
|
||||
|
|
@ -710,8 +744,29 @@ class CloudSyncService extends EventEmitter {
|
|||
)
|
||||
}
|
||||
|
||||
// 이 앱이 요청한 로그인만 받아들인다: 콜백은 이 시각 이후 한 번만 유효하다.
|
||||
this._pendingSignInUntil = Date.now() + SIGN_IN_WINDOW_MS
|
||||
logger.info(`Opening OAuth URL in external browser: ${provider}`)
|
||||
await shell.openExternal(data.url)
|
||||
if (!(await openExternalSafe(data.url))) {
|
||||
this._pendingSignInUntil = null
|
||||
throw new D3ROError(ErrorCode.LLMProcessingFailed, 'OAuth URL is not an https URL')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* deep link 로그인 콜백을 받아들일지. 사용자가 방금 로그인을 시작했고(startSignIn) 이미 로그인된 상태가
|
||||
* 아닐 때만 한 번 허용한다 — 아무 웹페이지나 d3ro-voice://auth-callback#access_token=... 으로 앱을
|
||||
* 공격자 계정에 로그인시켜 로컬 기록을 그 계정으로 올리게 하는 것(login CSRF)을 막는다.
|
||||
*/
|
||||
private _consumePendingSignIn(): void {
|
||||
const until = this._pendingSignInUntil
|
||||
this._pendingSignInUntil = null
|
||||
if (until === null || Date.now() > until) {
|
||||
throw new D3ROError(ErrorCode.LLMProcessingFailed, 'Ignored sign-in callback: no sign-in was started from this app')
|
||||
}
|
||||
if (this._session) {
|
||||
throw new D3ROError(ErrorCode.LLMProcessingFailed, 'Ignored sign-in callback: already signed in — sign out first')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -723,6 +778,7 @@ class CloudSyncService extends EventEmitter {
|
|||
throw new D3ROError(ErrorCode.LLMServerUnreachable, 'Cloud Sync가 설정되지 않았습니다')
|
||||
}
|
||||
|
||||
this._consumePendingSignIn()
|
||||
const { data, error } = await this._client.auth.exchangeCodeForSession(code)
|
||||
if (error || !data.session) {
|
||||
throw new D3ROError(
|
||||
|
|
@ -754,6 +810,7 @@ class CloudSyncService extends EventEmitter {
|
|||
throw new D3ROError(ErrorCode.LLMServerUnreachable, 'Cloud Sync가 설정되지 않았습니다')
|
||||
}
|
||||
|
||||
this._consumePendingSignIn()
|
||||
const { data, error } = await this._client.auth.setSession({
|
||||
access_token: params.accessToken,
|
||||
refresh_token: params.refreshToken,
|
||||
|
|
@ -805,20 +862,12 @@ class CloudSyncService extends EventEmitter {
|
|||
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 _scheduleFlush(): void {
|
||||
this._scheduler.scheduleFlush()
|
||||
}
|
||||
|
||||
private _schedulePull(delayMs = PULL_DEBOUNCE_MS): void {
|
||||
if (this._pullTimer) return
|
||||
this._pullTimer = setTimeout(() => {
|
||||
this._pullTimer = null
|
||||
void this._runEngine('pull').catch(() => undefined)
|
||||
}, delayMs)
|
||||
private _schedulePull(): void {
|
||||
this._scheduler.schedulePull()
|
||||
}
|
||||
|
||||
/** 수동 "업로드": 보관된 실패 항목도 다시 시도한다. */
|
||||
|
|
@ -862,6 +911,11 @@ class CloudSyncService extends EventEmitter {
|
|||
return result
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
if (err instanceof SyncAbortedError || engine !== this._engine) {
|
||||
// 로그아웃·계정 전환으로 멈춘 동기화 — 사용자에게 알릴 오류가 아니다.
|
||||
logger.info(`Sync ${kind} stopped: ${message}`)
|
||||
throw err
|
||||
}
|
||||
logger.error(`Sync ${kind} failed: ${message}`)
|
||||
this.emit('sync-error', { error: message })
|
||||
throw err
|
||||
|
|
@ -905,33 +959,23 @@ class CloudSyncService extends EventEmitter {
|
|||
}
|
||||
|
||||
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)
|
||||
this._scheduler.startHeartbeat()
|
||||
}
|
||||
|
||||
private _onHeartbeat(): void {
|
||||
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)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
this._scheduler.stop()
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -966,48 +1010,44 @@ class CloudSyncService extends EventEmitter {
|
|||
}
|
||||
}
|
||||
|
||||
// ── 토큰 영속화 (electron safeStorage) ─────────────────
|
||||
// ── 토큰 영속화 (electron safeStorage) — SessionTokenStore 에 위임 ─────
|
||||
|
||||
private _tokens(): SessionTokenStore {
|
||||
if (!this._tokenStore) {
|
||||
this._tokenStore = new EncryptedFileTokenStore(
|
||||
path.join(app.getPath('userData'), TOKEN_FILE),
|
||||
safeStorage,
|
||||
{
|
||||
existsSync: (p) => fs.existsSync(p),
|
||||
readFileSync: (p) => fs.readFileSync(p),
|
||||
writeFileSync: (p, data) => fs.writeFileSync(p, data),
|
||||
unlinkSync: (p) => fs.unlinkSync(p),
|
||||
},
|
||||
logger
|
||||
)
|
||||
}
|
||||
return this._tokenStore
|
||||
}
|
||||
|
||||
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)}`)
|
||||
}
|
||||
this._tokens().save(token)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
return this._tokens().load()
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
this._tokens().clear()
|
||||
}
|
||||
|
||||
/** 테스트 전용: 타이머·auth 구독을 해제한다. */
|
||||
disposeForTests(): void {
|
||||
this._scheduler.stop()
|
||||
this._unbindAuthEvents?.()
|
||||
this._unbindAuthEvents = null
|
||||
void this._client?.auth.stopAutoRefresh().catch(() => undefined)
|
||||
this.removeAllListeners()
|
||||
}
|
||||
|
||||
// ── EventEmitter 타입 오버라이드 ───────────────────────
|
||||
|
|
@ -1029,7 +1069,7 @@ class CloudSyncService extends EventEmitter {
|
|||
let instance: CloudSyncService | null = null
|
||||
|
||||
export function resetCloudSyncServiceForTests(): void {
|
||||
if (instance) instance.removeAllListeners()
|
||||
if (instance) instance.disposeForTests()
|
||||
instance = null
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,18 @@
|
|||
// src/main/services/CustomInstructionService.ts
|
||||
// 사용자 정의 LLM 명령어 관리. 설계서 01/Phase 6 참조.
|
||||
// electron-store에 저장, 프리셋 5개 기본 제공.
|
||||
// 계정 범위 저장소(users/<id>/custom-instructions.json)에 저장, 프리셋 5개 기본 제공.
|
||||
// 계정이 바뀌면(로그인·로그아웃) 그 계정의 목록으로 다시 읽는다 — 다른 계정의 명령이 보이거나 올라가지 않게.
|
||||
|
||||
import { getLogger } from './LoggerService'
|
||||
import { configGet, configSet } from './ConfigService'
|
||||
import { configGet } from './ConfigService'
|
||||
import { getCloudSyncService } from './CloudSyncService'
|
||||
import {
|
||||
AccountScopedList,
|
||||
currentAccountScope,
|
||||
electronStoreCollectionFactory,
|
||||
onAccountScopeChanged,
|
||||
type UserScopedCollectionFactory,
|
||||
} from './account-scope'
|
||||
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
|
||||
import type { CustomInstruction } from '@d3ro/core/types'
|
||||
|
||||
|
|
@ -68,44 +76,107 @@ const BUILTIN_INSTRUCTIONS: ReadonlyArray<Omit<CustomInstruction, 'createdAt' |
|
|||
]
|
||||
|
||||
// ============================================================
|
||||
// 저장소 (electron-store 사용)
|
||||
// 길이 제한 — 서버 custom_instructions CHECK 와 같은 값(마이그레이션 20260821000005)
|
||||
// ============================================================
|
||||
|
||||
// electron-store 대신 간단한 JSON 파일 사용 (ConfigService와 별도)
|
||||
// 실제로는 electron-store의 별도 인스턴스를 사용하지만,
|
||||
// Phase 6에서는 메모리 + configGet/configSet 패턴으로 단순화
|
||||
export const INSTRUCTION_LIMITS = { name: 80, description: 240, prompt: 4000 } as const
|
||||
|
||||
export type InstructionLimitField = keyof typeof INSTRUCTION_LIMITS
|
||||
|
||||
/** Postgres char_length 와 같은 코드 포인트 수 */
|
||||
function charLength(value: string): number {
|
||||
return [...value].length
|
||||
}
|
||||
|
||||
/** 서버 제한을 넘는 첫 필드. 없으면 null. (이름·프롬프트는 앞뒤 공백을 빼고 센다 — 서버 btrim 과 같다) */
|
||||
export function instructionLimitViolation(input: {
|
||||
name?: string
|
||||
description?: string
|
||||
prompt?: string
|
||||
}): InstructionLimitField | null {
|
||||
if (input.name !== undefined && charLength(input.name.trim()) > INSTRUCTION_LIMITS.name) return 'name'
|
||||
if (input.description !== undefined && charLength(input.description.trim()) > INSTRUCTION_LIMITS.description) {
|
||||
return 'description'
|
||||
}
|
||||
if (input.prompt !== undefined && charLength(input.prompt.trim()) > INSTRUCTION_LIMITS.prompt) return 'prompt'
|
||||
return null
|
||||
}
|
||||
|
||||
function assertWithinLimits(input: { name?: string; description?: string; prompt?: string }): void {
|
||||
const field = instructionLimitViolation(input)
|
||||
if (field !== null) {
|
||||
throw new D3ROError(
|
||||
ErrorCode.ConfigInvalidValue,
|
||||
`Instruction ${field} is too long (max ${INSTRUCTION_LIMITS[field]} characters)`,
|
||||
{ field, max: INSTRUCTION_LIMITS[field] }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 저장소 — 계정 범위 목록 (AccountScopedList)
|
||||
// ============================================================
|
||||
|
||||
let instructions: CustomInstruction[] = []
|
||||
let initialized = false
|
||||
let collectionFactory: UserScopedCollectionFactory = electronStoreCollectionFactory
|
||||
let store: AccountScopedList<CustomInstruction> | null = null
|
||||
let unsubscribeScope: (() => void) | null = null
|
||||
|
||||
function loadInstructions(): CustomInstruction[] {
|
||||
// electron-store에서 로드 시도
|
||||
/** 계정 범위가 생기기 전(전역 config)의 목록 — 로컬 모드 범위로 한 번 옮긴다. */
|
||||
function readLegacyInstructions(): CustomInstruction[] | null {
|
||||
try {
|
||||
const stored = configGet('customInstructions') as CustomInstruction[] | undefined
|
||||
if (Array.isArray(stored) && stored.length > 0) {
|
||||
return stored
|
||||
}
|
||||
return Array.isArray(stored) && stored.length > 0 ? stored : null
|
||||
} catch {
|
||||
// 첫 실행 시 키가 없을 수 있음
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// 프리셋으로 초기화
|
||||
function builtinDefaults(now: number): CustomInstruction[] {
|
||||
return BUILTIN_INSTRUCTIONS.map((b) => ({ ...b, createdAt: now, updatedAt: now }))
|
||||
}
|
||||
|
||||
function getStore(): AccountScopedList<CustomInstruction> {
|
||||
if (!store) {
|
||||
store = new AccountScopedList<CustomInstruction>({
|
||||
name: 'custom-instructions',
|
||||
factory: collectionFactory,
|
||||
readLegacy: readLegacyInstructions,
|
||||
})
|
||||
}
|
||||
return store
|
||||
}
|
||||
|
||||
function loadInstructions(scopeId: string): CustomInstruction[] {
|
||||
let stored: CustomInstruction[] | null = null
|
||||
try {
|
||||
stored = getStore().open(scopeId)
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to load instructions: ${error instanceof Error ? error.message : String(error)}`)
|
||||
}
|
||||
const now = Date.now()
|
||||
return BUILTIN_INSTRUCTIONS.map((b) => ({
|
||||
...b,
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
}))
|
||||
if (!stored || stored.length === 0) return builtinDefaults(now)
|
||||
// 가져온 목록에 프리셋이 빠져 있으면 채운다(프리셋은 지울 수 없다).
|
||||
const ids = new Set(stored.map((i) => i.id))
|
||||
return [...stored, ...builtinDefaults(now).filter((b) => !ids.has(b.id))]
|
||||
}
|
||||
|
||||
function saveInstructions(): void {
|
||||
try {
|
||||
configSet('customInstructions', instructions)
|
||||
getStore().save(instructions)
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to save instructions: ${error instanceof Error ? error.message : String(error)}`)
|
||||
}
|
||||
}
|
||||
|
||||
/** 계정이 바뀌면 그 계정의 목록으로 다시 읽는다(동기화 엔진이 만들어지기 전에 끝난다). */
|
||||
function reloadForScope(scopeId: string): void {
|
||||
if (!initialized) return
|
||||
instructions = loadInstructions(scopeId)
|
||||
logger.info(`CustomInstructionService reloaded for account scope (${instructions.length} instructions)`)
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// CustomInstructionService
|
||||
// ============================================================
|
||||
|
|
@ -113,8 +184,9 @@ function saveInstructions(): void {
|
|||
class CustomInstructionService {
|
||||
initialize(): void {
|
||||
if (initialized) return
|
||||
instructions = loadInstructions()
|
||||
instructions = loadInstructions(currentAccountScope())
|
||||
initialized = true
|
||||
unsubscribeScope ??= onAccountScopeChanged(reloadForScope)
|
||||
logger.info(`CustomInstructionService initialized (${instructions.length} instructions)`)
|
||||
}
|
||||
|
||||
|
|
@ -133,6 +205,8 @@ class CustomInstructionService {
|
|||
if (!input.prompt.trim()) {
|
||||
throw new D3ROError(ErrorCode.ConfigInvalidValue, 'Instruction prompt is empty')
|
||||
}
|
||||
// 서버 제한을 넘으면 저장하지 않는다 — 동기화가 잘라 올리면 다음 pull이 원본을 덮는다.
|
||||
assertWithinLimits(input)
|
||||
const now = Date.now()
|
||||
const instruction: CustomInstruction = {
|
||||
id: crypto.randomUUID(),
|
||||
|
|
@ -158,6 +232,7 @@ class CustomInstructionService {
|
|||
if (index === -1) return null
|
||||
|
||||
const existing = instructions[index]
|
||||
assertWithinLimits(existing.isBuiltin ? { prompt: data.prompt } : data)
|
||||
|
||||
// 프리셋은 프롬프트만 수정 가능 — 폰과 짝이 있는 프리셋은 서버 프리셋 행으로도 올린다
|
||||
if (existing.isBuiltin) {
|
||||
|
|
@ -285,8 +360,14 @@ export function getCustomInstructionService(): CustomInstructionService {
|
|||
return instance
|
||||
}
|
||||
|
||||
export function resetCustomInstructionServiceForTests(): void {
|
||||
export function resetCustomInstructionServiceForTests(
|
||||
factory: UserScopedCollectionFactory = electronStoreCollectionFactory
|
||||
): void {
|
||||
instance = null
|
||||
initialized = false
|
||||
instructions = []
|
||||
unsubscribeScope?.()
|
||||
unsubscribeScope = null
|
||||
store = null
|
||||
collectionFactory = factory
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,13 @@ import { EventEmitter } from 'events'
|
|||
import Store from 'electron-store'
|
||||
import { getLogger } from './LoggerService'
|
||||
import { getCloudSyncService } from './CloudSyncService'
|
||||
import {
|
||||
AccountScopedList,
|
||||
currentAccountScope,
|
||||
electronStoreCollectionFactory,
|
||||
onAccountScopeChanged,
|
||||
type UserScopedCollectionFactory,
|
||||
} from './account-scope'
|
||||
import { getMainWindow } from '../windows/WindowManager'
|
||||
import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
|
||||
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
|
||||
|
|
@ -67,31 +74,28 @@ const BUILTIN_TEMPLATES: DictationTemplate[] = [
|
|||
},
|
||||
]
|
||||
|
||||
type TemplateStoreSchema = {
|
||||
templates: DictationTemplate[]
|
||||
}
|
||||
|
||||
class DictationTemplateService extends EventEmitter {
|
||||
private _store: Store<TemplateStoreSchema>
|
||||
/** 계정 범위 저장소(users/<id>/dictation-templates.json) — 계정이 바뀌면 다시 읽는다 */
|
||||
private readonly _list: AccountScopedList<DictationTemplate>
|
||||
private _items: DictationTemplate[] = []
|
||||
private readonly _unsubscribeScope: () => void
|
||||
private _session: TemplateSessionInfo | null = null
|
||||
|
||||
constructor() {
|
||||
constructor(factory: UserScopedCollectionFactory = collectionFactory) {
|
||||
super()
|
||||
this._store = new Store<TemplateStoreSchema>({
|
||||
this._list = new AccountScopedList<DictationTemplate>({
|
||||
name: 'dictation-templates',
|
||||
defaults: {
|
||||
templates: [...BUILTIN_TEMPLATES],
|
||||
},
|
||||
factory,
|
||||
readLegacy: readLegacyTemplates,
|
||||
})
|
||||
|
||||
// 프리셋이 없으면 추가
|
||||
this._ensureBuiltins()
|
||||
this._loadScope(currentAccountScope())
|
||||
this._unsubscribeScope = onAccountScopeChanged((scopeId) => this._loadScope(scopeId))
|
||||
}
|
||||
|
||||
// ── CRUD ──
|
||||
|
||||
getAll(): DictationTemplate[] {
|
||||
return this._store.get('templates', [])
|
||||
return [...this._items]
|
||||
}
|
||||
|
||||
getById(id: string): DictationTemplate | null {
|
||||
|
|
@ -113,7 +117,7 @@ class DictationTemplateService extends EventEmitter {
|
|||
|
||||
const templates = this.getAll()
|
||||
templates.push(template)
|
||||
this._store.set('templates', templates)
|
||||
this._save(templates)
|
||||
getCloudSyncService().pushOne('user_templates', template.id)
|
||||
|
||||
return template
|
||||
|
|
@ -137,7 +141,7 @@ class DictationTemplateService extends EventEmitter {
|
|||
}
|
||||
|
||||
templates[idx] = updated
|
||||
this._store.set('templates', templates)
|
||||
this._save(templates)
|
||||
if (!updated.isBuiltin) getCloudSyncService().pushOne('user_templates', updated.id)
|
||||
return updated
|
||||
}
|
||||
|
|
@ -152,10 +156,7 @@ class DictationTemplateService extends EventEmitter {
|
|||
throw new D3ROError(ErrorCode.TemplateInvalidFormat, 'Cannot delete builtin template')
|
||||
}
|
||||
|
||||
this._store.set(
|
||||
'templates',
|
||||
templates.filter((t) => t.id !== id),
|
||||
)
|
||||
this._save(templates.filter((t) => t.id !== id))
|
||||
getCloudSyncService().pushDelete('user_templates', id)
|
||||
}
|
||||
|
||||
|
|
@ -166,7 +167,7 @@ class DictationTemplateService extends EventEmitter {
|
|||
if (idx === -1) templates.push(template)
|
||||
else if (!templates[idx].isBuiltin) templates[idx] = template
|
||||
else return
|
||||
this._store.set('templates', templates)
|
||||
this._save(templates)
|
||||
}
|
||||
|
||||
/** 동기화: 다른 기기에서 지운 사용자 템플릿을 지운다. */
|
||||
|
|
@ -174,7 +175,7 @@ class DictationTemplateService extends EventEmitter {
|
|||
const templates = this.getAll()
|
||||
const target = templates.find((t) => t.id === id)
|
||||
if (!target || target.isBuiltin) return false
|
||||
this._store.set('templates', templates.filter((t) => t.id !== id))
|
||||
this._save(templates.filter((t) => t.id !== id))
|
||||
return true
|
||||
}
|
||||
|
||||
|
|
@ -307,15 +308,50 @@ class DictationTemplateService extends EventEmitter {
|
|||
templates.push(builtin)
|
||||
}
|
||||
}
|
||||
this._store.set('templates', templates)
|
||||
this._save(templates)
|
||||
}
|
||||
|
||||
/** 계정 범위를 열고 그 계정의 템플릿으로 바꾼다. 없으면 프리셋으로 시작한다. */
|
||||
private _loadScope(scopeId: string): void {
|
||||
this.cancelSession()
|
||||
try {
|
||||
this._items = this._list.open(scopeId) ?? [...BUILTIN_TEMPLATES]
|
||||
} catch (err) {
|
||||
logger.warn(`Template store open failed: ${err instanceof Error ? err.message : String(err)}`)
|
||||
this._items = [...BUILTIN_TEMPLATES]
|
||||
}
|
||||
this._ensureBuiltins()
|
||||
}
|
||||
|
||||
private _save(templates: DictationTemplate[]): void {
|
||||
this._items = [...templates]
|
||||
try {
|
||||
this._list.save(this._items)
|
||||
} catch (err) {
|
||||
logger.warn(`Template save failed: ${err instanceof Error ? err.message : String(err)}`)
|
||||
}
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this._unsubscribeScope()
|
||||
this.cancelSession()
|
||||
this.removeAllListeners()
|
||||
}
|
||||
}
|
||||
|
||||
/** 계정 범위가 생기기 전의 기계 전역 저장소(dictation-templates.json) — 로컬 모드 범위로 한 번 옮긴다. */
|
||||
function readLegacyTemplates(): DictationTemplate[] | null {
|
||||
try {
|
||||
const legacy = new Store<{ templates?: DictationTemplate[] }>({ name: 'dictation-templates' })
|
||||
const templates = legacy.get('templates')
|
||||
return Array.isArray(templates) && templates.length > 0 ? templates : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
let collectionFactory: UserScopedCollectionFactory = electronStoreCollectionFactory
|
||||
|
||||
// ── 싱글톤 ──
|
||||
let instance: DictationTemplateService | null = null
|
||||
|
||||
|
|
@ -326,6 +362,10 @@ export function getDictationTemplateService(): DictationTemplateService {
|
|||
return instance
|
||||
}
|
||||
|
||||
export function resetDictationTemplateServiceForTests(): void {
|
||||
export function resetDictationTemplateServiceForTests(
|
||||
factory: UserScopedCollectionFactory = electronStoreCollectionFactory
|
||||
): void {
|
||||
instance?.dispose()
|
||||
instance = null
|
||||
collectionFactory = factory
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,11 +3,13 @@
|
|||
|
||||
import { eq, desc, like, and, sql, count } from 'drizzle-orm'
|
||||
import { getDatabase } from '../db'
|
||||
import { history, memoTags, stats } from '../db/schema'
|
||||
import { history, stats } from '../db/schema'
|
||||
import type { History, NewHistory } from '../db/schema'
|
||||
import { getLogger } from './LoggerService'
|
||||
import { getCloudSyncService } from './CloudSyncService'
|
||||
import { getInputTelemetryService } from './InputTelemetryService'
|
||||
import { deleteHistoryLocally } from './history-deletion'
|
||||
import { computeStreakDays } from './history-stats'
|
||||
import type {
|
||||
HistoryEntry,
|
||||
HistoryQueryParams,
|
||||
|
|
@ -126,11 +128,10 @@ class HistoryService {
|
|||
}
|
||||
|
||||
delete(id: string): boolean {
|
||||
const db = getDatabase()
|
||||
db.delete(memoTags).where(eq(memoTags.historyId, id)).run()
|
||||
const result = db.delete(history).where(eq(history.id, id)).run()
|
||||
if (result.changes > 0) getCloudSyncService().pushDelete('history', id)
|
||||
return result.changes > 0
|
||||
// 태그·행과 함께 이 기록의 녹음 WAV(userData/recordings)도 지운다.
|
||||
const { deleted } = deleteHistoryLocally({ ids: [id] })
|
||||
if (deleted > 0) getCloudSyncService().pushDelete('history', id)
|
||||
return deleted > 0
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -138,10 +139,8 @@ class HistoryService {
|
|||
* 로컬에 있던 행만 지운다 — 아직 내려받지 않은 원격 행까지 일괄 삭제하지 않는다.
|
||||
*/
|
||||
deleteAll(): void {
|
||||
const db = getDatabase()
|
||||
const ids = db.select({ id: history.id }).from(history).all().map((r) => r.id)
|
||||
db.delete(memoTags).run()
|
||||
db.delete(history).run()
|
||||
// 개인정보 삭제: 행뿐 아니라 녹음 파일까지 지운다.
|
||||
const { ids } = deleteHistoryLocally({ all: true })
|
||||
const sync = getCloudSyncService()
|
||||
for (const id of ids) sync.pushDelete('history', id)
|
||||
logger.info('All history entries deleted')
|
||||
|
|
@ -161,9 +160,27 @@ class HistoryService {
|
|||
return row ? this._toEntry(row) : null
|
||||
}
|
||||
|
||||
/**
|
||||
* 누적·오늘·연속 사용일 모두 history 테이블에서 계산한다(완료 세션만). 저장된 카운터는 로컬 생성만
|
||||
* 반영해 동기화·가져오기·삭제와 어긋났고, streak은 쓰는 곳이 없어 늘 0이었다.
|
||||
*/
|
||||
getStats(): StatsSummary {
|
||||
const db = getDatabase()
|
||||
const row = db.select().from(stats).where(eq(stats.id, 1)).get()
|
||||
const completed = eq(history.status, 'completed')
|
||||
const totals = db
|
||||
.select({
|
||||
sessions: count(),
|
||||
duration: sql<number>`COALESCE(SUM(duration), 0)`,
|
||||
words: sql<number>`COALESCE(SUM(word_count), 0)`
|
||||
})
|
||||
.from(history)
|
||||
.where(completed)
|
||||
.get()
|
||||
const days = db
|
||||
.select({ day: sql<string>`DISTINCT date(created_at / 1000, 'unixepoch', 'localtime')` })
|
||||
.from(history)
|
||||
.where(completed)
|
||||
.all()
|
||||
|
||||
const todayStart = new Date()
|
||||
todayStart.setHours(0, 0, 0, 0)
|
||||
|
|
@ -185,13 +202,13 @@ class HistoryService {
|
|||
.get()
|
||||
|
||||
return {
|
||||
totalRecordingTimeMs: (row?.totalDuration ?? 0) * 1000,
|
||||
totalWordCount: row?.totalWords ?? 0,
|
||||
totalSessionCount: row?.sessionCount ?? 0,
|
||||
totalRecordingTimeMs: (totals?.duration ?? 0) * 1000,
|
||||
totalWordCount: totals?.words ?? 0,
|
||||
totalSessionCount: totals?.sessions ?? 0,
|
||||
todayRecordingTimeMs: (todayResult?.duration ?? 0) * 1000,
|
||||
todayWordCount: todayResult?.words ?? 0,
|
||||
todaySessionCount: todayResult?.sessions ?? 0,
|
||||
streakDays: row?.streakDays ?? 0
|
||||
streakDays: computeStreakDays((days ?? []).map((d) => d.day).filter((d): d is string => typeof d === 'string'))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -199,13 +216,12 @@ class HistoryService {
|
|||
* 보존 정책에 따라 오래된 항목 정리 (30일 초과)
|
||||
*/
|
||||
runRetentionCleanup(): number {
|
||||
const db = getDatabase()
|
||||
const cutoff = Date.now() - 30 * 24 * 60 * 60 * 1000
|
||||
const result = db.delete(history).where(sql`created_at < ${cutoff}`).run()
|
||||
if (result.changes > 0) {
|
||||
logger.info(`Retention cleanup: ${result.changes} old entries removed`)
|
||||
const { deleted } = deleteHistoryLocally({ olderThan: cutoff })
|
||||
if (deleted > 0) {
|
||||
logger.info(`Retention cleanup: ${deleted} old entries removed`)
|
||||
}
|
||||
return result.changes
|
||||
return deleted
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,13 @@ import { EventEmitter } from 'events'
|
|||
import Store from 'electron-store'
|
||||
import { getLogger } from './LoggerService'
|
||||
import { getCloudSyncService } from './CloudSyncService'
|
||||
import {
|
||||
AccountScopedList,
|
||||
currentAccountScope,
|
||||
electronStoreCollectionFactory,
|
||||
onAccountScopeChanged,
|
||||
type UserScopedCollectionFactory,
|
||||
} from './account-scope'
|
||||
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
|
||||
import type {
|
||||
MeetingDocTemplate,
|
||||
|
|
@ -146,28 +153,27 @@ const BUILTIN_TEMPLATES: MeetingDocTemplate[] = [
|
|||
},
|
||||
]
|
||||
|
||||
type MeetingDocTemplateStoreSchema = {
|
||||
templates: MeetingDocTemplate[]
|
||||
}
|
||||
|
||||
class MeetingDocTemplateService extends EventEmitter {
|
||||
private _store: Store<MeetingDocTemplateStoreSchema>
|
||||
/** 계정 범위 저장소(users/<id>/meeting-doc-templates.json) — 계정이 바뀌면 다시 읽는다 */
|
||||
private readonly _list: AccountScopedList<MeetingDocTemplate>
|
||||
private _items: MeetingDocTemplate[] = []
|
||||
private readonly _unsubscribeScope: () => void
|
||||
|
||||
constructor() {
|
||||
constructor(factory: UserScopedCollectionFactory = collectionFactory) {
|
||||
super()
|
||||
this._store = new Store<MeetingDocTemplateStoreSchema>({
|
||||
this._list = new AccountScopedList<MeetingDocTemplate>({
|
||||
name: 'meeting-doc-templates',
|
||||
defaults: {
|
||||
templates: [...BUILTIN_TEMPLATES],
|
||||
},
|
||||
factory,
|
||||
readLegacy: readLegacyTemplates,
|
||||
})
|
||||
this._ensureBuiltins()
|
||||
this._loadScope(currentAccountScope())
|
||||
this._unsubscribeScope = onAccountScopeChanged((scopeId) => this._loadScope(scopeId))
|
||||
}
|
||||
|
||||
// ── CRUD ──
|
||||
|
||||
getAll(): MeetingDocTemplate[] {
|
||||
return this._store.get('templates', [])
|
||||
return [...this._items]
|
||||
}
|
||||
|
||||
getById(id: string): MeetingDocTemplate | null {
|
||||
|
|
@ -188,7 +194,7 @@ class MeetingDocTemplateService extends EventEmitter {
|
|||
|
||||
const templates = this.getAll()
|
||||
templates.push(template)
|
||||
this._store.set('templates', templates)
|
||||
this._save(templates)
|
||||
getCloudSyncService().pushOne('user_templates', template.id)
|
||||
logger.info(`회의 문서 템플릿 생성: ${template.id} (${template.name})`)
|
||||
return template
|
||||
|
|
@ -214,7 +220,7 @@ class MeetingDocTemplateService extends EventEmitter {
|
|||
}
|
||||
|
||||
templates[idx] = updated
|
||||
this._store.set('templates', templates)
|
||||
this._save(templates)
|
||||
if (!updated.isBuiltin) getCloudSyncService().pushOne('user_templates', updated.id)
|
||||
logger.info(`회의 문서 템플릿 수정: ${params.id}`)
|
||||
return updated
|
||||
|
|
@ -236,10 +242,7 @@ class MeetingDocTemplateService extends EventEmitter {
|
|||
)
|
||||
}
|
||||
|
||||
this._store.set(
|
||||
'templates',
|
||||
templates.filter((t) => t.id !== id),
|
||||
)
|
||||
this._save(templates.filter((t) => t.id !== id))
|
||||
getCloudSyncService().pushDelete('user_templates', id)
|
||||
logger.info(`회의 문서 템플릿 삭제: ${id}`)
|
||||
}
|
||||
|
|
@ -251,7 +254,7 @@ class MeetingDocTemplateService extends EventEmitter {
|
|||
if (idx === -1) templates.push(template)
|
||||
else if (!templates[idx].isBuiltin) templates[idx] = template
|
||||
else return
|
||||
this._store.set('templates', templates)
|
||||
this._save(templates)
|
||||
}
|
||||
|
||||
/** 동기화: 다른 기기에서 지운 사용자 템플릿을 지운다. */
|
||||
|
|
@ -259,7 +262,7 @@ class MeetingDocTemplateService extends EventEmitter {
|
|||
const templates = this.getAll()
|
||||
const target = templates.find((t) => t.id === id)
|
||||
if (!target || target.isBuiltin) return false
|
||||
this._store.set('templates', templates.filter((t) => t.id !== id))
|
||||
this._save(templates.filter((t) => t.id !== id))
|
||||
return true
|
||||
}
|
||||
|
||||
|
|
@ -273,15 +276,49 @@ class MeetingDocTemplateService extends EventEmitter {
|
|||
}
|
||||
}
|
||||
if (changed) {
|
||||
this._store.set('templates', templates)
|
||||
this._save(templates)
|
||||
}
|
||||
}
|
||||
|
||||
/** 계정 범위를 열고 그 계정의 템플릿으로 바꾼다. 없으면 프리셋으로 시작한다. */
|
||||
private _loadScope(scopeId: string): void {
|
||||
try {
|
||||
this._items = this._list.open(scopeId) ?? [...BUILTIN_TEMPLATES]
|
||||
} catch (err) {
|
||||
logger.warn(`Template store open failed: ${err instanceof Error ? err.message : String(err)}`)
|
||||
this._items = [...BUILTIN_TEMPLATES]
|
||||
}
|
||||
this._ensureBuiltins()
|
||||
}
|
||||
|
||||
private _save(templates: MeetingDocTemplate[]): void {
|
||||
this._items = [...templates]
|
||||
try {
|
||||
this._list.save(this._items)
|
||||
} catch (err) {
|
||||
logger.warn(`Template save failed: ${err instanceof Error ? err.message : String(err)}`)
|
||||
}
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this._unsubscribeScope()
|
||||
this.removeAllListeners()
|
||||
}
|
||||
}
|
||||
|
||||
/** 계정 범위가 생기기 전의 기계 전역 저장소(meeting-doc-templates.json) — 로컬 모드 범위로 한 번 옮긴다. */
|
||||
function readLegacyTemplates(): MeetingDocTemplate[] | null {
|
||||
try {
|
||||
const legacy = new Store<{ templates?: MeetingDocTemplate[] }>({ name: 'meeting-doc-templates' })
|
||||
const templates = legacy.get('templates')
|
||||
return Array.isArray(templates) && templates.length > 0 ? templates : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
let collectionFactory: UserScopedCollectionFactory = electronStoreCollectionFactory
|
||||
|
||||
// ── 싱글톤 ──
|
||||
let instance: MeetingDocTemplateService | null = null
|
||||
|
||||
|
|
@ -292,6 +329,10 @@ export function getMeetingDocTemplateService(): MeetingDocTemplateService {
|
|||
return instance
|
||||
}
|
||||
|
||||
export function resetMeetingDocTemplateServiceForTests(): void {
|
||||
export function resetMeetingDocTemplateServiceForTests(
|
||||
factory: UserScopedCollectionFactory = electronStoreCollectionFactory
|
||||
): void {
|
||||
instance?.dispose()
|
||||
instance = null
|
||||
collectionFactory = factory
|
||||
}
|
||||
|
|
|
|||
197
apps/desktop/src/main/services/account-scope.ts
Normal file
197
apps/desktop/src/main/services/account-scope.ts
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
// src/main/services/account-scope.ts
|
||||
// 계정 범위(account scope) 전환 알림과 계정별 목록 저장소 포트.
|
||||
//
|
||||
// SQLite만 사용자별(users/<id>/d3ro.db)로 나뉘고 명령·템플릿은 기계 전역 저장소에 있으면, 계정을 바꿀 때마다
|
||||
// 서비스 N곳이 따로 격리를 기억해야 한다(잊으면 계정 간 누출·동기화 오염). 그래서:
|
||||
// - db/index.ts 가 사용자 DB를 열 때 한 번 알린다(emitAccountScopeChanged).
|
||||
// - 서비스는 UserScopedCollection 포트로만 저장하고, 알림을 받으면 그 계정 범위로 다시 읽는다.
|
||||
// 이 모듈은 DB·서비스를 import하지 않는다(순환 방지).
|
||||
|
||||
import { app } from 'electron'
|
||||
import path from 'path'
|
||||
import Store from 'electron-store'
|
||||
import { getLogger } from './LoggerService'
|
||||
|
||||
const logger = getLogger('AccountScope')
|
||||
|
||||
/** 익명 로컬 모드 범위. db/index.ts LOCAL_USER_ID 와 같은 값이다. */
|
||||
export const LOCAL_SCOPE_ID = '_local'
|
||||
|
||||
// ── 전환 알림 ────────────────────────────────────────────
|
||||
|
||||
export type AccountScopeListener = (scopeId: string) => void
|
||||
|
||||
const listeners = new Set<AccountScopeListener>()
|
||||
let currentScope: string = LOCAL_SCOPE_ID
|
||||
|
||||
/** 현재 열린 계정 범위(사용자 DB id). 아직 아무 DB도 열리지 않았으면 로컬 모드. */
|
||||
export function currentAccountScope(): string {
|
||||
return currentScope
|
||||
}
|
||||
|
||||
export function onAccountScopeChanged(listener: AccountScopeListener): () => void {
|
||||
listeners.add(listener)
|
||||
return () => {
|
||||
listeners.delete(listener)
|
||||
}
|
||||
}
|
||||
|
||||
/** 사용자 DB가 바뀐 직후 부른다. 리스너 예외는 DB 전환을 막지 않는다. */
|
||||
export function emitAccountScopeChanged(scopeId: string): void {
|
||||
if (scopeId === currentScope) return
|
||||
currentScope = scopeId
|
||||
for (const listener of [...listeners]) {
|
||||
try {
|
||||
listener(scopeId)
|
||||
} catch (err) {
|
||||
logger.warn(`Account scope listener failed: ${err instanceof Error ? err.message : String(err)}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 테스트 전용 */
|
||||
export function resetAccountScopeForTests(): void {
|
||||
listeners.clear()
|
||||
currentScope = LOCAL_SCOPE_ID
|
||||
}
|
||||
|
||||
// ── 계정별 목록 저장소 포트 ──────────────────────────────
|
||||
|
||||
export interface UserScopedCollection<T> {
|
||||
/** 저장된 목록. 한 번도 저장한 적 없으면 null */
|
||||
load(): T[] | null
|
||||
save(items: T[]): void
|
||||
getMeta(key: string): string | null
|
||||
setMeta(key: string, value: string): void
|
||||
}
|
||||
|
||||
export type UserScopedCollectionFactory = <T>(name: string, scopeId: string) => UserScopedCollection<T>
|
||||
|
||||
function assertScopeId(scopeId: string): void {
|
||||
if (!scopeId || scopeId.includes('/') || scopeId.includes('\\') || scopeId.includes('..')) {
|
||||
throw new Error(`Invalid account scope id: "${scopeId}"`)
|
||||
}
|
||||
}
|
||||
|
||||
interface ScopedStoreSchema<T> {
|
||||
items: T[] | null
|
||||
meta: Record<string, string>
|
||||
}
|
||||
|
||||
/** 기본 구현: userData/users/<scopeId>/<name>.json (사용자 DB와 같은 폴더) */
|
||||
export const electronStoreCollectionFactory: UserScopedCollectionFactory = <T>(name: string, scopeId: string) => {
|
||||
assertScopeId(scopeId)
|
||||
const store = new Store<ScopedStoreSchema<T>>({
|
||||
name,
|
||||
cwd: path.join(app.getPath('userData'), 'users', scopeId),
|
||||
defaults: { items: null, meta: {} },
|
||||
})
|
||||
return {
|
||||
load: () => {
|
||||
const items = store.get('items', null)
|
||||
return Array.isArray(items) ? items : null
|
||||
},
|
||||
save: (items) => store.set('items', items),
|
||||
getMeta: (key) => store.get('meta', {})[key] ?? null,
|
||||
setMeta: (key, value) => store.set('meta', { ...store.get('meta', {}), [key]: value }),
|
||||
}
|
||||
}
|
||||
|
||||
/** 테스트용 메모리 구현. 같은 (name, scope)는 같은 저장소를 돌려준다. */
|
||||
export function createMemoryCollectionFactory(): UserScopedCollectionFactory {
|
||||
const data = new Map<string, { items: unknown[] | null; meta: Record<string, string> }>()
|
||||
return <T>(name: string, scopeId: string): UserScopedCollection<T> => {
|
||||
assertScopeId(scopeId)
|
||||
const key = `${scopeId}/${name}`
|
||||
let entry = data.get(key)
|
||||
if (!entry) {
|
||||
entry = { items: null, meta: {} }
|
||||
data.set(key, entry)
|
||||
}
|
||||
const slot = entry
|
||||
return {
|
||||
load: () => (slot.items ? ([...slot.items] as T[]) : null),
|
||||
save: (items) => {
|
||||
slot.items = [...items]
|
||||
},
|
||||
getMeta: (k) => slot.meta[k] ?? null,
|
||||
setMeta: (k, v) => {
|
||||
slot.meta[k] = v
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 계정별 목록 (레거시 이관 + 로컬 모드 가져오기) ────────
|
||||
|
||||
const META_LEGACY_MIGRATED = 'legacyMigrated'
|
||||
const META_LOCAL_IMPORTED = 'localImported'
|
||||
const META_CLAIMED_BY = 'claimedBy'
|
||||
|
||||
export interface AccountScopedListOptions<T> {
|
||||
/** 저장 파일 이름 */
|
||||
name: string
|
||||
factory: UserScopedCollectionFactory
|
||||
/** 계정 범위가 생기기 전의 기계 전역 저장소 값(최초 1회 로컬 모드 범위로 옮긴다) */
|
||||
readLegacy: () => T[] | null
|
||||
}
|
||||
|
||||
/**
|
||||
* 계정 범위별 목록. 규칙은 db/index.ts importLocalModeData 와 같다:
|
||||
* - 옛 전역 값은 로컬 모드(_local) 범위로 한 번 옮긴다.
|
||||
* - 로컬 모드에서 만든 사용자 항목은 처음 로그인한 계정 하나만 가져간다(claimedBy). 원본은 지우지 않는다.
|
||||
* - 다른 계정은 서로의 항목을 보지 못한다.
|
||||
*/
|
||||
export class AccountScopedList<T extends { id: string; isBuiltin: boolean }> {
|
||||
private collection: UserScopedCollection<T> | null = null
|
||||
private scope: string | null = null
|
||||
|
||||
constructor(private readonly options: AccountScopedListOptions<T>) {}
|
||||
|
||||
get scopeId(): string | null {
|
||||
return this.scope
|
||||
}
|
||||
|
||||
/** 범위를 열고 저장된 목록을 돌려준다(없으면 null — 호출자가 프리셋으로 채운다). */
|
||||
open(scopeId: string): T[] | null {
|
||||
const collection = this.openCollection(scopeId)
|
||||
if (scopeId !== LOCAL_SCOPE_ID && collection.getMeta(META_LOCAL_IMPORTED) !== '1') {
|
||||
this.importFromLocal(collection, scopeId)
|
||||
collection.setMeta(META_LOCAL_IMPORTED, '1')
|
||||
}
|
||||
this.collection = collection
|
||||
this.scope = scopeId
|
||||
return collection.load()
|
||||
}
|
||||
|
||||
save(items: T[]): void {
|
||||
if (!this.collection) throw new Error(`${this.options.name}: account scope is not open`)
|
||||
this.collection.save(items)
|
||||
}
|
||||
|
||||
private openCollection(scopeId: string): UserScopedCollection<T> {
|
||||
const collection = this.options.factory<T>(this.options.name, scopeId)
|
||||
if (scopeId === LOCAL_SCOPE_ID && collection.getMeta(META_LEGACY_MIGRATED) !== '1') {
|
||||
if (collection.load() === null) {
|
||||
const legacy = this.options.readLegacy()
|
||||
if (legacy && legacy.length > 0) collection.save(legacy)
|
||||
}
|
||||
collection.setMeta(META_LEGACY_MIGRATED, '1')
|
||||
}
|
||||
return collection
|
||||
}
|
||||
|
||||
private importFromLocal(target: UserScopedCollection<T>, scopeId: string): void {
|
||||
const local = this.openCollection(LOCAL_SCOPE_ID)
|
||||
const claimedBy = local.getMeta(META_CLAIMED_BY)
|
||||
if (claimedBy && claimedBy !== scopeId) return
|
||||
const imported = (local.load() ?? []).filter((item) => !item.isBuiltin)
|
||||
if (imported.length > 0) {
|
||||
const own = target.load() ?? []
|
||||
const ownIds = new Set(own.map((item) => item.id))
|
||||
target.save([...own, ...imported.filter((item) => !ownIds.has(item.id))])
|
||||
logger.info(`${this.options.name}: imported ${imported.length} local-mode item(s) into ${scopeId}`)
|
||||
}
|
||||
local.setMeta(META_CLAIMED_BY, scopeId)
|
||||
}
|
||||
}
|
||||
95
apps/desktop/src/main/services/history-deletion.ts
Normal file
95
apps/desktop/src/main/services/history-deletion.ts
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
// src/main/services/history-deletion.ts
|
||||
// 이력 로컬 삭제의 단일 경로: 태그·행을 지우고, 앱이 만든 녹음 WAV(userData/recordings)도 지운다.
|
||||
// HistoryService(단건·전체·보존 정리)와 동기화의 원격 삭제 반영(historyAdapter.deleteLocal)이 함께 쓴다.
|
||||
// 서버 전파(outbox)는 호출자가 결정한다 — 원격 삭제 반영은 다시 올리면 안 된다.
|
||||
|
||||
import { app } from 'electron'
|
||||
import { rm } from 'fs/promises'
|
||||
import path from 'path'
|
||||
import { inArray, sql } from 'drizzle-orm'
|
||||
import { getDatabase } from '../db'
|
||||
import { history, memoTags } from '../db/schema'
|
||||
import { getLogger } from './LoggerService'
|
||||
|
||||
const logger = getLogger('HistoryDeletion')
|
||||
|
||||
/** 앱이 녹음 파일을 쓰는 곳. VoiceModeService._saveAudioFile 과 같은 경로다. */
|
||||
export function recordingsDir(): string {
|
||||
return path.join(app.getPath('userData'), 'recordings')
|
||||
}
|
||||
|
||||
/**
|
||||
* path가 녹음 디렉터리 안의 파일인지. 파일 전사는 audioLocalPath 에 사용자가 고른 원본 경로를 넣으므로
|
||||
* 그 밖의 경로는 절대 지우지 않는다.
|
||||
*/
|
||||
export function isAppRecordingPath(filePath: string | null | undefined, dir = recordingsDir()): filePath is string {
|
||||
if (typeof filePath !== 'string' || filePath.trim() === '') return false
|
||||
const normalize = (p: string): string => {
|
||||
const resolved = path.resolve(p)
|
||||
return process.platform === 'win32' ? resolved.toLowerCase() : resolved
|
||||
}
|
||||
const base = normalize(dir) + path.sep
|
||||
const target = normalize(filePath)
|
||||
return target.startsWith(base) && target.length > base.length
|
||||
}
|
||||
|
||||
/** 녹음 파일을 지운다(최선 노력, 없으면 무시). 로컬 DB 삭제를 막지 않도록 기다리지 않아도 된다. */
|
||||
export async function removeRecordingFiles(paths: ReadonlyArray<string | null | undefined>): Promise<number> {
|
||||
const dir = recordingsDir()
|
||||
let removed = 0
|
||||
for (const filePath of paths) {
|
||||
if (!isAppRecordingPath(filePath, dir)) continue
|
||||
try {
|
||||
await rm(filePath, { force: true })
|
||||
removed++
|
||||
} catch (err) {
|
||||
logger.warn(`Recording delete failed: ${err instanceof Error ? err.message : String(err)}`)
|
||||
}
|
||||
}
|
||||
return removed
|
||||
}
|
||||
|
||||
export interface HistoryDeletionResult {
|
||||
/** 지운 이력 행 수 */
|
||||
deleted: number
|
||||
/** 지운 행의 id (선택 대상이 있었을 때) */
|
||||
ids: string[]
|
||||
}
|
||||
|
||||
type Target = { ids: readonly string[] } | { all: true } | { olderThan: number }
|
||||
|
||||
/**
|
||||
* 이력 행과 태그를 지우고 녹음 파일 정리를 예약한다. 녹음 정리는 DB 삭제가 끝난 뒤 비동기로 한다.
|
||||
*/
|
||||
export function deleteHistoryLocally(target: Target): HistoryDeletionResult {
|
||||
const db = getDatabase()
|
||||
const where =
|
||||
'ids' in target
|
||||
? target.ids.length > 0
|
||||
? inArray(history.id, [...target.ids])
|
||||
: null
|
||||
: 'olderThan' in target
|
||||
? sql`created_at < ${target.olderThan}`
|
||||
: undefined
|
||||
if (where === null) return { deleted: 0, ids: [] }
|
||||
|
||||
const selected =
|
||||
(where === undefined
|
||||
? db.select({ id: history.id, audioLocalPath: history.audioLocalPath }).from(history).all()
|
||||
: db.select({ id: history.id, audioLocalPath: history.audioLocalPath }).from(history).where(where).all()) ?? []
|
||||
const selectedIds = selected.map((r) => r.id)
|
||||
|
||||
if (where === undefined) {
|
||||
db.delete(memoTags).run()
|
||||
} else if (selectedIds.length > 0) {
|
||||
db.delete(memoTags).where(inArray(memoTags.historyId, selectedIds)).run()
|
||||
}
|
||||
const result = where === undefined ? db.delete(history).run() : db.delete(history).where(where).run()
|
||||
const deleted = result?.changes ?? 0
|
||||
|
||||
const audioPaths = selected.map((r) => r.audioLocalPath)
|
||||
if (deleted > 0 && audioPaths.some((p) => p)) {
|
||||
void removeRecordingFiles(audioPaths)
|
||||
}
|
||||
return { deleted, ids: selectedIds }
|
||||
}
|
||||
31
apps/desktop/src/main/services/history-stats.ts
Normal file
31
apps/desktop/src/main/services/history-stats.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
// src/main/services/history-stats.ts
|
||||
// 대시보드 통계 정책(순수 함수). 누적 값은 저장된 카운터가 아니라 history 테이블에서 계산한다 —
|
||||
// 동기화·로컬 모드 가져오기·삭제로 들어오고 나간 행도 그대로 반영되도록.
|
||||
|
||||
/** 로컬 날짜 키(YYYY-MM-DD). SQLite date(created_at/1000,'unixepoch','localtime') 와 같은 형식. */
|
||||
export function localDayKey(date: Date): string {
|
||||
const y = date.getFullYear()
|
||||
const m = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const d = String(date.getDate()).padStart(2, '0')
|
||||
return `${y}-${m}-${d}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 연속 사용일. 오늘(또는 오늘 아직 기록이 없으면 어제)부터 거꾸로 하루도 빠짐없이 기록이 있는 날 수.
|
||||
* @param dayKeys 기록이 있는 로컬 날짜 키들(중복·순서 무관)
|
||||
*/
|
||||
export function computeStreakDays(dayKeys: Iterable<string>, today: Date = new Date()): number {
|
||||
const days = new Set(dayKeys)
|
||||
if (days.size === 0) return 0
|
||||
const cursor = new Date(today.getFullYear(), today.getMonth(), today.getDate())
|
||||
if (!days.has(localDayKey(cursor))) {
|
||||
cursor.setDate(cursor.getDate() - 1)
|
||||
if (!days.has(localDayKey(cursor))) return 0
|
||||
}
|
||||
let streak = 0
|
||||
while (days.has(localDayKey(cursor))) {
|
||||
streak++
|
||||
cursor.setDate(cursor.getDate() - 1)
|
||||
}
|
||||
return streak
|
||||
}
|
||||
|
|
@ -5,27 +5,26 @@
|
|||
// - 로컬 변경은 outbox에 쌓였다가 push된다(오프라인·재시작에도 유실 없음).
|
||||
// - pull은 서버 시각(updated_at) keyset 커서로 가져온다. 로컬 시계를 쓰지 않는다.
|
||||
// - 아직 올라가지 않은 로컬 변경이 있는 행은 pull이 덮지 않는다.
|
||||
// - 삭제는 sync_tombstones로 양방향 전파된다.
|
||||
// - 삭제는 sync_tombstones로 양방향 전파된다. 원격 삭제는 push보다 먼저 반영한다(좀비 행 방지).
|
||||
// - 커서·플래그는 사용자 DB 안(sync_state)에 있어 계정별로 분리된다.
|
||||
// - 엔진은 만들어질 때 열린 사용자 DB에 묶인다. 해제되거나 DB가 바뀌면 await 뒤마다 멈추고 아무것도 쓰지 않는다.
|
||||
|
||||
import { EventEmitter } from 'events'
|
||||
import { getLogger } from '../LoggerService'
|
||||
import { TABLE_ADAPTERS, adapterFor, type PushContext, type SyncAdapter } from './sync-adapters'
|
||||
import { getCurrentUserId } from '../../db'
|
||||
import {
|
||||
fetchRemoteMemoTagKeys,
|
||||
listLocalMemoTagKeys,
|
||||
pushMemoTagAdds,
|
||||
pushMemoTagRemovals,
|
||||
reconcileMemoTags,
|
||||
} from './memo-tag-sync'
|
||||
import { fetchRemoteAudioOwners, isAudioSyncEnabled, listLocalAudioOwners, pushAudio } from './audio-sync'
|
||||
import { SETTINGS_ROW_ID, applyRemoteSettings, fetchRemoteSettings, pushSettings } from './settings-sync'
|
||||
import {
|
||||
applyRemoteBuiltins,
|
||||
editedBuiltinsDifferingFrom,
|
||||
fetchRemoteBuiltins,
|
||||
pushBuiltinPrompts,
|
||||
} from './builtin-instruction-sync'
|
||||
SyncAbortedError,
|
||||
TABLE_ADAPTERS,
|
||||
adapterFor,
|
||||
type ApplyResult,
|
||||
type PushContext,
|
||||
type SyncAdapter,
|
||||
} from './sync-adapters'
|
||||
import { fetchRemoteMemoTagKeys, listLocalMemoTagKeys, reconcileMemoTags } from './memo-tag-sync'
|
||||
import { fetchRemoteAudioOwners, isAudioSyncEnabled, listLocalAudioOwners } from './audio-sync'
|
||||
import { SETTINGS_ROW_ID, applyRemoteSettings, fetchRemoteSettings } from './settings-sync'
|
||||
import { applyRemoteBuiltins, editedBuiltinsDifferingFrom, fetchRemoteBuiltins } from './builtin-instruction-sync'
|
||||
import { deleteOrder, upsertOrder } from './sync-registry'
|
||||
import {
|
||||
completeEntry,
|
||||
dropEntry,
|
||||
|
|
@ -42,6 +41,7 @@ import {
|
|||
import {
|
||||
emptyRunResult,
|
||||
isSyncEntity,
|
||||
parentOf,
|
||||
type PushOutcome,
|
||||
type RemoteCursor,
|
||||
type RemoteRow,
|
||||
|
|
@ -62,6 +62,12 @@ const BACKFILL_FLAG = 'backfill:v1'
|
|||
/** 되감은 커서의 id 하한. 행 테이블은 uuid, sync_tombstones는 bigserial이다. */
|
||||
const MIN_UUID = '00000000-0000-0000-0000-000000000000'
|
||||
const MIN_SERIAL = '0'
|
||||
/** 반영을 미룬 행(부모 없음·일시 오류)을 다시 가져와 보는 최대 횟수. 넘으면 포기한다(남의 팀 회의 메모 등). */
|
||||
export const MAX_DEFERRED_ATTEMPTS = 5
|
||||
/** 엔티티마다 기억하는 미룬 행 수 상한 */
|
||||
const MAX_DEFERRED_IDS = 500
|
||||
/** 한 번의 pull에서 다시 가져와 보는 미룬 행 수 */
|
||||
const DEFERRED_RETRY_BATCH = 50
|
||||
|
||||
export interface SyncEngineOptions {
|
||||
remote: SyncRemote
|
||||
|
|
@ -83,6 +89,10 @@ function cursorKey(entity: SyncEntity | 'tombstones'): string {
|
|||
return `cursor:${entity}`
|
||||
}
|
||||
|
||||
function deferredKey(entity: SyncEntity): string {
|
||||
return `deferred:${entity}`
|
||||
}
|
||||
|
||||
function parseCursor(value: string | null): RemoteCursor | null {
|
||||
if (!value) return null
|
||||
try {
|
||||
|
|
@ -93,6 +103,20 @@ function parseCursor(value: string | null): RemoteCursor | null {
|
|||
}
|
||||
}
|
||||
|
||||
function parseDeferred(value: string | null): Map<string, number> {
|
||||
if (!value) return new Map()
|
||||
try {
|
||||
const parsed = JSON.parse(value) as unknown
|
||||
if (typeof parsed !== 'object' || parsed === null) return new Map()
|
||||
const entries = Object.entries(parsed as Record<string, unknown>).filter(
|
||||
(e): e is [string, number] => typeof e[1] === 'number' && Number.isFinite(e[1])
|
||||
)
|
||||
return new Map(entries)
|
||||
} catch {
|
||||
return new Map()
|
||||
}
|
||||
}
|
||||
|
||||
/** 커서를 CURSOR_OVERLAP_MS만큼 되감은 시작점. */
|
||||
function rewind(cursor: RemoteCursor | null, minId: string): RemoteCursor | null {
|
||||
if (!cursor) return null
|
||||
|
|
@ -127,10 +151,16 @@ function rowCursor(row: RemoteRow, column: string): RemoteCursor | null {
|
|||
return { ts, id: String(id) }
|
||||
}
|
||||
|
||||
function errorMessage(err: unknown): string {
|
||||
return err instanceof Error ? err.message : String(err)
|
||||
}
|
||||
|
||||
export class SyncEngine extends EventEmitter {
|
||||
private readonly remote: SyncRemote
|
||||
private readonly userId: string
|
||||
private readonly now: () => number
|
||||
/** 엔진이 만들어질 때 열려 있던 사용자 DB. 다른 DB가 열리면 쓰지 않는다. */
|
||||
private readonly boundDbUserId: string | null
|
||||
/** flush/pull/backfill을 한 줄로 세운다 — 서로 끼어들면 pending 판정이 흔들린다. */
|
||||
private queue: Promise<unknown> = Promise.resolve()
|
||||
private disposed = false
|
||||
|
|
@ -140,6 +170,7 @@ export class SyncEngine extends EventEmitter {
|
|||
this.remote = options.remote
|
||||
this.userId = options.userId
|
||||
this.now = options.now ?? Date.now
|
||||
this.boundDbUserId = getCurrentUserId()
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
|
|
@ -147,18 +178,37 @@ export class SyncEngine extends EventEmitter {
|
|||
this.removeAllListeners()
|
||||
}
|
||||
|
||||
/** 해제되지 않았고, 이 엔진의 사용자 DB가 아직 열려 있는지. */
|
||||
isCurrent(): boolean {
|
||||
return !this.disposed && getCurrentUserId() === this.boundDbUserId
|
||||
}
|
||||
|
||||
/** 진행 중·대기 중인 작업이 끝날 때까지(최대 timeoutMs) 기다린다. 끝났으면 true. */
|
||||
async whenIdle(timeoutMs: number): Promise<boolean> {
|
||||
let timer: NodeJS.Timeout | null = null
|
||||
const timeout = new Promise<boolean>((resolve) => {
|
||||
timer = setTimeout(() => resolve(false), timeoutMs)
|
||||
})
|
||||
try {
|
||||
return await Promise.race([this.queue.then(() => true), timeout])
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
|
||||
getStatus(): SyncStatus {
|
||||
const counts = outboxCounts()
|
||||
const lastPull = getSyncState('lastPullAt')
|
||||
return { ...counts, lastPullAt: lastPull ? Number(lastPull) : null }
|
||||
}
|
||||
|
||||
/** 로그인 직후/복원 직후: 최초 1회 대조 → push → pull. */
|
||||
/** 로그인 직후/복원 직후: 원격 삭제 반영 → 최초 1회 대조 → push → pull. */
|
||||
runFullSync(): Promise<SyncRunResult> {
|
||||
return this.serialize(async () => {
|
||||
const result = emptyRunResult()
|
||||
await this.pullTombstonesBeforePush(result, true)
|
||||
await this.backfillIfNeeded()
|
||||
await this.flushInner(result)
|
||||
await this.flushInner(result, true)
|
||||
await this.pullInner(result)
|
||||
return result
|
||||
})
|
||||
|
|
@ -168,8 +218,10 @@ export class SyncEngine extends EventEmitter {
|
|||
return this.serialize(async () => {
|
||||
if (options.releaseParked) releaseParkedEntries()
|
||||
const result = emptyRunResult()
|
||||
const backfillPending = getSyncState(BACKFILL_FLAG) !== 'done'
|
||||
if (backfillPending) await this.pullTombstonesBeforePush(result, true)
|
||||
await this.backfillIfNeeded()
|
||||
await this.flushInner(result)
|
||||
await this.flushInner(result, backfillPending)
|
||||
return result
|
||||
})
|
||||
}
|
||||
|
|
@ -183,72 +235,80 @@ export class SyncEngine extends EventEmitter {
|
|||
}
|
||||
|
||||
private serialize<T>(task: () => Promise<T>): Promise<T> {
|
||||
const run = this.queue.then(
|
||||
() => task(),
|
||||
() => task()
|
||||
)
|
||||
const guarded = (): Promise<T> => {
|
||||
// 해제 뒤에 줄을 선 작업은 시작하지 않는다(로그아웃 뒤 로컬 모드 DB에 쓰지 않게).
|
||||
if (!this.isCurrent()) return Promise.reject(new SyncAbortedError())
|
||||
return task()
|
||||
}
|
||||
const run = this.queue.then(guarded, guarded)
|
||||
this.queue = run.catch(() => undefined)
|
||||
return run
|
||||
}
|
||||
|
||||
/** await 뒤마다 부른다: 해제됐거나 사용자 DB가 바뀌었으면 중단한다. */
|
||||
private checkpoint(): void {
|
||||
if (!this.isCurrent()) throw new SyncAbortedError()
|
||||
}
|
||||
|
||||
private context(): PushContext {
|
||||
return { remote: this.remote, userId: this.userId, isCurrent: () => this.isCurrent() }
|
||||
}
|
||||
|
||||
// ── 최초 대조 ─────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 이 사용자 DB에서 처음 동기화할 때 한 번: 서버에 없거나 로컬이 더 새로운 행을 outbox에 넣는다.
|
||||
* 서버에 같은 판이 이미 있으면 다시 올리지 않는다(다른 기기의 편집을 덮지 않는다).
|
||||
* 호출 전에 원격 삭제(tombstone)가 반영돼 있어야 한다 — 지운 행을 되살리지 않도록.
|
||||
*/
|
||||
private async backfillIfNeeded(): Promise<void> {
|
||||
if (getSyncState(BACKFILL_FLAG) === 'done') return
|
||||
const now = this.now()
|
||||
let queued = 0
|
||||
const ctx = this.context()
|
||||
const queue: Array<{ entity: SyncEntity; id: string }> = []
|
||||
for (const adapter of TABLE_ADAPTERS) {
|
||||
const local = adapter.listLocalVersions()
|
||||
if (local.length === 0) continue
|
||||
const remoteVersions = await this.fetchRemoteVersions(adapter)
|
||||
this.checkpoint()
|
||||
for (const row of local) {
|
||||
const remoteUpdatedAt = remoteVersions.get(row.id)
|
||||
if (remoteUpdatedAt === undefined || row.updatedAt > remoteUpdatedAt) {
|
||||
enqueueChange(adapter.entity, row.id, 'upsert', now)
|
||||
queued++
|
||||
queue.push({ entity: adapter.entity, id: row.id })
|
||||
}
|
||||
}
|
||||
}
|
||||
const localTags = listLocalMemoTagKeys()
|
||||
if (localTags.size > 0) {
|
||||
const remoteTags = await fetchRemoteMemoTagKeys(this.remote, this.userId)
|
||||
this.checkpoint()
|
||||
for (const key of localTags) {
|
||||
if (!remoteTags.has(key)) {
|
||||
enqueueChange('memo_tags', key, 'upsert', now)
|
||||
queued++
|
||||
}
|
||||
if (!remoteTags.has(key)) queue.push({ entity: 'memo_tags', id: key })
|
||||
}
|
||||
}
|
||||
const ctx: PushContext = { remote: this.remote, userId: this.userId }
|
||||
// 설정: 서버에 행이 없을 때만 로컬 값을 올린다. 있으면 다른 기기의 선택이 우선(pull이 반영).
|
||||
if (!(await fetchRemoteSettings(ctx))) {
|
||||
enqueueChange('user_settings', SETTINGS_ROW_ID, 'upsert', now)
|
||||
queued++
|
||||
}
|
||||
const remoteSettings = await fetchRemoteSettings(ctx)
|
||||
this.checkpoint()
|
||||
if (!remoteSettings) queue.push({ entity: 'user_settings', id: SETTINGS_ROW_ID })
|
||||
// 데스크톱에서 고친 프리셋 프롬프트가 서버와 다르면 올린다(폰은 프리셋을 고칠 수 없다).
|
||||
for (const id of editedBuiltinsDifferingFrom(await fetchRemoteBuiltins(ctx))) {
|
||||
enqueueChange('builtin_instructions', id, 'upsert', now)
|
||||
queued++
|
||||
}
|
||||
const remoteBuiltins = await fetchRemoteBuiltins(ctx)
|
||||
this.checkpoint()
|
||||
for (const id of editedBuiltinsDifferingFrom(remoteBuiltins)) queue.push({ entity: 'builtin_instructions', id })
|
||||
if (isAudioSyncEnabled()) {
|
||||
for (const owner of ['history', 'meeting'] as const) {
|
||||
const local = listLocalAudioOwners(owner)
|
||||
if (local.length === 0) continue
|
||||
const remote = await fetchRemoteAudioOwners(this.remote, this.userId, owner)
|
||||
this.checkpoint()
|
||||
for (const id of local) {
|
||||
if (!remote.has(id)) {
|
||||
enqueueChange(owner === 'history' ? 'history_audio' : 'meeting_audio', id, 'upsert', now)
|
||||
queued++
|
||||
}
|
||||
if (!remote.has(id)) queue.push({ entity: owner === 'history' ? 'history_audio' : 'meeting_audio', id })
|
||||
}
|
||||
}
|
||||
}
|
||||
this.checkpoint()
|
||||
for (const item of queue) enqueueChange(item.entity, item.id, 'upsert', now)
|
||||
setSyncState(BACKFILL_FLAG, 'done', now)
|
||||
logger.info(`Backfill queued ${queued} local change(s) for ${this.userId}`)
|
||||
logger.info(`Backfill queued ${queue.length} local change(s) for ${this.userId}`)
|
||||
}
|
||||
|
||||
private async fetchRemoteVersions(adapter: SyncAdapter): Promise<Map<string, number>> {
|
||||
|
|
@ -264,6 +324,7 @@ export class SyncEngine extends EventEmitter {
|
|||
columns: 'id,updated_at',
|
||||
filters: adapter.pull?.filters,
|
||||
})
|
||||
this.checkpoint()
|
||||
for (const row of page) {
|
||||
if (typeof row.id === 'string' && typeof row.updated_at === 'string') {
|
||||
const at = Date.parse(row.updated_at)
|
||||
|
|
@ -280,10 +341,29 @@ export class SyncEngine extends EventEmitter {
|
|||
|
||||
// ── push ──────────────────────────────────────────────
|
||||
|
||||
private async flushInner(result: SyncRunResult): Promise<void> {
|
||||
/**
|
||||
* push 전에 원격 삭제를 반영한다: 폰에서 지운 행의 로컬 대기 upsert가 서버에 행을 되살리지 않게
|
||||
* outbox 항목을 버리고 로컬 행을 지운다. always=false 면 대기 upsert가 있을 때만 가져온다.
|
||||
*/
|
||||
private async pullTombstonesBeforePush(result: SyncRunResult, always: boolean): Promise<void> {
|
||||
if (!always && !listDueEntries(this.now()).some((e) => e.op === 'upsert')) return
|
||||
const changed = new Set<SyncEntity>(result.changed)
|
||||
try {
|
||||
result.deleted += await this.pullTombstones(changed)
|
||||
} catch (err) {
|
||||
if (err instanceof SyncAbortedError) throw err
|
||||
const message = errorMessage(err)
|
||||
result.errors.push(`tombstones: ${message}`)
|
||||
logger.warn(`Pull tombstones before push failed: ${message}`)
|
||||
}
|
||||
result.changed = [...changed]
|
||||
}
|
||||
|
||||
private async flushInner(result: SyncRunResult, tombstonesPulled: boolean): Promise<void> {
|
||||
if (!tombstonesPulled) await this.pullTombstonesBeforePush(result, false)
|
||||
const due = listDueEntries(this.now())
|
||||
if (due.length === 0) return
|
||||
const ctx: PushContext = { remote: this.remote, userId: this.userId }
|
||||
const ctx = this.context()
|
||||
const byEntity = new Map<SyncEntity, { upserts: OutboxEntry[]; deletes: OutboxEntry[] }>()
|
||||
for (const entry of due) {
|
||||
if (!isSyncEntity(entry.entity)) continue
|
||||
|
|
@ -292,60 +372,22 @@ export class SyncEngine extends EventEmitter {
|
|||
byEntity.set(entry.entity, bucket)
|
||||
}
|
||||
|
||||
const order: SyncEntity[] = [...TABLE_ADAPTERS.map((a) => a.entity), 'memo_tags']
|
||||
// 부모 먼저 upsert: history → … → memo_tags(이력 필요)
|
||||
const upsertOrder: SyncEntity[] = [
|
||||
'history',
|
||||
'dictionary',
|
||||
'meetings',
|
||||
'meeting_memos',
|
||||
'meeting_documents',
|
||||
'custom_instructions',
|
||||
'builtin_instructions',
|
||||
'user_settings',
|
||||
'user_templates',
|
||||
'knowledge_documents',
|
||||
'memo_tags',
|
||||
'history_audio',
|
||||
'meeting_audio',
|
||||
]
|
||||
let networkDown = false
|
||||
for (const entity of upsertOrder) {
|
||||
const bucket = byEntity.get(entity)
|
||||
// 부모 먼저 upsert (순서는 sync-types SYNC_ENTITY_SPECS 가 정본)
|
||||
for (const handler of upsertOrder()) {
|
||||
const bucket = byEntity.get(handler.entity)
|
||||
if (!bucket || bucket.upserts.length === 0 || networkDown) continue
|
||||
const ids = bucket.upserts.map((e) => e.rowId)
|
||||
const outcomes = await this.pushUpserts(ctx, entity, ids)
|
||||
networkDown = this.settle(bucket.upserts, outcomes, result, entity)
|
||||
const outcomes = await handler.push(ctx, bucket.upserts.map((e) => e.rowId))
|
||||
this.checkpoint()
|
||||
networkDown = this.settle(bucket.upserts, outcomes, result, handler.entity)
|
||||
}
|
||||
// 자식 먼저 delete (서버 cascade가 있어도 순서를 지켜 FK 오류를 피한다)
|
||||
for (const entity of [...order].reverse()) {
|
||||
const bucket = byEntity.get(entity)
|
||||
if (!bucket || bucket.deletes.length === 0 || networkDown) continue
|
||||
const ids = bucket.deletes.map((e) => e.rowId)
|
||||
const outcomes =
|
||||
entity === 'memo_tags'
|
||||
? await pushMemoTagRemovals(ctx, ids)
|
||||
: adapterFor(entity)
|
||||
? await this.requireAdapter(entity).pushDeletes(ctx, ids)
|
||||
: ids.map((id) => ({ id, error: null }))
|
||||
networkDown = this.settle(bucket.deletes, outcomes, result, entity)
|
||||
}
|
||||
}
|
||||
|
||||
private async pushUpserts(ctx: PushContext, entity: SyncEntity, ids: string[]): Promise<PushOutcome[]> {
|
||||
switch (entity) {
|
||||
case 'memo_tags':
|
||||
return pushMemoTagAdds(ctx, ids)
|
||||
case 'user_settings':
|
||||
return [{ id: SETTINGS_ROW_ID, error: await pushSettings(ctx) }]
|
||||
case 'builtin_instructions':
|
||||
return pushBuiltinPrompts(ctx, ids)
|
||||
case 'history_audio':
|
||||
return pushAudio(ctx, 'history', ids)
|
||||
case 'meeting_audio':
|
||||
return pushAudio(ctx, 'meeting', ids)
|
||||
default:
|
||||
return this.requireAdapter(entity).push(ctx, ids)
|
||||
for (const handler of deleteOrder()) {
|
||||
const bucket = byEntity.get(handler.entity)
|
||||
if (!bucket || bucket.deletes.length === 0 || networkDown || !handler.pushDeletes) continue
|
||||
const outcomes = await handler.pushDeletes(ctx, bucket.deletes.map((e) => e.rowId))
|
||||
this.checkpoint()
|
||||
networkDown = this.settle(bucket.deletes, outcomes, result, handler.entity)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -371,24 +413,29 @@ export class SyncEngine extends EventEmitter {
|
|||
return networkDown
|
||||
}
|
||||
|
||||
private requireAdapter(entity: SyncEntity): SyncAdapter {
|
||||
const adapter = adapterFor(entity)
|
||||
if (!adapter) throw new Error(`No sync adapter for ${entity}`)
|
||||
return adapter
|
||||
}
|
||||
|
||||
// ── pull ──────────────────────────────────────────────
|
||||
|
||||
private async pullInner(result: SyncRunResult): Promise<void> {
|
||||
const changed = new Set<SyncEntity>(result.changed)
|
||||
/** 이번 pull에서 실패한 엔티티 — 자식은 커서를 넘기지 않도록 건너뛴다. */
|
||||
const failed = new Set<SyncEntity>()
|
||||
for (const adapter of TABLE_ADAPTERS) {
|
||||
if (this.disposed || !adapter.pull) continue
|
||||
this.checkpoint()
|
||||
if (!adapter.pull) continue
|
||||
const parent = parentOf(adapter.entity)
|
||||
if (parent && failed.has(parent)) {
|
||||
failed.add(adapter.entity)
|
||||
logger.info(`Pull ${adapter.entity} skipped: parent ${parent} failed in this run`)
|
||||
continue
|
||||
}
|
||||
try {
|
||||
const applied = await this.pullEntity(adapter)
|
||||
if (applied > 0) changed.add(adapter.entity)
|
||||
result.pulled += applied
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
if (err instanceof SyncAbortedError) throw err
|
||||
failed.add(adapter.entity)
|
||||
const message = errorMessage(err)
|
||||
result.errors.push(`${adapter.entity}: ${message}`)
|
||||
logger.warn(`Pull ${adapter.entity} failed: ${message}`)
|
||||
}
|
||||
|
|
@ -398,60 +445,85 @@ export class SyncEngine extends EventEmitter {
|
|||
const removed = await this.pullTombstones(changed)
|
||||
result.deleted += removed
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
result.errors.push(`tombstones: ${message}`)
|
||||
if (err instanceof SyncAbortedError) throw err
|
||||
const message = errorMessage(err)
|
||||
if (!result.errors.includes(`tombstones: ${message}`)) result.errors.push(`tombstones: ${message}`)
|
||||
logger.warn(`Pull tombstones failed: ${message}`)
|
||||
}
|
||||
|
||||
try {
|
||||
const remoteTags = await fetchRemoteMemoTagKeys(this.remote, this.userId)
|
||||
const tagChanges = reconcileMemoTags(remoteTags, pendingOps('memo_tags'), this.now())
|
||||
if (tagChanges > 0) {
|
||||
changed.add('memo_tags')
|
||||
result.pulled += tagChanges
|
||||
if (!failed.has('history')) {
|
||||
try {
|
||||
const remoteTags = await fetchRemoteMemoTagKeys(this.remote, this.userId)
|
||||
this.checkpoint()
|
||||
const tagChanges = reconcileMemoTags(remoteTags, pendingOps('memo_tags'), this.now())
|
||||
if (tagChanges > 0) {
|
||||
changed.add('memo_tags')
|
||||
result.pulled += tagChanges
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof SyncAbortedError) throw err
|
||||
const message = errorMessage(err)
|
||||
result.errors.push(`memo_tags: ${message}`)
|
||||
logger.warn(`Reconcile memo_tags failed: ${message}`)
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
result.errors.push(`memo_tags: ${message}`)
|
||||
logger.warn(`Reconcile memo_tags failed: ${message}`)
|
||||
}
|
||||
|
||||
try {
|
||||
if (!pendingOps('user_settings').has(SETTINGS_ROW_ID)) {
|
||||
const row = await fetchRemoteSettings({ remote: this.remote, userId: this.userId })
|
||||
const row = await fetchRemoteSettings(this.context())
|
||||
this.checkpoint()
|
||||
if (row && applyRemoteSettings(row)) {
|
||||
changed.add('user_settings')
|
||||
result.pulled++
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
if (err instanceof SyncAbortedError) throw err
|
||||
const message = errorMessage(err)
|
||||
result.errors.push(`user_settings: ${message}`)
|
||||
logger.warn(`Pull user_settings failed: ${message}`)
|
||||
}
|
||||
|
||||
try {
|
||||
const builtins = await fetchRemoteBuiltins({ remote: this.remote, userId: this.userId })
|
||||
const builtins = await fetchRemoteBuiltins(this.context())
|
||||
this.checkpoint()
|
||||
const applied = applyRemoteBuiltins(builtins, new Set(pendingOps('builtin_instructions').keys()))
|
||||
if (applied > 0) {
|
||||
changed.add('custom_instructions')
|
||||
result.pulled += applied
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
if (err instanceof SyncAbortedError) throw err
|
||||
const message = errorMessage(err)
|
||||
result.errors.push(`builtin_instructions: ${message}`)
|
||||
logger.warn(`Pull builtin_instructions failed: ${message}`)
|
||||
}
|
||||
|
||||
this.checkpoint()
|
||||
result.changed = [...changed]
|
||||
if (result.errors.length === 0) setSyncState('lastPullAt', String(this.now()))
|
||||
}
|
||||
|
||||
/** 행 하나 반영. 'deferred'/예외면 false를 돌려주고 호출자가 미룬 목록에 넣는다. */
|
||||
private async applyOne(adapter: SyncAdapter, row: RemoteRow, id: string): Promise<ApplyResult> {
|
||||
this.checkpoint()
|
||||
try {
|
||||
return await adapter.applyRemote(row, this.context())
|
||||
} catch (err) {
|
||||
if (err instanceof SyncAbortedError) throw err
|
||||
logger.warn(`Apply ${adapter.entity}/${id} failed: ${errorMessage(err)}`)
|
||||
return 'deferred'
|
||||
}
|
||||
}
|
||||
|
||||
private async pullEntity(adapter: SyncAdapter): Promise<number> {
|
||||
const deferred = parseDeferred(getSyncState(deferredKey(adapter.entity)))
|
||||
const deferredBefore = JSON.stringify(Object.fromEntries(deferred))
|
||||
let applied = await this.retryDeferred(adapter, deferred)
|
||||
|
||||
const saved = parseCursor(getSyncState(cursorKey(adapter.entity)))
|
||||
let after = rewind(saved, MIN_UUID)
|
||||
let newest = saved
|
||||
let applied = 0
|
||||
let seen = 0
|
||||
for (;;) {
|
||||
const page = await this.remote.fetchPage({
|
||||
|
|
@ -463,6 +535,7 @@ export class SyncEngine extends EventEmitter {
|
|||
columns: adapter.pull?.columns,
|
||||
filters: adapter.pull?.filters,
|
||||
})
|
||||
this.checkpoint()
|
||||
if (page.length === 0) break
|
||||
// 페이지마다 새로 읽는다: 페이지 사이에 사용자가 편집했을 수 있다.
|
||||
const pending = pendingOps(adapter.entity)
|
||||
|
|
@ -470,12 +543,13 @@ export class SyncEngine extends EventEmitter {
|
|||
seen++
|
||||
const id = typeof row.id === 'string' ? row.id : null
|
||||
if (id && !pending.has(id)) {
|
||||
try {
|
||||
if (await adapter.applyRemote(row, { remote: this.remote, userId: this.userId })) applied++
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
`Apply ${adapter.entity}/${id} failed: ${err instanceof Error ? err.message : String(err)}`
|
||||
)
|
||||
const outcome = await this.applyOne(adapter, row, id)
|
||||
if (outcome === 'deferred') {
|
||||
// 커서는 넘기되 id를 기억해 다음 pull에서 다시 가져온다(부모 회의·청크가 늦게 오는 경우).
|
||||
deferred.set(id, deferred.get(id) ?? 0)
|
||||
} else {
|
||||
deferred.delete(id)
|
||||
if (outcome) applied++
|
||||
}
|
||||
}
|
||||
const cursor = rowCursor(row, 'updated_at')
|
||||
|
|
@ -487,10 +561,68 @@ export class SyncEngine extends EventEmitter {
|
|||
if (!next) break
|
||||
after = next
|
||||
}
|
||||
this.checkpoint()
|
||||
this.saveDeferred(adapter.entity, deferred, deferredBefore)
|
||||
if (newest && newest !== saved) setSyncState(cursorKey(adapter.entity), JSON.stringify(newest))
|
||||
return applied
|
||||
}
|
||||
|
||||
/** 지난 pull에서 미룬 행을 id로 다시 가져와 반영해 본다. 반영한 행 수를 돌려준다. */
|
||||
private async retryDeferred(adapter: SyncAdapter, deferred: Map<string, number>): Promise<number> {
|
||||
if (deferred.size === 0) return 0
|
||||
let applied = 0
|
||||
const pending = pendingOps(adapter.entity)
|
||||
const batch = [...deferred.keys()].slice(0, DEFERRED_RETRY_BATCH)
|
||||
for (const id of batch) {
|
||||
if (pending.has(id)) {
|
||||
deferred.delete(id)
|
||||
continue
|
||||
}
|
||||
const attempts = (deferred.get(id) ?? 0) + 1
|
||||
let rows: RemoteRow[]
|
||||
try {
|
||||
rows = await this.remote.selectWhere(
|
||||
adapter.entity,
|
||||
this.userId,
|
||||
[{ column: 'id', op: 'eq', value: id }, ...(adapter.pull?.filters ?? [])],
|
||||
adapter.pull?.columns
|
||||
)
|
||||
} catch (err) {
|
||||
this.checkpoint()
|
||||
logger.warn(`Retry ${adapter.entity}/${id} fetch failed: ${errorMessage(err)}`)
|
||||
if (attempts >= MAX_DEFERRED_ATTEMPTS) deferred.delete(id)
|
||||
else deferred.set(id, attempts)
|
||||
continue
|
||||
}
|
||||
this.checkpoint()
|
||||
const row = rows.find((r) => r.id === id)
|
||||
if (!row) {
|
||||
// 서버에서 지워졌거나 더는 내 행이 아니다.
|
||||
deferred.delete(id)
|
||||
continue
|
||||
}
|
||||
const outcome = await this.applyOne(adapter, row, id)
|
||||
if (outcome === 'deferred') {
|
||||
if (attempts >= MAX_DEFERRED_ATTEMPTS) {
|
||||
deferred.delete(id)
|
||||
logger.info(`Giving up on deferred ${adapter.entity}/${id} after ${attempts} attempt(s)`)
|
||||
} else {
|
||||
deferred.set(id, attempts)
|
||||
}
|
||||
continue
|
||||
}
|
||||
deferred.delete(id)
|
||||
if (outcome) applied++
|
||||
}
|
||||
return applied
|
||||
}
|
||||
|
||||
private saveDeferred(entity: SyncEntity, deferred: Map<string, number>, before: string): void {
|
||||
const trimmed = [...deferred.entries()].slice(-MAX_DEFERRED_IDS)
|
||||
const next = JSON.stringify(Object.fromEntries(trimmed))
|
||||
if (next !== before) setSyncState(deferredKey(entity), next, this.now())
|
||||
}
|
||||
|
||||
/** 다른 기기에서 지운 행을 로컬에서도 지운다. 원격 삭제는 로컬 미전송 변경보다 우선한다. */
|
||||
private async pullTombstones(changed: Set<SyncEntity>): Promise<number> {
|
||||
const saved = parseCursor(getSyncState(cursorKey('tombstones')))
|
||||
|
|
@ -506,6 +638,7 @@ export class SyncEngine extends EventEmitter {
|
|||
limit: PULL_PAGE_SIZE,
|
||||
columns: 'id,table_name,row_id,deleted_at',
|
||||
})
|
||||
this.checkpoint()
|
||||
if (page.length === 0) break
|
||||
for (const row of page) {
|
||||
const entity = row.table_name
|
||||
|
|
@ -521,7 +654,7 @@ export class SyncEngine extends EventEmitter {
|
|||
changed.add(entity)
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn(`Tombstone ${entity}/${rowId} failed: ${err instanceof Error ? err.message : String(err)}`)
|
||||
logger.warn(`Tombstone ${entity}/${rowId} failed: ${errorMessage(err)}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
101
apps/desktop/src/main/services/sync/session-token-store.ts
Normal file
101
apps/desktop/src/main/services/sync/session-token-store.ts
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
// src/main/services/sync/session-token-store.ts
|
||||
// refresh token 영속화(OS 암호화 저장소 + 파일). Supabase·Electron을 직접 알지 않도록 포트로 받는다.
|
||||
//
|
||||
// supabase-js는 메인 프로세스에서 refresh token을 약 1시간마다 회전시킨다. 회전된 토큰을 저장하지 않으면
|
||||
// 재시작 때 이미 쓰인 토큰으로 복원하다 로그아웃된다 — bindAuthEvents 가 회전 이벤트마다 저장한다.
|
||||
|
||||
export interface TokenCipher {
|
||||
isEncryptionAvailable(): boolean
|
||||
encryptString(plain: string): Buffer
|
||||
decryptString(encrypted: Buffer): string
|
||||
}
|
||||
|
||||
export interface TokenFileSystem {
|
||||
existsSync(path: string): boolean
|
||||
readFileSync(path: string): Buffer
|
||||
writeFileSync(path: string, data: Buffer): void
|
||||
unlinkSync(path: string): void
|
||||
}
|
||||
|
||||
export interface SessionTokenStoreLogger {
|
||||
warn(message: string): void
|
||||
}
|
||||
|
||||
export interface SessionTokenStore {
|
||||
load(): string | null
|
||||
save(refreshToken: string): void
|
||||
clear(): void
|
||||
}
|
||||
|
||||
export class EncryptedFileTokenStore implements SessionTokenStore {
|
||||
constructor(
|
||||
private readonly filePath: string,
|
||||
private readonly cipher: TokenCipher,
|
||||
private readonly fs: TokenFileSystem,
|
||||
private readonly logger: SessionTokenStoreLogger
|
||||
) {}
|
||||
|
||||
load(): string | null {
|
||||
try {
|
||||
if (!this.fs.existsSync(this.filePath)) return null
|
||||
if (!this.cipher.isEncryptionAvailable()) return null
|
||||
return this.cipher.decryptString(this.fs.readFileSync(this.filePath))
|
||||
} catch (err) {
|
||||
this.logger.warn(`Token load failed: ${err instanceof Error ? err.message : String(err)}`)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
save(refreshToken: string): void {
|
||||
try {
|
||||
if (!refreshToken) return
|
||||
if (this.cipher.isEncryptionAvailable()) {
|
||||
this.fs.writeFileSync(this.filePath, this.cipher.encryptString(refreshToken))
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.warn(`Token save failed: ${err instanceof Error ? err.message : String(err)}`)
|
||||
}
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
try {
|
||||
if (this.fs.existsSync(this.filePath)) this.fs.unlinkSync(this.filePath)
|
||||
} catch {
|
||||
// 지울 파일이 없거나 잠겨 있어도 로그아웃은 계속한다.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** supabase auth 이벤트 중 세션 회전을 뜻하는 것 */
|
||||
const ROTATION_EVENTS = new Set(['TOKEN_REFRESHED', 'SIGNED_IN', 'USER_UPDATED'])
|
||||
|
||||
export interface AuthSessionLike {
|
||||
refresh_token: string
|
||||
user: { id: string }
|
||||
}
|
||||
|
||||
export type AuthStateSubscribe<S extends AuthSessionLike> = (
|
||||
callback: (event: string, session: S | null) => void
|
||||
) => { unsubscribe(): void }
|
||||
|
||||
/**
|
||||
* auth 상태 이벤트에 붙어 회전된 refresh token을 저장한다.
|
||||
* - 현재 로그인된 사용자(activeUserId)의 세션만 저장한다(로그아웃 뒤 늦게 온 이벤트는 무시).
|
||||
* - SIGNED_OUT 은 무시한다 — 로그아웃 경로가 직접 지운다.
|
||||
* onSession 은 저장 뒤 호출돼 서비스가 메모리 세션(access token)도 갱신하게 한다.
|
||||
*/
|
||||
export function bindAuthEvents<S extends AuthSessionLike>(
|
||||
subscribe: AuthStateSubscribe<S>,
|
||||
store: SessionTokenStore,
|
||||
activeUserId: () => string | null,
|
||||
onSession: (session: S) => void
|
||||
): () => void {
|
||||
const subscription = subscribe((event, session) => {
|
||||
if (!session || !ROTATION_EVENTS.has(event)) return
|
||||
const userId = activeUserId()
|
||||
if (!userId || session.user.id !== userId) return
|
||||
store.save(session.refresh_token)
|
||||
onSession(session)
|
||||
})
|
||||
return () => subscription.unsubscribe()
|
||||
}
|
||||
|
|
@ -9,14 +9,14 @@ import {
|
|||
meetingDocuments,
|
||||
meetingMemos,
|
||||
meetingSessions,
|
||||
memoTags,
|
||||
ragDocuments,
|
||||
} from '../../db/schema'
|
||||
import type { CustomInstruction, DictationTemplate, MeetingDocTemplate, TemplateField } from '@d3ro/core/types'
|
||||
import { getCustomInstructionService } from '../CustomInstructionService'
|
||||
import { getCustomInstructionService, instructionLimitViolation } from '../CustomInstructionService'
|
||||
import { getDictationTemplateService } from '../DictationTemplateService'
|
||||
import { getMeetingDocTemplateService } from '../MeetingDocTemplateService'
|
||||
import { getRAGService } from '../RAGService'
|
||||
import { deleteHistoryLocally } from '../history-deletion'
|
||||
import { purgeRemoteAudio } from './audio-sync'
|
||||
import { pushMeetingSegments } from './transcript-sync'
|
||||
import { dropEntry } from './sync-outbox'
|
||||
|
|
@ -33,8 +33,31 @@ import {
|
|||
export interface PushContext {
|
||||
remote: SyncRemote
|
||||
userId: string
|
||||
/**
|
||||
* 이 작업이 아직 유효한지(엔진이 해제되지 않았고 같은 사용자 DB가 열려 있는지).
|
||||
* 네트워크를 기다린 뒤 로컬에 쓰는 어댑터는 쓰기 직전에 확인한다.
|
||||
*/
|
||||
isCurrent?: () => boolean
|
||||
}
|
||||
|
||||
/** 엔진이 해제됐거나 사용자 DB가 바뀌어 작업을 멈춘다 — 로컬에 아무것도 쓰지 않는다. */
|
||||
export class SyncAbortedError extends Error {
|
||||
constructor() {
|
||||
super('Sync aborted: engine disposed or user DB switched')
|
||||
this.name = 'SyncAbortedError'
|
||||
}
|
||||
}
|
||||
|
||||
export function assertCurrent(ctx: PushContext): void {
|
||||
if (ctx.isCurrent && !ctx.isCurrent()) throw new SyncAbortedError()
|
||||
}
|
||||
|
||||
/**
|
||||
* 원격 행 반영 결과. 'deferred' = 지금은 반영할 수 없지만(부모 회의가 아직 없음, 청크가 아직 없음)
|
||||
* 나중에 다시 시도해야 한다 — 엔진이 id를 기억했다가 다음 pull에서 다시 가져온다.
|
||||
*/
|
||||
export type ApplyResult = boolean | 'deferred'
|
||||
|
||||
export interface LocalVersion {
|
||||
id: string
|
||||
updatedAt: number
|
||||
|
|
@ -48,7 +71,7 @@ export interface SyncAdapter {
|
|||
push(ctx: PushContext, ids: string[]): Promise<PushOutcome[]>
|
||||
pushDeletes(ctx: PushContext, ids: string[]): Promise<PushOutcome[]>
|
||||
/** 원격 행을 로컬에 반영. 반영했으면 true. 자식 행을 더 읽어야 하는 엔티티는 비동기다 */
|
||||
applyRemote(row: RemoteRow, ctx: PushContext): boolean | Promise<boolean>
|
||||
applyRemote(row: RemoteRow, ctx: PushContext): ApplyResult | Promise<ApplyResult>
|
||||
/** 원격 삭제를 로컬에 반영. 지운 행이 있으면 true */
|
||||
deleteLocal(id: string): boolean
|
||||
}
|
||||
|
|
@ -192,7 +215,8 @@ export function historyToRemote(r: typeof history.$inferSelect, userId: string):
|
|||
summary_text: r.summaryText,
|
||||
is_favorite: r.isFavorite,
|
||||
created_at: iso(r.createdAt),
|
||||
updated_at: iso(r.updatedAt),
|
||||
// updated_at 은 보내지 않는다: 서버 시각(INSERT 기본값·트리거)이 keyset 커서의 기준이다.
|
||||
// 기기 시계·오프라인 편집 시각을 실으면 다른 기기의 커서 뒤로 들어가 영영 내려받히지 않는다.
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -252,9 +276,8 @@ const historyAdapter: SyncAdapter = {
|
|||
return true
|
||||
},
|
||||
deleteLocal(id) {
|
||||
const db = getDatabase()
|
||||
db.delete(memoTags).where(eq(memoTags.historyId, id)).run()
|
||||
return db.delete(history).where(eq(history.id, id)).run().changes > 0
|
||||
// 녹음 WAV까지 함께 지운다(폰에서 지운 기록의 목소리가 디스크에 남지 않게).
|
||||
return deleteHistoryLocally({ ids: [id] }).deleted > 0
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -272,7 +295,6 @@ export function dictionaryToRemote(r: typeof dictionary.$inferSelect, userId: st
|
|||
usage_count: Math.round(r.usageCount),
|
||||
last_used_at: nullableIso(r.lastUsedAt),
|
||||
created_at: iso(r.createdAt),
|
||||
updated_at: iso(r.updatedAt),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -413,7 +435,6 @@ export function meetingToRemote(r: typeof meetingSessions.$inferSelect, userId:
|
|||
llm_latency_ms: int(r.llmLatencyMs),
|
||||
error_message: r.errorMessage,
|
||||
created_at: iso(r.createdAt),
|
||||
updated_at: iso(r.updatedAt),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -508,7 +529,6 @@ export function meetingMemoToRemote(r: typeof meetingMemos.$inferSelect, userId:
|
|||
content: r.content,
|
||||
timestamp_ms: Math.round(r.timestampMs),
|
||||
created_at: iso(r.createdAt),
|
||||
updated_at: iso(r.updatedAt ?? r.createdAt),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -540,8 +560,10 @@ const meetingMemosAdapter: SyncAdapter = {
|
|||
const id = row.id
|
||||
const meetingId = row.meeting_id
|
||||
const content = str(row.content)
|
||||
// 남의 팀 회의에 내가 단 메모는 부모 회의가 로컬에 없다 — 고아 행을 만들지 않는다.
|
||||
if (!isUuid(id) || !isUuid(meetingId) || content === null || !hasLocalMeeting(meetingId)) return false
|
||||
if (!isUuid(id) || !isUuid(meetingId) || content === null) return false
|
||||
// 부모 회의가 아직 로컬에 없으면 고아 행을 만들지 않고 미룬다(회의 pull이 실패했을 수 있다).
|
||||
// 남의 팀 회의에 단 내 메모는 끝내 부모가 오지 않는다 — 엔진이 몇 번 재시도 후 포기한다.
|
||||
if (!hasLocalMeeting(meetingId)) return 'deferred'
|
||||
const createdAt = ms(row.created_at, Date.now())
|
||||
const values = {
|
||||
sessionId: meetingId,
|
||||
|
|
@ -577,7 +599,6 @@ export function meetingDocumentToRemote(r: typeof meetingDocuments.$inferSelect,
|
|||
llm_model: r.llmModel,
|
||||
llm_latency_ms: int(r.llmLatencyMs),
|
||||
created_at: iso(r.createdAt),
|
||||
updated_at: iso(r.updatedAt),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -610,7 +631,7 @@ const meetingDocumentsAdapter: SyncAdapter = {
|
|||
const templateType = oneOf(row.template_type, DOCUMENT_TYPES)
|
||||
const title = str(row.title)
|
||||
if (!isUuid(id) || !isUuid(meetingId) || templateType === null || title === null) return false
|
||||
if (!hasLocalMeeting(meetingId)) return false
|
||||
if (!hasLocalMeeting(meetingId)) return 'deferred'
|
||||
const updatedAt = ms(row.updated_at, Date.now())
|
||||
const values = {
|
||||
sessionId: meetingId,
|
||||
|
|
@ -637,13 +658,24 @@ const meetingDocumentsAdapter: SyncAdapter = {
|
|||
// ── custom_instructions ─────────────────────────────────
|
||||
// 데스크톱 프리셋(builtin-*)과 서버 프리셋(builtin_key)은 각자 따로 있다. 사용자가 만든 명령만 맞춘다.
|
||||
|
||||
/**
|
||||
* 서버 CHECK(이름 80·설명 240·프롬프트 4000자)를 넘는 명령은 잘라 보내지 않는다 — 잘린 사본이 다음 pull에서
|
||||
* 원본을 덮는다. 재시도 불가 오류로 돌려 outbox가 보관(parked)하게 한다.
|
||||
*/
|
||||
export function instructionPushError(i: CustomInstruction): SyncRemoteError | null {
|
||||
const field = instructionLimitViolation(i)
|
||||
return field === null
|
||||
? null
|
||||
: new SyncRemoteError(`custom instruction ${field} exceeds the server length limit`, '22001', false)
|
||||
}
|
||||
|
||||
export function instructionToRemote(i: CustomInstruction, userId: string): RemoteRow {
|
||||
return {
|
||||
id: i.id,
|
||||
user_id: userId,
|
||||
name: i.name.trim().slice(0, 80),
|
||||
description: (i.description ?? '').trim().slice(0, 240),
|
||||
prompt: i.prompt.trim().slice(0, 4000),
|
||||
name: i.name.trim(),
|
||||
description: (i.description ?? '').trim(),
|
||||
prompt: i.prompt,
|
||||
icon: (i.icon || 'sparkles').slice(0, 32),
|
||||
sort_order: Math.max(0, Math.round(i.order)),
|
||||
created_at: iso(i.createdAt),
|
||||
|
|
@ -665,12 +697,19 @@ const customInstructionsAdapter: SyncAdapter = {
|
|||
.map((id) => service.getById(id))
|
||||
.filter((i): i is CustomInstruction => i !== null && !i.isBuiltin && isUuid(i.id))
|
||||
const found = new Set(items.map((i) => i.id))
|
||||
const tooLong: PushOutcome[] = []
|
||||
const sendable: CustomInstruction[] = []
|
||||
for (const item of items) {
|
||||
const error = instructionPushError(item)
|
||||
if (error) tooLong.push({ id: item.id, error })
|
||||
else sendable.push(item)
|
||||
}
|
||||
const outcomes = await upsertIsolated(
|
||||
ctx,
|
||||
'custom_instructions',
|
||||
items.map((i) => ({ id: i.id, row: instructionToRemote(i, ctx.userId) }))
|
||||
sendable.map((i) => ({ id: i.id, row: instructionToRemote(i, ctx.userId) }))
|
||||
)
|
||||
return [...outcomes, ...missingAsDone(ids, found)]
|
||||
return [...outcomes, ...tooLong, ...missingAsDone(ids, found)]
|
||||
},
|
||||
pushDeletes(ctx, ids) {
|
||||
return deleteRemote(ctx, 'custom_instructions', ids)
|
||||
|
|
@ -767,6 +806,15 @@ export function templateToRpcParams(local: LocalTemplate): Record<string, unknow
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* sync_upsert_user_template_v1 의 P0002(template_not_found)는 "이 id의 템플릿이 다른 계정 소유"라는 뜻이다.
|
||||
* 다시 보내도 같은 결과라 재시도 불가로 바꿔 outbox가 보관하게 한다(전역 P0002는 재시도 가능으로 남긴다).
|
||||
*/
|
||||
export function templateUpsertError(err: unknown): SyncRemoteError {
|
||||
const error = toSyncRemoteError(err)
|
||||
return error.code === 'P0002' ? new SyncRemoteError(error.message, error.code, false) : error
|
||||
}
|
||||
|
||||
const userTemplatesAdapter: SyncAdapter = {
|
||||
entity: 'user_templates',
|
||||
pull: { filters: [{ column: 'is_builtin', op: 'eq', value: false }] },
|
||||
|
|
@ -789,7 +837,7 @@ const userTemplatesAdapter: SyncAdapter = {
|
|||
await ctx.remote.rpc('sync_upsert_user_template_v1', templateToRpcParams(local))
|
||||
outcomes.push({ id, error: null })
|
||||
} catch (err) {
|
||||
outcomes.push({ id, error: toSyncRemoteError(err) })
|
||||
outcomes.push({ id, error: templateUpsertError(err) })
|
||||
}
|
||||
}
|
||||
return outcomes
|
||||
|
|
@ -932,7 +980,11 @@ const knowledgeAdapter: SyncAdapter = {
|
|||
const exists = getDatabase().select({ id: ragDocuments.id }).from(ragDocuments).where(eq(ragDocuments.id, id)).get()
|
||||
if (exists) return false
|
||||
const chunkRows = await ctx.remote.selectChildren('knowledge_chunks', 'document_id', id, 'chunk_index,content', 'chunk_index')
|
||||
// 네트워크를 기다리는 사이 로그아웃·계정 전환이 있었으면 다른 사용자 DB에 쓰지 않는다.
|
||||
assertCurrent(ctx)
|
||||
const chunks = chunkRows.map((c) => str(c.content)).filter((c): c is string => c !== null)
|
||||
// 다른 기기가 문서 행을 올리고 청크를 아직 못 올렸을 수 있다 — 다음 pull에서 다시 본다.
|
||||
if (chunks.length === 0) return 'deferred'
|
||||
return getRAGService().applyRemoteDocument({
|
||||
id,
|
||||
fileName: str(row.file_name) ?? str(row.title) ?? 'document',
|
||||
|
|
|
|||
69
apps/desktop/src/main/services/sync/sync-registry.ts
Normal file
69
apps/desktop/src/main/services/sync/sync-registry.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
// src/main/services/sync/sync-registry.ts
|
||||
// 엔티티별 push 핸들러 레지스트리. 순서·부모·Realtime 여부는 sync-types.ts 의 SYNC_ENTITY_SPECS 가 정본이다.
|
||||
// 엔진은 이 레지스트리만 돌며 push/delete 한다 — 엔티티를 추가해도 엔진의 switch·목록을 고치지 않는다(OCP).
|
||||
//
|
||||
// sync-adapters 가 memo-tag-sync 등을 런타임 import 하므로, 순환을 피하려고 레지스트리는 별도 파일에 둔다.
|
||||
|
||||
import { adapterFor, type PushContext } from './sync-adapters'
|
||||
import { pushMemoTagAdds, pushMemoTagRemovals } from './memo-tag-sync'
|
||||
import { SETTINGS_ROW_ID, pushSettings } from './settings-sync'
|
||||
import { pushBuiltinPrompts } from './builtin-instruction-sync'
|
||||
import { pushAudio } from './audio-sync'
|
||||
import { SYNC_ENTITY_SPECS, type PushOutcome, type SyncEntity, type SyncEntitySpec } from './sync-types'
|
||||
|
||||
export interface SyncPushHandler {
|
||||
push(ctx: PushContext, ids: string[]): Promise<PushOutcome[]>
|
||||
/** 없으면 삭제를 서버로 보내지 않는다(삭제 대기 항목은 그대로 남는다). */
|
||||
pushDeletes?(ctx: PushContext, ids: string[]): Promise<PushOutcome[]>
|
||||
}
|
||||
|
||||
export type SyncRegistryEntry = SyncEntitySpec & SyncPushHandler
|
||||
|
||||
function tableHandler(entity: SyncEntity): SyncPushHandler {
|
||||
return {
|
||||
push: (ctx, ids) => requireAdapter(entity).push(ctx, ids),
|
||||
pushDeletes: (ctx, ids) => requireAdapter(entity).pushDeletes(ctx, ids),
|
||||
}
|
||||
}
|
||||
|
||||
function requireAdapter(entity: SyncEntity): NonNullable<ReturnType<typeof adapterFor>> {
|
||||
const adapter = adapterFor(entity)
|
||||
if (!adapter) throw new Error(`No sync adapter for ${entity}`)
|
||||
return adapter
|
||||
}
|
||||
|
||||
/** Record 로 두어 엔티티가 빠지면 컴파일 오류가 난다. */
|
||||
const HANDLERS: Record<SyncEntity, SyncPushHandler> = {
|
||||
history: tableHandler('history'),
|
||||
dictionary: tableHandler('dictionary'),
|
||||
meetings: tableHandler('meetings'),
|
||||
meeting_memos: tableHandler('meeting_memos'),
|
||||
meeting_documents: tableHandler('meeting_documents'),
|
||||
custom_instructions: tableHandler('custom_instructions'),
|
||||
user_templates: tableHandler('user_templates'),
|
||||
knowledge_documents: tableHandler('knowledge_documents'),
|
||||
builtin_instructions: { push: (ctx, ids) => pushBuiltinPrompts(ctx, ids) },
|
||||
user_settings: { push: async (ctx) => [{ id: SETTINGS_ROW_ID, error: await pushSettings(ctx) }] },
|
||||
memo_tags: {
|
||||
push: (ctx, ids) => pushMemoTagAdds(ctx, ids),
|
||||
pushDeletes: (ctx, ids) => pushMemoTagRemovals(ctx, ids),
|
||||
},
|
||||
history_audio: { push: (ctx, ids) => pushAudio(ctx, 'history', ids) },
|
||||
meeting_audio: { push: (ctx, ids) => pushAudio(ctx, 'meeting', ids) },
|
||||
}
|
||||
|
||||
/** push 순서(부모 먼저)로 정렬된 레지스트리. */
|
||||
export const SYNC_REGISTRY: readonly SyncRegistryEntry[] = SYNC_ENTITY_SPECS.map((spec) => ({
|
||||
...(spec as SyncEntitySpec),
|
||||
...HANDLERS[spec.entity],
|
||||
}))
|
||||
|
||||
/** upsert 순서: 부모 → 자식 */
|
||||
export function upsertOrder(): readonly SyncRegistryEntry[] {
|
||||
return SYNC_REGISTRY
|
||||
}
|
||||
|
||||
/** delete 순서: 자식 → 부모. 서버로 삭제를 보내는 엔티티만. */
|
||||
export function deleteOrder(): readonly SyncRegistryEntry[] {
|
||||
return [...SYNC_REGISTRY].reverse().filter((entry) => entry.pushDeletes !== undefined)
|
||||
}
|
||||
65
apps/desktop/src/main/services/sync/sync-scheduler.ts
Normal file
65
apps/desktop/src/main/services/sync/sync-scheduler.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
// src/main/services/sync/sync-scheduler.ts
|
||||
// 동기화 트리거 타이머: 로컬 변경 push 디바운스, Realtime pull 디바운스, 주기 heartbeat.
|
||||
// 무엇을 실행할지는 콜백으로 받는다 — 타이머만 따로 가짜 시계로 시험할 수 있다.
|
||||
|
||||
export type ScheduledRunKind = 'flush' | 'pull'
|
||||
|
||||
export interface SyncSchedulerOptions {
|
||||
flushDelayMs: number
|
||||
pullDelayMs: number
|
||||
heartbeatMs: number
|
||||
/** 디바운스가 끝나면 부른다 */
|
||||
run: (kind: ScheduledRunKind) => void
|
||||
/** heartbeat 주기마다 부른다(기기 점검·Realtime 되살리기·전체 동기화는 서비스가 정한다) */
|
||||
onHeartbeat: () => void
|
||||
}
|
||||
|
||||
export class SyncScheduler {
|
||||
private flushTimer: ReturnType<typeof setTimeout> | null = null
|
||||
private pullTimer: ReturnType<typeof setTimeout> | null = null
|
||||
private heartbeatTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
constructor(private readonly options: SyncSchedulerOptions) {}
|
||||
|
||||
/** 이미 예약돼 있으면 아무것도 하지 않는다(여러 변경을 한 번에 보낸다). */
|
||||
scheduleFlush(delayMs = this.options.flushDelayMs): void {
|
||||
if (this.flushTimer) return
|
||||
this.flushTimer = setTimeout(() => {
|
||||
this.flushTimer = null
|
||||
this.options.run('flush')
|
||||
}, delayMs)
|
||||
}
|
||||
|
||||
schedulePull(delayMs = this.options.pullDelayMs): void {
|
||||
if (this.pullTimer) return
|
||||
this.pullTimer = setTimeout(() => {
|
||||
this.pullTimer = null
|
||||
this.options.run('pull')
|
||||
}, delayMs)
|
||||
}
|
||||
|
||||
startHeartbeat(): void {
|
||||
this.stop()
|
||||
this.heartbeatTimer = setInterval(() => this.options.onHeartbeat(), this.options.heartbeatMs)
|
||||
}
|
||||
|
||||
/** heartbeat와 대기 중인 디바운스를 모두 멈춘다. */
|
||||
stop(): 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
|
||||
}
|
||||
}
|
||||
|
||||
isHeartbeatRunning(): boolean {
|
||||
return this.heartbeatTimer !== null
|
||||
}
|
||||
}
|
||||
|
|
@ -1,27 +1,54 @@
|
|||
// src/main/services/sync/sync-types.ts
|
||||
// 기기 간 동기화 공통 타입. 데스크톱 SQLite ↔ Supabase(모바일·웹이 직접 읽고 쓰는 정본).
|
||||
|
||||
/** 동기화 대상. 값은 Supabase 테이블 이름과 같고 sync_tombstones.table_name 과도 같다. */
|
||||
export const SYNC_ENTITIES = [
|
||||
'history',
|
||||
'dictionary',
|
||||
'meetings',
|
||||
'meeting_memos',
|
||||
'meeting_documents',
|
||||
'memo_tags',
|
||||
'custom_instructions',
|
||||
'user_templates',
|
||||
'knowledge_documents',
|
||||
/** 단일 행(row_id 'self'): 언어·테마·자동 다듬기·활성 명령 */
|
||||
'user_settings',
|
||||
/** 녹음 파일 업로드(row_id = history id / meeting id) */
|
||||
'history_audio',
|
||||
'meeting_audio',
|
||||
/**
|
||||
* 동기화 대상 명세 — 엔티티 목록·push 순서·부모 관계·Realtime 감시 여부의 단일 정본(SSOT).
|
||||
* 값은 Supabase 테이블 이름과 같고 sync_tombstones.table_name 과도 같다.
|
||||
*
|
||||
* 배열 순서 = push(upsert) 순서: 부모가 자식보다 먼저 온다. 삭제는 이 순서의 역순(자식 먼저).
|
||||
* 새 엔티티는 여기 한 줄과 sync-registry.ts 의 핸들러 한 개만 추가한다(누락은 타입 오류).
|
||||
*/
|
||||
export const SYNC_ENTITY_SPECS = [
|
||||
{ entity: 'history', realtime: true },
|
||||
{ entity: 'dictionary', realtime: true },
|
||||
{ entity: 'meetings', realtime: true },
|
||||
{ entity: 'meeting_memos', realtime: true, parent: 'meetings' },
|
||||
{ entity: 'meeting_documents', realtime: true, parent: 'meetings' },
|
||||
{ entity: 'custom_instructions', realtime: true },
|
||||
/** 데스크톱 프리셋 명령 프롬프트(row_id = 데스크톱 프리셋 id) → 서버 프리셋 행 */
|
||||
'builtin_instructions',
|
||||
] as const
|
||||
{ entity: 'builtin_instructions', realtime: false },
|
||||
/** 단일 행(row_id 'self'): 언어·테마·자동 다듬기·활성 명령 */
|
||||
{ entity: 'user_settings', realtime: true },
|
||||
{ entity: 'user_templates', realtime: true },
|
||||
{ entity: 'knowledge_documents', realtime: true },
|
||||
{ entity: 'memo_tags', realtime: true, parent: 'history' },
|
||||
/** 녹음 파일 업로드(row_id = history id / meeting id) */
|
||||
{ entity: 'history_audio', realtime: false, parent: 'history' },
|
||||
{ entity: 'meeting_audio', realtime: false, parent: 'meetings' },
|
||||
] as const satisfies ReadonlyArray<{ entity: string; realtime: boolean; parent?: string }>
|
||||
|
||||
export type SyncEntity = (typeof SYNC_ENTITIES)[number]
|
||||
export type SyncEntity = (typeof SYNC_ENTITY_SPECS)[number]['entity']
|
||||
|
||||
export interface SyncEntitySpec {
|
||||
entity: SyncEntity
|
||||
/** Realtime postgres_changes 로 감시하는 테이블인지 */
|
||||
realtime: boolean
|
||||
/** 먼저 반영돼야 하는 부모 엔티티 */
|
||||
parent?: SyncEntity
|
||||
}
|
||||
|
||||
export const SYNC_ENTITIES: readonly SyncEntity[] = SYNC_ENTITY_SPECS.map((s) => s.entity)
|
||||
|
||||
/** 엔티티의 부모. 없으면 null */
|
||||
export function parentOf(entity: SyncEntity): SyncEntity | null {
|
||||
const spec: SyncEntitySpec | undefined = SYNC_ENTITY_SPECS.find((s) => s.entity === entity)
|
||||
return spec?.parent ?? null
|
||||
}
|
||||
|
||||
/** Realtime 변경 감시 대상 테이블. sync_tombstones = 다른 기기의 삭제 */
|
||||
export function realtimeTables(): string[] {
|
||||
return [...SYNC_ENTITY_SPECS.filter((s) => s.realtime).map((s) => s.entity), 'sync_tombstones']
|
||||
}
|
||||
|
||||
export type SyncOp = 'upsert' | 'delete'
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
// src/main/windows/TrayManager.ts
|
||||
|
||||
import { Tray, Menu, app, nativeImage } from 'electron'
|
||||
import { getMainWindow } from './WindowManager'
|
||||
import { showMainWindow } from './WindowManager'
|
||||
import { getLogger } from '../services/LoggerService'
|
||||
import { getAppIconPath } from '../utils/paths'
|
||||
import { setIsQuitting } from '../lifecycle'
|
||||
|
|
@ -20,11 +20,7 @@ export function createTray(): void {
|
|||
{
|
||||
label: '표시',
|
||||
click: () => {
|
||||
const mainWindow = getMainWindow()
|
||||
if (mainWindow) {
|
||||
mainWindow.show()
|
||||
mainWindow.focus()
|
||||
}
|
||||
showMainWindow()
|
||||
}
|
||||
},
|
||||
{ type: 'separator' },
|
||||
|
|
@ -41,11 +37,7 @@ export function createTray(): void {
|
|||
tray.setContextMenu(contextMenu)
|
||||
|
||||
tray.on('double-click', () => {
|
||||
const mainWindow = getMainWindow()
|
||||
if (mainWindow) {
|
||||
mainWindow.show()
|
||||
mainWindow.focus()
|
||||
}
|
||||
showMainWindow()
|
||||
})
|
||||
|
||||
logger.info('Tray created')
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
// src/main/windows/WindowManager.ts
|
||||
// 설계서 01 WindowManagerService: 메인 윈도우 + 팝업 프리로딩 + 2-phase 리사이즈
|
||||
|
||||
import { BrowserWindow, shell, screen, ipcMain, Menu, clipboard } from 'electron'
|
||||
import { app, BrowserWindow, screen, ipcMain, Menu, clipboard } from 'electron'
|
||||
import { join } from 'path'
|
||||
import { is } from '@electron-toolkit/utils'
|
||||
import { WINDOW_SIZE } from '@d3ro/core/constants'
|
||||
|
|
@ -15,7 +15,8 @@ import {
|
|||
type KeyBindingActionId
|
||||
} from '@d3ro/core/keybinding'
|
||||
import { getLogger } from '../services/LoggerService'
|
||||
import { getIsQuitting } from '../lifecycle'
|
||||
import { getIsQuitting, setIsQuitting } from '../lifecycle'
|
||||
import { hardenWebContents } from './web-contents-hardening'
|
||||
import { configGet, configSet } from '../services/ConfigService'
|
||||
import { buildPopupThemeCss } from '@d3ro/ui/theme-vars'
|
||||
import { getI18n } from '@d3ro/i18n'
|
||||
|
|
@ -269,6 +270,25 @@ export function getMainWindow(): BrowserWindow | null {
|
|||
return mainWindow
|
||||
}
|
||||
|
||||
/** 부트스트랩이 메인 창을 한 번이라도 만들었는지 — 그 전에는 다시 만들지 않는다(중복 창 방지). */
|
||||
let mainWindowCreated = false
|
||||
|
||||
/**
|
||||
* 메인 창을 앞으로 가져온다. 이미 한 번 만들어진 뒤 닫혀 없으면 다시 만든다(트레이 '표시'·두 번째 실행).
|
||||
* 부트스트랩 전이면 null.
|
||||
*/
|
||||
export function showMainWindow(): BrowserWindow | null {
|
||||
let win = mainWindow && !mainWindow.isDestroyed() ? mainWindow : null
|
||||
if (!win) {
|
||||
if (!mainWindowCreated) return null
|
||||
win = createMainWindow()
|
||||
}
|
||||
if (win.isMinimized()) win.restore()
|
||||
win.show()
|
||||
win.focus()
|
||||
return win
|
||||
}
|
||||
|
||||
export function createMainWindow(): BrowserWindow {
|
||||
const primaryDisplay = screen.getPrimaryDisplay()
|
||||
const { width: screenWidth, height: screenHeight } = primaryDisplay.workAreaSize
|
||||
|
|
@ -302,6 +322,7 @@ export function createMainWindow(): BrowserWindow {
|
|||
}
|
||||
})
|
||||
|
||||
mainWindowCreated = true
|
||||
mainWindow.setSkipTaskbar(false)
|
||||
|
||||
// Alt 키로 메뉴 활성화 방지: 메뉴 완전 제거
|
||||
|
|
@ -320,11 +341,18 @@ export function createMainWindow(): BrowserWindow {
|
|||
})
|
||||
|
||||
mainWindow.on('close', (event) => {
|
||||
if (!getIsQuitting() && configGet('closeToTray')) {
|
||||
if (getIsQuitting()) return
|
||||
if (configGet('closeToTray')) {
|
||||
event.preventDefault()
|
||||
mainWindow?.hide()
|
||||
logger.info('Main window hidden to tray')
|
||||
return
|
||||
}
|
||||
// 트레이로 숨기지 않는 설정이면 창 닫기 = 앱 종료. 숨은 팝업이 남아 있어 window-all-closed가
|
||||
// 오지 않으므로, 그대로 두면 다시 열 수 없는 창 없는 프로세스가 남는다.
|
||||
setIsQuitting(true)
|
||||
logger.info('Main window closed with closeToTray=false — quitting')
|
||||
app.quit()
|
||||
})
|
||||
|
||||
mainWindow.on('closed', () => {
|
||||
|
|
@ -335,10 +363,8 @@ export function createMainWindow(): BrowserWindow {
|
|||
logger.info(`[Renderer] [${level}] ${message} (${sourceId}:${line})`)
|
||||
})
|
||||
|
||||
mainWindow.webContents.setWindowOpenHandler((details) => {
|
||||
shell.openExternal(details.url)
|
||||
return { action: 'deny' }
|
||||
})
|
||||
// 앱 밖 링크는 창 안에서 열지 않고(원격 페이지가 preload API를 얻지 못하게) 허용 scheme만 브라우저로 넘긴다.
|
||||
hardenWebContents(mainWindow)
|
||||
|
||||
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
|
||||
mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL'])
|
||||
|
|
@ -384,6 +410,7 @@ function createRecordingTipWindow(): BrowserWindow {
|
|||
backgroundThrottling: false
|
||||
}
|
||||
})
|
||||
hardenWebContents(win)
|
||||
|
||||
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
|
||||
win.loadURL(`${process.env['ELECTRON_RENDERER_URL']}/popups/recording-tip/index.html`)
|
||||
|
|
@ -496,6 +523,7 @@ function createResultPopupWindow(): BrowserWindow {
|
|||
nodeIntegration: false
|
||||
}
|
||||
})
|
||||
hardenWebContents(win)
|
||||
|
||||
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
|
||||
win.loadURL(`${process.env['ELECTRON_RENDERER_URL']}/popups/result-popup/index.html`)
|
||||
|
|
@ -579,6 +607,7 @@ function createHistoryPopupWindow(): BrowserWindow {
|
|||
nodeIntegration: false
|
||||
}
|
||||
})
|
||||
hardenWebContents(win)
|
||||
|
||||
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
|
||||
win.loadURL(`${process.env['ELECTRON_RENDERER_URL']}/popups/history-popup/index.html`)
|
||||
|
|
@ -671,6 +700,7 @@ function createCommandPopupWindow(): BrowserWindow {
|
|||
nodeIntegration: false
|
||||
}
|
||||
})
|
||||
hardenWebContents(win)
|
||||
|
||||
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
|
||||
win.loadURL(`${process.env['ELECTRON_RENDERER_URL']}/popups/command-popup/index.html`)
|
||||
|
|
@ -781,6 +811,7 @@ function createCaptionOverlayWindow(): BrowserWindow {
|
|||
nodeIntegration: false
|
||||
}
|
||||
})
|
||||
hardenWebContents(win)
|
||||
|
||||
// 클릭 통과: 마우스 이벤트를 무시하되, CSS hover 등을 위해 forward 활성화
|
||||
win.setIgnoreMouseEvents(true, { forward: true })
|
||||
|
|
@ -936,6 +967,7 @@ function createSuggestionOverlayWindow(): BrowserWindow {
|
|||
backgroundThrottling: false
|
||||
}
|
||||
})
|
||||
hardenWebContents(win)
|
||||
|
||||
applySuggestionOverlayMouseMode(win)
|
||||
|
||||
|
|
|
|||
75
apps/desktop/src/main/windows/web-contents-hardening.ts
Normal file
75
apps/desktop/src/main/windows/web-contents-hardening.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
// src/main/windows/web-contents-hardening.ts
|
||||
// 외부 URL·내비게이션 정책(@d3ro/core/url-policy)의 Electron 어댑터.
|
||||
// - 앱 창은 앱 오리진 밖으로 이동하지 않는다(원격 페이지가 preload electronAPI를 얻지 못하게).
|
||||
// - 새 창 요청은 허용 scheme일 때만 OS 브라우저로 넘긴다.
|
||||
// - 모든 shell.openExternal 호출은 openExternalSafe 를 거친다.
|
||||
|
||||
import { shell, type BrowserWindow, type IpcMainInvokeEvent } from 'electron'
|
||||
import { isAllowedExternalUrl, isAppOrigin, type ExternalUrlPolicyOptions } from '@d3ro/core/url-policy'
|
||||
import { getLogger } from '../services/LoggerService'
|
||||
|
||||
const logger = getLogger('WebContentsHardening')
|
||||
|
||||
/** 앱 렌더러가 올라오는 오리진: 배포본 file://, 개발 서버(ELECTRON_RENDERER_URL). */
|
||||
export function appOrigins(): string[] {
|
||||
const origins = ['file://']
|
||||
const devUrl = process.env['ELECTRON_RENDERER_URL']
|
||||
if (devUrl) {
|
||||
try {
|
||||
origins.push(new URL(devUrl).origin)
|
||||
} catch {
|
||||
// 잘못된 개발 URL은 무시 — file:// 만 허용한다.
|
||||
}
|
||||
}
|
||||
return origins
|
||||
}
|
||||
|
||||
/** 로그에 토큰·쿼리가 남지 않도록 scheme + host 만 남긴다. */
|
||||
function redact(url: string): string {
|
||||
try {
|
||||
const parsed = new URL(url)
|
||||
return `${parsed.protocol}//${parsed.host}`
|
||||
} catch {
|
||||
return '(unparseable)'
|
||||
}
|
||||
}
|
||||
|
||||
/** 허용된 URL만 OS 기본 핸들러로 연다. 열었으면 true. */
|
||||
export async function openExternalSafe(url: string, options: ExternalUrlPolicyOptions = {}): Promise<boolean> {
|
||||
if (!isAllowedExternalUrl(url, options)) {
|
||||
logger.warn(`Blocked external URL: ${redact(url)}`)
|
||||
return false
|
||||
}
|
||||
await shell.openExternal(url)
|
||||
return true
|
||||
}
|
||||
|
||||
/** IPC 호출자가 앱 자신의 페이지인지(원격 페이지가 특권 IPC를 쓰지 못하게). */
|
||||
export function isTrustedIpcSender(event: Pick<IpcMainInvokeEvent, 'senderFrame'>): boolean {
|
||||
const url = event.senderFrame?.url
|
||||
if (typeof url !== 'string') return false
|
||||
return isAppOrigin(url, appOrigins())
|
||||
}
|
||||
|
||||
/** 창의 webContents에 내비게이션·새 창·webview 가드를 건다. */
|
||||
export function hardenWebContents(win: BrowserWindow): void {
|
||||
const contents = win.webContents
|
||||
const guardNavigation = (event: { preventDefault: () => void }, url: string): void => {
|
||||
if (isAppOrigin(url, appOrigins())) return
|
||||
event.preventDefault()
|
||||
void openExternalSafe(url).catch((err: unknown) => {
|
||||
logger.warn(`openExternal failed: ${err instanceof Error ? err.message : String(err)}`)
|
||||
})
|
||||
}
|
||||
contents.on('will-navigate', guardNavigation)
|
||||
contents.on('will-redirect', guardNavigation)
|
||||
contents.setWindowOpenHandler((details) => {
|
||||
void openExternalSafe(details.url).catch((err: unknown) => {
|
||||
logger.warn(`openExternal failed: ${err instanceof Error ? err.message : String(err)}`)
|
||||
})
|
||||
return { action: 'deny' }
|
||||
})
|
||||
contents.on('will-attach-webview', (event) => {
|
||||
event.preventDefault()
|
||||
})
|
||||
}
|
||||
|
|
@ -11,7 +11,28 @@ interface MarkdownRendererProps {
|
|||
content: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 링크는 앱 창 안에서 이동하지 않는다 — 메인 창이 원격 페이지로 바뀌면 preload API가 그 페이지에 노출된다.
|
||||
* 클릭은 main의 openExternal(허용 scheme만)로 넘겨 OS 브라우저에서 연다.
|
||||
*/
|
||||
function openLinkExternally(event: React.MouseEvent<HTMLAnchorElement>, href: string | undefined): void {
|
||||
event.preventDefault()
|
||||
if (!href) return
|
||||
void window.electronAPI.system.openExternal({ url: href })
|
||||
}
|
||||
|
||||
const components: Components = {
|
||||
a: ({ children, href }) => (
|
||||
<Box
|
||||
component="a"
|
||||
href={href}
|
||||
rel="noopener noreferrer"
|
||||
onClick={(event: React.MouseEvent<HTMLAnchorElement>) => openLinkExternally(event, href)}
|
||||
sx={{ color: d3roPalette.accent.main, textDecoration: 'underline', cursor: 'pointer' }}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
),
|
||||
h1: ({ children }) => (
|
||||
<Box
|
||||
component="h1"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue