diff --git a/src/browser/components/preferences/sync-js.patch b/src/browser/components/preferences/sync-js.patch new file mode 100644 index 000000000..5fcabf4c4 --- /dev/null +++ b/src/browser/components/preferences/sync-js.patch @@ -0,0 +1,20 @@ +diff --git a/browser/components/preferences/sync.js b/browser/components/preferences/sync.js +index dc89a9c41a0dbd44054ede0025d333773f0ae908..7fd91bd704b3b187277e4c8b076f990cb56ea8dc 100644 +--- a/browser/components/preferences/sync.js ++++ b/browser/components/preferences/sync.js +@@ -40,6 +40,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.workspaces", type: "bool" }, + ]); + + /** +@@ -512,6 +513,7 @@ const SYNC_ENGINE_SETTINGS = [ + }, + { id: "syncAddons", pref: "services.sync.engine.addons", type: "addons" }, + { id: "syncSettings", pref: "services.sync.engine.prefs", type: "settings" }, ++ { id: "syncWorkspaces", pref: "services.sync.engine.workspaces", type: "workspaces" }, + ]; + + SYNC_ENGINE_SETTINGS.forEach(({ id, pref }) => { 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 e1efea205..80e6e9f36 100644 --- a/src/zen/moz.build +++ b/src/zen/moz.build @@ -19,4 +19,5 @@ DIRS += [ "sessionstore", "share", "spaces", + "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 a97dd0978..54e1e2d1d 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", () => { @@ -150,7 +151,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"); @@ -170,6 +171,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(() => { @@ -703,17 +761,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([ @@ -722,8 +780,6 @@ class nsZenWorkspaces { ]); this._workspaceBookmarksCache = { bookmarks, lastChangeTimestamp }; - - return this._workspaceCache; } restoreWorkspacesFromSessionStore(aWinData = {}) { @@ -783,7 +839,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(); @@ -809,6 +865,13 @@ class nsZenWorkspaces { })(); } + #markWorkspaceChanged(workspaceId) { + lazy.ZenSyncStore.markItemChanged({ + type: "space", + id: workspaceId, + }); + } + async selectStartPage() { if (!this.workspaceEnabled || gZenUIManager.testingEnabled) { return; @@ -1230,12 +1293,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( @@ -1367,30 +1437,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); } } @@ -2441,7 +2531,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", +]