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

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

import './strings.m.js';
import './unguessable_token.mojom-lite.js';
import './file_system_access_transfer_token.mojom-lite.js';
import './url.mojom-lite.js';

import {assertCast, MessagePipe} from '//system_apps/message_pipe.js';
import {loadTimeData} from 'chrome://resources/ash/common/load_time_data.m.js';

import * as error_reporter from './error_reporter.js';
import {DeleteFileMessage, EditInPhotosMessage, FileContext, IsFileArcWritableMessage, IsFileBrowserWritableMessage, LoadFilesMessage, Message, NavigateMessage, NotifyCurrentFileMessage, OpenAllowedFileMessage, OpenAllowedFileResponse, OpenFilesWithPickerMessage, OverwriteFileMessage, OverwriteViaFilePickerResponse, RenameFileMessage, RenameResult, RequestSaveFileMessage, RequestSaveFileResponse, SaveAsMessage, SaveAsResponse} from './message_types.js';
import {mediaAppPageHandler} from './mojo_api_bootstrap.js';

// TODO(b/319570394): Replace these.
declare const blink: any;
declare const Mojo: any;

const DEFAULT_APP_ICON = 'app';
const EMPTY_WRITE_ERROR_NAME = 'EmptyWriteError';

// Open file picker configurations. Should be kept in sync with launch handler
// configurations in media_web_app_info.cc.
const AUDIO_EXTENSIONS =
    ['.flac', '.m4a', '.mp3', '.oga', '.ogg', '.opus', '.wav', '.weba', '.m4a'];
const IMAGE_EXTENSIONS = [
  '.jpg',  '.png', '.webp', '.gif', '.avif', '.bmp',   '.ico', '.svg',
  '.jpeg', '.jpe', '.jfif', '.jif', '.jfi',  '.pjpeg', '.pjp', '.arw',
  '.cr2',  '.dng', '.nef',  '.nrw', '.orf',  '.raf',   '.rw2', '.svgz',
];
const VIDEO_EXTENSIONS = [
  '.3gp',
  '.avi',
  '.m4v',
  '.mkv',
  '.mov',
  '.mp4',
  '.mpeg',
  '.mpeg4',
  '.mpg',
  '.mpg4',
  '.ogv',
  '.ogx',
  '.ogm',
  '.webm',
];
const PDF_EXTENSIONS = ['.pdf'];
const OPEN_ACCEPT_ARGS: {[index: string]: FilePickerAcceptType} = {
  'AUDIO': {
    description: loadTimeData.getString('fileFilterAudio'),
    accept: {'audio/*': AUDIO_EXTENSIONS},
  },
  'IMAGE': {
    description: loadTimeData.getString('fileFilterImage'),
    accept: {'image/*': IMAGE_EXTENSIONS},
  },
  'VIDEO': {
    description: loadTimeData.getString('fileFilterVideo'),
    accept: {'video/*': VIDEO_EXTENSIONS},
  },
  'PDF': {description: 'PDF', accept: {'application/pdf': PDF_EXTENSIONS}},
  // All supported file types, excluding text files (see b/183150750).
  'ALL_EX_TEXT': {
    description: 'All',
    accept: {
      '*/*': [
        ...AUDIO_EXTENSIONS,
        ...IMAGE_EXTENSIONS,
        ...VIDEO_EXTENSIONS,
        ...PDF_EXTENSIONS,
      ],
    },
  },
};

/**
 * Sort order for files in the navigation ring.
 */
enum SortOrder {
  /**
   * Lexicographic (with natural number ordering): advancing goes "down" the
   * alphabet.
   */
  A_FIRST = 1,
  /**
   * Reverse lexicographic (with natural number ordering): advancing goes "up"
   * the alphabet.
   */
  Z_FIRST = 2,
  /** By modified time: pressing "right" goes to older files. */
  NEWEST_FIRST = 3,
}

/**
 * Wrapper around a file handle that allows the privileged context to arbitrate
 * read and write access as well as file navigation. `token` uniquely identifies
 * the file, `file` temporarily holds the object passed over postMessage, and
 * `handle` allows it to be reopened upon navigation. If an error occurred on
 * the last attempt to open `handle`, `lastError` holds the error name.
 */
interface FileDescriptor {
  token: number;
  file: File|null;
  handle: FileSystemFileHandle;
  lastError?: string;
  inCurrentDirectory?: boolean;
}

/**
 * Array of entries available in the current directory.
 */
const currentFiles: FileDescriptor[] = [];

/**
 * A variable for storing the name of the app, taken from the <title>. We store
 * it here since we mutate the title to show filename, but may want to restore
 * it in some circumstances i.e. returning to zero state.
 */
let appTitle: string|undefined;

/**
 * The current sort order.
 * TODO(crbug.com/40384768): Match the file manager order when launched that way.
 * Note currently this is reassigned in tests.
 */
// eslint-disable-next-line prefer-const
let sortOrder = SortOrder.A_FIRST;

/**
 * Index into `currentFiles` of the current file.
 */
let entryIndex = -1;

/**
 * Keeps track of the current launch (i.e. call to `launchWithDirectory`) .
 * Since file loading can be deferred i.e. we can load the first focused file
 * and start using the app then load other files in `loadOtherRelatedFiles()` we
 * need to make sure `loadOtherRelatedFiles` gets aborted if it is out of date
 * i.e. in interleaved launches.
 */
let globalLaunchNumber = -1;

/**
 * Reference to the directory handle that contains the first file in the most
 * recent launch event.
 */
let currentDirectoryHandle: FileSystemDirectoryHandle|null = null;

/**
 * Map of file tokens. Persists across new launch requests from the file
 * manager when chrome://media-app has not been closed.
 */
const tokenMap = new Map<number, FileSystemFileHandle>();

/**
 * A pipe through which we can send messages to the guest frame.
 * Use an undefined `target` to find the <iframe> automatically.
 * Do not rethrow errors, since handlers installed here are expected to
 * throw exceptions that are handled on the other side of the pipe. And
 * nothing `awaits` async callHandlerForMessageType_(), so they will always
 * be reported as `unhandledrejection` and trigger a crash report.
 */
const guestMessagePipe =
    new MessagePipe('chrome-untrusted://media-app', undefined, false);

// Register a handler for the "IFRAME_READY" message which does nothing. This
// prevents MessagePipe emitting an error that there is no handler for it. The
// message is handled by logic in first_message_received.js, which installs the
// event listener before the <iframe> is added to the DOM.
guestMessagePipe.registerHandler(Message.IFRAME_READY, () => {});

/**
 * The type of icon to show for this app's window.
 */
let appIconType = DEFAULT_APP_ICON;

