From 5723a89bcd4e11853045d664286332cd75a2e4ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kristijan=20Ribari=C4=87?= <96492763+kristijanribaric@users.noreply.github.com> Date: Thu, 20 Aug 2026 19:16:13 +0200 Subject: [PATCH] gh-14470: Add tab and folder sync support (gh-13984) Co-authored-by: mr. m <91018726+mr-cheffy@users.noreply.github.com> --- .../en-US/browser/browser/zen-workspaces.ftl | 2 + .../sync-engines-list-mjs.patch | 15 + .../sessionstore/SessionStore-sys-mjs.patch | 76 +- .../sync/modules/service-sys-mjs.patch | 6 +- .../ContextualIdentityService-sys-mjs.patch | 37 - .../ZenLiveFoldersManager.sys.mjs | 45 + .../sessionstore/ZenSessionManager.sys.mjs | 5 +- src/zen/sessionstore/ZenWindowSync.sys.mjs | 18 +- src/zen/spaces/ZenSpaceManager.mjs | 84 -- src/zen/sync/ZenSpacesSync.sys.mjs | 158 ++++ src/zen/sync/ZenSpacesSyncApplier.sys.mjs | 839 ++++++++++++++++++ src/zen/sync/ZenSpacesSyncModel.sys.mjs | 609 +++++++++++++ src/zen/sync/ZenSyncManager.sys.mjs | 160 ---- src/zen/sync/ZenWorkspacesSync.sys.mjs | 404 --------- src/zen/sync/moz.build | 5 +- 15 files changed, 1731 insertions(+), 732 deletions(-) create mode 100644 src/browser/components/preferences/widgets/sync-engine-list/sync-engines-list-mjs.patch delete mode 100644 src/toolkit/components/contextualidentity/ContextualIdentityService-sys-mjs.patch create mode 100644 src/zen/sync/ZenSpacesSync.sys.mjs create mode 100644 src/zen/sync/ZenSpacesSyncApplier.sys.mjs create mode 100644 src/zen/sync/ZenSpacesSyncModel.sys.mjs delete mode 100644 src/zen/sync/ZenSyncManager.sys.mjs delete mode 100644 src/zen/sync/ZenWorkspacesSync.sys.mjs diff --git a/locales/en-US/browser/browser/zen-workspaces.ftl b/locales/en-US/browser/browser/zen-workspaces.ftl index a418f05fd..7274a6bcc 100644 --- a/locales/en-US/browser/browser/zen-workspaces.ftl +++ b/locales/en-US/browser/browser/zen-workspaces.ftl @@ -86,6 +86,8 @@ zen-workspace-default-profile = Default zen-workspaces-delete-workspace-title = Delete Space? zen-workspaces-delete-workspace-body = Are you sure you want to delete { $name }? This action cannot be undone. +zen-workspaces-remote-delete-title = Delete Synced Space? +zen-workspaces-remote-delete-body = { $name } was deleted on another device. Delete it here too? Keeping it will restore it on your other devices. # Note that the html tag MUST not be changed or removed, as it is used to better # display the shortcut in the toast notification. diff --git a/src/browser/components/preferences/widgets/sync-engine-list/sync-engines-list-mjs.patch b/src/browser/components/preferences/widgets/sync-engine-list/sync-engines-list-mjs.patch new file mode 100644 index 000000000..6bcfd56b9 --- /dev/null +++ b/src/browser/components/preferences/widgets/sync-engine-list/sync-engines-list-mjs.patch @@ -0,0 +1,15 @@ +diff --git a/browser/components/preferences/widgets/sync-engine-list/sync-engines-list.mjs b/browser/components/preferences/widgets/sync-engine-list/sync-engines-list.mjs +index 9359a485ed6212c7df49a8d4b1e4a8febbc1c9b6..132fea440cfa535e78b35f706e7ef2d6881f9ba6 100644 +--- a/browser/components/preferences/widgets/sync-engine-list/sync-engines-list.mjs ++++ b/browser/components/preferences/widgets/sync-engine-list/sync-engines-list.mjs +@@ -50,6 +50,10 @@ const engineTypeToMetadata = { + iconSrc: "chrome://global/skin/icons/settings.svg", + l10nId: "sync-currently-syncing-settings", + }, ++ workspaces: { ++ iconSrc: "chrome://devtools/skin/images/tool-storage.svg", ++ l10nId: "sync-currently-syncing-workspaces", ++ }, + }; + + /** diff --git a/src/browser/components/sessionstore/SessionStore-sys-mjs.patch b/src/browser/components/sessionstore/SessionStore-sys-mjs.patch index b421fd11b..f0131d9e7 100644 --- a/src/browser/components/sessionstore/SessionStore-sys-mjs.patch +++ b/src/browser/components/sessionstore/SessionStore-sys-mjs.patch @@ -1,18 +1,20 @@ diff --git a/browser/components/sessionstore/SessionStore.sys.mjs b/browser/components/sessionstore/SessionStore.sys.mjs -index 11191a82a0f0dc6eee60f9de52a63bc6085c3981..f995c3b297c53aa6ee2219bfc09515ee4aa3be28 100644 +index 536e06eec9f35bad3b8366733b23b5a8697df9c8..631a16bc7518108641993836f0c52e2ab6e6c967 100644 --- a/browser/components/sessionstore/SessionStore.sys.mjs +++ b/browser/components/sessionstore/SessionStore.sys.mjs -@@ -129,6 +129,9 @@ const TAB_EVENTS = [ +@@ -129,6 +129,11 @@ const TAB_EVENTS = [ "TabSplitViewActivate", "SplitViewRemoved", "SplitViewCreated", + "TabAddedToEssentials", + "TabRemovedFromEssentials", ++ "TabMove", ++ "TabGroupMoved", + "ZenWorkspaceDataChanged", ]; const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"; -@@ -201,6 +204,8 @@ ChromeUtils.defineESModuleGetters(lazy, { +@@ -201,6 +206,8 @@ ChromeUtils.defineESModuleGetters(lazy, { TabStateCache: "resource:///modules/sessionstore/TabStateCache.sys.mjs", TabStateFlusher: "resource:///modules/sessionstore/TabStateFlusher.sys.mjs", setTimeout: "resource://gre/modules/Timer.sys.mjs", @@ -21,7 +23,7 @@ index 11191a82a0f0dc6eee60f9de52a63bc6085c3981..f995c3b297c53aa6ee2219bfc09515ee }); ChromeUtils.defineLazyGetter(lazy, "blankURI", () => { -@@ -1300,10 +1305,7 @@ var SessionStoreInternal = { +@@ -1317,10 +1324,7 @@ var SessionStoreInternal = { */ get willAutoRestore() { return ( @@ -33,17 +35,19 @@ index 11191a82a0f0dc6eee60f9de52a63bc6085c3981..f995c3b297c53aa6ee2219bfc09515ee ); }, -@@ -1990,6 +1992,9 @@ var SessionStoreInternal = { +@@ -2008,6 +2012,11 @@ var SessionStoreInternal = { case "TabPinned": case "TabUnpinned": case "SwapDocShells": + case "TabRemovedFromEssentials": + case "TabAddedToEssentials": ++ case "TabMove": ++ case "TabGroupMoved": + case "ZenWorkspaceDataChanged": this.saveStateDelayed(win); break; case "TabGroupCreate": -@@ -2099,6 +2104,10 @@ var SessionStoreInternal = { +@@ -2117,6 +2126,10 @@ var SessionStoreInternal = { this._windows[aWindow.__SSi].isTaskbarTab = true; } @@ -54,7 +58,7 @@ index 11191a82a0f0dc6eee60f9de52a63bc6085c3981..f995c3b297c53aa6ee2219bfc09515ee if (lazy.AIWindow.isAIWindowActiveAndEnabled(aWindow)) { this._windows[aWindow.__SSi].isAIWindow = true; } -@@ -2135,7 +2144,7 @@ var SessionStoreInternal = { +@@ -2153,7 +2166,7 @@ var SessionStoreInternal = { let isTaskbarTab = this._windows[aWindow.__SSi].isTaskbarTab; // A regular window is not a private window, taskbar tab window, or popup window let isRegularWindow = @@ -63,7 +67,7 @@ index 11191a82a0f0dc6eee60f9de52a63bc6085c3981..f995c3b297c53aa6ee2219bfc09515ee // perform additional initialization when the first window is loading if (lazy.RunState.isStopped) { -@@ -2147,7 +2156,7 @@ var SessionStoreInternal = { +@@ -2165,7 +2178,7 @@ var SessionStoreInternal = { // to disk to NOW() to enforce a full interval before the next write. lazy.SessionSaver.updateLastSaveTime(); @@ -72,7 +76,7 @@ index 11191a82a0f0dc6eee60f9de52a63bc6085c3981..f995c3b297c53aa6ee2219bfc09515ee this._log.debug( "initializeWindow, the window is private or a web app. Saving SessionStartup.state for possibly restoring later" ); -@@ -2191,6 +2200,7 @@ var SessionStoreInternal = { +@@ -2209,6 +2222,7 @@ var SessionStoreInternal = { null, "sessionstore-one-or-no-tab-restored" ); @@ -80,7 +84,7 @@ index 11191a82a0f0dc6eee60f9de52a63bc6085c3981..f995c3b297c53aa6ee2219bfc09515ee this._deferredAllWindowsRestored.resolve(); } // this window was opened by _openWindowWithState -@@ -2240,7 +2250,6 @@ var SessionStoreInternal = { +@@ -2258,7 +2272,6 @@ var SessionStoreInternal = { if (closedWindowState) { let newWindowState; if ( @@ -88,7 +92,7 @@ index 11191a82a0f0dc6eee60f9de52a63bc6085c3981..f995c3b297c53aa6ee2219bfc09515ee !lazy.SessionStartup.willRestore() ) { // We want to split the window up into pinned tabs and unpinned tabs. -@@ -2276,6 +2285,7 @@ var SessionStoreInternal = { +@@ -2294,6 +2307,7 @@ var SessionStoreInternal = { } if (newWindowState) { @@ -96,7 +100,7 @@ index 11191a82a0f0dc6eee60f9de52a63bc6085c3981..f995c3b297c53aa6ee2219bfc09515ee // Ensure that the window state isn't hidden this._restoreCount = 1; let state = { windows: [newWindowState] }; -@@ -2312,6 +2322,9 @@ var SessionStoreInternal = { +@@ -2330,6 +2344,9 @@ var SessionStoreInternal = { }); this._shouldRestoreLastSession = false; } @@ -106,7 +110,7 @@ index 11191a82a0f0dc6eee60f9de52a63bc6085c3981..f995c3b297c53aa6ee2219bfc09515ee if (this._restoreLastWindow && aWindow.toolbar.visible) { // always reset (if not a popup window) -@@ -2462,7 +2475,7 @@ var SessionStoreInternal = { +@@ -2480,7 +2497,7 @@ var SessionStoreInternal = { var tabbrowser = aWindow.gBrowser; @@ -115,7 +119,7 @@ index 11191a82a0f0dc6eee60f9de52a63bc6085c3981..f995c3b297c53aa6ee2219bfc09515ee TAB_EVENTS.forEach(function (aEvent) { tabbrowser.tabContainer.removeEventListener(aEvent, this, true); -@@ -2513,7 +2526,7 @@ var SessionStoreInternal = { +@@ -2531,7 +2548,7 @@ var SessionStoreInternal = { let isLastRegularWindow = Object.values(this._windows).filter( @@ -124,7 +128,7 @@ index 11191a82a0f0dc6eee60f9de52a63bc6085c3981..f995c3b297c53aa6ee2219bfc09515ee ).length == 1; this._log.debug( `onClose, closing window isLastRegularWindow? ${isLastRegularWindow}` -@@ -2570,8 +2583,8 @@ var SessionStoreInternal = { +@@ -2588,8 +2605,8 @@ var SessionStoreInternal = { // 2) Flush the window. // 3) When the flush is complete, revisit our decision to store the window // in _closedWindows, and add/remove as necessary. @@ -135,7 +139,7 @@ index 11191a82a0f0dc6eee60f9de52a63bc6085c3981..f995c3b297c53aa6ee2219bfc09515ee } completionPromise = lazy.TabStateFlusher.flushWindow(aWindow).then(() => { -@@ -2591,8 +2604,9 @@ var SessionStoreInternal = { +@@ -2609,8 +2626,9 @@ var SessionStoreInternal = { // Save non-private windows if they have at // least one saveable tab or are the last window. @@ -147,7 +151,7 @@ index 11191a82a0f0dc6eee60f9de52a63bc6085c3981..f995c3b297c53aa6ee2219bfc09515ee if (!isLastWindow && winData.closedId > -1) { this._addClosedAction( -@@ -2668,7 +2682,7 @@ var SessionStoreInternal = { +@@ -2686,7 +2704,7 @@ var SessionStoreInternal = { * to call this method again asynchronously (for example, after * a window flush). */ @@ -156,7 +160,7 @@ index 11191a82a0f0dc6eee60f9de52a63bc6085c3981..f995c3b297c53aa6ee2219bfc09515ee // Make sure SessionStore is still running, and make sure that we // haven't chosen to forget this window. if ( -@@ -2685,6 +2699,7 @@ var SessionStoreInternal = { +@@ -2703,6 +2721,7 @@ var SessionStoreInternal = { // _closedWindows from a previous call to this function. let winIndex = this._closedWindows.indexOf(winData); let alreadyStored = winIndex != -1; @@ -164,7 +168,7 @@ index 11191a82a0f0dc6eee60f9de52a63bc6085c3981..f995c3b297c53aa6ee2219bfc09515ee // If sidebar command is truthy, i.e. sidebar is open, store sidebar settings let shouldStore = hasSaveableTabs || isLastWindow; -@@ -3507,7 +3522,7 @@ var SessionStoreInternal = { +@@ -3525,7 +3544,7 @@ var SessionStoreInternal = { if (!isPrivateWindow && tabState.isPrivate) { return; } @@ -173,7 +177,7 @@ index 11191a82a0f0dc6eee60f9de52a63bc6085c3981..f995c3b297c53aa6ee2219bfc09515ee return; } -@@ -4246,6 +4261,12 @@ var SessionStoreInternal = { +@@ -4264,6 +4283,12 @@ var SessionStoreInternal = { Math.min(tabState.index, tabState.entries.length) ); tabState.pinned = false; @@ -186,7 +190,7 @@ index 11191a82a0f0dc6eee60f9de52a63bc6085c3981..f995c3b297c53aa6ee2219bfc09515ee if (inBackground === false) { aWindow.gBrowser.selectedTab = newTab; -@@ -4679,6 +4700,8 @@ var SessionStoreInternal = { +@@ -4697,6 +4722,8 @@ var SessionStoreInternal = { // Append the tab if we're opening into a different window, tabIndex: aSource == aTargetWindow ? pos : Infinity, pinned: state.pinned, @@ -195,7 +199,7 @@ index 11191a82a0f0dc6eee60f9de52a63bc6085c3981..f995c3b297c53aa6ee2219bfc09515ee userContextId: state.userContextId, skipLoad: true, preferredRemoteType, -@@ -5176,9 +5199,10 @@ var SessionStoreInternal = { +@@ -5194,9 +5221,10 @@ var SessionStoreInternal = { if (activePageData.title && activePageData.title != activePageData.url) { win.gBrowser.setInitialTabTitle(tab, activePageData.title, { isContentTitle: true, @@ -207,7 +211,7 @@ index 11191a82a0f0dc6eee60f9de52a63bc6085c3981..f995c3b297c53aa6ee2219bfc09515ee } } -@@ -5543,7 +5567,7 @@ var SessionStoreInternal = { +@@ -5561,7 +5589,7 @@ var SessionStoreInternal = { for (let i = tabbrowser.pinnedTabCount; i < tabbrowser.tabs.length; i++) { let tab = tabbrowser.tabs[i]; @@ -216,7 +220,7 @@ index 11191a82a0f0dc6eee60f9de52a63bc6085c3981..f995c3b297c53aa6ee2219bfc09515ee removableTabs.push(tab); } } -@@ -5656,7 +5680,7 @@ var SessionStoreInternal = { +@@ -5674,7 +5702,7 @@ var SessionStoreInternal = { // collect the data for all windows for (ix in this._windows) { @@ -225,7 +229,7 @@ index 11191a82a0f0dc6eee60f9de52a63bc6085c3981..f995c3b297c53aa6ee2219bfc09515ee // window data is still in _statesToRestore continue; } -@@ -5800,11 +5824,12 @@ var SessionStoreInternal = { +@@ -5818,11 +5846,12 @@ var SessionStoreInternal = { } let tabbrowser = aWindow.gBrowser; @@ -239,7 +243,7 @@ index 11191a82a0f0dc6eee60f9de52a63bc6085c3981..f995c3b297c53aa6ee2219bfc09515ee // update the internal state data for this window for (let tab of tabs) { if (tab == aWindow.FirefoxViewHandler.tab) { -@@ -5815,6 +5840,9 @@ var SessionStoreInternal = { +@@ -5833,6 +5862,9 @@ var SessionStoreInternal = { tabsData.push(tabData); } @@ -249,7 +253,7 @@ index 11191a82a0f0dc6eee60f9de52a63bc6085c3981..f995c3b297c53aa6ee2219bfc09515ee // update tab group state for this window winData.groups = []; for (let tabGroup of aWindow.gBrowser.tabGroups) { -@@ -5831,7 +5859,7 @@ var SessionStoreInternal = { +@@ -5849,7 +5881,7 @@ var SessionStoreInternal = { // a window is closed, point to the first item in the tab strip instead (it will never be the Firefox View tab, // since it's only inserted into the tab strip after it's selected). if (aWindow.FirefoxViewHandler.tab?.selected) { @@ -258,7 +262,7 @@ index 11191a82a0f0dc6eee60f9de52a63bc6085c3981..f995c3b297c53aa6ee2219bfc09515ee winData.title = tabbrowser.tabs[0].label; } winData.selected = selectedIndex; -@@ -6033,8 +6061,8 @@ var SessionStoreInternal = { +@@ -6051,8 +6083,8 @@ var SessionStoreInternal = { // selectTab represents. let selectTab = 0; if (overwriteTabs) { @@ -269,7 +273,7 @@ index 11191a82a0f0dc6eee60f9de52a63bc6085c3981..f995c3b297c53aa6ee2219bfc09515ee selectTab = Math.min(selectTab, winData.tabs.length); } -@@ -6056,6 +6084,7 @@ var SessionStoreInternal = { +@@ -6074,6 +6106,7 @@ var SessionStoreInternal = { if (overwriteTabs) { for (let i = tabbrowser.browsers.length - 1; i >= 0; i--) { if (!tabbrowser.tabs[i].selected) { @@ -277,7 +281,7 @@ index 11191a82a0f0dc6eee60f9de52a63bc6085c3981..f995c3b297c53aa6ee2219bfc09515ee tabbrowser.removeTab(tabbrowser.tabs[i]); } } -@@ -6090,6 +6119,12 @@ var SessionStoreInternal = { +@@ -6108,6 +6141,12 @@ var SessionStoreInternal = { savedTabGroup => !openTabGroupIdsInWindow.has(savedTabGroup.id) ); } @@ -290,7 +294,7 @@ index 11191a82a0f0dc6eee60f9de52a63bc6085c3981..f995c3b297c53aa6ee2219bfc09515ee // Move the originally open tabs to the end. if (initialTabs) { -@@ -6656,6 +6691,25 @@ var SessionStoreInternal = { +@@ -6674,6 +6713,25 @@ var SessionStoreInternal = { // Most of tabData has been restored, now continue with restoring // attributes that may trigger external events. @@ -316,7 +320,7 @@ index 11191a82a0f0dc6eee60f9de52a63bc6085c3981..f995c3b297c53aa6ee2219bfc09515ee if (tabData.pinned) { tabbrowser.pinTab(tab); -@@ -6838,6 +6892,9 @@ var SessionStoreInternal = { +@@ -6859,6 +6917,9 @@ var SessionStoreInternal = { aWindow.gURLBar.readOnly = false; } } @@ -326,7 +330,7 @@ index 11191a82a0f0dc6eee60f9de52a63bc6085c3981..f995c3b297c53aa6ee2219bfc09515ee let promiseParts = Promise.withResolvers(); aWindow.setTimeout(() => { -@@ -7633,7 +7690,7 @@ var SessionStoreInternal = { +@@ -7654,7 +7715,7 @@ var SessionStoreInternal = { let groupsToSave = new Map(); for (let tIndex = 0; tIndex < window.tabs.length; ) { @@ -335,7 +339,7 @@ index 11191a82a0f0dc6eee60f9de52a63bc6085c3981..f995c3b297c53aa6ee2219bfc09515ee // Adjust window.selected if (tIndex + 1 < window.selected) { window.selected -= 1; -@@ -7648,7 +7705,7 @@ var SessionStoreInternal = { +@@ -7669,7 +7730,7 @@ var SessionStoreInternal = { ); // We don't want to increment tIndex here. continue; @@ -344,7 +348,7 @@ index 11191a82a0f0dc6eee60f9de52a63bc6085c3981..f995c3b297c53aa6ee2219bfc09515ee // Convert any open groups into saved groups. let groupStateToSave = window.groups.find( groupState => groupState.id == window.tabs[tIndex].groupId -@@ -8193,7 +8250,6 @@ var SessionStoreInternal = { +@@ -8233,7 +8294,6 @@ var SessionStoreInternal = { timer.initWithCallback( function () { if (beats <= 0) { @@ -352,7 +356,7 @@ index 11191a82a0f0dc6eee60f9de52a63bc6085c3981..f995c3b297c53aa6ee2219bfc09515ee Glean.sessionRestore.shutdownFlushAllOutcomes.timed_out.add(1); deferred.resolve(); } -@@ -8671,6 +8727,7 @@ var SessionStoreInternal = { +@@ -8711,6 +8771,7 @@ var SessionStoreInternal = { if ( !savedTabGroupState.tabs.length || this.getSavedTabGroup(savedTabGroupState.id) diff --git a/src/services/sync/modules/service-sys-mjs.patch b/src/services/sync/modules/service-sys-mjs.patch index daf57720d..3b616d60a 100644 --- a/src/services/sync/modules/service-sys-mjs.patch +++ b/src/services/sync/modules/service-sys-mjs.patch @@ -1,5 +1,5 @@ diff --git a/services/sync/modules/service.sys.mjs b/services/sync/modules/service.sys.mjs -index 4f4efe572909441abdd81909282936702c225621..81ab06a9414ed656b644a21b40b683f06a4f8dbf 100644 +index 4f4efe572909441abdd81909282936702c225621..2b47a2c9e97cf87641a0be9814f9a6be4d43f6ea 100644 --- a/services/sync/modules/service.sys.mjs +++ b/services/sync/modules/service.sys.mjs @@ -99,6 +99,10 @@ function getEngineModules() { @@ -7,8 +7,8 @@ index 4f4efe572909441abdd81909282936702c225621..81ab06a9414ed656b644a21b40b683f0 whenFalse: "ExtensionStorageEngineBridge", }; + result.Spaces = { -+ module: "resource:///modules/zen/ZenWorkspacesSync.sys.mjs", -+ symbol: "ZenWorkspacesEngine", ++ module: "resource:///modules/zen/ZenSpacesSync.sys.mjs", ++ symbol: "ZenSpacesSyncEngine", + }; return result; } diff --git a/src/toolkit/components/contextualidentity/ContextualIdentityService-sys-mjs.patch b/src/toolkit/components/contextualidentity/ContextualIdentityService-sys-mjs.patch deleted file mode 100644 index 1b108254d..000000000 --- a/src/toolkit/components/contextualidentity/ContextualIdentityService-sys-mjs.patch +++ /dev/null @@ -1,37 +0,0 @@ -diff --git a/toolkit/components/contextualidentity/ContextualIdentityService.sys.mjs b/toolkit/components/contextualidentity/ContextualIdentityService.sys.mjs -index 5702ff28cc22206f5ce16584dac8a78d816562ce..3d08ecc97ce5995b30d8a4af0c33df329b428008 100644 ---- a/toolkit/components/contextualidentity/ContextualIdentityService.sys.mjs -+++ b/toolkit/components/contextualidentity/ContextualIdentityService.sys.mjs -@@ -270,11 +270,29 @@ _ContextualIdentityService.prototype = { - }); - }, - -- create(name, icon, color) { -+ create(name, icon, color, id = null) { - this.ensureDataReady(); - -- // Retrieve the next userContextId available. -- let userContextId = ++this._lastUserContextId; -+ if (id !== null) { -+ id = typeof id == "string" ? Number(id) : id; -+ if (!Number.isSafeInteger(id) || id <= 0) { -+ throw new Error(`Invalid userContextId '${id}'`); -+ } -+ } -+ -+ let userContextId; -+ if ( -+ id !== null && -+ !this._identities.some(identity => identity.userContextId === id) -+ ) { -+ userContextId = id; -+ this._lastUserContextId = Math.max( -+ this._lastUserContextId, -+ userContextId -+ ); -+ } else { -+ userContextId = ++this._lastUserContextId; -+ } - - // Throw an error if the next userContextId available is invalid (the one associated to - // MAX_USER_CONTEXT_ID is already reserved to "userContextIdInternal.webextStorageLocal", which diff --git a/src/zen/live-folders/ZenLiveFoldersManager.sys.mjs b/src/zen/live-folders/ZenLiveFoldersManager.sys.mjs index d0a29942b..035f1f76d 100644 --- a/src/zen/live-folders/ZenLiveFoldersManager.sys.mjs +++ b/src/zen/live-folders/ZenLiveFoldersManager.sys.mjs @@ -200,6 +200,50 @@ class nsZenLiveFoldersManager { return this.liveFolders.get(id); } + /** + * Returns the provider config needed to recreate this live folder on + * another device, or null for non-live folders. Fetch bookkeeping fields + * are stripped since they are device-local. + * + * @param {string} id - The folder ID. + */ + getSyncableFolderData(id) { + const liveFolder = this.liveFolders.get(id); + if (!liveFolder) { + return null; + } + const state = { ...liveFolder.serialize().state }; + delete state.lastFetched; + delete state.lastErrorId; + return { type: liveFolder.constructor.type, state }; + } + + /** + * Registers a live folder provider for a folder that arrived from + * Firefox Sync. The folder element must already exist. + * + * @param {string} id - The folder ID. + * @param {{ type: string, state: object }} config - Synced provider config. + */ + async adoptSyncedFolder(id, { type, state } = {}) { + await this.stateRestored.promise; + if (this.liveFolders.has(id)) { + return; + } + const ProviderClass = this.registry.get(type); + if (!ProviderClass || !state) { + return; + } + const liveFolder = new ProviderClass({ + id, + state: this.#applyDefaultStateValues({ ...state }), + manager: this, + }); + this.liveFolders.set(id, liveFolder); + liveFolder.start(); + this.saveState(); + } + async createFolder(type) { const [provider, providerType] = type.split(":"); let ProviderClass = this.registry.get(provider); @@ -619,6 +663,7 @@ class nsZenLiveFoldersManager { async #restoreState() { let data = await this.#readStateFromDisk(); if (!Array.isArray(data)) { + this.stateRestored.resolve(); return; } diff --git a/src/zen/sessionstore/ZenSessionManager.sys.mjs b/src/zen/sessionstore/ZenSessionManager.sys.mjs index 594ae490c..4b883ef02 100644 --- a/src/zen/sessionstore/ZenSessionManager.sys.mjs +++ b/src/zen/sessionstore/ZenSessionManager.sys.mjs @@ -10,7 +10,6 @@ const lazy = {}; ChromeUtils.defineESModuleGetters(lazy, { ZenLiveFoldersManager: "resource:///modules/zen/ZenLiveFoldersManager.sys.mjs", - ZenSyncStore: "resource:///modules/zen/ZenSyncManager.sys.mjs", PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", SessionStore: "resource:///modules/sessionstore/SessionStore.sys.mjs", SessionStartup: "resource:///modules/sessionstore/SessionStartup.sys.mjs", @@ -611,7 +610,9 @@ export class nsZenSessionManager { } ); this.#collectWindowData(windows); - lazy.ZenSyncStore.notifyAboutChanges(); + // Let interested consumers (e.g. Firefox Sync) know fresh sidebar data + // is available, without this module knowing anything about them. + Services.obs.notifyObservers(null, "zen-sidebar-data-collected"); // This would save the data to disk asynchronously or when quitting the app. let sidebar = this.#sidebarWithoutCloning; this.#file.data = sidebar; diff --git a/src/zen/sessionstore/ZenWindowSync.sys.mjs b/src/zen/sessionstore/ZenWindowSync.sys.mjs index 8b74bda90..51816e88b 100644 --- a/src/zen/sessionstore/ZenWindowSync.sys.mjs +++ b/src/zen/sessionstore/ZenWindowSync.sys.mjs @@ -1232,7 +1232,7 @@ class nsZenWindowSync { activeIndex = Math.min(activeIndex, entries.length - 1); activeIndex = Math.max(activeIndex, 0); let entryToUse = (entries[activeIndex] || entries[0]) ?? null; - this.#setPinnedInitialState( + this.setPinnedInitialState( aTab, { url: entryToUse?.url, title: entryToUse?.title }, image @@ -1250,14 +1250,22 @@ class nsZenWindowSync { */ setPinnedUrl(aTab, aUrl, aImage) { this.log(`Setting pinned url for tab ${aTab.id}`); - this.#setPinnedInitialState( + this.setPinnedInitialState( aTab, { url: aUrl, title: aTab.zenStaticLabel }, aImage ); } - #setPinnedInitialState(aTab, aEntry, aImage) { + /** + * Sets the pinned initial state (canonical entry and icon) for a tab's + * instances across all windows. + * + * @param {object} aTab - Any window's instance of the tab. + * @param {object} aEntry - The canonical { url, title } entry. + * @param {string} [aImage] - Optional icon to store. + */ + setPinnedInitialState(aTab, aEntry, aImage) { const initialState = { entry: aEntry, image: aImage }; this.#runOnAllWindows(null, win => { const targetTab = this.getItemFromWindow(win, aTab.id); @@ -1362,7 +1370,9 @@ class nsZenWindowSync { return; } tab._zenContentsVisible = true; - tab.id = this.#newTabSyncId; + if (!tab.id) { + tab.id = this.#newTabSyncId; + } if (lazy.gSyncOnlyPinnedTabs && !tab.pinned) { return; } diff --git a/src/zen/spaces/ZenSpaceManager.mjs b/src/zen/spaces/ZenSpaceManager.mjs index faf473bb0..df6fe45b6 100644 --- a/src/zen/spaces/ZenSpaceManager.mjs +++ b/src/zen/spaces/ZenSpaceManager.mjs @@ -11,7 +11,6 @@ const lazy = {}; ChromeUtils.defineESModuleGetters(lazy, { ZenSessionStore: "resource:///modules/zen/ZenSessionManager.sys.mjs", - ZenSyncStore: "resource:///modules/zen/ZenSyncManager.sys.mjs", }); ChromeUtils.defineLazyGetter(lazy, "browserBackgroundElement", () => { @@ -173,63 +172,6 @@ class nsZenWorkspaces { } } - /** - * Applies live sync changes: updates workspace cache, removes deleted items, - * then creates/updates pulled items. - * - * @param {{ spaces: Array}} pulled Reconcile-pulled items. - * @param {{ spaces: Array}} removals Items to remove. - */ - async _applySyncChanges(pulled, removals = {}) { - if (!this.shouldHaveWorkspaces || this.privateWindowOrDisabled) { - return; - } - await this.promiseInitialized; - - // 1. Update workspace cache (remove deleted, merge pulled) - const removedSpaceIds = new Set((removals.spaces || []).map(s => s.uuid)); - if (removedSpaceIds.size || pulled.spaces?.length) { - const localMap = new Map( - this.getWorkspaces() - .filter(w => !removedSpaceIds.has(w.uuid)) - .map(w => [w.uuid, w]) - ); - for (const space of pulled.spaces || []) { - if (!space?.uuid) { - continue; - } - const existing = localMap.get(space.uuid); - localMap.set(space.uuid, existing ? { ...existing, ...space } : space); - } - await this.propagateWorkspaces( - this.#getOrderedWorkspacesByPosition(Array.from(localMap.values())) - ); - this.#propagateWorkspaceData(); - } - } - - #getOrderedWorkspacesByPosition(workspaces) { - return [...workspaces] - .map((workspace, index) => ({ workspace, index })) - .sort((a, b) => { - const aPosition = - typeof a.workspace.position === "number" - ? a.workspace.position - : a.index; - const bPosition = - typeof b.workspace.position === "number" - ? b.workspace.position - : b.index; - return aPosition - bPosition || a.index - b.index; - }) - .map(({ workspace }) => { - // strip the position property that comes from pulled workspaces - const rest = { ...workspace }; - delete rest.position; - return rest; - }); - } - #afterLoadInit() { const onResize = (...args) => { requestAnimationFrame(() => { @@ -862,13 +804,6 @@ class nsZenWorkspaces { })(); } - #markWorkspaceChanged(workspaceId) { - lazy.ZenSyncStore.markItemChanged({ - type: "space", - id: workspaceId, - }); - } - async selectStartPage() { if (!this.workspaceEnabled || gZenUIManager.testingEnabled) { return; @@ -1297,8 +1232,6 @@ class nsZenWorkspaces { } else { workspacesData.push(workspaceData); } - // mark item as changed for sync - this.#markWorkspaceChanged(workspaceData.uuid); this.#propagateWorkspaceData(); } @@ -1307,9 +1240,6 @@ class nsZenWorkspaces { let { promise, resolve } = Promise.withResolvers(); this.#deleteWorkspaceOwnedTabs(windowID); - // mark item as changed for sync - this.#markWorkspaceChanged(windowID); - let workspacesData = this.getWorkspaces(); // Remove the workspace from the cache workspacesData = workspacesData.filter( @@ -1442,11 +1372,6 @@ class nsZenWorkspaces { return; } const workspaces = this.getWorkspaces(); - // Track previous positions so we only notify observers for workspaces whose - // position changed during the reorder. - const previousPositions = new Map( - workspaces.map((workspace, index) => [workspace.uuid, index]) - ); const workspace = workspaces.find(w => w.uuid === id); if (!workspace) { @@ -1475,15 +1400,6 @@ class nsZenWorkspaces { // Propagate the changes if the order has changed if (currentIndex !== newPosition) { this._workspaceCache = workspaces; - - for (const [i, ws] of workspaces.entries()) { - if (previousPositions.get(ws.uuid) === i) { - continue; - } - // mark item as changed for sync - this.#markWorkspaceChanged(ws.uuid); - } - this.#propagateWorkspaceData(workspaces); } } diff --git a/src/zen/sync/ZenSpacesSync.sys.mjs b/src/zen/sync/ZenSpacesSync.sys.mjs new file mode 100644 index 000000000..02f510ece --- /dev/null +++ b/src/zen/sync/ZenSpacesSync.sys.mjs @@ -0,0 +1,158 @@ +/* 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/. */ + +import { + Store, + SyncEngine, + Tracker, +} from "resource://services-sync/engines.sys.mjs"; +import { CryptoWrapper } from "resource://services-sync/record.sys.mjs"; +import { SCORE_INCREMENT_XLARGE } from "resource://services-sync/constants.sys.mjs"; +import { + SIDEBAR_COLLECTED_TOPIC, + ZenSpacesSyncModel, +} from "resource:///modules/zen/ZenSpacesSyncModel.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + ZenSpacesSyncApplier: "resource:///modules/zen/ZenSpacesSyncApplier.sys.mjs", +}); + +const TRACKED_TOPICS = [ + SIDEBAR_COLLECTED_TOPIC, + "contextual-identity-created", + "contextual-identity-updated", + "contextual-identity-deleted", +]; + +export class ZenSpacesSyncRecord extends CryptoWrapper { + _logName = "Sync.Record.ZenSpaces"; +} + +ZenSpacesSyncRecord.prototype.type = "spaces"; + +/** + * Store for the Spaces engine. Outgoing records are pure projections of + * the session-store sidebar data; incoming batches are handed to the + * applier, which reconciles them into the live browser. + */ +class ZenSpacesSyncStore extends Store { + async getAllIDs() { + return ZenSpacesSyncModel.getAllRecordIds(); + } + + async itemExists(id) { + return ZenSpacesSyncModel.itemExists(id); + } + + async createRecord(id, collection) { + const record = new ZenSpacesSyncRecord(collection, id); + const projected = ZenSpacesSyncModel.projectRecord(id); + if (!projected) { + record.deleted = true; + return record; + } + record.cleartext = { id, kind: projected.kind, data: projected.data }; + return record; + } + + async applyIncomingBatch(records, countTelemetry) { + const failed = await lazy.ZenSpacesSyncApplier.applyBatch(records); + for (const id of failed) { + countTelemetry?.addIncomingFailedReason(`failed to apply ${id}`); + } + return failed; + } + + async wipe() { + // Never delete user data on an engine wipe. + } + + async changeItemID() {} +} + +/** + * Tracker for the Spaces engine. There is no per-event bookkeeping: every + * time fresh sidebar data is collected (or a container changes), the model + * diffs the current projections against the last-uploaded snapshot; the + * score is only bumped when something actually differs. + */ +class ZenSpacesSyncTracker extends Tracker { + onStart() { + for (const topic of TRACKED_TOPICS) { + Services.obs.addObserver(this, topic); + } + } + + onStop() { + for (const topic of TRACKED_TOPICS) { + Services.obs.removeObserver(this, topic); + } + } + + observe(subject, topic) { + if (topic !== SIDEBAR_COLLECTED_TOPIC) { + ZenSpacesSyncModel.invalidate(); + } + try { + if (ZenSpacesSyncModel.hasPendingChanges()) { + this.score += SCORE_INCREMENT_XLARGE; + } + } catch (e) { + console.error("ZenSpacesSync: change detection failed:", e); + } + } +} + +export class ZenSpacesSyncEngine extends SyncEngine { + constructor(service) { + super("Spaces", service); + } + + get _storeObj() { + return ZenSpacesSyncStore; + } + + get _trackerObj() { + return ZenSpacesSyncTracker; + } + + get _recordObj() { + return ZenSpacesSyncRecord; + } + + get version() { + return 1; + } + + get syncPriority() { + return 8; + } + + get allowSkippedRecord() { + // A record that persistently fails to upload is skipped; the snapshot + // diff retries it on every following sync anyway. + return true; + } + + async getChangedIDs() { + return ZenSpacesSyncModel.computeChangedIDs(); + } + + async _reconcile() { + // Incoming records always apply, and any local divergence re-uploads afterwards + // (see ZenSpacesSyncModel.noteApplied). The base reconciliation would + // instead drop incoming changes whenever the id is also in the local + // changed set, because the snapshot diff stamps its changes with "now" + // and the local side would always win the age comparison. + return true; + } + + async trackRemainingChanges() {} + + async _onRecordsWritten(succeeded) { + ZenSpacesSyncModel.markUploaded(succeeded); + } +} diff --git a/src/zen/sync/ZenSpacesSyncApplier.sys.mjs b/src/zen/sync/ZenSpacesSyncApplier.sys.mjs new file mode 100644 index 000000000..398df24c2 --- /dev/null +++ b/src/zen/sync/ZenSpacesSyncApplier.sys.mjs @@ -0,0 +1,839 @@ +/* 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/. */ + +import { + canonicalJSON, + LAYOUT_RECORD_ID, + RECORD_KINDS, + ZenSpacesSyncModel, +} from "resource:///modules/zen/ZenSpacesSyncModel.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + SessionSaver: "resource:///modules/sessionstore/SessionSaver.sys.mjs", + E10SUtils: "resource://gre/modules/E10SUtils.sys.mjs", + TabStateCache: "resource:///modules/sessionstore/TabStateCache.sys.mjs", + ZenWindowSync: "resource:///modules/zen/ZenWindowSync.sys.mjs", + ZenLiveFoldersManager: + "resource:///modules/zen/ZenLiveFoldersManager.sys.mjs", + ContextualIdentityService: + "moz-src:///toolkit/components/contextualidentity/ContextualIdentityService.sys.mjs", +}); + +/** + * Applies batches of incoming Spaces records to the local browser. + */ +class nsZenSpacesSyncApplier { + #itemIn(win, id) { + return id ? win.document.getElementById(id) : null; + } + + /** + * @param {Array} records - decrypted incoming records. + * @returns {Array} ids of records that failed to apply. + */ + async applyBatch(records) { + const incoming = { + containers: [], + spaces: [], + tabs: [], + folders: [], + splits: [], + layout: null, + deleted: [], + }; + const handled = new Set(); + + for (const record of records) { + if (typeof record.id !== "string" || !record.id) { + continue; + } + if (record.deleted) { + if (record.id !== LAYOUT_RECORD_ID) { + incoming.deleted.push({ key: record.id, record }); + } + handled.add(record); + continue; + } + const data = record.cleartext?.data; + if (!data) { + continue; + } + const entry = { key: record.id, data, record }; + switch (record.cleartext.kind) { + case RECORD_KINDS.CONTAINER: + incoming.containers.push(entry); + break; + case RECORD_KINDS.SPACE: + incoming.spaces.push(entry); + break; + case RECORD_KINDS.TAB: + incoming.tabs.push(entry); + break; + case RECORD_KINDS.FOLDER: + incoming.folders.push(entry); + break; + case RECORD_KINDS.SPLIT: + incoming.splits.push(entry); + break; + case RECORD_KINDS.LAYOUT: + incoming.layout = entry; + break; + default: + // Unknown kind (newer client): leave untouched and unacknowledged. + continue; + } + handled.add(record); + } + + const failed = new Set(); + const fail = (record, e) => { + failed.add(record.id); + console.error(`ZenSpacesSync: failed to apply ${record.id}:`, e); + }; + + const deletions = this.#applyContainers(incoming, fail); + + const win = lazy.ZenWindowSync.firstSyncedWindow; + if (!win) { + // Report the records as failed so the engine redelivers the batch on a later sync. + const noWindow = new Error("no synced window to apply into"); + for (const entry of [ + ...incoming.spaces, + ...incoming.folders, + ...incoming.tabs, + ...incoming.splits, + ...(incoming.layout ? [incoming.layout] : []), + ...deletions, + ]) { + fail(entry.record, noWindow); + } + } else { + await win.gZenWorkspaces.promiseInitialized; + const removals = this.#routeTombstones(win, deletions); + this.#deleteTabs(win, removals.tabs, fail); + this.#deleteSplits(win, removals.splits, fail); + await this.#applySpaces(win, incoming.spaces, fail); + await this.#applyFolders(win, incoming.folders, fail); + this.#applyTabs(win, incoming.tabs, fail); + this.#applySplits(win, incoming.splits, fail); + await this.#deleteFolders(win, removals.folders, fail); + await this.#deleteSpaces(win, removals.spaces, fail); + this.#applyOrdering(win, incoming, fail); + // Collect the session soon so the stored sidebar (and with it the + // sync projections) reflects the applied state instead of re-uploading + // the pre-apply one. + lazy.SessionSaver.runDelayed(); + } + + ZenSpacesSyncModel.invalidate(); + for (const record of records) { + if (!handled.has(record) || failed.has(record.id)) { + continue; + } + ZenSpacesSyncModel.noteApplied( + record.id, + record.deleted ? null : record.cleartext + ); + } + return [...failed]; + } + + /** + * Tombstones have no payload. Whatever local thing owns the id decides + * what kind of deletion this is. Ids with no local counterpart need no + * work at all. + * + * @param {Window} win + * @param {Array<{key: string, record: object}>} deletions + */ + #routeTombstones(win, deletions) { + const routed = { tabs: [], folders: [], splits: [], spaces: [] }; + const spaces = win.gZenWorkspaces.getWorkspaces(); + for (const entry of deletions) { + const el = this.#itemIn(win, entry.key); + if (win.gBrowser.isTab(el)) { + routed.tabs.push(entry); + } else if (el?.isZenFolder) { + routed.folders.push(entry); + } else if (el?.hasAttribute?.("split-view-group")) { + routed.splits.push(entry); + } else if (spaces.some(s => s.uuid === entry.key)) { + routed.spaces.push(entry); + } + } + return routed; + } + + /* Mark: containers */ + + #applyContainers(incoming, fail) { + for (const { key: guid, data, record } of incoming.containers) { + try { + if (!data.name) { + continue; + } + const mapped = ZenSpacesSyncModel.contextIdForGuid(guid); + const existing = + mapped !== null + ? lazy.ContextualIdentityService.getPublicIdentityFromId(mapped) + : null; + if (existing) { + lazy.ContextualIdentityService.update( + mapped, + data.name, + data.icon, + data.color + ); + } else { + const identity = lazy.ContextualIdentityService.create( + data.name, + data.icon, + data.color + ); + ZenSpacesSyncModel.registerContainerGuid( + guid, + identity.userContextId + ); + } + } catch (e) { + fail(record, e); + } + } + + const rest = []; + for (const entry of incoming.deleted) { + const mapped = ZenSpacesSyncModel.contextIdForGuid(entry.key); + if (mapped === null) { + rest.push(entry); + continue; + } + try { + if (lazy.ContextualIdentityService.getPublicIdentityFromId(mapped)) { + lazy.ContextualIdentityService.remove(mapped); + } + ZenSpacesSyncModel.forgetContainerGuid(entry.key); + } catch (e) { + fail(entry.record, e); + } + } + return rest; + } + + /* Mark: spaces */ + + async #applySpaces(win, spaces, fail) { + if (!spaces.length) { + return; + } + const list = win.gZenWorkspaces.getWorkspaces(); + let changed = false; + const repaint = []; + for (const { data, record } of spaces) { + try { + if (!data.uuid) { + continue; + } + const fields = { + uuid: data.uuid, + name: data.name ?? "", + icon: data.icon ?? undefined, + theme: data.theme ?? null, + containerTabId: + ZenSpacesSyncModel.contextIdForGuid(data.containerGuid) ?? 0, + }; + // A space record re-syncs whenever its child ordering changes, so + // only propagate (and especially repaint) when its own fields did. + const index = list.findIndex(s => s.uuid === data.uuid); + const current = index === -1 ? null : list[index]; + const visualChanged = + !current || + (current.name ?? "") !== fields.name || + (current.icon ?? null) !== (fields.icon ?? null) || + canonicalJSON(current.theme ?? null) !== + canonicalJSON(fields.theme ?? null); + const containerChanged = + !current || (current.containerTabId ?? 0) !== fields.containerTabId; + if (!current) { + list.push(fields); + changed = true; + } else if (visualChanged || containerChanged) { + list[index] = { ...current, ...fields }; + changed = true; + } + if (visualChanged) { + repaint.push(data.uuid); + } + } catch (e) { + fail(record, e); + } + } + if (changed) { + await win.gZenWorkspaces.propagateWorkspaces(list); + win.gZenWindowSync.propagateWorkspacesToAllWindows(list); + for (const uuid of repaint) { + const updated = list.find(s => s.uuid === uuid); + if (updated) { + win.gZenThemePicker.onWorkspaceChange(updated); + } + } + } + } + + async #deleteSpaces(win, removals, fail) { + for (const { key: uuid, record } of removals) { + try { + const spaces = win.gZenWorkspaces.getWorkspaces(); + if (!spaces.some(s => s.uuid === uuid)) { + continue; + } + if (spaces.length <= 1) { + // Never delete the last space; the local copy revives it remotely. + continue; + } + if (!(await this.#confirmRemoteSpaceDelete(win, uuid))) { + continue; + } + await win.gZenWorkspaces.removeWorkspace(uuid); + } catch (e) { + fail(record, e); + } + } + } + + /** + * @param {Window} win + * @param {string} uuid + * @returns {Promise} Whether the user confirmed the deletion. + */ + async #confirmRemoteSpaceDelete(win, uuid) { + const name = win.gZenWorkspaces.getWorkspaceFromId(uuid)?.name || uuid; + const [title, body] = await win.document.l10n.formatValues([ + { id: "zen-workspaces-remote-delete-title" }, + { id: "zen-workspaces-remote-delete-body", args: { name } }, + ]); + const result = await Services.prompt.asyncConfirmEx( + win.browsingContext, + Services.prompt.MODAL_TYPE_WINDOW, + title, + body, + Services.prompt.STD_YES_NO_BUTTONS, + null, + null, + null, + null, + false + ); + return result.get("buttonNumClicked") === 0; + } + + /* Mark: folders */ + + async #applyFolders(win, folders, fail) { + // Parents before children so nesting targets exist. + const depths = new Map(folders.map(f => [f.key, f.data.parentFolderId])); + const depthOf = key => { + let depth = 0; + let parent = depths.get(key); + while (parent && depths.has(parent) && depth < 10) { + depth++; + parent = depths.get(parent); + } + return depth; + }; + const ordered = [...folders].sort( + (a, b) => depthOf(a.key) - depthOf(b.key) + ); + + for (const { key: folderId, data, record } of ordered) { + try { + let folder = this.#itemIn(win, folderId); + if (!folder?.isZenFolder) { + folder = win.gZenFolders.createFolder([], { + id: folderId, + label: data.name || "Folder", + workspaceId: + data.workspaceUuid || win.gZenWorkspaces.activeWorkspace, + isLiveFolder: !!data.live, + }); + } else if (data.name && folder.label !== data.name) { + folder.label = data.name; + } + + if ((folder.iconURL || null) !== (data.icon || null)) { + win.gZenFolders.setFolderUserIcon(folder, data.icon || null); + // Window sync picks this up and mirrors the icon everywhere else. + folder.dispatchEvent( + new win.CustomEvent("TabGroupUpdate", { bubbles: true }) + ); + } + + const desiredParent = data.parentFolderId + ? this.#itemIn(win, data.parentFolderId) + : null; + const currentParent = folder.group; + if ((currentParent?.id || null) !== (data.parentFolderId || null)) { + if (desiredParent?.isZenFolder) { + win.gBrowser.zenHandleTabMove(folder, () => { + if (desiredParent.tabs.length) { + desiredParent.tabs[0].after(folder); + } else { + desiredParent.appendChild(folder); + } + }); + } else if (!data.parentFolderId && currentParent?.isZenFolder) { + const ws = data.workspaceUuid || win.gZenWorkspaces.activeWorkspace; + const container = + win.gZenWorkspaces.workspaceElement(ws)?.pinnedTabsContainer; + if (container) { + win.gBrowser.zenHandleTabMove(folder, () => { + container.insertBefore( + folder, + container.querySelector(".pinned-tabs-container-separator") + ); + }); + } + } + } + if ( + data.workspaceUuid && + folder.getAttribute("zen-workspace-id") !== data.workspaceUuid + ) { + if (!folder.group) { + win.gZenFolders.changeFolderToSpace(folder, data.workspaceUuid); + } else { + folder.setAttribute("zen-workspace-id", data.workspaceUuid); + } + } + if (data.live) { + await lazy.ZenLiveFoldersManager.adoptSyncedFolder( + folderId, + data.live + ); + } + } catch (e) { + fail(record, e); + } + } + } + + async #deleteFolders(win, removals, fail) { + for (const { key: folderId, record } of removals) { + try { + const folder = this.#itemIn(win, folderId); + if (!folder?.isZenFolder) { + continue; + } + // Members without their own tombstone survive: unpack, then delete + // the (now empty) folder. + await folder.unpackTabs(); + await folder.delete(); + } catch (e) { + fail(record, e); + } + } + } + + /* Mark: tabs */ + + #applyTabs(win, tabs, fail) { + for (const { key: tabId, data, record } of tabs) { + try { + if (!data.url || data.url === "about:blank") { + continue; + } + const existing = this.#itemIn(win, tabId); + if (win.gBrowser.isTab(existing)) { + this.#updateTab(win, existing, tabId, data); + } else { + this.#createTab(win, tabId, data); + } + } catch (e) { + fail(record, e); + } + } + } + + #createTab(win, tabId, data) { + const userContextId = + ZenSpacesSyncModel.contextIdForGuid(data.containerGuid) ?? 0; + const tab = win.gBrowser.addTrustedTab(data.url, { + createLazyBrowser: true, + inBackground: true, + skipAnimation: true, + skipBackgroundNotify: true, + lazyTabTitle: data.title || undefined, + userContextId, + }); + // Setting the sync id before the queued TabOpen handler runs makes + // window sync treat this tab as already replicated. + tab.id = tabId; + tab._zenContentsVisible = true; + this.#applyTabDecorations(win, tab, data); + if (data.essential) { + win.gZenPinnedTabManager.addToEssentials(tab); + } else { + if (data.workspaceUuid) { + tab.setAttribute("zen-workspace-id", data.workspaceUuid); + } + win.gBrowser.pinTab(tab); + if (data.workspaceUuid) { + win.gZenWorkspaces.moveTabToWorkspace(tab, data.workspaceUuid); + } + const folder = data.folderId && this.#itemIn(win, data.folderId); + if (folder?.isZenFolder) { + folder.addTabs([tab]); + } + } + lazy.ZenWindowSync.on_TabOpen({ target: tab }, { ignoreExistingId: true }); + lazy.ZenWindowSync.setPinnedInitialState( + tab, + { url: data.url, title: data.title || "" }, + data.icon || undefined + ); + } + + #applyTabDecorations(win, tab, data) { + if (data.defaultContainer) { + tab.setAttribute("zenDefaultUserContextId", "true"); + } else { + tab.removeAttribute("zenDefaultUserContextId"); + } + tab.zenStaticLabel = + typeof data.staticLabel === "string" ? data.staticLabel : undefined; + tab.zenStaticIcon = data.hasStaticIcon && data.icon ? data.icon : undefined; + if (data.icon) { + try { + win.gBrowser.setIcon(tab, data.icon); + // The tab is lazy, so its session state cache keeps the (empty) + // image it was created with: it is only cleared once a restore + // finishes, which never happens for lazy tabs, and it would hide + // the icon from every session collect. Drop it so the session + // saves the icon set above. + lazy.TabStateCache.update(tab.linkedBrowser.permanentKey, { + image: null, + }); + } catch (e) { + console.error("ZenSpacesSync: failed to set tab icon", e); + } + } + } + + /** + * Reconciles the tab's synced identity: pinned initial state, static + * label/icon, the live icon and the default-container marker. + * + * @param {Window} win + * @param {MozTabbrowserTab} tab + * @param {object} data + */ + #updateTabIdentity(win, tab, data) { + const initial = tab._zenPinnedInitialState; + const identityChanged = + initial?.entry?.url !== data.url || + (initial?.entry?.title || "") !== (data.title || ""); + if (identityChanged || (initial?.image || "") !== (data.icon || "")) { + lazy.ZenWindowSync.setPinnedInitialState( + tab, + { url: data.url, title: data.title || "" }, + data.icon || undefined + ); + } + if (identityChanged && !tab.linkedPanel) { + // An unloaded tab has no live state to lose: retarget its lazy session + // state so the next activation loads the new canonical url directly + // instead of whatever the tab was created with. + this.#retargetUnloadedTab(win, tab, data); + } + + const staticLabel = + typeof data.staticLabel === "string" ? data.staticLabel : null; + const localLabel = + typeof tab.zenStaticLabel === "string" ? tab.zenStaticLabel : null; + if (staticLabel !== localLabel) { + tab.zenStaticLabel = staticLabel ?? undefined; + if (staticLabel) { + tab._zenChangeLabelFlag = true; + try { + win.gBrowser._setTabLabel(tab, staticLabel); + } finally { + delete tab._zenChangeLabelFlag; + } + } + } + tab.zenStaticIcon = data.hasStaticIcon && data.icon ? data.icon : undefined; + if (data.icon && tab.getAttribute("image") !== data.icon) { + try { + win.gBrowser.setIcon(tab, data.icon); + } catch (e) { + console.error("ZenSpacesSync: failed to set tab icon", e); + } + } + if (data.defaultContainer) { + tab.setAttribute("zenDefaultUserContextId", "true"); + } else { + tab.removeAttribute("zenDefaultUserContextId"); + } + } + + /** + * Points an unloaded tab's session state at the record's canonical url, + * mirroring ZenPinnedTabManager's reset-to-stored-state shape. + * + * @param {Window} win + * @param {MozTabbrowserTab} tab + * @param {object} data + */ + #retargetUnloadedTab(win, tab, data) { + try { + const state = JSON.parse(win.SessionStore.getTabState(tab)); + state.entries = [ + { + url: data.url, + title: data.title || "", + triggeringPrincipal_base64: lazy.E10SUtils.serializePrincipal( + Services.scriptSecurityManager.createContentPrincipal( + Services.io.newURI(data.url), + {} + ) + ), + }, + ]; + state.index = 1; + state.image = data.icon || undefined; + delete state.scroll; + win.SessionStore.setTabState(tab, state); + } catch (e) { + console.error("ZenSpacesSync: failed to retarget unloaded tab", e); + } + } + + #updateTab(win, tab, tabId, data) { + this.#updateTabIdentity(win, tab, data); + + const isEssential = tab.hasAttribute("zen-essential"); + if (data.essential && !isEssential) { + win.gZenPinnedTabManager.addToEssentials(tab); + return; + } + if (!data.essential && isEssential) { + win.gZenPinnedTabManager.removeEssentials(tab, /* unpin */ false); + } + if (data.essential) { + return; + } + + const inSplit = tab.group?.hasAttribute("split-view-group"); + if (inSplit) { + // The split record governs placement of its members. + return; + } + const currentFolder = tab.group?.isZenFolder ? tab.group.id : null; + const wantFolder = data.folderId || null; + if (currentFolder !== wantFolder) { + const target = wantFolder && this.#itemIn(win, wantFolder); + if (target?.isZenFolder) { + target.addTabs([tab]); + } else if (!wantFolder && currentFolder) { + // ungroupTab pops a single nesting level: a tab inside a subfolder + // lands inside the parent folder, so keep going until the tab is + // actually top-level. The isTabGroup check keeps the collapsed + // pinned-section pseudo-group (which the group getter also returns) + // from ever looping. + while (win.gBrowser.isTabGroup(tab.group) && tab.group.isZenFolder) { + win.gBrowser.ungroupTab(tab); + } + } + } + if ( + !wantFolder && + data.workspaceUuid && + tab.getAttribute("zen-workspace-id") !== data.workspaceUuid + ) { + win.gZenWorkspaces.moveTabToWorkspace(tab, data.workspaceUuid); + } + } + + #removeTab(win, tab) { + if (tab.splitView) { + win.gZenViewSplitter.removeTabFromGroup(tab, undefined, { + forUnsplit: true, + changeTab: false, + }); + } + win.gBrowser.removeTab(tab, { animate: true }); + } + + #deleteTabs(win, removals, fail) { + for (const { key: tabId, record } of removals) { + try { + const tab = this.#itemIn(win, tabId); + if (win.gBrowser.isTab(tab)) { + this.#removeTab(win, tab); + } + } catch (e) { + fail(record, e); + } + } + } + + /* Mark: splits */ + + #applySplits(win, splits, fail) { + for (const { key: splitId, data, record } of splits) { + try { + const members = (data.tabs || []) + .map(id => this.#itemIn(win, id)) + .filter(tab => win.gBrowser.isTab(tab)); + const existing = this.#itemIn(win, splitId); + const hasGroup = existing?.hasAttribute?.("split-view-group"); + if (hasGroup) { + const wanted = new Set(data.tabs || []); + for (const tab of [...existing.tabs]) { + if (tab.id && !wanted.has(tab.id)) { + win.gZenViewSplitter.removeTabFromGroup(tab, undefined, { + changeTab: false, + }); + } + } + } + if (members.length >= 2) { + win.gZenViewSplitter.splitTabs(members, data.gridType, -1, { + groupFetchId: splitId, + }); + } + // Folder membership, mirroring the tab flow in #updateTab: the + // record's folderId is authoritative for where the group lives. + const group = this.#itemIn(win, splitId); + if (group?.hasAttribute?.("split-view-group")) { + const currentFolder = group.group?.isZenFolder + ? group.group.id + : null; + const wantFolder = data.folderId || null; + if (currentFolder !== wantFolder) { + const target = wantFolder && this.#itemIn(win, wantFolder); + if (target?.isZenFolder) { + target.addTabs([group]); + } else if (!wantFolder && currentFolder) { + // Unwrap every folder level, not just the innermost one. + while ( + win.gBrowser.isTabGroup(group.group) && + group.group.isZenFolder + ) { + win.gBrowser.ungroupTab(group); + } + } + } + } + } catch (e) { + fail(record, e); + } + } + } + + #deleteSplits(win, removals, fail) { + for (const { key: splitId, record } of removals) { + try { + const index = win.gZenViewSplitter._data.findIndex( + group => group.groupId === splitId + ); + if (index >= 0) { + win.gZenViewSplitter.removeGroup(index); + } + } catch (e) { + fail(record, e); + } + } + } + + /* Mark: ordering */ + + #applyRelativeOrder(win, elements) { + let prev = null; + for (const el of elements) { + if (!el) { + continue; + } + if ( + prev && + prev.parentNode && + prev.parentNode === el.parentNode && + prev.nextElementSibling !== el + ) { + win.gBrowser.zenHandleTabMove(el, () => prev.after(el)); + } + prev = el; + } + } + + /** + * Ordering runs in the first synced window only: every move goes through + * zenHandleTabMove, whose TabMove events window sync replicates into the + * other windows. + * + * @param {Window} win + * @param {object} incoming + * @param {Function} fail + */ + #applyOrdering(win, incoming, fail) { + for (const { key, data, record } of [ + ...incoming.spaces, + ...incoming.folders, + ]) { + try { + if (Array.isArray(data.children) && data.children.length) { + // A folder's empty-tab placeholder anchors its first child: without + // it, relative ordering could never move anything to the start. + const folder = data.folderId ? this.#itemIn(win, key) : null; + const start = folder?.isZenFolder + ? folder.tabs.find(t => t.hasAttribute("zen-empty-tab")) + : null; + this.#applyRelativeOrder(win, [ + start ?? null, + ...data.children.map(id => this.#itemIn(win, id)), + ]); + } + } catch (e) { + fail(record, e); + } + } + if (!incoming.layout) { + return; + } + const { data, record } = incoming.layout; + try { + for (const tabIds of Object.values(data.essentials || {})) { + if (Array.isArray(tabIds) && tabIds.length > 1) { + this.#applyRelativeOrder( + win, + tabIds.map(id => this.#itemIn(win, id)) + ); + } + } + if (Array.isArray(data.spaces) && data.spaces.length > 1) { + const current = win.gZenWorkspaces.getWorkspaces(); + const byUuid = new Map(current.map(s => [s.uuid, s])); + const ordered = data.spaces + .map(uuid => byUuid.get(uuid)) + .filter(Boolean); + for (const space of current) { + if (!data.spaces.includes(space.uuid)) { + ordered.push(space); + } + } + const changedOrder = ordered.some( + (space, i) => current[i]?.uuid !== space.uuid + ); + if (changedOrder && ordered.length === current.length) { + win.gZenWindowSync.propagateWorkspacesToAllWindows(ordered); + } + } + } catch (e) { + fail(record, e); + } + } +} + +export const ZenSpacesSyncApplier = new nsZenSpacesSyncApplier(); diff --git a/src/zen/sync/ZenSpacesSyncModel.sys.mjs b/src/zen/sync/ZenSpacesSyncModel.sys.mjs new file mode 100644 index 000000000..3d08be58d --- /dev/null +++ b/src/zen/sync/ZenSpacesSyncModel.sys.mjs @@ -0,0 +1,609 @@ +/* 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/. */ + +import { JSONFile } from "resource://gre/modules/JSONFile.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + ZenSessionStore: "resource:///modules/zen/ZenSessionManager.sys.mjs", + ZenLiveFoldersManager: + "resource:///modules/zen/ZenLiveFoldersManager.sys.mjs", + ContextualIdentityService: + "moz-src:///toolkit/components/contextualidentity/ContextualIdentityService.sys.mjs", +}); + +export const SIDEBAR_COLLECTED_TOPIC = "zen-sidebar-data-collected"; + +export const RECORD_KINDS = Object.freeze({ + CONTAINER: "container", + SPACE: "space", + TAB: "tab", + FOLDER: "folder", + SPLIT: "split", + LAYOUT: "layout", +}); + +export const LAYOUT_RECORD_ID = "layout"; + +// Firefox ships four built-in containers with fixed userContextIds. They +// exist on every device, so they map to well-known guids instead of the +// per-profile generated ones. +const BUILTIN_CONTAINER_MAX = 4; +const BUILTIN_GUID_PREFIX = "builtin-"; + +const STORE_FILE_NAME = "zen-spaces-sync.json"; +const STORE_VERSION = 1; + +function sortedClone(value) { + if (Array.isArray(value)) { + return value.map(sortedClone); + } + if (value && typeof value === "object") { + const out = {}; + for (const key of Object.keys(value).sort()) { + out[key] = sortedClone(value[key]); + } + return out; + } + return value === undefined ? null : value; +} + +/** + * Deterministic JSON serialization (recursively sorted keys), so that two + * structurally equal payloads always stringify identically. + * + * @param {object} value + */ +export function canonicalJSON(value) { + return JSON.stringify(sortedClone(value)); +} + +/** + * Digest of a record's canonical form. The uploaded-state snapshot only ever + * needs equality checks, so it stores these short digests instead of the + * full canonical strings, keeping the store file small and cheap to write. + * + * @param {string} kind + * @param {object} data + * @returns {string} base64 SHA-256 of the canonical record payload. + */ +const textEncoder = new TextEncoder(); + +export function recordDigest(kind, data) { + const hasher = Cc["@mozilla.org/security/hash;1"].createInstance( + Ci.nsICryptoHash + ); + hasher.init(Ci.nsICryptoHash.SHA256); + const bytes = textEncoder.encode(canonicalJSON({ kind, data })); + hasher.update(bytes, bytes.length); + return hasher.finish(/* base64 = */ true); +} + +/** + * Owns the synced-data model for the Spaces engine. + */ +class nsZenSpacesSyncModel { + #file = null; + #cache = null; + + #data() { + if (!this.#file) { + this.#file = new JSONFile({ + path: PathUtils.join(PathUtils.profileDir, STORE_FILE_NAME), + dataPostProcessor: data => { + if (data.version !== STORE_VERSION) { + // Drop the uploaded snapshot so everything re-diffs against + // the (also wiped) server, but keep the container identity mappings. + data.uploaded = {}; + } + data.version = STORE_VERSION; + data.uploaded ||= {}; + data.containers ||= {}; + return data; + }, + }); + this.#file.ensureDataReady(); + } + return this.#file.data; + } + + invalidate() { + this.#cache = null; + } + + /* Mark: container identity */ + + guidForContextId(userContextId, { create = false } = {}) { + const id = Number(userContextId); + if (!Number.isSafeInteger(id) || id <= 0) { + return null; + } + if (id <= BUILTIN_CONTAINER_MAX) { + return `${BUILTIN_GUID_PREFIX}${id}`; + } + const data = this.#data(); + for (const [guid, mapped] of Object.entries(data.containers)) { + if (mapped === id) { + return guid; + } + } + if (!create) { + return null; + } + const guid = Services.uuid.generateUUID().toString().slice(1, -1); + data.containers[guid] = id; + this.#file.saveSoon(); + return guid; + } + + contextIdForGuid(guid) { + if (typeof guid !== "string" || !guid) { + return null; + } + if (guid.startsWith(BUILTIN_GUID_PREFIX)) { + const id = Number(guid.slice(BUILTIN_GUID_PREFIX.length)); + return Number.isSafeInteger(id) && id > 0 && id <= BUILTIN_CONTAINER_MAX + ? id + : null; + } + return this.#data().containers[guid] ?? null; + } + + registerContainerGuid(guid, userContextId) { + if (guid.startsWith(BUILTIN_GUID_PREFIX)) { + return; + } + this.#data().containers[guid] = userContextId; + this.#file.saveSoon(); + } + + forgetContainerGuid(guid) { + const data = this.#data(); + if (guid in data.containers) { + delete data.containers[guid]; + this.#file.saveSoon(); + } + } + + /* Mark: projections */ + + #isSyncableTab(tabData) { + return !!( + tabData && + tabData.zenSyncId && + (tabData.pinned || tabData.zenEssential) && + !tabData.zenIsEmpty && + !tabData.zenIsGlance && + !tabData.zenLiveFolderItemId + ); + } + + #tabIdentity(tabData) { + const initial = tabData._zenPinnedInitialState; + let url = initial?.entry?.url; + let title = initial?.entry?.title; + if (!url || url === "about:blank") { + const entries = tabData.entries || []; + if (entries.length) { + let index = Math.min( + Math.max((tabData.index || entries.length) - 1, 0), + entries.length - 1 + ); + const entry = entries[index] || entries[0]; + if (entry?.url && entry.url !== "about:blank") { + url = entry.url; + title ??= entry.title; + } + } + } + if (!url || url === "about:blank") { + return null; + } + const icon = initial?.image || tabData.image || ""; + return { url, title: title || "", icon }; + } + + /** + * Builds the ordered child id list for one parent (a space's pinned + * section or a folder). Tabs and splits are ordered by their position in + * the collected tab list; folders are spliced in next to their recorded + * previous sibling. + * + * @param {object} root0 + * @param {Array} root0.tabs + * @param {Array} root0.folders + * @param {Set} root0.splitIds + * @param {Map} root0.splitParents + * @param {Map} root0.splitWs + * @param {object} root0.scope + */ + #childSequence({ tabs, folders, splitIds, splitParents, splitWs, scope }) { + const seq = []; + const seenSplits = new Set(); + for (const tab of tabs) { + const groupId = tab.groupId || null; + if (groupId && splitIds.has(groupId)) { + if ( + !seenSplits.has(groupId) && + scope.matchSplit( + splitParents.get(groupId) ?? null, + splitWs.get(groupId) ?? null + ) + ) { + seenSplits.add(groupId); + seq.push(groupId); + } + continue; + } + if (scope.matchTab(tab, groupId)) { + seq.push(tab.zenSyncId); + } + } + for (const folder of folders) { + if (!scope.matchFolder(folder)) { + continue; + } + let index = seq.length; + const prev = folder.prevSiblingInfo; + if (!prev || prev.type === "start") { + // No stored sibling means the folder is the first child of its + // container (the section rebuild on startup places content before + // the fake start element, so the first item has no sibling at all). + index = 0; + } else if (prev.id) { + const at = seq.indexOf(prev.id); + if (at !== -1) { + index = at + 1; + } else if (prev.type === "tab") { + // An unsynced tab sibling (empty placeholders, live folder items) + // always sits at the start of its container. + index = 0; + } + } + seq.splice(index, 0, folder.id); + } + return seq; + } + + /** + * Derives the shared projection context from one sidebar collection. + * Syncable tabs, folders, the syncable splits and their parents and + * workspaces, plus the folder-membership resolver. + * + * @param {object} sidebar + */ + #projectionContext(sidebar) { + const tabs = (sidebar.tabs || []).filter(t => this.#isSyncableTab(t)); + const folders = (sidebar.folders || []).filter( + f => f?.id && !f.splitViewGroup + ); + const folderIds = new Set(folders.map(f => f.id)); + const allSplits = (sidebar.splitViewData || []).filter(g => g?.groupId); + const splitParents = new Map(); + for (const entry of sidebar.folders || []) { + if (entry?.splitViewGroup && entry.id) { + splitParents.set(entry.id, entry.parentId || null); + } + } + + // Only splits whose members are all synced tabs are synced themselves. + const syncableTabIds = new Set(tabs.map(t => t.zenSyncId)); + const splits = allSplits.filter( + g => + Array.isArray(g.tabs) && + g.tabs.length >= 2 && + g.tabs.every(id => syncableTabIds.has(id)) + ); + const splitIds = new Set(splits.map(g => g.groupId)); + const splitWs = new Map(); + for (const tab of tabs) { + const groupId = tab.groupId || null; + if (groupId && splitIds.has(groupId) && !splitWs.has(groupId)) { + splitWs.set(groupId, tab.zenWorkspace || null); + } + } + const folderOf = groupId => { + if (!groupId) { + return null; + } + if (splitIds.has(groupId)) { + return splitParents.get(groupId) || null; + } + return folderIds.has(groupId) ? groupId : null; + }; + return { tabs, folders, splits, splitIds, splitParents, splitWs, folderOf }; + } + + /** + * @param {Map} map + * @param {object} ctx - The projection context. + */ + #projectTabs(map, ctx) { + for (const tab of ctx.tabs) { + const identity = this.#tabIdentity(tab); + if (!identity) { + continue; + } + const essential = !!tab.zenEssential; + map.set(tab.zenSyncId, { + kind: RECORD_KINDS.TAB, + data: { + tabId: tab.zenSyncId, + url: identity.url, + title: identity.title, + icon: identity.icon, + containerGuid: this.guidForContextId(tab.userContextId, { + create: true, + }), + essential, + workspaceUuid: essential ? null : tab.zenWorkspace || null, + folderId: ctx.folderOf(tab.groupId || null), + staticLabel: + typeof tab.zenStaticLabel === "string" ? tab.zenStaticLabel : null, + hasStaticIcon: !!tab.zenHasStaticIcon, + defaultContainer: !!tab.zenDefaultUserContextId, + }, + }); + } + } + + projections() { + const sidebar = lazy.ZenSessionStore.getSidebarData() || {}; + const stamp = sidebar.lastCollected || 0; + if (this.#cache && this.#cache.stamp === stamp) { + return this.#cache.map; + } + + const map = new Map(); + const ctx = this.#projectionContext(sidebar); + const { tabs, folders, splits, splitIds, splitParents, splitWs } = ctx; + + for (const identity of lazy.ContextualIdentityService.getPublicIdentities()) { + if (!identity.name) { + continue; + } + const guid = this.guidForContextId(identity.userContextId, { + create: true, + }); + if (!guid) { + continue; + } + map.set(guid, { + kind: RECORD_KINDS.CONTAINER, + data: { + guid, + name: identity.name, + icon: identity.icon || "", + color: identity.color || "", + }, + }); + } + + const spaces = sidebar.spaces || []; + for (const space of spaces) { + if (!space?.uuid) { + continue; + } + const uuid = space.uuid; + map.set(uuid, { + kind: RECORD_KINDS.SPACE, + data: { + uuid, + name: space.name ?? "", + icon: space.icon ?? null, + theme: space.theme ?? null, + containerGuid: this.guidForContextId(space.containerTabId, { + create: true, + }), + children: this.#childSequence({ + tabs, + folders, + splitIds, + splitParents, + splitWs, + scope: { + matchTab: (t, groupId) => + !groupId && + !t.zenEssential && + (t.zenWorkspace || null) === uuid, + matchSplit: (parent, ws) => !parent && ws === uuid, + matchFolder: f => !f.parentId && (f.workspaceId || null) === uuid, + }, + }), + }, + }); + } + + for (const folder of folders) { + const fid = folder.id; + let live = null; + if (folder.isLiveFolder) { + try { + live = lazy.ZenLiveFoldersManager.getSyncableFolderData(fid); + } catch (e) { + console.error("ZenSpacesSync: failed to project live folder", e); + } + } + map.set(fid, { + kind: RECORD_KINDS.FOLDER, + data: { + folderId: fid, + name: folder.name ?? "", + icon: folder.userIcon || null, + workspaceUuid: folder.workspaceId || null, + parentFolderId: folder.parentId || null, + live, + children: this.#childSequence({ + tabs, + folders, + splitIds, + splitParents, + splitWs, + scope: { + matchTab: (t, groupId) => groupId === fid, + matchSplit: parent => parent === fid, + matchFolder: f => f.parentId === fid, + }, + }), + }, + }); + } + + this.#projectTabs(map, ctx); + + for (const split of splits) { + map.set(split.groupId, { + kind: RECORD_KINDS.SPLIT, + data: { + splitId: split.groupId, + gridType: split.gridType || "grid", + tabs: [...split.tabs], + workspaceUuid: splitWs.get(split.groupId) ?? null, + folderId: splitParents.get(split.groupId) || null, + }, + }); + } + + if (spaces.length) { + const essentials = {}; + for (const tab of tabs) { + if (!tab.zenEssential) { + continue; + } + const key = + this.guidForContextId(tab.userContextId, { create: true }) || + "default"; + (essentials[key] ||= []).push(tab.zenSyncId); + } + map.set(LAYOUT_RECORD_ID, { + kind: RECORD_KINDS.LAYOUT, + data: { + spaces: spaces.map(s => s.uuid).filter(Boolean), + essentials, + }, + }); + } + + this.#cache = { stamp, map }; + return map; + } + + #digestCache = null; + + /** + * Digests of all current projections. Change detection runs several times per + * sync cycle (tracker observe, changed-id computation, upload bookkeeping) + * and only needs to hash again after a fresh sidebar collection. + */ + #digestAll() { + const map = this.projections(); + if (this.#digestCache?.map === map) { + return this.#digestCache.digests; + } + const digests = new Map(); + for (const [id, projected] of map) { + digests.set(id, recordDigest(projected.kind, projected.data)); + } + this.#digestCache = { map, digests }; + return digests; + } + + /* Mark: engine-facing API */ + + getAllRecordIds() { + const ids = {}; + for (const id of this.projections().keys()) { + ids[id] = true; + } + return ids; + } + + itemExists(id) { + return this.projections().has(id); + } + + projectRecord(id) { + return this.projections().get(id) ?? null; + } + + /** + * Changes = diff between the current projections and the last state the + * server acknowledged. Ids present locally with different content are + * modified; ids only present in the uploaded snapshot are deletions. + */ + computeChangedIDs() { + const uploaded = this.#data().uploaded; + const current = this.#digestAll(); + const now = Date.now() / 1000; + const changes = {}; + for (const [id, digest] of current) { + if (uploaded[id] !== digest) { + changes[id] = now; + } + } + for (const id of Object.keys(uploaded)) { + if (!current.has(id)) { + changes[id] = now; + } + } + return changes; + } + + hasPendingChanges() { + const uploaded = this.#data().uploaded; + const current = this.#digestAll(); + for (const [id, digest] of current) { + if (uploaded[id] !== digest) { + return true; + } + } + for (const id of Object.keys(uploaded)) { + if (!current.has(id)) { + return true; + } + } + return false; + } + + /** + * Called after records were successfully uploaded. Remember exactly what + * the server now holds for them. + * + * @param {Array} ids + */ + markUploaded(ids) { + const data = this.#data(); + const current = this.#digestAll(); + for (const id of ids) { + if (current.has(id)) { + data.uploaded[id] = current.get(id); + } else { + delete data.uploaded[id]; + } + } + this.#file.saveSoon(); + } + + /** + * Called after an incoming record was applied locally. Storing the + * incoming payload as the uploaded state means a faithful local + * materialization produces no re-upload, while a divergent one re-uploads + * the local truth (self-healing). + * + * @param {string} id + * @param {?object} cleartext - null for tombstones. + */ + noteApplied(id, cleartext) { + const data = this.#data(); + if (!cleartext) { + delete data.uploaded[id]; + } else { + data.uploaded[id] = recordDigest(cleartext.kind, cleartext.data); + } + this.#file.saveSoon(); + } +} + +export const ZenSpacesSyncModel = new nsZenSpacesSyncModel(); diff --git a/src/zen/sync/ZenSyncManager.sys.mjs b/src/zen/sync/ZenSyncManager.sys.mjs deleted file mode 100644 index 1b454da81..000000000 --- a/src/zen/sync/ZenSyncManager.sys.mjs +++ /dev/null @@ -1,160 +0,0 @@ -/* 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/. */ - -const lazy = {}; - -ChromeUtils.defineESModuleGetters(lazy, { - ZenSessionStore: "resource:///modules/zen/ZenSessionManager.sys.mjs", - ContextualIdentityService: - "resource://gre/modules/ContextualIdentityService.sys.mjs", - ZenWindowSync: "resource:///modules/zen/ZenWindowSync.sys.mjs", -}); - -function normalizeUserContextId(value) { - const normalized = typeof value === "string" ? Number(value) : value; - if (!Number.isSafeInteger(normalized) || normalized <= 0) { - return null; - } - return normalized; -} - -class ZenSyncManager { - getSidebarData() { - return lazy.ZenSessionStore.getSidebarData(); - } - - /** - * Whether to ignore changes to items. This is used to prevent - * infinite loops when applying incoming sync changes. - * - * @type {boolean} - */ - #ignoreChanges = false; - - #changedItems = new Map(); - - markItemChanged(item) { - if (item.type && item.id && !this.#ignoreChanges) { - const key = `${item.type}~${item.id}`; - this.#changedItems.set(key, { type: item.type, id: item.id }); - } - } - - #getChangedItems() { - return Array.from(this.#changedItems.values()); - } - - #clearChangedItems() { - this.#changedItems.clear(); - } - - notifyAboutChanges() { - const changedItems = this.#getChangedItems(); - - for (const item of changedItems) { - Services.obs.notifyObservers( - { wrappedJSObject: item }, - "zen-workspace-item-changed" - ); - } - this.#clearChangedItems(); - } - - async applyIncomingBatch(pulled, removals) { - try { - this.#ignoreChanges = true; - this.#applyIncomingContainers( - pulled.containers || [], - removals.containers || [] - ); - - const win = lazy.ZenWindowSync.firstSyncedWindow; - if (win?.gZenWorkspaces) { - await win.gZenWorkspaces._applySyncChanges(pulled, removals); - } - } catch (e) { - console.error("ZenSyncManager: Failed to apply incoming sync data:", e); - throw e; - } finally { - this.#ignoreChanges = false; - } - } - - #applyIncomingContainers(pulledContainers, removedContainers) { - const localContainersById = new Map( - lazy.ContextualIdentityService.getPublicIdentities().map(container => [ - container.userContextId, - container, - ]) - ); - - for (const container of pulledContainers) { - if (!container.name) { - continue; - } - - const userContextId = normalizeUserContextId(container.userContextId); - if (userContextId === null) { - console.warn( - "ZenSyncManager: Ignoring incoming container with invalid userContextId", - { container } - ); - continue; - } - - const existsLocally = localContainersById.has(userContextId); - - if (existsLocally) { - lazy.ContextualIdentityService.update( - userContextId, - container.name, - container.icon, - container.color - ); - continue; - } - - const createdIdentity = lazy.ContextualIdentityService.create( - container.name, - container.icon, - container.color, - userContextId - ); - if (createdIdentity) { - localContainersById.set(createdIdentity.userContextId, createdIdentity); - } - if (createdIdentity && createdIdentity.userContextId !== userContextId) { - console.warn("ZenSyncManager: Container sync created unexpected ID", { - requestedId: userContextId, - createdId: createdIdentity.userContextId, - name: container.name, - }); - } - } - - for (const container of removedContainers) { - const userContextId = normalizeUserContextId(container.userContextId); - if (userContextId === null) { - console.warn( - "ZenSyncManager: Ignoring container removal with invalid userContextId", - { container } - ); - continue; - } - - if (!localContainersById.has(userContextId)) { - continue; - } - - try { - lazy.ContextualIdentityService.remove(userContextId); - localContainersById.delete(userContextId); - } catch { - // Container may already be gone locally. - } - } - } -} - -export const ZenSyncStore = new ZenSyncManager(); diff --git a/src/zen/sync/ZenWorkspacesSync.sys.mjs b/src/zen/sync/ZenWorkspacesSync.sys.mjs deleted file mode 100644 index 0b0946ed4..000000000 --- a/src/zen/sync/ZenWorkspacesSync.sys.mjs +++ /dev/null @@ -1,404 +0,0 @@ -/* 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/. */ - -import { - Store, - SyncEngine, - Tracker, -} from "resource://services-sync/engines.sys.mjs"; -import { CryptoWrapper } from "resource://services-sync/record.sys.mjs"; -import { SCORE_INCREMENT_XLARGE } from "resource://services-sync/constants.sys.mjs"; - -const lazy = {}; - -ChromeUtils.defineESModuleGetters(lazy, { - ZenSyncStore: "resource:///modules/zen/ZenSyncManager.sys.mjs", - ContextualIdentityService: - "resource://gre/modules/ContextualIdentityService.sys.mjs", -}); - -const RECORD_ID_PREFIX_BY_TYPE = Object.freeze({ - space: "s", - container: "c", -}); - -const RECORD_TYPE_BY_PREFIX = Object.freeze({ - s: "space", - c: "container", -}); - -/** - * Sync record wrapper for workspace and container items stored in the - * Workspaces engine collection. - */ -export class ZenWorkspacesRecord extends CryptoWrapper { - _logName = "Sync.Record.ZenSpaces"; -} - -ZenWorkspacesRecord.prototype.type = "spaces"; - -function parseRecordId(id) { - const sep = id.indexOf("~"); - if (sep === -1) { - return null; - } - const prefix = id.slice(0, sep); - const key = id.slice(sep + 1); - return { type: RECORD_TYPE_BY_PREFIX[prefix] || prefix, key }; -} - -function createRecordId(type, id) { - const prefix = RECORD_ID_PREFIX_BY_TYPE[type]; - if (!prefix) { - throw new Error(`Unknown Spaces Sync record type: ${type}`); - } - return `${prefix}~${id}`; -} - -function normalizeUserContextId(value) { - const normalized = typeof value === "string" ? Number(value) : value; - if (!Number.isSafeInteger(normalized) || normalized <= 0) { - return null; - } - return normalized; -} - -/** - * Strips the sync-envelope fields (`id` and `type`) from incoming record data - * and restores the item's real identity key where needed - * - * @param {object} data - */ -function stripSyncFields(data) { - const rest = { ...data }; - delete rest.id; - delete rest.type; - return rest; -} - -/** - * Sync store implementation that serializes local workspace and container - * state into records and applies incoming remote changes. - */ -class ZenWorkspacesStore extends Store { - constructor(name, engine) { - super(name, engine); - } - - async getAllIDs() { - const ids = {}; - const sidebar = lazy.ZenSyncStore.getSidebarData(); - - for (const space of sidebar.spaces || []) { - if (space.uuid) { - ids[createRecordId("space", space.uuid)] = true; - } - } - - for (const c of lazy.ContextualIdentityService.getPublicIdentities()) { - ids[createRecordId("container", c.userContextId)] = true; - } - - return ids; - } - - async itemExists(id) { - const parsed = parseRecordId(id); - if (!parsed) { - return false; - } - const sidebar = lazy.ZenSyncStore.getSidebarData(); - - switch (parsed.type) { - case "space": - return (sidebar.spaces || []).some(s => s.uuid === parsed.key); - case "container": - return lazy.ContextualIdentityService.getPublicIdentities().some( - c => String(c.userContextId) === parsed.key - ); - default: - return false; - } - } - - async createRecord(id, collection) { - const record = new ZenWorkspacesRecord(collection, id); - const parsed = parseRecordId(id); - if (!parsed) { - record.deleted = true; - return record; - } - - const sidebar = lazy.ZenSyncStore.getSidebarData(); - - switch (parsed.type) { - case "space": { - const spaces = sidebar.spaces || []; - const idx = spaces.findIndex(s => s.uuid === parsed.key); - if (idx === -1) { - record.deleted = true; - return record; - } - const rest = { ...spaces[idx] }; - delete rest.syncStatus; - record.cleartext = { id, type: "space", ...rest, position: idx }; - break; - } - - case "container": { - const container = - lazy.ContextualIdentityService.getPublicIdentities().find( - c => String(c.userContextId) === parsed.key - ); - if (!container) { - record.deleted = true; - return record; - } - record.cleartext = { - id, - type: "container", - userContextId: container.userContextId, - name: container.name, - icon: container.icon, - color: container.color, - }; - break; - } - - default: - record.deleted = true; - } - - return record; - } - - async applyIncomingBatch(records, _countTelemetry) { - const pulled = { spaces: [], containers: [] }; - const removals = { spaces: [], containers: [] }; - - for (const record of records) { - if (record.deleted) { - this._collectRemoval(record.id, removals); - continue; - } - const data = record.cleartext; - if (!data?.type) { - continue; - } - const clean = stripSyncFields(data); - switch (data.type) { - case "space": - pulled.spaces.push(clean); - break; - case "container": - pulled.containers.push(clean); - break; - } - } - - // Suppress change tracking while applying incoming data to prevent - // feedback loops where applied items get re-uploaded immediately. - this.engine._tracker.ignoreAll = true; - try { - await lazy.ZenSyncStore.applyIncomingBatch(pulled, removals); - } finally { - this.engine._tracker.ignoreAll = false; - } - return []; - } - - _collectRemoval(id, removals) { - const parsed = parseRecordId(id); - if (!parsed) { - return; - } - switch (parsed.type) { - case "space": - removals.spaces.push({ uuid: parsed.key }); - break; - case "container": { - const userContextId = normalizeUserContextId(parsed.key); - if (userContextId === null) { - console.warn( - "ZenWorkspacesStore: Ignoring container removal with invalid userContextId", - { id } - ); - break; - } - removals.containers.push({ userContextId }); - break; - } - } - } - - async create(record) { - await this._applySingle(record); - } - - async update(record) { - await this._applySingle(record); - } - - async _applySingle(record) { - this.engine._tracker.ignoreAll = true; - try { - if (record.deleted) { - const removals = { spaces: [], containers: [] }; - this._collectRemoval(record.id, removals); - await lazy.ZenSyncStore.applyIncomingBatch( - { spaces: [], containers: [] }, - removals - ); - return; - } - const data = record.cleartext; - if (!data?.type) { - return; - } - const clean = stripSyncFields(data); - const pulled = { spaces: [], containers: [] }; - switch (data.type) { - case "space": - pulled.spaces.push(clean); - break; - case "container": - pulled.containers.push(clean); - break; - } - await lazy.ZenSyncStore.applyIncomingBatch(pulled, { - spaces: [], - containers: [], - }); - } finally { - this.engine._tracker.ignoreAll = false; - } - } - - async remove() { - // No-op: never delete user data on wipe - } - - async wipe() { - // No-op: never delete user data on wipe - } - - changeItemID() { - // No-op - } -} - -/** - * Sync tracker that watches workspace and contextual identity observers and - * marks the corresponding record IDs as changed. - */ -class ZenWorkspacesTracker extends Tracker { - #changedIDs = {}; - #ignoreAll = false; - - get ignoreAll() { - return this.#ignoreAll; - } - - set ignoreAll(value) { - this.#ignoreAll = value; - } - - onStart() { - Services.obs.addObserver(this, "zen-workspace-item-changed"); - Services.obs.addObserver(this, "contextual-identity-created"); - Services.obs.addObserver(this, "contextual-identity-updated"); - Services.obs.addObserver(this, "contextual-identity-deleted"); - } - - onStop() { - Services.obs.removeObserver(this, "zen-workspace-item-changed"); - Services.obs.removeObserver(this, "contextual-identity-created"); - Services.obs.removeObserver(this, "contextual-identity-updated"); - Services.obs.removeObserver(this, "contextual-identity-deleted"); - } - - observe(subject, topic, _data) { - if (this.#ignoreAll) { - return; - } - if (topic === "zen-workspace-item-changed") { - const type = subject?.wrappedJSObject?.type; - const id = subject?.wrappedJSObject?.id; - if (type && id) { - this._trackChange({ type, id }); - } - } else if (topic.startsWith("contextual-identity-")) { - const id = subject?.wrappedJSObject?.userContextId; - if (id && normalizeUserContextId(id) !== null) { - this._trackChange({ type: "container", id }); - } - } - } - - _trackChange(data) { - if (data.type && data.id) { - const id = createRecordId(data.type, data.id); - this.#changedIDs[id] = Date.now() / 1000; - this.score += SCORE_INCREMENT_XLARGE; - } - } - - async getChangedIDs() { - return { ...this.#changedIDs }; - } - - async addChangedID(id, when) { - this.#changedIDs[id] = when; - return true; - } - - async removeChangedID(...ids) { - for (const id of ids) { - delete this.#changedIDs[id]; - } - return true; - } - - clearChangedIDs() { - this.#changedIDs = {}; - } -} - -/** - * Sync engine entrypoint that wires the Workspaces record, store, and tracker - * implementations into Firefox Sync. - */ -export class ZenWorkspacesEngine extends SyncEngine { - static get name() { - return "Spaces"; - } - - constructor(service) { - super("Spaces", service); - } - - get _storeObj() { - return ZenWorkspacesStore; - } - - get _trackerObj() { - return ZenWorkspacesTracker; - } - - get _recordObj() { - return ZenWorkspacesRecord; - } - - get version() { - return 2; - } - - get syncPriority() { - return 8; - } - - get allowSkippedRecord() { - return false; - } -} diff --git a/src/zen/sync/moz.build b/src/zen/sync/moz.build index d10ad7a19..11868b8a0 100644 --- a/src/zen/sync/moz.build +++ b/src/zen/sync/moz.build @@ -3,6 +3,7 @@ # file, You can obtain one at http://mozilla.org/MPL/2.0/. EXTRA_JS_MODULES.zen += [ - "ZenSyncManager.sys.mjs", - "ZenWorkspacesSync.sys.mjs", + "ZenSpacesSync.sys.mjs", + "ZenSpacesSyncApplier.sys.mjs", + "ZenSpacesSyncModel.sys.mjs", ]