/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */"use strict";ChromeUtils.import("resource://gre/modules/Services.jsm");ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm");ChromeUtils.import("resource://normandy/lib/LogManager.jsm");XPCOMUtils.defineLazyServiceGetter(this,"timerManager","@mozilla.org/updates/timer-manager;1","nsIUpdateTimerManager");XPCOMUtils.defineLazyModuleGetters(this,{Storage:"resource://normandy/lib/Storage.jsm",FilterExpressions:"resource://gre/modules/components-utils/FilterExpressions.jsm",NormandyApi:"resource://normandy/lib/NormandyApi.jsm",ClientEnvironment:"resource://normandy/lib/ClientEnvironment.jsm",CleanupManager:"resource://normandy/lib/CleanupManager.jsm",AddonStudies:"resource://normandy/lib/AddonStudies.jsm",Uptake:"resource://normandy/lib/Uptake.jsm",ActionsManager:"resource://normandy/lib/ActionsManager.jsm",});varEXPORTED_SYMBOLS=["RecipeRunner"];constlog=LogManager.getLogger("recipe-runner");constTIMER_NAME="recipe-client-addon-run";constPREF_CHANGED_TOPIC="nsPref:changed";constTELEMETRY_ENABLED_PREF="datareporting.healthreport.uploadEnabled";constPREF_PREFIX="app.normandy";constRUN_INTERVAL_PREF=`${PREF_PREFIX}.run_interval_seconds`;constFIRST_RUN_PREF=`${PREF_PREFIX}.first_run`;constSHIELD_ENABLED_PREF=`${PREF_PREFIX}.enabled`;constDEV_MODE_PREF=`${PREF_PREFIX}.dev_mode`;constAPI_URL_PREF=`${PREF_PREFIX}.api_url`;constLAZY_CLASSIFY_PREF=`${PREF_PREFIX}.experiments.lazy_classify`;constPREFS_TO_WATCH=[RUN_INTERVAL_PREF,TELEMETRY_ENABLED_PREF,SHIELD_ENABLED_PREF,API_URL_PREF,];/** * cacheProxy returns an object Proxy that will memoize properties of the target. */functioncacheProxy(target){constcache=newMap();returnnewProxy(target,{get(target,prop,receiver){if(!cache.has(prop)){cache.set(prop,target[prop]);}returncache.get(prop);},});}varRecipeRunner={asyncinit(){this.enabled=null;this.checkPrefs();// sets this.enabledthis.watchPrefs();// Run if enabled immediately on first run, or if dev mode is enabled.constfirstRun=Services.prefs.getBoolPref(FIRST_RUN_PREF,true);constdevMode=Services.prefs.getBoolPref(DEV_MODE_PREF,false);if(this.enabled&&(devMode||firstRun)){awaitthis.run();}if(firstRun){Services.prefs.setBoolPref(FIRST_RUN_PREF,false);}},enable(){if(this.enabled){return;}this.registerTimer();this.enabled=true;},disable(){if(this.enabled){this.unregisterTimer();}// this.enabled may be null, so always set it to falsethis.enabled=false;},/** Watch for prefs to change, and call this.observer when they do */watchPrefs(){for(constprefofPREFS_TO_WATCH){Services.prefs.addObserver(pref,this);}CleanupManager.addCleanupHandler(this.unwatchPrefs.bind(this));},unwatchPrefs(){for(constprefofPREFS_TO_WATCH){Services.prefs.removeObserver(pref,this);}},/** When prefs change, this is fired */observe(subject,topic,data){switch(topic){casePREF_CHANGED_TOPIC:{constprefName=data;switch(prefName){caseRUN_INTERVAL_PREF:this.updateRunInterval();break;// explicit fall-throughcaseTELEMETRY_ENABLED_PREF:caseSHIELD_ENABLED_PREF:caseAPI_URL_PREF:this.checkPrefs();break;default:log.debug(`Observer fired with unexpected pref change: ${prefName}`);}break;}}},checkPrefs(){// Only run if Unified Telemetry is enabled.if(!Services.prefs.getBoolPref(TELEMETRY_ENABLED_PREF)){log.debug("Disabling RecipeRunner because Unified Telemetry is disabled.");this.disable();return;}if(!Services.prefs.getBoolPref(SHIELD_ENABLED_PREF)){log.debug(`Disabling Shield because ${SHIELD_ENABLED_PREF} is set to false`);this.disable();return;}if(!Services.policies.isAllowed("Shield")){log.debug("Disabling Shield because it's blocked by policy.");this.disable();return;}constapiUrl=Services.prefs.getCharPref(API_URL_PREF);if(!apiUrl){log.warn(`Disabling Shield because ${API_URL_PREF} is not set.`);this.disable();return;}if(!apiUrl.startsWith("https://")){log.warn(`Disabling Shield because ${API_URL_PREF} is not an HTTPS url: ${apiUrl}.`);this.disable();return;}log.debug(`Enabling Shield`);this.enable();},registerTimer(){this.updateRunInterval();CleanupManager.addCleanupHandler(()=>timerManager.unregisterTimer(TIMER_NAME));},unregisterTimer(){timerManager.unregisterTimer(TIMER_NAME);},updateRunInterval(){// Run once every `runInterval` wall-clock seconds. This is managed by setting a "last ran"// timestamp, and running if it is more than `runInterval` seconds ago. Even with very short// intervals, the timer will only fire at most once every few minutes.construnInterval=Services.prefs.getIntPref(RUN_INTERVAL_PREF);timerManager.registerTimer(TIMER_NAME,()=>this.run(),runInterval);},asyncrun(){this.clearCaches();// Unless lazy classification is enabled, prep the classify cache.if(!Services.prefs.getBoolPref(LAZY_CLASSIFY_PREF,false)){try{awaitClientEnvironment.getClientClassification();}catch(err){// Try to go on without this data; the filter expressions will// gracefully fail without this info if they need it.}}// Fetch recipes before execution in case we fail and exit early.letrecipes;try{recipes=awaitNormandyApi.fetchRecipes({enabled:true});log.debug(`Fetched ${recipes.length} recipes from the server: `+recipes.map(r=>r.name).join(", "));}catch(e){constapiUrl=Services.prefs.getCharPref(API_URL_PREF);log.error(`Could not fetch recipes from ${apiUrl}: "${e}"`);letstatus=Uptake.RUNNER_SERVER_ERROR;if(/NetworkError/.test(e)){status=Uptake.RUNNER_NETWORK_ERROR;}elseif(einstanceofNormandyApi.InvalidSignatureError){status=Uptake.RUNNER_INVALID_SIGNATURE;}Uptake.reportRunner(status);return;}constactions=newActionsManager();awaitactions.fetchRemoteActions();awaitactions.preExecution();// Evaluate recipe filtersconstrecipesToRun=[];for(constrecipeofrecipes){if(awaitthis.checkFilter(recipe)){recipesToRun.push(recipe);}}// Execute recipes, if we have any.if(recipesToRun.length===0){log.debug("No recipes to execute");}else{for(constrecipeofrecipesToRun){awaitactions.runRecipe(recipe);}}awaitactions.finalize();// Close storage connectionsawaitAddonStudies.close();Uptake.reportRunner(Uptake.RUNNER_SUCCESS);},getFilterContext(recipe){constenvironment=cacheProxy(ClientEnvironment);environment.recipe={id:recipe.id,arguments:recipe.arguments,};return{normandy:environment,};},/** * Evaluate a recipe's filter expression against the environment. * @param {object} recipe * @param {string} recipe.filter The expression to evaluate against the environment. * @return {boolean} The result of evaluating the filter, cast to a bool, or false * if an error occurred during evaluation. */asynccheckFilter(recipe){constcontext=this.getFilterContext(recipe);try{constresult=awaitFilterExpressions.eval(recipe.filter_expression,context);return!!result;}catch(err){log.error(`Error checking filter for "${recipe.name}". Filter: [${recipe.filter_expression}]. Error: "${err}"`);returnfalse;}},/** * Clear all caches of systems used by RecipeRunner, in preparation * for a clean run. */clearCaches(){ClientEnvironment.clearClassifyCache();NormandyApi.clearIndexCache();},/** * Clear out cached state and fetch/execute recipes from the given * API url. This is used mainly by the mock-recipe-server JS that is * executed in the browser console. */asynctestRun(baseApiUrl){constoldApiUrl=Services.prefs.getCharPref(API_URL_PREF);Services.prefs.setCharPref(API_URL_PREF,baseApiUrl);try{Storage.clearAllStorage();this.clearCaches();awaitthis.run();}finally{Services.prefs.setCharPref(API_URL_PREF,oldApiUrl);this.clearCaches();}},};