From 6cb5b5f9d2b67637f1fd33b8e944f5ec9c2d002e Mon Sep 17 00:00:00 2001 From: bdbch Date: Wed, 25 Feb 2026 06:24:22 +0100 Subject: [PATCH 1/2] feat(sync): implement workspace synchronization functionality - added SyncComponents.manifest for sync initialization - created ZenWorkspacesSync.sys.mjs for managing workspace sync - updated ZenSessionManager to handle synced data updates - modified ZenWorkspaces to notify observers on data changes --- src/zen/ZenComponents.manifest | 1 + src/zen/moz.build | 1 + .../sessionstore/ZenSessionManager.sys.mjs | 81 +++++ src/zen/sync/SyncComponents.manifest | 6 + src/zen/sync/ZenWorkspacesSync.sys.mjs | 287 ++++++++++++++++++ src/zen/sync/moz.build | 7 + src/zen/workspaces/ZenWorkspaces.mjs | 2 + 7 files changed, 385 insertions(+) create mode 100644 src/zen/sync/SyncComponents.manifest create mode 100644 src/zen/sync/ZenWorkspacesSync.sys.mjs create mode 100644 src/zen/sync/moz.build diff --git a/src/zen/ZenComponents.manifest b/src/zen/ZenComponents.manifest index 5cfcfd855b..2032aa2122 100644 --- a/src/zen/ZenComponents.manifest +++ b/src/zen/ZenComponents.manifest @@ -15,4 +15,5 @@ category app-startup nsBrowserGlue @mozilla.org/browser/browserglue;1 applicatio #include common/Components.manifest #include sessionstore/SessionComponents.manifest +#include sync/SyncComponents.manifest #include live-folders/LiveFoldersComponents.manifest diff --git a/src/zen/moz.build b/src/zen/moz.build index 6e54f9573e..a3afcbb207 100644 --- a/src/zen/moz.build +++ b/src/zen/moz.build @@ -17,4 +17,5 @@ DIRS += [ "toolkit", "sessionstore", "share", + "sync", ] diff --git a/src/zen/sessionstore/ZenSessionManager.sys.mjs b/src/zen/sessionstore/ZenSessionManager.sys.mjs index 321b6312ae..232d5ffc75 100644 --- a/src/zen/sessionstore/ZenSessionManager.sys.mjs +++ b/src/zen/sessionstore/ZenSessionManager.sys.mjs @@ -111,6 +111,22 @@ export class nsZenSessionManager { return PathUtils.join(PathUtils.profileDir, "zen-sessions-backup"); } + get sidebarData() { + return this.#sidebar; + } + + get tabs() { + return this.sidebarData ? this.sidebarData.tabs || [] : []; + } + + get folders() { + return this.sidebarData ? this.sidebarData.folders || [] : []; + } + + get spaces() { + return this.sidebarData ? this.sidebarData.spaces || [] : []; + } + async #getBackupRecoveryOrder() { // Also add the most recent backup file to the recovery order let backupFiles = [PathUtils.join(this.#backupFolderPath, "clean.jsonlz4")]; @@ -524,9 +540,74 @@ export class nsZenSessionManager { } lazy.ZenLiveFoldersManager.saveState(soon); this.#debounceRegeneration(); + Services.obs.notifyObservers(null, "ZenWorkspaceDataChanged"); this.log(`Saving Zen session data with ${sidebar.tabs?.length || 0} tabs`); } + /** + * Helper function to save the session file. Returns false if there is no file to save. + * Returns the result of the save operation otherwise. + */ + saveSessionFile(soon = false) { + if (!this.#file) { + return false; + } + + if (soon) { + return this.#file.saveSoon(); + } + + return this.#file._save(); + } + + /** + * Called when there is new synced data from other windows. + */ + updateSyncedData({ tabs, folders, spaces }, soon = false) { + let sidebar = this.#sidebar; + if (tabs) { + sidebar.tabs = this.#filterUnusedTabs(tabs); + } + if (folders) { + sidebar.folders = folders; + } + if (spaces) { + sidebar.spaces = spaces; + } + this.#sidebar = sidebar; + return this.saveSessionFile(soon); + } + + /** + * Called when tabs need to be synced to other windows. + */ + updateSyncedTabs(tabs, soon = false) { + let sidebar = this.#sidebar; + sidebar.tabs = this.#filterUnusedTabs(tabs); + this.#sidebar = sidebar; + return this.saveSessionFile(soon); + } + + /** + * Called when folders need to be synced to other windows. + */ + updateSyncedFolders(folders, soon = false) { + let sidebar = this.#sidebar; + sidebar.folders = folders; + this.#sidebar = sidebar; + return this.saveSessionFile(soon); + } + + /** + * Called when spaces need to be synced to other windows. + */ + updateSyncedSpaces(spaces, soon = false) { + let sidebar = this.#sidebar; + sidebar.spaces = spaces; + this.#sidebar = sidebar; + return this.saveSessionFile(soon); + } + /** * 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/sync/SyncComponents.manifest b/src/zen/sync/SyncComponents.manifest new file mode 100644 index 0000000000..500fe155a2 --- /dev/null +++ b/src/zen/sync/SyncComponents.manifest @@ -0,0 +1,6 @@ +# 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/. + +# Browser global components initializing before UI startup +category browser-before-ui-startup resource:///modules/zen/ZenWorkspacesSync.sys.mjs ZenWorkspacesSync.init diff --git a/src/zen/sync/ZenWorkspacesSync.sys.mjs b/src/zen/sync/ZenWorkspacesSync.sys.mjs new file mode 100644 index 0000000000..f3484bc492 --- /dev/null +++ b/src/zen/sync/ZenWorkspacesSync.sys.mjs @@ -0,0 +1,287 @@ +import { CryptoWrapper } from "resource://services-sync/record.sys.mjs"; +import { Store, Tracker, SyncEngine } from "resource://services-sync/engines.sys.mjs"; +import { ZenSessionStore } from "resource:///modules/zen/ZenSessionManager.sys.mjs"; + +export function ZenWorkspacesRecord(collection, id) { + CryptoWrapper.call(this, collection, id); +} + +ZenWorkspacesRecord.prototype = { + _logName: "Sync.Record.ZenWorkspaces", +}; +Object.setPrototypeOf(ZenWorkspacesRecord.prototype, CryptoWrapper.prototype); + +export function ZenWorkspacesStore(name, engine) { + Store.call(this, name, engine); +} + +ZenWorkspacesStore.prototype = { + _logName: "Sync.Store.ZenWorkspaces", + + async getAllIDs() { + const ids = {}; + + for (const space of ZenSessionStore.spaces) { + ids[space.uuid] = true; + } + + for (const folder of ZenSessionStore.folders) { + ids[folder.id] = true; + } + + for (const tab of ZenSessionStore.tabs) { + if (tab.zenSyncId) { + ids[tab.zenSyncId] = true; + } + } + + return ids; + }, + + async createRecord(id, collection) { + const record = new ZenWorkspacesRecord(collection, id); + + const space = ZenSessionStore.spaces.find((s) => s.uuid === id); + if (space) { + record.cleartext = { + ...space, + type: "space", + }; + this._log.debug("createRecord", record); + return record; + } + + const tab = ZenSessionStore.tabs.find((t) => t.zenSyncId === id); + if (tab) { + record.cleartext = { + ...tab, + type: "tab", + }; + this._log.debug("createRecord", record); + return record; + } + + const folder = ZenSessionStore.folders.find((f) => f.id === id); + if (folder) { + record.cleartext = { + ...folder, + type: "folder", + }; + this._log.debug("createRecord", record); + return record; + } + + record.deleted = true; + this._log.debug("createRecord", record); + return record; + }, + + async itemExists(id) { + return id in (await this.getAllIDs()); + }, + + async _upsert(record) { + const type = record.cleartext.type; + this._log.debug("_upsert", type, record); + + switch (type) { + case "space": { + const spaces = ZenSessionStore.spaces; + const existingIndex = spaces.findIndex((s) => s.uuid === record.cleartext.uuid); + if (existingIndex !== -1) { + spaces[existingIndex] = { ...spaces[existingIndex], ...record.cleartext }; + } else { + spaces.push(record.cleartext); + } + + this.engine._tracker.ignoreAll = true; + try { + await ZenSessionStore.updateSyncedSpaces(spaces, true); + } finally { + this.engine._tracker.ignoreAll = false; + } + return; + } + + case "tab": { + const tabs = ZenSessionStore.tabs; + const existingIndex = tabs.findIndex((t) => t.zenSyncId === record.cleartext.zenSyncId); + if (existingIndex !== -1) { + tabs[existingIndex] = { ...tabs[existingIndex], ...record.cleartext }; + } else { + tabs.push(record.cleartext); + } + + this.engine._tracker.ignoreAll = true; + try { + await ZenSessionStore.updateSyncedTabs(tabs, true); + } finally { + this.engine._tracker.ignoreAll = false; + } + return; + } + + case "folder": { + const folders = ZenSessionStore.folders; + const existingIndex = folders.findIndex((f) => f.id === record.cleartext.id); + if (existingIndex !== -1) { + folders[existingIndex] = { ...folders[existingIndex], ...record.cleartext }; + } else { + folders.push(record.cleartext); + } + + this.engine._tracker.ignoreAll = true; + try { + await ZenSessionStore.updateSyncedFolders(folders, true); + } finally { + this.engine._tracker.ignoreAll = false; + } + } + } + }, + + async create(record) { + return this._upsert(record); + }, + + async update(record) { + return this._upsert(record); + }, + + async remove(record) { + this._log.debug("remove", record); + + switch (record.cleartext.type) { + case "space": { + const spaces = ZenSessionStore.spaces.filter((s) => s.uuid !== record.cleartext.uuid); + this.engine._tracker.ignoreAll = true; + try { + await ZenSessionStore.updateSyncedSpaces(spaces, true); + } finally { + this.engine._tracker.ignoreAll = false; + } + break; + } + case "tab": { + const tabs = ZenSessionStore.tabs.filter((t) => t.zenSyncId !== record.cleartext.zenSyncId); + this.engine._tracker.ignoreAll = true; + try { + await ZenSessionStore.updateSyncedTabs(tabs, true); + } finally { + this.engine._tracker.ignoreAll = false; + } + break; + } + case "folder": { + const folders = ZenSessionStore.folders.filter((f) => f.id !== record.cleartext.id); + this.engine._tracker.ignoreAll = true; + try { + await ZenSessionStore.updateSyncedFolders(folders, true); + } finally { + this.engine._tracker.ignoreAll = false; + } + break; + } + } + }, + + async wipe() { + this.engine._tracker.ignoreAll = true; + try { + await ZenSessionStore.updateSyncedData({ spaces: [], tabs: [], folders: [] }, true); + } finally { + this.engine._tracker.ignoreAll = false; + } + }, + + async changeItemID(oldID, newID) { + const tabs = ZenSessionStore.tabs.map((tab) => { + if (tab.zenSyncId === oldID) { + return { ...tab, zenSyncId: newID }; + } + return tab; + }); + + const spaces = ZenSessionStore.spaces.map((space) => { + if (space.uuid === oldID) { + return { ...space, uuid: newID }; + } + return space; + }); + + const folders = ZenSessionStore.folders.map((folder) => { + if (folder.id === oldID) { + return { ...folder, id: newID }; + } + return folder; + }); + + this.engine._tracker.ignoreAll = true; + try { + await ZenSessionStore.updateSyncedData( + { + spaces, + tabs, + folders, + }, + true + ); + } finally { + this.engine._tracker.ignoreAll = false; + } + }, +}; +Object.setPrototypeOf(ZenWorkspacesStore.prototype, Store.prototype); + +export function ZenWorkspacesTracker(name, engine) { + Tracker.call(this, name, engine); +} +ZenWorkspacesTracker.prototype = { + _logName: "Sync.Tracker.ZenWorkspaces", + + onStart() { + Services.obs.addObserver(this, "ZenWorkspaceDataChanged"); + }, + + onStop() { + Services.obs.removeObserver(this, "ZenWorkspaceDataChanged"); + }, + + async observe(subject, topic, data) { + this._log.debug("observe", topic, data); + + if (this.ignoreAll) { + return; + } + + switch (topic) { + case "ZenWorkspaceDataChanged": + this.score += 15; + break; + } + }, +}; +Object.setPrototypeOf(ZenWorkspacesTracker.prototype, Tracker.prototype); + +export function ZenWorkspacesEngine(service) { + SyncEngine.call(this, "Zen-Workspaces", service); +} +ZenWorkspacesEngine.prototype = { + _logName: "Sync.Engine.ZenWorkspaces", + _storeObj: ZenWorkspacesStore, + _trackerObj: ZenWorkspacesTracker, + _recordObj: ZenWorkspacesRecord, + version: 4, + + get prefName() { + return "workspaces"; + }, +}; +Object.setPrototypeOf(ZenWorkspacesEngine.prototype, SyncEngine.prototype); + +export const ZenWorkspacesSync = { + init() { + const { Weave } = ChromeUtils.importESModule("resource://services-sync/main.sys.mjs"); + Weave.Service.engineManager.register(ZenWorkspacesEngine); + }, +}; diff --git a/src/zen/sync/moz.build b/src/zen/sync/moz.build new file mode 100644 index 0000000000..3c95faeb5e --- /dev/null +++ b/src/zen/sync/moz.build @@ -0,0 +1,7 @@ +# 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 += [ + "ZenWorkspacesSync.sys.mjs", +] diff --git a/src/zen/workspaces/ZenWorkspaces.mjs b/src/zen/workspaces/ZenWorkspaces.mjs index 135a2bd986..a171dfa331 100644 --- a/src/zen/workspaces/ZenWorkspaces.mjs +++ b/src/zen/workspaces/ZenWorkspaces.mjs @@ -1353,6 +1353,8 @@ class nsZenWorkspaces { if (!this.#hasInitialized || this.privateWindowOrDisabled) { return; } + + Services.obs.notifyObservers(null, "ZenWorkspaceDataChanged"); window.dispatchEvent(new CustomEvent("ZenWorkspaceDataChanged"), { bubbles: true }); window.gZenWindowSync.propagateWorkspacesToAllWindows(aSpaceData ?? this._workspaceCache); } From 60b84734705d52e4b8ab0d2cf2ff2b288ece9da1 Mon Sep 17 00:00:00 2001 From: bdbch Date: Wed, 25 Feb 2026 06:33:06 +0100 Subject: [PATCH 2/2] fix(sync): improve record type resolution and cleanup logic - Simplified record creation by consolidating cleartext assignment. - Added _resolveType method for better type determination. - Enhanced remove method to use resolved type and added warning for unresolved types. --- src/zen/sync/ZenWorkspacesSync.sys.mjs | 48 +++++++++++++++++--------- 1 file changed, 31 insertions(+), 17 deletions(-) diff --git a/src/zen/sync/ZenWorkspacesSync.sys.mjs b/src/zen/sync/ZenWorkspacesSync.sys.mjs index f3484bc492..9cf7c870ea 100644 --- a/src/zen/sync/ZenWorkspacesSync.sys.mjs +++ b/src/zen/sync/ZenWorkspacesSync.sys.mjs @@ -43,36 +43,27 @@ ZenWorkspacesStore.prototype = { const space = ZenSessionStore.spaces.find((s) => s.uuid === id); if (space) { - record.cleartext = { - ...space, - type: "space", - }; + record.cleartext = { ...space, type: "space" }; this._log.debug("createRecord", record); return record; } const tab = ZenSessionStore.tabs.find((t) => t.zenSyncId === id); if (tab) { - record.cleartext = { - ...tab, - type: "tab", - }; + record.cleartext = { ...tab, type: "tab" }; this._log.debug("createRecord", record); return record; } const folder = ZenSessionStore.folders.find((f) => f.id === id); if (folder) { - record.cleartext = { - ...folder, - type: "folder", - }; + record.cleartext = { ...folder, type: "folder" }; this._log.debug("createRecord", record); return record; } record.deleted = true; - this._log.debug("createRecord", record); + this._log.debug("createRecord (tombstone)", record); return record; }, @@ -80,6 +71,24 @@ ZenWorkspacesStore.prototype = { return id in (await this.getAllIDs()); }, + _resolveType(record) { + const type = record.cleartext?.type; + if (type) { + return type; + } + const id = record.id; + if (ZenSessionStore.spaces.some((s) => s.uuid === id)) { + return "space"; + } + if (ZenSessionStore.tabs.some((t) => t.zenSyncId === id)) { + return "tab"; + } + if (ZenSessionStore.folders.some((f) => f.id === id)) { + return "folder"; + } + return null; + }, + async _upsert(record) { const type = record.cleartext.type; this._log.debug("_upsert", type, record); @@ -151,9 +160,12 @@ ZenWorkspacesStore.prototype = { async remove(record) { this._log.debug("remove", record); - switch (record.cleartext.type) { + const type = this._resolveType(record); + const id = record.id; + + switch (type) { case "space": { - const spaces = ZenSessionStore.spaces.filter((s) => s.uuid !== record.cleartext.uuid); + const spaces = ZenSessionStore.spaces.filter((s) => s.uuid !== id); this.engine._tracker.ignoreAll = true; try { await ZenSessionStore.updateSyncedSpaces(spaces, true); @@ -163,7 +175,7 @@ ZenWorkspacesStore.prototype = { break; } case "tab": { - const tabs = ZenSessionStore.tabs.filter((t) => t.zenSyncId !== record.cleartext.zenSyncId); + const tabs = ZenSessionStore.tabs.filter((t) => t.zenSyncId !== id); this.engine._tracker.ignoreAll = true; try { await ZenSessionStore.updateSyncedTabs(tabs, true); @@ -173,7 +185,7 @@ ZenWorkspacesStore.prototype = { break; } case "folder": { - const folders = ZenSessionStore.folders.filter((f) => f.id !== record.cleartext.id); + const folders = ZenSessionStore.folders.filter((f) => f.id !== id); this.engine._tracker.ignoreAll = true; try { await ZenSessionStore.updateSyncedFolders(folders, true); @@ -182,6 +194,8 @@ ZenWorkspacesStore.prototype = { } break; } + default: + this._log.warn("remove: could not resolve type for id", id); } },