chromium/ui/file_manager/file_manager/foreground/elements/files_safe_media.ts

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

import {PolymerElement} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';

import {toSandboxedURL} from '../../common/js/url_constants.js';

import {getTemplate} from './files_safe_media.html.js';

export interface FilesSafeMedia {
  $: {content: HTMLDivElement};
  type: string;
  src: FilePreviewContent;
  fire: (eventName: string) => void;
}

type ContentsIframeNode = HTMLIFrameElement&{isVideoMedia_: boolean};

/**
 * Polymer element to render media securely in a chrome-untrusted:// <iframe>
 * element.
 *
 * When tapped, 'files-safe-media-tap-inside', 'files-safe-media-tap-outside'
 * events are fired depending on the position of the tap.
 */
export class FilesSafeMedia extends PolymerElement {
  static get is() {
    return 'files-safe-media';
  }

  static get template() {
    return getTemplate();
  }

  static get properties() {
    return {
      /**
       * Source content accessible from the sandboxed environment.
       */
      src: {
        type: Object,
        observer: 'onSrcChange_',
        reflectToAttribute: true,
      },

      /**
       * <files-safe-media> media type: e.g. audio, image, video, html.
       */
      type: {
        type: String,
        readonly: true,
      },
    };
  }

  private contentsNode_: ContentsIframeNode|null = null;

  private sourceFile_() {
    switch (this.type) {
      case 'image':
        return toSandboxedURL('untrusted_resources/files_img_content.html')
            .toString();
      case 'audio':
        return toSandboxedURL('untrusted_resources/files_audio_content.html')
            .toString();
      case 'video':
        return toSandboxedURL('untrusted_resources/files_video_content.html')
            .toString();
      case 'html':
        return toSandboxedURL('untrusted_resources/files_text_content.html')
            .toString();
      default:
        console.warn('Unsupported type: ' + this.type);
        return '';
    }
  }

  private onSrcChange_() {
    const hasContent = this.src.dataType !== '';

    if (!hasContent) {
      // Remove untrusted iframe to clean up unnecessary processes.
      if (this.contentsNode_) {
        this.$.content.removeChild(this.contentsNode_);
        this.contentsNode_ = null;
      }
      return;
    }

    if (this.contentsNode_ && this.contentsNode_.isVideoMedia_) {
      // Remove old video item first to stop UI flicker when drawing the new
      // video content: bug b/260619403
      this.$.content.removeChild(this.contentsNode_);
      this.contentsNode_ = null;
    }

    if (!this.contentsNode_) {
      // Create the node, which will callback here (onSrcChange_) when done.
      this.createUntrustedContents_();
      return;
    }

    const data: UntrustedPreviewData = {
      type: this.type,
      sourceContent: this.src,
    };

    if (this.contentsNode_.contentWindow) {
      this.contentsNode_.isVideoMedia_ = (this.type === 'video');
      // Send the data to preview to the untrusted <iframe>.
      this.contentsNode_.contentWindow.postMessage(
          data, toSandboxedURL().origin);
    }
  }

  private createUntrustedContents_() {
    const node = document.createElement('iframe') as ContentsIframeNode;
    this.contentsNode_ = node;
    // Allow autoplay for audio files.
    if (this.type === 'audio') {
      node.setAttribute('allow', 'autoplay');
    }
    this.$.content.appendChild(node);
    node.addEventListener('load', () => this.onSrcChange_());
    node.src = this.sourceFile_();
  }

  created() {
    /**
     * Holds the untrusted iframe when a source to preview is set. Set to null
     * otherwise.
     */
    this.contentsNode_ = null;
  }

  override ready() {
    super.ready();
    this.addEventListener('focus', () => {
      if (this.type === 'audio' || this.type === 'video') {
        // Avoid setting the focus on the files-safe-media itself, rather sends
        // it down to its untrusted iframe element.
        if (this.contentsNode_) {
          this.contentsNode_.focus();
        }
      }
    });

    window.addEventListener('message', event => {
      if (event.origin !== toSandboxedURL().origin) {
        console.warn('Unknown origin: ' + event.origin);
        return;
      }
      if (event.data === 'tap-inside') {
        this.dispatchEvent(new CustomEvent(
            'files-safe-media-tap-inside', {bubbles: true, composed: true}));
      } else if (event.data === 'tap-outside') {
        this.dispatchEvent(new CustomEvent(
            'files-safe-media-tap-outside', {bubbles: true, composed: true}));
      } else if (event.data === 'webview-loaded') {
        if (this.contentsNode_) {
          this.contentsNode_.setAttribute('loaded', '');
        }
      } else if (event.data === 'webview-cleared') {
        if (this.contentsNode_) {
          this.contentsNode_.removeAttribute('loaded');
        }
      } else if (event.data === 'content-decode-failed') {
        this.dispatchEvent(new CustomEvent(
            'files-safe-media-load-error', {bubbles: true, composed: true}));
      }
    });
  }
}

declare global {
  interface HTMLElementTagNameMap {
    'files-safe-media': FilesSafeMedia;
  }
}

customElements.define(FilesSafeMedia.is, FilesSafeMedia);