d3ro-voice/apps/desktop/src/main/config-effects.ts

87 lines
3.4 KiB
TypeScript

// src/main/config-effects.ts
// 설정 키별 부수효과 레지스트리 — 어느 경로로 바뀌든(범용 config:set, 전용 IPC, 원격 동기화)
// 같은 효과가 한 번씩 적용되게 한다.
//
// 예전엔 효과가 입력 경로에 붙어 있었다: 테마 팝업 다시 칠하기는 SET_THEME 와 원격 동기화 경로에만,
// OS 로그인 항목 갱신은 SET_AUTO_LAUNCH 에만 있었다. 설정 화면은 범용 config:set 을 써서 두 효과가
// 모두 빠졌다(자동 실행이 재부팅 뒤 동작하지 않고, 미리 로드된 팝업이 이전 테마로 남았다).
// 이제 효과는 설정 변경 이벤트 하나를 구독하는 이 레지스트리에 등록한다(OCP — 새 키는 등록만 한다).
// Electron 에 의존하지 않는다: 구독 함수와 효과는 주입받는다(테스트는 가짜를 넘긴다).
export interface ConfigChange {
key: string
value: unknown
previousValue?: unknown
}
export type ConfigEffect = (value: unknown, previousValue: unknown) => void
export interface ConfigEffectsDeps {
/** 설정 변경 구독 (ConfigService.onConfigChanged) — 해지 함수를 돌려준다 */
subscribe: (listener: (change: ConfigChange) => void) => () => void
/** 효과가 던진 오류 — 다른 효과와 설정 저장은 계속된다 */
onError?: (key: string, error: unknown) => void
}
function isPrimitive(value: unknown): boolean {
return value === null || (typeof value !== 'object' && typeof value !== 'function')
}
/**
* 값이 실제로 바뀌었는가. 원시값은 같으면 건너뛴다 — 효과 안에서 같은 값을 다시 저장해도
* (AutoLaunchService.setEnabled 는 적용 후 configSet 을 부른다) 재귀하지 않는다.
* 객체·배열은 비교하지 않고 적용한다(효과는 멱등이어야 한다).
*/
export function isEffectiveChange(change: ConfigChange): boolean {
if (isPrimitive(change.value) && isPrimitive(change.previousValue)) {
return !Object.is(change.value, change.previousValue)
}
return true
}
export class ConfigEffects {
private readonly _effects = new Map<string, ConfigEffect[]>()
private _unsubscribe: (() => void) | null = null
constructor(private readonly _deps: ConfigEffectsDeps) {}
/** 키에 효과를 등록한다. 돌려받은 함수로 등록을 푼다. */
register(key: string, effect: ConfigEffect): () => void {
const list = this._effects.get(key) ?? []
list.push(effect)
this._effects.set(key, list)
return () => {
const current = this._effects.get(key)
if (!current) return
const next = current.filter((e) => e !== effect)
if (next.length > 0) this._effects.set(key, next)
else this._effects.delete(key)
}
}
/** 설정 변경 구독을 시작한다(멱등). */
start(): void {
if (this._unsubscribe) return
this._unsubscribe = this._deps.subscribe((change) => this.dispatch(change))
}
dispose(): void {
this._unsubscribe?.()
this._unsubscribe = null
this._effects.clear()
}
/** 변경 하나를 등록된 효과에 전달한다(테스트·직접 호출용). */
dispatch(change: ConfigChange): void {
const effects = this._effects.get(change.key)
if (!effects || effects.length === 0) return
if (!isEffectiveChange(change)) return
for (const effect of [...effects]) {
try {
effect(change.value, change.previousValue)
} catch (err) {
this._deps.onError?.(change.key, err)
}
}
}
}