mirror of
https://github.com/zen-browser/desktop.git
synced 2026-08-23 23:11:35 +00:00
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:
committed by
GitHub
parent
28167f86c5
commit
5723a89bcd
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
+ },
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
158
src/zen/sync/ZenSpacesSync.sys.mjs
Normal file
158
src/zen/sync/ZenSpacesSync.sys.mjs
Normal 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);
|
||||
}
|
||||
}
|
||||
839
src/zen/sync/ZenSpacesSyncApplier.sys.mjs
Normal file
839
src/zen/sync/ZenSpacesSyncApplier.sys.mjs
Normal 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();
|
||||
609
src/zen/sync/ZenSpacesSyncModel.sys.mjs
Normal file
609
src/zen/sync/ZenSpacesSyncModel.sys.mjs
Normal 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();
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user