chromium/ash/webui/media_app_ui/resources/js/receiver.ts

// Copyright 2023 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

/// <reference path="media_app.d.ts" />

import './sandboxed_load_time_data.js';

import {COLOR_PROVIDER_CHANGED, ColorChangeUpdater} from '//resources/cr_components/color_change_listener/colors_css_updater.js';
import type {RectF} from '//resources/mojo/ui/gfx/geometry/mojom/geometry.mojom-webui.js';
import type {Url as MojoUrl} from '//resources/mojo/url/mojom/url.mojom-webui.js';
import {assertCast, MessagePipe} from '//system_apps/message_pipe.js';

import type {MahiUntrustedPageHandlerRemote, OcrUntrustedPageHandlerRemote, PageMetadata} from './media_app_ui_untrusted.mojom-webui.js';
import {EditInPhotosMessage, FileContext, IsFileArcWritableMessage, IsFileArcWritableResponse, IsFileBrowserWritableMessage, IsFileBrowserWritableResponse, LoadFilesMessage, Message, OpenAllowedFileMessage, OpenAllowedFileResponse, OpenFilesWithPickerMessage, OverwriteFileMessage, OverwriteViaFilePickerResponse, RenameFileResponse, RenameResult, RequestSaveFileMessage, RequestSaveFileResponse, SaveAsMessage, SaveAsResponse} from './message_types.js';
import {connectToMahiHandler, connectToOcrHandler, mahiCallbackRouter, ocrCallbackRouter} from './mojo_api_bootstrap_untrusted.js';
import {loadPiex} from './piex_module_loader.js';

/** A pipe through which we can send messages to the parent frame. */
const parentMessagePipe = new MessagePipe('chrome://media-app', window.parent);

/**
 * Placeholder Blob used when a null file is received. For null files we only
 * know the name until the file is navigated to.
 */
const PLACEHOLDER_BLOB = new Blob([]);

/**
 * On PDF loaded, try to get this byte size of text content to check whether
 * this file contains text.
 */
const PDF_TEXT_CONTENT_PEEK_BYTE_SIZE = 100;

/**
 * A file received from the privileged context, and decorated with IPC methods
 * added in the untrusted (this) context to communicate back.
 */
export class ReceivedFile implements AbstractFile {
  blob: Blob;
  name: string;
  token: number;
  size: number;
  mimeType: string;
  fromClipboard: boolean;
  error: string;

  deleteOriginalFile?: () => Promise<void>;
  renameOriginalFile?: (newName: string) => Promise<number>;

  constructor(file: FileContext) {
    this.blob = file.file || PLACEHOLDER_BLOB;
    this.name = file.name;
    this.size = this.blob.size;
    this.mimeType = this.blob.type;
    this.token = file.token;
    this.error = file.error;
    this.fromClipboard = false;
    if (file.canDelete) {
      this.deleteOriginalFile = () => this.deleteOriginalFileImpl();
    }
    if (file.canRename) {
      this.renameOriginalFile = (newName) =>
          this.renameOriginalFileImpl(newName);
    }
  }

  async isArcWritable() {
    const message: IsFileArcWritableMessage = {token: this.token};

    const {writable} = (await parentMessagePipe.sendMessage(
                           Message.IS_FILE_ARC_WRITABLE, message)) as
        IsFileArcWritableResponse;
    return writable;
  }

  async isBrowserWritable() {
    const message: IsFileBrowserWritableMessage = {token: this.token};

    const {writable} = (await parentMessagePipe.sendMessage(
                           Message.IS_FILE_BROWSER_WRITABLE, message)) as
        IsFileBrowserWritableResponse;
    return writable;
  }

  async editInPhotos() {
    const message: EditInPhotosMessage = {
      token: this.token,
      mimeType: this.mimeType,
    };

    await parentMessagePipe.sendMessage(Message.EDIT_IN_PHOTOS, message);
  }

