prefs.sys.mjs (17410B)
1 /* This Source Code Form is subject to the terms of the Mozilla Public 2 * License, v. 2.0. If a copy of the MPL was not distributed with this 3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 5 // Prefs which start with this prefix are our "control" prefs - they indicate 6 // which preferences should be synced. 7 const PREF_SYNC_PREFS_PREFIX = "services.sync.prefs.sync."; 8 9 // Prefs which have a default value are usually not synced - however, if the 10 // preference exists under this prefix and the value is: 11 // * `true`, then we do sync default values. 12 // * `false`, then as soon as we ever sync a non-default value out, or sync 13 // any value in, then we toggle the value to `true`. 14 // 15 // We never explicitly set this pref back to false, so it's one-shot. 16 // Some preferences which are known to have a different default value on 17 // different platforms have this preference with a default value of `false`, 18 // so they don't sync until one device changes to the non-default value, then 19 // that value forever syncs, even if it gets reset back to the default. 20 // Note that preferences handled this way *must also* have the "normal" 21 // control pref set. 22 // A possible future enhancement would be to sync these prefs so that 23 // other distributions can flag them if they change the default, but that 24 // doesn't seem worthwhile until we can be confident they'd actually create 25 // this special control pref at the same time they flip the default. 26 const PREF_SYNC_SEEN_PREFIX = "services.sync.prefs.sync-seen."; 27 28 import { 29 Store, 30 SyncEngine, 31 Tracker, 32 } from "resource://services-sync/engines.sys.mjs"; 33 import { CryptoWrapper } from "resource://services-sync/record.sys.mjs"; 34 import { Svc, Utils } from "resource://services-sync/util.sys.mjs"; 35 import { SCORE_INCREMENT_XLARGE } from "resource://services-sync/constants.sys.mjs"; 36 import { CommonUtils } from "resource://services-common/utils.sys.mjs"; 37 38 const lazy = {}; 39 40 ChromeUtils.defineLazyGetter(lazy, "PREFS_GUID", () => 41 CommonUtils.encodeBase64URL(Services.appinfo.ID) 42 ); 43 44 ChromeUtils.defineESModuleGetters(lazy, { 45 AddonManager: "resource://gre/modules/AddonManager.sys.mjs", 46 }); 47 48 // In bug 1538015, we decided that it isn't always safe to allow all "incoming" 49 // preferences to be applied locally. So we introduced another preference to control 50 // this for backward compatibility. We removed that capability in bug 1854698, but in the 51 // interests of working well between different versions of Firefox, we still forever 52 // want to prevent this preference from syncing. 53 // This was the name of the "control" pref. 54 const PREF_SYNC_PREFS_ARBITRARY = 55 "services.sync.prefs.dangerously_allow_arbitrary"; 56 57 // Check for a local control pref or PREF_SYNC_PREFS_ARBITRARY 58 function isAllowedPrefName(prefName) { 59 if (prefName == PREF_SYNC_PREFS_ARBITRARY) { 60 return false; // never allow this. 61 } 62 // The pref must already have a control pref set, although it doesn't matter 63 // here whether that value is true or false. We can't use prefHasUserValue 64 // here because we also want to check prefs still with default values. 65 try { 66 Services.prefs.getBoolPref(PREF_SYNC_PREFS_PREFIX + prefName); 67 // pref exists! 68 return true; 69 } catch (_) { 70 return false; 71 } 72 } 73 74 export function PrefRec(collection, id) { 75 CryptoWrapper.call(this, collection, id); 76 } 77 78 PrefRec.prototype = { 79 _logName: "Sync.Record.Pref", 80 }; 81 Object.setPrototypeOf(PrefRec.prototype, CryptoWrapper.prototype); 82 83 Utils.deferGetSet(PrefRec, "cleartext", ["value"]); 84 85 export function PrefsEngine(service) { 86 SyncEngine.call(this, "Prefs", service); 87 } 88 89 PrefsEngine.prototype = { 90 _storeObj: PrefStore, 91 _trackerObj: PrefTracker, 92 _recordObj: PrefRec, 93 version: 2, 94 95 syncPriority: 1, 96 allowSkippedRecord: false, 97 98 async getChangedIDs() { 99 // No need for a proper timestamp (no conflict resolution needed). 100 let changedIDs = {}; 101 if (this._tracker.modified) { 102 changedIDs[lazy.PREFS_GUID] = 0; 103 } 104 return changedIDs; 105 }, 106 107 async _wipeClient() { 108 await SyncEngine.prototype._wipeClient.call(this); 109 this.justWiped = true; 110 }, 111 112 async _reconcile(item) { 113 // Apply the incoming item if we don't care about the local data 114 if (this.justWiped) { 115 this.justWiped = false; 116 return true; 117 } 118 return SyncEngine.prototype._reconcile.call(this, item); 119 }, 120 121 async _uploadOutgoing() { 122 try { 123 await SyncEngine.prototype._uploadOutgoing.call(this); 124 } finally { 125 this._store._incomingPrefs = null; 126 } 127 }, 128 129 async trackRemainingChanges() { 130 if (this._modified.count() > 0) { 131 this._tracker.modified = true; 132 } 133 }, 134 }; 135 Object.setPrototypeOf(PrefsEngine.prototype, SyncEngine.prototype); 136 137 // We don't use services.sync.engine.tabs.filteredSchemes since it includes 138 // about: pages and the like, which we want to be syncable in preferences. 139 // Blob, moz-extension, data and file uris are never safe to sync, 140 // so we limit our check to those. 141 const UNSYNCABLE_URL_REGEXP = /^(moz-extension|blob|data|file):/i; 142 function isUnsyncableURLPref(prefName) { 143 if (Services.prefs.getPrefType(prefName) != Ci.nsIPrefBranch.PREF_STRING) { 144 return false; 145 } 146 const prefValue = Services.prefs.getStringPref(prefName, ""); 147 return UNSYNCABLE_URL_REGEXP.test(prefValue); 148 } 149 150 function PrefStore(name, engine) { 151 Store.call(this, name, engine); 152 Svc.Obs.add( 153 "profile-before-change", 154 function () { 155 this.__prefs = null; 156 }, 157 this 158 ); 159 } 160 PrefStore.prototype = { 161 __prefs: null, 162 // used just for logging so we can work out why we chose to re-upload 163 _incomingPrefs: null, 164 get _prefs() { 165 if (!this.__prefs) { 166 this.__prefs = Services.prefs.getBranch(""); 167 } 168 return this.__prefs; 169 }, 170 171 _getSyncPrefs() { 172 let syncPrefs = Services.prefs 173 .getBranch(PREF_SYNC_PREFS_PREFIX) 174 .getChildList("") 175 .filter(pref => isAllowedPrefName(pref) && !isUnsyncableURLPref(pref)); 176 // Also sync preferences that determine which prefs get synced. 177 let controlPrefs = syncPrefs.map(pref => PREF_SYNC_PREFS_PREFIX + pref); 178 return controlPrefs.concat(syncPrefs); 179 }, 180 181 _isSynced(pref) { 182 if (pref.startsWith(PREF_SYNC_PREFS_PREFIX)) { 183 // this is an incoming control pref, which is ignored if there's not already 184 // a local control pref for the preference. 185 let controlledPref = pref.slice(PREF_SYNC_PREFS_PREFIX.length); 186 return isAllowedPrefName(controlledPref); 187 } 188 189 // This is the pref itself - it must be both allowed, and have a control 190 // pref which is true. 191 if (!this._prefs.getBoolPref(PREF_SYNC_PREFS_PREFIX + pref, false)) { 192 return false; 193 } 194 return isAllowedPrefName(pref); 195 }, 196 197 // Given a preference name, returns either a string, bool, number or null. 198 _getPrefValue(pref) { 199 switch (this._prefs.getPrefType(pref)) { 200 case Ci.nsIPrefBranch.PREF_STRING: 201 return this._prefs.getStringPref(pref); 202 case Ci.nsIPrefBranch.PREF_INT: 203 return this._prefs.getIntPref(pref); 204 case Ci.nsIPrefBranch.PREF_BOOL: 205 return this._prefs.getBoolPref(pref); 206 // case Ci.nsIPrefBranch.PREF_INVALID: handled by the fallthrough 207 } 208 return null; 209 }, 210 211 _getAllPrefs() { 212 let values = {}; 213 for (let pref of this._getSyncPrefs()) { 214 // Note: _isSynced doesn't call isUnsyncableURLPref since it would cause 215 // us not to apply (syncable) changes to preferences that are set locally 216 // which have unsyncable urls. 217 if (this._isSynced(pref) && !isUnsyncableURLPref(pref)) { 218 let isSet = this._prefs.prefHasUserValue(pref); 219 // Missing and default prefs get the null value, unless that `seen` 220 // pref is set, in which case it always gets the value. 221 let forceValue = this._prefs.getBoolPref( 222 PREF_SYNC_SEEN_PREFIX + pref, 223 false 224 ); 225 if (isSet || forceValue) { 226 values[pref] = this._getPrefValue(pref); 227 } else { 228 values[pref] = null; 229 } 230 // If incoming and outgoing don't match then either the user toggled a 231 // pref that doesn't match an incoming non-default value for that pref 232 // during a sync (unlikely!) or it refused to stick and is behaving oddly. 233 if (this._incomingPrefs) { 234 let inValue = this._incomingPrefs[pref]; 235 let outValue = values[pref]; 236 if (inValue != null && outValue != null && inValue != outValue) { 237 this._log.debug(`Incoming pref '${pref}' refused to stick?`); 238 this._log.trace(`Incoming: '${inValue}', outgoing: '${outValue}'`); 239 } 240 } 241 // If this is a special "sync-seen" pref, and it's not the default value, 242 // set the seen pref to true. 243 if ( 244 isSet && 245 this._prefs.getBoolPref(PREF_SYNC_SEEN_PREFIX + pref, false) === false 246 ) { 247 this._log.trace(`toggling sync-seen pref for '${pref}' to true`); 248 this._prefs.setBoolPref(PREF_SYNC_SEEN_PREFIX + pref, true); 249 } 250 } 251 } 252 return values; 253 }, 254 255 _maybeLogPrefChange(pref, incomingValue, existingValue) { 256 if (incomingValue != existingValue) { 257 this._log.debug(`Adjusting preference "${pref}" to the incoming value`); 258 // values are PII, so must only be logged at trace. 259 this._log.trace(`Existing: ${existingValue}. Incoming: ${incomingValue}`); 260 } 261 }, 262 263 async _setAllPrefs(values) { 264 const selectedThemeIDPref = "extensions.activeThemeID"; 265 const pendingThemePref = "extensions.pendingActiveThemeID"; 266 let selectedThemeIDBefore = this._prefs.getStringPref( 267 selectedThemeIDPref, 268 "" 269 ); 270 let selectedThemeIDAfter = selectedThemeIDBefore; 271 // Clear the pending theme pref that might've hung around 272 if (this._prefs.prefHasUserValue(pendingThemePref)) { 273 this._prefs.clearUserPref(pendingThemePref); 274 } 275 276 // Update 'services.sync.prefs.sync.foo.pref' before 'foo.pref', otherwise 277 // _isSynced returns false when 'foo.pref' doesn't exist (e.g., on a new device). 278 let prefs = Object.keys(values).sort( 279 a => -a.indexOf(PREF_SYNC_PREFS_PREFIX) 280 ); 281 for (let pref of prefs) { 282 let value = values[pref]; 283 if (!this._isSynced(pref)) { 284 // It's unusual for us to find an incoming preference (ie, a pref some other 285 // instance thinks is syncable) which we don't think is syncable. 286 this._log.trace(`Ignoring incoming unsyncable preference "${pref}"`); 287 continue; 288 } 289 290 if (typeof value == "string" && UNSYNCABLE_URL_REGEXP.test(value)) { 291 this._log.trace(`Skipping incoming unsyncable url for pref: ${pref}`); 292 continue; 293 } 294 295 switch (pref) { 296 // Some special prefs we don't want to set directly. 297 case selectedThemeIDPref: 298 selectedThemeIDAfter = value; 299 break; 300 301 // default is to just set the pref 302 default: { 303 if (value == null) { 304 // Pref has gone missing. The best we can do is reset it. 305 if (this._prefs.prefHasUserValue(pref)) { 306 this._log.debug(`Clearing existing local preference "${pref}"`); 307 this._log.trace( 308 `Existing local value for preference: ${this._getPrefValue( 309 pref 310 )}` 311 ); 312 } 313 this._prefs.clearUserPref(pref); 314 } else { 315 try { 316 switch (typeof value) { 317 case "string": 318 this._maybeLogPrefChange( 319 pref, 320 value, 321 this._prefs.getStringPref(pref, undefined) 322 ); 323 this._prefs.setStringPref(pref, value); 324 break; 325 case "number": 326 this._maybeLogPrefChange( 327 pref, 328 value, 329 this._prefs.getIntPref(pref, undefined) 330 ); 331 this._prefs.setIntPref(pref, value); 332 break; 333 case "boolean": 334 this._maybeLogPrefChange( 335 pref, 336 value, 337 this._prefs.getBoolPref(pref, undefined) 338 ); 339 this._prefs.setBoolPref(pref, value); 340 break; 341 } 342 } catch (ex) { 343 this._log.trace(`Failed to set pref: ${pref}`, ex); 344 } 345 } 346 // If there's a "sync-seen" pref for this it gets toggled to true 347 // regardless of the value. 348 let seenPref = PREF_SYNC_SEEN_PREFIX + pref; 349 if ( 350 this._prefs.getPrefType(seenPref) != Ci.nsIPrefBranch.PREF_INVALID 351 ) { 352 this._prefs.setBoolPref(PREF_SYNC_SEEN_PREFIX + pref, true); 353 } 354 } 355 } 356 } 357 // Themes are a little messy. Themes which have been installed are handled 358 // by the addons engine - but default themes aren't seen by that engine. 359 // So if there's a new default theme ID and that ID corresponds to a 360 // system addon, then we arrange to enable that addon here. 361 if (selectedThemeIDBefore != selectedThemeIDAfter) { 362 // We need to await before continuing here because enabling theme-addons 363 // also sets the extensions.activeThemeId pref, if we don't 364 // there are scenarios where the prefs thought we didn't 365 // actually set the pref and could cause unintended theme resets 366 try { 367 await this._maybeEnableBuiltinTheme(selectedThemeIDAfter); 368 } catch (e) { 369 this._log.error("Failed to maybe update the default theme", e); 370 } 371 } 372 }, 373 374 async _maybeEnableBuiltinTheme(themeId) { 375 let addon = null; 376 try { 377 addon = await lazy.AddonManager.getAddonByID(themeId); 378 } catch (ex) { 379 this._log.trace( 380 `There's no addon with ID '${themeId} - it can't be a builtin theme` 381 ); 382 return; 383 } 384 if (addon && addon.isBuiltin && addon.type == "theme") { 385 this._log.trace(`Enabling builtin theme '${themeId}'`); 386 await addon.enable(); 387 } else { 388 // We set this pref if the theme is not built-in and instead need to pass it 389 // to the addons engine and enable, see addonutils for more info 390 this._prefs.setStringPref("extensions.pendingActiveThemeID", themeId); 391 this._log.trace( 392 `Have incoming theme ID of '${themeId}' but it's not a builtin theme, 393 setting extensions.pendingActiveThemeID so addons engine can enable it` 394 ); 395 } 396 }, 397 398 async getAllIDs() { 399 /* We store all prefs in just one WBO, with just one GUID */ 400 let allprefs = {}; 401 allprefs[lazy.PREFS_GUID] = true; 402 return allprefs; 403 }, 404 405 async changeItemID() { 406 this._log.trace("PrefStore GUID is constant!"); 407 }, 408 409 async itemExists(id) { 410 return id === lazy.PREFS_GUID; 411 }, 412 413 async createRecord(id, collection) { 414 let record = new PrefRec(collection, id); 415 416 if (id == lazy.PREFS_GUID) { 417 record.value = this._getAllPrefs(); 418 } else { 419 record.deleted = true; 420 } 421 422 return record; 423 }, 424 425 async create() { 426 this._log.trace("Ignoring create request"); 427 }, 428 429 async remove() { 430 this._log.trace("Ignoring remove request"); 431 }, 432 433 async update(record) { 434 // Silently ignore pref updates that are for other apps. 435 if (record.id != lazy.PREFS_GUID) { 436 return; 437 } 438 439 this._log.trace("Received pref updates, applying..."); 440 this._incomingPrefs = record.value; 441 await this._setAllPrefs(record.value); 442 }, 443 444 async wipe() { 445 this._log.trace("Ignoring wipe request"); 446 }, 447 }; 448 Object.setPrototypeOf(PrefStore.prototype, Store.prototype); 449 450 function PrefTracker(name, engine) { 451 Tracker.call(this, name, engine); 452 this._ignoreAll = false; 453 Svc.Obs.add("profile-before-change", this.asyncObserver); 454 } 455 PrefTracker.prototype = { 456 get ignoreAll() { 457 return this._ignoreAll; 458 }, 459 460 set ignoreAll(value) { 461 this._ignoreAll = value; 462 }, 463 464 get modified() { 465 return Svc.PrefBranch.getBoolPref("engine.prefs.modified", false); 466 }, 467 set modified(value) { 468 Svc.PrefBranch.setBoolPref("engine.prefs.modified", value); 469 }, 470 471 clearChangedIDs: function clearChangedIDs() { 472 this.modified = false; 473 }, 474 475 __prefs: null, 476 get _prefs() { 477 if (!this.__prefs) { 478 this.__prefs = Services.prefs.getBranch(""); 479 } 480 return this.__prefs; 481 }, 482 483 onStart() { 484 Services.prefs.addObserver("", this.asyncObserver); 485 }, 486 487 onStop() { 488 this.__prefs = null; 489 Services.prefs.removeObserver("", this.asyncObserver); 490 }, 491 492 async observe(subject, topic, data) { 493 switch (topic) { 494 case "profile-before-change": 495 await this.stop(); 496 break; 497 case "nsPref:changed": 498 if (this.ignoreAll) { 499 break; 500 } 501 // Trigger a sync for MULTI-DEVICE for a change that determines 502 // which prefs are synced or a regular pref change. 503 if ( 504 data.indexOf(PREF_SYNC_PREFS_PREFIX) == 0 || 505 this._prefs.getBoolPref(PREF_SYNC_PREFS_PREFIX + data, false) 506 ) { 507 this.score += SCORE_INCREMENT_XLARGE; 508 this.modified = true; 509 this._log.trace("Preference " + data + " changed"); 510 } 511 break; 512 } 513 }, 514 }; 515 Object.setPrototypeOf(PrefTracker.prototype, Tracker.prototype); 516 517 export function getPrefsGUIDForTest() { 518 return lazy.PREFS_GUID; 519 }