diff --git a/prefs/zen/sync.yaml b/prefs/zen/sync.yaml
new file mode 100644
index 000000000..dff75e481
--- /dev/null
+++ b/prefs/zen/sync.yaml
@@ -0,0 +1,12 @@
+# 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/.
+
+- name: services.sync.engine.spaces
+ value: true
+ condition: "@IS_TWILIGHT@"
+
+- name: services.sync.engine.spaces
+ value: false
+ locked: true
+ condition: "!@IS_TWILIGHT@"
diff --git a/prefs/zen/workspaces.yaml b/prefs/zen/workspaces.yaml
index 24aaf887c..dfa8b7d6c 100644
--- a/prefs/zen/workspaces.yaml
+++ b/prefs/zen/workspaces.yaml
@@ -32,9 +32,6 @@
- name: zen.workspaces.scroll-modifier-key
value: ctrl
-- name: services.sync.engine.workspaces
- value: false
-
- name: zen.workspaces.separate-essentials
value: true
diff --git a/src/browser/components/preferences/config/account-sync-mjs.patch b/src/browser/components/preferences/config/account-sync-mjs.patch
new file mode 100644
index 000000000..ce14b3e05
--- /dev/null
+++ b/src/browser/components/preferences/config/account-sync-mjs.patch
@@ -0,0 +1,20 @@
+diff --git a/browser/components/preferences/config/account-sync.mjs b/browser/components/preferences/config/account-sync.mjs
+index b503987a08e2ce64bfc01c4e3a21e5e19ef834f0..b1c03a0b219fd3eddd93c1deaeb18ab79c85f168 100644
+--- a/browser/components/preferences/config/account-sync.mjs
++++ b/browser/components/preferences/config/account-sync.mjs
+@@ -42,6 +42,7 @@ Preferences.addAll([
+ { id: "services.sync.engine.creditcards", type: "bool" },
+ { id: "services.sync.engine.addons", type: "bool" },
+ { id: "services.sync.engine.prefs", type: "bool" },
++ { id: "services.sync.engine.spaces", type: "bool" },
+ ]);
+
+ /**
+@@ -546,6 +547,7 @@ const SYNC_ENGINE_SETTINGS = [
+ type: "payments",
+ },
+ { id: "syncAddons", pref: "services.sync.engine.addons", type: "addons" },
++ { id: "syncSpaces", pref: "services.sync.engine.spaces", type: "workspaces" },
+ { id: "syncSettings", pref: "services.sync.engine.prefs", type: "settings" },
+ ];
+
diff --git a/src/browser/components/preferences/dialogs/syncChooseWhatToSync-js.patch b/src/browser/components/preferences/dialogs/syncChooseWhatToSync-js.patch
index e066aca21..da89f90fb 100644
--- a/src/browser/components/preferences/dialogs/syncChooseWhatToSync-js.patch
+++ b/src/browser/components/preferences/dialogs/syncChooseWhatToSync-js.patch
@@ -6,7 +6,7 @@ index 64aa0d98a0622c01f3dcfff1a04bfcda368354d2..2013e04b0881ad2295d6897b91e1573c
{ id: "services.sync.engine.passwords", type: "bool" },
{ id: "services.sync.engine.addresses", type: "bool" },
{ id: "services.sync.engine.creditcards", type: "bool" },
-+ { id: "services.sync.engine.workspaces", type: "bool" },
++ { id: "services.sync.engine.spaces", type: "bool" },
]);
let gSyncChooseWhatToSync = {
diff --git a/src/browser/components/preferences/dialogs/syncChooseWhatToSync-xhtml.patch b/src/browser/components/preferences/dialogs/syncChooseWhatToSync-xhtml.patch
index 3b0ab370d..0ebce7576 100644
--- a/src/browser/components/preferences/dialogs/syncChooseWhatToSync-xhtml.patch
+++ b/src/browser/components/preferences/dialogs/syncChooseWhatToSync-xhtml.patch
@@ -20,7 +20,7 @@ index a893c5ec3d007820d98f5d92dd039640faa2c181..9cbd00102e44ccf98b37845474d92d57
+
+
+
diff --git a/src/browser/components/preferences/sync-inc-xhtml.patch b/src/browser/components/preferences/sync-inc-xhtml.patch
index 06944335a..4b6e6306d 100644
--- a/src/browser/components/preferences/sync-inc-xhtml.patch
+++ b/src/browser/components/preferences/sync-inc-xhtml.patch
@@ -6,7 +6,7 @@ index c379e1a5f82692406a92d9fcd3bca2769dfac5b2..af037dd3d995813d966524ac44a3795d
-+
++
+
+
+
diff --git a/src/services/sync/modules/service-sys-mjs.patch b/src/services/sync/modules/service-sys-mjs.patch
new file mode 100644
index 000000000..67522f59b
--- /dev/null
+++ b/src/services/sync/modules/service-sys-mjs.patch
@@ -0,0 +1,15 @@
+diff --git a/services/sync/modules/service.sys.mjs b/services/sync/modules/service.sys.mjs
+index c873293871ffaba305bc1bf41730d79c13546b85..0e0171cec13dfcbb296ec7bf03628370ce2fa93f 100644
+--- a/services/sync/modules/service.sys.mjs
++++ b/services/sync/modules/service.sys.mjs
+@@ -99,6 +99,10 @@ function getEngineModules() {
+ whenTrue: "ExtensionStorageEngineKinto",
+ whenFalse: "ExtensionStorageEngineBridge",
+ };
++ result.Workspaces = {
++ module: "resource:///modules/zen/ZenWorkspacesSync.sys.mjs",
++ symbol: "ZenWorkspacesEngine",
++ };
+ return result;
+ }
+
diff --git a/src/toolkit/components/contextualidentity/ContextualIdentityService-sys-mjs.patch b/src/toolkit/components/contextualidentity/ContextualIdentityService-sys-mjs.patch
new file mode 100644
index 000000000..1b108254d
--- /dev/null
+++ b/src/toolkit/components/contextualidentity/ContextualIdentityService-sys-mjs.patch
@@ -0,0 +1,37 @@
+diff --git a/toolkit/components/contextualidentity/ContextualIdentityService.sys.mjs b/toolkit/components/contextualidentity/ContextualIdentityService.sys.mjs
+index 5702ff28cc22206f5ce16584dac8a78d816562ce..3d08ecc97ce5995b30d8a4af0c33df329b428008 100644
+--- a/toolkit/components/contextualidentity/ContextualIdentityService.sys.mjs
++++ b/toolkit/components/contextualidentity/ContextualIdentityService.sys.mjs
+@@ -270,11 +270,29 @@ _ContextualIdentityService.prototype = {
+ });
+ },
+
+- create(name, icon, color) {
++ create(name, icon, color, id = null) {
+ this.ensureDataReady();
+
+- // Retrieve the next userContextId available.
+- let userContextId = ++this._lastUserContextId;
++ if (id !== null) {
++ id = typeof id == "string" ? Number(id) : id;
++ if (!Number.isSafeInteger(id) || id <= 0) {
++ throw new Error(`Invalid userContextId '${id}'`);
++ }
++ }
++
++ let userContextId;
++ if (
++ id !== null &&
++ !this._identities.some(identity => identity.userContextId === id)
++ ) {
++ userContextId = id;
++ this._lastUserContextId = Math.max(
++ this._lastUserContextId,
++ userContextId
++ );
++ } else {
++ userContextId = ++this._lastUserContextId;
++ }
+
+ // Throw an error if the next userContextId available is invalid (the one associated to
+ // MAX_USER_CONTEXT_ID is already reserved to "userContextIdInternal.webextStorageLocal", which
diff --git a/src/zen/moz.build b/src/zen/moz.build
index 8b1426065..4e3e80ecb 100644
--- a/src/zen/moz.build
+++ b/src/zen/moz.build
@@ -20,4 +20,5 @@ DIRS += [
"share",
"spaces",
"space-routing",
+ "sync",
]
diff --git a/src/zen/sessionstore/ZenSessionManager.sys.mjs b/src/zen/sessionstore/ZenSessionManager.sys.mjs
index 39f2d0eb2..bccec81c6 100644
--- a/src/zen/sessionstore/ZenSessionManager.sys.mjs
+++ b/src/zen/sessionstore/ZenSessionManager.sys.mjs
@@ -10,6 +10,7 @@ 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",
@@ -609,6 +610,7 @@ export class nsZenSessionManager {
}
);
this.#collectWindowData(windows);
+ lazy.ZenSyncStore.notifyAboutChanges();
// This would save the data to disk asynchronously or when quitting the app.
let sidebar = this.#sidebarWithoutCloning;
this.#file.data = sidebar;
@@ -622,6 +624,15 @@ export class nsZenSessionManager {
this.log(`Saving Zen session data with ${sidebar.tabs?.length || 0} tabs`);
}
+ /**
+ * Returns the sidebar data object.
+ *
+ * @returns {object}
+ */
+ getSidebarData() {
+ return this.#sidebarWithoutCloning;
+ }
+
/**
* Called when the last known backup should be deleted and a new one
* created. This uses the #deferredBackupTask to debounce clusters of
diff --git a/src/zen/spaces/ZenSpaceManager.mjs b/src/zen/spaces/ZenSpaceManager.mjs
index 51cfb89ba..997ea5eb6 100644
--- a/src/zen/spaces/ZenSpaceManager.mjs
+++ b/src/zen/spaces/ZenSpaceManager.mjs
@@ -11,6 +11,7 @@ const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
ZenSessionStore: "resource:///modules/zen/ZenSessionManager.sys.mjs",
+ ZenSyncStore: "resource:///modules/zen/ZenSyncManager.sys.mjs",
});
ChromeUtils.defineLazyGetter(lazy, "browserBackgroundElement", () => {
@@ -148,7 +149,7 @@ class nsZenWorkspaces {
if (!this.privateWindowOrDisabled) {
const observerFunction = async () => {
delete this._workspaceBookmarksCache;
- await this.workspaceBookmarks();
+ await this.#initializeWorkspaceBookmarks();
this._invalidateBookmarkContainers();
};
Services.obs.addObserver(observerFunction, "workspace-bookmarks-updated");
@@ -168,6 +169,63 @@ 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(() => {
@@ -691,17 +749,17 @@ class nsZenWorkspaces {
return spacesForSS;
}
- async workspaceBookmarks() {
+ async #initializeWorkspaceBookmarks() {
if (this.privateWindowOrDisabled) {
this._workspaceBookmarksCache = {
bookmarks: [],
lastChangeTimestamp: 0,
};
- return this._workspaceBookmarksCache;
+ return;
}
if (this._workspaceBookmarksCache) {
- return this._workspaceBookmarksCache;
+ return;
}
const [bookmarks, lastChangeTimestamp] = await Promise.all([
@@ -710,8 +768,6 @@ class nsZenWorkspaces {
]);
this._workspaceBookmarksCache = { bookmarks, lastChangeTimestamp };
-
- return this._workspaceCache;
}
restoreWorkspacesFromSessionStore(aWinData = {}) {
@@ -771,7 +827,7 @@ class nsZenWorkspaces {
return (async () => {
await this.#waitForPromises();
this.#afterLoadInit();
- await this.workspaceBookmarks();
+ await this.#initializeWorkspaceBookmarks();
await this.changeWorkspace(activeWorkspace, { onInit: true });
this.#fixTabPositions();
this.onWindowResize();
@@ -797,6 +853,13 @@ class nsZenWorkspaces {
})();
}
+ #markWorkspaceChanged(workspaceId) {
+ lazy.ZenSyncStore.markItemChanged({
+ type: "space",
+ id: workspaceId,
+ });
+ }
+
async selectStartPage() {
if (!this.workspaceEnabled || gZenUIManager.testingEnabled) {
return;
@@ -1214,12 +1277,19 @@ class nsZenWorkspaces {
} else {
workspacesData.push(workspaceData);
}
+ // mark item as changed for sync
+ this.#markWorkspaceChanged(workspaceData.uuid);
+
this.#propagateWorkspaceData();
}
removeWorkspace(windowID) {
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(
@@ -1351,30 +1421,50 @@ class nsZenWorkspaces {
if (this.privateWindowOrDisabled) {
return;
}
- const workspaces = this._workspaceCache;
+ 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) {
console.warn(`Workspace with ID ${id} not found for reordering.`);
return;
}
+
// Remove the workspace from its current position
const currentIndex = workspaces.indexOf(workspace);
if (currentIndex === -1) {
console.warn(`Workspace with ID ${id} not found in the list.`);
return;
}
- workspaces.splice(currentIndex, 1);
+
// Insert the workspace at the new position
- if (newPosition < 0 || newPosition > workspaces.length) {
+ if (newPosition < 0 || newPosition >= workspaces.length) {
console.warn(
`Invalid position ${newPosition} for reordering workspace with ID ${id}.`
);
return;
}
+
+ workspaces.splice(currentIndex, 1);
workspaces.splice(newPosition, 0, workspace);
+
// Propagate the changes if the order has changed
if (currentIndex !== newPosition) {
- this.#propagateWorkspaceData();
+ 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);
}
}
@@ -2493,7 +2583,7 @@ class nsZenWorkspaces {
for (const tab of gBrowser.tabs) {
if (
!tab.hasAttribute("zen-workspace-id") &&
- !tab.hasAttribute("zen-workspace-id")
+ !tab.hasAttribute("zen-essential")
) {
tab.setAttribute("zen-workspace-id", workspace.uuid);
}
diff --git a/src/zen/sync/ZenSyncManager.sys.mjs b/src/zen/sync/ZenSyncManager.sys.mjs
new file mode 100644
index 000000000..1b454da81
--- /dev/null
+++ b/src/zen/sync/ZenSyncManager.sys.mjs
@@ -0,0 +1,160 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ ZenSessionStore: "resource:///modules/zen/ZenSessionManager.sys.mjs",
+ ContextualIdentityService:
+ "resource://gre/modules/ContextualIdentityService.sys.mjs",
+ ZenWindowSync: "resource:///modules/zen/ZenWindowSync.sys.mjs",
+});
+
+function normalizeUserContextId(value) {
+ const normalized = typeof value === "string" ? Number(value) : value;
+ if (!Number.isSafeInteger(normalized) || normalized <= 0) {
+ return null;
+ }
+ return normalized;
+}
+
+class ZenSyncManager {
+ getSidebarData() {
+ return lazy.ZenSessionStore.getSidebarData();
+ }
+
+ /**
+ * Whether to ignore changes to items. This is used to prevent
+ * infinite loops when applying incoming sync changes.
+ *
+ * @type {boolean}
+ */
+ #ignoreChanges = false;
+
+ #changedItems = new Map();
+
+ markItemChanged(item) {
+ if (item.type && item.id && !this.#ignoreChanges) {
+ const key = `${item.type}~${item.id}`;
+ this.#changedItems.set(key, { type: item.type, id: item.id });
+ }
+ }
+
+ #getChangedItems() {
+ return Array.from(this.#changedItems.values());
+ }
+
+ #clearChangedItems() {
+ this.#changedItems.clear();
+ }
+
+ notifyAboutChanges() {
+ const changedItems = this.#getChangedItems();
+
+ for (const item of changedItems) {
+ Services.obs.notifyObservers(
+ { wrappedJSObject: item },
+ "zen-workspace-item-changed"
+ );
+ }
+ this.#clearChangedItems();
+ }
+
+ async applyIncomingBatch(pulled, removals) {
+ try {
+ this.#ignoreChanges = true;
+ this.#applyIncomingContainers(
+ pulled.containers || [],
+ removals.containers || []
+ );
+
+ const win = lazy.ZenWindowSync.firstSyncedWindow;
+ if (win?.gZenWorkspaces) {
+ await win.gZenWorkspaces._applySyncChanges(pulled, removals);
+ }
+ } catch (e) {
+ console.error("ZenSyncManager: Failed to apply incoming sync data:", e);
+ throw e;
+ } finally {
+ this.#ignoreChanges = false;
+ }
+ }
+
+ #applyIncomingContainers(pulledContainers, removedContainers) {
+ const localContainersById = new Map(
+ lazy.ContextualIdentityService.getPublicIdentities().map(container => [
+ container.userContextId,
+ container,
+ ])
+ );
+
+ for (const container of pulledContainers) {
+ if (!container.name) {
+ continue;
+ }
+
+ const userContextId = normalizeUserContextId(container.userContextId);
+ if (userContextId === null) {
+ console.warn(
+ "ZenSyncManager: Ignoring incoming container with invalid userContextId",
+ { container }
+ );
+ continue;
+ }
+
+ const existsLocally = localContainersById.has(userContextId);
+
+ if (existsLocally) {
+ lazy.ContextualIdentityService.update(
+ userContextId,
+ container.name,
+ container.icon,
+ container.color
+ );
+ continue;
+ }
+
+ const createdIdentity = lazy.ContextualIdentityService.create(
+ container.name,
+ container.icon,
+ container.color,
+ userContextId
+ );
+ if (createdIdentity) {
+ localContainersById.set(createdIdentity.userContextId, createdIdentity);
+ }
+ if (createdIdentity && createdIdentity.userContextId !== userContextId) {
+ console.warn("ZenSyncManager: Container sync created unexpected ID", {
+ requestedId: userContextId,
+ createdId: createdIdentity.userContextId,
+ name: container.name,
+ });
+ }
+ }
+
+ for (const container of removedContainers) {
+ const userContextId = normalizeUserContextId(container.userContextId);
+ if (userContextId === null) {
+ console.warn(
+ "ZenSyncManager: Ignoring container removal with invalid userContextId",
+ { container }
+ );
+ continue;
+ }
+
+ if (!localContainersById.has(userContextId)) {
+ continue;
+ }
+
+ try {
+ lazy.ContextualIdentityService.remove(userContextId);
+ localContainersById.delete(userContextId);
+ } catch {
+ // Container may already be gone locally.
+ }
+ }
+ }
+}
+
+export const ZenSyncStore = new ZenSyncManager();
diff --git a/src/zen/sync/ZenWorkspacesSync.sys.mjs b/src/zen/sync/ZenWorkspacesSync.sys.mjs
new file mode 100644
index 000000000..175319169
--- /dev/null
+++ b/src/zen/sync/ZenWorkspacesSync.sys.mjs
@@ -0,0 +1,404 @@
+/* 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.ZenWorkspaces";
+}
+
+ZenWorkspacesRecord.prototype.type = "workspaces";
+
+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 Workspaces 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 "Workspaces";
+ }
+
+ constructor(service) {
+ super("Workspaces", service);
+ }
+
+ get _storeObj() {
+ return ZenWorkspacesStore;
+ }
+
+ get _trackerObj() {
+ return ZenWorkspacesTracker;
+ }
+
+ get _recordObj() {
+ return ZenWorkspacesRecord;
+ }
+
+ get version() {
+ return 2;
+ }
+
+ get syncPriority() {
+ return 8;
+ }
+
+ get allowSkippedRecord() {
+ return false;
+ }
+}
diff --git a/src/zen/sync/moz.build b/src/zen/sync/moz.build
new file mode 100644
index 000000000..d10ad7a19
--- /dev/null
+++ b/src/zen/sync/moz.build
@@ -0,0 +1,8 @@
+# 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/.
+
+EXTRA_JS_MODULES.zen += [
+ "ZenSyncManager.sys.mjs",
+ "ZenWorkspacesSync.sys.mjs",
+]
diff --git a/tools/ffprefs/src/main.rs b/tools/ffprefs/src/main.rs
index ac11c8e5b..20724730d 100644
--- a/tools/ffprefs/src/main.rs
+++ b/tools/ffprefs/src/main.rs
@@ -346,6 +346,17 @@ fn expand_pref_values(prefs: &mut [Preference]) {
}
pref.value = new_value.clone();
}
+ // Also change the condition if it contains placeholders
+ if let Some(condition) = &pref.condition {
+ let mut new_condition = condition.clone();
+ for (key, value) in &env_values {
+ let placeholder = format!("@{}@", key);
+ if new_condition.contains(&placeholder) {
+ new_condition = new_condition.replace(&placeholder, if *value { "1" } else { "0" });
+ }
+ pref.condition = Some(new_condition.clone());
+ }
+ }
}
}