From 678f2e666afb8df815ef2bf23e16de7c4a1eb799 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Thu, 18 Jun 2020 11:57:43 +0200 Subject: [PATCH] MOBILE-3401 ios: Fix iframe links and window.open in iOS --- .../mod/h5pactivity/components/index/index.ts | 2 +- src/assets/js/iframe-treat-links.js | 210 ++++++++++++++++++ src/components/iframe/iframe.ts | 3 +- src/core/h5p/assets/moodle/js/embed.js | 3 +- src/providers/file.ts | 16 +- src/providers/utils/iframe.ts | 175 ++++++++++----- 6 files changed, 347 insertions(+), 62 deletions(-) create mode 100644 src/assets/js/iframe-treat-links.js diff --git a/src/addon/mod/h5pactivity/components/index/index.ts b/src/addon/mod/h5pactivity/components/index/index.ts index 0244d338f..2dc01a2d5 100644 --- a/src/addon/mod/h5pactivity/components/index/index.ts +++ b/src/addon/mod/h5pactivity/components/index/index.ts @@ -407,7 +407,7 @@ export class AddonModH5PActivityIndexComponent extends CoreCourseModuleMainActiv * @return Whether it's an XAPI post statement of the current activity. */ protected isCurrentXAPIPost(data: any): boolean { - if (data.context != 'moodleapp' || data.action != 'xapi_post_statement' || !data.statements) { + if (data.environment != 'moodleapp' || data.context != 'h5p' || data.action != 'xapi_post_statement' || !data.statements) { return false; } diff --git a/src/assets/js/iframe-treat-links.js b/src/assets/js/iframe-treat-links.js new file mode 100644 index 000000000..68f83571b --- /dev/null +++ b/src/assets/js/iframe-treat-links.js @@ -0,0 +1,210 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +(function () { + var url = location.href; + + if (url.match(/^moodleappfs:\/\/localhost/i) || !url.match(/^[a-z0-9]+:\/\//i)) { + // Same domain as the app, stop. + return; + } + + // Redefine window.open. + window.open = function(url, name, specs) { + if (name == '_self') { + // Link should be loaded in the same frame. + location.href = toAbsolute(url); + + return; + } + + getRootWindow(window).postMessage({ + environment: 'moodleapp', + context: 'iframe', + action: 'window_open', + frameUrl: location.href, + url: url, + name: name, + specs: specs, + }, '*'); + }; + + // Handle link clicks. + document.addEventListener('click', (event) => { + if (event.defaultPrevented) { + // Event already prevented by some other code. + return; + } + + // Find the link being clicked. + var el = event.target; + while (el && el.tagName !== 'A') { + el = el.parentElement; + } + + if (!el || el.treated) { + return; + } + + // Add click listener to the link, this way if the iframe has added a listener to the link it will be executed first. + el.treated = true; + el.addEventListener('click', function(event) { + linkClicked(el, event); + }); + }, { + capture: true // Use capture to fix this listener not called if the element clicked is too deep in the DOM. + }); + + + + /** + * Concatenate two paths, adding a slash between them if needed. + * + * @param leftPath Left path. + * @param rightPath Right path. + * @return Concatenated path. + */ + function concatenatePaths(leftPath, rightPath) { + if (!leftPath) { + return rightPath; + } else if (!rightPath) { + return leftPath; + } + + var lastCharLeft = leftPath.slice(-1); + var firstCharRight = rightPath.charAt(0); + + if (lastCharLeft === '/' && firstCharRight === '/') { + return leftPath + rightPath.substr(1); + } else if (lastCharLeft !== '/' && firstCharRight !== '/') { + return leftPath + '/' + rightPath; + } else { + return leftPath + rightPath; + } + } + + /** + * Get the root window. + * + * @param win Current window to check. + * @return Root window. + */ + function getRootWindow(win) { + if (win.parent === win) { + return win; + } + + return getRootWindow(win.parent); + } + + /** + * Get the scheme from a URL. + * + * @param url URL to treat. + * @return Scheme, undefined if no scheme found. + */ + function getUrlScheme(url) { + if (!url) { + return; + } + + var matches = url.match(/^([a-z][a-z0-9+\-.]*):/); + if (matches && matches[1]) { + return matches[1]; + } + } + + /** + * Check if a URL is absolute. + * + * @param url URL to treat. + * @return Whether it's absolute. + */ + function isAbsoluteUrl(url) { + return /^[^:]{2,}:\/\//i.test(url); + } + + /** + * Check whether a URL scheme belongs to a local file. + * + * @param scheme Scheme to check. + * @return Whether the scheme belongs to a local file. + */ + function isLocalFileUrlScheme(scheme) { + if (scheme) { + scheme = scheme.toLowerCase(); + } + + return scheme == 'cdvfile' || + scheme == 'file' || + scheme == 'filesystem' || + scheme == 'moodleappfs'; + } + + /** + * Handle a click on an anchor element. + * + * @param link Anchor element clicked. + * @param event Click event. + */ + function linkClicked(link, event) { + if (event.defaultPrevented) { + // Event already prevented by some other code. + return; + } + + var linkScheme = getUrlScheme(link.href); + var pageScheme = getUrlScheme(location.href); + var isTargetSelf = !link.target || link.target == '_self'; + + if (!link.href || linkScheme == 'javascript') { + // Links with no URL and Javascript links are ignored. + return; + } + + event.preventDefault(); + + if (isTargetSelf && (isLocalFileUrlScheme(linkScheme) || !isLocalFileUrlScheme(pageScheme))) { + // Link should be loaded in the same frame. Don't do it if link is online and frame is local. + location.href = toAbsolute(link.href); + + return; + } + + getRootWindow(window).postMessage({ + environment: 'moodleapp', + context: 'iframe', + action: 'link_clicked', + frameUrl: location.href, + link: {href: link.href, target: link.target}, + }, '*'); + } + + /** + * Convert a URL to an absolute URL if needed using the frame src. + * + * @param url URL to convert. + * @return Absolute URL. + */ + function toAbsolute(url) { + if (isAbsoluteUrl(url)) { + return url; + } + + // It's a relative URL, use the frame src to create the full URL. + var pathToDir = location.href.substring(0, location.href.lastIndexOf('/')); + + return concatenatePaths(pathToDir, url); + } +})(); \ No newline at end of file diff --git a/src/components/iframe/iframe.ts b/src/components/iframe/iframe.ts index dd77bd5e8..5806412bb 100644 --- a/src/components/iframe/iframe.ts +++ b/src/components/iframe/iframe.ts @@ -25,6 +25,7 @@ import { CoreIframeUtilsProvider } from '@providers/utils/iframe'; import { CoreUtilsProvider } from '@providers/utils/utils'; import { CoreSplitViewComponent } from '@components/split-view/split-view'; import { CoreUrl } from '@singletons/url'; +import { WKWebViewCookiesWindow } from 'cordova-plugin-wkwebview-cookies'; @Component({ selector: 'core-iframe', @@ -108,7 +109,7 @@ export class CoreIframeComponent implements OnChanges { if (this.platform.is('ios') && !this.urlUtils.isLocalFileUrl(url)) { // Save a "fake" cookie for the iframe's domain to fix a bug in WKWebView. try { - const win = window; + const win = window; const urlParts = CoreUrl.parse(url); await win.WKWebViewCookies.setCookie({ diff --git a/src/core/h5p/assets/moodle/js/embed.js b/src/core/h5p/assets/moodle/js/embed.js index 8df9b26a3..904eded89 100644 --- a/src/core/h5p/assets/moodle/js/embed.js +++ b/src/core/h5p/assets/moodle/js/embed.js @@ -80,7 +80,8 @@ H5PEmbedCommunicator = (function() { */ self.post = function(component, statements) { window.parent.postMessage({ - context: 'moodleapp', + environment: 'moodleapp', + context: 'h5p', action: 'xapi_post_statement', component: component, statements: statements, diff --git a/src/providers/file.ts b/src/providers/file.ts index e5d30996c..98dfd579c 100644 --- a/src/providers/file.ts +++ b/src/providers/file.ts @@ -1229,7 +1229,7 @@ export class CoreFileProvider { } /** - * Get the full path to the www folder at runtime. + * Get the path to the www folder at runtime based on the WebView URL. * * @return Path. */ @@ -1243,6 +1243,20 @@ export class CoreFileProvider { return window.location.href; } + /** + * Get the full path to the www folder. + * + * @return Path. + */ + getWWWAbsolutePath(): string { + if (cordova && cordova.file && cordova.file.applicationDirectory) { + return this.textUtils.concatenatePaths(cordova.file.applicationDirectory, 'www'); + } + + // Cannot use Cordova to get it, use the WebView URL. + return this.getWWWPath(); + } + /** * Helper function to call Ionic WebView convertFileSrc only in the needed platforms. * This is needed to make files work with the Ionic WebView plugin. diff --git a/src/providers/utils/iframe.ts b/src/providers/utils/iframe.ts index 551606ead..e477e7401 100644 --- a/src/providers/utils/iframe.ts +++ b/src/providers/utils/iframe.ts @@ -27,6 +27,7 @@ import { CoreUtilsProvider } from './utils'; import { CoreContentLinksHelperProvider } from '@core/contentlinks/providers/helper'; import { makeSingleton } from '@singletons/core.singletons'; import { CoreUrl } from '@singletons/url'; +import { WKUserScriptWindow } from 'cordova-plugin-wkuserscript'; /* * "Utils" service with helper functions for iframes, embed and similar. @@ -43,6 +44,19 @@ export class CoreIframeUtilsProvider { private translate: TranslateService, private network: Network, private zone: NgZone, private config: Config, private contentLinksHelper: CoreContentLinksHelperProvider) { this.logger = logger.getInstance('CoreUtilsProvider'); + + const win = window; + + if (platform.is('ios') && win.WKUserScript) { + platform.ready().then(() => { + // Inject code to the iframes because we cannot access the online ones. + const path = textUtils.concatenatePaths(fileProvider.getWWWAbsolutePath(), 'assets/js/iframe-treat-links.js'); + win.WKUserScript.addScript({id: 'CoreIframeUtilsScript', file: path}); + + // Handle post messages received by iframes. + window.addEventListener('message', this.handleIframeMessage.bind(this)); + }); + } } /** @@ -186,6 +200,30 @@ export class CoreIframeUtilsProvider { return { window: contentWindow, document: contentDocument }; } + /** + * Handle some iframe messages. + * + * @param event Message event. + */ + handleIframeMessage(event: MessageEvent): void { + if (!event.data || event.data.environment != 'moodleapp' || event.data.context != 'iframe') { + return; + } + + switch (event.data.action) { + case 'window_open': + this.windowOpen(event.data.url, event.data.name); + break; + + case 'link_clicked': + this.linkClicked(event.data.link); + break; + + default: + break; + } + } + /** * Redefine the open method in the contentWindow of an element and the sub frames. * Please notice that the element should be an iframe, embed or similar. @@ -198,55 +236,9 @@ export class CoreIframeUtilsProvider { redefineWindowOpen(element: any, contentWindow: Window, contentDocument: Document, navCtrl?: NavController): void { if (contentWindow) { // Intercept window.open. - contentWindow.open = (url: string, target: string): Window => { - const scheme = this.urlUtils.getUrlScheme(url); - if (!scheme) { - // It's a relative URL, use the frame src to create the full URL. - const src = element.src || element.data; - if (src) { - const dirAndFile = this.fileProvider.getFileAndDirectoryFromPath(src); - if (dirAndFile.directory) { - url = this.textUtils.concatenatePaths(dirAndFile.directory, url); - } else { - this.logger.warn('Cannot get iframe dir path to open relative url', url, element); + contentWindow.open = (url: string, name: string): Window => { + this.windowOpen(url, name, element, navCtrl); - return null; - } - } else { - this.logger.warn('Cannot get iframe src to open relative url', url, element); - - return null; - } - } - - if (target == '_self') { - // Link should be loaded in the same frame. - if (element.tagName.toLowerCase() == 'object') { - element.setAttribute('data', url); - } else { - element.setAttribute('src', url); - } - } else if (this.urlUtils.isLocalFileUrl(url)) { - // It's a local file. - this.utils.openFile(url).catch((error) => { - this.domUtils.showErrorModal(error); - }); - } else { - // It's an external link, check if it can be opened in the app. - this.contentLinksHelper.handleLink(url, undefined, navCtrl, true, true).then((treated) => { - if (!treated) { - // Not opened in the app, open with browser. Check if we need to auto-login - if (!this.sitesProvider.isLoggedIn()) { - // Not logged in, cannot auto-login. - this.utils.openInBrowser(url); - } else { - this.sitesProvider.getCurrentSite().openInBrowserWithAutoLoginIfSameSite(url); - } - } - }); - } - - // We cannot create new Window objects directly, return null which is a valid return value for Window.open(). return null; }; } @@ -329,21 +321,88 @@ export class CoreIframeUtilsProvider { // Add click listener to the link, this way if the iframe has added a listener to the link it will be executed first. link.treated = true; - link.addEventListener('click', this.linkClicked.bind(this, element, link)); + link.addEventListener('click', this.linkClicked.bind(this, link, element)); }, { capture: true // Use capture to fix this listener not called if the element clicked is too deep in the DOM. }); } + /** + * Handle a window.open called by a frame. + * + * @param url URL passed to window.open. + * @param name Name passed to window.open. + * @param element HTML element of the frame. + * @param navCtrl NavController to use if a link can be opened in the app. + * @return Promise resolved when done. + */ + protected async windowOpen(url: string, name: string, element?: any, navCtrl?: NavController): Promise { + const scheme = this.urlUtils.getUrlScheme(url); + if (!scheme) { + // It's a relative URL, use the frame src to create the full URL. + const src = element && (element.src || element.data); + if (src) { + const dirAndFile = this.fileProvider.getFileAndDirectoryFromPath(src); + if (dirAndFile.directory) { + url = this.textUtils.concatenatePaths(dirAndFile.directory, url); + } else { + this.logger.warn('Cannot get iframe dir path to open relative url', url, element); + + return; + } + } else { + this.logger.warn('Cannot get iframe src to open relative url', url, element); + + return; + } + } + + if (name == '_self') { + // Link should be loaded in the same frame. + if (!element) { + this.logger.warn('Cannot load URL in iframe because the element was not supplied', url); + + return; + } + + if (element.tagName.toLowerCase() == 'object') { + element.setAttribute('data', url); + } else { + element.setAttribute('src', url); + } + } else if (this.urlUtils.isLocalFileUrl(url)) { + // It's a local file. + try { + await this.utils.openFile(url); + } catch (error) { + this.domUtils.showErrorModal(error); + } + } else { + // It's an external link, check if it can be opened in the app. + const treated = await this.contentLinksHelper.handleLink(url, undefined, navCtrl, true, true); + + if (!treated) { + // Not opened in the app, open with browser. Check if we need to auto-login + if (!this.sitesProvider.isLoggedIn()) { + // Not logged in, cannot auto-login. + this.utils.openInBrowser(url); + } else { + await this.sitesProvider.getCurrentSite().openInBrowserWithAutoLoginIfSameSite(url); + } + } + } + } + /** * A link inside a frame was clicked. * + * @param link Data of the link clicked. * @param element Frame element. - * @param link Link clicked. * @param event Click event. */ - protected linkClicked(element: HTMLFrameElement | HTMLObjectElement, link: HTMLAnchorElement, event: Event): void { - if (event.defaultPrevented) { + protected linkClicked(link: {href: string, target?: string}, element?: HTMLFrameElement | HTMLObjectElement, event?: Event) + : void { + if (event && event.defaultPrevented) { // Event already prevented by some other code. return; } @@ -356,12 +415,12 @@ export class CoreIframeUtilsProvider { if (!this.urlUtils.isLocalFileUrlScheme(urlParts.protocol, urlParts.domain)) { // Scheme suggests it's an external resource. - event.preventDefault(); + event && event.preventDefault(); - const frameSrc = ( element).src || ( element).data; + const frameSrc = element && (( element).src || ( element).data); // If the frame is not local, check the target to identify how to treat the link. - if (!this.urlUtils.isLocalFileUrl(frameSrc) && (!link.target || link.target == '_self')) { + if (element && !this.urlUtils.isLocalFileUrl(frameSrc) && (!link.target || link.target == '_self')) { // Load the link inside the frame itself. if (element.tagName.toLowerCase() == 'object') { element.setAttribute('data', link.href); @@ -380,13 +439,13 @@ export class CoreIframeUtilsProvider { } } else if (link.target == '_parent' || link.target == '_top' || link.target == '_blank') { // Opening links with _parent, _top or _blank can break the app. We'll open it in InAppBrowser. - event.preventDefault(); + event && event.preventDefault(); this.utils.openFile(link.href).catch((error) => { this.domUtils.showErrorModal(error); }); - } else if (this.platform.is('ios') && (!link.target || link.target == '_self')) { + } else if (this.platform.is('ios') && (!link.target || link.target == '_self') && element) { // In cordova ios 4.1.0 links inside iframes stopped working. We'll manually treat them. - event.preventDefault(); + event && event.preventDefault(); if (element.tagName.toLowerCase() == 'object') { element.setAttribute('data', link.href); } else {