| // Copyright (C) 2018 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 {produce} from 'immer'; |
| |
| import {assertExists} from '../base/logging'; |
| import {Actions} from '../common/actions'; |
| import {ConversionJobStatus} from '../common/conversion_jobs'; |
| import {createEmptyState} from '../common/empty_state'; |
| import {State} from '../common/state'; |
| import {STATE_VERSION} from '../common/state'; |
| import { |
| BUCKET_NAME, |
| saveState, |
| saveTrace, |
| toSha256 |
| } from '../common/upload_utils'; |
| import {globals as frontendGlobals} from '../frontend/globals'; |
| import {publishConversionJobStatusUpdate} from '../frontend/publish'; |
| import {Router} from '../frontend/router'; |
| |
| import {Controller} from './controller'; |
| import {globals} from './globals'; |
| import {RecordConfig, recordConfigValidator} from './record_config_types'; |
| import {runValidator} from './validators'; |
| |
| export class PermalinkController extends Controller<'main'> { |
| private lastRequestId?: string; |
| constructor() { |
| super('main'); |
| } |
| |
| run() { |
| if (globals.state.permalink.requestId === undefined || |
| globals.state.permalink.requestId === this.lastRequestId) { |
| return; |
| } |
| const requestId = assertExists(globals.state.permalink.requestId); |
| this.lastRequestId = requestId; |
| |
| // if the |hash| is not set, this is a request to create a permalink. |
| if (globals.state.permalink.hash === undefined) { |
| const isRecordingConfig = |
| assertExists(globals.state.permalink.isRecordingConfig); |
| |
| const jobName = 'create_permalink'; |
| publishConversionJobStatusUpdate({ |
| jobName, |
| jobStatus: ConversionJobStatus.InProgress, |
| }); |
| |
| PermalinkController.createPermalink(isRecordingConfig) |
| .then(hash => { |
| globals.dispatch(Actions.setPermalink({requestId, hash})); |
| }) |
| .finally(() => { |
| publishConversionJobStatusUpdate({ |
| jobName, |
| jobStatus: ConversionJobStatus.NotRunning, |
| }); |
| }); |
| return; |
| } |
| |
| // Otherwise, this is a request to load the permalink. |
| PermalinkController.loadState(globals.state.permalink.hash) |
| .then(stateOrConfig => { |
| if (PermalinkController.isRecordConfig(stateOrConfig)) { |
| // This permalink state only contains a RecordConfig. Show the |
| // recording page with the config, but keep other state as-is. |
| const validConfig = |
| runValidator(recordConfigValidator, stateOrConfig as unknown) |
| .result; |
| globals.dispatch(Actions.setRecordConfig({config: validConfig})); |
| Router.navigate('#!/record'); |
| return; |
| } |
| globals.dispatch(Actions.setState({newState: stateOrConfig})); |
| this.lastRequestId = stateOrConfig.permalink.requestId; |
| }); |
| } |
| |
| private static upgradeState(state: State): State { |
| if (state.version !== STATE_VERSION) { |
| const newState = createEmptyState(); |
| let maxEngineId = Number.MIN_SAFE_INTEGER; |
| // Copy the URL of the trace into the empty state. |
| for (const cfg of Object.values(state.engines)) { |
| newState |
| .engines[cfg.id] = {id: cfg.id, ready: false, source: cfg.source}; |
| maxEngineId = Math.max(maxEngineId, Number(cfg.id)); |
| } |
| if (maxEngineId !== Number.MIN_SAFE_INTEGER) { |
| // set the current engine Id to the maximum engine Id in the permalink |
| newState.currentEngineId = String(maxEngineId); |
| } |
| const message = `Unable to parse old state version. Discarding state ` + |
| `and loading trace.`; |
| console.warn(message); |
| PermalinkController.updateStatus(message); |
| return newState; |
| } |
| return state; |
| } |
| |
| private static isRecordConfig(stateOrConfig: State| |
| RecordConfig): stateOrConfig is RecordConfig { |
| const mode = (stateOrConfig as {mode?: string}).mode; |
| return mode !== undefined && |
| ['STOP_WHEN_FULL', 'RING_BUFFER', 'LONG_TRACE'].includes(mode); |
| } |
| |
| private static async createPermalink(isRecordingConfig: boolean): |
| Promise<string> { |
| let uploadState: State|RecordConfig = globals.state; |
| |
| if (isRecordingConfig) { |
| uploadState = globals.state.recordConfig; |
| } else { |
| const engine = assertExists(frontendGlobals.getCurrentEngine()); |
| let dataToUpload: File|ArrayBuffer|undefined = undefined; |
| let traceName = `trace ${engine.id}`; |
| if (engine.source.type === 'FILE') { |
| dataToUpload = engine.source.file; |
| traceName = dataToUpload.name; |
| } else if (engine.source.type === 'ARRAY_BUFFER') { |
| dataToUpload = engine.source.buffer; |
| } else if (engine.source.type !== 'URL') { |
| throw new Error(`Cannot share trace ${JSON.stringify(engine.source)}`); |
| } |
| |
| if (dataToUpload !== undefined) { |
| PermalinkController.updateStatus(`Uploading ${traceName}`); |
| const url = await saveTrace(dataToUpload); |
| // Convert state to use URLs and remove permalink. |
| uploadState = produce(globals.state, draft => { |
| draft.engines[engine.id].source = {type: 'URL', url}; |
| draft.permalink = {}; |
| }); |
| } |
| } |
| |
| // Upload state. |
| PermalinkController.updateStatus(`Creating permalink...`); |
| const hash = await saveState(uploadState); |
| PermalinkController.updateStatus(`Permalink ready`); |
| return hash; |
| } |
| |
| private static async loadState(id: string): Promise<State|RecordConfig> { |
| const url = `https://storage.googleapis.com/${BUCKET_NAME}/${id}`; |
| const response = await fetch(url); |
| if (!response.ok) { |
| throw new Error( |
| `Could not fetch permalink.\n` + |
| `Are you sure the id (${id}) is correct?\n` + |
| `URL: ${url}`); |
| } |
| const text = await response.text(); |
| const stateHash = await toSha256(text); |
| const state = JSON.parse(text); |
| if (stateHash !== id) { |
| throw new Error(`State hash does not match ${id} vs. ${stateHash}`); |
| } |
| if (!this.isRecordConfig(state)) { |
| return this.upgradeState(state); |
| } |
| return state; |
| } |
| |
| private static updateStatus(msg: string): void { |
| // TODO(hjd): Unify loading updates. |
| globals.dispatch(Actions.updateStatus({ |
| msg, |
| timestamp: Date.now() / 1000, |
| })); |
| } |
| } |