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