gh-14470: Add tab and folder sync support (gh-13984)

Co-authored-by: mr. m <91018726+mr-cheffy@users.noreply.github.com>
This commit is contained in:
Kristijan Ribarić
2026-08-20 19:16:13 +02:00
committed by GitHub
parent 28167f86c5
commit 5723a89bcd
15 changed files with 1731 additions and 732 deletions

View File

@@ -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.

View File

@@ -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",
+ },
};
/**

View File

@@ -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)

View File

@@ -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;
}

View File

@@ -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

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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<string>} 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<boolean>} 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();

View File

@@ -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<object>} root0.tabs
* @param {Array<object>} root0.folders
* @param {Set<string>} root0.splitIds
* @param {Map<string, ?string>} root0.splitParents
* @param {Map<string, ?string>} 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<string>} 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();

View File

@@ -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();

View File

@@ -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;
}
}

View File

@@ -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",
]