Refactors initDisplayDelay function for readability
[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();
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 enableWindow() {
294     this.suspended = false;
295     // TODO: On 6.0, this causes a black screen on relaunch
296     if (this.showTimer)
297       clearTimeout(this.showTimer);
298     if (!this.backgroundRunnable())
299       this.mainWindow.setEnabled(true);
300   }
301
302   private initDisplayDelay() {
303     let splashShown = this.preloadStatus !== 'preload' && wrt.showSplashScreen();
304     if (splashShown || wrt.tv)
305       return;
306
307     this.showTimer = setTimeout(() => {
308       if (!this.suspended) {
309         console.log('FrameRendered not obtained from engine. To show window, timer fired');
310         this.mainWindow.emit('ready-to-show');
311       }
312     }, 2000);
313   }
314
315   private backgroundRunnable(): boolean {
316     return this.backgroundSupport || this.backgroundExecution;
317   }
318
319   private suspendByStatus() {
320     if (this.preloadStatus === 'readyToShow' ||
321         this.preloadStatus === 'preload' ||
322         this.runningStatus === 'behind') {
323       console.log('WebApplication : suspendByStatus');
324       console.log(`preloadStatus: ${this.preloadStatus}, runningStatus: ${this.runningStatus}`);
325       // TODO : Need to care this situation and decide to pass the addon event emitter to suspend()
326       this.suspend();
327       if (this.runningStatus !== 'behind')
328         (wrt.tv as NativeWRTjs.TVExtension).notifyAppStatus('preload');
329     }
330   }
331
332   private showInspectorGuide() {
333     console.log('WebApplication : showInspectorGuide');
334     this.showInspectorGuide = () => {}; // call once
335     const message = `${this.debugPort.toString()}
336 Fast RWI is used, [about:blank] is loaded fist instead of
337 [${this.inspectorSrc}]
338 Click OK button will start the real loading.
339 Notes:
340 Please connect to RWI in PC before click OK button.
341 Then you can get network log from the initial loading.
342 Please click Record button in Timeline panel in PC before click OK button,
343 Then you can get profile log from the initial loading.`;
344     let tv = wrt.tv as NativeWRTjs.TVExtension;
345     tv.showDialog(this.mainWindow.webContents, message);
346
347     if (this.preloadStatus !== 'none') {
348       setTimeout(() => {
349         tv.cancelDialogs(this.mainWindow.webContents);
350       }, 5000);
351     }
352   }
353
354   handleAppControlEvent(appControl: any) {
355     let launchMode = appControl.getData('http://samsung.com/appcontrol/data/launch_mode');
356     this.handlePreloadState(launchMode);
357
358     let skipReload = appControl.getData('SkipReload');
359     if (skipReload == 'Yes') {
360       console.log('skipping reload');
361       // TODO : Need to care this situation and decide to pass the addon event emitter to resume()
362       this.resume();
363       return;
364     }
365
366     let loadInfo = appControl.getLoadInfo();
367     let src = loadInfo.getSrc();
368     let reload = loadInfo.getReload() || this.needReload(src);
369     // handle http://tizen.org/appcontrol/operation/main operation specially.
370     // only menu-screen app can send launch request with main operation.
371     // in this case, web app should have to resume web app not reset.
372     if (reload && appControl.getOperation() == 'http://tizen.org/appcontrol/operation/main')
373       reload = false;
374     if (reload)
375       this.handleAppControlReload(src);
376     else
377       this.sendAppControlEvent();
378   }
379
380   private launchInspectorIfNeeded(appControl: NativeWRTjs.AppControl) {
381     console.log('launchInspectorIfNeeded');
382     let inspectorEnabledByVconf = wrt.tv ? wrt.tv.needUseInspector() : false;
383     if (inspectorEnabledByVconf && !this.backgroundExecution) {
384       this.inspectorSrc = this.contentSrc;
385       this.contentSrc = 'about:blank';
386     }
387     let hasAulDebug = (appControl.getData('__AUL_DEBUG__') === '1');
388     if (hasAulDebug || inspectorEnabledByVconf) {
389       let debugPort = wrt.startInspectorServer();
390       let data = { "port": [debugPort.toString()] };
391       this.debugPort = debugPort;
392       appControl.reply(data);
393     }
394   }
395
396   loadUrl(appControl: NativeWRTjs.AppControl) {
397     this.contentSrc = appControl.getLoadInfo().getSrc();
398     this.launchInspectorIfNeeded(appControl);
399     this.mainWindow.loadURL(this.contentSrc);
400     this.prelaunch(this.contentSrc);
401     if (wrt.da) {
402       this.mainWindow.emit('ready-to-show');
403     }
404   }
405
406   suspend() {
407     if (this.suspended || this.inQuit)
408       return;
409     console.log('WebApplication : suspend');
410     addonManager.emit('lcSuspend', this.mainWindow.id);
411     this.suspended = true;
412     this.windowList[this.windowList.length - 1].hide();
413     this.flushData();
414     if (!this.backgroundRunnable()) {
415       if (!this.multitaskingSupport) {
416         // FIXME : terminate app after visibilitychange event handling
417         setTimeout(() => {
418           console.log('multitasking is not supported; quitting app')
419           app.quit();
420         }, 1000);
421       } else {
422         this.windowList.forEach((window) => window.setEnabled(false));
423       }
424     }
425   }
426
427   resume() {
428     console.log('WebApplication : resume');
429     this.suspended = false;
430     addonManager.emit('lcResume', this.mainWindow.id);
431
432     if (!this.firstRendered) {
433       console.log('WebApplication : resume firstRendered is false');
434       return;
435     }
436     if (!this.backgroundRunnable())
437       this.windowList.forEach((window) => window.setEnabled(true));
438     this.windowList[this.windowList.length - 1].show();
439   }
440
441   quit() {
442     console.log('WebApplication : quit');
443     this.flushData();
444     this.windowList.forEach((window) => window.removeAllListeners());
445     this.inQuit = false;
446     if (!this.suspended)
447       this.suspend();
448   }
449
450   beforeQuit() {
451     console.log('WebApplication : beforeQuit');
452     addonManager.emit('lcQuit', this.mainWindow.id);
453     if (wrt.tv) {
454       this.inspectorSrc = '';
455       wrt.tv.cancelDialogs(this.mainWindow.webContents);
456     }
457     if (this.debugPort) {
458       console.log('stop inspector server');
459       this.debugPort = 0;
460       wrt.stopInspectorServer();
461     }
462     this.inQuit = true;
463   }
464
465   private needReload(src: string) {
466     if (this.isAlwaysReload) {
467       return true;
468     }
469     let reload = false;
470     let originalUrl = this.mainWindow.webContents.getURL();
471     if (wrt.tv) {
472       console.log(`appcontrol src = ${src}, original url = ${originalUrl}`);
473       if (src && originalUrl) {
474         let appcontrolUrl = (new URL(src)).href;
475         let oldUrl = (new URL(originalUrl)).href;
476         console.log(`appcontrolUrl = ${appcontrolUrl}, oldUrl = ${oldUrl}`);
477         // FIXME(dh81.song)
478         // Below case it must be distinguishable for known cases
479         //   from 'file:///index.htmlx' to 'file:///index.html'
480         if (appcontrolUrl !== oldUrl.substr(0, appcontrolUrl.length))
481           reload = true;
482       } else {
483         reload = true;
484       }
485     } else if (src !== originalUrl) {
486       reload = true;
487     }
488     return reload;
489   }
490
491   private handleAppControlReload(url: string) {
492     console.log('WebApplication : handleAppControlReload');
493     this.closeWindows();
494     this.enableWindow();
495     this.mainWindow.loadURL(url);
496   }
497
498   private handlePreloadState(launchMode: string) {
499     if (this.preloadStatus == 'readyToShow') {
500       this.show();
501     } else {
502       if (launchMode != 'backgroundAtStartup')
503         this.preloadStatus = 'none';
504     }
505   }
506
507   private flushData() {
508     console.log('WebApplication : FlushData');
509     this.windowList.forEach((window) => window.webContents.session.flushStorageData());
510   }
511
512   sendAppControlEvent() {
513     const kAppControlEventScript = `(function(){
514   var __event = document.createEvent("CustomEvent");
515   __event.initCustomEvent("appcontrol", true, true, null);
516   document.dispatchEvent(__event);
517   for (var i=0; i < window.frames.length; i++)
518     window.frames[i].document.dispatchEvent(__event);
519 })()`;
520     wrt.executeJS(this.mainWindow.webContents, kAppControlEventScript);
521   }
522
523   private activateIMEWebHelperClient() {
524     console.log('webApplication : activateIMEWebHelperClient');
525     const kImeActivateFunctionCallScript =
526         '(function(){WebHelperClient.impl.activate();})()';
527     wrt.executeJS(this.mainWindow.webContents, kImeActivateFunctionCallScript);
528   }
529
530   show() {
531     console.log('WebApplication : show');
532     this.preloadStatus = 'none';
533     if (this.backgroundExecution) {
534       console.log('skip showing while backgroundExecution mode');
535     } else if (!this.mainWindow.isVisible()) {
536       console.log('show window');
537       this.mainWindow.show();
538     }
539   }
540
541   private closeWindows() {
542     wrt.tv?.clearSurface(this.mainWindow.webContents);
543     this.windowList.forEach((window) => {
544       if (window != this.mainWindow)
545         window.destroy();
546     });
547   }
548
549   keyEvent(key: string) {
550     console.log(`WebApplication : keyEvent[${key}]`);
551     switch(key) {
552       case "ArrowUp":
553       case "Up":
554         addonManager.emit('hwUpkey', this.mainWindow.id);
555         break;
556       case "ArrowDown":
557       case "Down":
558         addonManager.emit('hwDownkey', this.mainWindow.id);
559         break;
560       default:
561         console.log('No handler for ' + key);
562         break;
563     }
564   }
565
566   prelaunch(url: string) {
567     console.log('WebApplication : prelaunch');
568     addonManager.emit('lcPrelaunch', this.mainWindow.id, url);
569   }
570
571   lowMemory() {
572     console.log('WebApplication : lowMemory to clearcache');
573     if (!wrt.tv)
574       return;
575     this.windowList.forEach((window) => {
576       //clear webframe cache
577       (wrt.tv as NativeWRTjs.TVExtension).clearWebCache(window.webContents);
578       window.webContents.session.clearCache(function() {
579         console.log('clear session Cache complete');
580       })
581     });
582   }
583
584   ambientTick() {
585     const kAmbientTickEventScript = `(function(){
586   var __event = document.createEvent("CustomEvent");
587   __event.initCustomEvent("timetick", true, true);
588   document.dispatchEvent(__event);
589   for (var i=0; i < window.frames.length; i++)
590     window.frames[i].document.dispatchEvent(__event);
591 })()`;
592     wrt.executeJS(this.mainWindow.webContents, kAmbientTickEventScript);
593   }
594
595   ambientChanged(ambient_mode: boolean) {
596     const kAmbientChangedEventScript = `(function(){
597   var __event = document.createEvent(\"CustomEvent\");
598   var __detail = {};
599   __event.initCustomEvent(\"ambientmodechanged\",true,true,__detail);
600   __event.detail.ambientMode = ${ambient_mode ? 'true' : 'false'};
601   document.dispatchEvent(__event);
602   for (var i=0; i < window.frames.length; i++)
603     window.frames[i].document.dispatchEvent(__event);
604 })()`;
605     wrt.executeJS(this.mainWindow.webContents, kAmbientChangedEventScript);
606   }
607 }