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..9cf7c870ea --- /dev/null +++ b/src/zen/sync/ZenWorkspacesSync.sys.mjs @@ -0,0 +1,301 @@ +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 (tombstone)", record); + return record; + }, + + async itemExists(id) { + 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); + + 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); + + const type = this._resolveType(record); + const id = record.id; + + switch (type) { + case "space": { + const spaces = ZenSessionStore.spaces.filter((s) => s.uuid !== id); + 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 !== id); + 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 !== id); + this.engine._tracker.ignoreAll = true; + try { + await ZenSessionStore.updateSyncedFolders(folders, true); + } finally { + this.engine._tracker.ignoreAll = false; + } + break; + } + default: + this._log.warn("remove: could not resolve type for id", id); + } + }, + + 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); }