fix(web): persist general preferences in host settings
This commit is contained in:
@@ -8,28 +8,24 @@
|
||||
* settings General section — the theme feature owns its own settings surface.
|
||||
*/
|
||||
import type { Context } from 'cordis'
|
||||
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { BoundActions } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { bindSettingsPreference, type ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
|
||||
import type {} from '@deepseek-ai/dsh-client-locale/client'
|
||||
import type { AppearanceRowInjected } from './AppearanceRow.tsx'
|
||||
import { AppearanceRow } from './AppearanceRow.tsx'
|
||||
import { createAppearanceRowStore } from './settings-store.ts'
|
||||
import { ThemeSettingsController } from './theme-settings.ts'
|
||||
import { en, zh, type ThemeKey } from './locales.ts'
|
||||
import {
|
||||
DEFAULT_PREFERENCE, isThemePreference, THEME_SETTINGS_NAMESPACE,
|
||||
DEFAULT_PREFERENCE, isThemePreference, THEME_PREFERENCE_FIELD, THEME_SETTINGS_NAMESPACE,
|
||||
type ThemePreference,
|
||||
} from '../theme-settings.ts'
|
||||
|
||||
export type { AppearanceRowComponentProps, AppearanceRowInjected } from './AppearanceRow.tsx'
|
||||
export type { AppearanceRowState } from './settings-store.ts'
|
||||
export type { ThemePreferenceTarget } from './theme-settings.ts'
|
||||
export { ThemeSettingsController } from './theme-settings.ts'
|
||||
export type { ThemeKey } from './locales.ts'
|
||||
export {
|
||||
DEFAULT_PREFERENCE, THEME_PREFERENCE_FIELD, THEME_SETTINGS_NAMESPACE,
|
||||
DEFAULT_PREFERENCE, THEME_PREFERENCE_FIELD, THEME_PREFERENCES, THEME_SETTINGS_NAMESPACE,
|
||||
type ThemePreference,
|
||||
} from '../theme-settings.ts'
|
||||
|
||||
@@ -196,7 +192,6 @@ export class ThemeService {
|
||||
this.themes = this.themes.filter(t => t.id !== definition.id)
|
||||
if (this.preference === definition.id) {
|
||||
this.preference = DEFAULT_PREFERENCE
|
||||
this.persist(this.preference)
|
||||
}
|
||||
this.publish()
|
||||
}
|
||||
@@ -235,33 +230,17 @@ export const inject = ['slots', 'locale', 'connection']
|
||||
* slot (a feature owns its settings surface).
|
||||
* @param ctx - client cordis context.
|
||||
*/
|
||||
export async function apply(ctx: ClientContext): Promise<void> {
|
||||
const connection = ctx.get('connection') as ConnectionHandle
|
||||
export function apply(ctx: ClientContext): void {
|
||||
const theme = new ThemeService(ctx)
|
||||
const controller = new ThemeSettingsController(
|
||||
connection.api,
|
||||
theme,
|
||||
connection.isLoopback ? 'host' : 'memory',
|
||||
)
|
||||
const controller = bindSettingsPreference(ctx, {
|
||||
namespace: THEME_SETTINGS_NAMESPACE,
|
||||
field: THEME_PREFERENCE_FIELD,
|
||||
decode: value => isThemePreference(value) ? value : undefined,
|
||||
sync: (preference) => { theme.syncPreference(preference) },
|
||||
})
|
||||
theme.bindPersistence((preference) => { void controller.persist(preference) })
|
||||
await controller.load()
|
||||
ctx.provide('theme', theme)
|
||||
|
||||
ctx.effect(() => {
|
||||
const refresh = (ns?: string): void => {
|
||||
if (ns !== undefined && ns !== THEME_SETTINGS_NAMESPACE) return
|
||||
void controller.load()
|
||||
}
|
||||
const disposers = [
|
||||
ctx.on('settings/changed', refresh),
|
||||
ctx.on('connection/reset', () => { refresh() }),
|
||||
]
|
||||
return () => {
|
||||
controller.dispose()
|
||||
for (const dispose of disposers) dispose()
|
||||
}
|
||||
}, 'ui-theme: settings invalidations')
|
||||
|
||||
ctx.effect(() => ctx.locale.register(SETTINGS_NS, { zh, en }), 'ui-theme: settings row dictionaries')
|
||||
|
||||
const store = createAppearanceRowStore()
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
/** Host-backed persistence controller for the browser theme preference. */
|
||||
|
||||
import type {
|
||||
IApiClient, SettingsNamespaceView,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
import {
|
||||
THEME_PREFERENCE_FIELD, THEME_SETTINGS_NAMESPACE, isThemePreference,
|
||||
type ThemePreference,
|
||||
} from '../theme-settings.ts'
|
||||
|
||||
/** Preference target implemented by {@link ThemeService}. */
|
||||
export interface ThemePreferenceTarget {
|
||||
/**
|
||||
* Apply a Host value without writing it back.
|
||||
* @param preference - validated durable preference.
|
||||
*/
|
||||
syncPreference(preference: ThemePreference): void
|
||||
}
|
||||
|
||||
function preferenceOf(view: SettingsNamespaceView): ThemePreference | undefined {
|
||||
if (typeof view.value !== 'object' || view.value === null) return undefined
|
||||
const preference = (view.value as Record<string, unknown>)[THEME_PREFERENCE_FIELD]
|
||||
return isThemePreference(preference) ? preference : undefined
|
||||
}
|
||||
|
||||
/** Coordinates startup reads, ordered writes, and pushed invalidations. */
|
||||
export class ThemeSettingsController {
|
||||
private generation = 0
|
||||
private writeTail: Promise<void> = Promise.resolve()
|
||||
|
||||
/**
|
||||
* @param api - settings wire face.
|
||||
* @param target - live theme service receiving durable values.
|
||||
* @param persistence - remote browsers stay process-local because the settings API is loopback-only.
|
||||
*/
|
||||
constructor(
|
||||
private readonly api: Pick<IApiClient, 'settings'>,
|
||||
private readonly target: ThemePreferenceTarget,
|
||||
private readonly persistence: 'host' | 'memory' = 'host',
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Load the durable preference after earlier writes settle; the latest operation wins.
|
||||
* @returns nothing; an unavailable or invalid descriptor leaves the last good value active.
|
||||
*/
|
||||
async load(): Promise<void> {
|
||||
const generation = ++this.generation
|
||||
if (this.persistence === 'memory') return
|
||||
await this.writeTail
|
||||
if (generation !== this.generation) return
|
||||
let response: Awaited<ReturnType<Pick<IApiClient, 'settings'>['settings']['describe']>>
|
||||
try {
|
||||
response = await this.api.settings.describe({})
|
||||
} catch (_settingsReadFailure) {
|
||||
// A transport failure leaves the last good in-process theme active. A
|
||||
// connection/reset or settings/changed notification retries the read.
|
||||
return
|
||||
}
|
||||
if (!response.result.ok || generation !== this.generation) return
|
||||
const view = response.result.value.namespaces.find(
|
||||
candidate => candidate.ns === THEME_SETTINGS_NAMESPACE,
|
||||
)
|
||||
if (view === undefined) return
|
||||
const preference = preferenceOf(view)
|
||||
if (preference !== undefined) this.target.syncPreference(preference)
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist one user selection. Writes are serialized so rapid picks land in
|
||||
* gesture order; a rejected latest write reloads the durable value.
|
||||
* @param preference - selected built-in preference.
|
||||
* @returns nothing after the write or recovery read settles.
|
||||
*/
|
||||
async persist(preference: ThemePreference): Promise<void> {
|
||||
const generation = ++this.generation
|
||||
if (this.persistence === 'memory') return
|
||||
const write = this.writeTail.then(async () => {
|
||||
const response = await this.api.settings.mutate({
|
||||
ns: THEME_SETTINGS_NAMESPACE,
|
||||
ops: [{ op: 'set', path: [THEME_PREFERENCE_FIELD], value: preference }],
|
||||
})
|
||||
if (!response.result.ok) throw new Error(response.result.error.message)
|
||||
if (generation === this.generation) {
|
||||
const accepted = preferenceOf(response.result.value)
|
||||
if (accepted !== undefined) this.target.syncPreference(accepted)
|
||||
}
|
||||
})
|
||||
this.writeTail = write.catch(() => {})
|
||||
try {
|
||||
await write
|
||||
} catch {
|
||||
if (generation === this.generation) await this.load()
|
||||
}
|
||||
}
|
||||
|
||||
/** Prevent in-flight reads and writes from publishing after plugin disposal. */
|
||||
dispose(): void {
|
||||
this.generation += 1
|
||||
}
|
||||
}
|
||||
@@ -4,12 +4,12 @@ import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import { settingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
import {
|
||||
DEFAULT_PREFERENCE, THEME_PREFERENCE_FIELD, THEME_SETTINGS_NAMESPACE,
|
||||
DEFAULT_PREFERENCE, THEME_PREFERENCE_FIELD, THEME_PREFERENCES, THEME_SETTINGS_NAMESPACE,
|
||||
type ThemePreference,
|
||||
} from './theme-settings.ts'
|
||||
|
||||
export {
|
||||
DEFAULT_PREFERENCE, THEME_PREFERENCE_FIELD, THEME_SETTINGS_NAMESPACE,
|
||||
DEFAULT_PREFERENCE, THEME_PREFERENCE_FIELD, THEME_PREFERENCES, THEME_SETTINGS_NAMESPACE,
|
||||
type ThemePreference,
|
||||
} from './theme-settings.ts'
|
||||
|
||||
@@ -18,7 +18,7 @@ interface ThemeSettings {
|
||||
}
|
||||
|
||||
const ThemeSettingsSchema: z<ThemeSettings> = z.object({
|
||||
[THEME_PREFERENCE_FIELD]: z.union(['light', 'dark', 'system']).default(DEFAULT_PREFERENCE),
|
||||
[THEME_PREFERENCE_FIELD]: z.union([...THEME_PREFERENCES]).default(DEFAULT_PREFERENCE),
|
||||
})
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
/** Theme preferences stored in the Host user-settings document. */
|
||||
|
||||
/** Built-in preferences accepted at the registry and settings boundaries. */
|
||||
export const THEME_PREFERENCES = ['light', 'dark', 'system'] as const
|
||||
|
||||
/** Settings namespace owned by the theme plugin. */
|
||||
export const THEME_SETTINGS_NAMESPACE = 'ui-theme'
|
||||
|
||||
@@ -7,7 +10,7 @@ export const THEME_SETTINGS_NAMESPACE = 'ui-theme'
|
||||
export const THEME_PREFERENCE_FIELD = 'preference'
|
||||
|
||||
/** Theme preference persisted by the product Appearance row. */
|
||||
export type ThemePreference = 'light' | 'dark' | 'system'
|
||||
export type ThemePreference = typeof THEME_PREFERENCES[number]
|
||||
|
||||
/** Default preference when the user-settings document has no override. */
|
||||
export const DEFAULT_PREFERENCE: ThemePreference = 'system'
|
||||
@@ -18,5 +21,5 @@ export const DEFAULT_PREFERENCE: ThemePreference = 'system'
|
||||
* @returns whether the value is a built-in preference.
|
||||
*/
|
||||
export function isThemePreference(value: unknown): value is ThemePreference {
|
||||
return value === 'light' || value === 'dark' || value === 'system'
|
||||
return THEME_PREFERENCES.some(preference => preference === value)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user