/**
 * Sets the app icon depending on the icon type and color theme.
 * @param mediaQueryList Determines whether or not the icon should be in dark
 *     mode.
 */
function updateAppIcon(mediaQueryList: MediaQueryList|
                       (Event & {matches: boolean})) {
  // The default app icon does not have a separate dark variant.
  const isDark =
      mediaQueryList.matches && appIconType !== DEFAULT_APP_ICON ? '_dark' : '';

  const icon = document.querySelector<HTMLLinkElement>('link[rel=icon]');
  icon!.href = `system_assets/${appIconType}_icon${isDark}.svg`;
}

const darkMediaQuery = window.matchMedia('(prefers-color-scheme: dark)');

guestMessagePipe.registerHandler(Message.NOTIFY_CURRENT_FILE, (message) => {
  const notifyMsg: NotifyCurrentFileMessage = message;

  const title = document.querySelector('title')!;
  appTitle = appTitle || title.text;
  title.text = notifyMsg.name || appTitle;

  appIconType = notifyMsg.type ? notifyMsg.type.split('/')[0]! : 'file';
  if (title.text === appTitle) {
    appIconType = DEFAULT_APP_ICON;
  } else if (notifyMsg.type === 'application/pdf') {
    appIconType = 'pdf';
  } else if (!['audio', 'image', 'video', 'file'].includes(appIconType)) {
    appIconType = 'file';
  }
  updateAppIcon(darkMediaQuery);
});

darkMediaQuery.addEventListener('change', updateAppIcon);

guestMessagePipe.registerHandler(Message.OPEN_FEEDBACK_DIALOG, () => {
  let response = mediaAppPageHandler.openFeedbackDialog();
  if (response === null) {
    response = {errorMessage: 'Null response received'};
  }
  return response;
});

guestMessagePipe.registerHandler(Message.TOGGLE_BROWSER_FULLSCREEN_MODE, () => {
  mediaAppPageHandler.toggleBrowserFullscreenMode();
});

guestMessagePipe.registerHandler(
    Message.OPEN_IN_SANDBOXED_VIEWER, (message) => {
      window.open(
          `./viewpdfhost.html?${new URLSearchParams(message)}`, '_blank',
          'popup=1');
    });

guestMessagePipe.registerHandler(Message.RELOAD_MAIN_FRAME, () => {
  window.location.reload();
});

guestMessagePipe.registerHandler(Message.MAYBE_TRIGGER_PDF_HATS, () => {
  mediaAppPageHandler.maybeTriggerPdfHats();
});

guestMessagePipe.registerHandler(Message.EDIT_IN_PHOTOS, (message) => {
  const editInPhotosMsg: EditInPhotosMessage = message;
  const fileHandle = fileHandleForToken(editInPhotosMsg.token);

  const transferToken = new blink.mojom.FileSystemAccessTransferTokenRemote(
      Mojo.getFileSystemAccessTransferToken(fileHandle));

  return mediaAppPageHandler.editInPhotos(
      transferToken, editInPhotosMsg.mimeType);
});

guestMessagePipe.registerHandler(Message.IS_FILE_ARC_WRITABLE, (message) => {
  const writableMsg: IsFileArcWritableMessage = message;
  const fileHandle = fileHandleForToken(writableMsg.token);

  const transferToken = new blink.mojom.FileSystemAccessTransferTokenRemote(
      Mojo.getFileSystemAccessTransferToken(fileHandle));

  return mediaAppPageHandler.isFileArcWritable(transferToken);
});

guestMessagePipe.registerHandler(
    Message.IS_FILE_BROWSER_WRITABLE, (message) => {
      const writableMsg: IsFileBrowserWritableMessage = message;
      const fileHandle = fileHandleForToken(writableMsg.token);

      const transferToken = new blink.mojom.FileSystemAccessTransferTokenRemote(
          Mojo.getFileSystemAccessTransferToken(fileHandle));

      return mediaAppPageHandler.isFileBrowserWritable(transferToken);
    });

guestMessagePipe.registerHandler(
    Message.OVERWRITE_FILE,
    async(message): Promise<void|OverwriteViaFilePickerResponse> => {
      const overwrite: OverwriteFileMessage = message;
      const originalHandle = fileHandleForToken(overwrite.token);
      try {
        await saveBlobToFile(originalHandle, overwrite.blob);
      } catch (e: any) {
        if (e.name === EMPTY_WRITE_ERROR_NAME) {
          throw e;
        }
        // TODO(b/160843424): Collect UMA.
        console.warn('Showing a picker due to', e);
        return pickFileForFailedOverwrite(
            originalHandle.name, e.name, overwrite);
      }
    });

guestMessagePipe.registerHandler(
    Message.SUBMIT_FORM,
    async (message) => {
      mediaAppPageHandler.submitForm(
          {url: message.url}, message.payload, message.header);
    },
);

/**
 * Shows a file picker and redirects a failed OverwriteFileMessage to the chosen
 * file. Updates app state and rebinds file tokens if the write is successful.
 */
async function pickFileForFailedOverwrite(
    fileName: string, errorName: string,
    overwrite: OverwriteFileMessage): Promise<OverwriteViaFilePickerResponse> {
  const fileHandle = await pickWritableFile(
      fileName, overwrite.blob.type, overwrite.token, []);
  await saveBlobToFile(fileHandle, overwrite.blob);

  // Success. Replace the old handle.
  tokenMap.set(overwrite.token, fileHandle);
  const entry = currentFiles.find(i => i.token === overwrite.token);
  if (entry) {
    entry.handle = fileHandle;
  }
  return {renamedTo: fileHandle.name, errorName};
}

guestMessagePipe.registerHandler(Message.DELETE_FILE, async (message) => {
  const deleteMsg: DeleteFileMessage = message;
  const {handle, directory} =
      assertFileAndDirectoryMutable(deleteMsg.token, 'Delete');

  if (!(await isHandleInCurrentDirectory(handle))) {
    // removeEntry() silently "succeeds" in this case, but that gives poor UX.
    console.warn(`"${handle.name}" not found in the last opened folder.`);
    const error = new Error('Ignoring delete request: file not found');
    error.name = 'NotFoundError';
    throw error;
  }

  await directory.removeEntry(handle.name);

  // Remove the file that was deleted.
  currentFiles.splice(entryIndex, 1);

  // Attempts to load the file to the right which is at now at
  // `currentFiles[entryIndex]`, where `entryIndex` was previously the index of
  // the deleted file.
  await advance(0);
});

/** Handler to rename the currently focused file. */
guestMessagePipe.registerHandler(Message.RENAME_FILE, async (message) => {
  const renameMsg: RenameFileMessage = message;
  const {handle, directory} =
      assertFileAndDirectoryMutable(renameMsg.token, 'Rename');

  if (await filenameExistsInCurrentDirectory(renameMsg.newFilename)) {
    return {renameResult: RenameResult.FILE_EXISTS};
  }

  const originalFile = await maybeGetFileFromFileHandle(handle);
  let originalFileIndex =
      currentFiles.findIndex(fd => fd.token === renameMsg.token);

  if (!originalFile || originalFileIndex < 0) {
    return {renameResult: RenameResult.FILE_NO_LONGER_IN_LAST_OPENED_DIRECTORY};
  }

  const renamedFileHandle =
      await directory.getFileHandle(renameMsg.newFilename, {create: true});
  // Copy file data over to the new file.
  const writer = await renamedFileHandle.createWritable();
  const sink: WritableStream<any> = writer;
  const source: {stream: () => ReadableStream} = originalFile;
  await source.stream().pipeTo(sink);

  // Remove the old file since the new file has all the data & the new name.
  // Note even though removing an entry that doesn't exist is considered
  // success, we first check `handle` is the same as the handle for the file
  // with that filename in the `currentDirectoryHandle`.
  if (await isHandleInCurrentDirectory(handle)) {
    await directory.removeEntry(originalFile.name);
  }

  // Replace the old file in our internal representation. There is no harm using
  // the old file's token since the old file is removed.
  tokenMap.set(renameMsg.token, renamedFileHandle);
  // Remove the entry for `originalFile` in current files, replace it with a
  // FileDescriptor for the renamed file.

  // Ensure the file is still in `currentFiles` after all the above `awaits`. If
  // missing it means either new files have loaded (or tried to), see
  // b/164985809.
  originalFileIndex =
      currentFiles.findIndex(fd => fd.token === renameMsg.token);

  if (originalFileIndex < 0) {
    // Can't navigate to the renamed file so don't add it to `currentFiles`.
    return {renameResult: RenameResult.SUCCESS};
  }

  currentFiles.splice(originalFileIndex, 1, {
    token: renameMsg.token,
    file: null,
    handle: renamedFileHandle,
    inCurrentDirectory: true,
  });

  return {renameResult: RenameResult.SUCCESS};
});

guestMessagePipe.registerHandler(Message.NAVIGATE, async (message) => {
  const navigate: NavigateMessage = message;

  await advance(navigate.direction, navigate.currentFileToken);
});

guestMessagePipe.registerHandler(Message.REQUEST_SAVE_FILE, async (message) => {
  const {suggestedName, mimeType, startInToken, accept} =
      message as RequestSaveFileMessage;
  const handle =
      await pickWritableFile(suggestedName, mimeType, startInToken, accept);

  const response: RequestSaveFileResponse = {
    pickedFileContext: {
      token: generateToken(handle),
      file: assertCast(await handle.getFile()),
      name: handle.name,
      error: '',
      canDelete: false,
      canRename: false,
    },
  };
  return response;
});

guestMessagePipe.registerHandler(Message.SAVE_AS, async (message) => {
  const {blob, oldFileToken, pickedFileToken} = message as SaveAsMessage;
  const oldFileDescriptor = currentFiles.find(fd => fd.token === oldFileToken);
  const pickedHandle = assertCast(tokenMap.get(pickedFileToken));
  const pickedFileDescriptor: FileDescriptor = {
    // We silently take over the old file's file descriptor by taking its token,
    // note we can be passed an undefined token if the file we are saving was
    // dragged into the media app.
    token: oldFileToken || tokenGenerator.next().value,
    file: null,
    handle: pickedHandle,
  };
  const oldFileIndex = currentFiles.findIndex(fd => fd.token === oldFileToken);
  tokenMap.set(pickedFileDescriptor.token, pickedHandle);
  // Give the old file a new token, if we couldn't find the old file we assume
  // its been deleted (or pasted/dragged into the media app) and skip this
  // step.
  if (oldFileDescriptor) {
    oldFileDescriptor.token = generateToken(oldFileDescriptor.handle);
  }
  try {
    // Note `pickedFileHandle` could be the same as a `FileSystemFileHandle`
    // that exists in `tokenMap`. Possibly even the `File` currently open. But
    // that's OK. E.g. the next overwrite-file request will just invoke
    // `saveBlobToFile` in the same way.
    await saveBlobToFile(pickedHandle, blob);
  } catch (e: unknown) {
    // If something went wrong revert the token back to its original
    // owner so future file actions function correctly.
    if (oldFileDescriptor && oldFileToken) {
      oldFileDescriptor.token = oldFileToken;
      tokenMap.set(oldFileToken, oldFileDescriptor.handle);
    }
    throw e;
  }

  // Note: oldFileIndex may be `-1` here which causes the new file to be added
  // to the start of the array, this is WAI.
  currentFiles.splice(oldFileIndex + 1, 0, pickedFileDescriptor);
  // Silently update entry index without triggering a reload of the media app.
  entryIndex = oldFileIndex + 1;

  const response: SaveAsResponse = {newFilename: pickedHandle.name};
  return response;
});

guestMessagePipe.registerHandler(Message.OPEN_FILES_WITH_PICKER, async (m) => {
  const {startInToken, accept, isSingleFile} = m as OpenFilesWithPickerMessage;
  const acceptTypes = accept.map(k => OPEN_ACCEPT_ARGS[k]).filter(a => !!a) as
      FilePickerAcceptType[];

  const options: OpenFilePickerOptions = {multiple: !isSingleFile};

  if (startInToken) {
    options.startIn = fileHandleForToken(startInToken);
  }

  if (acceptTypes.length > 0) {
    options.excludeAcceptAllOption = true;
    options.types = acceptTypes;
  }

  const handles = await window.showOpenFilePicker!(options);
  const newDescriptors: FileDescriptor[] = [];
  for (const handle of handles) {
    newDescriptors.push({
      token: generateToken(handle),
      file: null,
      handle: handle,
      inCurrentDirectory: false,
    });
  }
  if (newDescriptors.length === 0) {
    // Be defensive against the file picker returning an empty array rather than
    // throwing an abort exception. Or any filtering we may introduce.
    return;
  }

  // Perform a full "relaunch": replace everything and set focus to index 0.
  currentFiles.splice(0, currentFiles.length, ...newDescriptors);
  entryIndex = 0;
  await sendSnapshotToGuest([...currentFiles], ++globalLaunchNumber);
});

guestMessagePipe.registerHandler(Message.OPEN_ALLOWED_FILE, async (message) => {
  const {fileToken} = message as OpenAllowedFileMessage;
  const handle = fileHandleForToken(fileToken);
  const response:
      OpenAllowedFileResponse = {file: (await getFileFromHandle(handle)).file};
  return response;
});

