ASRouterTargeting.jsm 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419
  1. const {FilterExpressions} = ChromeUtils.import("resource://gre/modules/components-utils/FilterExpressions.jsm");
  2. const {Services} = ChromeUtils.import("resource://gre/modules/Services.jsm");
  3. ChromeUtils.defineModuleGetter(this, "ASRouterPreferences",
  4. "resource://activity-stream/lib/ASRouterPreferences.jsm");
  5. ChromeUtils.defineModuleGetter(this, "AddonManager",
  6. "resource://gre/modules/AddonManager.jsm");
  7. ChromeUtils.defineModuleGetter(this, "NewTabUtils",
  8. "resource://gre/modules/NewTabUtils.jsm");
  9. ChromeUtils.defineModuleGetter(this, "ProfileAge",
  10. "resource://gre/modules/ProfileAge.jsm");
  11. ChromeUtils.defineModuleGetter(this, "ShellService",
  12. "resource:///modules/ShellService.jsm");
  13. ChromeUtils.defineModuleGetter(this, "TelemetryEnvironment",
  14. "resource://gre/modules/TelemetryEnvironment.jsm");
  15. ChromeUtils.defineModuleGetter(this, "AppConstants",
  16. "resource://gre/modules/AppConstants.jsm");
  17. ChromeUtils.defineModuleGetter(this, "AttributionCode",
  18. "resource:///modules/AttributionCode.jsm");
  19. const FXA_USERNAME_PREF = "services.sync.username";
  20. const FXA_ENABLED_PREF = "identity.fxaccounts.enabled";
  21. const SEARCH_REGION_PREF = "browser.search.region";
  22. const MOZ_JEXL_FILEPATH = "mozjexl";
  23. const {activityStreamProvider: asProvider} = NewTabUtils;
  24. const FRECENT_SITES_UPDATE_INTERVAL = 6 * 60 * 60 * 1000; // Six hours
  25. const FRECENT_SITES_IGNORE_BLOCKED = false;
  26. const FRECENT_SITES_NUM_ITEMS = 25;
  27. const FRECENT_SITES_MIN_FRECENCY = 100;
  28. /**
  29. * CachedTargetingGetter
  30. * @param property {string} Name of the method called on ActivityStreamProvider
  31. * @param options {{}?} Options object passsed to ActivityStreamProvider method
  32. * @param updateInterval {number?} Update interval for query. Defaults to FRECENT_SITES_UPDATE_INTERVAL
  33. */
  34. function CachedTargetingGetter(property, options = null, updateInterval = FRECENT_SITES_UPDATE_INTERVAL) {
  35. return {
  36. _lastUpdated: 0,
  37. _value: null,
  38. // For testing
  39. expire() {
  40. this._lastUpdated = 0;
  41. this._value = null;
  42. },
  43. get() {
  44. return new Promise(async (resolve, reject) => {
  45. const now = Date.now();
  46. if (now - this._lastUpdated >= updateInterval) {
  47. try {
  48. this._value = await asProvider[property](options);
  49. this._lastUpdated = now;
  50. } catch (e) {
  51. Cu.reportError(e);
  52. reject(e);
  53. }
  54. }
  55. resolve(this._value);
  56. });
  57. },
  58. };
  59. }
  60. function CheckBrowserNeedsUpdate(updateInterval = FRECENT_SITES_UPDATE_INTERVAL) {
  61. const UpdateChecker = Cc["@mozilla.org/updates/update-checker;1"];
  62. const checker = {
  63. _lastUpdated: 0,
  64. _value: null,
  65. // For testing. Avoid update check network call.
  66. setUp(value) {
  67. this._lastUpdated = Date.now();
  68. this._value = value;
  69. },
  70. expire() {
  71. this._lastUpdated = 0;
  72. this._value = null;
  73. },
  74. get() {
  75. return new Promise((resolve, reject) => {
  76. const now = Date.now();
  77. const updateServiceListener = {
  78. onCheckComplete(request, updates, updateCount) {
  79. checker._value = updateCount > 0;
  80. resolve(checker._value);
  81. },
  82. onError(request, update) {
  83. reject(request);
  84. },
  85. QueryInterface: ChromeUtils.generateQI(["nsIUpdateCheckListener"]),
  86. };
  87. if (UpdateChecker && (now - this._lastUpdated >= updateInterval)) {
  88. const checkerInstance = UpdateChecker.createInstance(Ci.nsIUpdateChecker);
  89. checkerInstance.checkForUpdates(updateServiceListener, true);
  90. this._lastUpdated = now;
  91. } else {
  92. resolve(this._value);
  93. }
  94. });
  95. },
  96. };
  97. return checker;
  98. }
  99. const QueryCache = {
  100. expireAll() {
  101. Object.keys(this.queries).forEach(query => {
  102. this.queries[query].expire();
  103. });
  104. },
  105. queries: {
  106. TopFrecentSites: new CachedTargetingGetter(
  107. "getTopFrecentSites",
  108. {
  109. ignoreBlocked: FRECENT_SITES_IGNORE_BLOCKED,
  110. numItems: FRECENT_SITES_NUM_ITEMS,
  111. topsiteFrecency: FRECENT_SITES_MIN_FRECENCY,
  112. onePerDomain: true,
  113. includeFavicon: false,
  114. }
  115. ),
  116. TotalBookmarksCount: new CachedTargetingGetter("getTotalBookmarksCount"),
  117. CheckBrowserNeedsUpdate: new CheckBrowserNeedsUpdate(),
  118. },
  119. };
  120. /**
  121. * sortMessagesByWeightedRank
  122. *
  123. * Each message has an associated weight, which is guaranteed to be strictly
  124. * positive. Sort the messages so that higher weighted messages are more likely
  125. * to come first.
  126. *
  127. * Specifically, sort them so that the probability of message x_1 with weight
  128. * w_1 appearing before message x_2 with weight w_2 is (w_1 / (w_1 + w_2)).
  129. *
  130. * This is equivalent to requiring that x_1 appearing before x_2 is (w_1 / w_2)
  131. * "times" as likely as x_2 appearing before x_1.
  132. *
  133. * See Bug 1484996, Comment 2 for a justification of the method.
  134. *
  135. * @param {Array} messages - A non-empty array of messages to sort, all with
  136. * strictly positive weights
  137. * @returns the sorted array
  138. */
  139. function sortMessagesByWeightedRank(messages) {
  140. return messages
  141. .map(message => ({message, rank: Math.pow(Math.random(), 1 / message.weight)}))
  142. .sort((a, b) => b.rank - a.rank)
  143. .map(({message}) => message);
  144. }
  145. /**
  146. * Messages with targeting should get evaluated first, this way we can have
  147. * fallback messages (no targeting at all) that will show up if nothing else
  148. * matched
  149. */
  150. function sortMessagesByTargeting(messages) {
  151. return messages.sort((a, b) => {
  152. if (a.targeting && !b.targeting) {
  153. return -1;
  154. }
  155. if (!a.targeting && b.targeting) {
  156. return 1;
  157. }
  158. return 0;
  159. });
  160. }
  161. const TargetingGetters = {
  162. get locale() {
  163. return Services.locale.appLocaleAsLangTag;
  164. },
  165. get localeLanguageCode() {
  166. return Services.locale.appLocaleAsLangTag && Services.locale.appLocaleAsLangTag.substr(0, 2);
  167. },
  168. get browserSettings() {
  169. const {settings} = TelemetryEnvironment.currentEnvironment;
  170. return {
  171. // This way of getting attribution is deprecated - use atttributionData instead
  172. attribution: settings.attribution,
  173. update: settings.update,
  174. };
  175. },
  176. get attributionData() {
  177. // Attribution is determined at startup - so we can use the cached attribution at this point
  178. return AttributionCode.getCachedAttributionData();
  179. },
  180. get currentDate() {
  181. return new Date();
  182. },
  183. get profileAgeCreated() {
  184. return ProfileAge().then(times => times.created);
  185. },
  186. get profileAgeReset() {
  187. return ProfileAge().then(times => times.reset);
  188. },
  189. get usesFirefoxSync() {
  190. return Services.prefs.prefHasUserValue(FXA_USERNAME_PREF);
  191. },
  192. get isFxAEnabled() {
  193. return Services.prefs.getBoolPref(FXA_ENABLED_PREF, true);
  194. },
  195. get sync() {
  196. return {
  197. desktopDevices: Services.prefs.getIntPref("services.sync.clients.devices.desktop", 0),
  198. mobileDevices: Services.prefs.getIntPref("services.sync.clients.devices.mobile", 0),
  199. totalDevices: Services.prefs.getIntPref("services.sync.numClients", 0),
  200. };
  201. },
  202. get xpinstallEnabled() {
  203. // This is needed for all add-on recommendations, to know if we allow xpi installs in the first place
  204. return Services.prefs.getBoolPref("xpinstall.enabled", true);
  205. },
  206. get addonsInfo() {
  207. return AddonManager.getActiveAddons(["extension", "service"])
  208. .then(({addons, fullData}) => {
  209. const info = {};
  210. for (const addon of addons) {
  211. info[addon.id] = {
  212. version: addon.version,
  213. type: addon.type,
  214. isSystem: addon.isSystem,
  215. isWebExtension: addon.isWebExtension,
  216. };
  217. if (fullData) {
  218. Object.assign(info[addon.id], {
  219. name: addon.name,
  220. userDisabled: addon.userDisabled,
  221. installDate: addon.installDate,
  222. });
  223. }
  224. }
  225. return {addons: info, isFullData: fullData};
  226. });
  227. },
  228. get searchEngines() {
  229. return new Promise(resolve => {
  230. // Note: calling init ensures this code is only executed after Search has been initialized
  231. Services.search.getVisibleEngines().then(engines => {
  232. resolve({
  233. current: Services.search.defaultEngine.identifier,
  234. installed: engines
  235. .map(engine => engine.identifier)
  236. .filter(engine => engine),
  237. });
  238. }).catch(() => resolve({installed: [], current: ""}));
  239. });
  240. },
  241. get isDefaultBrowser() {
  242. try {
  243. return ShellService.isDefaultBrowser();
  244. } catch (e) {}
  245. return null;
  246. },
  247. get devToolsOpenedCount() {
  248. return Services.prefs.getIntPref("devtools.selfxss.count");
  249. },
  250. get topFrecentSites() {
  251. return QueryCache.queries.TopFrecentSites.get().then(sites => sites.map(site => (
  252. {
  253. url: site.url,
  254. host: (new URL(site.url)).hostname,
  255. frecency: site.frecency,
  256. lastVisitDate: site.lastVisitDate,
  257. }
  258. )));
  259. },
  260. get pinnedSites() {
  261. return NewTabUtils.pinnedLinks.links.map(site => (site ? {
  262. url: site.url,
  263. host: (new URL(site.url)).hostname,
  264. searchTopSite: site.searchTopSite,
  265. } : {}));
  266. },
  267. get providerCohorts() {
  268. return ASRouterPreferences.providers.reduce((prev, current) => {
  269. prev[current.id] = current.cohort || "";
  270. return prev;
  271. }, {});
  272. },
  273. get totalBookmarksCount() {
  274. return QueryCache.queries.TotalBookmarksCount.get();
  275. },
  276. get firefoxVersion() {
  277. return parseInt(AppConstants.MOZ_APP_VERSION.match(/\d+/), 10);
  278. },
  279. get region() {
  280. return Services.prefs.getStringPref(SEARCH_REGION_PREF, "");
  281. },
  282. get needsUpdate() {
  283. return QueryCache.queries.CheckBrowserNeedsUpdate.get();
  284. },
  285. get hasPinnedTabs() {
  286. for (let win of Services.wm.getEnumerator("navigator:browser")) {
  287. if (win.closed || !win.ownerGlobal.gBrowser) {
  288. continue;
  289. }
  290. if (win.ownerGlobal.gBrowser.visibleTabs.filter(t => t.pinned).length) {
  291. return true;
  292. }
  293. }
  294. return false;
  295. },
  296. };
  297. this.ASRouterTargeting = {
  298. Environment: TargetingGetters,
  299. ERROR_TYPES: {
  300. MALFORMED_EXPRESSION: "MALFORMED_EXPRESSION",
  301. OTHER_ERROR: "OTHER_ERROR",
  302. },
  303. // Combines the getter properties of two objects without evaluating them
  304. combineContexts(contextA = {}, contextB = {}) {
  305. const sameProperty = Object.keys(contextA).find(p => Object.keys(contextB).includes(p));
  306. if (sameProperty) {
  307. Cu.reportError(`Property ${sameProperty} exists in both contexts and is overwritten.`);
  308. }
  309. const context = {};
  310. Object.defineProperties(context, Object.getOwnPropertyDescriptors(contextA));
  311. Object.defineProperties(context, Object.getOwnPropertyDescriptors(contextB));
  312. return context;
  313. },
  314. isMatch(filterExpression, customContext) {
  315. return FilterExpressions.eval(filterExpression, this.combineContexts(this.Environment, customContext));
  316. },
  317. isTriggerMatch(trigger = {}, candidateMessageTrigger = {}) {
  318. if (trigger.id !== candidateMessageTrigger.id) {
  319. return false;
  320. } else if (!candidateMessageTrigger.params && !candidateMessageTrigger.patterns) {
  321. return true;
  322. }
  323. if (!trigger.param) {
  324. return false;
  325. }
  326. return (candidateMessageTrigger.params &&
  327. candidateMessageTrigger.params.includes(trigger.param.host)) ||
  328. (candidateMessageTrigger.patterns &&
  329. new MatchPatternSet(candidateMessageTrigger.patterns).matches(trigger.param.url));
  330. },
  331. /**
  332. * checkMessageTargeting - Checks is a message's targeting parameters are satisfied
  333. *
  334. * @param {*} message An AS router message
  335. * @param {obj} context A FilterExpression context
  336. * @param {func} onError A function to handle errors (takes two params; error, message)
  337. * @returns
  338. */
  339. async checkMessageTargeting(message, context, onError) {
  340. // If no targeting is specified,
  341. if (!message.targeting) {
  342. return true;
  343. }
  344. let result;
  345. try {
  346. result = await this.isMatch(message.targeting, context);
  347. } catch (error) {
  348. Cu.reportError(error);
  349. if (onError) {
  350. const type = error.fileName.includes(MOZ_JEXL_FILEPATH) ? this.ERROR_TYPES.MALFORMED_EXPRESSION : this.ERROR_TYPES.OTHER_ERROR;
  351. onError(type, error, message);
  352. }
  353. result = false;
  354. }
  355. return result;
  356. },
  357. /**
  358. * findMatchingMessage - Given an array of messages, returns one message
  359. * whos targeting expression evaluates to true
  360. *
  361. * @param {Array} messages An array of AS router messages
  362. * @param {obj} impressions An object containing impressions, where keys are message ids
  363. * @param {trigger} string A trigger expression if a message for that trigger is desired
  364. * @param {obj|null} context A FilterExpression context. Defaults to TargetingGetters above.
  365. * @returns {obj} an AS router message
  366. */
  367. async findMatchingMessage({messages, trigger, context, onError}) {
  368. const weightSortedMessages = sortMessagesByWeightedRank([...messages]);
  369. const sortedMessages = sortMessagesByTargeting(weightSortedMessages);
  370. const triggerContext = trigger ? trigger.context : {};
  371. const combinedContext = this.combineContexts(context, triggerContext);
  372. for (const candidate of sortedMessages) {
  373. if (
  374. candidate &&
  375. (trigger ? this.isTriggerMatch(trigger, candidate.trigger) : !candidate.trigger) &&
  376. // If a trigger expression was passed to this function, the message should match it.
  377. // Otherwise, we should choose a message with no trigger property (i.e. a message that can show up at any time)
  378. await this.checkMessageTargeting(candidate, combinedContext, onError)
  379. ) {
  380. return candidate;
  381. }
  382. }
  383. return null;
  384. },
  385. };
  386. // Export for testing
  387. this.QueryCache = QueryCache;
  388. this.CachedTargetingGetter = CachedTargetingGetter;
  389. this.EXPORTED_SYMBOLS = ["ASRouterTargeting", "QueryCache", "CachedTargetingGetter"];