/********************************************************************************
 * Copyright (C) 2018 TypeFox and others.
 *
 * This program and the accompanying materials are made available under the
 * terms of the Eclipse Public License v. 2.0 which is available at
 * http://www.eclipse.org/legal/epl-2.0.
 *
 * This Source Code may also be made available under the following Secondary
 * Licenses when the conditions for such availability set forth in the Eclipse
 * Public License v. 2.0 are satisfied: GNU General Public License, version 2
 * with the GNU Classpath Exception which is available at
 * https://www.gnu.org/software/classpath/license.html.
 *
 * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0
 ********************************************************************************/ import * as PDFObject from 'pdfobject' ; import { inject , injectable , postConstruct } from '@theia/core/shared/inversify' ; import { Message } from '@theia/core/shared/@phosphor/messaging' ; import URI from '@theia/core/lib/common/uri' ; import { ILogger } from '@theia/core/lib/common/logger' ; import { Emitter } from '@theia/core/lib/common/event' ; import { KeybindingRegistry } from '@theia/core/lib/browser/keybinding' ; import { WindowService } from '@theia/core/lib/browser/window/window-service' ; import { parseCssTime , Key , KeyCode } from '@theia/core/lib/browser' ; import { DisposableCollection , Disposable } from '@theia/core/lib/common/disposable' ; import { BaseWidget , addEventListener , codiconArray } from '@theia/core/lib/browser/widgets/widget' ; import { LocationMapperService } from './location-mapper-service' ; import { ApplicationShellMouseTracker } from '@theia/core/lib/browser/shell/application-shell-mouse-tracker' ; import debounce = require ( '@theia/core/shared/lodash.debounce' ) ; import { MiniBrowserContentStyle } from './mini-browser-content-style' ; import { FileService } from '@theia/filesystem/lib/browser/file-service' ; import { FileChangesEvent , FileChangeType } from '@theia/filesystem/lib/common/files' ; /**
 * Initializer properties for the embedded browser widget.
 */ @ injectable ( ) export class MiniBrowserProps { /**
     * `show` if the toolbar should be visible. If `read-only`, the toolbar is visible but the address cannot be changed and it acts as a link instead.\
     * `hide` if the toolbar should be hidden. `show` by default. If the `startPage` is not defined, this property is always `show`.
     */ readonly toolbar ? : 'show' | 'hide' | 'read-only' ; /**
     * If defined, the browser will load this page on startup. Otherwise, it show a blank page.
     */ readonly startPage ? : string ; /**
     * Sandbox options for the underlying `iframe`. Defaults to `SandboxOptions#DEFAULT` if not provided.
     */ readonly sandbox ? : MiniBrowserProps . SandboxOptions [ ] ; /**
     * The optional icon class for the widget.
     */ readonly iconClass ? : string ; /**
     * The desired name of the widget.
     */ readonly name ? : string ; /**
     * `true` if the `iFrame`'s background has to be reset to the default white color. Otherwise, `false`. `false` is the default.
     */ readonly resetBackground ? : boolean ; } export namespace MiniBrowserProps { /**
     * Enumeration of the supported `sandbox` options for the `iframe`.
     */ export enum SandboxOptions { /**
         * Allows form submissions.
         */ 'allow-forms' , /**
         * Allows popups, such as `window.open()`, `showModalDialog()`, `target=”_blank”`, etc.
         */ 'allow-popups' , /**
         * Allows pointer lock.
         */ 'allow-pointer-lock' , /**
         * Allows the document to maintain its origin. Pages loaded from https://example.com/ will retain access to that origin’s data.
         */ 'allow-same-origin' , /**
         * Allows JavaScript execution. Also allows features to trigger automatically (as they’d be trivial to implement via JavaScript).
         */ 'allow-scripts' , /**
         * Allows the document to break out of the frame by navigating the top-level `window`.
         */ 'allow-top-navigation' , /**
         * Allows the embedded browsing context to open modal windows.
         */ 'allow-modals' , /**
         * Allows the embedded browsing context to disable the ability to lock the screen orientation.
         */ 'allow-orientation-lock' , /**
         * Allows a sandboxed document to open new windows without forcing the sandboxing flags upon them.
         * This will allow, for example, a third-party advertisement to be safely sandboxed without forcing the same restrictions upon a landing page.
         */ 'allow-popups-to-escape-sandbox' , /**
         * Allows embedders to have control over whether an iframe can start a presentation session.
         */ 'allow-presentation' , /**
         * Allows the embedded browsing context to navigate (load) content to the top-level browsing context only when initiated by a user gesture.
         * If this keyword is not used, this operation is not allowed.
         */ 'allow-top-navigation-by-user-activation' } export namespace SandboxOptions { /**
         * The default `sandbox` options, if other is not provided.
         *
         * See: https://www.html5rocks.com/en/tutorials/security/sandboxed-iframes/
         */ export const DEFAULT = [ SandboxOptions [ 'allow-same-origin' ] , SandboxOptions [ 'allow-scripts' ] , SandboxOptions [ 'allow-popups' ] , SandboxOptions [ 'allow-forms' ] , SandboxOptions [ 'allow-modals' ] ] ; } } export const MiniBrowserContentFactory = Symbol ( 'MiniBrowserContentFactory' ) ; export type MiniBrowserContentFactory = ( props : MiniBrowserProps ) => MiniBrowserContent ; @ injectable ( ) export class MiniBrowserContent extends BaseWidget { @ inject ( ILogger ) protected readonly logger : ILogger ; @ inject ( WindowService ) protected readonly windowService : WindowService ; @ inject ( LocationMapperService ) protected readonly locationMapper : LocationMapperService ; @ inject ( KeybindingRegistry ) protected readonly keybindings : KeybindingRegistry ; @ inject ( ApplicationShellMouseTracker ) protected readonly mouseTracker : ApplicationShellMouseTracker ; @ inject ( FileService ) protected readonly fileService : FileService ; protected readonly submitInputEmitter = new Emitter < string > ( ) ; protected readonly navigateBackEmitter = new Emitter < void > ( ) ; protected readonly navigateForwardEmitter = new Emitter < void > ( ) ; protected readonly refreshEmitter = new Emitter < void > ( ) ; protected readonly openEmitter = new Emitter < void > ( ) ; protected readonly input : HTMLInputElement ; protected readonly loadIndicator : HTMLElement ; protected readonly errorBar : HTMLElement & Readonly < { message : HTMLElement ; } > ; protected readonly frame : HTMLIFrameElement ; protected readonly transparentOverlay : HTMLElement ; protected readonly pdfContainer : HTMLElement ; protected frameLoadTimeout : number ; protected readonly initialHistoryLength : number ; protected readonly toDisposeOnGo = new DisposableCollection ( ) ; constructor ( @ inject ( MiniBrowserProps ) protected readonly props ) { super ( ) ; this . node . tabIndex = 0 ; this . addClass ( MiniBrowserContentStyle . MINI_BROWSER ) ; this . input = this . createToolbar ( this . node ) . input ; const contentArea = this . createContentArea ( this . node ) ; this . frame = contentArea . frame ; this . transparentOverlay = contentArea . transparentOverlay ; this . loadIndicator = contentArea . loadIndicator ; this . errorBar = contentArea . errorBar ; this . pdfContainer = contentArea . pdfContainer ; this . initialHistoryLength = history . length ; this . toDispose . pushAll ( [ this . submitInputEmitter , this . navigateBackEmitter , this . navigateForwardEmitter , this . refreshEmitter , this . openEmitter ] ) ; } @ postConstruct ( ) protected init ( ) { this . toDispose . push ( this . mouseTracker . onMousedown ( e => { if ( this . frame . style . display !== 'none' ) { this . transparentOverlay . style . display = 'block' ; } } ) ) ; this . toDispose . push ( this . mouseTracker . onMouseup ( e => { if ( this . frame . style . display !== 'none' ) { this . transparentOverlay . style . display = 'none' ; } } ) ) ; const { startPage } = this . props ; if ( startPage ) { setTimeout ( ( ) => this . go ( startPage ) , 500 ) ; this . listenOnContentChange ( startPage ) ; } } protected onActivateRequest ( msg ) { super . onActivateRequest ( msg ) ; if ( this . getToolbarProps ( ) !== 'hide' ) { this . input . focus ( ) ; } else { this . node . focus ( ) ; } } protected async listenOnContentChange ( location ) { if ( await this . fileService . exists ( new URI ( location ) ) ) { const fileUri = new URI ( location ) ; const watcher = this . fileService . watch ( fileUri ) ; this . toDispose . push ( watcher ) ; const onFileChange = ( event ) => { if ( event . contains ( fileUri , FileChangeType . ADDED ) || event . contains ( fileUri , FileChangeType . UPDATED ) ) { this . go ( location , { showLoadIndicator : false } ) ; } } ; this . toDispose . push ( this . fileService . onDidFilesChange ( debounce ( onFileChange , 500 ) ) ) ; } } protected createToolbar ( parent ) { const toolbar = document . createElement ( 'div' ) ; toolbar . classList . add ( this . getToolbarProps ( ) === 'read-only' ? MiniBrowserContentStyle . TOOLBAR_READ_ONLY : MiniBrowserContentStyle . TOOLBAR ) ; parent . appendChild ( toolbar ) ; this . createPrevious ( toolbar ) ; this . createNext ( toolbar ) ; this . createRefresh ( toolbar ) ; const input = this . createInput ( toolbar ) ; input . readOnly = this . getToolbarProps ( ) === 'read-only' ; this . createOpen ( toolbar ) ; if ( this . getToolbarProps ( ) === 'hide' ) { toolbar . style . display = 'none' ; } return Object . assign ( toolbar , { input } ) ; } protected getToolbarProps ( ) { return ! this . props . startPage ? 'show' : this . props . toolbar || 'show' ; } protected createContentArea ( parent ) { const contentArea = document . createElement ( 'div' ) ; contentArea . classList . add ( MiniBrowserContentStyle . CONTENT_AREA ) ; const loadIndicator = document . createElement ( 'div' ) ; loadIndicator . classList . add ( MiniBrowserContentStyle . PRE_LOAD ) ; loadIndicator . style . display = 'none' ; const errorBar = this . createErrorBar ( ) ; const frame = this . createIFrame ( ) ; this . submitInputEmitter . event ( input => this . go ( input , { preserveFocus : false } ) ) ; this . navigateBackEmitter . event ( this . handleBack . bind ( this ) ) ; this . navigateForwardEmitter . event ( this . handleForward . bind ( this ) ) ; this . refreshEmitter . event ( this . handleRefresh . bind ( this ) ) ; this . openEmitter . event ( this . handleOpen . bind ( this ) ) ; const transparentOverlay = document . createElement ( 'div' ) ; transparentOverlay . classList . add ( MiniBrowserContentStyle . TRANSPARENT_OVERLAY ) ; transparentOverlay . style . display = 'none' ; const pdfContainer = document . createElement ( 'div' ) ; pdfContainer . classList . add ( MiniBrowserContentStyle . PDF_CONTAINER ) ; pdfContainer . id = `${ this . id }-pdf-container` ; pdfContainer . style . display = 'none' ; contentArea . appendChild ( errorBar ) ; contentArea . appendChild ( transparentOverlay ) ; contentArea . appendChild ( pdfContainer ) ; contentArea . appendChild ( loadIndicator ) ; contentArea . appendChild ( frame ) ; parent . appendChild ( contentArea ) ; return Object . assign ( contentArea , { frame , loadIndicator , errorBar , pdfContainer , transparentOverlay } ) ; } protected createIFrame ( ) { const frame = document . createElement ( 'iframe' ) ; const sandbox = ( this . props . sandbox || MiniBrowserProps . SandboxOptions . DEFAULT ) . map ( name => MiniBrowserProps . SandboxOptions [ name ] ) ; frame . sandbox . add ( ... sandbox ) ; this . toDispose . push ( addEventListener ( frame , 'load' , this . onFrameLoad . bind ( this ) ) ) ; this . toDispose . push ( addEventListener ( frame , 'error' , this . onFrameError . bind ( this ) ) ) ; return frame ; } protected createErrorBar ( ) { const errorBar = document . createElement ( 'div' ) ; errorBar . classList . add ( MiniBrowserContentStyle . ERROR_BAR ) ; errorBar . style . display = 'none' ; const icon = document . createElement ( 'span' ) ; icon . classList . add ( ... codiconArray ( 'info' ) ) ; errorBar . appendChild ( icon ) ; const message = document . createElement ( 'span' ) ; errorBar . appendChild ( message ) ; return Object . assign ( errorBar , { message } ) ; } protected onFrameLoad ( ) { clearTimeout ( this . frameLoadTimeout ) ; this . maybeResetBackground ( ) ; this . hideLoadIndicator ( ) ; this . hideErrorBar ( ) ; } protected onFrameError ( ) { clearTimeout ( this . frameLoadTimeout ) ; this . maybeResetBackground ( ) ; this . hideLoadIndicator ( ) ; this . showErrorBar ( 'An error occurred while loading this page' ) ; } protected onFrameTimeout ( ) { clearTimeout ( this . frameLoadTimeout ) ; this . maybeResetBackground ( ) ; this . hideLoadIndicator ( ) ; this . showErrorBar ( 'Still loading...' ) ; } protected showLoadIndicator ( ) { this . loadIndicator . classList . remove ( MiniBrowserContentStyle . FADE_OUT ) ; this . loadIndicator . style . display = 'block' ; } protected hideLoadIndicator ( ) { this . loadIndicator . classList . add ( MiniBrowserContentStyle . FADE_OUT ) ; const preloadStyle = window . getComputedStyle ( this . loadIndicator ) ; const transitionDuration = parseCssTime ( preloadStyle . transitionDuration , 0 ) ; setTimeout ( ( ) => { if ( this . loadIndicator . classList . contains ( MiniBrowserContentStyle . FADE_OUT ) ) { this . loadIndicator . style . display = 'none' ; this . loadIndicator . classList . remove ( MiniBrowserContentStyle . FADE_OUT ) ; } } , transitionDuration ) ; } protected showErrorBar ( message ) { this . errorBar . message . textContent = message ; this . errorBar . style . display = 'block' ; } protected hideErrorBar ( ) { this . errorBar . message . textContent = '' ; this . errorBar . style . display = 'none' ; } protected maybeResetBackground ( ) { if ( this . props . resetBackground === true ) { this . frame . style . backgroundColor = 'white' ; } } protected handleBack ( ) { if ( history . length - this . initialHistoryLength > 0 ) { history . back ( ) ; } } protected handleForward ( ) { if ( history . length > this . initialHistoryLength ) { history . forward ( ) ; } } protected handleRefresh ( ) { let location = this . props . startPage ; if ( this . input && this . input . value ) { location = this . input . value ; } try { const { contentDocument } = this . frame ; if ( contentDocument && contentDocument . location ) { location = contentDocument . location . href ; } } catch { } if ( location ) { this . go ( location , { preserveFocus : false } ) ; } } protected handleOpen ( ) { const location = this . frameSrc ( ) || this . input . value ; if ( location ) { this . windowService . openNewWindow ( location ) ; } } protected createInput ( parent ) { const input = document . createElement ( 'input' ) ; input . type = 'text' ; input . spellcheck = false ; input . classList . add ( 'theia-input' ) ; this . toDispose . pushAll ( [ addEventListener ( input , 'keydown' , this . handleInputChange . bind ( this ) ) , addEventListener ( input , 'click' , ( ) => { if ( this . getToolbarProps ( ) === 'read-only' ) { this . handleOpen ( ) ; } else { if ( input . value ) { input . select ( ) ; } } } ) ] ) ; parent . appendChild ( input ) ; return input ; } protected handleInputChange ( e ) { const { key } = KeyCode . createKeyCode ( e ) ; if ( key && Key . ENTER . keyCode === key . keyCode && this . getToolbarProps ( ) === 'show' ) { const { target } = e ; if ( target instanceof HTMLInputElement ) { this . mapLocation ( target . value ) . then ( location => this . submitInputEmitter . fire ( location ) ) ; } } } protected createPrevious ( parent ) { return this . onClick ( this . createButton ( parent , 'Show The Previous Page' , MiniBrowserContentStyle . PREVIOUS ) , this . navigateBackEmitter ) ; } protected createNext ( parent ) { return this . onClick ( this . createButton ( parent , 'Show The Next Page' , MiniBrowserContentStyle . NEXT ) , this . navigateForwardEmitter ) ; } protected createRefresh ( parent ) { return this . onClick ( this . createButton ( parent , 'Reload This Page' , MiniBrowserContentStyle . REFRESH ) , this . refreshEmitter ) ; } protected createOpen ( parent ) { const button = this . onClick ( this . createButton ( parent , 'Open In A New Window' , MiniBrowserContentStyle . OPEN ) , this . openEmitter ) ; return button ; } protected createButton ( parent , title , ... className ) { const button = document . createElement ( 'div' ) ; button . title = title ; button . classList . add ( ... className , MiniBrowserContentStyle . BUTTON ) ; parent . appendChild ( button ) ; return button ; } protected onClick ( element , emitter ) { this . toDispose . push ( addEventListener ( element , 'click' , ( ) => { if ( ! element . classList . contains ( MiniBrowserContentStyle . DISABLED ) ) { emitter . fire ( undefined ) ; } } ) ) ; return element ; } protected mapLocation ( location ) { return this . locationMapper . map ( location ) ; } protected setInput ( value ) { if ( this . input . value !== value ) { this . input . value = value ; } } protected frameSrc ( ) { let src = this . frame . src ; try { const { contentWindow } = this . frame ; if ( contentWindow ) { src = contentWindow . location . href ; } } catch { } if ( src === 'about:blank' ) { src = '' ; } return src ; } protected contentDocument ( ) { try { let { contentDocument } = this . frame ; if ( contentDocument === null ) { const { contentWindow } = this . frame ; if ( contentWindow ) { contentDocument = contentWindow . document ; } } return contentDocument ; } catch { return null ; } } protected async go ( location , options ? ) { const { showLoadIndicator , preserveFocus } = { showLoadIndicator : true , preserveFocus : true , ... options } ; if ( location ) { try { this . toDisposeOnGo . dispose ( ) ; const url = await this . mapLocation ( location ) ; this . setInput ( url ) ; if ( this . getToolbarProps ( ) === 'read-only' ) { this . input . title = `Open ${ url } In A New Window` ; } clearTimeout ( this . frameLoadTimeout ) ; this . frameLoadTimeout = window . setTimeout ( this . onFrameTimeout . bind ( this ) , 4000 ) ; if ( showLoadIndicator ) { this . showLoadIndicator ( ) ; } if ( url . endsWith ( '.pdf' ) ) { this . pdfContainer . style . display = 'block' ; this . frame . style . display = 'none' ; PDFObject . embed ( url , this . pdfContainer , { fallbackLink : `<p style="padding: 0px 15px 0px 15px">Your browser does not support inline PDFs. Click on this <a href='[url]' target="_blank">link</a> to open the PDF in a new tab.</p>` } ) ; clearTimeout ( this . frameLoadTimeout ) ; this . hideLoadIndicator ( ) ; if ( ! preserveFocus ) { this . pdfContainer . focus ( ) ; } } else { this . pdfContainer . style . display = 'none' ; this . frame . style . display = 'block' ; this . frame . src = url ; if ( ! preserveFocus ) { this . frame . addEventListener ( 'load' , ( ) => { const window = this . frame . contentWindow ; if ( window ) { window . focus ( ) ; } } , { once : true } ) ; } } this . toDisposeOnGo . push ( addEventListener ( this . frame , 'load' , ( ) => { try { const { contentDocument } = this . frame ; if ( contentDocument ) { const keypressHandler = ( e ) => this . keybindings . run ( e ) ; contentDocument . addEventListener ( 'keypress' , keypressHandler , true ) ; this . toDisposeOnDetach . push ( Disposable . create ( ( ) => contentDocument . removeEventListener ( 'keypress' , keypressHandler ) ) ) ; } } catch { } } ) ) ; } catch ( e ) { clearTimeout ( this . frameLoadTimeout ) ; this . hideLoadIndicator ( ) ; this . showErrorBar ( String ( e ) ) ; console . log ( e ) ; } } } }