  async overwriteOriginal(blob: Blob) {
    const message: OverwriteFileMessage = {token: this.token, blob: blob};

    const result: OverwriteViaFilePickerResponse =
        await parentMessagePipe.sendMessage(Message.OVERWRITE_FILE, message);
    // Note the following are skipped if an exception is thrown above.
    if (result.renamedTo) {
      this.name = result.renamedTo;
      // Assume a rename could have moved the file to a new folder via a file
      // picker, which will break rename/delete functionality.
      delete this.deleteOriginalFile;
      delete this.renameOriginalFile;
    }
    this.error = result.errorName || '';
    this.updateFile(blob, this.name);
  }

  async deleteOriginalFileImpl() {
    await parentMessagePipe.sendMessage(
        Message.DELETE_FILE, {token: this.token});
  }

  async renameOriginalFileImpl(newName: string) {
    const renameResponse: RenameFileResponse =
        await parentMessagePipe.sendMessage(Message.RENAME_FILE, {
          token: this.token,
          newFilename: newName,
        });
    if (renameResponse.renameResult === RenameResult.SUCCESS) {
      this.name = newName;
    }
    return renameResponse.renameResult;
  }

  async saveAs(blob: Blob, pickedFileToken: number) {
    const message: SaveAsMessage = {
      blob,
      oldFileToken: this.token,
      pickedFileToken,
    };
    const result: SaveAsResponse = await parentMessagePipe.sendMessage(
        Message.SAVE_AS,
        message,
    );
    this.updateFile(blob, result.newFilename);
    // Files obtained by a file picker currently can not be renamed/deleted.
    // TODO(b/163285659): Detect when the new file is in the same folder as an
    // on-launch file. Those should still be able to be renamed/deleted.
    delete this.deleteOriginalFile;
    delete this.renameOriginalFile;
  }

  async getExportFile(accept: string[]) {
    const msg: RequestSaveFileMessage = {
      suggestedName: this.name,
      mimeType: this.mimeType,
      startInToken: this.token,
      accept,
    };
    const response: RequestSaveFileResponse =
        await parentMessagePipe.sendMessage(Message.REQUEST_SAVE_FILE, msg);
    return new ReceivedFile(response.pickedFileContext);
  }

  async openFile() {
    const msg: OpenAllowedFileMessage = {
      fileToken: this.token,
    };
    const response: OpenAllowedFileResponse =
        await parentMessagePipe.sendMessage(Message.OPEN_ALLOWED_FILE, msg);
    return response.file;
  }

  /**
   * Updates the wrapped file to reflect a change written to disk.
   */
  private updateFile(blob: Blob, name: string) {
    // Wrap the blob to acquire "now()" as the lastModified time. Note this may
    // differ from the actual mtime recorded on the inode.
    this.blob = new File([blob], name, {type: blob.type});
    this.size = blob.size;
    this.mimeType = blob.type;
    this.name = name;
  }
}

/**
 * Source of truth for what files are loaded in the app. This can be appended to
 * via `ReceivedFileList.addFiles()`.
 */
let lastLoadedReceivedFileList: ReceivedFileList|null = null;

/**
 * A file list consisting of all files received from the parent. Exposes all
 * readable files in the directory, some of which may be writable.
 */
export class ReceivedFileList implements AbstractFileList {
  length: number;
  currentFileIndex: number;

  files: ReceivedFile[];  // Public for tests.
  private observers: Array<(files: AbstractFileList) => unknown> = [];

  constructor(filesMessage: LoadFilesMessage) {
    const {files, currentFileIndex} = filesMessage;
    if (files.length) {
      // If we were not provided with a currentFileIndex, default to making the
      // first file the current file.
      this.currentFileIndex = currentFileIndex >= 0 ? currentFileIndex : 0;
    } else {
      // If we are empty we have no current file.
      this.currentFileIndex = -1;
    }

    this.length = files.length;
    this.files = files.map((f) => new ReceivedFile(f));
  }

  item(index: number) {
    return this.files[index] || null;
  }

