From 75e053d72f21c65312b5fe89eecc281202ab677e Mon Sep 17 00:00:00 2001 From: Yun Chan Date: Mon, 28 Sep 2026 02:16:15 +0900 Subject: [PATCH] fix(sync): keep desktop-only language and preset command across settings pulls --- .../services/sync/settings-sync-policy.ts | 103 +++++++++++++ .../src/main/services/sync/settings-sync.ts | 83 +++-------- .../sync/settings-sync-redteam-r2-2.test.ts | 136 ++++++++++++++++++ 3 files changed, 258 insertions(+), 64 deletions(-) create mode 100644 apps/desktop/src/main/services/sync/settings-sync-policy.ts create mode 100644 apps/desktop/tests/main/sync/settings-sync-redteam-r2-2.test.ts diff --git a/apps/desktop/src/main/services/sync/settings-sync-policy.ts b/apps/desktop/src/main/services/sync/settings-sync-policy.ts new file mode 100644 index 0000000..8f98083 --- /dev/null +++ b/apps/desktop/src/main/services/sync/settings-sync-policy.ts @@ -0,0 +1,103 @@ +// src/main/services/sync/settings-sync-policy.ts +// 데스크톱 설정 ↔ 모바일 user_settings 매핑 규칙(순수 함수). IO(설정 저장소·원격·명령 서비스)는 +// settings-sync.ts가 맡고, 여기서는 "무엇을 올리고 무엇을 받아들일지"만 결정한다. +// locale ↔ language (서버가 저장할 수 있는 ko/en만. 데스크톱 전용 언어는 로컬에 둔다) +// theme_mode ↔ theme (system/light/dark 만. 데스크톱 전용 테마는 로컬에 둔다) +// auto_polish_enabled ↔ defaultLLMAction ('none' 이면 꺼짐) +// active_instruction_id ↔ activeInstructionId (양쪽에 있는 사용자 명령일 때만) + +import type { LLMActionSelection, ThemeMode } from '@d3ro/core/types' +import type { RemoteRow } from './sync-types' + +export interface LocalSettings { + language: string + theme: ThemeMode + defaultLLMAction: LLMActionSelection + activeInstructionId: string +} + +export type SharedLocale = 'ko' | 'en' +export type SharedThemeMode = 'system' | 'light' | 'dark' + +export interface RemoteSettingsPatch { + locale?: SharedLocale + theme_mode?: SharedThemeMode + auto_polish_enabled: boolean +} + +/** 서버에 올릴 활성 명령 변경. null이면 올릴 것이 없다. */ +export interface ActiveInstructionPush { + instructionId: string | null +} + +function isSharedLocale(value: unknown): value is SharedLocale { + return value === 'ko' || value === 'en' +} + +function isSharedTheme(theme: ThemeMode): boolean { + return theme === 'auto' || theme === 'light' || theme === 'dark' +} + +function remoteThemeToLocal(value: unknown): ThemeMode | null { + if (value === 'system') return 'auto' + if (value === 'light' || value === 'dark') return value + return null +} + +export function toRemoteSettings(local: LocalSettings): RemoteSettingsPatch { + const patch: RemoteSettingsPatch = { auto_polish_enabled: local.defaultLLMAction !== 'none' } + if (isSharedLocale(local.language)) patch.locale = local.language + if (local.theme === 'auto') patch.theme_mode = 'system' + else if (local.theme === 'light' || local.theme === 'dark') patch.theme_mode = local.theme + return patch +} + +/** 원격 행이 요구하는 로컬 변경만 돌려준다(같으면 빈 객체). */ +export function remoteToLocalPatch( + row: RemoteRow, + local: LocalSettings, + userInstructionIds: ReadonlySet +): Partial { + const patch: Partial = {} + + // 데스크톱 전용 언어(ja 등)는 서버에 올라가지 않으므로 서버의 ko/en(기본값 포함)이 덮으면 안 된다. + if (isSharedLocale(local.language) && isSharedLocale(row.locale) && row.locale !== local.language) { + patch.language = row.locale + } + + if (isSharedTheme(local.theme)) { + const theme = remoteThemeToLocal(row.theme_mode) + if (theme && theme !== local.theme) patch.theme = theme + } + + if (row.auto_polish_enabled === false && local.defaultLLMAction !== 'none') patch.defaultLLMAction = 'none' + if (row.auto_polish_enabled === true && local.defaultLLMAction === 'none') patch.defaultLLMAction = 'refine' + + const remoteActive = row.active_instruction_id + if (typeof remoteActive === 'string' && userInstructionIds.has(remoteActive)) { + if (remoteActive !== local.activeInstructionId) patch.activeInstructionId = remoteActive + } else if (remoteActive === null && userInstructionIds.has(local.activeInstructionId)) { + // 사용자 명령이 해제됐다. 데스크톱 프리셋(builtin-*)이 켜져 있으면 모바일이 알 수 없으므로 둔다. + patch.activeInstructionId = '' + } + return patch +} + +/** + * 로컬 활성 명령을 서버에 어떻게 올릴지 정한다. + * - '없음' → null + * - 양쪽에 있는 사용자 명령 → 그 id + * - 데스크톱 프리셋(모바일에 없음) → 서버가 아는 사용자 명령을 가리키고 있으면 null로 해제한다. + * 그대로 두면 다음 pull이 그 사용자 명령을 다시 켜서 사용자가 고른 프리셋을 되돌린다. + * 서버가 모르는 값(아직 받지 못한 명령 등)은 건드리지 않는다. + */ +export function resolveActiveInstructionPush( + localActive: string, + remoteActive: string | null, + userInstructionIds: ReadonlySet +): ActiveInstructionPush | null { + if (localActive === '') return remoteActive !== null ? { instructionId: null } : null + if (userInstructionIds.has(localActive)) return remoteActive !== localActive ? { instructionId: localActive } : null + if (remoteActive !== null && userInstructionIds.has(remoteActive)) return { instructionId: null } + return null +} diff --git a/apps/desktop/src/main/services/sync/settings-sync.ts b/apps/desktop/src/main/services/sync/settings-sync.ts index 38eb411..8d89c20 100644 --- a/apps/desktop/src/main/services/sync/settings-sync.ts +++ b/apps/desktop/src/main/services/sync/settings-sync.ts @@ -1,16 +1,22 @@ // src/main/services/sync/settings-sync.ts // 모바일 user_settings(한 행, revision 낙관적 동시성)와 데스크톱 설정 중 뜻이 같은 것만 맞춘다. -// locale ↔ language (모바일이 받는 ko/en만) -// theme_mode ↔ theme (system/light/dark 만 — 데스크톱 전용 테마는 로컬에 둔다) -// auto_polish_enabled ↔ defaultLLMAction ('none' 이면 꺼짐) -// active_instruction_id ↔ activeInstructionId (양쪽에 있는 사용자 명령일 때만) +// 매핑 규칙은 settings-sync-policy.ts(순수 함수)에 있고, 여기서는 설정 저장소·원격 IO만 다룬다. import type { LLMActionSelection, ThemeMode } from '@d3ro/core/types' import { configGet, configSet } from '../ConfigService' import { getCustomInstructionService } from '../CustomInstructionService' import { isUuid, type PushContext } from './sync-adapters' +import { + remoteToLocalPatch, + resolveActiveInstructionPush, + toRemoteSettings, + type LocalSettings, + type RemoteSettingsPatch, +} from './settings-sync-policy' import { toSyncRemoteError, type RemoteRow, type SyncRemoteError } from './sync-types' +export { remoteToLocalPatch, toRemoteSettings, type LocalSettings, type RemoteSettingsPatch } from './settings-sync-policy' + export const SETTINGS_ROW_ID = 'self' /** 이 키들이 바뀌면 설정 동기화 대상이다 */ @@ -25,19 +31,6 @@ export function isApplyingRemoteSettings(): boolean { return applyingRemote } -export interface LocalSettings { - language: string - theme: ThemeMode - defaultLLMAction: LLMActionSelection - activeInstructionId: string -} - -export interface RemoteSettingsPatch { - locale?: 'ko' | 'en' - theme_mode?: 'system' | 'light' | 'dark' - auto_polish_enabled: boolean -} - export function readLocalSettings(): LocalSettings { return { language: String(configGet('language') ?? 'ko'), @@ -47,45 +40,6 @@ export function readLocalSettings(): LocalSettings { } } -export function toRemoteSettings(local: LocalSettings): RemoteSettingsPatch { - const patch: RemoteSettingsPatch = { auto_polish_enabled: local.defaultLLMAction !== 'none' } - if (local.language === 'ko' || local.language === 'en') patch.locale = local.language - if (local.theme === 'auto') patch.theme_mode = 'system' - else if (local.theme === 'light' || local.theme === 'dark') patch.theme_mode = local.theme - return patch -} - -/** 원격 행이 요구하는 로컬 변경만 돌려준다(같으면 빈 객체). */ -export function remoteToLocalPatch( - row: RemoteRow, - local: LocalSettings, - userInstructionIds: ReadonlySet -): Partial { - const patch: Partial = {} - if ((row.locale === 'ko' || row.locale === 'en') && row.locale !== local.language) patch.language = row.locale - - const sharedTheme = local.theme === 'auto' || local.theme === 'light' || local.theme === 'dark' - if (sharedTheme) { - const theme: ThemeMode | null = - row.theme_mode === 'system' ? 'auto' : row.theme_mode === 'light' || row.theme_mode === 'dark' ? row.theme_mode : null - if (theme && theme !== local.theme) patch.theme = theme - } - - if (row.auto_polish_enabled === false && local.defaultLLMAction !== 'none') patch.defaultLLMAction = 'none' - if (row.auto_polish_enabled === true && local.defaultLLMAction === 'none') patch.defaultLLMAction = 'refine' - - const remoteActive = row.active_instruction_id - if (isUuid(remoteActive)) { - if (userInstructionIds.has(remoteActive) && remoteActive !== local.activeInstructionId) { - patch.activeInstructionId = remoteActive - } - } else if (remoteActive === null && userInstructionIds.has(local.activeInstructionId)) { - // 사용자 명령이 해제됐다. 데스크톱 프리셋(builtin-*)이 켜져 있으면 모바일이 알 수 없으므로 둔다. - patch.activeInstructionId = '' - } - return patch -} - function userInstructionIds(): Set { return new Set( getCustomInstructionService() @@ -136,14 +90,15 @@ export async function pushSettings(ctx: PushContext): Promise = {}): LocalSettings { + return { language: 'ko', theme: 'auto', defaultLLMAction: 'refine', activeInstructionId: '', ...overrides } +} + +describe('settings-sync-policy: 언어', () => { + it('데스크톱 전용 언어(ja)는 서버의 ko/en이 덮지 않는다', () => { + const row = { locale: 'ko', theme_mode: 'system', auto_polish_enabled: true, active_instruction_id: null } + expect(remoteToLocalPatch(row, local({ language: 'ja' }), new Set())).toEqual({}) + expect(remoteToLocalPatch({ ...row, locale: 'en' }, local({ language: 'zh-TW' }), new Set())).toEqual({}) + }) + + it('로컬이 ko/en이면 서버 언어를 받는다', () => { + const row = { locale: 'en', theme_mode: 'system', auto_polish_enabled: true, active_instruction_id: null } + expect(remoteToLocalPatch(row, local({ language: 'ko' }), new Set())).toEqual({ language: 'en' }) + }) + + it('서버가 모르는 locale 값은 무시한다', () => { + const row = { locale: 'fr', theme_mode: 'system', auto_polish_enabled: true, active_instruction_id: null } + expect(remoteToLocalPatch(row, local({ language: 'ko' }), new Set())).toEqual({}) + }) + + it('push는 ko/en만 locale로 보낸다', () => { + expect(toRemoteSettings(local({ language: 'en' }))).toMatchObject({ locale: 'en' }) + expect(toRemoteSettings(local({ language: 'ja' }))).not.toHaveProperty('locale') + }) +}) + +describe('settings-sync-policy: 활성 명령', () => { + const ids = new Set([U, U2]) + + it('pull: 서버의 사용자 명령은 로컬 사용자 명령·없음을 바꾼다', () => { + expect(remoteToLocalPatch({ active_instruction_id: U }, local({ activeInstructionId: '' }), ids)).toEqual({ + activeInstructionId: U, + }) + expect(remoteToLocalPatch({ active_instruction_id: null }, local({ activeInstructionId: U }), ids)).toEqual({ + activeInstructionId: '', + }) + expect(remoteToLocalPatch({ active_instruction_id: 'not-local' }, local({ activeInstructionId: U }), ids)).toEqual({}) + }) + + it('push: 없음·사용자 명령은 서버와 다를 때만 올린다', () => { + expect(resolveActiveInstructionPush('', U, ids)).toEqual({ instructionId: null }) + expect(resolveActiveInstructionPush('', null, ids)).toBeNull() + expect(resolveActiveInstructionPush(U, U2, ids)).toEqual({ instructionId: U }) + expect(resolveActiveInstructionPush(U, U, ids)).toBeNull() + }) + + it('push: 데스크톱 프리셋은 서버의 알려진 사용자 명령을 해제하고, 모르는 값은 건드리지 않는다', () => { + expect(resolveActiveInstructionPush('builtin-summarize', U, ids)).toEqual({ instructionId: null }) + expect(resolveActiveInstructionPush('builtin-summarize', null, ids)).toBeNull() + expect(resolveActiveInstructionPush('builtin-summarize', '44444444-4444-4444-8444-444444444444', ids)).toBeNull() + }) +}) + +describe('설정 동기화 왕복 (엔진)', () => { + let testDb: ReturnType + let remote: FakeSyncRemote + let engine: SyncEngine + + beforeEach(() => { + testDb = createTestDb() + bindTestDatabase(testDb.db, USER) + initInMemoryConfig() + resetCustomInstructionServiceForTests() + resetDictationTemplateServiceForTests() + resetMeetingDocTemplateServiceForTests() + resetRAGServiceForTests() + getCustomInstructionService().initialize() + vi.spyOn(globalThis, 'fetch').mockRejectedValue(new TypeError('fetch failed')) + remote = new FakeSyncRemote(USER) + engine = new SyncEngine({ remote, userId: USER }) + }) + + afterEach(() => { + vi.restoreAllMocks() + engine.dispose() + unbindTestDatabase() + resetInMemoryConfig() + testDb.close() + }) + + it('데스크톱에서 ja를 고르면 이후 pull이 ko로 되돌리지 않는다', async () => { + remote.mobileInsert('user_settings', { locale: 'ko', theme_mode: 'system', auto_polish_enabled: true, revision: 1, active_instruction_id: null }) + await engine.runFullSync() + configSet('language', 'ja') + enqueueChange('user_settings', 'self', 'upsert') + await engine.flush() + await engine.pull() + expect(configGet('language')).toBe('ja') + expect(remote.rows('user_settings')[0].locale).toBe('ko') + }) + + it('프리셋을 고르면 서버 활성 명령이 해제되어 다음 pull이 사용자 명령으로 되돌리지 않는다', async () => { + const command = getCustomInstructionService().create({ name: 'Calm', description: '', prompt: 'Make it calm' }) + configSet('activeInstructionId', command.id) + await engine.runFullSync() + enqueueChange('user_settings', 'self', 'upsert') + await engine.flush() + expect(remote.rows('user_settings')[0].active_instruction_id).toBe(command.id) + + configSet('activeInstructionId', 'builtin-summarize') + enqueueChange('user_settings', 'self', 'upsert') + await engine.flush() + expect(remote.rows('user_settings')[0].active_instruction_id).toBeNull() + + await engine.pull() + expect(configGet('activeInstructionId')).toBe('builtin-summarize') + }) +})