ui: Add 'Open from GDrive' sidebar button and GDrive client

Add a new Google Drive core plugin that lets internal users open a
trace directly from Google Drive.

- Add gdrive_client.ts, a GDrive client wrapping the Drive API that
  lazily loads the Google Drive / Identity Services scripts, handles
  OAuth2 authentication (with token caching), and can pick and open a
  file via the Drive picker.
- Add a runtime config loader that fetches the (non-checked-in) API
  credentials from an internal script into window.gDriveConfig.
- Add an 'Open from GDrive' sidebar button (gated to internal users)
  and support opening a trace from the Google Drive "Open with" deep
  link (?state=<json>), honoring the resource key and including
  popup-blocked and file-access authorization fallbacks.
- Add a 'visible' field to sidebar menu items so items can be hidden
  entirely, and wire up the CSP to allow the Google Drive origins.
diff --git a/CHANGELOG b/CHANGELOG
index 583166b..36c6837 100644
--- a/CHANGELOG
+++ b/CHANGELOG
@@ -9,6 +9,8 @@
      disabled.
   UI:
     * Double-clicking a timeline slice now selects and zooms into it.
+    * Added the ability (internal users only) to open traces from Google Drive
+      via a new "Open from GDrive" sidebar button.
   SDK:
    *
 
diff --git a/ui/package.json b/ui/package.json
index 7c773fc..15af958 100644
--- a/ui/package.json
+++ b/ui/package.json
@@ -44,6 +44,9 @@
     "@testing-library/dom": "^10.4.1",
     "@types/chrome": "0.0.268",
     "@types/color-convert": "^2.0.3",
+    "@types/gapi": "^0.0.47",
+    "@types/gapi.client.drive-v3": "^0.0.5",
+    "@types/google.picker": "^0.0.51",
     "@types/markdown-it": "^14.1.2",
     "@types/mithril": "^2.2.6",
     "@types/node": "^20.19.41",
diff --git a/ui/pnpm-lock.yaml b/ui/pnpm-lock.yaml
index c262617..05e4dae 100644
--- a/ui/pnpm-lock.yaml
+++ b/ui/pnpm-lock.yaml
@@ -114,6 +114,15 @@
       '@types/color-convert':
         specifier: ^2.0.3
         version: 2.0.4
+      '@types/gapi':
+        specifier: ^0.0.47
+        version: 0.0.47
+      '@types/gapi.client.drive-v3':
+        specifier: ^0.0.5
+        version: 0.0.5
+      '@types/google.picker':
+        specifier: ^0.0.51
+        version: 0.0.51
       '@types/markdown-it':
         specifier: ^14.1.2
         version: 14.1.2
@@ -516,6 +525,12 @@
   '@marijn/find-cluster-break@1.0.3':
     resolution: {integrity: sha512-FY+MKLBoTsLNJF/eLWaOsXGdz6uh3Iu1axjPf6TUq92IYumcTcXWHoS747JARLkcdlJ/Waiaxc5wQfFO8jC6NA==}
 
+  '@maxim_mazurok/gapi.client.discovery-v1@0.6.20200806':
+    resolution: {integrity: sha512-QN6aGmIbLXcJW0SEf8tz9UWCpQTBMy2u4xSuZ04YofzMGknhWwMeUecySLntCZYAEstHzDoSgwxCf5Gb8GqIRQ==}
+
+  '@maxim_mazurok/gapi.client.drive-v3@0.3.20260810':
+    resolution: {integrity: sha512-J8IA28ANqiCD0E1oUkrRcFjaaAZOV2kU+MY/0wU13fMfin2d7+QJ9PnhTqB8cLU+WWOg+FtWt/FRnHUbpZr5bA==}
+
   '@oxc-project/types@0.143.0':
     resolution: {integrity: sha512-u6JZdLBTLotrNC9Vd6vPssINdzcCzleKAH6EJKImQb7GtYvX5keN2dxkoK44stCc4tffE6QQRtZTXVSzsLUlWA==}
 
