Skip to content

Commit 62f010a

Browse files
authored
ui: add API for command filtering for embedders (#154)
In some applications that embed the Perfetto UI, there are a number of commands that are not needed. For example, in an application that manages the opening of traces into instances of the Perfetto UI, there is no need for the commands that open and close traces. And other commands may overlap or conflict with other capabilities provided by the host application, which therefore may wish to exclude those commands. Add an API to the Registry class to permit an embedding application to install a filter that quietly prevents registration of unwanted services. Employ this capability in the CommandManager. Signed-off-by: Christian W. Damus <cdamus.ext@eclipsesource.com>
1 parent 483d0b1 commit 62f010a

File tree

4 files changed

+195
-5
lines changed

4 files changed

+195
-5
lines changed

ui/src/base/registry.ts

Lines changed: 52 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212
// See the License for the specific language governing permissions and
1313
// limitations under the License.
1414

15+
import {assertExists, assertFalse} from './logging';
16+
1517
export interface HasKind {
1618
kind: string;
1719
}
@@ -26,6 +28,8 @@ export class RegistryError extends Error {
2628
export class Registry<T> {
2729
private key: (t: T) => string;
2830
protected registry: Map<string, T>;
31+
private _keyFilter?: (key: string) => boolean;
32+
private readonly keyFilter = (key: string) => this._keyFilter?.(key) ?? true;
2933

3034
static kindRegistry<T extends HasKind>(): Registry<T> {
3135
return new Registry<T>((t) => t.kind);
@@ -36,8 +40,33 @@ export class Registry<T> {
3640
this.key = key;
3741
}
3842

43+
/**
44+
* Set a filter to allow services to be registered only under certain matching keys.
45+
* This is intended for applications embedding the Perfetto UI to exclude services
46+
* that are inappropriate or otherwise unwanted in their contexts. Initially, a
47+
* registry has no filter.
48+
*
49+
* **Note** that a filter may only be set once. An attempt to replace or clear the
50+
* filter will throw an error.
51+
*/
52+
setFilter(filter: (key: string) => boolean): void {
53+
assertFalse(this._keyFilter !== undefined, 'A key filter is already set.');
54+
this._keyFilter = assertExists(filter);
55+
56+
// Run the filter to knock out anything already registered that does not pass it
57+
[...this.registry.keys()]
58+
.filter((key) => !filter(key))
59+
.forEach((key) => this.registry.delete(key));
60+
}
61+
3962
register(registrant: T): Disposable {
4063
const kind = this.key(registrant);
64+
if (!this.keyFilter(kind)) {
65+
// Simply refuse to register the entry
66+
return {
67+
[Symbol.dispose]: () => undefined,
68+
};
69+
}
4170
if (this.registry.has(kind)) {
4271
throw new RegistryError(
4372
`Registrant ${kind} already exists in the registry`,
@@ -85,30 +114,48 @@ export class Registry<T> {
85114
// A proxy is not sufficient because we need non-overridden
86115
// methods to delegate to overridden methods.
87116
const result = new (class ChildRegistry extends Registry<T> {
88-
constructor (private readonly parent: Registry<T>) {
117+
constructor(private readonly parent: Registry<T>) {
89118
super(parent.key);
90119
}
91120

121+
override setFilter(filter: (key: string) => boolean): void {
122+
// Dyamically delegate to whatever the parent filter is at the
123+
// time of filtering
124+
const parentFilter = this.parent.keyFilter.bind(this.parent);
125+
const combinedFilter = (key: string) =>
126+
filter(key) && parentFilter(key);
127+
128+
super.setFilter(combinedFilter);
129+
}
130+
92131
override has(kind: string): boolean {
93-
return this.registry.has(kind) || this.parent.has(kind);
132+
return (
133+
this.keyFilter(kind) &&
134+
(this.registry.has(kind) || this.parent.has(kind))
135+
);
94136
}
95137

96138
override get(kind: string): T {
139+
if (!this.keyFilter(kind)) {
140+
return super.get(kind); // This will throw a consistent Error type
141+
}
97142
return this.tryGet(kind) ?? this.parent.get(kind);
98143
}
99144

100145
override tryGet(kind: string): T | undefined {
101-
return this.registry.get(kind) ?? this.parent.tryGet(kind);
146+
return !this.keyFilter(kind)
147+
? undefined
148+
: this.registry.get(kind) ?? this.parent.tryGet(kind);
102149
}
103150

104151
override *values() {
105152
// Yield own values first
106153
yield* this.registry.values();
107154

108-
// Then yield parent values not shadowed by my keys
155+
// Then yield parent values not shadowed by my keys and that pass my filter
109156
for (const value of this.parent.values()) {
110157
const kind = this.key(value);
111-
if (!this.registry.has(kind)) {
158+
if (!this.registry.has(kind) && this.keyFilter(kind)) {
112159
yield value;
113160
}
114161
}

ui/src/base/registry_unittest.ts

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,43 @@ test('registry allows iteration', () => {
5959
expect(values.includes(b)).toBe(true);
6060
});
6161

62+
describe('registry filter', () => {
63+
test('prevents registration of non-matching kinds', () => {
64+
const registry = Registry.kindRegistry<Registrant>();
65+
registry.setFilter((key) => key.startsWith('a'));
66+
67+
const alpha: Registrant = {kind: 'alpha', n: 1};
68+
const beta: Registrant = {kind: 'beta', n: 2};
69+
registry.register(alpha);
70+
registry.register(beta);
71+
72+
expect(registry.has('alpha')).toBe(true);
73+
expect(registry.has('beta')).toBe(false);
74+
expect(() => registry.get('beta')).toThrow();
75+
expect(registry.get('alpha')).toBe(alpha);
76+
});
77+
78+
test('removes extant non-matching registrations', () => {
79+
const registry = Registry.kindRegistry<Registrant>();
80+
const a: Registrant = {kind: 'alpha', n: 1};
81+
const b: Registrant = {kind: 'beta', n: 2};
82+
registry.register(a);
83+
registry.register(b);
84+
85+
registry.setFilter((key) => key.startsWith('a'));
86+
87+
expect(registry.has('alpha')).toBe(true);
88+
expect(registry.has('beta')).toBe(false);
89+
expect(() => registry.get('beta')).toThrow();
90+
});
91+
});
92+
93+
test('cannot replace a filter', () => {
94+
const registry = Registry.kindRegistry<Registrant>();
95+
registry.setFilter(() => true);
96+
expect(() => registry.setFilter(() => false)).toThrow();
97+
});
98+
6299
describe('Hierarchical (child) registries', () => {
63100
test('inheritance of registrations', () => {
64101
const parent = Registry.kindRegistry<Registrant>();
@@ -176,4 +213,65 @@ describe('Hierarchical (child) registries', () => {
176213

177214
expect(child).not.toHaveProperty('id');
178215
});
216+
217+
describe('registry filter', () => {
218+
test('child does not inherit parent filter', () => {
219+
const parent = Registry.kindRegistry<Registrant>();
220+
parent.setFilter((key) => key.startsWith('x'));
221+
222+
const child = parent.createChild();
223+
224+
const child1: Registrant = {kind: 'xyz', n: 1};
225+
const child2: Registrant = {kind: 'abc', n: 2};
226+
child.register(child1);
227+
child.register(child2);
228+
229+
const parentOk: Registrant = {kind: 'xenon', n: 3};
230+
const parentNok: Registrant = {kind: 'beta', n: 4};
231+
parent.register(parentOk);
232+
parent.register(parentNok);
233+
234+
expect(child.has('xyz')).toBe(true);
235+
expect(child.get('xyz')).toBe(child1);
236+
expect(child.has('abc')).toBe(true);
237+
expect(child.get('abc')).toBe(child2);
238+
239+
// Other registrations still accessible (or not) as usual via inheritance
240+
expect(child.get('xenon')).toBe(parentOk);
241+
expect(child.has('beta')).toBe(false);
242+
expect(() => child.get('beta')).toThrow();
243+
});
244+
245+
test('setting filter on child does not affect parent', () => {
246+
const parent = Registry.kindRegistry<Registrant>();
247+
const a: Registrant = {kind: 'a', n: 1};
248+
const b: Registrant = {kind: 'b', n: 2};
249+
parent.register(a);
250+
parent.register(b);
251+
252+
const child = parent.createChild();
253+
const c: Registrant = {kind: 'c', n: 3};
254+
const d: Registrant = {kind: 'd', n: 4};
255+
child.register(c);
256+
child.register(d);
257+
258+
child.setFilter((key) => key === 'b' || key === 'd');
259+
260+
// Parent not pruned
261+
expect(parent.has('a')).toBe(true);
262+
expect(parent.has('b')).toBe(true);
263+
264+
// Child pruned
265+
expect(child.has('c')).toBe(false);
266+
expect(child.has('d')).toBe(true);
267+
268+
// Other registrations still accessible (or not) as usual
269+
expect(child.get('b')).toBe(b);
270+
271+
// But inheritance needs to respect the child's filtering
272+
expect(child.has('a')).toBe(false);
273+
expect(() => child.get('a')).toThrow();
274+
expect(child.valuesAsArray()).toStrictEqual([d, b]);
275+
});
276+
});
179277
});

ui/src/core/command_manager.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,23 @@ export class CommandManagerImpl implements CommandManager {
8282
return new CommandManagerImpl(this.registry);
8383
}
8484

85+
/**
86+
* Set a filter to screen command registrations. Command IDs that do not
87+
* pass the filter are not registered. This is distinct from the
88+
* `allowlistCheck` function, which screens out start-up commands that
89+
* should be skipped.
90+
*
91+
* This is intended for applications embedding the Perfetto UI to exclude services
92+
* that are inappropriate or otherwise unwanted in their contexts. Initially, a
93+
* registry has no filter.
94+
*
95+
* **Note** that a filter may only be set once. An attempt to replace or clear the
96+
* filter will throw an error.
97+
*/
98+
setFilter(filter: (commandId: string) => boolean) {
99+
this.registry.setFilter(filter);
100+
}
101+
85102
getCommand(commandId: string): Command {
86103
return this.registry.get(commandId);
87104
}

ui/src/core/command_manager_unittest.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,3 +38,31 @@ describe('CommandManagerImpl child manager', () => {
3838
expect(parent.hasCommand('child')).toBe(false);
3939
});
4040
});
41+
42+
describe('CommandManagerImpl filtering', () => {
43+
test('filters existing and future registrations', () => {
44+
const parent = new CommandManagerImpl();
45+
46+
const allowCmd = TestCommand('allow');
47+
const denyCmd = TestCommand('deny');
48+
49+
parent.registerCommand(allowCmd);
50+
parent.registerCommand(denyCmd);
51+
52+
const mgr = parent.createChild();
53+
expect(mgr.hasCommand('allow')).toBe(true);
54+
expect(mgr.hasCommand('deny')).toBe(true);
55+
56+
mgr.setFilter((id) => id.startsWith('allow'));
57+
58+
// Extant non-matching commands are purged
59+
expect(mgr.hasCommand('allow')).toBe(true);
60+
expect(mgr.hasCommand('deny')).toBe(false);
61+
62+
// New registrations are screened by the filter.
63+
mgr.registerCommand(TestCommand('deny'));
64+
expect(mgr.hasCommand('deny')).toBe(false);
65+
mgr.registerCommand(TestCommand('allowed'));
66+
expect(mgr.hasCommand('allowed')).toBe(true);
67+
});
68+
});

0 commit comments

Comments
 (0)