/**
 * Shows a file picker to get a writable file.
 */
function pickWritableFile(
    suggestedName: string, mimeType: string, startInToken: number,
    accept: string[]): Promise<FileSystemFileHandle> {
  const JPG_EXTENSIONS =
      ['.jpg', '.jpeg', '.jpe', '.jfif', '.jif', '.jfi', '.pjpeg', '.pjp'];
  const ACCEPT_ARGS: {[index: string]: FilePickerAcceptType} = {
    'JPG': {description: 'JPG', accept: {'image/jpeg': JPG_EXTENSIONS}},
    'PNG': {description: 'PNG', accept: {'image/png': ['.png']}},
    'WEBP': {description: 'WEBP', accept: {'image/webp': ['.webp']}},
    'PDF': {description: 'PDF', accept: {'application/pdf': ['.pdf']}},
  };
  const acceptTypes = accept.map(k => ACCEPT_ARGS[k]).filter(a => !!a) as
      FilePickerAcceptType[];

  const options: SaveFilePickerOptions = {
    suggestedName,
  };

  if (startInToken) {
    options.startIn = fileHandleForToken(startInToken);
  }

  if (acceptTypes.length > 0) {
    options.excludeAcceptAllOption = true;
    options.types = acceptTypes;
  } else {
    // Search for the mimeType, and add a single entry. If none is found, the
    // file picker is left "unconfigured"; with just "all files".
    for (const a of Object.values(ACCEPT_ARGS)) {
      if (a.accept[mimeType]) {
        options.excludeAcceptAllOption = true;
        options.types = [a];
      }
    }
  }

  // This may throw an error, but we can handle and recover from it on the
  // unprivileged side.
  return window.showSaveFilePicker!(options);
}

/**
 * Generator instance for unguessable tokens.
 */
const tokenGenerator: Generator<number> = (function*() {
  // To use the regular number type, tokens must stay below
  // Number.MAX_SAFE_INTEGER (2^53). So stick with ~33 bits. Note we can not
  // request more than 64kBytes from crypto.getRandomValues() at a time.
  const randomBuffer = new Uint32Array(1000);
  while (true) {
    assertCast(crypto).getRandomValues(randomBuffer);
    for (let i = 0; i < randomBuffer.length; ++i) {
      const token = randomBuffer[i];
      // Disallow "0" as a token.
      if (token && !tokenMap.has(token)) {
        yield Number(token);
      }
    }
  }
})();

/**
 * Generate a file token, and persist the mapping to `handle`.
 */
function generateToken(handle: FileSystemFileHandle): number {
  const token = tokenGenerator.next().value;
  tokenMap.set(token, handle);
  return token;
}

/**
 * Return the mimetype of a file given it's filename. Returns null if the
 * mimetype could not be determined or if the file does not have a extension.
 * TODO(b/178986064): Remove this once we have a file system access metadata
 * api.
 */
function getMimeTypeFromFilename(filename: string): string|null {
  // This file extension to mime type map is adapted from
  // https://source.chromium.org/chromium/chromium/src/+/main:net/base/mime_util.cc;l=147;drc=51373c4ea13372d7711c59d9929b0be5d468633e
  const mapping: {[index: string]: string} = {
    'avif': 'image/avif',
    'crx': 'application/x-chrome-extension',
    'css': 'text/css',
    'flac': 'audio/flac',
    'gif': 'image/gif',
    'htm': 'text/html',
    'html': 'text/html',
    'jpeg': 'image/jpeg',
    'jpg': 'image/jpeg',
    'js': 'text/javascript',
    'm4a': 'audio/x-m4a',
    'm4v': 'video/mp4',
    'mht': 'multipart/related',
    'mhtml': 'multipart/related',
    'mjs': 'text/javascript',
    'mp3': 'audio/mpeg',
    'mp4': 'video/mp4',
    'oga': 'audio/ogg',
    'ogg': 'audio/ogg',
    'ogm': 'video/ogg',
    'ogv': 'video/ogg',
    'opus': 'audio/ogg',
    'png': 'image/png',
    'shtm': 'text/html',
    'shtml': 'text/html',
    'wasm': 'application/wasm',
    'wav': 'audio/wav',
    'webm': 'video/webm',
    'webp': 'image/webp',
    'xht': 'application/xhtml+xml',
    'xhtm': 'application/xhtml+xml',
    'xhtml': 'application/xhtml+xml',
    'xml': 'text/xml',
    'epub': 'application/epub+zip',
    'woff': 'application/font-woff',
    'gz': 'application/gzip',
    'tgz': 'application/gzip',
    'json': 'application/json',
    'bin': 'application/octet-stream',
    'exe': 'application/octet-stream',
    'com': 'application/octet-stream',
    'pdf': 'application/pdf',
    'p7m': 'application/pkcs7-mime',
    'p7c': 'application/pkcs7-mime',
    'p7z': 'application/pkcs7-mime',
    'p7s': 'application/pkcs7-signature',
    'ps': 'application/postscript',
    'eps': 'application/postscript',
    'ai': 'application/postscript',
    'rdf': 'application/rdf+xml',
    'rss': 'application/rss+xml',
    'apk': 'application/vnd.android.package-archive',
    'xul': 'application/vnd.mozilla.xul+xml',
    'xls': 'application/vnd.ms-excel',
    'xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
    'zip': 'application/zip',
    'weba': 'audio/webm',
    'bmp': 'image/bmp',
    'jfif': 'image/jpeg',
    'pjpeg': 'image/jpeg',
    'pjp': 'image/jpeg',
    'svg': 'image/svg+xml',
    'svgz': 'image/svg+xml',
    'tiff': 'image/tiff',
    'tif': 'image/tiff',
    'ico': 'image/vnd.microsoft.icon',
    'eml': 'message/rfc822',
    'ics': 'text/calendar',
    'ehtml': 'text/html',
    'txt': 'text/plain',
    'text': 'text/plain',
    'sh': 'text/x-sh',
    'xsl': 'text/xml',
    'xbl': 'text/xml',
    'xslt': 'text/xml',
    'mpeg': 'video/mpeg',
    'mpg': 'video/mpeg',

    // Add more video file types. These are not web-supported types, but are
    // supported on ChromeOS, and have file handlers in media_web_app_info.cc.
    'mkv': 'video/x-matroska',
    '3gp': 'video/3gpp',
    'mov': 'video/quicktime',
    'avi': 'video/x-msvideo',
    'mpeg4': 'video/mp4',
    'mpg4': 'video/mp4',
  };

  const fileParts = filename.split('.');
  if (fileParts.length < 2) {
    return null;
  }
  const extension = fileParts[fileParts.length - 1]!.toLowerCase();
  const mimeType = mapping[extension];
  return mimeType !== undefined ? mimeType : null;
}

/**
 * Returns the `FileSystemFileHandle` for the given `token`. This is
 * "guaranteed" to succeed: tokens are only generated once a file handle has
 * been successfully opened at least once (and determined to be "related"). The
 * handle doesn't expire, but file system operations may fail later on.
 * One corner case, however, is when the initial file open fails and the token
 * gets replaced by `-1`. File operations all need to fail in that case.
 */
function fileHandleForToken(token: number): FileSystemFileHandle {
  const handle = tokenMap.get(token);
  if (!handle) {
    throw new DOMException(`No handle for token(${token})`, 'NotFoundError');
  }
  return handle;
}

/**
 * Saves the provided blob the provided fileHandle. Assumes the handle is
 * writable.
 */
async function saveBlobToFile(
    handle: FileSystemFileHandle, data: Blob): Promise<void> {
  if (data.size === 0) {
    // Bugs or error states in the app could cause an unexpected write of zero
    // bytes to a file, which could cause data loss. Reject it here.
    const error = new Error('saveBlobToFile(): Refusing to write zero bytes.');
    error.name = EMPTY_WRITE_ERROR_NAME;
    throw error;
  }
  const writer = await handle.createWritable();
  await writer.write(data);
  await writer.truncate(data.size);
  await writer.close();
}

/**
 * Warns if a given exception is "uncommon". That is, one that the guest might
 * not provide UX for and should be dumped to console to give additional
 * context.
 */
function warnIfUncommon(e: DOMException, fileName: string) {
  // Errors we expect to be thrown in normal operation.
  const commonErrors = ['NotFoundError', 'NotAllowedError', 'NotAFile'];
  if (commonErrors.includes(e.name)) {
    return;
  }
  console.warn(`Unexpected ${e.name} on ${fileName}: ${e.message}`);
}

/**
 * If `fd.file` is null, re-opens the file handle in `fd`.
 */
async function refreshFile(fd: FileDescriptor) {
  if (fd.file) {
    return;
  }
  fd.lastError = '';
  try {
    fd.file = (await getFileFromHandle(fd.handle)).file;
  } catch (e: any) {
    fd.lastError = e.name;
    // A failure here is only a problem for the "current" file (and that needs
    // to be handled in the unprivileged context), so ignore known errors.
    warnIfUncommon(e, fd.handle.name);
  }
}

/**
 * Loads the current file list into the guest.
 */
async function sendFilesToGuest(): Promise<void> {
  return sendSnapshotToGuest(
      [...currentFiles], globalLaunchNumber);  // Shallow copy.
}

/**
 * Converts a file descriptor from `currentFiles` into a `FileContext` used by
 * the LoadFilesMessage. Closure forgets that some fields may be missing without
 * naming the type explicitly on the signature here.
 */
function fileDescriptorToFileContext(fd: FileDescriptor): FileContext {
  // TODO(b/163285659): Properly detect files that can't be renamed/deleted.
  return {
    token: fd.token,
    file: fd.file,
    name: fd.handle.name,
    error: fd.lastError || '',
    canDelete: fd.inCurrentDirectory || false,
    canRename: fd.inCurrentDirectory || false,
  };
}

/**
 * Loads the provided file list into the guest.
 * Note: code paths can defer loads i.e. `launchWithDirectory()` increment
 * `globalLaunchNumber` to ensure their deferred load is still relevant when it
 * finishes processing. Other code paths that call `sendSnapshotToGuest()` don't
 * have to.
 */
async function sendSnapshotToGuest(
    snapshot: FileDescriptor[], localLaunchNumber: number,
    extraFiles: boolean = false): Promise<void> {
  const focusIndex = entryIndex;

  // Attempt to reopen the focus file only. In future we might also open
  // "nearby" files for preloading. However, reopening *all* files on every
  // navigation attempt to verify they can still be navigated to adds noticeable
  // lag in large directories.
  let targetIndex = -1;
  if (focusIndex >= 0 && focusIndex < snapshot.length) {
    targetIndex = focusIndex;
  } else if (snapshot.length !== 0) {
    targetIndex = 0;
  }
  if (targetIndex >= 0) {
    const descriptor = snapshot[targetIndex];
    await refreshFile(descriptor!);
    await refreshLoadRequiredAssociatedFiles(
        snapshot, descriptor!.handle.name, extraFiles);
    if (extraFiles) {
      snapshot.shift();
    }
  }

  if (localLaunchNumber !== globalLaunchNumber) {
    return;
  }
  const loadFilesMessage: LoadFilesMessage = {
    currentFileIndex: focusIndex,
    // Handle can't be passed through a message pipe.
    files: snapshot.map(fileDescriptorToFileContext),
  };

  // Clear handles to the open files in the privileged context so they are
  // refreshed on a navigation request. The refcount to the File will be alive
  // in the postMessage object until the guest takes its own reference.
  for (const fd of snapshot) {
    fd.file = null;
  }

  // Wait for the signal from first_message_received.js before proceeding.
  await window.firstMessageReceived;

  if (extraFiles) {
    await guestMessagePipe.sendMessage(
        Message.LOAD_EXTRA_FILES, loadFilesMessage);
  } else {
    await guestMessagePipe.sendMessage(Message.LOAD_FILES, loadFilesMessage);
  }
}

/**
 * Throws an error if the file or directory handles don't exist or the token for
 * the file to be mutated is incorrect.
 */
function assertFileAndDirectoryMutable(
    editFileToken: number, operation: string):
    {handle: FileSystemFileHandle, directory: FileSystemDirectoryHandle} {
  if (!currentDirectoryHandle) {
    throw new Error(`${operation} failed. File without launch directory.`);
  }

  return {
    handle: fileHandleForToken(editFileToken),
    directory: currentDirectoryHandle,
  };
}

/**
 * Returns whether `handle` is in `currentDirectoryHandle`. Prevents mutating a
 * file that doesn't exist.
 */
async function isHandleInCurrentDirectory(handle: FileSystemFileHandle):
    Promise<boolean> {
  const file: File|null = await maybeGetFileFromFileHandle(handle);
  // If we were unable to get a file from the handle it must not be in the
  // current directory anymore.
  if (!file) {
    return false;
  }

  // It's unclear if getFile will always give us a NotFoundError if the file has
  // been moved as it's not explicitly stated in the File System Access API
  // spec. As such we perform an additional check here to make sure the file
  // returned by the handle is in fact in the current directory.
  // TODO(b/172628918): Remove this once we have more assurances getFile() does
  // the right thing.
  const currentFilename = file.name;
  const fileHandle = await getFileHandleFromCurrentDirectory(currentFilename);
  return fileHandle ? fileHandle.isSameEntry(handle) : false;
}