@@ -773,6 +788,21 @@
   '@types/filewriter@0.0.33':
     resolution: {integrity: sha512-xFU8ZXTw4gd358lb2jw25nxY9QAgqn2+bKKjKOYfNCzN4DKCFetK7sPtrlpg66Ywe3vWY9FNxprZawAh9wfJ3g==}
 
+  '@types/gapi.client.discovery-v1@0.0.4':
+    resolution: {integrity: sha512-uevhRumNE65F5mf2gABLaReOmbFSXONuzFZjNR3dYv6BmkHg+wciubHrfBAsp3554zNo3Dcg6dUAlwMqQfpwjQ==}
+
+  '@types/gapi.client.drive-v3@0.0.5':
+    resolution: {integrity: sha512-yYBxiqMqJVBg4bns4Q28+f2XdJnd3tVA9dxQX1lXMVmzT2B+pZdyCi1u9HLwGveVlookSsAXuqfLfS9KO6MF6w==}
+
+  '@types/gapi.client@1.0.8':
+    resolution: {integrity: sha512-qJQUmmumbYym3Amax0S8CVzuSngcXsC1fJdwRS2zeW5lM63zXkw4wJFP+bG0jzgi0R6EsJKoHnGNVTDbOyG1ng==}
+
+  '@types/gapi@0.0.47':
+    resolution: {integrity: sha512-/ZsLuq6BffMgbKMtZyDZ8vwQvTyKhKQ1G2K6VyWCgtHHhfSSXbk4+4JwImZiTjWNXfI2q1ZStAwFFHSkNoTkHA==}
+
+  '@types/google.picker@0.0.51':
+    resolution: {integrity: sha512-z6o2J4PQTcXvlW1rtgQx65d5uEF+rMI1hzrnazKQxBONdEuYAr4AeOSH2KZy12WHPmqMX+aWYyfcZ0uktBBhhA==}
+
   '@types/har-format@1.2.16':
     resolution: {integrity: sha512-fluxdy7ryD3MV6h8pTfTYpy/xQzCFC7m89nOH9y94cNqJ1mDIDPut7MnRHI3F6qRmh/cT2fUjG1MLdCNb4hE9A==}
 
@@ -2465,6 +2495,16 @@
 
   '@marijn/find-cluster-break@1.0.3': {}
 
+  '@maxim_mazurok/gapi.client.discovery-v1@0.6.20200806':
+    dependencies:
+      '@types/gapi.client': 1.0.8
+      '@types/gapi.client.discovery-v1': 0.0.4
+
+  '@maxim_mazurok/gapi.client.drive-v3@0.3.20260810':
+    dependencies:
+      '@types/gapi.client': 1.0.8
+      '@types/gapi.client.discovery-v1': 0.0.4
+
   '@oxc-project/types@0.143.0': {}
 
   '@parcel/watcher-android-arm64@2.5.6':
@@ -2641,6 +2681,20 @@
 
   '@types/filewriter@0.0.33': {}
 
+  '@types/gapi.client.discovery-v1@0.0.4':
+    dependencies:
+      '@maxim_mazurok/gapi.client.discovery-v1': 0.6.20200806
+
+  '@types/gapi.client.drive-v3@0.0.5':
+    dependencies:
+      '@maxim_mazurok/gapi.client.drive-v3': 0.3.20260810
+
+  '@types/gapi.client@1.0.8': {}
+
+  '@types/gapi@0.0.47': {}
+
+  '@types/google.picker@0.0.51': {}
+
   '@types/har-format@1.2.16': {}
 
   '@types/json-schema@7.0.15': {}
