mirror of
https://github.com/zen-browser/desktop.git
synced 2026-06-14 15:33:42 +00:00
Add space and container sync (gh-13598)
This commit is contained in:
committed by
GitHub
parent
818c448dfc
commit
eb527aec48
20
src/browser/components/preferences/sync-js.patch
Normal file
20
src/browser/components/preferences/sync-js.patch
Normal file
@@ -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 }) => {
|
||||
15
src/services/sync/modules/service-sys-mjs.patch
Normal file
15
src/services/sync/modules/service-sys-mjs.patch
Normal file
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -19,4 +19,5 @@ DIRS += [
|
||||
"sessionstore",
|
||||
"share",
|
||||
"spaces",
|
||||
"sync",
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
160
src/zen/sync/ZenSyncManager.sys.mjs
Normal file
160
src/zen/sync/ZenSyncManager.sys.mjs
Normal file
@@ -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();
|
||||
404
src/zen/sync/ZenWorkspacesSync.sys.mjs
Normal file
404
src/zen/sync/ZenWorkspacesSync.sys.mjs
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
8
src/zen/sync/moz.build
Normal file
8
src/zen/sync/moz.build
Normal file
@@ -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",
|
||||
]
|
||||
Reference in New Issue
Block a user