Merge "Using native window open" into tizen
[platform/framework/web/wrtjs.git] / wrt_app / src / web_application.ts
1 /*
2  * Copyright (c) 2019 Samsung Electronics Co., Ltd All Rights Reserved
3  *
4  *    Licensed under the Apache License, Version 2.0 (the "License");
5  *    you may not use this file except in compliance with the License.
6  *    You may obtain a copy of the License at
7  *
8  *        http://www.apache.org/licenses/LICENSE-2.0
9  *
10  *    Unless required by applicable law or agreed to in writing, software
11  *    distributed under the License is distributed on an "AS IS" BASIS,
12  *    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  *    See the License for the specific language governing permissions and
14  *    limitations under the License.
15  */
16
17 'use strict';
18
19 import { app, protocol } from 'electron';
20 import { wrt } from '../browser/wrt';
21 import * as WRTWebContents from '../browser/wrt_web_contents';
22 import { WRTWindow } from '../browser/wrt_window';
23 import { addonManager } from './addon_manager';
24
25 export class WebApplication {
26   accessiblePath?: string[];
27   backgroundExecution: boolean;
28   defaultBackgroundColor: string;
29   defaultTransparent: boolean;
30   isAlwaysReload: boolean;
31   mainWindow: Electron.BrowserWindow;
32   multitaskingSupport: boolean;
33   notificationPermissionMap?: Map<Electron.WebContents, boolean>;
34   preloadStatus: string;
35   showTimer?: NodeJS.Timeout;
36
37   backgroundSupport = wrt.getBackgroundSupport();
38   debugPort = 0;
39   firstRendered = false;
40   contentSrc = '';
41   inspectorSrc = '';
42   loadFinished = false;
43   pendingCallbacks: Map<number, any> = new Map();
44   pendingID = 0;
45   runningStatus = 'none';
46   suspended = false;
47   windowList: Electron.BrowserWindow[] = [];
48   inQuit = false;
49
50   constructor(options: RuntimeOption) {
51     if (options.launchMode == 'backgroundAtStartup') {
52       console.log('backgroundAtStartup');
53       this.preloadStatus = 'preload';
54     } else {
55       this.preloadStatus = 'none';
56     }
57     if (options.launchMode == 'backgroundExecution') {
58       console.log('backgroundExecution');
59       this.backgroundExecution = true;
60     } else {
61       this.backgroundExecution = false;
62     }
63     this.accessiblePath = wrt.tv?.getAccessiblePath();
64     this.isAlwaysReload = (wrt.tv ? wrt.tv.isAlwaysReload() : false);
65     this.multitaskingSupport = (wrt.tv ? wrt.tv.getMultitaskingSupport() : true);
66     this.defaultBackgroundColor = (wrt.tv ? '#0000' :
67         ((wrt.getPlatformType() === "product_wearable") ? '#000' : '#FFF'));
68     this.defaultTransparent = (wrt.tv ? true : false);
69
70     this.setupEventListener(options);
71
72     this.mainWindow = new WRTWindow(this.getWindowOption(options));
73     this.initDisplayDelay(true);
74     this.setupMainWindowEventListener();
75   }
76
77   private setupEventListener(options: RuntimeOption) {
78     app.on('browser-window-created', (event: any, window: any) => {
79       if (this.windowList.length > 0)
80         this.windowList[this.windowList.length - 1].hide();
81       this.windowList.push(window);
82       console.log(`window created : #${this.windowList.length}`);
83
84       window.on('closed', () => {
85         console.log(`window closed : #${this.windowList.length}`);
86         let index = this.windowList.indexOf(window);
87         this.windowList.splice(index, 1);
88         if (index === this.windowList.length && this.windowList.length > 0)
89           this.windowList[this.windowList.length - 1].show();
90       });
91     });
92
93     app.on('web-contents-created', (event: any, webContents: any) => {
94       webContents.on('crashed', function() {
95         console.error('webContents crashed');
96         app.exit(100);
97       });
98
99       webContents.session.setPermissionRequestHandler((webContents: any, permission: string, callback: any) => {
100         console.log(`handlePermissionRequests for ${permission}`);
101         if (permission === 'notifications') {
102           if (!this.notificationPermissionMap)
103             this.notificationPermissionMap = new Map();
104           else if (this.notificationPermissionMap.has(webContents)) {
105             process.nextTick(callback, this.notificationPermissionMap.get(webContents));
106             return;
107           }
108           const id = ++this.pendingID;
109           console.log(`Raising a notification permission request with id: ${id}`);
110           this.pendingCallbacks.set(id, (result: boolean) => {
111             (this.notificationPermissionMap as Map<Electron.WebContents, boolean>).set(webContents, result);
112             callback(result);
113           });
114           wrt.handleNotificationPermissionRequest(id, webContents);
115         } else if (permission === 'media') {
116           const id = ++this.pendingID;
117           console.log(`Raising a media permission request with id: ${id}`);
118           this.pendingCallbacks.set(id, callback);
119           wrt.handleMediaPermissionRequest(id, webContents);
120         } else if (permission === 'geolocation') {
121           const id = ++this.pendingID;
122           console.log(`Raising a geolocation permission request with id: ${id}`);
123           this.pendingCallbacks.set(id, callback);
124           wrt.handleGeolocationPermissionRequest(id, webContents);
125         } else {
126           /* electron by default allows permission for all if no request handler
127              is there; so granting permission only temporarily to not have any
128              side effects */
129           callback(true);
130         }
131       });
132     });
133
134     app.on('certificate-error', (event: any, webContents: any, url: string, error: string, certificate: any, callback: any) => {
135       console.log('A certificate error has occurred');
136       event.preventDefault();
137       if (certificate.data) {
138         const id = ++this.pendingID;
139         console.log(`Raising a certificate error response with id: ${id}`);
140         this.pendingCallbacks.set(id, callback);
141         wrt.handleCertificateError(id, webContents, certificate.data, url, error);
142       } else {
143         console.log('Certificate could not be opened');
144         callback(false);
145       }
146     });
147
148     app.on('login', (event: any, webContents: any, request: any, authInfo: any, callback: any) => {
149       console.log(`Login info is required, isproxy: ${authInfo.isProxy}`);
150       event.preventDefault();
151       let usrname = '';
152       let passwd = '';
153       if (wrt.tv && authInfo.isProxy) {
154         let vconfProxy = wrt.tv.getProxy();
155         if (vconfProxy) {
156           let proxyInfo = new URL(vconfProxy);
157           usrname = proxyInfo.username;
158           passwd = proxyInfo.password;
159         }
160         if (usrname && passwd) {
161           callback(usrname, passwd);
162         } else {
163           console.log('Login, but usrname and passwd is empty!!!');
164           callback('', '');
165         }
166       } else {
167         const id = ++this.pendingID;
168         console.log(`Raising a login info request with id: ${id}`);
169         this.pendingCallbacks.set(id, callback);
170         wrt.handleAuthRequest(id, webContents);
171       }
172     });
173
174     if (this.accessiblePath) {
175       console.log(`accessiblePath: ${this.accessiblePath}`);
176       protocol.interceptFileProtocol('file', (request: any, callback: any) => {
177         if (request.url) {
178           let parsed_info = new URL(request.url);
179           let access_path = parsed_info.host + decodeURI(parsed_info.pathname);
180           console.log(`check path: : ${access_path}`);
181           for (let path of (this.accessiblePath as string[])) {
182             if (access_path.startsWith(path)) {
183               callback(access_path);
184               return;
185             }
186           }
187           if (access_path.indexOf("/shared/res/") > -1) {
188             callback(access_path);
189             return;
190           }
191           else {
192             console.log(`invalid accesspath: ${access_path}`);
193             (callback as any)(403);
194           }
195         } else {
196           console.log('request url is empty');
197           (callback as any)(403);
198         }
199       }, (error: Error) => {
200         console.log(error);
201       });
202     }
203
204     wrt.on('permission-response', (event: any, id: number, result: boolean) => {
205       console.log(`permission-response for ${id} is ${result}`);
206       let callback = this.pendingCallbacks.get(id);
207       if (typeof callback === 'function') {
208         console.log('calling permission response callback');
209         callback(result);
210         this.pendingCallbacks.delete(id);
211       }
212     });
213
214     wrt.on('auth-response', (event: any, id: number, submit: boolean, user: string, password: string) => {
215       let callback = this.pendingCallbacks.get(id);
216       if (typeof callback === 'function') {
217         console.log('calling auth response callback');
218         if (submit)
219           callback(user, password);
220         else
221           callback();
222         this.pendingCallbacks.delete(id);
223       }
224     });
225
226     wrt.on('app-status-changed', (event: any, status: string) => {
227       console.log(`runningStatus: ${status}, ${this.loadFinished}`);
228       if (!wrt.tv)
229         return;
230       this.runningStatus = status;
231       if (this.runningStatus === 'DialogClose' && this.inspectorSrc) {
232         console.log(`runningStatus is DialogClose, src is ${this.inspectorSrc}`);
233         this.mainWindow.loadURL(this.inspectorSrc);
234         this.inspectorSrc = '';
235       } else if (this.runningStatus == 'behind' && this.loadFinished) {
236         // TODO : Need to care this situation and decide to pass the addon event emitter to suspend()
237         this.suspend();
238       }
239     });
240   }
241
242   private getWindowOption(options: RuntimeOption): NativeWRTjs.WRTWindowConstructorOptions {
243     return {
244       fullscreen: false,
245       backgroundColor: this.defaultBackgroundColor,
246       transparent: this.defaultTransparent,
247       show: false,
248       webPreferences: {
249         nodeIntegration: options.isAddonAvailable,
250         nodeIntegrationInWorker: false,
251         nativeWindowOpen: true,
252       },
253       webContents: WRTWebContents.create(),
254     };
255   }
256
257   private setupMainWindowEventListener() {
258     this.mainWindow.once('ready-to-show', () => {
259       console.log('mainWindow ready-to-show');
260       if (this.showTimer)
261         clearTimeout(this.showTimer);
262       wrt.hideSplashScreen(0);
263       this.firstRendered = true;
264       if (this.preloadStatus == 'preload') {
265         this.preloadStatus = 'readyToShow';
266         console.log('preloading show is skipped!');
267         return;
268       }
269       this.show();
270     });
271
272     this.mainWindow.webContents.on('did-start-loading', () => {
273       console.log('webContents did-start-loading');
274       this.loadFinished = false;
275     });
276
277     this.mainWindow.webContents.on('did-finish-load', () => {
278       console.log('webContents did-finish-load');
279       this.loadFinished = true;
280       wrt.hideSplashScreen(1);
281       if (wrt.isIMEWebApp()) {
282         this.activateIMEWebHelperClient();
283       } else if (wrt.tv) {
284         if (this.inspectorSrc)
285           this.showInspectorGuide();
286         else
287           this.suspendByStatus();
288       }
289       addonManager.emit('contentDidFinishLoad', this.mainWindow.id);
290     });
291   }
292
293   private initDisplayDelay(firstLaunch: boolean) {
294     // TODO: On 6.0, this causes a black screen on relaunch
295     if (firstLaunch)
296       this.firstRendered = false;
297     this.suspended = false;
298     if (this.showTimer)
299       clearTimeout(this.showTimer);
300     let splashShown = this.preloadStatus !== 'preload' && firstLaunch && wrt.showSplashScreen();
301     if (!splashShown && !wrt.tv) {
302       this.showTimer = setTimeout(() => {
303         if (!this.suspended) {
304           console.log('FrameRendered not obtained from engine. To show window, timer fired');
305           this.mainWindow.emit('ready-to-show');
306         }
307       }, 2000);
308     }
309     if (!firstLaunch && !this.backgroundRunnable())
310       this.mainWindow.setEnabled(true);
311   }
312
313   private backgroundRunnable(): boolean {
314     return this.backgroundSupport || this.backgroundExecution;
315   }
316
317   private suspendByStatus() {
318     if (this.preloadStatus === 'readyToShow' ||
319         this.preloadStatus === 'preload' ||
320         this.runningStatus === 'behind') {
321       console.log('WebApplication : suspendByStatus');
322       console.log(`preloadStatus: ${this.preloadStatus}, runningStatus: ${this.runningStatus}`);
323       // TODO : Need to care this situation and decide to pass the addon event emitter to suspend()
324       this.suspend();
325       if (this.runningStatus !== 'behind')
326         (wrt.tv as NativeWRTjs.TVExtension).notifyAppStatus('preload');
327     }
328   }
329
330   private showInspectorGuide() {
331     console.log('WebApplication : showInspectorGuide');
332     this.showInspectorGuide = () => {}; // call once
333     const message = `${this.debugPort.toString()}
334 Fast RWI is used, [about:blank] is loaded fist instead of
335 [${this.inspectorSrc}]
336 Click OK button will start the real loading.
337 Notes:
338 Please connect to RWI in PC before click OK button.
339 Then you can get network log from the initial loading.
340 Please click Record button in Timeline panel in PC before click OK button,
341 Then you can get profile log from the initial loading.`;
342     let tv = wrt.tv as NativeWRTjs.TVExtension;
343     tv.showDialog(this.mainWindow.webContents, message);
344
345     if (this.preloadStatus !== 'none') {
346       setTimeout(() => {
347         tv.cancelDialogs(this.mainWindow.webContents);
348       }, 5000);
349     }
350   }
351
352   handleAppControlEvent(appControl: any) {
353     let launchMode = appControl.getData('http://samsung.com/appcontrol/data/launch_mode');
354     this.handlePreloadState(launchMode);
355
356     let skipReload = appControl.getData('SkipReload');
357     if (skipReload == 'Yes') {
358       console.log('skipping reload');
359       // TODO : Need to care this situation and decide to pass the addon event emitter to resume()
360       this.resume();
361       return;
362     }
363
364     let loadInfo = appControl.getLoadInfo();
365     let src = loadInfo.getSrc();
366     let reload = loadInfo.getReload() || this.needReload(src);
367     // handle http://tizen.org/appcontrol/operation/main operation specially.
368     // only menu-screen app can send launch request with main operation.
369     // in this case, web app should have to resume web app not reset.
370     if (reload && appControl.getOperation() == 'http://tizen.org/appcontrol/operation/main')
371       reload = false;
372     if (reload)
373       this.handleAppControlReload(src);
374     else
375       this.sendAppControlEvent();
376   }
377
378   private launchInspectorIfNeeded(appControl: NativeWRTjs.AppControl) {
379     console.log('launchInspectorIfNeeded');
380     let inspectorEnabledByVconf = wrt.tv ? wrt.tv.needUseInspector() : false;
381     if (inspectorEnabledByVconf && !this.backgroundExecution) {
382       this.inspectorSrc = this.contentSrc;
383       this.contentSrc = 'about:blank';
384     }
385     let hasAulDebug = (appControl.getData('__AUL_DEBUG__') === '1');
386     if (hasAulDebug || inspectorEnabledByVconf) {
387       let debugPort = wrt.startInspectorServer();
388       let data = { "port": [debugPort.toString()] };
389       this.debugPort = debugPort;
390       appControl.reply(data);
391     }
392   }
393
394   loadUrl(appControl: NativeWRTjs.AppControl) {
395     this.contentSrc = appControl.getLoadInfo().getSrc();
396     this.launchInspectorIfNeeded(appControl);
397     this.mainWindow.loadURL(this.contentSrc);
398     this.prelaunch(this.contentSrc);
399     if (wrt.da) {
400       this.mainWindow.emit('ready-to-show');
401     }
402   }
403
404   suspend() {
405     if (this.suspended || this.inQuit)
406       return;
407     console.log('WebApplication : suspend');
408     addonManager.emit('lcSuspend', this.mainWindow.id);
409     this.suspended = true;
410     this.windowList[this.windowList.length - 1].hide();
411     this.flushData();
412     if (!this.backgroundRunnable()) {
413       if (!this.multitaskingSupport) {
414         // FIXME : terminate app after visibilitychange event handling
415         setTimeout(() => {
416           console.log('multitasking is not supported; quitting app')
417           app.quit();
418         }, 1000);
419       } else {
420         this.windowList.forEach((window) => window.setEnabled(false));
421       }
422     }
423   }
424
425   resume() {
426     console.log('WebApplication : resume');
427     this.suspended = false;
428     addonManager.emit('lcResume', this.mainWindow.id);
429
430     if (!this.firstRendered) {
431       console.log('WebApplication : resume firstRendered is false');
432       return;
433     }
434     if (!this.backgroundRunnable())
435       this.windowList.forEach((window) => window.setEnabled(true));
436     this.windowList[this.windowList.length - 1].show();
437   }
438
439   quit() {
440     console.log('WebApplication : quit');
441     this.flushData();
442     this.windowList.forEach((window) => window.removeAllListeners());
443     this.inQuit = false;
444     if (!this.suspended)
445       this.suspend();
446   }
447
448   beforeQuit() {
449     console.log('WebApplication : beforeQuit');
450     addonManager.emit('lcQuit', this.mainWindow.id);
451     if (wrt.tv) {
452       this.inspectorSrc = '';
453       wrt.tv.cancelDialogs(this.mainWindow.webContents);
454     }
455     if (this.debugPort) {
456       console.log('stop inspector server');
457       this.debugPort = 0;
458       wrt.stopInspectorServer();
459     }
460     this.inQuit = true;
461   }
462
463   private needReload(src: string) {
464     if (this.isAlwaysReload) {
465       return true;
466     }
467     let reload = false;
468     let originalUrl = this.mainWindow.webContents.getURL();
469     if (wrt.tv) {
470       console.log(`appcontrol src = ${src}, original url = ${originalUrl}`);
471       if (src && originalUrl) {
472         let appcontrolUrl = (new URL(src)).href;
473         let oldUrl = (new URL(originalUrl)).href;
474         console.log(`appcontrolUrl = ${appcontrolUrl}, oldUrl = ${oldUrl}`);
475         // FIXME(dh81.song)
476         // Below case it must be distinguishable for known cases
477         //   from 'file:///index.htmlx' to 'file:///index.html'
478         if (appcontrolUrl !== oldUrl.substr(0, appcontrolUrl.length))
479           reload = true;
480       } else {
481         reload = true;
482       }
483     } else if (src !== originalUrl) {
484       reload = true;
485     }
486     return reload;
487   }
488
489   private handleAppControlReload(url: string) {
490     console.log('WebApplication : handleAppControlReload');
491     this.closeWindows();
492     this.initDisplayDelay(false);
493     this.mainWindow.loadURL(url);
494   }
495
496   private handlePreloadState(launchMode: string) {
497     if (this.preloadStatus == 'readyToShow') {
498       this.show();
499     } else {
500       if (launchMode != 'backgroundAtStartup')
501         this.preloadStatus = 'none';
502     }
503   }
504
505   private flushData() {
506     console.log('WebApplication : FlushData');
507     this.windowList.forEach((window) => window.webContents.session.flushStorageData());
508   }
509
510   sendAppControlEvent() {
511     const kAppControlEventScript = `(function(){
512   var __event = document.createEvent("CustomEvent");
513   __event.initCustomEvent("appcontrol", true, true, null);
514   document.dispatchEvent(__event);
515   for (var i=0; i < window.frames.length; i++)
516     window.frames[i].document.dispatchEvent(__event);
517 })()`;
518     wrt.executeJS(this.mainWindow.webContents, kAppControlEventScript);
519   }
520
521   private activateIMEWebHelperClient() {
522     console.log('webApplication : activateIMEWebHelperClient');
523     const kImeActivateFunctionCallScript =
524         '(function(){WebHelperClient.impl.activate();})()';
525     wrt.executeJS(this.mainWindow.webContents, kImeActivateFunctionCallScript);
526   }
527
528   show() {
529     console.log('WebApplication : show');
530     this.preloadStatus = 'none';
531     if (this.backgroundExecution) {
532       console.log('skip showing while backgroundExecution mode');
533     } else if (!this.mainWindow.isVisible()) {
534       console.log('show window');
535       this.mainWindow.show();
536     }
537   }
538
539   private closeWindows() {
540     wrt.tv?.clearSurface(this.mainWindow.webContents);
541     this.windowList.forEach((window) => {
542       if (window != this.mainWindow)
543         window.destroy();
544     });
545   }
546
547   keyEvent(key: string) {
548     console.log(`WebApplication : keyEvent[${key}]`);
549     switch(key) {
550       case "ArrowUp":
551       case "Up":
552         addonManager.emit('hwUpkey', this.mainWindow.id);
553         break;
554       case "ArrowDown":
555       case "Down":
556         addonManager.emit('hwDownkey', this.mainWindow.id);
557         break;
558       default:
559         console.log('No handler for ' + key);
560         break;
561     }
562   }
563
564   prelaunch(url: string) {
565     console.log('WebApplication : prelaunch');
566     addonManager.emit('lcPrelaunch', this.mainWindow.id, url);
567   }
568
569   lowMemory() {
570     console.log('WebApplication : lowMemory to clearcache');
571     if (!wrt.tv)
572       return;
573     this.windowList.forEach((window) => {
574       //clear webframe cache
575       (wrt.tv as NativeWRTjs.TVExtension).clearWebCache(window.webContents);
576       window.webContents.session.clearCache(function() {
577         console.log('clear session Cache complete');
578       })
579     });
580   }
581
582   ambientTick() {
583     const kAmbientTickEventScript = `(function(){
584   var __event = document.createEvent("CustomEvent");
585   __event.initCustomEvent("timetick", true, true);
586   document.dispatchEvent(__event);
587   for (var i=0; i < window.frames.length; i++)
588     window.frames[i].document.dispatchEvent(__event);
589 })()`;
590     wrt.executeJS(this.mainWindow.webContents, kAmbientTickEventScript);
591   }
592
593   ambientChanged(ambient_mode: boolean) {
594     const kAmbientChangedEventScript = `(function(){
595   var __event = document.createEvent(\"CustomEvent\");
596   var __detail = {};
597   __event.initCustomEvent(\"ambientmodechanged\",true,true,__detail);
598   __event.detail.ambientMode = ${ambient_mode ? 'true' : 'false'};
599   document.dispatchEvent(__event);
600   for (var i=0; i < window.frames.length; i++)
601     window.frames[i].document.dispatchEvent(__event);
602 })()`;
603     wrt.executeJS(this.mainWindow.webContents, kAmbientChangedEventScript);
604   }
605 }