fix(sync): keep desktop-only language and preset command across settings pulls
This commit is contained in:
parent
ddc78546f0
commit
75e053d72f
3 changed files with 258 additions and 64 deletions
103
apps/desktop/src/main/services/sync/settings-sync-policy.ts
Normal file
103
apps/desktop/src/main/services/sync/settings-sync-policy.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue