chromium/ui/file_manager/file_manager/foreground/js/metadata/metadata_dispatcher.ts

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

import {ExifParser} from './exif_parser.js';
import {Id3Parser} from './id3_parser.js';
import {BmpParser, GifParser, IcoParser, PngParser, WebpParser} from './image_parsers.js';
import type {ParserMetadata} from './metadata_item.js';
import type {MetadataParser} from './metadata_parser.js';
import {type MetadataParserLogger} from './metadata_parser.js';
import {MpegParser} from './mpeg_parser.js';

// Helper function to type entries as FileEntry. We redefine it here because
// importing entry_utils.js has some transitive side effects that access objects
// not accessible in a shared worker.
function isFileEntry(entry: Entry): entry is FileEntry {
  return entry.isFile;
}

/**
 * Dispatches metadata requests to the correct parser.
 */
class MetadataDispatcher implements MetadataParserLogger {
  /**
   * Verbose logging for the dispatcher.
   *
   * Individual parsers also take this as their default verbosity setting.
   */
  readonly verbose = false;
  private parserInstances_: MetadataParser[];
  private parserRegexp_: RegExp;

  // Explicitly type this as a record so we can index into this object with a
  // string.
  private messageHandlers_: Record<string, Function> = {
    init: this.init_.bind(this),
    request: this.request_.bind(this),
  };

  /***
   * @param port Worker port.
   */
  constructor(private port_: WorkerGlobalScope|MessagePort) {
    this.port_.onmessage = this.onMessage.bind(this);

    const patterns = [];

    this.parserInstances_ = [];

    const parserClasses = [
      BmpParser,
      ExifParser,
      GifParser,
      IcoParser,
      Id3Parser,
      MpegParser,
      PngParser,
      WebpParser,
    ];

    for (const parserClass of parserClasses) {
      const parser = new parserClass(this);
      this.parserInstances_.push(parser);
      patterns.push(parser.urlFilter.source);
    }

    this.parserRegexp_ = new RegExp('(' + patterns.join('|') + ')', 'i');
  }

  /**
   * |init| message handler.
   */
  private init_() {
    // Inform our owner that we're done initializing.
    // If we need to pass more data back, we can add it to the param array.
    // TODO(cleanup): parserRegexp_ looks unused in content_metadata_provider
    // and in this file, too.
    this.postMessage('initialized', [this.parserRegexp_]);
    this.vlog('initialized with URL filter ' + this.parserRegexp_);
  }

  /**
   * |request| message handler.
   * @param fileURL File URL.
   */
  private request_(fileURL: string) {
    try {
      this.processOneFile(fileURL, (metadata: ParserMetadata) => {
        this.postMessage('result', [fileURL, metadata]);
      });
    } catch (ex) {
      this.error(fileURL, ex!);
    }
  }

  /**
   * Indicate to the caller that an operation has failed.
   *
   * No other messages relating to the failed operation should be sent.
   */
  error(...args: Array<object|string>) {
    // TODO(cleanup): Strictly type these arguments to the [url, step, cause]
    // format that ContentMetadataProvider expects.

    this.postMessage('error', args);
  }

  /**
   * Send a log message to the caller.
   *
   * Callers must not parse log messages for control flow.
   */
  log(...args: Array<object|string>) {
    this.postMessage('log', args);
  }

  /**
   * Send a log message to the caller only if this.verbose is true.
   */
  vlog(...args: Array<object|string>) {
    if (this.verbose) {
      this.log(...args);
    }
  }

  /**
   * Post a properly formatted message to the caller.
   * @param verb Message type descriptor.
   * @param args Arguments array.
   */
  postMessage(verb: string, args: any[]) {
    this.port_.postMessage({verb: verb, arguments: args});
  }

  /**
   * Message handler.
   * @param event Event object.
   */
  onMessage(event: MessageEvent) {
    const data = event.data;

    const handler = this.messageHandlers_[data.verb];
    if (handler instanceof Function) {
      handler.apply(this, data.arguments);
    } else {
      this.log('Unknown message from client: ' + data.verb, data);
    }
  }

  private detectFormat_(fileURL: string): MetadataParser|null {
    for (const parser of this.parserInstances_) {
      if (fileURL.match(parser.urlFilter)) {
        return parser;
      }
    }
    return null;
  }

  /**
   * @param fileURL File URL.
   * @param callback Completion callback.
   */
  async processOneFile(
      fileURL: string, callback: (metadata: ParserMetadata) => void) {
    // Step one, find the parser matching the url.
    const parser = this.detectFormat_(fileURL);
    if (!parser) {
      this.error(fileURL, 'detectFormat', 'unsupported format');
      return;
    }
    // Create the metadata object as early as possible so that we can
    // pass it with the error message.
    const metadata: ParserMetadata = parser.createDefaultMetadata();

    // Step two, turn the url into an entry.
    const entry = await new Promise<Entry>(
        (resolve, reject) => globalThis.webkitResolveLocalFileSystemURL(
            fileURL, resolve, reject));
    if (!isFileEntry(entry)) {
      this.error(fileURL, 'getEntry', 'url does not refer a file', metadata);
      return;
    }

    // Step three, turn the entry into a file.
    const file = await new Promise(entry.file.bind(entry));

    // Step four, parse the file.
    metadata.fileSize = file.size;
    try {
      parser.parse(
          file, metadata, callback,
          (error) => this.error(fileURL, 'parseContent', error));
    } catch (e) {
      this.error(fileURL, 'parseContent', (e as Error).stack!);
    }
  }
}

// This interface and the following self type assertion is needed as we
// currently use the same tsconfig to build this as with the rest of Files App.
// TODO(b/289003444): Use a separate tsconfig to build this file with webworker
// definitions, and then remove this interface and the following type assertion.
interface WorkerGlobalScope {
  onmessage: (ev: MessageEvent) => any;
  addEventListener(type: 'connect', listener: (ev: MessageEvent) => any): void;
  postMessage(message: any): void;
}

// Webworker spec says that the worker global object is called self.  That's
// a terrible name since we use it all over the chrome codebase to capture
// the 'this' keyword in lambdas.
const global = self as WorkerGlobalScope;

if (global.constructor.name === 'SharedWorkerGlobalScope') {
  global.addEventListener('connect', e => {
    const port = e.ports[0]!;
    new MetadataDispatcher(port);
    port.start();
  });
} else {
  // Non-shared worker.
  new MetadataDispatcher(global);
}