From a3b8ffea415b737e3b6c248c987e40cc62126fe1 Mon Sep 17 00:00:00 2001 From: Dave Townsend Date: Tue, 12 Nov 2024 15:16:46 +0000 Subject: [PATCH] Bug 1926507: Badge the taskbar/dock with the profile avatar. r=niklas,jhirsch Differential Revision: https://phabricator.services.mozilla.com/D228439 --- .../profiles/SelectableProfile.sys.mjs | 9 ++ .../profiles/SelectableProfileService.sys.mjs | 139 +++++++++++++----- .../components/profiles/tests/browser/head.js | 4 +- .../components/profiles/tests/unit/head.js | 13 ++ .../test_selectable_profiles_lifecycle.js | 79 +++++++++- .../test_shared_prefs_lifecycles_methods.js | 13 -- 6 files changed, 208 insertions(+), 49 deletions(-) diff --git a/browser/components/profiles/SelectableProfile.sys.mjs b/browser/components/profiles/SelectableProfile.sys.mjs index d4570e6369cb..a657700fd129 100644 --- a/browser/components/profiles/SelectableProfile.sys.mjs +++ b/browser/components/profiles/SelectableProfile.sys.mjs @@ -134,6 +134,15 @@ export class SelectableProfile { }; } + get iconPaintContext() { + return { + fillColor: this.#themeBg, + strokeColor: this.#themeFg, + fillOpacity: 1.0, + strokeOpacity: 1.0, + }; + } + /** * Update the theme (all three properties are required), then trigger saving * the profile, which will notify() other running instances. diff --git a/browser/components/profiles/SelectableProfileService.sys.mjs b/browser/components/profiles/SelectableProfileService.sys.mjs index 4d8fd57636b5..68c3f4a8517b 100644 --- a/browser/components/profiles/SelectableProfileService.sys.mjs +++ b/browser/components/profiles/SelectableProfileService.sys.mjs @@ -26,6 +26,8 @@ const NOTIFY_TIMEOUT = 200; const COMMAND_LINE_UPDATE = "profiles-updated"; const COMMAND_LINE_ACTIVATE = "profiles-activate"; +const gSupportsBadging = "nsIMacDockSupport" in Ci || "nsIWinTaskbar" in Ci; + function loadImage(url) { return new Promise((resolve, reject) => { let imageTools = Cc["@mozilla.org/image/tools;1"].getService(Ci.imgITools); @@ -33,6 +35,7 @@ function loadImage(url) { let observer = imageTools.createScriptedObserver({ sizeAvailable() { resolve(imageContainer); + imageContainer = null; }, }); @@ -58,36 +61,6 @@ function loadImage(url) { }); } -// This is waiting to be used by bug 1926507. -// eslint-disable-next-line no-unused-vars -async function updateTaskbar(iconUrl, profileName, strokeColor, fillColor) { - try { - let image = await loadImage(iconUrl); - - if ("nsIMacDockSupport" in Ci) { - Cc["@mozilla.org/widget/macdocksupport;1"] - .getService(Ci.nsIMacDockSupport) - .setBadgeImage(image, { fillColor, strokeColor }); - } else if ("nsIWinTaskbar" in Ci) { - lazy.EveryWindow.registerCallback( - "profiles", - win => { - let iconController = Cc["@mozilla.org/windows-taskbar;1"] - .getService(Ci.nsIWinTaskbar) - .getOverlayIconController(win.docShell); - iconController.setOverlayIcon(image, profileName, { - fillColor, - strokeColor, - }); - }, - () => {} - ); - } - } catch (e) { - console.error(e); - } -} - /** * The service that manages selectable profiles */ @@ -111,6 +84,7 @@ class SelectableProfileServiceClass { #initPromise = null; #notifyTask = null; #observedPrefs = null; + #badge = null; static #dirSvc = null; // The initial preferences that will be shared amongst profiles. Only used during database @@ -410,6 +384,7 @@ class SelectableProfileServiceClass { this.#currentProfile = null; this.#groupToolkitProfile = null; this.#storeID = null; + this.#badge = null; this.#initialized = false; } @@ -418,6 +393,17 @@ class SelectableProfileServiceClass { lazy.EveryWindow.registerCallback( this.#everyWindowCallbackId, window => { + if (this.#badge && "nsIWinTaskbar" in Ci) { + let iconController = Cc["@mozilla.org/windows-taskbar;1"] + .getService(Ci.nsIWinTaskbar) + .getOverlayIconController(window.docShell); + iconController.setOverlayIcon( + this.#badge.image, + this.#badge.description, + this.#badge.iconPaintContext + ); + } + let isPBM = lazy.PrivateBrowsingUtils.isWindowPrivate(window); if (isPBM) { return; @@ -653,6 +639,64 @@ class SelectableProfileServiceClass { } } + async #updateTaskbar() { + try { + if (!gSupportsBadging) { + return; + } + + let count = await this.getProfileCount(); + + if (count > 1 && !this.#badge) { + this.#badge = { + image: await loadImage( + Services.io.newURI( + `chrome://browser/content/profiles/assets/48_${ + this.#currentProfile.avatar + }.svg` + ) + ), + iconPaintContext: this.#currentProfile.iconPaintContext, + description: this.#currentProfile.name, + }; + + if ("nsIMacDockSupport" in Ci) { + Cc["@mozilla.org/widget/macdocksupport;1"] + .getService(Ci.nsIMacDockSupport) + .setBadgeImage(this.#badge.image, this.#badge.iconPaintContext); + } else if ("nsIWinTaskbar" in Ci) { + for (let win of lazy.EveryWindow.readyWindows) { + let iconController = Cc["@mozilla.org/windows-taskbar;1"] + .getService(Ci.nsIWinTaskbar) + .getOverlayIconController(win.docShell); + iconController.setOverlayIcon( + this.#badge.image, + this.#badge.description, + this.#badge.iconPaintContext + ); + } + } + } else if (count <= 1 && this.#badge) { + this.#badge = null; + + if ("nsIMacDockSupport" in Ci) { + Cc["@mozilla.org/widget/macdocksupport;1"] + .getService(Ci.nsIMacDockSupport) + .setBadgeImage(null); + } else if ("nsIWinTaskbar" in Ci) { + for (let win of lazy.EveryWindow.readyWindows) { + let iconController = Cc["@mozilla.org/windows-taskbar;1"] + .getService(Ci.nsIWinTaskbar) + .getOverlayIconController(win.docShell); + iconController.setOverlayIcon(null, null); + } + } + } + } catch (e) { + console.error(e); + } + } + /** * Invoked when changes have been made to the database. Sends the observer * notification "sps-profiles-updated" indicating that something has changed. @@ -668,6 +712,8 @@ class SelectableProfileServiceClass { await this.loadSharedPrefsFromDatabase(); } + await this.#updateTaskbar(); + if (source != "startup") { Services.obs.notifyObservers(null, "sps-profiles-updated", source); } @@ -1137,6 +1183,12 @@ class SelectableProfileServiceClass { profileObj ); + if (aSelectableProfile.id == this.#currentProfile.id) { + // Force a rebuild of the taskbar icon. + this.#badge = null; + this.#currentProfile = aSelectableProfile; + } + this.#notifyTask.arm(); } @@ -1158,6 +1210,24 @@ class SelectableProfileServiceClass { .sort((p1, p2) => p1.name.localeCompare(p2.name)); } + /** + * Get the number of profiles in the group. + * + * @returns {number} + * The number of profiles in the group. + */ + async getProfileCount() { + if (!this.#connection) { + return 0; + } + + let rows = await this.#connection.executeCached( + 'SELECT COUNT(*) AS "count" FROM "Profiles";' + ); + + return rows[0]?.getResultByName("count") ?? 0; + } + /** * Get a specific profile by its internal ID. * @@ -1171,9 +1241,12 @@ class SelectableProfileServiceClass { } let row = ( - await this.#connection.execute("SELECT * FROM Profiles WHERE id = :id;", { - id: aProfileID, - }) + await this.#connection.executeCached( + "SELECT * FROM Profiles WHERE id = :id;", + { + id: aProfileID, + } + ) )[0]; return row ? new SelectableProfile(row, this) : null; diff --git a/browser/components/profiles/tests/browser/head.js b/browser/components/profiles/tests/browser/head.js index 5ab70dc770a3..4748ef125f99 100644 --- a/browser/components/profiles/tests/browser/head.js +++ b/browser/components/profiles/tests/browser/head.js @@ -95,9 +95,9 @@ async function openDatabase() { add_setup(async () => { await SelectableProfileService.resetProfileService(gProfileService); - registerCleanupFunction(() => { + registerCleanupFunction(async () => { SelectableProfileService.overrideDirectoryService(null); - SelectableProfileService.resetProfileService(null); + await SelectableProfileService.resetProfileService(null); }); }); diff --git a/browser/components/profiles/tests/unit/head.js b/browser/components/profiles/tests/unit/head.js index 86ac8619bd0f..8d12d41278b0 100644 --- a/browser/components/profiles/tests/unit/head.js +++ b/browser/components/profiles/tests/unit/head.js @@ -68,6 +68,19 @@ function getRelativeProfilePath(path) { return relativePath; } +// Waits for the profile service to update about a change +async function updateNotified() { + let { resolve, promise } = Promise.withResolvers(); + let observer = (subject, topic, data) => { + Services.obs.removeObserver(observer, "sps-profiles-updated"); + resolve(data); + }; + + Services.obs.addObserver(observer, "sps-profiles-updated"); + + await promise; +} + async function openDatabase() { let dbFile = Services.dirsvc.get("UAppData", Ci.nsIFile); dbFile.append("Profile Groups"); diff --git a/browser/components/profiles/tests/unit/test_selectable_profiles_lifecycle.js b/browser/components/profiles/tests/unit/test_selectable_profiles_lifecycle.js index bb566fa3eb60..3c4c7f2692f5 100644 --- a/browser/components/profiles/tests/unit/test_selectable_profiles_lifecycle.js +++ b/browser/components/profiles/tests/unit/test_selectable_profiles_lifecycle.js @@ -3,6 +3,60 @@ https://creativecommons.org/publicdomain/zero/1.0/ */ "use strict"; +const { MockRegistrar } = ChromeUtils.importESModule( + "resource://testing-common/MockRegistrar.sys.mjs" +); + +const badgingService = { + isRegistered: false, + badge: null, + + // nsIMacDockSupport + setBadgeImage(image, paintContext) { + this.badge = { image, paintContext }; + }, + + QueryInterface: ChromeUtils.generateQI(["nsIMacDockSupport"]), + + assertBadged(fg, bg) { + if (!this.isRegistered) { + return; + } + + Assert.ok(this.badge?.image, "Should have set a badge image"); + Assert.ok(this.badge?.paintContext, "Should have set a paint context"); + Assert.equal( + this.badge?.paintContext?.strokeColor, + fg, + "Stroke color should be correct" + ); + Assert.equal( + this.badge?.paintContext?.fillColor, + bg, + "Stroke color should be correct" + ); + }, + + assertNotBadged() { + if (!this.isRegistered) { + return; + } + + Assert.ok(!this.badge?.image, "Should not have set a badge image"); + Assert.ok(!this.badge?.paintContext, "Should not have set a paint context"); + }, +}; + +add_setup(() => { + if ("nsIMacDockSupport" in Ci) { + badgingService.isRegistered = true; + MockRegistrar.register( + "@mozilla.org/widget/macdocksupport;1", + badgingService + ); + } +}); + add_task(async function test_SelectableProfileLifecycle() { startProfileService(); const SelectableProfileService = getSelectableProfileService(); @@ -28,6 +82,8 @@ add_task(async function test_SelectableProfileLifecycle() { await SelectableProfileService.maybeSetupDataStore(); let currentProfile = SelectableProfileService.currentProfile; + badgingService.assertNotBadged(); + const leafName = (await currentProfile.rootDir).leafName; const profilePath = PathUtils.join( @@ -57,6 +113,12 @@ add_task(async function test_SelectableProfileLifecycle() { let selectableProfile = profiles[0]; + Assert.equal( + selectableProfile.id, + SelectableProfileService.currentProfile.id, + "Should be the selected profile." + ); + let profile = await SelectableProfileService.getProfile(selectableProfile.id); for (let attr of ["id", "name", "path"]) { @@ -74,8 +136,13 @@ add_task(async function test_SelectableProfileLifecycle() { } selectableProfile.name = "updatedTestProfile"; + selectableProfile.theme = { + themeId: "lightTheme", + themeFg: "#e2e1e3", + themeBg: "010203", + }; - await SelectableProfileService.updateProfile(selectableProfile); + await updateNotified(); profile = await SelectableProfileService.getProfile(selectableProfile.id); @@ -85,7 +152,12 @@ add_task(async function test_SelectableProfileLifecycle() { "We got the correct profile name: updatedTestProfile" ); + badgingService.assertNotBadged(); + let newProfile = await createTestProfile({ name: "New profile" }); + + await updateNotified(); + let rootDir = await newProfile.rootDir; let localDir = PathUtils.join( Services.dirsvc.get("DefProfLRt", Ci.nsIFile).path, @@ -135,11 +207,16 @@ add_task(async function test_SelectableProfileLifecycle() { profiles = await SelectableProfileService.getAllProfiles(); Assert.equal(profiles.length, 2, "Should now be two profiles."); + badgingService.assertBadged("#e2e1e3", "010203"); + await SelectableProfileService.deleteProfile(newProfile); + await updateNotified(); profiles = await SelectableProfileService.getAllProfiles(); Assert.equal(profiles.length, 1, "Should now be one profiles."); + badgingService.assertNotBadged(); + profileDirExists = await IOUtils.exists(rootDir.path); profileLocalDirExists = await IOUtils.exists(localDir); Assert.ok(!profileDirExists, "Profile dir was successfully removed"); diff --git a/browser/components/profiles/tests/unit/test_shared_prefs_lifecycles_methods.js b/browser/components/profiles/tests/unit/test_shared_prefs_lifecycles_methods.js index 0eef53b3546b..426d2f29719d 100644 --- a/browser/components/profiles/tests/unit/test_shared_prefs_lifecycles_methods.js +++ b/browser/components/profiles/tests/unit/test_shared_prefs_lifecycles_methods.js @@ -24,19 +24,6 @@ add_setup(async () => { await SelectableProfileService.maybeSetupDataStore(); }); -// Waits for the profile service to update about a change -async function updateNotified() { - let { resolve, promise } = Promise.withResolvers(); - let observer = (subject, topic, data) => { - Services.obs.removeObserver(observer, "sps-profiles-updated"); - resolve(data); - }; - - Services.obs.addObserver(observer, "sps-profiles-updated"); - - await promise; -} - add_task(async function test_SharedPrefsLifecycle() { const SelectableProfileService = getSelectableProfileService(); let prefs = await SelectableProfileService.getAllDBPrefs();