fix(sync): keep desktop-only language and preset command across settings pulls

This commit is contained in:
Yun Chan 2026-09-28 02:16:15 +09:00
parent ddc78546f0
commit 75e053d72f
3 changed files with 258 additions and 64 deletions

View file

@ -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<string>
): Partial<LocalSettings> {
const patch: Partial<LocalSettings> = {}
// 데스크톱 전용 언어(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<string>
): 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
}

View file

@ -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<string>
): Partial<LocalSettings> {
const patch: Partial<LocalSettings> = {}
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<string> {
return new Set(
getCustomInstructionService()
@ -136,14 +90,15 @@ export async function pushSettings(ctx: PushContext): Promise<SyncRemoteError |
}
if (!saved) return toSyncRemoteError({ code: '40001', message: 'user_settings changed repeatedly on another device' })
// 활성 명령: 양쪽에 있는 사용자 명령이거나 '없음'일 때만 올린다(프리셋은 모바일에 없다).
const ids = userInstructionIds()
const active = local.activeInstructionId
const remoteActive = remote?.active_instruction_id ?? null
if (active === '' && remoteActive !== null) {
await ctx.remote.rpc('set_active_custom_instruction', { instruction_id: null })
} else if (ids.has(active) && remoteActive !== active) {
await ctx.remote.rpc('set_active_custom_instruction', { instruction_id: active })
// 활성 명령: 사용자 명령·'없음'은 그대로, 데스크톱 프리셋은 서버의 사용자 명령을 해제한다.
const remoteActive = remote?.active_instruction_id
const activePush = resolveActiveInstructionPush(
local.activeInstructionId,
typeof remoteActive === 'string' ? remoteActive : null,
userInstructionIds()
)
if (activePush) {
await ctx.remote.rpc('set_active_custom_instruction', { instruction_id: activePush.instructionId })
}
return null
} catch (err) {