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