From 9aa7302944647a242b28bf0541cba3329d5f5de1 Mon Sep 17 00:00:00 2001 From: Yun Chan Date: Mon, 28 Sep 2026 00:53:42 +0900 Subject: [PATCH] fix(sync): bind sync engine to its user DB, scope instructions/templates per account, harden navigation --- apps/desktop/src/main/db/index.ts | 6 +- apps/desktop/src/main/index.ts | 8 +- apps/desktop/src/main/ipc/license-handlers.ts | 8 +- apps/desktop/src/main/ipc/window-handlers.ts | 17 +- .../src/main/services/CloudSyncService.ts | 242 +++++----- .../main/services/CustomInstructionService.ts | 123 ++++- .../main/services/DictationTemplateService.ts | 88 +++- .../src/main/services/HistoryService.ts | 56 ++- .../services/MeetingDocTemplateService.ts | 85 +++- .../src/main/services/account-scope.ts | 197 ++++++++ .../src/main/services/history-deletion.ts | 95 ++++ .../src/main/services/history-stats.ts | 31 ++ .../src/main/services/sync/SyncEngine.ts | 389 ++++++++++------ .../main/services/sync/session-token-store.ts | 101 +++++ .../src/main/services/sync/sync-adapters.ts | 92 +++- .../src/main/services/sync/sync-registry.ts | 69 +++ .../src/main/services/sync/sync-scheduler.ts | 65 +++ .../src/main/services/sync/sync-types.ts | 65 ++- apps/desktop/src/main/windows/TrayManager.ts | 14 +- .../desktop/src/main/windows/WindowManager.ts | 46 +- .../main/windows/web-contents-hardening.ts | 75 +++ .../components/meeting/MarkdownRenderer.tsx | 21 + .../services/history-redteam-r1-0.test.ts | 133 ++++++ .../cloud-sync-extracted-redteam-r1-0.test.ts | 172 +++++++ .../tests/main/sync/sync-redteam-r1-0.test.ts | 428 ++++++++++++++++++ .../main-window-close-redteam-r1-0.test.ts | 136 ++++++ ...eb-contents-hardening-redteam-r1-0.test.ts | 100 ++++ packages/core/__tests__/url-policy.test.ts | 46 ++ packages/core/src/url-policy.ts | 59 +++ ...8010000_sync_server_stamped_updated_at.sql | 33 ++ 30 files changed, 2614 insertions(+), 386 deletions(-) create mode 100644 apps/desktop/src/main/services/account-scope.ts create mode 100644 apps/desktop/src/main/services/history-deletion.ts create mode 100644 apps/desktop/src/main/services/history-stats.ts create mode 100644 apps/desktop/src/main/services/sync/session-token-store.ts create mode 100644 apps/desktop/src/main/services/sync/sync-registry.ts create mode 100644 apps/desktop/src/main/services/sync/sync-scheduler.ts create mode 100644 apps/desktop/src/main/windows/web-contents-hardening.ts create mode 100644 apps/desktop/tests/main/services/history-redteam-r1-0.test.ts create mode 100644 apps/desktop/tests/main/sync/cloud-sync-extracted-redteam-r1-0.test.ts create mode 100644 apps/desktop/tests/main/sync/sync-redteam-r1-0.test.ts create mode 100644 apps/desktop/tests/main/windows/main-window-close-redteam-r1-0.test.ts create mode 100644 apps/desktop/tests/main/windows/web-contents-hardening-redteam-r1-0.test.ts create mode 100644 packages/core/__tests__/url-policy.test.ts create mode 100644 packages/core/src/url-policy.ts create mode 100644 server/supabase/migrations/20260928010000_sync_server_stamped_updated_at.sql diff --git a/apps/desktop/src/main/db/index.ts b/apps/desktop/src/main/db/index.ts index fc3e39d..7681e5b 100644 --- a/apps/desktop/src/main/db/index.ts +++ b/apps/desktop/src/main/db/index.ts @@ -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 해제. */ diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 6940623..7a8481b 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -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 { diff --git a/apps/desktop/src/main/ipc/license-handlers.ts b/apps/desktop/src/main/ipc/license-handlers.ts index bcecdbd..9c5c33e 100644 --- a/apps/desktop/src/main/ipc/license-handlers.ts +++ b/apps/desktop/src/main/ipc/license-handlers.ts @@ -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}`) diff --git a/apps/desktop/src/main/ipc/window-handlers.ts b/apps/desktop/src/main/ipc/window-handlers.ts index 89b1590..866f943 100644 --- a/apps/desktop/src/main/ipc/window-handlers.ts +++ b/apps/desktop/src/main/ipc/window-handlers.ts @@ -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) }) } diff --git a/apps/desktop/src/main/services/CloudSyncService.ts b/apps/desktop/src/main/services/CloudSyncService.ts index 021b505..bdc4fcb 100644 --- a/apps/desktop/src/main/services/CloudSyncService.ts +++ b/apps/desktop/src/main/services/CloudSyncService.ts @@ -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( + (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 { - 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 } diff --git a/apps/desktop/src/main/services/CustomInstructionService.ts b/apps/desktop/src/main/services/CustomInstructionService.ts index 72a6dd3..c601e4a 100644 --- a/apps/desktop/src/main/services/CustomInstructionService.ts +++ b/apps/desktop/src/main/services/CustomInstructionService.ts @@ -1,10 +1,18 @@ // src/main/services/CustomInstructionService.ts // 사용자 정의 LLM 명령어 관리. 설계서 01/Phase 6 참조. -// electron-store에 저장, 프리셋 5개 기본 제공. +// 계정 범위 저장소(users//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 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 | 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 { + if (!store) { + store = new AccountScopedList({ + 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 } diff --git a/apps/desktop/src/main/services/DictationTemplateService.ts b/apps/desktop/src/main/services/DictationTemplateService.ts index 93d3402..dc5061a 100644 --- a/apps/desktop/src/main/services/DictationTemplateService.ts +++ b/apps/desktop/src/main/services/DictationTemplateService.ts @@ -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 + /** 계정 범위 저장소(users//dictation-templates.json) — 계정이 바뀌면 다시 읽는다 */ + private readonly _list: AccountScopedList + private _items: DictationTemplate[] = [] + private readonly _unsubscribeScope: () => void private _session: TemplateSessionInfo | null = null - constructor() { + constructor(factory: UserScopedCollectionFactory = collectionFactory) { super() - this._store = new Store({ + this._list = new AccountScopedList({ 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 } diff --git a/apps/desktop/src/main/services/HistoryService.ts b/apps/desktop/src/main/services/HistoryService.ts index ec84590..f2c4539 100644 --- a/apps/desktop/src/main/services/HistoryService.ts +++ b/apps/desktop/src/main/services/HistoryService.ts @@ -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`COALESCE(SUM(duration), 0)`, + words: sql`COALESCE(SUM(word_count), 0)` + }) + .from(history) + .where(completed) + .get() + const days = db + .select({ day: sql`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 { diff --git a/apps/desktop/src/main/services/MeetingDocTemplateService.ts b/apps/desktop/src/main/services/MeetingDocTemplateService.ts index 43dd5a9..9063225 100644 --- a/apps/desktop/src/main/services/MeetingDocTemplateService.ts +++ b/apps/desktop/src/main/services/MeetingDocTemplateService.ts @@ -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 + /** 계정 범위 저장소(users//meeting-doc-templates.json) — 계정이 바뀌면 다시 읽는다 */ + private readonly _list: AccountScopedList + private _items: MeetingDocTemplate[] = [] + private readonly _unsubscribeScope: () => void - constructor() { + constructor(factory: UserScopedCollectionFactory = collectionFactory) { super() - this._store = new Store({ + this._list = new AccountScopedList({ 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 } diff --git a/apps/desktop/src/main/services/account-scope.ts b/apps/desktop/src/main/services/account-scope.ts new file mode 100644 index 0000000..394ea06 --- /dev/null +++ b/apps/desktop/src/main/services/account-scope.ts @@ -0,0 +1,197 @@ +// src/main/services/account-scope.ts +// 계정 범위(account scope) 전환 알림과 계정별 목록 저장소 포트. +// +// SQLite만 사용자별(users//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() +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 { + /** 저장된 목록. 한 번도 저장한 적 없으면 null */ + load(): T[] | null + save(items: T[]): void + getMeta(key: string): string | null + setMeta(key: string, value: string): void +} + +export type UserScopedCollectionFactory = (name: string, scopeId: string) => UserScopedCollection + +function assertScopeId(scopeId: string): void { + if (!scopeId || scopeId.includes('/') || scopeId.includes('\\') || scopeId.includes('..')) { + throw new Error(`Invalid account scope id: "${scopeId}"`) + } +} + +interface ScopedStoreSchema { + items: T[] | null + meta: Record +} + +/** 기본 구현: userData/users//.json (사용자 DB와 같은 폴더) */ +export const electronStoreCollectionFactory: UserScopedCollectionFactory = (name: string, scopeId: string) => { + assertScopeId(scopeId) + const store = new Store>({ + 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 }>() + return (name: string, scopeId: string): UserScopedCollection => { + 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 { + /** 저장 파일 이름 */ + name: string + factory: UserScopedCollectionFactory + /** 계정 범위가 생기기 전의 기계 전역 저장소 값(최초 1회 로컬 모드 범위로 옮긴다) */ + readLegacy: () => T[] | null +} + +/** + * 계정 범위별 목록. 규칙은 db/index.ts importLocalModeData 와 같다: + * - 옛 전역 값은 로컬 모드(_local) 범위로 한 번 옮긴다. + * - 로컬 모드에서 만든 사용자 항목은 처음 로그인한 계정 하나만 가져간다(claimedBy). 원본은 지우지 않는다. + * - 다른 계정은 서로의 항목을 보지 못한다. + */ +export class AccountScopedList { + private collection: UserScopedCollection | null = null + private scope: string | null = null + + constructor(private readonly options: AccountScopedListOptions) {} + + 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 { + const collection = this.options.factory(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, 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) + } +} diff --git a/apps/desktop/src/main/services/history-deletion.ts b/apps/desktop/src/main/services/history-deletion.ts new file mode 100644 index 0000000..e9191d3 --- /dev/null +++ b/apps/desktop/src/main/services/history-deletion.ts @@ -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): Promise { + 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 } +} diff --git a/apps/desktop/src/main/services/history-stats.ts b/apps/desktop/src/main/services/history-stats.ts new file mode 100644 index 0000000..de0c6b0 --- /dev/null +++ b/apps/desktop/src/main/services/history-stats.ts @@ -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, 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 +} diff --git a/apps/desktop/src/main/services/sync/SyncEngine.ts b/apps/desktop/src/main/services/sync/SyncEngine.ts index 48b2471..ef387eb 100644 --- a/apps/desktop/src/main/services/sync/SyncEngine.ts +++ b/apps/desktop/src/main/services/sync/SyncEngine.ts @@ -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 { + 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).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 = 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 { + let timer: NodeJS.Timeout | null = null + const timeout = new Promise((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 { 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(task: () => Promise): Promise { - const run = this.queue.then( - () => task(), - () => task() - ) + const guarded = (): Promise => { + // 해제 뒤에 줄을 선 작업은 시작하지 않는다(로그아웃 뒤 로컬 모드 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 { 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> { @@ -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 { + /** + * push 전에 원격 삭제를 반영한다: 폰에서 지운 행의 로컬 대기 upsert가 서버에 행을 되살리지 않게 + * outbox 항목을 버리고 로컬 행을 지운다. always=false 면 대기 upsert가 있을 때만 가져온다. + */ + private async pullTombstonesBeforePush(result: SyncRunResult, always: boolean): Promise { + if (!always && !listDueEntries(this.now()).some((e) => e.op === 'upsert')) return + const changed = new Set(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 { + 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() 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 { - 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 { const changed = new Set(result.changed) + /** 이번 pull에서 실패한 엔티티 — 자식은 커서를 넘기지 않도록 건너뛴다. */ + const failed = new Set() 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 { + 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 { + 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): Promise { + 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, 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): Promise { 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)}`) } } } diff --git a/apps/desktop/src/main/services/sync/session-token-store.ts b/apps/desktop/src/main/services/sync/session-token-store.ts new file mode 100644 index 0000000..f2e9a5b --- /dev/null +++ b/apps/desktop/src/main/services/sync/session-token-store.ts @@ -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 = ( + callback: (event: string, session: S | null) => void +) => { unsubscribe(): void } + +/** + * auth 상태 이벤트에 붙어 회전된 refresh token을 저장한다. + * - 현재 로그인된 사용자(activeUserId)의 세션만 저장한다(로그아웃 뒤 늦게 온 이벤트는 무시). + * - SIGNED_OUT 은 무시한다 — 로그아웃 경로가 직접 지운다. + * onSession 은 저장 뒤 호출돼 서비스가 메모리 세션(access token)도 갱신하게 한다. + */ +export function bindAuthEvents( + subscribe: AuthStateSubscribe, + 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() +} diff --git a/apps/desktop/src/main/services/sync/sync-adapters.ts b/apps/desktop/src/main/services/sync/sync-adapters.ts index 0ec6a6c..fcdc4d3 100644 --- a/apps/desktop/src/main/services/sync/sync-adapters.ts +++ b/apps/desktop/src/main/services/sync/sync-adapters.ts @@ -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 pushDeletes(ctx: PushContext, ids: string[]): Promise /** 원격 행을 로컬에 반영. 반영했으면 true. 자식 행을 더 읽어야 하는 엔티티는 비동기다 */ - applyRemote(row: RemoteRow, ctx: PushContext): boolean | Promise + applyRemote(row: RemoteRow, ctx: PushContext): ApplyResult | Promise /** 원격 삭제를 로컬에 반영. 지운 행이 있으면 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 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', diff --git a/apps/desktop/src/main/services/sync/sync-registry.ts b/apps/desktop/src/main/services/sync/sync-registry.ts new file mode 100644 index 0000000..b61439c --- /dev/null +++ b/apps/desktop/src/main/services/sync/sync-registry.ts @@ -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 + /** 없으면 삭제를 서버로 보내지 않는다(삭제 대기 항목은 그대로 남는다). */ + pushDeletes?(ctx: PushContext, ids: string[]): Promise +} + +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> { + const adapter = adapterFor(entity) + if (!adapter) throw new Error(`No sync adapter for ${entity}`) + return adapter +} + +/** Record 로 두어 엔티티가 빠지면 컴파일 오류가 난다. */ +const HANDLERS: Record = { + 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) +} diff --git a/apps/desktop/src/main/services/sync/sync-scheduler.ts b/apps/desktop/src/main/services/sync/sync-scheduler.ts new file mode 100644 index 0000000..19287b2 --- /dev/null +++ b/apps/desktop/src/main/services/sync/sync-scheduler.ts @@ -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 | null = null + private pullTimer: ReturnType | null = null + private heartbeatTimer: ReturnType | 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 + } +} diff --git a/apps/desktop/src/main/services/sync/sync-types.ts b/apps/desktop/src/main/services/sync/sync-types.ts index 54f643e..bf07ea4 100644 --- a/apps/desktop/src/main/services/sync/sync-types.ts +++ b/apps/desktop/src/main/services/sync/sync-types.ts @@ -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' diff --git a/apps/desktop/src/main/windows/TrayManager.ts b/apps/desktop/src/main/windows/TrayManager.ts index 68dacb2..652cc0b 100644 --- a/apps/desktop/src/main/windows/TrayManager.ts +++ b/apps/desktop/src/main/windows/TrayManager.ts @@ -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') diff --git a/apps/desktop/src/main/windows/WindowManager.ts b/apps/desktop/src/main/windows/WindowManager.ts index 43d7620..9082240 100644 --- a/apps/desktop/src/main/windows/WindowManager.ts +++ b/apps/desktop/src/main/windows/WindowManager.ts @@ -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) diff --git a/apps/desktop/src/main/windows/web-contents-hardening.ts b/apps/desktop/src/main/windows/web-contents-hardening.ts new file mode 100644 index 0000000..643ec97 --- /dev/null +++ b/apps/desktop/src/main/windows/web-contents-hardening.ts @@ -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 { + 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): 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() + }) +} diff --git a/apps/desktop/src/renderer/components/meeting/MarkdownRenderer.tsx b/apps/desktop/src/renderer/components/meeting/MarkdownRenderer.tsx index 09829ca..c6a0832 100644 --- a/apps/desktop/src/renderer/components/meeting/MarkdownRenderer.tsx +++ b/apps/desktop/src/renderer/components/meeting/MarkdownRenderer.tsx @@ -11,7 +11,28 @@ interface MarkdownRendererProps { content: string } +/** + * 링크는 앱 창 안에서 이동하지 않는다 — 메인 창이 원격 페이지로 바뀌면 preload API가 그 페이지에 노출된다. + * 클릭은 main의 openExternal(허용 scheme만)로 넘겨 OS 브라우저에서 연다. + */ +function openLinkExternally(event: React.MouseEvent, href: string | undefined): void { + event.preventDefault() + if (!href) return + void window.electronAPI.system.openExternal({ url: href }) +} + const components: Components = { + a: ({ children, href }) => ( + ) => openLinkExternally(event, href)} + sx={{ color: d3roPalette.accent.main, textDecoration: 'underline', cursor: 'pointer' }} + > + {children} + + ), h1: ({ children }) => ( +const created: string[] = [] + +function writeRecording(name: string): string { + const dir = recordingsDir() + fs.mkdirSync(dir, { recursive: true }) + const file = path.join(dir, name) + fs.writeFileSync(file, Buffer.from('RIFF')) + created.push(file) + return file +} + +function insertHistory(opts: { audio?: string | null; createdAt?: number; status?: 'completed' | 'error'; words?: number; duration?: number }): string { + const id = crypto.randomUUID() + const at = opts.createdAt ?? Date.now() + testDb.db + .insert(history) + .values({ + id, + originalText: 'x', + duration: opts.duration ?? 1, + wordCount: opts.words ?? 1, + status: opts.status ?? 'completed', + audioLocalPath: opts.audio ?? null, + createdAt: at, + updatedAt: at, + }) + .run() + return id +} + +async function waitGone(file: string): Promise { + await vi.waitFor(() => expect(fs.existsSync(file)).toBe(false), { timeout: 2000, interval: 10 }) +} + +beforeEach(() => { + resetAccountScopeForTests() + testDb = createTestDb() + bindTestDatabase(testDb.db, '_local') + initInMemoryConfig() + resetHistoryServiceForTests() +}) + +afterEach(() => { + unbindTestDatabase() + resetInMemoryConfig() + testDb.close() + for (const f of created.splice(0)) fs.rmSync(f, { force: true }) + resetAccountScopeForTests() +}) + +describe('녹음 파일 정리', () => { + it('단건 삭제는 녹음 WAV도 지운다', async () => { + const wav = writeRecording(`${crypto.randomUUID()}.wav`) + const id = insertHistory({ audio: wav }) + expect(getHistoryService().delete(id)).toBe(true) + await waitGone(wav) + }) + + it('전체 삭제·원격 삭제 반영도 녹음을 지우지만, 녹음 폴더 밖 사용자 원본은 건드리지 않는다', async () => { + const wav1 = writeRecording(`${crypto.randomUUID()}.wav`) + const wav2 = writeRecording(`${crypto.randomUUID()}.wav`) + const outside = path.join(path.dirname(recordingsDir()), `user-source-${crypto.randomUUID()}.wav`) + fs.writeFileSync(outside, Buffer.from('RIFF')) + created.push(outside) + insertHistory({ audio: wav1 }) + insertHistory({ audio: wav2 }) + insertHistory({ audio: outside }) + getHistoryService().deleteAll() + await waitGone(wav1) + await waitGone(wav2) + expect(fs.existsSync(outside)).toBe(true) + expect(testDb.db.select().from(history).all()).toEqual([]) + }) + + it('동기화의 원격 삭제 반영(historyAdapter.deleteLocal)도 녹음을 지운다', async () => { + const { adapterFor } = await import('../../../src/main/services/sync/sync-adapters') + const wav = writeRecording(`${crypto.randomUUID()}.wav`) + const id = insertHistory({ audio: wav }) + expect(adapterFor('history')?.deleteLocal(id)).toBe(true) + await waitGone(wav) + }) + + it('녹음 폴더 경계 판정', () => { + const dir = recordingsDir() + expect(isAppRecordingPath(path.join(dir, 'a.wav'), dir)).toBe(true) + expect(isAppRecordingPath(path.join(dir, '..', 'a.wav'), dir)).toBe(false) + expect(isAppRecordingPath(`${dir}-evil${path.sep}a.wav`, dir)).toBe(false) + expect(isAppRecordingPath(dir, dir)).toBe(false) + expect(isAppRecordingPath(null, dir)).toBe(false) + }) +}) + +describe('대시보드 통계', () => { + it('동기화·가져오기로 들어온 기록도 누적 통계에 들어가고 연속 사용일을 계산한다', () => { + const day = 24 * 60 * 60 * 1000 + const now = Date.now() + // create()를 거치지 않은 행(동기화 반영) — 예전 카운터에는 반영되지 않았다 + insertHistory({ createdAt: now, words: 5, duration: 2 }) + insertHistory({ createdAt: now - day, words: 3, duration: 1 }) + insertHistory({ createdAt: now - 2 * day, words: 2, duration: 1 }) + insertHistory({ createdAt: now - 2 * day, status: 'error', words: 100, duration: 50 }) + const stats = getHistoryService().getStats() + expect(stats.totalSessionCount).toBe(3) + expect(stats.totalWordCount).toBe(10) + expect(stats.totalRecordingTimeMs).toBe(4000) + expect(stats.streakDays).toBe(3) + }) + + it('computeStreakDays: 오늘 기록이 없으면 어제부터, 끊기면 멈춘다', () => { + const today = new Date(2026, 8, 28, 15) + const key = (offset: number): string => localDayKey(new Date(2026, 8, 28 - offset)) + expect(computeStreakDays([], today)).toBe(0) + expect(computeStreakDays([key(0), key(1), key(2)], today)).toBe(3) + expect(computeStreakDays([key(1), key(2)], today)).toBe(2) + expect(computeStreakDays([key(0), key(2)], today)).toBe(1) + expect(computeStreakDays([key(2), key(3)], today)).toBe(0) + }) +}) diff --git a/apps/desktop/tests/main/sync/cloud-sync-extracted-redteam-r1-0.test.ts b/apps/desktop/tests/main/sync/cloud-sync-extracted-redteam-r1-0.test.ts new file mode 100644 index 0000000..02b6e95 --- /dev/null +++ b/apps/desktop/tests/main/sync/cloud-sync-extracted-redteam-r1-0.test.ts @@ -0,0 +1,172 @@ +// CloudSyncService 에서 분리한 SessionTokenStore·SyncScheduler, 로그인 콜백 가드 회귀 테스트 + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + EncryptedFileTokenStore, + bindAuthEvents, + type AuthSessionLike, + type TokenCipher, + type TokenFileSystem, +} from '../../../src/main/services/sync/session-token-store' +import { SyncScheduler } from '../../../src/main/services/sync/sync-scheduler' + +function memoryFs(): TokenFileSystem & { files: Map } { + const files = new Map() + return { + files, + existsSync: (p) => files.has(p), + readFileSync: (p) => { + const data = files.get(p) + if (!data) throw new Error('ENOENT') + return data + }, + writeFileSync: (p, data) => { + files.set(p, Buffer.from(data)) + }, + unlinkSync: (p) => { + files.delete(p) + }, + } +} + +const reversingCipher: TokenCipher = { + isEncryptionAvailable: () => true, + encryptString: (plain) => Buffer.from([...plain].reverse().join(''), 'utf-8'), + decryptString: (encrypted) => [...encrypted.toString('utf-8')].reverse().join(''), +} + +const logger = { warn: vi.fn() } + +describe('EncryptedFileTokenStore', () => { + it('암호화해 저장하고 복원하며, clear 는 파일을 지운다', () => { + const fs = memoryFs() + const store = new EncryptedFileTokenStore('/u/cloud-sync.token', reversingCipher, fs, logger) + expect(store.load()).toBeNull() + store.save('rt-1') + expect(fs.files.get('/u/cloud-sync.token')?.toString('utf-8')).toBe('1-tr') + expect(store.load()).toBe('rt-1') + store.clear() + expect(store.load()).toBeNull() + }) + + it('OS 암호화를 못 쓰면 평문으로 저장하지 않는다', () => { + const fs = memoryFs() + const store = new EncryptedFileTokenStore('/t', { ...reversingCipher, isEncryptionAvailable: () => false }, fs, logger) + store.save('secret') + expect(fs.files.size).toBe(0) + }) +}) + +describe('bindAuthEvents — 회전된 refresh token 저장', () => { + type Session = AuthSessionLike & { access_token: string } + function harness(activeUser: string | null) { + let emit: (event: string, session: Session | null) => void = () => undefined + const unsubscribe = vi.fn() + const fs = memoryFs() + const store = new EncryptedFileTokenStore('/t', reversingCipher, fs, logger) + const onSession = vi.fn() + let active = activeUser + const unbind = bindAuthEvents( + (cb) => { + emit = cb + return { unsubscribe } + }, + store, + () => active, + onSession + ) + return { + emit: (event: string, session: Session | null) => emit(event, session), + store, + onSession, + unsubscribe, + unbind, + setActive: (u: string | null) => { + active = u + }, + } + } + const session = (rt: string, user = 'u1'): Session => ({ refresh_token: rt, access_token: `at-${rt}`, user: { id: user } }) + + it('TOKEN_REFRESHED 마다 새 토큰을 저장하고 메모리 세션도 갱신한다', () => { + const h = harness('u1') + h.store.save('rt-old') + h.emit('TOKEN_REFRESHED', session('rt-new')) + expect(h.store.load()).toBe('rt-new') + expect(h.onSession).toHaveBeenCalledWith(expect.objectContaining({ access_token: 'at-rt-new' })) + }) + + it('로그아웃 뒤·다른 사용자·SIGNED_OUT 이벤트는 무시한다', () => { + const h = harness('u1') + h.store.save('rt-keep') + h.emit('TOKEN_REFRESHED', session('rt-other', 'u2')) + h.emit('SIGNED_OUT', null) + h.emit('INITIAL_SESSION', session('rt-initial')) + expect(h.store.load()).toBe('rt-keep') + h.setActive(null) + h.emit('TOKEN_REFRESHED', session('rt-late')) + expect(h.store.load()).toBe('rt-keep') + expect(h.onSession).not.toHaveBeenCalled() + h.unbind() + expect(h.unsubscribe).toHaveBeenCalled() + }) +}) + +describe('SyncScheduler', () => { + afterEach(() => { + vi.useRealTimers() + }) + + it('디바운스는 여러 요청을 한 번으로 모은다', () => { + vi.useFakeTimers() + const run = vi.fn() + const scheduler = new SyncScheduler({ flushDelayMs: 1500, pullDelayMs: 1500, heartbeatMs: 300_000, run, onHeartbeat: vi.fn() }) + scheduler.scheduleFlush() + scheduler.scheduleFlush() + scheduler.schedulePull() + vi.advanceTimersByTime(1499) + expect(run).not.toHaveBeenCalled() + vi.advanceTimersByTime(1) + expect(run.mock.calls).toEqual([['flush'], ['pull']]) + scheduler.scheduleFlush() + vi.advanceTimersByTime(1500) + expect(run).toHaveBeenCalledTimes(3) + }) + + it('heartbeat 는 주기마다 부르고 stop 은 대기 중인 디바운스까지 취소한다', () => { + vi.useFakeTimers() + const run = vi.fn() + const onHeartbeat = vi.fn() + const scheduler = new SyncScheduler({ flushDelayMs: 1500, pullDelayMs: 1500, heartbeatMs: 1000, run, onHeartbeat }) + scheduler.startHeartbeat() + vi.advanceTimersByTime(3000) + expect(onHeartbeat).toHaveBeenCalledTimes(3) + scheduler.scheduleFlush() + scheduler.stop() + vi.advanceTimersByTime(5000) + expect(onHeartbeat).toHaveBeenCalledTimes(3) + expect(run).not.toHaveBeenCalled() + expect(scheduler.isHeartbeatRunning()).toBe(false) + }) +}) + +describe('로그인 콜백 가드 (login CSRF)', () => { + it('앱이 시작하지 않은 로그인 콜백 토큰은 세션을 바꾸지 않고 거부한다', async () => { + const { getCloudSyncService, resetCloudSyncServiceForTests } = await import('../../../src/main/services/CloudSyncService') + const { initInMemoryConfig, resetInMemoryConfig } = await import('../../../src/main/services/ConfigService') + initInMemoryConfig() + resetCloudSyncServiceForTests() + const svc = getCloudSyncService() + try { + await svc.init() + await expect( + svc.handleAuthTokens({ accessToken: 'attacker.at', refreshToken: 'attacker-rt' }) + ).rejects.toThrow(/no sign-in was started/) + await expect(svc.handleAuthCallback('attacker-code')).rejects.toThrow(/no sign-in was started/) + expect(svc.isAuthenticated()).toBe(false) + } finally { + resetCloudSyncServiceForTests() + resetInMemoryConfig() + } + }) +}) diff --git a/apps/desktop/tests/main/sync/sync-redteam-r1-0.test.ts b/apps/desktop/tests/main/sync/sync-redteam-r1-0.test.ts new file mode 100644 index 0000000..a9e036b --- /dev/null +++ b/apps/desktop/tests/main/sync/sync-redteam-r1-0.test.ts @@ -0,0 +1,428 @@ +// 동기화 레드팀 r1-0 회귀 테스트 +// - 해제된 엔진이 다른 사용자 DB(로컬 모드)에 쓰지 않는다 +// - 서버 시각 updated_at (클라이언트 시각을 보내지 않는다) +// - 계정별 명령·템플릿 범위, 템플릿 P0002 보관 +// - 미룬 행(부모 없음) 재시도, 부모 실패 시 자식 커서 보존 +// - push 전에 원격 삭제 반영(좀비 행 방지) +// - 명령 길이 제한(잘라 올리지 않음) +// - 엔티티 레지스트리 전수·순서 + +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { eq } from 'drizzle-orm' +import { createTestDb } from '../../helpers/createTestDb' +import { FakeSyncRemote } from '../../helpers/fakeSyncRemote' +import { bindTestDatabase, unbindTestDatabase } from '../../../src/main/db' +import { history, meetingMemos, memoTags } from '../../../src/main/db/schema' +import { initInMemoryConfig, resetInMemoryConfig } from '../../../src/main/services/ConfigService' +import { + getCustomInstructionService, + INSTRUCTION_LIMITS, + resetCustomInstructionServiceForTests, +} from '../../../src/main/services/CustomInstructionService' +import { + getDictationTemplateService, + resetDictationTemplateServiceForTests, +} from '../../../src/main/services/DictationTemplateService' +import { + getMeetingDocTemplateService, + resetMeetingDocTemplateServiceForTests, +} from '../../../src/main/services/MeetingDocTemplateService' +import { + createMemoryCollectionFactory, + resetAccountScopeForTests, + type UserScopedCollectionFactory, +} from '../../../src/main/services/account-scope' +import { SyncEngine } from '../../../src/main/services/sync/SyncEngine' +import { + SyncAbortedError, + dictionaryToRemote, + historyToRemote, + instructionPushError, + meetingDocumentToRemote, + meetingMemoToRemote, + meetingToRemote, + templateUpsertError, +} from '../../../src/main/services/sync/sync-adapters' +import { enqueueChange, outboxCounts, pendingOps } from '../../../src/main/services/sync/sync-outbox' +import { deleteOrder, SYNC_REGISTRY, upsertOrder } from '../../../src/main/services/sync/sync-registry' +import { + SYNC_ENTITIES, + SYNC_ENTITY_SPECS, + SyncRemoteError, + parentOf, + realtimeTables, + toSyncRemoteError, + type RemotePageRequest, + type RemoteRow, +} from '../../../src/main/services/sync/sync-types' + +const USER = '11111111-1111-4111-8111-111111111111' +const USER_B = '33333333-3333-4333-8333-333333333333' + +function uuid(): string { + return crypto.randomUUID() +} + +/** fetchPage 를 테이블별로 가로챌 수 있는 원격 */ +class GatedRemote extends FakeSyncRemote { + gate: ((request: RemotePageRequest) => Promise | void) | null = null + override async fetchPage(request: RemotePageRequest): Promise { + const rows = await super.fetchPage(request) + if (this.gate) await this.gate(request) + return rows + } +} + +let dbs: Array> = [] +let factory: UserScopedCollectionFactory + +function openDb(userId: string): ReturnType { + const created = createTestDb() + dbs.push(created) + bindTestDatabase(created.db, userId) + return created +} + +beforeEach(() => { + resetAccountScopeForTests() + initInMemoryConfig() + factory = createMemoryCollectionFactory() + resetCustomInstructionServiceForTests(factory) + resetDictationTemplateServiceForTests(factory) + resetMeetingDocTemplateServiceForTests(factory) +}) + +afterEach(() => { + unbindTestDatabase() + resetInMemoryConfig() + for (const d of dbs) d.close() + dbs = [] + resetCustomInstructionServiceForTests() + resetDictationTemplateServiceForTests() + resetMeetingDocTemplateServiceForTests() + resetAccountScopeForTests() +}) + +describe('해제된 엔진은 다른 사용자 DB에 쓰지 않는다', () => { + it('pull 도중 로그아웃(DB 전환)되면 계정 행이 로컬 모드 DB로 들어가지 않고 로컬 태그도 지우지 않는다', async () => { + const accountDb = openDb(USER) + const remote = new GatedRemote(USER) + for (let i = 0; i < 700; i++) { + remote.mobileInsert('history', { id: uuid(), original_text: `a${i}`, duration: 1, mode: 'dictation', status: 'completed' }) + } + const engine = new SyncEngine({ remote, userId: USER }) + + let release: () => void = () => undefined + let pages = 0 + remote.gate = (request) => { + if (request.table !== 'history') return + pages++ + if (pages === 2) { + return new Promise((resolve) => { + release = resolve + }) + } + } + const running = engine.runFullSync() + // 두 번째 페이지 응답을 기다리는 동안 로그아웃: 엔진 해제 → 로컬 모드 DB로 전환 + for (let i = 0; i < 200 && pages < 2; i++) await new Promise((r) => setTimeout(r, 10)) + expect(pages).toBe(2) + engine.dispose() + const localDb = openDb('_local') + const localHistory = uuid() + const now = Date.now() + localDb.db.insert(history).values({ id: localHistory, originalText: 'local', duration: 1, createdAt: now, updatedAt: now }).run() + localDb.db.insert(memoTags).values({ id: uuid(), historyId: localHistory, tag: 'mine', createdAt: now }).run() + release() + + await expect(running).rejects.toBeInstanceOf(SyncAbortedError) + const localRows = localDb.db.select().from(history).all() + expect(localRows.map((r) => r.id)).toEqual([localHistory]) + expect(localDb.db.select().from(memoTags).all()).toHaveLength(1) + // 계정 DB는 첫 페이지까지만 반영됐고 커서도 로컬 DB에 쓰이지 않았다 + expect(accountDb.db.select().from(history).all().length).toBeLessThanOrEqual(500) + }) + + it('해제 뒤에 줄을 선 작업은 시작하지 않는다', async () => { + openDb(USER) + const remote = new FakeSyncRemote(USER) + const engine = new SyncEngine({ remote, userId: USER }) + engine.dispose() + await expect(engine.pull()).rejects.toBeInstanceOf(SyncAbortedError) + expect(remote.calls).toEqual([]) + await expect(engine.whenIdle(100)).resolves.toBe(true) + }) + + it('다른 사용자 DB가 열리면 같은 엔진도 멈춘다', async () => { + openDb(USER) + const remote = new FakeSyncRemote(USER) + const engine = new SyncEngine({ remote, userId: USER }) + openDb(USER_B) + expect(engine.isCurrent()).toBe(false) + await expect(engine.runFullSync()).rejects.toBeInstanceOf(SyncAbortedError) + }) +}) + +describe('updated_at 은 서버 시각', () => { + it('push 페이로드에 클라이언트 updated_at 을 싣지 않는다', () => { + const at = Date.parse('2026-01-01T00:00:00Z') + const h = historyToRemote( + { + id: uuid(), title: null, originalText: 'x', polishedText: null, focusedApp: null, focusedAppName: null, + focusedAppWindowTitle: null, mode: 'dictation', status: 'completed', errorCode: null, audioLocalPath: null, + duration: 1, detectedLanguage: null, micDevice: null, wordCount: 1, sttModel: null, llmModel: null, + sttLatencyMs: null, llmLatencyMs: null, createdAt: at, updatedAt: at, appVersion: '1', summaryText: null, + isFavorite: false, + } as Parameters[0], + USER + ) + expect(h).not.toHaveProperty('updated_at') + const d = dictionaryToRemote( + { id: uuid(), word: 'w', pronunciation: null, category: 'user', usageCount: 0, lastUsedAt: null, createdAt: at, updatedAt: at } as Parameters[0], + USER + ) + expect(d).not.toHaveProperty('updated_at') + const m = meetingToRemote({ id: uuid(), title: 't', status: 'completed', startedAt: at, endedAt: null, createdAt: at, updatedAt: at } as Parameters[0], USER) + expect(m).not.toHaveProperty('updated_at') + const memo = meetingMemoToRemote({ id: uuid(), sessionId: uuid(), content: 'c', timestampMs: 1, createdAt: at, updatedAt: at } as Parameters[0], USER) + expect(memo).not.toHaveProperty('updated_at') + const doc = meetingDocumentToRemote({ id: uuid(), sessionId: uuid(), templateType: 'minutes', title: 't', content: '', createdAt: at, updatedAt: at } as Parameters[0], USER) + expect(doc).not.toHaveProperty('updated_at') + }) + + it('다른 데스크톱이 늦게 올린 오래된 기록도 이미 동기화된 기기가 내려받는다', async () => { + const remote = new FakeSyncRemote(USER) + // 데스크톱 B: 이미 동기화돼 커서가 "지금" 근처 + const dbB = openDb(USER) + remote.mobileInsert('history', { id: uuid(), original_text: 'recent', duration: 1, mode: 'dictation', status: 'completed' }) + const engineB = new SyncEngine({ remote, userId: USER }) + await engineB.runFullSync() + engineB.dispose() + for (let i = 0; i < 120; i++) remote.now() // 서버 시계 2분 경과 + + // 데스크톱 A: 몇 달 전 로컬 기록을 처음 올린다 + const dbA = openDb(USER) + const old = Date.parse('2026-03-01T00:00:00Z') + const oldId = uuid() + dbA.db.insert(history).values({ id: oldId, originalText: 'old recording', duration: 1, createdAt: old, updatedAt: old }).run() + const engineA = new SyncEngine({ remote, userId: USER }) + await engineA.runFullSync() + engineA.dispose() + expect(remote.find('history', oldId)).toBeDefined() + + // B의 다음 pull 이 받아야 한다 + bindTestDatabase(dbB.db, USER) + const engineB2 = new SyncEngine({ remote, userId: USER }) + await engineB2.pull() + engineB2.dispose() + expect(dbB.db.select().from(history).where(eq(history.id, oldId)).get()?.originalText).toBe('old recording') + }) +}) + +describe('계정별 명령·템플릿', () => { + it('다른 계정으로 로그인하면 이전 계정의 명령·템플릿이 보이지 않고 backfill 로 올라가지 않는다', async () => { + openDb(USER) + getCustomInstructionService().initialize() + const mine = getCustomInstructionService().create({ name: 'A private', description: '', prompt: 'secret prompt of A' }) + const dictation = getDictationTemplateService().create({ name: 'A tpl', description: '', fields: [], outputFormat: 'x' }) + const meetingTpl = getMeetingDocTemplateService().create({ name: 'A doc', description: '', systemPrompt: 'p' }) + + // 로그아웃 → 로컬 모드: A의 항목이 보이지 않는다 + openDb('_local') + expect(getCustomInstructionService().getById(mine.id)).toBeNull() + expect(getDictationTemplateService().getById(dictation.id)).toBeNull() + expect(getMeetingDocTemplateService().getById(meetingTpl.id)).toBeNull() + + // B 로그인 + openDb(USER_B) + const remote = new FakeSyncRemote(USER_B) + const engine = new SyncEngine({ remote, userId: USER_B }) + await engine.runFullSync() + engine.dispose() + expect(getCustomInstructionService().getById(mine.id)).toBeNull() + expect(remote.find('custom_instructions', mine.id)).toBeUndefined() + expect(remote.find('user_templates', dictation.id)).toBeUndefined() + expect(remote.find('user_templates', meetingTpl.id)).toBeUndefined() + expect(getCustomInstructionService().getAll().filter((i) => i.isBuiltin)).toHaveLength(5) + + // A 다시 로그인: A의 항목이 돌아온다 + openDb(USER) + expect(getCustomInstructionService().getById(mine.id)?.prompt).toBe('secret prompt of A') + expect(getDictationTemplateService().getById(dictation.id)).not.toBeNull() + }) + + it('로컬 모드에서 만든 명령은 처음 로그인한 계정 하나만 가져간다', () => { + openDb('_local') + getCustomInstructionService().initialize() + const local = getCustomInstructionService().create({ name: 'local', description: '', prompt: 'p' }) + openDb(USER) + expect(getCustomInstructionService().getById(local.id)).not.toBeNull() + openDb(USER_B) + expect(getCustomInstructionService().getById(local.id)).toBeNull() + }) + + it('다른 계정 소유 템플릿(P0002)은 재시도 불가로 보관되고, 일반 P0002 는 여전히 재시도한다', () => { + const err = templateUpsertError({ code: 'P0002', message: 'template_not_found' }) + expect(err.retryable).toBe(false) + expect(toSyncRemoteError({ code: 'P0002', message: 'history_not_found' }).retryable).toBe(true) + expect(templateUpsertError(new SyncRemoteError('fetch failed', 'network', true)).retryable).toBe(true) + }) +}) + +describe('미룬 행과 커서', () => { + it('부모 회의가 늦게 온 메모는 커서가 지나가도 다음 pull 에서 반영된다', async () => { + openDb(USER) + const remote = new FakeSyncRemote(USER) + const meetingId = uuid() + const memoId = uuid() + // 메모가 먼저 서버에 보이고(회의 행은 아직 없음) + remote.mobileInsert('meeting_memos', { id: memoId, meeting_id: meetingId, content: 'late parent', timestamp_ms: 1 }) + for (let i = 0; i < 120; i++) remote.now() + // 더 새 메모(다른 회의)로 memo 커서가 2분 앞으로 간다 + const otherMeeting = uuid() + remote.mobileInsert('meetings', { id: otherMeeting, title: 'other', status: 'completed', started_at: remote.now() }) + remote.mobileInsert('meeting_memos', { id: uuid(), meeting_id: otherMeeting, content: 'newer', timestamp_ms: 1 }) + + const engine = new SyncEngine({ remote, userId: USER }) + const first = await engine.runFullSync() + expect(first.errors).toEqual([]) + expect(testMemo(memoId)).toBeUndefined() + + // 부모 회의가 도착 + remote.mobileInsert('meetings', { id: meetingId, title: 'late', status: 'completed', started_at: remote.now() }) + await engine.pull() + engine.dispose() + expect(testMemo(memoId)?.content).toBe('late parent') + }) + + it('회의 pull 이 실패하면 같은 실행에서 메모 커서를 넘기지 않는다', async () => { + openDb(USER) + const remote = new GatedRemote(USER) + const meetingId = uuid() + remote.mobileInsert('meetings', { id: meetingId, title: 'm', status: 'completed', started_at: remote.now() }) + const memoId = uuid() + remote.mobileInsert('meeting_memos', { id: memoId, meeting_id: meetingId, content: 'child', timestamp_ms: 1 }) + let failMeetings = true + remote.gate = (request) => { + if (request.table === 'meetings' && failMeetings) throw new SyncRemoteError('fetch failed', 'network', true) + } + const engine = new SyncEngine({ remote, userId: USER }) + const failed = await engine.pull() + expect(failed.errors.some((e) => e.startsWith('meetings'))).toBe(true) + expect(remote.calls.filter((c) => c === 'fetch:meeting_memos')).toEqual([]) + + failMeetings = false + await engine.pull() + engine.dispose() + expect(testMemo(memoId)?.content).toBe('child') + }) +}) + +function testMemo(id: string): { content: string } | undefined { + return dbs[dbs.length - 1].db.select().from(meetingMemos).where(eq(meetingMemos.id, id)).get() +} + +describe('push 전에 원격 삭제 반영', () => { + it('폰에서 지운 행의 오프라인 편집이 서버에 행을 되살리지 않는다', async () => { + const d = openDb(USER) + const remote = new FakeSyncRemote(USER) + const id = uuid() + remote.mobileInsert('history', { id, original_text: 'orig', duration: 1, mode: 'dictation', status: 'completed' }) + const engine = new SyncEngine({ remote, userId: USER }) + await engine.runFullSync() + expect(d.db.select().from(history).where(eq(history.id, id)).get()).toBeDefined() + + // 폰이 지우고, 데스크톱은 오프라인에서 편집해 대기 upsert 가 있다 + remote.mobileDelete('history', id) + d.db.update(history).set({ title: 'edited offline', updatedAt: Date.now() }).where(eq(history.id, id)).run() + enqueueChange('history', id, 'upsert') + + const result = await engine.flush() + engine.dispose() + expect(remote.find('history', id)).toBeUndefined() + expect(d.db.select().from(history).where(eq(history.id, id)).get()).toBeUndefined() + expect(pendingOps('history').has(id)).toBe(false) + expect(result.deleted).toBe(1) + }) + + it('최초 대조(backfill)는 서버에서 지워진 행을 다시 올리지 않는다', async () => { + const d = openDb(USER) + const remote = new FakeSyncRemote(USER) + const id = uuid() + remote.mobileInsert('history', { id, original_text: 'x', duration: 1, mode: 'dictation', status: 'completed' }) + remote.mobileDelete('history', id) + const at = Date.now() + d.db.insert(history).values({ id, originalText: 'x', duration: 1, createdAt: at, updatedAt: at }).run() + + const engine = new SyncEngine({ remote, userId: USER }) + await engine.runFullSync() + engine.dispose() + expect(remote.find('history', id)).toBeUndefined() + expect(d.db.select().from(history).where(eq(history.id, id)).get()).toBeUndefined() + expect(outboxCounts()).toEqual({ pending: 0, parked: 0 }) + }) +}) + +describe('명령 길이 제한', () => { + it('서버 제한을 넘는 명령은 만들거나 고칠 수 없다', () => { + openDb(USER) + const svc = getCustomInstructionService() + svc.initialize() + expect(() => svc.create({ name: 'n', description: '', prompt: 'x'.repeat(INSTRUCTION_LIMITS.prompt + 1) })).toThrow(/too long/) + expect(() => svc.create({ name: 'n'.repeat(INSTRUCTION_LIMITS.name + 1), description: '', prompt: 'p' })).toThrow(/too long/) + const ok = svc.create({ name: 'n', description: '', prompt: 'x'.repeat(INSTRUCTION_LIMITS.prompt) }) + expect(() => svc.update(ok.id, { prompt: 'y'.repeat(INSTRUCTION_LIMITS.prompt + 1) })).toThrow(/too long/) + expect(svc.getById(ok.id)?.prompt).toHaveLength(INSTRUCTION_LIMITS.prompt) + }) + + it('이미 저장된 긴 명령은 잘라 올리지 않고 재시도 불가 오류로 보관된다', async () => { + openDb(USER) + const svc = getCustomInstructionService() + svc.initialize() + const longPrompt = 'z'.repeat(6000) + const id = uuid() + svc.applyRemote({ id, name: 'legacy', description: '', prompt: longPrompt, icon: 'x', isBuiltin: false, order: 9, createdAt: 1, updatedAt: 1 }) + const instruction = svc.getById(id) + expect(instruction).not.toBeNull() + expect(instructionPushError(instruction!)?.retryable).toBe(false) + + const remote = new FakeSyncRemote(USER) + const engine = new SyncEngine({ remote, userId: USER }) + await engine.runFullSync() + engine.dispose() + expect(remote.find('custom_instructions', id)).toBeUndefined() + expect(svc.getById(id)?.prompt).toBe(longPrompt) + }) +}) + +describe('엔티티 레지스트리', () => { + it('모든 엔티티가 정확히 한 번 등록되고 부모가 자식보다 먼저 push 된다', () => { + const entities = SYNC_REGISTRY.map((e) => e.entity) + expect([...entities].sort()).toEqual([...SYNC_ENTITIES].sort()) + expect(new Set(entities).size).toBe(entities.length) + for (const spec of SYNC_ENTITY_SPECS) { + const parent = parentOf(spec.entity) + if (!parent) continue + expect(SYNC_ENTITIES).toContain(parent) + expect(entities.indexOf(parent)).toBeLessThan(entities.indexOf(spec.entity)) + } + }) + + it('push·delete 순서와 Realtime 대상이 이전과 같다', () => { + expect(upsertOrder().map((e) => e.entity)).toEqual([ + 'history', 'dictionary', 'meetings', 'meeting_memos', 'meeting_documents', 'custom_instructions', + 'builtin_instructions', 'user_settings', 'user_templates', 'knowledge_documents', 'memo_tags', + 'history_audio', 'meeting_audio', + ]) + expect(deleteOrder().map((e) => e.entity)).toEqual([ + 'memo_tags', 'knowledge_documents', 'user_templates', 'custom_instructions', 'meeting_documents', + 'meeting_memos', 'meetings', 'dictionary', 'history', + ]) + expect([...realtimeTables()].sort()).toEqual( + [ + 'history', 'dictionary', 'meetings', 'meeting_memos', 'meeting_documents', 'memo_tags', + 'custom_instructions', 'user_templates', 'knowledge_documents', 'user_settings', 'sync_tombstones', + ].sort() + ) + }) +}) diff --git a/apps/desktop/tests/main/windows/main-window-close-redteam-r1-0.test.ts b/apps/desktop/tests/main/windows/main-window-close-redteam-r1-0.test.ts new file mode 100644 index 0000000..a4ec426 --- /dev/null +++ b/apps/desktop/tests/main/windows/main-window-close-redteam-r1-0.test.ts @@ -0,0 +1,136 @@ +// closeToTray=false 로 메인 창을 닫으면 창 없는 프로세스로 남지 않고 종료한다. 창이 사라진 뒤 트레이 +// '표시'·두 번째 실행은 창을 다시 만든다. + +import { beforeEach, describe, expect, it, vi } from 'vitest' + +type Listener = (...args: unknown[]) => void + +const state = vi.hoisted(() => ({ + windows: [] as Array<{ listeners: Map; destroyed: boolean }>, + quit: vi.fn(), +})) + +vi.unmock('../../../src/main/windows/WindowManager') + +vi.mock('electron', () => { + const quit = state.quit + class FakeBrowserWindow { + listeners = new Map() + destroyed = false + webContents = { + on: vi.fn(), + setWindowOpenHandler: vi.fn(), + openDevTools: vi.fn(), + insertCSS: vi.fn(async () => ''), + send: vi.fn(), + } + constructor() { + state.windows.push(this) + } + on(event: string, listener: Listener): this { + this.listeners.set(event, listener) + return this + } + once(event: string, listener: Listener): this { + return this.on(event, listener) + } + setSkipTaskbar = vi.fn() + setMenu = vi.fn() + loadURL = vi.fn(async () => undefined) + loadFile = vi.fn(async () => undefined) + center = vi.fn() + restore = vi.fn() + show = vi.fn() + focus = vi.fn() + hide = vi.fn() + setAlwaysOnTop = vi.fn() + flashFrame = vi.fn() + isMinimized = vi.fn(() => false) + isDestroyed(): boolean { + return this.destroyed + } + } + return { + app: { quit, getPath: vi.fn(() => '.'), on: vi.fn(), getVersion: vi.fn(() => '1.0.0'), isPackaged: false }, + BrowserWindow: Object.assign(FakeBrowserWindow, { getAllWindows: vi.fn(() => []) }), + screen: { + getPrimaryDisplay: vi.fn(() => ({ workAreaSize: { width: 1920, height: 1080 }, workArea: { x: 0, y: 0, width: 1920, height: 1080 } })), + getCursorScreenPoint: vi.fn(() => ({ x: 0, y: 0 })), + getDisplayNearestPoint: vi.fn(() => ({ workArea: { x: 0, y: 0, width: 1920, height: 1080 } })), + }, + ipcMain: { on: vi.fn(), handle: vi.fn(), removeHandler: vi.fn() }, + Menu: { setApplicationMenu: vi.fn(), buildFromTemplate: vi.fn() }, + clipboard: { writeText: vi.fn() }, + shell: { openExternal: vi.fn(async () => undefined) }, + safeStorage: { isEncryptionAvailable: vi.fn(() => false) }, + nativeImage: { createFromPath: vi.fn(), createEmpty: vi.fn() }, + Tray: vi.fn(), + dialog: { showErrorBox: vi.fn() }, + } +}) + +vi.mock('@electron-toolkit/utils', () => ({ is: { dev: false } })) + +vi.mock('../../../src/main/lifecycle', () => { + let quitting = false + return { + getIsQuitting: () => quitting, + setIsQuitting: (value: boolean) => { + quitting = value + }, + } +}) + +const config: Record = { closeToTray: false, theme: 'dark', language: 'ko' } +vi.mock('../../../src/main/services/ConfigService', () => ({ + configGet: (key: string) => config[key], + configSet: (key: string, value: unknown) => { + config[key] = value + }, +})) + +beforeEach(async () => { + state.windows.length = 0 + state.quit.mockClear() + const lifecycle = await import('../../../src/main/lifecycle') + lifecycle.setIsQuitting(false) +}) + +describe('main window close', () => { + it('closeToTray=false 이면 창 닫기가 앱 종료로 이어진다', async () => { + config.closeToTray = false + const { createMainWindow } = await import('../../../src/main/windows/WindowManager') + createMainWindow() + const win = state.windows[0] + const event = { preventDefault: vi.fn() } + win.listeners.get('close')?.(event) + expect(event.preventDefault).not.toHaveBeenCalled() + expect(state.quit).toHaveBeenCalledTimes(1) + }) + + it('closeToTray=true 이면 숨기기만 하고 종료하지 않는다', async () => { + vi.resetModules() + config.closeToTray = true + const { createMainWindow } = await import('../../../src/main/windows/WindowManager') + createMainWindow() + const win = state.windows[0] + const event = { preventDefault: vi.fn() } + win.listeners.get('close')?.(event) + expect(event.preventDefault).toHaveBeenCalled() + expect(state.quit).not.toHaveBeenCalled() + }) + + it('창이 파괴된 뒤 showMainWindow 는 창을 다시 만든다(부트스트랩 전에는 만들지 않는다)', async () => { + vi.resetModules() + const { createMainWindow, showMainWindow, getMainWindow } = await import('../../../src/main/windows/WindowManager') + expect(showMainWindow()).toBeNull() + expect(state.windows).toHaveLength(0) + createMainWindow() + const first = state.windows[0] + first.destroyed = true + first.listeners.get('closed')?.() + expect(getMainWindow()).toBeNull() + expect(showMainWindow()).not.toBeNull() + expect(state.windows).toHaveLength(2) + }) +}) diff --git a/apps/desktop/tests/main/windows/web-contents-hardening-redteam-r1-0.test.ts b/apps/desktop/tests/main/windows/web-contents-hardening-redteam-r1-0.test.ts new file mode 100644 index 0000000..4ae6f3c --- /dev/null +++ b/apps/desktop/tests/main/windows/web-contents-hardening-redteam-r1-0.test.ts @@ -0,0 +1,100 @@ +// 내비게이션·외부 URL 가드 회귀 테스트 + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ipcMain, shell } from 'electron' +import { IPC_CHANNELS } from '@d3ro/core/ipc-channels' +import { + hardenWebContents, + isTrustedIpcSender, + openExternalSafe, +} from '../../../src/main/windows/web-contents-hardening' + +type Handler = (...args: unknown[]) => unknown + +function fakeWindow() { + const listeners = new Map() + let openHandler: ((details: { url: string }) => { action: string }) | null = null + const webContents = { + on: vi.fn((event: string, listener: Handler) => { + listeners.set(event, listener) + }), + setWindowOpenHandler: vi.fn((handler: (details: { url: string }) => { action: string }) => { + openHandler = handler + }), + } + return { + win: { webContents } as unknown as Parameters[0], + fire: (event: string, url: string) => { + const preventDefault = vi.fn() + listeners.get(event)?.({ preventDefault }, url) + return preventDefault + }, + open: (url: string) => openHandler?.({ url }), + listeners, + } +} + +beforeEach(() => { + vi.mocked(shell.openExternal).mockClear() +}) + +describe('hardenWebContents', () => { + it('앱 창이 원격 페이지로 이동하지 못하게 막고 https 는 브라우저로 넘긴다', async () => { + const w = fakeWindow() + hardenWebContents(w.win) + const prevented = w.fire('will-navigate', 'https://evil.example/phish') + expect(prevented).toHaveBeenCalled() + await vi.waitFor(() => expect(shell.openExternal).toHaveBeenCalledWith('https://evil.example/phish')) + }) + + it('앱 자신의 페이지(file://)로의 이동은 허용한다', () => { + const w = fakeWindow() + hardenWebContents(w.win) + expect(w.fire('will-navigate', 'file:///C:/app/out/renderer/index.html#/x')).not.toHaveBeenCalled() + }) + + it('새 창은 항상 거부하고 file:/UNC/사용자 정의 scheme 은 OS로 넘기지 않는다', async () => { + const w = fakeWindow() + hardenWebContents(w.win) + expect(w.open('file:///C:/Windows/System32/calc.exe')).toEqual({ action: 'deny' }) + expect(w.open('search-ms:query=x')).toEqual({ action: 'deny' }) + expect(w.fire('will-navigate', 'ms-msdt:/id PCWDiagnostic')).toHaveBeenCalled() + await new Promise((r) => setTimeout(r, 10)) + expect(shell.openExternal).not.toHaveBeenCalled() + expect(w.listeners.has('will-attach-webview')).toBe(true) + }) +}) + +describe('openExternalSafe / isTrustedIpcSender', () => { + it('허용 scheme 만 연다', async () => { + expect(await openExternalSafe('file:///etc/passwd')).toBe(false) + expect(await openExternalSafe('https://d3ro.chanpaca.net/')).toBe(true) + expect(shell.openExternal).toHaveBeenCalledTimes(1) + }) + + it('원격 페이지가 보낸 IPC 는 신뢰하지 않는다', () => { + expect(isTrustedIpcSender({ senderFrame: { url: 'file:///C:/app/index.html' } } as never)).toBe(true) + expect(isTrustedIpcSender({ senderFrame: { url: 'https://evil.example/' } } as never)).toBe(false) + expect(isTrustedIpcSender({ senderFrame: null } as never)).toBe(false) + }) +}) + +describe('SYSTEM.OPEN_EXTERNAL IPC', () => { + it('file:·원격 발신자 요청을 거부하고 https 는 연다', async () => { + const handlers = new Map() + vi.mocked(ipcMain.handle).mockImplementation((channel: string, handler: Handler) => { + handlers.set(channel, handler) + }) + const { registerWindowHandlers } = await import('../../../src/main/ipc/window-handlers') + registerWindowHandlers() + const handler = handlers.get(IPC_CHANNELS.SYSTEM.OPEN_EXTERNAL)! + const appEvent = { senderFrame: { url: 'file:///C:/app/index.html' } } + const remoteEvent = { senderFrame: { url: 'https://evil.example/' } } + + await expect(handler(appEvent, { url: 'file:///C:/Windows/System32/calc.exe' })).resolves.toMatchObject({ success: false }) + await expect(handler(remoteEvent, { url: 'https://example.com' })).resolves.toMatchObject({ success: false }) + expect(shell.openExternal).not.toHaveBeenCalled() + await expect(handler(appEvent, { url: 'https://example.com' })).resolves.toMatchObject({ success: true }) + expect(shell.openExternal).toHaveBeenCalledWith('https://example.com') + }) +}) diff --git a/packages/core/__tests__/url-policy.test.ts b/packages/core/__tests__/url-policy.test.ts new file mode 100644 index 0000000..452b452 --- /dev/null +++ b/packages/core/__tests__/url-policy.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest' +import { isAllowedExternalUrl, isAppOrigin } from '../src/url-policy' + +describe('isAllowedExternalUrl', () => { + it('allows https and mailto by default', () => { + expect(isAllowedExternalUrl('https://d3ro.chanpaca.net/app/billing')).toBe(true) + expect(isAllowedExternalUrl('mailto:help@example.com')).toBe(true) + }) + + it.each([ + 'file:///C:/Windows/System32/calc.exe', + 'javascript:alert(1)', + 'search-ms:query=x&crumb=location:\\\\evil\\share', + 'ms-msdt:/id PCWDiagnostic', + '\\\\evil.example\\share\\payload.exe', + '//evil.example/x', + 'd3ro-voice://auth-callback#access_token=x', + 'http://example.com', + 'https://user:pass@example.com', + '', + 'not a url', + ])('rejects %s', (url) => { + expect(isAllowedExternalUrl(url)).toBe(false) + }) + + it('allows extra exact origins (dev web app) without widening schemes', () => { + const options = { allowOrigins: ['http://localhost:3000'] } + expect(isAllowedExternalUrl('http://localhost:3000/app/billing?tier=pro', options)).toBe(true) + expect(isAllowedExternalUrl('http://localhost:3001/app', options)).toBe(false) + expect(isAllowedExternalUrl('http://evil.example', options)).toBe(false) + }) +}) + +describe('isAppOrigin', () => { + it('matches the packaged file renderer and the dev server origin only', () => { + const origins = ['file://', 'http://localhost:5173'] + expect(isAppOrigin('file:///C:/app/resources/app.asar/out/renderer/index.html#/meetings', origins)).toBe(true) + expect(isAppOrigin('http://localhost:5173/#/settings', origins)).toBe(true) + expect(isAppOrigin('http://localhost:5174/', origins)).toBe(false) + expect(isAppOrigin('https://evil.example/', origins)).toBe(false) + }) + + it('does not treat file: as app origin unless allowed', () => { + expect(isAppOrigin('file:///C:/x.html', ['http://localhost:5173'])).toBe(false) + }) +}) diff --git a/packages/core/src/url-policy.ts b/packages/core/src/url-policy.ts new file mode 100644 index 0000000..94a1877 --- /dev/null +++ b/packages/core/src/url-policy.ts @@ -0,0 +1,59 @@ +// packages/core/src/url-policy.ts +// 외부 URL·내비게이션 정책 SSOT. 순수 함수만 둔다 — Electron 어댑터는 데스크톱 main이 맡는다. +// +// - 앱 밖으로 넘기는 URL(OS 기본 핸들러)은 허용 scheme만 연다. file:, UNC, ms-*, search-ms: 같은 +// OS 핸들러는 원격 코드 실행 경로가 되므로 막는다. +// - 앱 창은 앱 자신의 오리진 밖으로 이동하지 않는다(원격 페이지가 preload API를 얻지 못하게). + +export interface ExternalUrlPolicyOptions { + /** 허용할 scheme(콜론 포함, 소문자). 기본 https:·mailto: */ + allowSchemes?: readonly string[] + /** 추가로 허용할 정확한 오리진(예: 개발용 http://localhost:3000). */ + allowOrigins?: readonly string[] +} + +export const DEFAULT_EXTERNAL_SCHEMES: readonly string[] = ['https:', 'mailto:'] + +function parse(url: string): URL | null { + if (typeof url !== 'string' || url.trim() === '' || url.length > 8192) return null + // 백슬래시로 시작하는 UNC(\\server\share)는 URL 파서가 상대 경로로 보지 않도록 먼저 거른다. + if (url.startsWith('\\\\') || url.startsWith('//')) return null + try { + return new URL(url) + } catch { + return null + } +} + +/** OS 기본 핸들러(브라우저·메일)로 넘겨도 되는 URL인지. */ +export function isAllowedExternalUrl(url: string, options: ExternalUrlPolicyOptions = {}): boolean { + const parsed = parse(url) + if (!parsed) return false + const origin = parsed.origin + if (options.allowOrigins?.some((allowed) => allowed === origin)) return true + const schemes = options.allowSchemes ?? DEFAULT_EXTERNAL_SCHEMES + if (!schemes.includes(parsed.protocol.toLowerCase())) return false + if (parsed.protocol === 'https:') { + // 자격 증명이 박힌 URL(https://user@evil)이나 호스트 없는 URL은 열지 않는다. + if (!parsed.hostname || parsed.username || parsed.password) return false + } + return true +} + +/** + * URL이 앱 자신의 페이지인지. appOrigins는 'file://' 또는 'http://localhost:5173' 같은 오리진. + * file: 은 오리진이 'null'이라 scheme으로 비교한다. + */ +export function isAppOrigin(url: string, appOrigins: readonly string[]): boolean { + const parsed = parse(url) + if (!parsed) return false + for (const allowed of appOrigins) { + if (allowed === 'file://') { + if (parsed.protocol === 'file:') return true + continue + } + const allowedParsed = parse(allowed) + if (allowedParsed && allowedParsed.origin === parsed.origin) return true + } + return false +} diff --git a/server/supabase/migrations/20260928010000_sync_server_stamped_updated_at.sql b/server/supabase/migrations/20260928010000_sync_server_stamped_updated_at.sql new file mode 100644 index 0000000..fe4063a --- /dev/null +++ b/server/supabase/migrations/20260928010000_sync_server_stamped_updated_at.sql @@ -0,0 +1,33 @@ +-- 20260928010000_sync_server_stamped_updated_at.sql +-- 동기화 커서는 서버 시각(updated_at)에 기대는데, 기존 moddatetime 트리거는 BEFORE UPDATE 뿐이라 +-- INSERT 때는 클라이언트가 보낸 updated_at(오프라인 편집 시각·느린 시계·backfill된 옛 행)이 그대로 남았다. +-- 그러면 이미 동기화된 다른 기기의 keyset 커서(updated_at > cursor - 60s) 뒤로 들어가 영영 내려받히지 않는다. +-- 데스크톱은 이제 updated_at 을 보내지 않지만, 옛 클라이언트·다른 경로도 막기 위해 INSERT 에도 서버 시각을 찍는다. +-- +-- public.moddatetime() 은 이 프로젝트의 plpgsql 함수(NEW.updated_at = now())라 INSERT 에도 쓸 수 있다. +-- 적용 전 검토 필요: 운영 DB에 직접 적용하지 말 것(릴리스 절차로 배포). + +DROP TRIGGER IF EXISTS set_updated_at_on_insert_history ON public.history; +CREATE TRIGGER set_updated_at_on_insert_history + BEFORE INSERT ON public.history + FOR EACH ROW EXECUTE FUNCTION public.moddatetime(); + +DROP TRIGGER IF EXISTS set_updated_at_on_insert_dictionary ON public.dictionary; +CREATE TRIGGER set_updated_at_on_insert_dictionary + BEFORE INSERT ON public.dictionary + FOR EACH ROW EXECUTE FUNCTION public.moddatetime(); + +DROP TRIGGER IF EXISTS set_updated_at_on_insert_meetings ON public.meetings; +CREATE TRIGGER set_updated_at_on_insert_meetings + BEFORE INSERT ON public.meetings + FOR EACH ROW EXECUTE FUNCTION public.moddatetime(); + +DROP TRIGGER IF EXISTS set_updated_at_on_insert_meeting_memos ON public.meeting_memos; +CREATE TRIGGER set_updated_at_on_insert_meeting_memos + BEFORE INSERT ON public.meeting_memos + FOR EACH ROW EXECUTE FUNCTION public.moddatetime(); + +DROP TRIGGER IF EXISTS set_updated_at_on_insert_meeting_documents ON public.meeting_documents; +CREATE TRIGGER set_updated_at_on_insert_meeting_documents + BEFORE INSERT ON public.meeting_documents + FOR EACH ROW EXECUTE FUNCTION public.moddatetime();