/**
 * Returns if a`filename` exists in `currentDirectoryHandle`.
 */
async function filenameExistsInCurrentDirectory(filename: string):
    Promise<boolean> {
  return (await getFileHandleFromCurrentDirectory(filename, true)) !== null;
}

/**
 * Returns the `FileSystemFileHandle` for `filename` if it exists in the current
 * directory, otherwise null.
 */
async function getFileHandleFromCurrentDirectory(
    filename: string, suppressError = false): Promise<FileSystemHandle|null> {
  if (!currentDirectoryHandle) {
    return null;
  }
  try {
    return (
        await currentDirectoryHandle.getFileHandle(filename, {create: false}));
  } catch (e: any) {
    if (!suppressError) {
      // Some filenames (e.g. "thumbs.db") can't be opened (or deleted) by
      // filename. TypeError doesn't give a good error message in the app, so
      // convert to a new Error.
      if (e.name === 'TypeError' &&
          e.message ===
              'Failed to execute \'getFileHandle\' on ' +
                  '\'FileSystemDirectoryHandle\': Name is not allowed.') {
        console.warn(e);  // Warn so a crash report is not generated.
        throw new DOMException(
            'File has a reserved name and can not be opened',
            'InvalidModificationError');
      }
      console.error(e);
    }
    return null;
  }
}

/**
 * Gets a file from a handle received via the fileHandling API. Only handles
 * expected to be files should be passed to this function. Throws a DOMException
 * if opening the file fails - usually because the handle is stale.
 */
async function getFileFromHandle(fileSystemHandle: FileSystemHandle):
    Promise<{file: File, handle: FileSystemFileHandle}> {
  if (!fileSystemHandle || fileSystemHandle.kind !== 'file') {
    // Invent our own exception for this corner case. It might happen if a file
    // is deleted and replaced with a directory with the same name.
    throw new DOMException('Not a file.', 'NotAFile');
  }
  const handle = fileSystemHandle as FileSystemFileHandle;
  const file = await handle.getFile();  // Note: throws DOMException.
  return {file, handle};
}

/**
 * Calls getFile on `handle` and gracefully returns null if it encounters a
 * NotFoundError, which can happen if the file is no longer in the current
 * directory due to being moved or deleted.
 */
async function maybeGetFileFromFileHandle(handle: FileSystemFileHandle):
    Promise<File|null> {
  let file: File|null;
  try {
    file = await handle.getFile();
  } catch (e: any) {
    // NotFoundError can be thrown if `handle` is no longer in the directory we
    // have access to.
    if (e.name === 'NotFoundError') {
      file = null;
    } else {
      // Any other error is unexpected.
      throw e;
    }
  }
  return file;
}

/**
 * Returns whether `fileName` is a file potentially containing subtitles.
 */
function isSubtitleFile(fileName: string): boolean {
  return /\.vtt$/.test(fileName.toLowerCase());
}

/**
 * Returns whether `fileName` is a file likely to be a video.
 */
function isVideoFile(fileName: string): boolean {
  return /^video\//.test(getMimeTypeFromFilename(fileName) ?? '');
}

/**
 * Returns whether `fileName` is a file likely to be an image.
 */
function isImageFile(fileName: string): boolean {
  // Detect RAW images, which often don't have a mime type set.
  return /\.(arw|cr2|dng|nef|nrw|orf|raf|rw2)$/.test(fileName.toLowerCase()) ||
      /^image\//.test(getMimeTypeFromFilename(fileName) ?? '');
}

/**
 * Returns whether `fileName` is a file likely to be audio.
 */
function isAudioFile(fileName: string): boolean {
  return /^audio\//.test(getMimeTypeFromFilename(fileName) ?? '');
}

/**
 * Returns whether fileName is the filename for a video or image, or a related
 * file type (e.g. video subtitles).
 */
function isVideoOrImage(fileName: string): boolean {
  return isImageFile(fileName) || isVideoFile(fileName) ||
      isSubtitleFile(fileName);
}

/**
 * Returns whether `siblingFile` is related to `focusFile`. That is, whether
 * they should be traversable from one another. Usually this means they share a
 * similar (non-empty) MIME type.
 * @param focusFile The file selected by the user.
 * @param siblingFileName Filename for a file in the same directory as
 *     `focusFile`.
 */
function isFileRelated(focusFile: File, siblingFileName: string): boolean {
  const siblingFileType = getMimeTypeFromFilename(siblingFileName);
  return focusFile.name === siblingFileName ||
      (!!focusFile.type && !!siblingFileType &&
       focusFile.type === siblingFileType) ||
      (isVideoOrImage(focusFile.name) && isVideoOrImage(siblingFileName));
}

/**
 * Enum like return value of `processOtherFilesInDirectory()`.
 */
enum ProcessOtherFilesResult {
  // Newer load in progress, can abort loading these files.
  ABORT = -2,
  // The focusFile is missing, treat this as a normal load.
  FOCUS_FILE_MISSING = -1,
  // The focusFile is present, load these files as extra files.
  FOCUS_FILE_RELEVANT = 0,
}

/**
 * Loads related files the working directory to initialize file iteration
 * according to the type of the opened file. If `globalLaunchNumber` changes
 * (i.e. another launch occurs), this will abort early and not change
 * `currentFiles`.
 */