  async loadNext(currentFileToken: number) {
    // Awaiting this message send allows callers to wait for the full effects of
    // the navigation to complete. This may include a call to load a new set of
    // files, and the initial decode, which replaces this AbstractFileList and
    // alters other app state.
    await parentMessagePipe.sendMessage(
        Message.NAVIGATE, {currentFileToken, direction: 1});
  }

  async loadPrev(currentFileToken: number) {
    await parentMessagePipe.sendMessage(
        Message.NAVIGATE, {currentFileToken, direction: -1});
  }

  addObserver(observer: (files: AbstractFileList) => unknown) {
    this.observers.push(observer);
  }

  async openFilesWithFilePicker(
      acceptTypeKeys: string[],
      startInFolder?: AbstractFile,
      isSingleFile?: boolean,
  ) {
    // AbstractFile doesn't guarantee tokens. Use one from a ReceivedFile if
    // there is one, after ensuring it is valid.
    const startInToken = startInFolder?.token || 0;
    const msg: OpenFilesWithPickerMessage = {
      startInToken: startInToken > 0 ? startInToken : 0,
      accept: acceptTypeKeys,
      isSingleFile: !!isSingleFile,
    };
    await parentMessagePipe.sendMessage(Message.OPEN_FILES_WITH_PICKER, msg);
  }

  filterInPlace(filter: (file: AbstractFile) => boolean) {
    this.files = this.files.filter(filter);
    this.length = this.files.length;
    this.currentFileIndex = this.length > 0 ? 0 : -1;
  }

  addFiles(files: ReceivedFile[]) {
    if (files.length === 0) {
      return;
    }
    this.files = [...this.files, ...files];
    this.length = this.files.length;
    // Call observers with the new underlying files.
    this.observers.map((o) => o(this));
  }
}

parentMessagePipe.registerHandler(
    Message.LOAD_FILES, async (filesMessage: LoadFilesMessage) => {
      lastLoadedReceivedFileList = new ReceivedFileList(filesMessage);
      await loadFiles(lastLoadedReceivedFileList);
    });

// Load extra files by appending to the current `ReceivedFileList`.
parentMessagePipe.registerHandler(
    Message.LOAD_EXTRA_FILES, async (extraFilesMessage: LoadFilesMessage) => {
      if (!lastLoadedReceivedFileList) {
        return;
      }
      const newFiles = extraFilesMessage.files.map((f) => new ReceivedFile(f));
      lastLoadedReceivedFileList.addFiles(newFiles);
    });

// As soon as the LOAD_FILES handler is installed, signal readiness to the
// parent frame (privileged context).
parentMessagePipe.sendMessage(Message.IFRAME_READY);

let ocrUntrustedPageHandler: OcrUntrustedPageHandlerRemote;
let mahiUntrustedPageHandler: MahiUntrustedPageHandlerRemote;

ocrCallbackRouter.requestBitmap.addListener(async (requestedPageId: string) => {
  const app = getApp();
  if (app) {
    const result = await app.requestBitmap(requestedPageId);
    return {page: result};
  }
  return null;
});
ocrCallbackRouter.setViewport.addListener(
    (viewportBox: RectF) => void getApp()?.setViewport(viewportBox));
ocrCallbackRouter.setPdfOcrEnabled.addListener(
    (enabled: boolean) => void getApp()?.setPdfOcrEnabled(enabled));
ocrCallbackRouter.onConnectionError.addListener(() => {
  console.warn('Calling MediaApp RequestBitmap() failed to return bitmap.');
});

mahiCallbackRouter.getPdfContent.addListener(async (limit: number) => {
  const app = getApp();
  if (app) {
    const content = await app.getPdfContent(limit);
    return {content};
  }
  return null;
});
mahiCallbackRouter.hidePdfContextMenu.addListener(
    () => void getApp()?.hidePdfContextMenu());
mahiCallbackRouter.onConnectionError.addListener(() => {
  console.warn('Calling MediaApp GetPdfContent() failed to return content.');
});