diff --git a/ui/src/core_plugins/dev.perfetto.GoogleDrive/gdrive_client.ts b/ui/src/core_plugins/dev.perfetto.GoogleDrive/gdrive_client.ts
new file mode 100644
index 0000000..8ad6b8c
--- /dev/null
+++ b/ui/src/core_plugins/dev.perfetto.GoogleDrive/gdrive_client.ts
@@ -0,0 +1,282 @@
+// 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 {ensureExists} from '../../base/assert';
+import {errResult, okResult, type Result} from '../../base/result';
+
+// The Google Drive / Google Identity Services loader scripts. We load these
+// lazily (rather than statically in index.html) so that only users who
+// actually use the plugin pay the cost of fetching them.
+const GAPI_LOADER_URL = 'https://apis.google.com/js/api.js';
+const GSI_CLIENT_URL = 'https://accounts.google.com/gsi/client';
+
+// Loads an external <script> and resolves once it has finished loading. The
+// promise is cached per URL so that repeated calls share a single load.
+const scriptLoadPromises = new Map<string, Promise<void>>();
+function loadScript(src: string): Promise<void> {
+  let promise = scriptLoadPromises.get(src);
+  if (promise === undefined) {
+    promise = new Promise<void>((resolve, reject) => {
+      const script = document.createElement('script');
+      script.src = src;
+      script.async = true;
+      script.onload = () => resolve();
+      script.onerror = () => reject(new Error(`Failed to load ${src}`));
+      document.head.append(script);
+    });
+    scriptLoadPromises.set(src, promise);
+  }
+  return promise;
+}
+
+export const SCOPES =
+  'https://www.googleapis.com/auth/drive.file https://www.googleapis.com/auth/drive.install';
+
+export interface PickerResponse {
+  readonly docs: google.picker.DocumentObject[];
+}
+
+export interface PickerConfig {
+  readonly view: google.picker.DocsView;
+  readonly title?: string;
+}
+
+export interface GoogleDriveFile {
+  readonly id: string;
+  readonly name: string;
+  readonly blob: Blob;
+}
+
+export type AuthenticationResponse =
+  | {
+      readonly response: 'success';
+      readonly accessToken: string;
+    }
+  | {
+      readonly response: 'popup_blocked';
+    }
+  | {
+      readonly response: 'popup_closed';
+    };
+
+export class GoogleDriveClient {
+  private clientInitPromise: Promise<void> | undefined;
+  private pendingToken: Promise<AuthenticationResponse> | undefined;
+
+  constructor(
+    private readonly apiKey: string,
+    private readonly clientId: string,
+    private readonly appId: string,
+  ) {}
+
+  async picker(
+    token: string,
+    config: PickerConfig,
+  ): Promise<PickerResponse | undefined> {
+    return await new Promise<PickerResponse | undefined>((resolve, reject) => {
+      const pickerBuilder = new google.picker.PickerBuilder()
+        .setAppId(this.appId)
+        .setOAuthToken(token)
+        .setDeveloperKey(this.apiKey)
+        .setOrigin(window.location.protocol + '//' + window.location.host)
+        .addView(config.view)
+        .setCallback((data) => {
+          switch (data.action) {
+            case google.picker.Action.PICKED:
+              resolve({docs: ensureExists(data.docs)});
+              break;
+            case google.picker.Action.CANCEL:
+              resolve(undefined);
+              break;
+            case google.picker.Action.ERROR:
+              reject(new Error('Something went wrong with the picker'));
+              break;
+          }
+        });
+
+      if (config.title) {
+        pickerBuilder.setTitle(config.title);
+      }
+
+      const picker = pickerBuilder.build();
+      picker.setVisible(true);
+    });
+  }
+
+  // Request access to a specific file by ID via the picker API.
+  async requestFileAccess(
+    token: string,
+    fileId: string,
+  ): Promise<google.picker.DocumentObject[] | undefined> {
+    await this.gapiLoad('picker');
+    const view = new google.picker.DocsView(google.picker.ViewId.DOCS)
+      .setMode(google.picker.DocsViewMode.LIST)
+      .setFileIds(fileId);
+    const pickerResult = await this.picker(token, {view});
+    return pickerResult?.docs;
+  }
+
+  // Open the Google Drive file picker to select a file.
+  async pickFile(
+    token: string,
+  ): Promise<google.picker.DocumentObject[] | undefined> {
+    await this.gapiLoad('picker');
+    const view = new google.picker.DocsView(google.picker.ViewId.DOCS).setMode(
+      google.picker.DocsViewMode.LIST,
+    );
+    const pickerResult = await this.picker(token, {view});
+    return pickerResult?.docs;
+  }
+
+  async openFile(
+    fileId: string,
+    name?: string,
+    resourceKey?: string,
+  ): Promise<Result<GoogleDriveFile>> {
+    try {
+      // The resource key (from the "Open with" state payload) is required to
+      // access a file via the Drive API when the app is not a collaborator.
+      const params: {fileId: string; alt: 'media'; key?: string} = {
+        fileId: fileId,
+        alt: 'media',
+      };
+      if (resourceKey !== undefined) {
+        params.key = resourceKey;
+      }
+      const response = await gapi.client.drive.files.get(params);
+
+      // The response body is a string, but it represents binary data.
+      // We can convert it to an ArrayBuffer by accessing the character codes.
+      const body = response.body;
+      const buffer = new ArrayBuffer(body.length);
+      const view = new Uint8Array(buffer);
+      for (let i = 0; i < body.length; i++) {
+        view[i] = body.charCodeAt(i);
+      }
+      return okResult({
+        id: fileId,
+        name: name ?? 'Google Drive Trace',
+        blob: new Blob([buffer]),
+      });
+    } catch (error) {
+      return errResult(error);
+    }
+  }
+
+  async authenticate(): Promise<AuthenticationResponse> {
+    await this.maybeInitClient();
+
+    if (!this.pendingToken) {
+      this.pendingToken = new Promise<AuthenticationResponse>(
+        async (resolve, reject) => {
+          const cachedToken = localStorage.getItem('driveToken');
+          if (cachedToken !== null) {
+            if (await this.isTokenValid(cachedToken)) {
+              // We need to do this when reusing a cached token, but not when
+              // getting a new one. Maybe the tokenClient does this automatically
+              // under the hood?
+              gapi.client.setToken({access_token: cachedToken});
+              resolve({response: 'success', accessToken: cachedToken});
+              return;
+            } else {
+              localStorage.removeItem('driveToken');
+            }
+          }
+
+          // The google.accounts.oauth2 token client is typed via the Window
+          // augmentation in google_drive.d.ts. Load the GSI client script
+          // lazily before using it.
+          await loadScript(GSI_CLIENT_URL);
+          const oauth2 = window.google?.accounts?.oauth2;
+          if (!oauth2) {
+            reject(new Error('Google Identity Services is not available'));
+            return;
+          }
+          const tokenClient: GDriveTokenClient = oauth2.initTokenClient({
+            client_id: this.clientId,
+            scope: SCOPES,
+            callback: (tokenResponse: GDriveTokenResponse) => {
+              if (Boolean(tokenResponse.error)) {
+                return;
+              }
+              const accessToken = tokenResponse.access_token;
+              // Resolve any pending promises waiting for the new token.
+              if (accessToken != null) {
+                localStorage.setItem('driveToken', accessToken);
+                resolve({response: 'success', accessToken: accessToken});
+              }
+            },
+            error_callback: (error: GDriveTokenClientError) => {
+              if (error.type === 'popup_failed_to_open') {
+                resolve({response: 'popup_blocked'});
+              } else if (error.type === 'popup_closed') {
+                resolve({response: 'popup_closed'});
+              } else {
+                // Improve this message
+                reject(new Error('Something went wrong'));
+              }
+              // Clear the pending promise so that future calls to
+              // authenticate() can try again.
+              this.pendingToken = undefined;
+            },
+          });
+          tokenClient.requestAccessToken({prompt: ''});
+        },
+      );
+    }
+
+    return await this.pendingToken;
+  }
+
+  private async maybeInitClient(): Promise<void> {
+    if (!this.clientInitPromise) {
+      this.clientInitPromise = new Promise<void>(async (resolve) => {
+        await this.gapiLoad('client');
+        await gapi.client.init({
+          apiKey: this.apiKey,
+          discoveryDocs: [
+            'https://www.googleapis.com/discovery/v1/apis/drive/v3/rest',
+          ],
+        });
+        resolve();
+      });
+    }
+    await this.clientInitPromise;
+  }
+
+  private async gapiLoad(what: string): Promise<void> {
+    await loadScript(GAPI_LOADER_URL);
+    await new Promise<void>((resolve) => {
+      gapi.load(what, () => resolve());
+    });
+  }
+
+  private async isTokenValid(token: string) {
+    try {
+      const response = await fetch(
+        'https://www.googleapis.com/oauth2/v1/tokeninfo',
+        {
+          method: 'GET',
+          headers: {
+            Authorization: `Bearer ${token}`,
+          },
+        },
+      );
+
+      return response.ok;
+    } catch {
+      return false;
+    }
+  }
+}
diff --git a/ui/src/core_plugins/dev.perfetto.GoogleDrive/google_drive.d.ts b/ui/src/core_plugins/dev.perfetto.GoogleDrive/google_drive.d.ts
new file mode 100644
index 0000000..59fd09e
--- /dev/null
+++ b/ui/src/core_plugins/dev.perfetto.GoogleDrive/google_drive.d.ts
@@ -0,0 +1,74 @@
+// 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.
+
+// Ambient types for the parts of the Google Drive / Google Identity Services
+// APIs that are not covered by the installed @types packages:
+//   * gapi.client.drive.share (the Drive Share settings dialog)
+//   * google.accounts.oauth2 (the GSI OAuth2 token client)
+//
+// We declare only the minimal surface this plugin actually uses.
+
+declare global {
+  // The Drive Share API is exposed under gapi.drive.share, NOT
+  // gapi.client.drive.share (which is where the generated
+  // @types/gapi.client.drive-v3 types live). Declare the minimal surface
+  // this plugin uses.
+  namespace gapi.drive {
+    namespace share {
+      class ShareClient {
+        setOAuthToken(token: string): void;
+        setItemIds(ids: string[]): void;
+        showSettingsDialog(): void;
+      }
+    }
+  }
+
+  // The GSI OAuth2 token client returned by google.accounts.oauth2
+  // .initTokenClient().
+  interface GDriveTokenClient {
+    requestAccessToken(options?: {prompt?: string}): void;
+  }
+
+  interface GDriveTokenResponse {
+    readonly error?: string;
+    readonly access_token?: string;
+    readonly expires_in?: string;
+    readonly token_type?: string;
+    readonly scope?: string;
+  }
+
+  interface GDriveTokenClientError {
+    readonly type?: string;
+  }
+
+  // The GSI scripts attach everything to window.google. We type the oauth2
+  // token client here via a Window augmentation.
+  interface Window {
+    google?: {
+      accounts?: {
+        oauth2?: {
+          initTokenClient(params: {
+            client_id: string;
+            scope: string;
+            callback?: (response: GDriveTokenResponse) => void;
+            error_callback?: (error: GDriveTokenClientError) => void;
+          }): GDriveTokenClient;
+        };
+      };
+    };
+  }
+}
+
+// We can only augment the global scope from an external module.
+export {};
diff --git a/ui/src/core_plugins/dev.perfetto.GoogleDrive/index.ts b/ui/src/core_plugins/dev.perfetto.GoogleDrive/index.ts
new file mode 100644
index 0000000..433fcf5
--- /dev/null
+++ b/ui/src/core_plugins/dev.perfetto.GoogleDrive/index.ts
@@ -0,0 +1,279 @@
+// Copyright (C) 2026 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 m from 'mithril';
+import type {AppImpl} from '../../core/app_impl';
+import type {PerfettoPlugin} from '../../public/plugin';
+import {Button, ButtonVariant} from '../../widgets/button';
+import {closeModal, showModal} from '../../widgets/modal';
+import {Stack} from '../../widgets/stack';
+import {GoogleDriveClient, type GoogleDriveFile} from './gdrive_client';
+import type {Result} from '../../base/result';
+import {defer} from '../../base/deferred';
+import {Intent} from '../../widgets/common';
+
+// The Google Drive credentials are not checked into the repo. They are loaded
+// at runtime from an internal script (the same way the internal userscript is
+// loaded) via a <script> tag, which sidesteps the CORS restrictions a
+// cross-origin fetch() would hit. The script populates window.gDriveConfig.
+const CONFIG_URL =
+  'https://storage.cloud.google.com/perfetto-ui-internal/gdrive_config.js';
+const CONFIG_LOAD_TIMEOUT_MS = 5000;
+
+interface GDriveConfig {
+  readonly apiKey: string;
+  readonly clientId: string;
+  readonly appId: string;
+}
+
+declare global {
+  interface Window {
+    gDriveConfig?: GDriveConfig;
+  }
+}
+
+interface GDriveConfigResult {
+  readonly config: GDriveConfig;
+  // Whether the config script was actually fetched (i.e. CONFIG_URL was
+  // reachable). Used to decide whether to expose the sidebar button: the
+  // config only exists on internal deployments.
+  readonly ok: boolean;
+}
+
+let gDriveConfigPromise: Promise<GDriveConfigResult> | undefined;
+
+// Kicks off the config script load and returns a promise that resolves to the
+// populated window.gDriveConfig along with whether it was successfully
+// fetched. The result is cached so the script is only loaded once.
+function loadGDriveConfig(): Promise<GDriveConfigResult> {
+  if (!gDriveConfigPromise) {
+    const defaults: GDriveConfig = {apiKey: '', clientId: '', appId: ''};
+    window.gDriveConfig = defaults;
+
+    const loaded = defer<GDriveConfigResult>();
+    // Only the first settle wins (onload/onerror/timeout can all fire).
+    let settled = false;
+    const settle = (ok: boolean) => {
+      if (settled) return;
+      settled = true;
+      loaded.resolve({config: window.gDriveConfig ?? defaults, ok});
+    };
+
+    const script = document.createElement('script');
+    script.src = CONFIG_URL;
+    script.async = true;
+    script.onload = () => settle(true);
+    script.onerror = () => settle(false);
+    document.head.append(script);
+
+    setTimeout(() => settle(false), CONFIG_LOAD_TIMEOUT_MS);
+    gDriveConfigPromise = loaded;
+  }
+  return gDriveConfigPromise;
+}
+
+let gDriveClientPromise: Promise<GoogleDriveClient> | undefined;
+
+// Lazily loads the config and constructs the client on first use, caching the
+// result so the config is only loaded once.
+function getGDriveClient(): Promise<GoogleDriveClient> {
+  if (!gDriveClientPromise) {
+    gDriveClientPromise = loadGDriveConfig().then(
+      ({config}) =>
+        new GoogleDriveClient(config.apiKey, config.clientId, config.appId),
+    );
+  }
+  return gDriveClientPromise;
+}
+
+// The "Open with" deep link (?state=<json>) is handled by
+// handleOpenWithState() below. For it to actually fire, the app still needs to
+// be registered with Google Drive ("Open with" app) with the correct redirect
+// URI, e.g. https://perfetto.dev/?state={...} where the JSON blob names the
+// file to open. See TODO(stevegolton) for that setup.
+
+export default class implements PerfettoPlugin {
+  static readonly id = 'dev.perfetto.GoogleDrive';
+  static readonly description = 'Open and save traces to Google Drive';
+
+  static onActivate(app: AppImpl) {
+    // Only expose the sidebar button when the config is reachable (i.e. this
+    // is an internal deployment). The config load is kicked off here and the
+    // item is added once it resolves successfully; otherwise nothing is shown.
+    loadGDriveConfig().then(({ok}) => {
+      if (!ok) return;
+      app.sidebar.addMenuItem({
+        section: 'trace_files',
+        text: 'Open from GDrive',
+        icon: 'drive_export',
+        badge: 'preview',
+        // Just below "Open trace file".
+        sortOrder: 1.25,
+        action: async () => {
+          const client = await getGDriveClient();
+          const auth = await client.authenticate();
+          if (auth.response !== 'success') return;
+          const files = await client.pickFile(auth.accessToken);
+          if (!files) return;
+          if (files.length === 0) return;
+
+          const firstFile = files[0];
+          const fileResult = await client.openFile(
+            firstFile.id,
+            firstFile.name,
+          );
+          if (fileResult.ok) {
+            const file = fileResult.value;
+            app.openTraceFromBuffer({
+              buffer: await file.blob.arrayBuffer(),
+              title: file.name,
+              fileName: file.name,
+            });
+          }
+        },
+      });
+    });
+
+    // When openining a trace directly from Google drive's UI, it passes the
+    // file id and the resource keys in a hard-coded arg called 'state'.
+    // Intercept this and load the file if these args are passed.
+    const state = app.initialRouteArgs['state'];
+
+    if (state !== undefined && typeof state === 'string') {
+      handleOpenWithState(app, state);
+    }
+  }
+}
+
+// The shape of the JSON blob Google Drive posts to our registered "Open with"
+// redirect URL as ?state=<url-encoded-json> when a user opens a file in our
+// app directly from the Drive UI.
+interface GDriveOpenState {
+  // The IDs of the file(s) being opened.
+  readonly ids?: string[];
+  // The action being performed; 'open' for the "Open with" flow.
+  readonly action?: string;
+  // Maps file id -> resource key. The resource key is required to access a
+  // file via the Drive API when the app is not a collaborator on it.
+  readonly resourceKeys?: Record<string, string>;
+}
+
+async function openGDriveTrace(
+  app: AppImpl,
+  fileId: string,
+  resourceKey?: string,
+): Promise<Result<GoogleDriveFile>> {
+  const client = await getGDriveClient();
+  const fileResult = await client.openFile(fileId, undefined, resourceKey);
+  if (fileResult.ok) {
+    const file = fileResult.value;
+    app.openTraceFromBuffer({
+      buffer: await file.blob.arrayBuffer(),
+      title: file.name,
+      fileName: file.name,
+    });
+  }
+  return fileResult;
+}
+
+// Authenticate (handling the popup-blocked case) and open the trace, falling
+// back to an "authorize file access" prompt if the initial fetch fails.
+async function openGDriveTraceWithAuth(
+  app: AppImpl,
+  fileId: string,
+  resourceKey?: string,
+): Promise<void> {
+  const client = await getGDriveClient();
+  const auth = await client.authenticate();
+  if (auth.response === 'popup_blocked') {
+    showModal({
+      key: 'GoogleDrivePopupBlocked',
+      title: 'Popups blocked',
+      content: () =>
+        m(Stack, [
+          m(
+            'p',
+            'Google drive authentication requires a popup, please disable popups on this website and reload the page.',
+          ),
+          m(Stack, {orientation: 'horizontal'}, [
+            m(Button, {
+              label: 'Try again',
+              intent: Intent.Primary,
+              variant: ButtonVariant.Filled,
+              onclick: async () => {
+                window.location.reload();
+              },
+            }),
+          ]),
+        ]),
+    });
+    return;
+  }
+
+  if (auth.response !== 'success') {
+    return;
+  }
+
+  const accessToken = auth.accessToken;
+  const fileResult = await openGDriveTrace(app, fileId, resourceKey);
+  if (!fileResult.ok) {
+    // Maybe we need to authorize access to the file from our app?
+    showModal({
+      key: 'GoogleDriveAuthNeeded',
+      title: 'Authorize access to file',
+      content: () =>
+        m(Stack, [
+          m(
+            'p',
+            "This is the first time you've accessed this app through the drive, please authorize access to the file.",
+          ),
+          m(Button, {
+            label: 'Authorize File Access',
+            intent: Intent.Primary,
+            variant: ButtonVariant.Filled,
+            onclick: async () => {
+              const files = await client.requestFileAccess(accessToken, fileId);
+              if (!files) return;
+              await openGDriveTrace(app, fileId, resourceKey);
+              closeModal('GoogleDriveAuthNeeded');
+            },
+          }),
+        ]),
+    });
+  }
+}
+
+// Handles the Google Drive "Open with" deep link (?state=<json>).
+async function handleOpenWithState(app: AppImpl, state: string) {
+  let parsed: GDriveOpenState;
+  try {
+    parsed = JSON.parse(state) as GDriveOpenState;
+  } catch {
+    // Not a GDrive "Open with" payload; ignore.
+    return;
+  }
+
+  const fileId = parsed.ids?.[0];
+  if (fileId === undefined) return;
+  const resourceKey = parsed.resourceKeys?.[fileId];
+
+  // Remove the 'state' query param now that we've consumed it. The router will
+  // append ?local_cache_key=xxx to the hash once the trace finishes loading,
+  // giving a clean shareable URL.
+  const url = new URL(window.location.href);
+  url.searchParams.delete('state');
+  history.replaceState(null, '', url.toString());
+
+  await openGDriveTraceWithAuth(app, fileId, resourceKey);
+}
diff --git a/ui/src/frontend/index.ts b/ui/src/frontend/index.ts
index e6fb110..4853f49 100644
--- a/ui/src/frontend/index.ts
+++ b/ui/src/frontend/index.ts
@@ -162,6 +162,7 @@
       'https://*.googleusercontent.com',
       'https://www.googletagmanager.com',
       'https://*.google-analytics.com',