async function processOtherFilesInDirectory(
    directory: FileSystemDirectoryHandle, focusFile: File|null,
    localLaunchNumber: number): Promise<ProcessOtherFilesResult> {
  if (!focusFile || !focusFile.name) {
    return ProcessOtherFilesResult.ABORT;
  }

  let relatedFiles: FileDescriptor[] = [];
  // TODO(b/158149714): Clear out old tokens as well? Care needs to be taken to
  // ensure any file currently open with unsaved changes can still be saved.
  try {
    for await (const handle of directory.values()) {
      if (localLaunchNumber !== globalLaunchNumber) {
        // Abort, another more up to date launch in progress.
        return ProcessOtherFilesResult.ABORT;
      }

      if (handle.kind !== 'file') {
        continue;
      }
      const fileHandle = handle as FileSystemFileHandle;
      // Only allow traversal of related file types.
      if (isFileRelated(focusFile, handle.name)) {
        // Note: The focus file will be processed here again but will be skipped
        // over when added to `currentFiles`.
        relatedFiles.push({
          token: generateToken(fileHandle),
          // This will get populated by refreshFile before the file gets opened.
          file: null,
          handle: fileHandle,
          inCurrentDirectory: true,
        });
      }
    }
  } catch (e: unknown) {
    console.warn(e, '(failed to traverse directory)');
    // It's unlikely traversal can "resume", but try to continue with anything
    // obtained so far.
  }

  if (currentFiles.length > 1) {
    // Related files identified as required for the initial load must be removed
    // so they don't appear in the file list twice.
    const atLoadCurrentFiles = currentFiles.slice(1);
    relatedFiles = relatedFiles.filter(
        f => !atLoadCurrentFiles.find(c => c.handle.name === f.handle.name));
  }

  if (localLaunchNumber !== globalLaunchNumber) {
    return ProcessOtherFilesResult.ABORT;
  }

  await sortFiles(relatedFiles);
  const name = focusFile.name;
  const focusIndex = relatedFiles.findIndex(i => i.handle.name === name);
  entryIndex = 0;
  if (focusIndex === -1) {
    // The focus file is no longer there i.e. might have been deleted, should be
    // missing from `currentFiles` as well.
    currentFiles.push(...relatedFiles);
    return ProcessOtherFilesResult.FOCUS_FILE_MISSING;
  } else {
    // Rotate the sorted files so focusIndex becomes index 0 such that we have
    // [focus file, ...files larger, ...files smaller].
    currentFiles.push(...relatedFiles.slice(focusIndex + 1));
    currentFiles.push(...relatedFiles.slice(0, focusIndex));
    return ProcessOtherFilesResult.FOCUS_FILE_RELEVANT;
  }
}

/**
 * Sorts the given `files` by `sortOrder`.
 */
async function sortFiles(files: FileDescriptor[]) {
  if (sortOrder === SortOrder.NEWEST_FIRST) {
    // If we are sorting by modification time we need to have the actual File
    // object available.
    for (const descriptor of files) {
      // TODO(b/166210455): Remove this call to getFile as it may be slow for
      // android files see b/172529567. Leaving it in at the moment since sort
      // order is not set to NEWEST_FIRST in any production release and there is
      // no way to get modified time without calling getFile.
      try {
        descriptor.file = (await getFileFromHandle(descriptor.handle)).file;
      } catch (e: any) {
        warnIfUncommon(e, descriptor.handle.name);
      }
    }
  }

  // Iteration order is not guaranteed using `directory.entries()`, so we
  // sort it afterwards by modification time to ensure a consistent and logical
  // order. More recent (i.e. higher timestamp) files should appear first. In
  // the case where timestamps are equal, the files will be sorted
  // lexicographically according to their names.
  files.sort((a, b) => {
    if (sortOrder === SortOrder.NEWEST_FIRST) {
      // Sort null files last if they racily appear.
      if (!a.file && !b.file) {
        return 0;
      } else if (!b.file) {
        return -1;
      } else if (!a.file) {
        return 1;
      } else if (a.file.lastModified === b.file.lastModified) {
        return a.file.name.localeCompare(b.file.name);
      }
      return b.file.lastModified - a.file.lastModified;
    }
    // Else default to lexicographical sort.
    // Match the Intl.Collator params used for sorting in the files app in
    // file_manager/common/js/util.js.
    const direction = sortOrder === SortOrder.A_FIRST ? 1 : -1;
    return direction *
        a.handle.name.localeCompare(
            b.handle.name, [],
            {usage: 'sort', numeric: true, sensitivity: 'base'});
  });
}

/**
 * Loads related files in the working directory and sends them to the guest. If
 * the focus file (currentFiles[0]) is no longer relevant i.e. is has been
 * deleted, we load files as usual.
 */
async function loadOtherRelatedFiles(
    directory: FileSystemDirectoryHandle, focusFile: File|null,
    _focusHandle: FileSystemFileHandle|null, localLaunchNumber: number) {
  const processResult = await processOtherFilesInDirectory(
      directory, focusFile, localLaunchNumber);
  if (localLaunchNumber !== globalLaunchNumber ||
      processResult === ProcessOtherFilesResult.ABORT) {
    return;
  }

  const shallowCopy = [...currentFiles];
  // If the focus file is no longer relevant, loads files as normal.
  await sendSnapshotToGuest(
      shallowCopy, localLaunchNumber,
      processResult === ProcessOtherFilesResult.FOCUS_FILE_RELEVANT);
}

/**
 * Sets state for the files opened in the current directory.
 */
function setCurrentDirectory(
    directory: FileSystemDirectoryHandle,
    focusFile: {file: File, handle: FileSystemFileHandle}) {
  // Load currentFiles into the guest.
  currentFiles.length = 0;
  currentFiles.push({
    token: generateToken(focusFile.handle),
    file: focusFile.file,
    handle: focusFile.handle,
    inCurrentDirectory: true,
  });
  currentDirectoryHandle = directory;
  entryIndex = 0;
}

/**
 * Returns a filename associated with `focusFileName` that may be required to
 * properly load the file. The file might not exist.
 * TODO(b/175099007): Support multiple associated files.
 */
function requiredAssociatedFileName(focusFileName: string): string {
  // Subtitles must be identified for the initial load to be properly attached.
  if (!isVideoFile(focusFileName)) {
    return '';
  }
  // To match the video player app, just look for `.vtt` until alternative
  // heuristics are added inside the app layer. See b/175099007.
  return focusFileName.replace(/\.[^\.]+$/, '.vtt');
}

/**
 * Adds file handles for associated files to the set of launch files.
 */
async function detectLoadRequiredAssociatedFiles(
    directory: FileSystemDirectoryHandle, focusFileName: string) {
  const vttFileName = requiredAssociatedFileName(focusFileName);
  if (!vttFileName) {
    return;
  }
  try {
    const vttFileHandle = await directory.getFileHandle(vttFileName);
    currentFiles.push({
      token: generateToken(vttFileHandle),
      file: null,  // Will be set by `refreshLoadRequiredAssociatedFiles()`.
      handle: vttFileHandle,
      inCurrentDirectory: true,
    });
  } catch (e: unknown) {
    // Do nothing if not found or not permitted.
  }
}

/**
 * Refreshes the File object for all file handles associated with the focus
 * file.
 */
async function refreshLoadRequiredAssociatedFiles(
    snapshot: FileDescriptor[], focusFileName: string,
    forExtraFilesMessage: boolean) {
  const vttFileName = requiredAssociatedFileName(focusFileName);
  if (!vttFileName) {
    return;
  }
  const index = snapshot.findIndex(d => d.handle.name === vttFileName);
  if (index >= 0) {
    await refreshFile(snapshot[index]!);
    // In the extra files message, it's necessary to remove the vtt file from
    // the snapshot to avoid it being added again in the receiver.
    if (forExtraFilesMessage) {
      snapshot.splice(index, 1);
    }
  }
}

