ui: Google drive integration Change-Id: Ibe958129cc7178ca9b1abb08a5d6f7318763df92
diff --git a/ui/package.json b/ui/package.json index 6dd1a28..3ba6520 100644 --- a/ui/package.json +++ b/ui/package.json
@@ -35,6 +35,7 @@ "esbuild": "^0.21.5", "events": "^3.3.0", "fzf": "^0.5.2", + "gapi-script": "^1.2.0", "hsluv": "^0.1.0", "immer": "^10.1.1", "jsbn-rsa": "^1.0.4", @@ -55,6 +56,11 @@ "@playwright/test": "^1.47.0", "@rollup/plugin-commonjs": "^26.0.1", "@rollup/plugin-node-resolve": "^15.2.3", + "@types/gapi": "^0.0.47", + "@types/gapi.auth2": "^0.0.61", + "@types/gapi.client.drive-v3": "^0.0.5", + "@types/google-one-tap": "^1.2.6", + "@types/google.picker": "^0.0.51", "@types/jest": "^29.5.12", "@typescript-eslint/eslint-plugin": "^7.14.1", "@typescript-eslint/parser": "^7.14.1",
diff --git a/ui/pnpm-lock.yaml b/ui/pnpm-lock.yaml index c088e25..c90911a 100644 --- a/ui/pnpm-lock.yaml +++ b/ui/pnpm-lock.yaml
@@ -89,6 +89,9 @@ fzf: specifier: ^0.5.2 version: 0.5.2 + gapi-script: + specifier: ^1.2.0 + version: 1.2.0 hsluv: specifier: ^0.1.0 version: 0.1.0 @@ -145,6 +148,21 @@ '@rollup/plugin-node-resolve': specifier: ^15.2.3 version: 15.2.3(rollup@2.79.1) + '@types/gapi': + specifier: ^0.0.47 + version: 0.0.47 + '@types/gapi.auth2': + specifier: ^0.0.61 + version: 0.0.61 + '@types/gapi.client.drive-v3': + specifier: ^0.0.5 + version: 0.0.5 + '@types/google-one-tap': + specifier: ^1.2.6 + version: 1.2.6 + '@types/google.picker': + specifier: ^0.0.51 + version: 0.0.51 '@types/jest': specifier: ^29.5.12 version: 29.5.12 @@ -1448,6 +1466,20 @@ resolution: {integrity: sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==} dev: false + /@maxim_mazurok/gapi.client.discovery-v1@0.1.20200806: + resolution: {integrity: sha512-Wl6UfmZVDdWbY3PUu8E2ULk9RPLjnMqp/iOA4tcK8Ne+U/GmlnWP/e34IaZNGArfl7iXJNOG+/3Rj9L9jQyF9Q==} + dependencies: + '@types/gapi.client': 1.0.8 + '@types/gapi.client.discovery-v1': 0.0.4 + dev: true + + /@maxim_mazurok/gapi.client.drive-v3@0.0.20250811: + resolution: {integrity: sha512-hexYbwEHQRdd8oY+SRZwOI3rp5OaOqGxKmbvKSizxPj73ntKJtIJN2SOMXFebfR8wRjZ+y8mEEmZl4GP9VFZTg==} + dependencies: + '@types/gapi.client': 1.0.8 + '@types/gapi.client.discovery-v1': 0.0.4 + dev: true + /@mdn/browser-compat-data@5.5.35: resolution: {integrity: sha512-APtxt3S+a64EcXpG7E3a0bLx+CPqEcgN45FY/GEmbBBgX51AGIPkkYFy0JQDuOR0MFCozjo50q5Im74jflrkiQ==} dev: true @@ -1764,10 +1796,44 @@ resolution: {integrity: sha512-BsPXH/irW0ht0Ji6iw/jJaK8Lj3FJemon2gvEqHKpCdDCeemHa+rI3WBGq5z7cDMZgoLjY40oninGxqk+8NzNQ==} dev: false + /@types/gapi.auth2@0.0.61: + resolution: {integrity: sha512-cn+omiRoE/LTxZncnVl1QhcLggOT0sJ8Yz9RXIsw5R2zLyRf+0o6kaZzJ/Gr3Sxz6i7J/+PbXAF8yeZipCaiWw==} + dependencies: + '@types/gapi': 0.0.47 + dev: true + + /@types/gapi.client.discovery-v1@0.0.4: + resolution: {integrity: sha512-uevhRumNE65F5mf2gABLaReOmbFSXONuzFZjNR3dYv6BmkHg+wciubHrfBAsp3554zNo3Dcg6dUAlwMqQfpwjQ==} + dependencies: + '@maxim_mazurok/gapi.client.discovery-v1': 0.1.20200806 + dev: true + + /@types/gapi.client.drive-v3@0.0.5: + resolution: {integrity: sha512-yYBxiqMqJVBg4bns4Q28+f2XdJnd3tVA9dxQX1lXMVmzT2B+pZdyCi1u9HLwGveVlookSsAXuqfLfS9KO6MF6w==} + dependencies: + '@maxim_mazurok/gapi.client.drive-v3': 0.0.20250811 + dev: true + + /@types/gapi.client@1.0.8: + resolution: {integrity: sha512-qJQUmmumbYym3Amax0S8CVzuSngcXsC1fJdwRS2zeW5lM63zXkw4wJFP+bG0jzgi0R6EsJKoHnGNVTDbOyG1ng==} + dev: true + + /@types/gapi@0.0.47: + resolution: {integrity: sha512-/ZsLuq6BffMgbKMtZyDZ8vwQvTyKhKQ1G2K6VyWCgtHHhfSSXbk4+4JwImZiTjWNXfI2q1ZStAwFFHSkNoTkHA==} + dev: true + /@types/geojson@7946.0.4: resolution: {integrity: sha512-MHmwBtCb7OCv1DSivz2UNJXPGU/1btAWRKlqJ2saEhVJkpkvqHMMaOpKg0v4sAbDWSQekHGvPVMM8nQ+Jen03Q==} dev: false + /@types/google-one-tap@1.2.6: + resolution: {integrity: sha512-REmJsXVHvKb/sgI8DF+7IesMbDbcsEokHBqxU01ENZ8d98UPWdRLhUCtxEm9bhNFFg6PJGy7PNFdvovp0hK3jA==} + dev: true + + /@types/google.picker@0.0.51: + resolution: {integrity: sha512-z6o2J4PQTcXvlW1rtgQx65d5uEF+rMI1hzrnazKQxBONdEuYAr4AeOSH2KZy12WHPmqMX+aWYyfcZ0uktBBhhA==} + dev: true + /@types/graceful-fs@4.1.6: resolution: {integrity: sha512-Sig0SNORX9fdW+bQuTEovKj3uHcUL6LQKbCrrqb1X7J6/ReAbhCXRAhc+SMejhLELFj2QcyuxmUooZ4bt5ReSw==} dependencies: @@ -3578,6 +3644,10 @@ resolution: {integrity: sha512-Tt4kuxLXFKHy8KT40zwsUPUkg1CrsgY25FxA2U/j/0WgEDCk3ddc/zLTCCcbSHX9FcKtLuVaDGtGE/STWC+j3Q==} dev: false + /gapi-script@1.2.0: + resolution: {integrity: sha512-NKTVKiIwFdkO1j1EzcrWu/Pz7gsl1GmBmgh+qhuV2Ytls04W/Eg5aiBL91SCiBM9lU0PMu7p1hTVxhh1rPT5Lw==} + dev: false + /gaxios@6.7.1: resolution: {integrity: sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ==} engines: {node: '>=14'}
diff --git a/ui/src/assets/index.html b/ui/src/assets/index.html index 5867c1e..3b4c1fd 100644 --- a/ui/src/assets/index.html +++ b/ui/src/assets/index.html
@@ -3,6 +3,8 @@ <head> <meta charset="utf-8"> <title>Perfetto UI</title> + <script src="https://apis.google.com/js/api.js"></script> + <script src="https://accounts.google.com/gsi/client" async defer></script> <meta content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0" name="viewport" /> <link rel="shortcut icon" id="favicon" type="image/png" href="data:image/png;base64,iVBORw0KGgo="> </head>
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..7382811 --- /dev/null +++ b/ui/src/core_plugins/dev.perfetto.GoogleDrive/gdrive_client.ts
@@ -0,0 +1,346 @@ +// 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 {assertExists} from '../../base/logging'; +import {errResult, okResult, Result} from '../../base/result'; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type TokenClient = any; + +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: assertExists(data.docs)}); + debugger; + 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; + } + + // Pick a folder using the Google Drive file picker. + async pickFolder( + token: string, + ): Promise<google.picker.DocumentObject | undefined> { + await this.gapiLoad('picker'); + const view = new google.picker.DocsView(google.picker.ViewId.FOLDERS) + .setMode(google.picker.DocsViewMode.LIST) + .setIncludeFolders(true) + .setSelectFolderEnabled(true) + .setMimeTypes('application/vnd.google-apps.folder'); + const pickerResult = await this.picker(token, { + view, + title: 'Select folder', + }); + return pickerResult?.docs[0]!; + } + + async openFile( + token: string, + fileId: string, + ): Promise<Result<GoogleDriveFile>> { + console.log(token); + + try { + const response = await gapi.client.drive.files.get({ + fileId: fileId, + alt: 'media', + }); + + // 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: 'Google Drive Trace', + blob: new Blob([buffer]), + }); + } catch (error) { + return errResult(error); + } + } + + async openSharingDialog(token: string, fileId: string) { + await this.gapiLoad('drive-share'); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const s = new (gapi as any).drive.share.ShareClient(); + s.setOAuthToken(token); + s.setItemIds([fileId]); + s.showSettingsDialog(); + } + + async uploadFile( + token: string, + traceBlob: Blob, + parentId: string = 'root', + fileName: string, + ): Promise<Result<string>> { + console.log('Uploading file to Google Drive:', {fileName, parentId}); + const traceBuffer = await traceBlob.arrayBuffer(); + + const metadata = { + name: fileName, + mimeType: 'application/octet-stream', + parents: [parentId], + }; + + const boundary = '-------314159265358979323846'; + const delimiter = `\r\n--${boundary}\r\n`; + const closeDelim = `\r\n--${boundary}--`; + + const metadataPart = + delimiter + + 'Content-Type: application/json; charset=UTF-8\r\n\r\n' + + JSON.stringify(metadata); + + const mediaPart = + delimiter + 'Content-Type: application/octet-stream\r\n\r\n'; + + const body = new Blob([ + new TextEncoder().encode(metadataPart), + new TextEncoder().encode(mediaPart), + traceBuffer, + new TextEncoder().encode(closeDelim), + ]); + + try { + const response = await fetch( + 'https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart', + { + method: 'POST', + headers: { + 'Content-Type': `multipart/related; boundary=${boundary}`, + 'Authorization': `Bearer ${token}`, + }, + body, + }, + ); + const result = await response.json(); + const fileId = result.id; + console.log('File uploaded successfully:', result); + return okResult(fileId); + } catch (error) { + console.error('Error uploading file:', error); + return errResult('Upload failed'); + } + } + + 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)) { + console.log('Using cached Google Drive token'); + // 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'); + console.log( + 'Cached Google Drive token is invalid, fetching new one', + ); + } + } + + // HACK: The 'google.accounts' object is not available in the type definitions + // until '@types/google-one-tap' is installed. We cast to 'any' to bypass + // the type checker. + const tokenClient: TokenClient = + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (window as any).google.accounts.oauth2.initTokenClient({ + client_id: this.clientId, + scope: SCOPES, + // TODO(stevegolton): Add proper types once @types/google-one-tap is installed. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + callback: (tokenResponse: any) => { + console.log('Token response', tokenResponse); + if (Boolean(tokenResponse.error)) { + console.error('OAuth Error:', 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}); + } + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + error_callback: (foo: any) => { + if (foo.type === 'popup_failed_to_open') { + resolve({response: 'popup_blocked'}); + } else if (foo.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> { + return await new Promise((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 (error) { + console.error('Token validation error:', error); + return false; + } + } +}
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..aa838ca --- /dev/null +++ b/ui/src/core_plugins/dev.perfetto.GoogleDrive/index.ts
@@ -0,0 +1,321 @@ +// 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 m from 'mithril'; +import {AppImpl} from '../../core/app_impl'; +import {TraceImpl} from '../../core/trace_impl'; +import {PerfettoPlugin} from '../../public/plugin'; +import {Anchor} from '../../widgets/anchor'; +import {Button, ButtonVariant} from '../../widgets/button'; +import {closeModal, showModal} from '../../widgets/modal'; +import {Stack, StackAuto} from '../../widgets/stack'; +import {TextInput} from '../../widgets/text_input'; +import {GoogleDriveClient, GoogleDriveFile} from './gdrive_client'; +import {Result} from '../../base/result'; +import {Intent} from '../../widgets/common'; +import {CopyToClipboardButton} from '../../widgets/copy_to_clipboard_button'; + +// Note: keys purposely stripped for now... +const API_KEY = ''; +const CLIENT_ID = ''; +const APP_ID = ''; +const gDriveClient = new GoogleDriveClient(API_KEY, CLIENT_ID, APP_ID); +const uploadedTraces = new WeakMap<TraceImpl, string>(); +let pendingTraceFileId: string | undefined = undefined; + +// TODO(stevegolton): Add the ability for the app to be able to open a google +// drive file by clicking through the open with menu of the google drive UI. +// This will require the app to be registered with google and the correct +// redirect URIs to be set up - i.e. https://perfetto.dev/?state={...} <-- JSON +// blob represents a file + +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) { + app.sidebar.addMenuItem({ + section: 'navigation', + text: 'Open from Google Drive', + icon: 'add_to_drive', + action: async () => { + const auth = await gDriveClient.authenticate(); + if (auth.response !== 'success') return; + const files = await gDriveClient.pickFile(auth.accessToken); + if (!files) return; + if (files.length === 0) return; + + const firstFile = files[0]; + const fileResult = await gDriveClient.openFile( + auth.accessToken, + firstFile.id, + ); + if (fileResult.ok) { + const file = fileResult.value; + pendingTraceFileId = file.id; + app.openTraceFromBuffer({ + buffer: await file.blob.arrayBuffer(), + title: file.name || 'Google Drive Trace', + fileName: file.name || 'gdrive-trace.pftrace', + }); + } + }, + }); + + const args = app.initialPluginRouteArgs; + const fileId = args['openFileId']; + + if (fileId !== undefined && typeof fileId === 'string') { + handlePermalink(app, fileId); + } + } + + async onTraceLoad(trace: TraceImpl): Promise<void> { + if (pendingTraceFileId) { + uploadedTraces.set(trace, pendingTraceFileId); + pendingTraceFileId = undefined; + } + + trace.sidebar.addMenuItem({ + section: 'current_trace', + text: 'Share to Google Drive', + icon: 'add_to_drive', + sortOrder: 10, + action: () => { + // This button opens a modal dialog which displays the state of the file + // in google drive allowing the user to upload it and optionally share + // it. The dialog gets the state of whether the file is already in + // google drive from the trace type + the fileId. + showModal({ + key: 'GDriveUpload', + title: 'Google Drive', + content: () => m(UploadTraceModal, {trace, gDriveClient}), + }); + }, + }); + } +} + +async function openGoogleDriveTrace( + app: AppImpl, + token: string, + fileId: string, +): Promise<Result<GoogleDriveFile>> { + const fileResult = await gDriveClient.openFile(token, fileId); + if (fileResult.ok) { + const file = fileResult.value; + app.openTraceFromBuffer({ + buffer: await file.blob.arrayBuffer(), + title: file.name, + // fileName: file.name, + }); + pendingTraceFileId = file.id; + } + return fileResult; +} + +async function handlePermalink(app: AppImpl, fileId: string) { + // Here we should check to see if we actually need to authenticate or wether + // we can get away with using our cached token. If we do need to authenticate, + // use full screen authentication as the normal authentication would probably + // be blocked by the popup blocker in the browser. We might need to + // authenticate first, use fullscreen authentication. + + const auth = await gDriveClient.authenticate(); + if (auth.response === 'popup_blocked') { + console.log('Popup blocked, need to show a dialog to the user.'); + 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(); + }, + }), + m(Button, { + label: 'Reload', + intent: Intent.Primary, + variant: ButtonVariant.Filled, + onclick: async () => { + window.location.reload(); + }, + }), + ]), + ]), + }); + } + + if (auth.response !== 'success') { + console.log('Failed to authenticate with Google Drive'); + return; + } + + const accessToken = auth.accessToken; + const fileResult = await openGoogleDriveTrace(app, accessToken, fileId); + 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 gDriveClient.requestFileAccess( + accessToken, + fileId, + ); + if (!files) return; + await openGoogleDriveTrace(app, accessToken, fileId); + closeModal('GoogleDriveAuthNeeded'); + // TODO(stevegolton): If this didn't work, I'm out of ideas! + }, + }), + ]), + }); + console.log(fileResult); + } +} + +interface UploadTraceModalAttrs { + readonly trace: TraceImpl; + readonly gDriveClient: GoogleDriveClient; +} + +function UploadTraceModal(): m.Component<UploadTraceModalAttrs> { + let fileName = ''; + let location: 'root' | google.picker.DocumentObject = 'root'; + let uploading = false; + + return { + oninit({attrs}) { + fileName = attrs.trace.traceInfo.traceTitle ?? ''; + }, + view({attrs}) { + const {trace} = attrs; + const fileId = uploadedTraces.get(trace); + + if (fileId) { + // The file is already uploaded. + return m(Stack, {spacing: 'large'}, [ + m( + Anchor, + { + href: `https://docs.google.com/file/d/${fileId}/view`, + target: '_blank', + }, + 'View file in Google Drive', + ), + m(Stack, {orientation: 'horizontal'}, [ + m(CopyToClipboardButton, { + textToCopy: `${window.location.origin}#!/?dev.perfetto.GoogleDrive:openFileId=${fileId}`, + title: 'Copy link', + label: 'Copy link', + intent: Intent.Primary, + variant: ButtonVariant.Filled, + }), + m(Button, { + label: 'Change ACLs', + onclick: async () => { + const auth = await attrs.gDriveClient.authenticate(); + if (auth.response === 'success') { + attrs.gDriveClient.openSharingDialog( + auth.accessToken, + fileId, + ); + } + }, + }), + ]), + ]); + } else { + // The file is not yet uploaded. + return m(Stack, {spacing: 'large'}, [ + m(Stack, {spacing: 'medium'}, [ + m(Stack, {orientation: 'horizontal'}, [ + 'Filename: ', + m(TextInput, { + className: 'pf-gdrive-share__filename', + value: fileName, + oninput: (e: Event) => { + fileName = (e.target as HTMLInputElement).value; + }, + placeholder: 'Enter filename...', + }), + ]), + m(Stack, {orientation: 'horizontal'}, [ + 'Location: ', + location === 'root' ? 'My Drive' : location.name, + m(StackAuto), + m(Button, { + label: 'Change location...', + onclick: async () => { + const auth = await attrs.gDriveClient.authenticate(); + if (auth.response !== 'success') return; + const folder = await attrs.gDriveClient.pickFolder( + auth.accessToken, + ); + if (!folder) return; + location = folder; + m.redraw(); + }, + }), + ]), + ]), + m(Button, { + loading: uploading, + disabled: fileName.length === 0 || uploading, + onclick: async () => { + uploading = true; + const auth = await attrs.gDriveClient.authenticate(); + if (auth.response !== 'success') return; + const traceBlob = await trace.getTraceFile(); + const result = await attrs.gDriveClient.uploadFile( + auth.accessToken, + traceBlob, + location === 'root' ? 'root' : location.id, + fileName, + ); + if (result.ok) { + uploadedTraces.set(trace, result.value); + } + + m.redraw(); + }, + intent: Intent.Primary, + variant: ButtonVariant.Filled, + label: 'Upload', + }), + ]); + } + }, + }; +}
diff --git a/ui/src/core_plugins/dev.perfetto.GoogleDrive/styles.scss b/ui/src/core_plugins/dev.perfetto.GoogleDrive/styles.scss new file mode 100644 index 0000000..e879a09 --- /dev/null +++ b/ui/src/core_plugins/dev.perfetto.GoogleDrive/styles.scss
@@ -0,0 +1,19 @@ +// 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. + +.pf-gdrive-share { + &__filename { + flex: 1; + } +}
diff --git a/ui/src/frontend/index.ts b/ui/src/frontend/index.ts index 57f4da3..693be92 100644 --- a/ui/src/frontend/index.ts +++ b/ui/src/frontend/index.ts
@@ -123,6 +123,7 @@ 'https://*.googleusercontent.com', 'https://www.googletagmanager.com', 'https://*.google-analytics.com', + 'https://accounts.google.com/', ], 'object-src': ['none'], 'connect-src': [ @@ -132,6 +133,8 @@ 'https://*.googleapis.com', // For Google Cloud Storage fetches. 'blob:', 'data:', + 'https://drive.google.com', + 'https://play.google.com', ].concat(rpcPolicy), 'img-src': [ `'self'`, @@ -140,8 +143,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', + ], 'navigate-to': ['https://*.perfetto.dev', 'self'], }; const meta = document.createElement('meta'); @@ -251,8 +261,12 @@ defaultValue: true, }); + const initialRouteArgs = Router.parseUrl(window.location.href).args; + + console.log('Initial route args', initialRouteArgs); + AppImpl.initialize({ - initialRouteArgs: Router.parseUrl(window.location.href).args, + initialRouteArgs, settingsManager, timestampFormatSetting, durationPrecisionSetting,
diff --git a/ui/src/widgets/copy_to_clipboard_button.ts b/ui/src/widgets/copy_to_clipboard_button.ts index f59159a..0451aed 100644 --- a/ui/src/widgets/copy_to_clipboard_button.ts +++ b/ui/src/widgets/copy_to_clipboard_button.ts
@@ -47,6 +47,7 @@ readonly textToCopy: string; readonly title?: string; readonly label?: string; + readonly intent?: Intent; readonly variant?: ButtonVariant; } @@ -65,7 +66,7 @@ variant: attrs.variant, title: attrs.title ?? 'Copy to clipboard', icon: helper.copied ? Icons.Check : Icons.Copy, - intent: helper.copied ? Intent.Success : Intent.None, + intent: attrs.intent, label, onclick: async () => { await helper.copy(attrs.textToCopy);
diff --git a/ui/src/widgets/tree.ts b/ui/src/widgets/tree.ts index 5a822f8..670d0b0 100644 --- a/ui/src/widgets/tree.ts +++ b/ui/src/widgets/tree.ts
@@ -15,6 +15,7 @@ import m from 'mithril'; import {classNames} from '../base/classnames'; import {hasChildren} from '../base/mithril_utils'; +import {Icon} from './icon'; // Heirachical tree layout with left and right values. // Right and left values of the same indentation level are horizontally aligned. @@ -93,6 +94,7 @@ onCollapseChanged(this.collapsed, attrs); }, }), + attrs.icon && m(Icon, {icon: attrs.icon, className: 'pf-left-icon'}), left, ), this.renderRight(vnode),