1// Copyright (C) 2021 The Android Open Source Project 2// 3// Licensed under the Apache License, Version 2.0 (the "License"); 4// you may not use this file except in compliance with the License. 5// You may obtain a copy of the License at 6// 7// http://www.apache.org/licenses/LICENSE-2.0 8// 9// Unless required by applicable law or agreed to in writing, software 10// distributed under the License is distributed on an "AS IS" BASIS, 11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12// See the License for the specific language governing permissions and 13// limitations under the License. 14 15// This file should not import anything else. Since the flags will be used from 16// ~everywhere and the are "statically" initialized (i.e. files construct Flags 17// at import time) if this file starts importing anything we will quickly run 18// into issues with initialization order which will be a pain. 19import {z} from 'zod'; 20import {Flag, FlagSettings, OverrideState} from '../public/feature_flag'; 21 22export interface FlagStore { 23 load(): object; 24 save(o: object): void; 25} 26 27// Stored state for a number of flags. 28interface FlagOverrides { 29 [id: string]: OverrideState; 30} 31 32class Flags { 33 private store: FlagStore; 34 private flags: Map<string, FlagImpl>; 35 private overrides: FlagOverrides; 36 37 constructor(store: FlagStore) { 38 this.store = store; 39 this.flags = new Map(); 40 this.overrides = {}; 41 this.load(); 42 } 43 44 register(settings: FlagSettings): Flag { 45 const id = settings.id; 46 if (this.flags.has(id)) { 47 throw new Error(`Flag with id "${id}" is already registered.`); 48 } 49 50 const saved = this.overrides[id]; 51 const state = saved === undefined ? OverrideState.DEFAULT : saved; 52 const flag = new FlagImpl(this, state, settings); 53 this.flags.set(id, flag); 54 return flag; 55 } 56 57 allFlags(): Flag[] { 58 const includeDevFlags = ['127.0.0.1', '::1', 'localhost'].includes( 59 window.location.hostname, 60 ); 61 62 let flags = [...this.flags.values()]; 63 flags = flags.filter((flag) => includeDevFlags || !flag.devOnly); 64 flags.sort((a, b) => a.name.localeCompare(b.name)); 65 return flags; 66 } 67 68 resetAll() { 69 for (const flag of this.flags.values()) { 70 flag.state = OverrideState.DEFAULT; 71 } 72 this.save(); 73 } 74 75 load(): void { 76 const o = this.store.load(); 77 78 // Check if the given object is a valid FlagOverrides. 79 // This is necessary since someone could modify the persisted flags 80 // behind our backs. 81 const flagsSchema = z.record( 82 z.string(), 83 z.union([z.literal(OverrideState.TRUE), z.literal(OverrideState.FALSE)]), 84 ); 85 const {success, data} = flagsSchema.safeParse(o); 86 if (success) { 87 this.overrides = data; 88 } 89 } 90 91 save(): void { 92 for (const flag of this.flags.values()) { 93 if (flag.isOverridden()) { 94 this.overrides[flag.id] = flag.state; 95 } else { 96 delete this.overrides[flag.id]; 97 } 98 } 99 100 this.store.save(this.overrides); 101 } 102} 103 104class FlagImpl implements Flag { 105 registry: Flags; 106 state: OverrideState; 107 108 readonly id: string; 109 readonly name: string; 110 readonly description: string; 111 readonly defaultValue: boolean; 112 readonly devOnly: boolean; 113 114 constructor(registry: Flags, state: OverrideState, settings: FlagSettings) { 115 this.registry = registry; 116 this.id = settings.id; 117 this.state = state; 118 this.description = settings.description; 119 this.defaultValue = settings.defaultValue; 120 this.name = settings.name ?? settings.id; 121 this.devOnly = settings.devOnly || false; 122 } 123 124 get(): boolean { 125 switch (this.state) { 126 case OverrideState.TRUE: 127 return true; 128 case OverrideState.FALSE: 129 return false; 130 case OverrideState.DEFAULT: 131 default: 132 return this.defaultValue; 133 } 134 } 135 136 set(value: boolean): void { 137 const next = value ? OverrideState.TRUE : OverrideState.FALSE; 138 if (this.state === next) { 139 return; 140 } 141 this.state = next; 142 this.registry.save(); 143 } 144 145 overriddenState(): OverrideState { 146 return this.state; 147 } 148 149 reset() { 150 this.state = OverrideState.DEFAULT; 151 this.registry.save(); 152 } 153 154 isOverridden(): boolean { 155 return this.state !== OverrideState.DEFAULT; 156 } 157} 158 159class LocalStorageStore implements FlagStore { 160 static KEY = 'perfettoFeatureFlags'; 161 162 load(): object { 163 const s = localStorage.getItem(LocalStorageStore.KEY); 164 let parsed: object; 165 try { 166 parsed = JSON.parse(s ?? '{}'); 167 } catch (e) { 168 return {}; 169 } 170 if (typeof parsed !== 'object' || parsed === null) { 171 return {}; 172 } 173 return parsed; 174 } 175 176 save(o: object): void { 177 const s = JSON.stringify(o); 178 localStorage.setItem(LocalStorageStore.KEY, s); 179 } 180} 181 182export const FlagsForTesting = Flags; 183export const featureFlags = new Flags(new LocalStorageStore()); 184