/**
 * A delegate which exposes privileged WebUI functionality to the media
 * app.
 */
const DELEGATE: ClientApiDelegate = {
  async openFeedbackDialog() {
    const response =
        await parentMessagePipe.sendMessage(Message.OPEN_FEEDBACK_DIALOG);
    return response['errorMessage'] as string;
  },
  async submitForm(
      url: MojoUrl,
      payload: number[],
      header: string,
  ) {
    const msg = {
      url,
      payload,
      header,
    };
    await parentMessagePipe.sendMessage(Message.SUBMIT_FORM, msg);
  },
  async toggleBrowserFullscreenMode() {
    await parentMessagePipe.sendMessage(Message.TOGGLE_BROWSER_FULLSCREEN_MODE);
  },
  async requestSaveFile(
      suggestedName: string,
      mimeType: string,
      accept: string[],
  ) {
    const msg: RequestSaveFileMessage = {
      suggestedName,
      mimeType,
      startInToken: 0,
      accept,
    };
    const response: RequestSaveFileResponse =
        await parentMessagePipe.sendMessage(Message.REQUEST_SAVE_FILE, msg);
    return new ReceivedFile(response.pickedFileContext);
  },
  notifyCurrentFile(name?: string, type?: string) {
    parentMessagePipe.sendMessage(Message.NOTIFY_CURRENT_FILE, {name, type});
  },
  notifyFileOpened(name?: string, type?: string) {
    // Close any existing pipes when opening a new file.
    ocrUntrustedPageHandler?.$.close();
    mahiUntrustedPageHandler?.$.close();

    if (type === 'application/pdf') {
      ocrUntrustedPageHandler = connectToOcrHandler();
      mahiUntrustedPageHandler = connectToMahiHandler(name);
    }
  },
  notifyFilenameChanged(name: string) {
    mahiUntrustedPageHandler?.onPdfFileNameUpdated(name);
  },
  async extractPreview(file: Blob) {
    try {
      const bufferPromise = file.arrayBuffer();
      const extractFromRawImageBuffer = await loadPiex();
      return await extractFromRawImageBuffer(await bufferPromise);
    } catch (e: unknown) {
      console.warn(e);
      if ((e as Error).name === 'Error') {
        (e as Error).name = 'JpegNotFound';
      }
      throw e;
    }
  },
  openInSandboxedViewer(title: string, blobUuid: string) {
    parentMessagePipe.sendMessage(
        Message.OPEN_IN_SANDBOXED_VIEWER, {title, blobUuid});
  },
  reloadMainFrame() {
    parentMessagePipe.sendMessage(Message.RELOAD_MAIN_FRAME);
  },
  maybeTriggerPdfHats() {
    parentMessagePipe.sendMessage(Message.MAYBE_TRIGGER_PDF_HATS);
  },
  // TODO(b/219631600): Implement openUrlInBrowserTab() for LacrOS if needed.

  // All methods below are on the guest / untrusted frame.

  async pageMetadataUpdated(pageMetadata: PageMetadata[]) {
    await ocrUntrustedPageHandler?.pageMetadataUpdated(pageMetadata);
  },
  async pageContentsUpdated(dirtyPageId: string) {
    await ocrUntrustedPageHandler?.pageContentsUpdated(dirtyPageId);
  },
  async viewportUpdated(viewportBox: RectF, scaleFactor: number) {
    await ocrUntrustedPageHandler?.viewportUpdated(viewportBox, scaleFactor);
  },
  async onPdfLoaded() {
    let hasText = false;
    const app = getApp();
    if (app) {
      const peekContent =
          (await app.getPdfContent(PDF_TEXT_CONTENT_PEEK_BYTE_SIZE))
              ?.toString() ??
          '';
      hasText = peekContent.trim() !== '';
    }

    if (!hasText) {
      mahiUntrustedPageHandler?.$.close();
    } else {
      await mahiUntrustedPageHandler?.onPdfLoaded();
    }
  },
  async onPdfContextMenuShow(anchor: RectF) {
    await mahiUntrustedPageHandler?.onPdfContextMenuShow(anchor);
  },
  async onPdfContextMenuHide() {
    await mahiUntrustedPageHandler?.onPdfContextMenuHide();
  },
};

