[ui] Use mountStore() for using persistent state in plugins.

- Avoids having to template plugins everywhere with the store type.
- Similar interface to tracks.
- Less confusing, persistent state is totally opt-in.

Change-Id: Iebd8cbe1842c5cef7b2480b220e0129b0355ef0d
diff --git a/docs/contributing/ui-plugins.md b/docs/contributing/ui-plugins.md
index 241e0a7..23fbe25 100644
--- a/docs/contributing/ui-plugins.md
+++ b/docs/contributing/ui-plugins.md
@@ -176,58 +176,73 @@
 }
 ```
 
-This interface will be used as type parameter to the `Plugin` and
-`TracePluginContext` interfaces.
+To access permalink state, call `mountStore()` on your `TracePluginContext`
+object, passing in a migration function.
 ```typescript
-class MyPlugin implements Plugin<MyState> {
-
-  migrate(initialState: unknown): MyState {
-    // ...
+class MyPlugin implements Plugin {
+  async onTraceLoad(ctx: TracePluginContext): Promise<void> {
+    const store = ctx.mountStore(migrate);
   }
+}
 
-  async onTraceLoad(ctx: TracePluginContext<MyState>): Promise<void> {
-    // You can access the store on ctx.store
-  }
-
-  async onTraceUnload(ctx: TracePluginContext<MyState>): Promise<void> {
-    // You can access the store on ctx.store
-  }
-
+function migrate(initialState: unknown): MyState {
   // ...
 }
 ```
 
-`migrate()` is called after `onActivate()` just before `onTraceLoad()`. There
-are two cases to consider:
+When it comes to migration, there are two cases to consider:
 - Loading a new trace
 - Loading from a permalink
 
-In case of a new trace `migrate()` is called with `undefined`. In this
-case you should return a default version of `MyState`:
+In case of a new trace, your migration function is called with `undefined`. In
+this case you should return a default version of `MyState`:
 ```typescript
-class MyPlugin implements Plugin<MyState> {
+const DEFAULT = {favouriteSlices: []};
 
-  migrate(initialState: unknown): MyState {
-    if (initialState === undefined) {
-      return {
-        favouriteSlices: [];
-      };
-    }
-    // ...
+function migrate(initialState: unknown): MyState {
+  if (initialState === undefined) {
+    // Return default version of MyState.
+    return DEFAULT;
+  } else {
+    // Migrate old version here.
   }
-
-  // ...
 }
 ```
 
-In the permalink case `migrate()` is called with the state of the plugin
-store at the time the permalink was generated. This may be from a
-older or newer version of the plugin.
-**Plugin's must not make assumptions about the contents of `initialState`**.
+In the permalink case, your migration function is called with the state of the
+plugin store at the time the permalink was generated. This may be from an older
+or newer version of the plugin.
 
-In this case you need to carefully validate the state object.
+**Plugins must not make assumptions about the contents of `initialState`!**
 
-TODO: Add validation example.
+In this case you need to carefully validate the state object. This could be
+achieved in several ways, none of which are particularly straight forward. State
+migration is difficult!
+
+One brute force way would be to use a version number.
+
+```typescript
+interface MyState {
+  version: number;
+  favouriteSlices: MySliceInfo[];
+}
+
+const VERSION = 3;
+const DEFAULT = {favouriteSlices: []};
+
+function migrate(initialState: unknown): MyState {
+  if (initialState && (initialState as {version: any}).version === VERSION) {
+    // Version number checks out, assume the structure is correct.
+    return initialState as State;
+  } else {
+    // Null, undefined, or bad version number - return default value.
+    return DEFAULT;
+  }
+}
+```
+
+You'll need to remember to update your version number when making changes!
+Migration should be unit-tested to ensure compatibility.
 
 Examples:
 - [dev.perfetto.ExampleState](https://cs.android.com/android/platform/superproject/main/+/main:external/perfetto/ui/src/plugins/dev.perfetto.ExampleState/index.ts).
diff --git a/ui/src/common/plugins.ts b/ui/src/common/plugins.ts
index bca6b15..cb27e61 100644
--- a/ui/src/common/plugins.ts
+++ b/ui/src/common/plugins.ts
@@ -17,16 +17,15 @@
 import {ViewerImpl, ViewerProxy} from '../common/viewer';
 import {globals} from '../frontend/globals';
 import {
-  BasePlugin,
   Command,
   EngineProxy,
   MetricVisualisation,
+  Migrate,
   Plugin,
   PluginClass,
   PluginContext,
   PluginContextTrace,
   PluginDescriptor,
-  StatefulPlugin,
   Store,
   Track,
   TrackContext,
@@ -77,18 +76,16 @@
 // related resources, such as the engine and the store.
 // The TracePluginContext exists for the whole duration a plugin is active AND a
 // trace is loaded.
-class TracePluginContextImpl<T> implements PluginContextTrace<T>, Disposable {
+class TracePluginContextImpl implements PluginContextTrace, Disposable {
   private trash = new Trash();
   private alive = true;
 
   constructor(
-      private ctx: PluginContext, readonly store: Store<T>,
-      readonly engine: EngineProxy,
+      private ctx: PluginContext, readonly engine: EngineProxy,
       readonly trackRegistry: Map<string, TrackDescriptor>,
       private defaultTracks: Set<TrackRef>,
       private commandRegistry: Map<string, Command>) {
     this.trash.add(engine);
-    this.trash.add(store);
   }
 
   addCommand(cmd: Command): void {
@@ -110,6 +107,13 @@
     return this.ctx.viewer;
   }
 
+  get pluginId(): string {
+    return this.ctx.pluginId;
+  }
+
+  // Register a new track in this context.
+  // All tracks registered through this method are removed when this context is
+  // destroyed, i.e. when the trace is unloaded.
   registerTrack(trackDesc: TrackDescriptor): void {
     // Silently ignore if context is dead.
     if (!this.alive) return;
@@ -142,26 +146,42 @@
     this.trash.dispose();
     this.alive = false;
   }
+
+  mountStore<T>(migrate: Migrate<T>): Store<T> {
+    const globalStore = globals.store;
+
+    // Migrate initial state
+    const initialState = globalStore.state.plugins[this.pluginId];
+    const migratedState = migrate(initialState);
+
+    // Update global store with migrated plugin state
+    globalStore.edit((draft) => {
+      draft.plugins[this.pluginId] = migratedState;
+    });
+
+    // Return proxy store for this plugin
+    return globalStore.createProxy<T>(['plugins', this.pluginId]);
+  }
 }
 
 // 'Static' registry of all known plugins.
-export class PluginRegistry extends Registry<PluginDescriptor<unknown>> {
+export class PluginRegistry extends Registry<PluginDescriptor> {
   constructor() {
     super((info) => info.pluginId);
   }
 }
 
-interface PluginDetails<T> {
-  plugin: Plugin<T>;
+interface PluginDetails {
+  plugin: Plugin;
   context: PluginContext&Disposable;
-  traceContext?: TracePluginContextImpl<unknown>;
+  traceContext?: TracePluginContextImpl;
 }
 
-function isPluginClass<T>(v: unknown): v is PluginClass<T> {
+function isPluginClass(v: unknown): v is PluginClass {
   return typeof v === 'function' && !!(v.prototype.onActivate);
 }
 
-function makePlugin<T>(info: PluginDescriptor<T>): Plugin<T> {
+function makePlugin(info: PluginDescriptor): Plugin {
   const {plugin} = info;
 
   if (typeof plugin === 'function') {
@@ -178,7 +198,7 @@
 
 export class PluginManager {
   private registry: PluginRegistry;
-  private plugins: Map<string, PluginDetails<unknown>>;
+  private plugins: Map<string, PluginDetails>;
   private engine?: Engine;
   readonly trackRegistry = new Map<string, TrackDescriptor>();
   readonly commandRegistry = new Map<string, Command>();
@@ -203,7 +223,7 @@
 
     plugin.onActivate && plugin.onActivate(context);
 
-    const pluginDetails: PluginDetails<unknown> = {
+    const pluginDetails: PluginDetails = {
       plugin,
       context,
     };
@@ -236,7 +256,7 @@
     return this.getPluginContext(pluginId) !== undefined;
   }
 
-  getPluginContext(pluginId: string): PluginDetails<unknown>|undefined {
+  getPluginContext(pluginId: string): PluginDetails|undefined {
     return this.plugins.get(pluginId);
   }
 
@@ -286,59 +306,26 @@
     return trackInfo && trackInfo.track(trackCtx);
   }
 
-  private doPluginTraceLoad<T>(
-      pluginDetails: PluginDetails<T>, engine: Engine, pluginId: string): void {
+  private doPluginTraceLoad(
+      pluginDetails: PluginDetails, engine: Engine, pluginId: string): void {
     const {plugin, context} = pluginDetails;
 
     const engineProxy = engine.getProxy(pluginId);
 
-    // Migrate state & write back to store.
-    if (isStatefulPlugin(plugin)) {
-      const initialState = globals.store.state.plugins[pluginId];
-      const migratedState = plugin.migrate(initialState);
-      globals.store.edit((draft) => {
-        draft.plugins[pluginId] = migratedState;
-      });
+    const traceCtx = new TracePluginContextImpl(
+        context,
+        engineProxy,
+        this.trackRegistry,
+        this.defaultTracks,
+        this.commandRegistry);
+    pluginDetails.traceContext = traceCtx;
 
-      const proxyStore = globals.store.createProxy<T>(['plugins', pluginId]);
-      const traceCtx = new TracePluginContextImpl(
-          context,
-          proxyStore,
-          engineProxy,
-          this.trackRegistry,
-          this.defaultTracks,
-          this.commandRegistry);
-      pluginDetails.traceContext = traceCtx;
-
-      // TODO(stevegolton): Await onTraceLoad.
-      plugin.onTraceLoad && plugin.onTraceLoad(traceCtx);
-    } else {
-      // Stateless plugin i.e. the plugin's state type is undefined.
-      // Just provide a store proxy over this plugin's state, the plugin can
-      // work the state out for itself if it wants to, but we're not going to
-      // help it out by calling migrate().
-      const proxyStore = globals.store.createProxy<T>(['plugins', pluginId]);
-      const traceCtx = new TracePluginContextImpl(
-          context,
-          proxyStore,
-          engineProxy,
-          this.trackRegistry,
-          this.defaultTracks,
-          this.commandRegistry);
-      pluginDetails.traceContext = traceCtx;
-
-      // TODO(stevegolton): Await onTraceLoad.
-      plugin.onTraceLoad && plugin.onTraceLoad(traceCtx);
-    }
+    // TODO(stevegolton): Await onTraceLoad.
+    plugin.onTraceLoad && plugin.onTraceLoad(traceCtx);
   }
 }
 
-function isStatefulPlugin<T>(plugin: BasePlugin<T>|
-                             StatefulPlugin<T>): plugin is StatefulPlugin<T> {
-  return 'migrate' in plugin;
-}
-
-function maybeDoPluginTraceUnload(pluginDetails: PluginDetails<unknown>): void {
+function maybeDoPluginTraceUnload(pluginDetails: PluginDetails): void {
   const {traceContext, plugin} = pluginDetails;
 
   if (traceContext) {
diff --git a/ui/src/common/plugins_unittest.ts b/ui/src/common/plugins_unittest.ts
index 5be1f4a..4d3d4c2 100644
--- a/ui/src/common/plugins_unittest.ts
+++ b/ui/src/common/plugins_unittest.ts
@@ -26,9 +26,8 @@
   rpcSendRequestBytes(_data: Uint8Array) {}
 }
 
-function makeMockPlugin(): Plugin<any> {
+function makeMockPlugin(): Plugin {
   return {
-    migrate: jest.fn(),
     onActivate: jest.fn(),
     onDeactivate: jest.fn(),
     onTraceLoad: jest.fn(),
@@ -91,17 +90,4 @@
 
     expect(mockPlugin.onTraceUnload).toHaveBeenCalledTimes(1);
   });
-
-  it('does not invoke migrate at activation time', () => {
-    manager.activatePlugin('foo', viewer);
-
-    expect(mockPlugin.migrate).not.toHaveBeenCalled();
-  });
-
-  it('invokes migrate when trace is loaded', () => {
-    manager.activatePlugin('foo', viewer);
-    manager.onTraceLoad(engine);
-
-    expect(mockPlugin.migrate).toHaveBeenCalledTimes(1);
-  });
 });
diff --git a/ui/src/plugins/com.example.Skeleton/index.ts b/ui/src/plugins/com.example.Skeleton/index.ts
index 9a30c32..282d615 100644
--- a/ui/src/plugins/com.example.Skeleton/index.ts
+++ b/ui/src/plugins/com.example.Skeleton/index.ts
@@ -13,11 +13,13 @@
 // limitations under the License.
 
 import {
+  createStore,
   MetricVisualisation,
   Plugin,
   PluginContext,
   PluginContextTrace,
   PluginDescriptor,
+  Store,
 } from '../../public';
 
 interface State {
@@ -25,20 +27,24 @@
 }
 
 // SKELETON: Rename this class to match your plugin.
-class Skeleton implements Plugin<State> {
+class Skeleton implements Plugin {
+  private store: Store<State> = createStore({foo: 'foo'});
+
   onActivate(_: PluginContext): void {
     //
   }
 
-  migrate(_initialState: unknown): State {
-    return {foo: 'bar'};
+  async onTraceLoad(ctx: PluginContextTrace): Promise<void> {
+    this.store = ctx.mountStore((_: unknown): State => {
+      return {foo: 'bar'};
+    });
+
+    this.store.edit((state) => {
+      state.foo = 'baz';
+    });
   }
 
-  async onTraceLoad(_: PluginContextTrace<State>): Promise<void> {
-    //
-  }
-
-  async onTraceUnload(_: PluginContextTrace<State>): Promise<void> {
+  async onTraceUnload(_: PluginContextTrace): Promise<void> {
     //
   }
 
@@ -46,12 +52,12 @@
     //
   }
 
-  metricVisualisations(_: PluginContextTrace<State>): MetricVisualisation[] {
+  metricVisualisations(_: PluginContextTrace): MetricVisualisation[] {
     return [];
   }
 }
 
-export const plugin: PluginDescriptor<State> = {
+export const plugin: PluginDescriptor = {
   // SKELETON: Update pluginId to match the directory of the plugin.
   pluginId: 'com.example.Skeleton',
   plugin: Skeleton,
diff --git a/ui/src/plugins/dev.perfetto.ExampleState/index.ts b/ui/src/plugins/dev.perfetto.ExampleState/index.ts
index a18cfe2..9e4e31c 100644
--- a/ui/src/plugins/dev.perfetto.ExampleState/index.ts
+++ b/ui/src/plugins/dev.perfetto.ExampleState/index.ts
@@ -25,8 +25,8 @@
 
 // This example plugin shows using state that is persisted in the
 // permalink.
-class ExampleState implements Plugin<State> {
-  migrate(initialState: unknown): State {
+class ExampleState implements Plugin {
+  private migrate(initialState: unknown): State {
     if (initialState && typeof initialState === 'object' &&
         'counter' in initialState && typeof initialState.counter === 'number') {
       return {counter: initialState.counter};
@@ -39,8 +39,10 @@
     //
   }
 
-  async onTraceLoad(ctx: PluginContextTrace<State>): Promise<void> {
-    const {viewer, store} = ctx;
+  async onTraceLoad(ctx: PluginContextTrace): Promise<void> {
+    const {viewer} = ctx;
+    const store = ctx.mountStore((init: unknown) => this.migrate(init));
+
     ctx.addCommand({
       id: 'dev.perfetto.ExampleState#ShowCounter',
       name: 'Show ExampleState counter',
@@ -54,7 +56,7 @@
   }
 }
 
-export const plugin: PluginDescriptor<State> = {
+export const plugin: PluginDescriptor = {
   pluginId: 'dev.perfetto.ExampleState',
   plugin: ExampleState,
 };
diff --git a/ui/src/public/index.ts b/ui/src/public/index.ts
index d47e608..435f137 100644
--- a/ui/src/public/index.ts
+++ b/ui/src/public/index.ts
@@ -30,7 +30,7 @@
   STR,
   STR_NULL,
 } from '../common/query_result';
-export {Store} from '../frontend/store';
+export {createStore, Store} from '../frontend/store';
 
 
 // An imperative API for plugins to change the UI.
@@ -146,6 +146,10 @@
 // This interface defines a context for a plugin, which is an object passed to
 // most hooks within the plugin. It should be used to interact with Perfetto.
 export interface PluginContext {
+  // The unique ID for this plugin.
+  readonly pluginId: string;
+
+  // The viewer API, used to interface with Perfetto.
   readonly viewer: Viewer;
 
   // Add a command.
@@ -254,9 +258,8 @@
 // Similar to PluginContext but with additional methods to operate on the
 // currently loaded trace. Passed to trace-relevant hooks on a plugin instead of
 // PluginContext.
-export interface PluginContextTrace<T = undefined> extends PluginContext {
+export interface PluginContextTrace extends PluginContext {
   readonly engine: EngineProxy;
-  readonly store: Store<T>;
 
   // Register a new track against a unique key known as a URI.
   // Once a track is registered it can be referenced multiple times on the
@@ -275,30 +278,22 @@
   // This is simply a helper which calls registerTrack() then addDefaultTrack()
   // with the same URI.
   registerStaticTrack(track: TrackDescriptor&TrackRef): void;
+
+  // Create a store mounted over the top of this plugin's persistent state.
+  mountStore<T>(migrate: Migrate<T>): Store<T>;
 }
 
-export interface BasePlugin<State> {
+export interface Plugin {
   // Lifecycle methods.
   onActivate(ctx: PluginContext): void;
-  onTraceLoad?(ctx: PluginContextTrace<State>): Promise<void>;
-  onTraceUnload?(ctx: PluginContextTrace<State>): Promise<void>;
+  onTraceLoad?(ctx: PluginContextTrace): Promise<void>;
+  onTraceUnload?(ctx: PluginContextTrace): Promise<void>;
   onDeactivate?(ctx: PluginContext): void;
 
   // Extension points.
   metricVisualisations?(ctx: PluginContext): MetricVisualisation[];
 }
 
-export interface StatefulPlugin<State> extends BasePlugin<State> {
-  // Function to migrate the persistent state.
-  migrate(initialState: unknown): State;
-}
-
-// Generic interface all plugins must implement.
-// If a state type is passed, the plugin must implement migrate(). Otherwise if
-// the state type is omitted, migrate need not be defined.
-export type Plugin<State = undefined> =
-    State extends undefined ? BasePlugin<State>: StatefulPlugin<State>;
-
 // This interface defines what a plugin factory should look like.
 // This can be defined in the plugin class definition by defining a constructor
 // and the relevant static methods:
@@ -309,9 +304,9 @@
 //   ... methods from the TracePlugin interface go here ...
 // }
 // ... which can then be passed around by class i.e. MyPlugin
-export interface PluginClass<T> {
+export interface PluginClass {
   // Instantiate the plugin.
-  new(): Plugin<T>;
+  new(): Plugin;
 }
 
 // Describes a reference to a registered track.
@@ -356,9 +351,9 @@
 
 // Plugins can be passed as class refs, factory functions, or concrete plugin
 // implementations.
-export type PluginFactory<T> = PluginClass<T>|Plugin<T>|(() => Plugin<T>);
+export type PluginFactory = PluginClass|Plugin|(() => Plugin);
 
-export interface PluginDescriptor<T = undefined> {
+export interface PluginDescriptor {
   // A unique string for your plugin. To ensure the name is unique you
   // may wish to use a URL with reversed components in the manner of
   // Java package names.
@@ -366,5 +361,5 @@
 
   // The plugin factory used to instantiate the plugin object, or if this is
   // an actual plugin implementation, it's just used as-is.
-  plugin: PluginFactory<T>;
+  plugin: PluginFactory;
 }
diff --git a/ui/src/tracks/actual_frames/index.ts b/ui/src/tracks/actual_frames/index.ts
index 4095041..e069236 100644
--- a/ui/src/tracks/actual_frames/index.ts
+++ b/ui/src/tracks/actual_frames/index.ts
@@ -155,7 +155,7 @@
 class ActualFrames implements Plugin {
   onActivate(_ctx: PluginContext): void {}
 
-  async onTraceLoad(ctx: PluginContextTrace<undefined>): Promise<void> {
+  async onTraceLoad(ctx: PluginContextTrace): Promise<void> {
     const {engine} = ctx;
     const result = await engine.query(`
       with process_async_tracks as materialized (
diff --git a/ui/src/tracks/annotation/index.ts b/ui/src/tracks/annotation/index.ts
index 5a0899d..7c05829 100644
--- a/ui/src/tracks/annotation/index.ts
+++ b/ui/src/tracks/annotation/index.ts
@@ -38,7 +38,7 @@
     await this.addAnnotationCounterTracks(ctx);
   }
 
-  private async addAnnotationTracks(ctx: PluginContextTrace<undefined>) {
+  private async addAnnotationTracks(ctx: PluginContextTrace) {
     const {engine} = ctx;
 
     const result = await engine.query(`
diff --git a/ui/src/tracks/null_track/index.ts b/ui/src/tracks/null_track/index.ts
index 4040900..dff2b06 100644
--- a/ui/src/tracks/null_track/index.ts
+++ b/ui/src/tracks/null_track/index.ts
@@ -42,7 +42,7 @@
 class NullTrackPlugin implements Plugin {
   onActivate(_ctx: PluginContext): void {}
 
-  async onTraceLoad(ctx: PluginContextTrace<undefined>): Promise<void> {
+  async onTraceLoad(ctx: PluginContextTrace): Promise<void> {
     // TODO(stevegolton): This is not the right way to handle blank tracks,
     // instead we should probably just render some blank element at render time
     // if no track uri is supplied.
diff --git a/ui/src/tracks/screenshots/index.ts b/ui/src/tracks/screenshots/index.ts
index c354371..ca73ab5 100644
--- a/ui/src/tracks/screenshots/index.ts
+++ b/ui/src/tracks/screenshots/index.ts
@@ -83,7 +83,7 @@
 class ScreenshotsPlugin implements Plugin {
   onActivate(_ctx: PluginContext): void {}
 
-  async onTraceLoad(ctx: PluginContextTrace<undefined>): Promise<void> {
+  async onTraceLoad(ctx: PluginContextTrace): Promise<void> {
     ctx.registerStaticTrack({
       uri: 'perfetto.Screenshots',
       displayName: 'Screenshots',
diff --git a/ui/src/tracks/thread_state/index.ts b/ui/src/tracks/thread_state/index.ts
index d5944ca..62c79bb 100644
--- a/ui/src/tracks/thread_state/index.ts
+++ b/ui/src/tracks/thread_state/index.ts
@@ -297,7 +297,7 @@
 class ThreadState implements Plugin {
   onActivate(_ctx: PluginContext): void {}
 
-  async onTraceLoad(ctx: PluginContextTrace<undefined>): Promise<void> {
+  async onTraceLoad(ctx: PluginContextTrace): Promise<void> {
     const {engine} = ctx;
     const result = await engine.query(`
       select
diff --git a/ui/src/tracks/visualised_args/index.ts b/ui/src/tracks/visualised_args/index.ts
index e4bf07b..e2f92e6 100644
--- a/ui/src/tracks/visualised_args/index.ts
+++ b/ui/src/tracks/visualised_args/index.ts
@@ -108,7 +108,8 @@
 
 class VisualisedArgsPlugin implements Plugin {
   onActivate(_ctx: PluginContext): void {}
-  async onTraceLoad(ctx: PluginContextTrace<undefined>): Promise<void> {
+
+  async onTraceLoad(ctx: PluginContextTrace): Promise<void> {
     ctx.registerTrack({
       uri: VISUALISED_ARGS_SLICE_TRACK_URI,
       tags: {