import angular from 'angular' ; import { Injectable } from 'angular-ts-decorators' ; import autobind from 'autobind-decorator' ; import * as detectBrowser from 'detect-browser' ; import browser , { Alarms , Downloads , Notifications } from 'webextension-polyfill' ; import { Alert } from '../../shared/alert/alert.interface' ; import { AlertService } from '../../shared/alert/alert.service' ; import { BackupRestoreService } from '../../shared/backup-restore/backup-restore.service' ; import { BookmarkHelperService } from '../../shared/bookmark/bookmark-helper/bookmark-helper.service' ; import { AmbiguousSyncRequestError , FailedDownloadFileError , HttpRequestAbortedError } from '../../shared/errors/errors' ; import { ExceptionHandler } from '../../shared/errors/errors.interface' ; import Globals from '../../shared/global-shared.constants' ; import { MessageCommand } from '../../shared/global-shared.enum' ; import { PlatformService } from '../../shared/global-shared.interface' ; import { LogService } from '../../shared/log/log.service' ; import { NetworkService } from '../../shared/network/network.service' ; import { SettingsService } from '../../shared/settings/settings.service' ; import { StoreKey } from '../../shared/store/store.enum' ; import { StoreService } from '../../shared/store/store.service' ; import { Sync } from '../../shared/sync/sync.interface' ; import { SyncService } from '../../shared/sync/sync.service' ; import { UpgradeService } from '../../shared/upgrade/upgrade.service' ; import { UtilityService } from '../../shared/utility/utility.service' ; import { ChromiumBookmarkService } from '../chromium/shared/chromium-bookmark/chromium-bookmark.service' ; import { BookmarkIdMapperService } from '../shared/bookmark-id-mapper/bookmark-id-mapper.service' ; import { DownloadFileMessage , EnableAutoBackUpMessage , InstallBackup , Message , SyncBookmarksMessage } from '../webext.interface' ; @ autobind @ Injectable ( 'WebExtBackgroundService' ) export class WebExtBackgroundService { Strings = require ( '../../../../res/strings/en.json' ) ; $exceptionHandler : ExceptionHandler ; $q : ng . IQService ; $timeout : ng . ITimeoutService ; alertSvc : AlertService ; backupRestoreSvc : BackupRestoreService ; bookmarkIdMapperSvc : BookmarkIdMapperService ; bookmarkHelperSvc : BookmarkHelperService ; bookmarkSvc : ChromiumBookmarkService ; logSvc : LogService ; networkSvc : NetworkService ; platformSvc : PlatformService ; settingsSvc : SettingsService ; storeSvc : StoreService ; syncSvc : SyncService ; upgradeSvc : UpgradeService ; utilitySvc : UtilityService ; notificationClickHandlers : any [ ] = [ ] ; static $inject = [ '$exceptionHandler' , '$q' , '$timeout' , 'AlertService' , 'BackupRestoreService' , 'BookmarkHelperService' , 'BookmarkIdMapperService' , 'BookmarkService' , 'LogService' , 'NetworkService' , 'PlatformService' , 'SettingsService' , 'StoreService' , 'SyncService' , 'UpgradeService' , 'UtilityService' ] ; constructor ( $exceptionHandler , $q , $timeout , AlertSvc , BackupRestoreSvc , BookmarkHelperSvc , BookmarkIdMapperSvc , BookmarkSvc , LogSvc , NetworkSvc , PlatformSvc , SettingsSvc , StoreSvc , SyncSvc , UpgradeSvc , UtilitySvc ) { this . $exceptionHandler = $exceptionHandler ; this . $q = $q ; this . $timeout = $timeout ; this . alertSvc = AlertSvc ; this . backupRestoreSvc = BackupRestoreSvc ; this . bookmarkIdMapperSvc = BookmarkIdMapperSvc ; this . bookmarkHelperSvc = BookmarkHelperSvc ; this . bookmarkSvc = BookmarkSvc ; this . logSvc = LogSvc ; this . networkSvc = NetworkSvc ; this . platformSvc = PlatformSvc ; this . settingsSvc = SettingsSvc ; this . storeSvc = StoreSvc ; this . syncSvc = SyncSvc ; this . upgradeSvc = UpgradeSvc ; this . utilitySvc = UtilitySvc ; browser . alarms . onAlarm . addListener ( this . onAlarm ) ; browser . notifications . onClicked . addListener ( this . onNotificationClicked ) ; browser . notifications . onClosed . addListener ( this . onNotificationClosed ) ; browser . runtime . onMessage . addListener ( this . onMessage ) ; } checkForNewVersion ( ) { this . platformSvc . getAppVersion ( ) . then ( ( appVersion ) => { return this . utilitySvc . checkForNewVersion ( appVersion ) . then ( ( newVersion ) => { if ( ! newVersion ) { return ; } const alert = { message : this . platformSvc . getI18nString ( this . Strings . Alert . AppUpdateAvailable . Message ) . replace ( '{version}' , `v${ newVersion }` ) , title : this . platformSvc . getI18nString ( this . Strings . Alert . AppUpdateAvailable . Title ) } ; this . displayAlert ( alert , `${ Globals . ReleaseNotesUrlStem }${ newVersion }` ) ; } ) ; } ) ; } checkForSyncUpdates ( ) { const currentSync = this . syncSvc . getCurrentSync ( ) ; if ( currentSync ) { return this . $q . resolve ( ) ; } return this . syncSvc . executeSync ( ) . catch ( this . checkForSyncUpdatesFailed ) ; } checkForSyncUpdatesFailed ( err ) { if ( this . networkSvc . isNetworkConnectionError ( err ) ) { this . logSvc . logInfo ( 'Could not check for updates, no connection' ) ; return ; } throw err ; } checkForSyncUpdatesOnStartup ( ) { return this . $q < void > ( ( resolve , reject ) => { this . utilitySvc . isSyncEnabled ( ) . then ( ( syncEnabled ) => { if ( ! syncEnabled ) { return resolve ( ) ; } this . checkForSyncUpdates ( ) . then ( resolve ) . catch ( ( err ) => { if ( ! this . networkSvc . isNetworkConnectionError ( err ) ) { return reject ( err ) ; } this . logSvc . logInfo ( 'Connection lost, retrying check for sync updates momentarily' ) ; this . $timeout ( ( ) => { this . utilitySvc . isSyncEnabled ( ) . then ( ( syncEnabledAfterError ) => { if ( ! syncEnabledAfterError ) { this . logSvc . logInfo ( 'Sync was disabled before retry attempted' ) ; return reject ( new HttpRequestAbortedError ( ) ) ; } this . checkForSyncUpdates ( ) . then ( resolve ) . catch ( reject ) ; } ) ; } , 5000 ) ; } ) ; } ) ; } ) ; } displayAlert ( alert , url ? ) { const urlRegex = new RegExp ( Globals . URL . ValidUrlRegex , 'i' ) ; const urlInAlert = alert . message . match ( urlRegex ) ?. find ( Boolean ) ; const messageToDisplay = urlInAlert ? new DOMParser ( ) . parseFromString ( `<span>${ alert . message }</span>` , 'text/xml' ) . firstElementChild . textContent : alert . message ; const options = { iconUrl : `${ Globals . PathToAssets }/notification.svg` , message : messageToDisplay , title : alert . title , type : 'basic' } ; browser . notifications . create ( this . utilitySvc . getUniqueishId ( ) , options ) . then ( ( notificationId ) => { const urlToOpenOnClick = urlInAlert ?? url ; if ( urlToOpenOnClick ) { const openUrlInNewTab = ( ) => { this . platformSvc . openUrl ( urlToOpenOnClick ) ; } ; this . notificationClickHandlers . push ( { id : notificationId , eventHandler : openUrlInNewTab } ) ; } } ) ; } getDownloadById ( id ) { return browser . downloads . search ( { id } ) . then ( ( results ) => { const [ download ] = results ; if ( ( download ?? undefined ) === undefined ) { this . logSvc . logWarning ( 'Unable to find download' ) ; return ; } return download ; } ) ; } init ( ) { this . logSvc . logInfo ( 'Starting up' ) ; this . platformSvc . getAppVersion ( ) . then ( this . upgradeSvc . checkIfUpgradeRequired ) . then ( ( upgradeRequired ) => upgradeRequired && this . upgradeExtension ( ) ) . then ( ( ) => this . $q . all ( [ this . platformSvc . getAppVersionName ( ) , this . platformSvc . getCurrentLocale ( ) , this . settingsSvc . all ( ) , this . storeSvc . get ( [ StoreKey . LastUpdated , StoreKey . SyncId ] ) , this . utilitySvc . getServiceUrl ( ) , this . utilitySvc . getSyncVersion ( ) , this . utilitySvc . isSyncEnabled ( ) ] ) . then ( ( data ) => { const [ appVersion , currentLocale , settings , storeContent , serviceUrl , syncVersion , syncEnabled ] = data ; const debugInfo = angular . copy ( storeContent ) as any ; debugInfo . appVersion = appVersion ; debugInfo . checkForAppUpdates = settings . checkForAppUpdates ; debugInfo . currentLocale = currentLocale ; debugInfo . platform = detectBrowser . detect ( ) ; debugInfo . platform . name = this . utilitySvc . getBrowserName ( ) ; debugInfo . serviceUrl = serviceUrl ; debugInfo . syncBookmarksToolbar = settings . syncBookmarksToolbar ; debugInfo . syncEnabled = syncEnabled ; debugInfo . syncVersion = syncVersion ; this . logSvc . logInfo ( Object . keys ( debugInfo ) . filter ( ( key ) => { return debugInfo [ key ] != null ; } ) . reduce ( ( prev , current ) => { prev [ current ] = debugInfo [ current ] ; return prev ; } , { } ) ) ; this . platformSvc . refreshNativeInterface ( syncEnabled ) ; if ( settings . checkForAppUpdates ) { this . $timeout ( this . checkForNewVersion , 5e3 ) ; } if ( ! syncEnabled ) { return ; } return this . syncSvc . enableSync ( ) . then ( ( ) => this . $timeout ( this . checkForSyncUpdatesOnStartup , 5e3 ) ) ; } ) ) ; } installExtension ( ) { return ( this . storeSvc . init ( ) . then ( ( ) => this . $q . all ( [ this . storeSvc . set ( StoreKey . DisplayOtherSyncsWarning , true ) , this . storeSvc . set ( StoreKey . DisplayPermissions , true ) , this . storeSvc . set ( StoreKey . SyncBookmarksToolbar , true ) ] ) ) . then ( ( ) => { return this . bookmarkSvc . getNativeBookmarksAsBookmarks ( ) . then ( ( bookmarks ) => { const backup = { bookmarks , date : new Date ( ) . toISOString ( ) } ; return this . storeSvc . set ( StoreKey . InstallBackup , JSON . stringify ( backup ) ) ; } ) ; } ) . then ( ( ) => this . platformSvc . getAppVersion ( ) . then ( ( currentVersion ) => this . upgradeSvc . setLastUpgradeVersion ( currentVersion ) . then ( ( ) => { this . logSvc . logInfo ( `Installed ${ currentVersion }` ) ; } ) ) ) . catch ( ( err ) => { this . $exceptionHandler ( err ) ; return this . $q . reject ( err ) ; } ) ) ; } onAlarm ( alarm ) { switch ( alarm ?. name ) { case Globals . Alarms . AutoBackUp . Name : this . backupRestoreSvc . runAutoBackUp ( ) ; break ; case Globals . Alarms . SyncUpdatesCheck . Name : this . checkForSyncUpdates ( ) ; break ; default : } } onInstall ( event ) { const details = angular . element ( event . currentTarget as Element ) . data ( 'details' ) ; ( details ?. reason === 'install' ? this . installExtension ( ) : this . $q . resolve ( ) ) . then ( this . init ) ; } onNotificationClicked ( notificationId ) { const notificationClickHandler = this . notificationClickHandlers . find ( ( x ) => { return x . id === notificationId ; } ) ; if ( notificationClickHandler != null ) { notificationClickHandler . eventHandler ( ) ; browser . notifications . clear ( notificationId ) ; } } onNotificationClosed ( notificationId ) { const index = this . notificationClickHandlers . findIndex ( ( x ) => { return x . id === notificationId ; } ) ; if ( index >= 0 ) { this . notificationClickHandlers . splice ( index , 1 ) ; } } onMessage ( message ) { return new Promise ( ( resolve , reject ) => { let action ; switch ( message . command ) { case MessageCommand . SyncBookmarks : action = this . runSyncBookmarksCommand ( message as SyncBookmarksMessage ) ; break ; case MessageCommand . RestoreBookmarks : action = this . runRestoreBookmarksCommand ( message as SyncBookmarksMessage ) ; break ; case MessageCommand . GetCurrentSync : action = this . runGetCurrentSyncCommand ( ) ; break ; case MessageCommand . GetSyncQueueLength : action = this . runGetSyncQueueLengthCommand ( ) ; break ; case MessageCommand . DisableSync : action = this . runDisableSyncCommand ( ) ; break ; case MessageCommand . DownloadFile : action = this . runDownloadFileCommand ( message as DownloadFileMessage ) ; break ; case MessageCommand . EnableEventListeners : action = this . runEnableEventListenersCommand ( ) ; break ; case MessageCommand . DisableEventListeners : action = this . runDisableEventListenersCommand ( ) ; break ; case MessageCommand . EnableAutoBackUp : action = this . runEnableAutoBackUpCommand ( message as EnableAutoBackUpMessage ) ; break ; case MessageCommand . DisableAutoBackUp : action = this . runDisableAutoBackUpCommand ( ) ; break ; default : action = this . $q . reject ( new AmbiguousSyncRequestError ( ) ) ; } action . then ( resolve ) . catch ( reject ) ; } ) . catch ( ( err ) => { err . message = err . constructor . name ; throw err ; } ) ; } runDisableAutoBackUpCommand ( ) { return browser . alarms . clear ( Globals . Alarms . AutoBackUp . Name ) . then ( ( ) => { } ) ; } runDisableEventListenersCommand ( ) { return this . bookmarkSvc . disableEventListeners ( ) ; } runDisableSyncCommand ( ) { return this . syncSvc . disableSync ( ) ; } runDownloadFileCommand ( message ) { const { filename , textContents , displaySaveDialog = true } = message ; if ( ! filename ) { return this . $q . reject ( new Error ( 'File name parameter missing.' ) ) ; } if ( ! textContents ) { return this . $q . reject ( new Error ( 'File contents parameter missing.' ) ) ; } return new this . $q < string | void > ( ( resolve , reject ) => { const file = new Blob ( [ textContents ] , { type : 'text/plain' } ) ; const url = URL . createObjectURL ( file ) ; browser . downloads . download ( { filename , saveAs : displaySaveDialog , url } ) . then ( ( downloadId ) => { const onChangedHandler = ( delta ) => { switch ( delta . state ?. current ) { case 'complete' : URL . revokeObjectURL ( url ) ; browser . downloads . onChanged . removeListener ( onChangedHandler ) ; this . getDownloadById ( downloadId ) . then ( ( download ) => { this . logSvc . logInfo ( `Downloaded file ${ download . filename }` ) ; resolve ( download . filename ) ; } ) ; break ; case 'interrupted' : URL . revokeObjectURL ( url ) ; browser . downloads . onChanged . removeListener ( onChangedHandler ) ; if ( delta . error ?. current === 'USER_CANCELED' ) { resolve ( ) ; } else { reject ( new FailedDownloadFileError ( ) ) ; } break ; default : } } ; browser . downloads . onChanged . addListener ( onChangedHandler ) ; } , reject ) ; } ) ; } runEnableAutoBackUpCommand ( message ) { const { schedule } = message ; let delayInMinutes = 0 ; const now = new Date ( ) ; const runTime = new Date ( now . getFullYear ( ) , now . getMonth ( ) , now . getDate ( ) , parseInt ( schedule . autoBackUpHour , 10 ) , parseInt ( schedule . autoBackUpMinute , 10 ) ) ; if ( runTime < now ) { runTime . setDate ( now . getDate ( ) + 1 ) ; } delayInMinutes = Math . round ( ( runTime . getTime ( ) - now . getTime ( ) ) / 1e3 / 60 ) ; let periodInMinutes ; switch ( schedule . autoBackUpUnit ) { case 'week' : periodInMinutes = 60 * 24 * 7 ; break ; case 'month' : periodInMinutes = 60 * 24 * ( 365 / 12 ) ; break ; case 'day' : default : periodInMinutes = 60 * 24 ; } periodInMinutes *= parseInt ( schedule . autoBackUpNumber , 10 ) ; return browser . alarms . clear ( Globals . Alarms . AutoBackUp . Name ) . then ( ( ) => { return browser . alarms . create ( Globals . Alarms . AutoBackUp . Name , { delayInMinutes , periodInMinutes } ) ; } ) ; } runEnableEventListenersCommand ( ) { return this . bookmarkSvc . enableEventListeners ( ) ; } runGetCurrentSyncCommand ( ) { return this . $q . resolve ( this . syncSvc . getCurrentSync ( ) ) ; } runGetSyncQueueLengthCommand ( ) { return this . $q . resolve ( this . syncSvc . getSyncQueueLength ( ) ) ; } runRestoreBookmarksCommand ( message ) { const { sync } = message ; return this . syncSvc . queueSync ( sync ) ; } runSyncBookmarksCommand ( message ) { const { sync , runSync } = message ; if ( angular . isUndefined ( sync ) ) { return this . syncSvc . executeSync ( ) ; } return this . syncSvc . queueSync ( sync , runSync ) ; } upgradeExtension ( ) { return this . platformSvc . getAppVersion ( ) . then ( this . upgradeSvc . upgrade ) . then ( ( ) => { return this . platformSvc . getAppVersionName ( ) . then ( ( appVersion ) => { const alert = { message : this . platformSvc . getI18nString ( this . Strings . Alert . AppUpdated . Message ) , title : `${ this . platformSvc . getI18nString ( this . Strings . Alert . AppUpdated . Title ) } v${ appVersion }` } ; this . displayAlert ( alert , `${ Globals . ReleaseNotesUrlStem }${ appVersion }` ) ; return this . storeSvc . set ( StoreKey . DisplayUpdated , true ) ; } ) ; } ) ; } }