/**
 * Returns the media app if it can find it in the DOM.
 */
function getApp(): ClientApi {
  const app = document.querySelector('backlight-app')!;
  return app as unknown as ClientApi;
}

/**
 * Loads a file list into the media app.
 */
async function loadFilesImpl(fileList: ReceivedFileList) {
  const app = getApp();
  if (app) {
    await app.loadFiles(fileList);
  } else {
    // Note we don't await in this case, which may affect b/152729704.
    window.customLaunchData.files = fileList;
  }
}

/** Store `loadFilesImpl` into a variable so that tests may spy on it. */
let loadFiles = loadFilesImpl;

/**
 * Runs any initialization code on the media app once it is in the dom.
 */
function initializeApp(app: ClientApi) {
  app.setDelegate(DELEGATE);
}

/**
 * Called when a mutation occurs on document.body to check if the media app is
 * available.
 */
function mutationCallback(
    _mutationsList: MutationRecord[], observer: MutationObserver) {
  const app = getApp();
  if (!app) {
    return;
  }
  // The media app now exists so we can initialize it.
  initializeApp(app);
  observer.disconnect();
}

window.addEventListener('DOMContentLoaded', () => {
  // Start listening to color change events. These events get picked up by logic
  // in ts_helpers.ts on the google3 side.
  /** @suppress {checkTypes} */
  (function() {
    ColorChangeUpdater.forDocument().start();
  })();

  const app = getApp();
  if (app) {
    initializeApp(app);
    return;
  }
  // If translations need to be fetched, the app element may not be added yet.
  // In that case, observe <body> until it is.
  const observer = new MutationObserver(mutationCallback);
  observer.observe(document.body, {childList: true});
});

declare global {
  interface Window {
    chooseFileSystemEntries: null;
    addColorChangeListener: (listener: EventListenerOrEventListenerObject|
                             null) => unknown;
    removeColorChangeListener: (listener: EventListenerOrEventListenerObject|
                                null) => unknown;
    lastLoadedReceivedFileList: () => ReceivedFileList | null;
  }
}

// Ensure that if no files are loaded into the media app there is a default
// empty file list available.
window.customLaunchData = {
  delegate: DELEGATE,
  files: new ReceivedFileList({files: [], currentFileIndex: -1}),
};

// Attempting to show file pickers in the sandboxed <iframe> is guaranteed to
// result in a SecurityError: hide them.
window.chooseFileSystemEntries = null;
window.showOpenFilePicker = null;
window.showSaveFilePicker = null;
window.showDirectoryPicker = null;

// Expose functions to bind to color change events to window so they can be
// automatically picked up by installColors(). See ts_helpers.ts in google3.
window.addColorChangeListener = function(listener) {
  ColorChangeUpdater.forDocument().eventTarget.addEventListener(
      COLOR_PROVIDER_CHANGED, listener);
};
window.removeColorChangeListener = function(listener) {
  ColorChangeUpdater.forDocument().eventTarget.removeEventListener(
      COLOR_PROVIDER_CHANGED, listener);
};

export const TEST_ONLY = {
  RenameResult,
  DELEGATE,
  assertCast,
  parentMessagePipe,
  loadFiles,
  setLoadFiles: (spy: any) => {
    loadFiles = spy;
  },
};

// Small, auxiliary file that adds hooks to support test cases relying on the
// "real" app context (e.g. for stack traces).
import './app_context_test_support.js';

// Temporarily expose lastLoadedReceivedFileList on `window` for
// MediaAppIntegrationWithFilesAppAllProfilesTest.RenameFile.
// TODO(b/185957537): Convert the test case to a JS module.
window.lastLoadedReceivedFileList = () => lastLoadedReceivedFileList;