/**
 * Launch the media app with the files in the provided directory, using `handle`
 * as the initial launch entry.
 */
async function launchWithDirectory(
    directory: FileSystemDirectoryHandle, handle: FileSystemHandle) {
  const localLaunchNumber = ++globalLaunchNumber;

  let asFile;
  try {
    asFile = await getFileFromHandle(handle);
  } catch (e: any) {
    console.warn(`${handle.name}: ${e.message}`);
    sendSnapshotToGuest(
        [{
          token: -1,
          file: null,
          handle: handle as FileSystemFileHandle,
          lastError: e.name,
        }],
        localLaunchNumber);
    return;
  }
  // Load currentFiles into the guest.
  setCurrentDirectory(directory, asFile);
  await detectLoadRequiredAssociatedFiles(directory, handle.name);
  await sendSnapshotToGuest([...currentFiles], localLaunchNumber);
  // The app is operable with the first file now.

  // Process other files in directory.
  // TODO(https://github.com/WICG/file-system-access/issues/215): Don't process
  // other files if there is only 1 file which is already loaded by
  // `sendSnapshotToGuest()` above.
  await loadOtherRelatedFiles(
      directory, asFile.file, asFile.handle, localLaunchNumber);
}

/**
 * Launch the media app with the selected files.
 */
async function launchWithMultipleSelection(
    directory: FileSystemDirectoryHandle,
    handles: Array<FileSystemHandle|null|undefined>) {
  currentFiles.length = 0;
  for (const handle of handles) {
    if (handle && handle.kind === 'file') {
      const fileHandle = handle as FileSystemFileHandle;
      currentFiles.push({
        token: generateToken(fileHandle),
        file: null,  // Just let sendSnapshotToGuest() "refresh" it.
        handle: fileHandle,
        // TODO(b/163285659): Enable delete/rename for multi-select files.
      });
    }
  }
  await sortFiles(currentFiles);
  entryIndex = currentFiles.length > 0 ? 0 : -1;
  currentDirectoryHandle = directory;
  await sendFilesToGuest();
}

/**
 * Advance to another file.
 *
 * @param direction How far to advance (e.g. +/-1).
 * @param currentFileToken The token of the file that
 *     direction is in reference to. If unprovided it's assumed that
 *     currentFiles[entryIndex] is the current file.
 */
async function advance(direction: number, currentFileToken?: number) {
  let currIndex = entryIndex;
  if (currentFileToken) {
    const fileIndex =
        currentFiles.findIndex(fd => fd.token === currentFileToken);
    currIndex = fileIndex === -1 ? currIndex : fileIndex;
  }

  if (currentFiles.length) {
    entryIndex = (currIndex + direction) % currentFiles.length;
    if (entryIndex < 0) {
      entryIndex += currentFiles.length;
    }
  } else {
    entryIndex = -1;
  }
  await sendFilesToGuest();
}

/**
 * The launchQueue consumer. This returns a promise to help tests, but the file
 * handling API will ignore it.
 */
async function launchConsumer(params?: LaunchParams): Promise<void> {
  // The MediaApp sets `include_launch_directory = true` in its SystemAppInfo
  // struct compiled into Chrome. That means files[0] is guaranteed to be a
  // directory, with remaining launch files following it. Validate that this is
  // true and abort the launch if is is not.
  if (!params || !params.files || params.files.length < 2) {
    console.error('Invalid launch (missing files): ', params);
    return;
  }

  if (assertCast(params.files[0]).kind !== 'directory') {
    console.error('Invalid launch: files[0] is not a directory: ', params);
    return;
  }
  const directory = params.files[0] as FileSystemDirectoryHandle;
  // With a single file selected, that file is the focus file. Otherwise, there
  // is no inherent focus file.
  const maybeFocusEntry = assertCast(params.files[1]);

  // With a single file selected, launch with all files in the directory as
  // navigation candidates. Otherwise, launch with all selected files (except
  // the launch directory itself) as navigation candidates. The only exception
  // to this is audio files, which we explicitly don't load the directory for.
  if (params.files.length === 2 && !isAudioFile(maybeFocusEntry.name)) {
    try {
      await launchWithDirectory(directory, maybeFocusEntry);
    } catch (e: unknown) {
      console.error(e, '(launchWithDirectory aborted)');
    }
  } else {
    try {
      await launchWithMultipleSelection(directory, params.files.slice(1));
    } catch (e: unknown) {
      console.error(e, '(launchWithMultipleSelection aborted)');
    }
  }
}

/**
 * Wrapper for the launch consumer to ensure it doesn't return a Promise, nor
 * propagate exceptions. Tests will want to target `launchConsumer` directly so
 * that they can properly await launch results.
 */
function wrappedLaunchConsumer(params?: LaunchParams) {
  launchConsumer(params).catch(e => {
    console.error(e, '(launch aborted)');
  });
}

/**
 * Installs the handler for launch files, if window.launchQueue is available.
 */
function installLaunchHandler() {
  if (!window.launchQueue) {
    console.error('FileHandling API missing.');
    return;
  }
  window.launchQueue.setConsumer(wrappedLaunchConsumer);
}

installLaunchHandler();

// Make sure the guest frame has focus.
const guest = assertCast(document.querySelector<HTMLIFrameElement>(
    'iframe[src^="chrome-untrusted://media-app"]'));
guest.addEventListener('load', () => {
  guest.focus();
});

export const TEST_ONLY = {
  Message,
  SortOrder,
  advance,
  currentDirectoryHandle,
  currentFiles,
  fileHandleForToken,
  globalLaunchNumber,
  guestMessagePipe,
  launchConsumer,
  launchWithDirectory,
  loadOtherRelatedFiles,
  pickWritableFile,
  processOtherFilesInDirectory,
  sendFilesToGuest,
  setCurrentDirectory,
  sortOrder,
  tokenGenerator,
  tokenMap,
  mediaAppPageHandler,
  error_reporter,
  getGlobalLaunchNumber: () => globalLaunchNumber,
  incrementLaunchNumber: () => ++globalLaunchNumber,
  setCurrentDirectoryHandle: (d: FileSystemDirectoryHandle|null) => {
    currentDirectoryHandle = d;
  },
  setSortOrder: (s: SortOrder) => {
    sortOrder = s;
  },
  getEntryIndex: () => entryIndex,
  setEntryIndex: (i: number) => {
    entryIndex = i;
  },
};

// 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';

// Expose `advance()` for MediaAppIntegrationTest.FileOpenCanTraverseDirectory.
window['advance'] = advance;