blob: c8d58cca48d9750c5c2a5904fe2c8ac329efd809 [file]
// Copyright (C) 2025 The Android Open Source Project
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import type {z} from 'zod';
import type {
Setting,
SettingDescriptor,
SettingRenderer,
SettingsManager,
} from '../public/settings';
import type {Storage} from './storage';
export const PERFETTO_SETTINGS_STORAGE_KEY = 'perfettoSettings';
function deepFreeze<T>(obj: T): T {
if (obj === null || typeof obj !== 'object') return obj;
Object.freeze(obj);
Object.values(obj).forEach(deepFreeze);
return obj;
}
// Implement the Setting interface for registered settings
export class SettingImpl<T> implements Setting<T> {
// Record what the raw value was at startup. This is used to determine if a
// reload is required.
readonly bootRawValue: unknown;
private cache?: {rawValue: unknown; normalizedValue: T};
constructor(
private readonly manager: SettingsManagerImpl,
public readonly pluginId: string | undefined,
public readonly id: string,
public readonly name: string,
public readonly description: string,
public readonly defaultValue: T,
public readonly schema: z.ZodType<T>,
public readonly requiresReload: boolean = false,
public readonly render?: SettingRenderer<T>,
) {
this.bootRawValue = this.manager.getStore()[this.id];
}
get isDefault(): boolean {
const currentValue = this.get();
return JSON.stringify(currentValue) === JSON.stringify(this.defaultValue);
}
get(): T {
const rawValue = this.manager.getStore()[this.id];
const cache = this.cache;
if (cache !== undefined && cache.rawValue === rawValue) {
return cache.normalizedValue;
}
const parseResult = this.schema.safeParse(rawValue);
// Deep freeze the object to prevent accidential mutations - will throw in
// if attempted (in strict mode - which is the default for TS).
const normalizedValue = deepFreeze(
parseResult.success ? parseResult.data : this.defaultValue,
);
this.cache = {rawValue, normalizedValue};
return normalizedValue;
}
set(newValue: T): void {
this.manager.updateStoredValue(this.id, newValue);
}
reset(): void {
this.manager.clearStoredValue(this.id);
}
[Symbol.dispose](): void {
// Use the stored disposable if available
this.manager.unregister(this.id);
}
}
export class SettingsManagerImpl implements SettingsManager {
private readonly registry = new Map<string, SettingImpl<unknown>>();
private currentStoredValues: Readonly<Record<string, unknown>> = {};
private readonly store: Storage;
constructor(store: Storage) {
this.store = store;
this.load();
}
get<T>(id: string): Setting<T> | undefined {
return this.registry.get(id) as Setting<T> | undefined;
}
register<T>(setting: SettingDescriptor<T>, pluginId?: string): Setting<T> {
// Determine the initial value: stored value if valid, otherwise default.
if (this.registry.has(setting.id)) {
throw new Error(`Setting with id "${setting.id}" already registered.`);
}
const settingImpl = new SettingImpl<T>(
this,
pluginId,
setting.id,
setting.name,
setting.description,
setting.defaultValue,
setting.schema,
setting.requiresReload,
setting.render,
);
this.registry.set(setting.id, settingImpl as SettingImpl<unknown>);
return settingImpl;
}
unregister(id: string): void {
this.registry.delete(id);
}
resetAll(): void {
this.currentStoredValues = {};
this.save();
}
getAllSettings(): ReadonlyArray<SettingImpl<unknown>> {
const settings = Array.from(this.registry.values());
settings.sort((a, b) => a.name.localeCompare(b.name));
return settings;
}
isReloadRequired(): boolean {
// Check if any setting that requires reload has changed from its original value
for (const setting of this.registry.values()) {
if (setting.requiresReload) {
const currentRawValue = this.currentStoredValues[setting.id];
if (
JSON.stringify(currentRawValue) !==
JSON.stringify(setting.bootRawValue)
) {
return true;
}
}
}
return false;
}
// Internal method to get the store reference (for cache invalidation)
getStore(): Readonly<Record<string, unknown>> {
return this.currentStoredValues;
}
// Internal method to update stored values
updateStoredValue(id: string, value: unknown): void {
this.currentStoredValues = {...this.currentStoredValues, [id]: value};
this.save();
}
clearStoredValue(id: string): void {
const {[id]: _, ...rest} = this.currentStoredValues;
this.currentStoredValues = rest;
this.save();
}
private load(): void {
try {
this.currentStoredValues = this.store.load();
} catch (e) {
console.error('Failed to load settings from store:', e);
this.currentStoredValues = {};
}
// Re-validate existing registered settings after load
let needsUpdate = false;
let updatedValues = this.currentStoredValues;
for (const setting of this.registry.values()) {
const storedValue = updatedValues[setting.id];
const parseResult = setting.schema.safeParse(storedValue);
// Ensure storage reflects the potentially corrected value
if (!parseResult.success && storedValue !== undefined) {
updatedValues = {...updatedValues, [setting.id]: setting.defaultValue};
needsUpdate = true;
}
}
if (needsUpdate) {
this.currentStoredValues = updatedValues;
}
// Save potentially corrected values back to storage
this.save();
}
private save(): void {
try {
this.store.save(this.currentStoredValues);
} catch (e) {
console.error('Failed to save settings to store:', e);
}
}
}