// Area c, settings: app.describeSettings, app.setSettings and // app.undoSettings over the allow-list in lib/settings-schema.ts. // // A change is checked at the moment it is made, the way the settings page // would show it: the key must be in the allow-list, the value one the // setting may take there, the row must be shown (its feature gate, its // page's gate, no-third-party mode, the admin's hidden flag), not locked by // the admin and within the admin's restriction. It is written through the // same setters the pages use (updateSetting, setTheme, setLocale, // setViewMode), so plugin hooks run, the open page shows it and it syncs; // then it is read back, since another plugin may have stopped it. import { useSettingsStore } from '@/stores/settings-store'; import { useThemeStore } from '@/stores/theme-store'; import { useLocaleStore } from '@/stores/locale-store'; import { useCalendarStore } from '@/stores/calendar-store'; import { usePolicyStore } from '@/stores/policy-store'; import { settingsHooks } from '../../plugin-hooks'; import { noThirdPartyTraffic } from '../../third-party-mode'; import { toastDurationAtLeast } from '../../accessibility-settings'; import { detectBrowserLocale } from '@/i18n/detect-locale'; import { getByPath, settingsTabLabelKey } from '../../settings-search'; import { DENIED_SETTINGS, PAGE_GATES, SETTINGS_SCHEMA, isAllowedSettingValue, settingValueLabelKey, type SettingSchemaEntry, } from '../../settings-schema'; import { isPlainRecord, sessionClient } from '../host-api-input'; import { messagesFor } from '../host-api-app'; import { issueUndoToken, undoAccount, withUndoToken } from './c.undo'; import type { SettingDescription, SettingRefusal, SettingValue, SettingsChangeResult, SettingsUndoResult, } from './c.types'; /** Most settings one call names. */ export const MAX_PLUGIN_SETTINGS = 20; /** How long a change may wait for another plugin's onBeforeSettingChange. */ const VETO_WAIT_MS = 3000; type Messages = Record; export function currentSettingValue(entry: SettingSchemaEntry): SettingValue | null { let value: unknown; switch (entry.store) { case 'theme': value = (useThemeStore.getState() as unknown as { theme?: unknown }).theme; break; case 'locale': value = useLocaleStore.getState().locale; break; case 'calendar': value = useCalendarStore.getState().viewMode; break; default: value = (useSettingsStore.getState() as unknown as Record)[entry.key]; } if (Array.isArray(value)) return [...value] as SettingValue; return typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean' ? value : null; } const same = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b); /** * app.getSettings names two settings after what they mean to a reader * (language, calendarView); setSettings takes those names too. */ const SETTING_ALIASES: Readonly> = { language: 'locale', calendarView: 'viewMode' }; export const canonicalSetting = (name: string): string => SETTING_ALIASES[name] ?? name; const schemaEntry = (key: string): SettingSchemaEntry | undefined => (Object.prototype.hasOwnProperty.call(SETTINGS_SCHEMA, key) ? SETTINGS_SCHEMA[key] : undefined); function gateOn(gate: Parameters['isFeatureEnabled']>[0]): boolean { try { return usePolicyStore.getState().isFeatureEnabled(gate); } catch { return true; } } /** * Why `key` can't be changed (to `value`, when given) right now, or null. * `pending` holds the other values of the same call (calendar hours are * checked against each other). */ export function settingRefusal(name: string, value?: unknown, pending: Record = {}): SettingRefusal | null { const key = canonicalSetting(name); const entry = schemaEntry(key); if (!entry) return DENIED_SETTINGS.includes(key) || key in (useSettingsStore.getState() as object) ? 'not_allowed' : 'unknown'; const policy = usePolicyStore.getState(); if (entry.gate && !gateOn(entry.gate)) return 'unavailable'; const pageGate = PAGE_GATES[entry.page]; if (pageGate && !gateOn(pageGate)) return 'unavailable'; if (entry.thirdParty && noThirdPartyTraffic()) return 'unavailable'; if (entry.store === 'settings' && policy.isSettingHidden(key)) return 'hidden'; if (entry.store === 'settings' && policy.isSettingLocked(key) && !entry.preset) return 'locked'; if (entry.store === 'theme' && policy.getColorModePolicy().locked) return 'locked'; if (value === undefined) return null; if (!isAllowedSettingValue(entry, value)) return 'bad_value'; if (entry.kind.type === 'choice' || entry.kind.type === 'list') { const values = Array.isArray(value) ? value : [value]; const gated = entry.kind.options.filter((o) => o.gate && !gateOn(o.gate)); if (gated.some((o) => values.includes(o.value))) return 'unavailable'; } const restricted = restrictionRefusal(entry, value); if (restricted) return restricted; if (key === 'calendarDayStartHour' || key === 'calendarDayEndHour') { const state = useSettingsStore.getState(); const start = Number(key === 'calendarDayStartHour' ? value : pending.calendarDayStartHour ?? state.calendarDayStartHour); const end = Number(key === 'calendarDayEndHour' ? value : pending.calendarDayEndHour ?? state.calendarDayEndHour); if (!(end > start)) return 'bad_value'; } return null; } /** The admin's restriction on the value (allowed values, minimum, maximum), or null. */ function restrictionRefusal(entry: SettingSchemaEntry, value: unknown): SettingRefusal | null { const key = entry.key; const restriction = entry.store === 'settings' ? usePolicyStore.getState().getRestriction(key) : undefined; if (!restriction) return null; if (restriction.allowedValues && !restriction.allowedValues.some((v) => same(v, value))) return 'bad_value'; if (typeof value === 'number') { if (key === 'toastDuration' && typeof restriction.min === 'number') { if (!toastDurationAtLeast(value, restriction.min)) return 'bad_value'; } else if (typeof restriction.min === 'number' && value < restriction.min) return 'bad_value'; if (typeof restriction.max === 'number' && value > restriction.max) return 'bad_value'; } return null; } function format(messages: Messages, key: string, params: Record = {}): string | null { const raw = getByPath(messages, key); if (typeof raw !== 'string') return null; return raw.replace(/\{(\w+)\}/g, (all, name: string) => params[name] ?? all); } function userLocale(): string { return useLocaleStore.getState().locale || detectBrowserLocale('en'); } /** A value in the user's words: the page's own label, else a formatted value. */ export function settingValueLabel(entry: SettingSchemaEntry, value: unknown, messages: Messages, locale = userLocale()): string { if (value === null || value === undefined) return ''; const key = settingValueLabelKey(entry, value); if (key) { const zone = (() => { try { return Intl.DateTimeFormat().resolvedOptions().timeZone; } catch { return 'UTC'; } })(); const text = format(messages, key, { zone }); if (text) return text; } const kind = entry.kind; if ((kind.type === 'choice' || kind.type === 'list')) { const label = (v: unknown) => { const option = kind.options.find((o) => o.value === v); return (option?.label && format(messages, option.label)) || option?.text || String(v); }; return Array.isArray(value) ? value.map(label).join(', ') : label(value); } if (kind.type === 'hour' && typeof value === 'number') return `${String(value).padStart(2, '0')}:00`; if (kind.type === 'days' && Array.isArray(value)) { // 2023-01-01 was a Sunday (day 0). return value.map((d) => { try { return new Intl.DateTimeFormat(locale, { weekday: 'long', timeZone: 'UTC' }).format(new Date(Date.UTC(2023, 0, 1 + Number(d)))); } catch { return String(d); } }).join(', '); } if (kind.type === 'locale' && typeof value === 'string' && value && value !== 'auto') { try { return new Intl.DisplayNames([value], { type: 'language' }).of(value) ?? value; } catch { return value; } } return Array.isArray(value) ? value.join(', ') : String(value); } function readChanges(arg: unknown, method: string): Record { if (!isPlainRecord(arg)) throw new Error(`${method}: changes must be an object of setting names and values`); const keys = Object.keys(arg); if (keys.length === 0 || keys.length > MAX_PLUGIN_SETTINGS) throw new Error(`${method}: name 1 to ${MAX_PLUGIN_SETTINGS} settings`); return arg; } /** * The settings named, in the user's language: label, page, current value * and, for a change, the new one, and whether it may be made now. `arg` is * an object of changes or an array of setting names. */ export async function doDescribeSettings(arg: unknown): Promise> { const method = 'app.describeSettings'; const changes: Record = Array.isArray(arg) ? Object.fromEntries(arg.slice(0, MAX_PLUGIN_SETTINGS + 1).map((k) => [String(k), undefined])) : readChanges(arg, method); if (Object.keys(changes).length === 0 || Object.keys(changes).length > MAX_PLUGIN_SETTINGS) { throw new Error(`${method}: name 1 to ${MAX_PLUGIN_SETTINGS} settings`); } const locale = userLocale(); const messages = await messagesFor(locale); const out: Record = {}; for (const [key, next] of Object.entries(changes)) { if (key.length > 100) continue; const reason = settingRefusal(key, next, changes); const entry = schemaEntry(canonicalSetting(key)); if (!entry) { out[key] = { key, label: key, page: '', pageLabel: '', current: null, currentLabel: '', allowed: false, reason: reason ?? 'unknown' }; continue; } const current = currentSettingValue(entry); const options = entry.kind.type === 'choice' || entry.kind.type === 'boolean' ? (entry.kind.type === 'boolean' ? [true, false] : entry.kind.options.map((o) => o.value)) .filter((v) => settingRefusal(entry.key, v, changes) === null) .map((v) => ({ value: v, label: settingValueLabel(entry, v, messages, locale) })) : undefined; out[key] = { key, label: format(messages, entry.label) ?? key, page: entry.page, pageLabel: format(messages, settingsTabLabelKey(entry.page)) ?? entry.page, current, currentLabel: settingValueLabel(entry, current, messages, locale), ...(next !== undefined ? { next: next as SettingValue, nextLabel: settingValueLabel(entry, next, messages, locale) } : {}), allowed: reason === null, ...(reason ? { reason } : {}), ...(options && options.length ? { options } : {}), }; } return out; } /** Writes one value through the setter the settings page uses. */ function write(entry: SettingSchemaEntry, value: unknown): void { switch (entry.store) { case 'theme': useThemeStore.getState().setTheme(value as 'light' | 'dark' | 'system'); return; case 'locale': useLocaleStore.getState().setLocale(value as string); return; case 'calendar': useCalendarStore.getState().setViewMode(value as never); return; default: useSettingsStore.getState().updateSetting(entry.key as never, value as never); } } /** Writes and reads back: false when the value didn't stick (another plugin stopped it). */ async function writeAndCheck(entry: SettingSchemaEntry, value: unknown): Promise { write(entry, value); if (same(currentSettingValue(entry), value)) return true; // With another plugin's onBeforeSettingChange the store applies the change // once that plugin answered, or never. if (entry.store !== 'settings' || settingsHooks.onBeforeSettingChange.size === 0) return false; const until = Date.now() + VETO_WAIT_MS; while (Date.now() < until) { await new Promise((resolve) => setTimeout(resolve, 50)); if (same(currentSettingValue(entry), value)) return true; } return false; } /** * Change settings for the user. Each key is checked and written on its own; * the result says which were applied (old and new value) and which were * refused and why, plus a token for app.undoSettings. */ export async function doSetSettings(pluginId: string, arg: unknown): Promise { const method = 'app.setSettings'; const changes = readChanges(arg, method); const client = sessionClient(method); const applied: SettingsChangeResult['applied'] = {}; const refused: SettingsChangeResult['refused'] = {}; const previous: Record = {}; for (const [key, value] of Object.entries(changes)) { const reason = settingRefusal(key, value, changes); if (reason) { refused[key] = reason; continue; } const entry = SETTINGS_SCHEMA[canonicalSetting(key)]; const old = currentSettingValue(entry); if (same(old, value)) { applied[key] = { old, new: value as SettingValue }; continue; } if (!(await writeAndCheck(entry, value))) { refused[key] = 'stopped'; continue; } applied[key] = { old, new: value as SettingValue }; previous[entry.key] = old; } const undoToken = Object.keys(previous).length > 0 ? issueUndoToken(pluginId, undoAccount(client), 'settings', previous) : null; return { applied, refused, undoToken }; } /** * Put back what app.setSettings changed: the exact old value, even one the * allow-list wouldn't offer (the user chose it), but not where the admin * has locked the setting since. */ export async function doUndoSettings(pluginId: string, token: unknown): Promise { const method = 'app.undoSettings'; const client = sessionClient(method); return withUndoToken, SettingsUndoResult>(method, pluginId, undoAccount(client), 'settings', token, (previous) => restoreSettings(previous)); } /** Writes the old values back, setting by setting (see doUndoSettings). */ async function restoreSettings(previous: Record): Promise { const restored: string[] = []; const refused: SettingsUndoResult['refused'] = {}; for (const [key, old] of Object.entries(previous)) { const entry = schemaEntry(key); if (!entry || old === null) { refused[key] = 'unknown'; continue; } // As for a change: not where the admin has locked or hidden the setting // since, a gate now hides its row, or the admin's restriction rules the // old value out. The value itself needn't be one the assistant offers: // the user had it. const reason = settingRefusal(key) ?? restrictionRefusal(entry, old); if (reason) { refused[key] = reason; continue; } if (same(currentSettingValue(entry), old) || (await writeAndCheck(entry, old))) restored.push(key); else refused[key] = 'stopped'; } return { restored, refused }; }