+      'https://accounts.google.com/',
     ],
     'object-src': [`'none'`],
     'connect-src': [
@@ -171,6 +172,8 @@
       'https:', // Allow any HTTPS; service worker firewall adds granular filtering.
       'blob:',
       'data:',
+      'https://drive.google.com',
+      'https://play.google.com',
     ].concat(rpcPolicy),
     'img-src': [
       `'self'`,
@@ -179,8 +182,15 @@
       'https://*.google-analytics.com',
       'https://www.googletagmanager.com',
       'https://*.googleapis.com',
+      'https://ssl.gstatic.com',
     ],
     'style-src': [`'self'`, `'unsafe-inline'`],
+    'frame-src': [
+      'https://content.googleapis.com',
+      'https://accounts.google.com/',
+      'https://docs.google.com',
+      'https://drive.google.com',
+    ],
   };
   const meta = document.createElement('meta');
   meta.httpEquiv = 'Content-Security-Policy';
diff --git a/ui/src/frontend/views/sidebar_section.ts b/ui/src/frontend/views/sidebar_section.ts
index a59fc62..095dc24 100644
--- a/ui/src/frontend/views/sidebar_section.ts
+++ b/ui/src/frontend/views/sidebar_section.ts
@@ -32,14 +32,17 @@
   private expanded?: boolean;
 
   view({attrs}: m.CVnode<SidebarSectionAttrs>): m.Children {
-    if (attrs.items.length === 0) return undefined;
+    const items = attrs.items.filter(
+      (item) => item.visible === undefined || item.visible === true,
+    );
+    if (items.length === 0) return undefined;
 
     if (this.expanded === undefined) {
       this.expanded = !attrs.defaultCollapsed;
     }
     const expanded = this.expanded;
 
-    const menuItems = attrs.items
+    const menuItems = items
       .slice()
       .sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0))
       .map((item) => m(SidebarItem, {app: attrs.app, item}));
diff --git a/ui/src/public/sidebar.ts b/ui/src/public/sidebar.ts
index 43b53c8..3bba03d 100644
--- a/ui/src/public/sidebar.ts
+++ b/ui/src/public/sidebar.ts
@@ -78,6 +78,10 @@
   // that shows the returned text (the string has "disabled reason" semantic);
   readonly disabled?: string | boolean | (() => string | boolean);
 
+  // If false (or the callback returns false) the item is not rendered at all.
+  // If a callback it is invoked on every render frame; keep it cheap.
+  readonly visible?: boolean;
+
   // One of the three following arguments must be specified.
 } & (
   | {