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. } & ( | {