/******************************************************************************* uMatrix - a Chromium browser extension to black/white list requests. Copyright (C) 2014-present Raymond Hill This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see {http://www.gnu.org/licenses/}. Home: https://github.com/gorhill/uMatrix */ /* global punycode, publicSuffixList */ 'use strict'; /******************************************************************************/ µMatrix.getBytesInUse = async function() { const promises = []; let bytesInUse; // Not all platforms implement this method. promises.push( vAPI.storage.getBytesInUse instanceof Function ? vAPI.storage.getBytesInUse(null) : undefined ); if ( navigator.storage instanceof Object && navigator.storage.estimate instanceof Function ) { promises.push(navigator.storage.estimate()); } const results = await Promise.all(promises); const processCount = count => { if ( typeof count !== 'number' ) { return; } if ( bytesInUse === undefined ) { bytesInUse = 0; } bytesInUse += count; return bytesInUse; }; processCount(results[0]); if ( results.length > 1 && results[1] instanceof Object ) { processCount(results[1].usage); } return bytesInUse; }; /******************************************************************************/ µMatrix.saveUserSettings = function() { return vAPI.storage.set(this.userSettings); }; µMatrix.loadUserSettings = async function() { this.userSettings = await vAPI.storage.get(this.userSettings); if ( typeof this.userSettings.externalHostsFiles === 'string' ) { this.userSettings.externalHostsFiles = this.userSettings.externalHostsFiles.length !== 0 ? this.userSettings.externalHostsFiles.split('\n') : []; } // Backward-compatibility: populate the new list selection array with // existing data. if ( this.userSettings.selectedHostsFiles.length === 1 && this.userSettings.selectedHostsFiles[0] === '' ) { const bin = await vAPI.storage.get('liveHostsFiles'); if ( bin instanceof Object === false || bin.liveHostsFiles instanceof Object === false ) { const availableHostFiles = await this.getAvailableHostsFiles(); this.userSettings.selectedHostsFiles = Array.from(availableHostFiles.keys()); } else { const selectedHostsFiles = new Set(); for ( const entry of this.toMap(bin.liveHostsFiles) ) { if ( entry[1].off !== true ) { selectedHostsFiles.add(entry[0]); } } this.userSettings.selectedHostsFiles = Array.from(selectedHostsFiles); } vAPI.storage.set({ selectedHostsFiles: this.userSettings.selectedHostsFiles }); } if ( this.userSettings.selectedRecipeFiles.length !== 1 || this.userSettings.selectedRecipeFiles[0] !== '' ) { return this.userSettings; } const availableRulesetFiles = await this.getAvailableRecipeFiles(); const selectedRulesetKeys = new Set(); for ( const entry of availableRulesetFiles ) { const assetKey = entry[0]; const assetLang = entry[1].lang; if ( assetLang === undefined ) { selectedRulesetKeys.add(assetKey); continue; } for ( const lang of navigator.languages ) { if ( assetLang.indexOf(lang) !== -1 ) { selectedRulesetKeys.add(assetKey); break; } } } this.userSettings.selectedRecipeFiles = Array.from(selectedRulesetKeys); vAPI.storage.set({ selectedRecipeFiles: this.userSettings.selectedRecipeFiles }); return this.userSettings; }; /******************************************************************************/ µMatrix.loadRawSettings = async function() { const bin = await vAPI.storage.get('rawSettings'); if ( bin instanceof Object === false ) { return; } const hs = bin.rawSettings; if ( hs instanceof Object ) { const hsDefault = this.rawSettingsDefault; for ( const key in hsDefault ) { if ( hsDefault.hasOwnProperty(key) && hs.hasOwnProperty(key) && typeof hs[key] === typeof hsDefault[key] ) { this.rawSettings[key] = hs[key]; } } if ( typeof this.rawSettings.suspendTabsUntilReady === 'boolean' ) { this.rawSettings.suspendTabsUntilReady = this.rawSettings.suspendTabsUntilReady ? 'yes' : 'unset'; } } this.fireDOMEvent('rawSettingsChanged'); }; // Note: Save only the settings which values differ from the default ones. // This way the new default values in the future will properly apply for those // which were not modified by the user. µMatrix.saveRawSettings = function() { const bin = { rawSettings: {} }; for ( const prop in this.rawSettings ) { if ( this.rawSettings.hasOwnProperty(prop) && this.rawSettings[prop] !== this.rawSettingsDefault[prop] ) { bin.rawSettings[prop] = this.rawSettings[prop]; } } this.saveImmediateHiddenSettings(); return vAPI.storage.set(bin); }; self.addEventListener('rawSettingsChanged', ( ) => { const µm = µMatrix; self.log.verbosity = µm.rawSettings.consoleLogLevel; vAPI.net.setOptions({ cnameAliasList: µm.rawSettings.cnameAliasList, cnameIgnoreList: µm.rawSettings.cnameIgnoreList, cnameIgnore1stParty: µm.rawSettings.cnameIgnore1stParty, cnameIgnoreRootDocument: µm.rawSettings.cnameIgnoreRootDocument, cnameMaxTTL: µm.rawSettings.cnameMaxTTL, cnameReplayFullURL: µm.rawSettings.cnameReplayFullURL, }); }); self.addEventListener('rawSettingsChanged', ( ) => { const µm = µMatrix; self.log.verbosity = µm.rawSettings.consoleLogLevel; vAPI.net.setOptions({ cnameIgnoreList: µm.rawSettings.cnameIgnoreList, cnameIgnore1stParty: µm.rawSettings.cnameIgnore1stParty, cnameIgnoreExceptions: µm.rawSettings.cnameIgnoreExceptions, cnameIgnoreRootDocument: µm.rawSettings.cnameIgnoreRootDocument, cnameMaxTTL: µm.rawSettings.cnameMaxTTL, cnameReplayFullURL: µm.rawSettings.cnameReplayFullURL, }); }); /******************************************************************************/ µMatrix.rawSettingsFromString = function(raw) { const out = Object.assign({}, this.rawSettingsDefault); const lineIter = new this.LineIterator(raw); while ( lineIter.eot() === false ) { const line = lineIter.next(); const matches = /^\s*(\S+)\s+(.+)$/.exec(line); if ( matches === null || matches.length !== 3 ) { continue; } const name = matches[1]; if ( out.hasOwnProperty(name) === false ) { continue; } const value = matches[2].trim(); switch ( typeof out[name] ) { case 'boolean': if ( value === 'true' ) { out[name] = true; } else if ( value === 'false' ) { out[name] = false; } break; case 'string': out[name] = value; break; case 'number': { const i = parseInt(value, 10); if ( isNaN(i) === false ) { out[name] = i; } break; } default: break; } } this.rawSettings = out; this.saveRawSettings(); this.fireDOMEvent('rawSettingsChanged'); }; µMatrix.stringFromRawSettings = function() { const out = []; for ( const key of Object.keys(this.rawSettings).sort() ) { out.push(key + ' ' + this.rawSettings[key]); } return out.join('\n'); }; /******************************************************************************/ // These settings must be available immediately on startup, without delay // through the vAPI.localStorage. Add/remove settings as needed. µMatrix.saveImmediateHiddenSettings = function() { const props = [ 'consoleLogLevel', 'suspendTabsUntilReady', ]; const toSave = {}; for ( const prop of props ) { if ( this.rawSettings[prop] !== this.rawSettingsDefault[prop] ) { toSave[prop] = this.rawSettings[prop]; } } if ( Object.keys(toSave).length !== 0 ) { vAPI.localStorage.setItem( 'immediateHiddenSettings', JSON.stringify(toSave) ); } else { vAPI.localStorage.removeItem('immediateHiddenSettings'); } }; /******************************************************************************/ µMatrix.saveMatrix = function() { return vAPI.storage.set({ userMatrix: this.pMatrix.toArray() }); }; µMatrix.loadMatrix = async function() { const bin = await vAPI.storage.get('userMatrix'); if ( bin instanceof Object === false ) { return; } if ( typeof bin.userMatrix === 'string' ) { this.pMatrix.fromString(bin.userMatrix); } else if ( Array.isArray(bin.userMatrix) ) { this.pMatrix.fromArray(bin.userMatrix); } this.tMatrix.assign(this.pMatrix); }; /******************************************************************************/ µMatrix.loadRecipes = async function(reset) { if ( reset ) { this.recipeManager.reset(); } const toLoad = []; const recipeMetadata = await this.getAvailableRecipeFiles(); for ( const entry of recipeMetadata ) { const assetKey = entry[0]; const recipeFile = entry[1]; if ( recipeFile.selected !== true ) { continue; } toLoad.push(this.assets.get(assetKey)); } if ( this.userSettings.userRecipes.enabled ) { this.recipeManager.fromString( '! uMatrix: Ruleset recipes 1.0\n' + this.userSettings.userRecipes.content ); } const results = await Promise.all(toLoad); for ( const result of results ) { const content = result.content || ''; if ( content === '' ) { continue; } const entry = recipeMetadata.get(result.assetKey); if ( entry.submitter === 'user' ) { const match = /^! +Title: *(.+)$/im.exec(content.slice(2048)); if ( match !== null && match[1] !== entry.title ) { this.assets.registerAssetSource( result.assetKey, { title: match[1] } ); } } this.recipeManager.fromString(content); } vAPI.messaging.broadcast({ what: 'loadRecipeFilesCompleted' }); }; /******************************************************************************/ µMatrix.assetKeysFromImportedAssets = function(raw) { var out = new Set(), reIgnore = /^[!#]/, reValid = /^[a-z-]+:\/\/\S+\/./, lineIter = new this.LineIterator(raw); while ( lineIter.eot() === false ) { let location = lineIter.next().trim(); if ( reIgnore.test(location) || !reValid.test(location) ) { continue; } out.add(location); } return out; }; /******************************************************************************/ µMatrix.getAvailableHostsFiles = async function() { const availableHostsFiles = new Map(); // Custom lists. const importedListKeys = new Set(this.userSettings.externalHostsFiles); for ( const assetKey of importedListKeys ) { const entry = { content: 'filters', contentURL: assetKey, external: true, submitter: 'user', title: assetKey }; this.assets.registerAssetSource(assetKey, entry); availableHostsFiles.set(assetKey, entry); } // Built-in lists const entries = await this.assets.metadata(); for ( const assetKey in entries ) { if ( entries.hasOwnProperty(assetKey) === false ) { continue; } let entry = entries[assetKey]; if ( entry.content !== 'filters' ) { continue; } if ( entry.submitter === 'user' && importedListKeys.has(assetKey) === false ) { this.assets.unregisterAssetSource(assetKey); this.assets.remove(assetKey); continue; } availableHostsFiles.set(assetKey, Object.assign({}, entry)); } // Populate available lists with useful data. const bin = await vAPI.storage.get('liveHostsFiles'); if ( bin && bin.liveHostsFiles ) { for ( const [ assetKey, liveAsset ] of this.toMap(bin.liveHostsFiles) ) { const availableAsset = availableHostsFiles.get(assetKey); if ( availableAsset === undefined ) { continue; } if ( liveAsset.entryCount !== undefined ) { availableAsset.entryCount = liveAsset.entryCount; } if ( liveAsset.entryUsedCount !== undefined ) { availableAsset.entryUsedCount = liveAsset.entryUsedCount; } // This may happen if the list name was pulled from the list content if ( availableAsset.title === '' && liveAsset.title !== undefined ) { availableAsset.title = liveAsset.title; } } } for ( const asseyKey of this.userSettings.selectedHostsFiles ) { const asset = availableHostsFiles.get(asseyKey); if ( asset !== undefined ) { asset.selected = true; } } return availableHostsFiles; }; /******************************************************************************/ µMatrix.getAvailableRecipeFiles = async function() { const availableRecipeFiles = new Map(); // Imported recipe resources. const importedResourceKeys = new Set(this.userSettings.externalRecipeFiles); for ( const assetKey of importedResourceKeys ) { const entry = { content: 'recipes', contentURL: assetKey, external: true, submitter: 'user' }; this.assets.registerAssetSource(assetKey, entry); availableRecipeFiles.set(assetKey, entry); } const entries = await this.assets.metadata(); for ( const assetKey in entries ) { if ( entries.hasOwnProperty(assetKey) === false ) { continue; } let entry = entries[assetKey]; if ( entry.content !== 'recipes' ) { continue; } if ( entry.submitter === 'user' && importedResourceKeys.has(assetKey) === false ) { this.assets.unregisterAssetSource(assetKey); this.assets.remove(assetKey); continue; } availableRecipeFiles.set(assetKey, Object.assign({}, entry)); } for ( const asseyKey of this.userSettings.selectedRecipeFiles ) { const asset = availableRecipeFiles.get(asseyKey); if ( asset !== undefined ) { asset.selected = true; } } return availableRecipeFiles; }; /******************************************************************************/ µMatrix.loadHostsFiles = async function() { const status = await this.hostsFilesSelfie.load(); if ( status !== true ) { this.liveHostsFiles = await this.getAvailableHostsFiles(); this.ubiquitousBlacklist.reset(); this.ubiquitousBlacklistRef = this.ubiquitousBlacklist.createOne(); // Load all hosts file which are not disabled. const assetPromises = []; for ( const assetKey of this.userSettings.selectedHostsFiles ) { assetPromises.push( this.assets.get(assetKey).then(details => { this.mergeHostsFile(details); }) ); } await Promise.all(assetPromises); vAPI.storage.set({ liveHostsFiles: Array.from(this.liveHostsFiles) }); this.hostsFilesSelfie.create(); } vAPI.messaging.broadcast({ what: 'loadHostsFilesCompleted' }); }; /******************************************************************************/ µMatrix.mergeHostsFile = function(details) { const addedCount = this.ubiquitousBlacklistRef.addedCount; const addCount = this.ubiquitousBlacklistRef.addCount; // https://www.reddit.com/r/uMatrix/comments/ftebgz/ // Be ready to deal with a removed asset. if ( typeof details.content === 'string' && details.content !== '' ) { this.mergeHostsFileContent(details.content); } const hostsFileMeta = this.liveHostsFiles.get(details.assetKey); if ( hostsFileMeta === undefined ) { this.liveHostsFiles.delete(details.assetKey); return; } hostsFileMeta.entryCount = this.ubiquitousBlacklistRef.addCount - addCount; hostsFileMeta.entryUsedCount = this.ubiquitousBlacklistRef.addedCount - addedCount; }; /******************************************************************************/ µMatrix.mergeHostsFileContent = function(rawText) { const rawEnd = rawText.length; const ubiquitousBlacklistRef = this.ubiquitousBlacklistRef; const reLocalhost = new RegExp( [ '(?:^|\\s+)(?:', [ 'localhost\\.localdomain', 'ip6-allrouters', 'broadcasthost', 'ip6-localhost', 'ip6-allnodes', 'ip6-loopback', 'localhost', 'local', '255\\.255\\.255\\.255', '127\\.0\\.0\\.1', '0\\.0\\.0\\.0', 'fe80::1%lo0', 'ff02::1', 'ff02::2', '::1', '::', ].join('|'), ')(?=\\s+|$)' ].join(''), 'g' ); const reAsciiSegment = /^[\x21-\x7e]+$/; let lineBeg = 0; while ( lineBeg < rawEnd ) { let lineEnd = rawText.indexOf('\n', lineBeg); if ( lineEnd < 0 ) { lineEnd = rawText.indexOf('\r', lineBeg); if ( lineEnd < 0 ) { lineEnd = rawEnd; } } // rhill 2014-04-18: The trim is important here, as without it there // could be a lingering `\r` which would cause problems in the // following parsing code. let line = rawText.slice(lineBeg, lineEnd).trim(); lineBeg = lineEnd + 1; // https://github.com/gorhill/httpswitchboard/issues/15 // Ensure localhost et al. don't end up in the ubiquitous blacklist. line = line .replace(/#.*$/, '') .toLowerCase() .replace(reLocalhost, '') .trim(); // The filter is whatever sequence of printable ascii character without // whitespaces const matches = reAsciiSegment.exec(line); if ( matches === null ) { continue; } // Bypass anomalies // For example, when a filter contains whitespace characters, or // whatever else outside the range of printable ascii characters. if ( matches[0] !== line ) { continue; } line = matches[0]; if ( line === '' ) { continue; } ubiquitousBlacklistRef.add(line); } }; /******************************************************************************/ µMatrix.selectAssets = async function(details) { const applyAssetSelection = function( metadata, details, propSelectedAssetKeys, propImportedAssetKeys, propInlineAsset ) { const µm = µMatrix; const µmus = µm.userSettings; let selectedAssetKeys = new Set(); let importedAssetKeys = new Set(µmus[propImportedAssetKeys]); if ( Array.isArray(details.toSelect) ) { for ( const assetKey of details.toSelect ) { if ( metadata.has(assetKey) ) { selectedAssetKeys.add(assetKey); } } } if ( Array.isArray(details.toRemove) ) { for ( const assetKey of details.toRemove ) { importedAssetKeys.delete(assetKey); µm.assets.remove(assetKey); } } // Hosts file to import // https://github.com/gorhill/uBlock/issues/1181 // Try mapping the URL of an imported filter list to the assetKey of // an existing stock list. if ( typeof details.toImport === 'string' ) { const assetKeyFromURL = function(url) { const needle = url.replace(/^https?:/, ''); for ( const [ assetKey, asset ] of metadata ) { if ( asset.content === 'internal' ) { continue; } if ( typeof asset.contentURL === 'string' ) { if ( asset.contentURL.endsWith(needle) ) { return assetKey; } continue; } if ( Array.isArray(asset.contentURL) === false ) { continue; } for ( let i = 0, n = asset.contentURL.length; i < n; i++ ) { if ( asset.contentURL[i].endsWith(needle) ) { return assetKey; } } } return url; }; const toImport = µm.assetKeysFromImportedAssets(details.toImport); for ( const url of toImport ) { if ( importedAssetKeys.has(url) ) { continue; } const assetKey = assetKeyFromURL(url); if ( assetKey === url ) { importedAssetKeys.add(assetKey); } selectedAssetKeys.add(assetKey); } } const bin = {}; let needReload = false; if ( details.toInline instanceof Object ) { const newInline = details.toInline; const oldInline = µmus[propInlineAsset]; newInline.content = newInline.content.trim(); if ( newInline.content.length !== 0 ) { newInline.content += '\n'; } const newContent = newInline.enabled ? newInline.content : ''; const oldContent = oldInline.enabled ? oldInline.content : ''; if ( newContent !== oldContent ) { needReload = true; } if ( newInline.enabled !== oldInline.enabled || newInline.content !== oldInline.content ) { µmus[propInlineAsset] = newInline; bin[propInlineAsset] = newInline; } } selectedAssetKeys = Array.from(selectedAssetKeys).sort(); µmus[propSelectedAssetKeys].sort(); if ( selectedAssetKeys.join() !== µmus[propSelectedAssetKeys].join() ) { µmus[propSelectedAssetKeys] = selectedAssetKeys; bin[propSelectedAssetKeys] = selectedAssetKeys; needReload = true; } importedAssetKeys = Array.from(importedAssetKeys).sort(); µmus[propImportedAssetKeys].sort(); if ( importedAssetKeys.join() !== µmus[propImportedAssetKeys].join() ) { µmus[propImportedAssetKeys] = importedAssetKeys; bin[propImportedAssetKeys] = importedAssetKeys; needReload = true; } if ( Object.keys(bin).length !== 0 ) { vAPI.storage.set(bin); } return needReload; }; const metadata = this.toMap(await this.assets.metadata()); const hostsChanged = applyAssetSelection( metadata, details.hosts, 'selectedHostsFiles', 'externalHostsFiles', 'userHosts' ); if ( hostsChanged ) { this.hostsFilesSelfie.destroy(); } const recipesChanged = applyAssetSelection( metadata, details.recipes, 'selectedRecipeFiles', 'externalRecipeFiles', 'userRecipes' ); return { hostsChanged, recipesChanged }; }; /******************************************************************************/ // `switches` contains the preset blacklists for which the switch must be // revisited. µMatrix.reloadHostsFiles = function() { this.loadHostsFiles(); }; /******************************************************************************/ µMatrix.hostsFilesSelfie = (( ) => { const µm = µMatrix; const magic = 1; let timer; const toSelfie = function() { const trieDetails = µm.ubiquitousBlacklist.optimize(); vAPI.localStorage.setItem( 'ubiquitousBlacklist.trieDetails', JSON.stringify(trieDetails) ); µm.cacheStorage.set({ hostsFilesSelfie: { magic, trie: µm.ubiquitousBlacklist.serialize(µm.base64), trieref: µm.ubiquitousBlacklist.compileOne(µm.ubiquitousBlacklistRef), } }); }; return { create: function() { this.cancel(); timer = vAPI.setTimeout( ( ) => { timer = undefined; toSelfie(); }, (µm.rawSettings.autoUpdateAssetFetchPeriod + 15) * 1000 ); }, destroy: function() { this.cancel(); µm.cacheStorage.remove('hostsFilesSelfie'); }, load: async function() { this.cancel(); const bin = await µm.cacheStorage.get('hostsFilesSelfie'); if ( bin instanceof Object === false || bin.hostsFilesSelfie instanceof Object === false || bin.hostsFilesSelfie.trie === undefined || bin.hostsFilesSelfie.trieref === undefined || bin.hostsFilesSelfie.magic !== magic ) { return false; } µm.ubiquitousBlacklist.unserialize( bin.hostsFilesSelfie.trie, µm.base64 ); µm.ubiquitousBlacklistRef = µm.ubiquitousBlacklist.createOne(bin.hostsFilesSelfie.trieref); return true; }, cancel: function() { if ( timer !== undefined ) { clearTimeout(timer); } timer = undefined; } }; })(); /******************************************************************************/ µMatrix.loadPublicSuffixList = async function() { // TODO: remove once all users are way past 1.4.0. this.cacheStorage.remove('publicSuffixListSelfie'); try { const result = await this.assets.get(`compiled/${this.pslAssetKey}`); if ( publicSuffixList.fromSelfie(result.content, this.base64) ) { return; } } catch (ex) { console.error(ex); return; } const result = await this.assets.get(this.pslAssetKey); if ( result.content !== '' ) { this.compilePublicSuffixList(result.content); } }; µMatrix.compilePublicSuffixList = function(content) { publicSuffixList.parse(content, punycode.toASCII); this.assets.put( 'compiled/' + this.pslAssetKey, publicSuffixList.toSelfie(µMatrix.base64) ); }; /******************************************************************************/ µMatrix.scheduleAssetUpdater = (( ) => { let timer, next = 0; return function(updateDelay) { if ( timer ) { clearTimeout(timer); timer = undefined; } if ( updateDelay === 0 ) { this.assets.updateStop(); next = 0; return; } const now = Date.now(); // Use the new schedule if and only if it is earlier than the previous // one. if ( next !== 0 ) { updateDelay = Math.min(updateDelay, Math.max(next - now, 0)); } next = now + updateDelay; timer = vAPI.setTimeout(( ) => { timer = undefined; next = 0; this.assets.updateStart({ delay: 120000 }); }, updateDelay); }; })(); /******************************************************************************/ µMatrix.assetObserver = function(topic, details) { const µmus = this.userSettings; // Do not update filter list if not in use. if ( topic === 'before-asset-updated' ) { if ( details.content === 'internal' || details.content === 'filters' && µmus.selectedHostsFiles.indexOf(details.assetKey) !== -1 || details.content === 'recipes' && µmus.selectedRecipeFiles.indexOf(details.assetKey) !== -1 ) { return true; } return; } if ( topic === 'after-asset-updated' ) { if ( details.content === 'filters' && µmus.selectedHostsFiles.indexOf(details.assetKey) !== -1 ) { this.hostsFilesSelfie.destroy(); } else if ( details.assetKey === this.pslAssetKey ) { this.compilePublicSuffixList(details.content); } vAPI.messaging.broadcast({ what: 'assetUpdated', key: details.assetKey, cached: true }); return; } // Update failed. if ( topic === 'asset-update-failed' ) { vAPI.messaging.broadcast({ what: 'assetUpdated', key: details.assetKey, failed: true }); return; } // Reload all filter lists if needed. if ( topic === 'after-assets-updated' ) { if ( this.arraysIntersect( details.assetKeys, µmus.selectedRecipeFiles ) ) { this.loadRecipes(true); } if ( this.arraysIntersect( details.assetKeys, µmus.selectedHostsFiles ) ) { this.hostsFilesSelfie.destroy(); this.loadHostsFiles(); } if ( µmus.autoUpdate ) { this.scheduleAssetUpdater(25200000); } else { this.scheduleAssetUpdater(0); } vAPI.messaging.broadcast({ what: 'assetsUpdated', assetKeys: details.assetKeys }); return; } };