Files
desktop/src/zen/sync/ZenSpacesSyncModel.sys.mjs

815 lines
23 KiB
JavaScript

/* 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 { JSONFile } from "resource://gre/modules/JSONFile.sys.mjs";
import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
ZenSessionStore: "resource:///modules/zen/ZenSessionManager.sys.mjs",
ZenLiveFoldersManager:
"resource:///modules/zen/ZenLiveFoldersManager.sys.mjs",
ContextualIdentityService:
"moz-src:///toolkit/components/contextualidentity/ContextualIdentityService.sys.mjs",
});
XPCOMUtils.defineLazyPreferenceGetter(
lazy,
"syncDebug",
"zen.spaces-sync.debug",
false
);
XPCOMUtils.defineLazyPreferenceGetter(
lazy,
"syncNormalTabs",
"zen.spaces-sync.normal-tabs",
false
);
/**
* Debug logging for the whole Spaces sync pipeline.
*
* @param {string} message
* @param {...any} args
*/
export function syncLog(message, ...args) {
if (lazy.syncDebug) {
// eslint-disable-next-line no-console
console.debug(`ZenSpacesSync: ${message}`, ...args);
}
}
export const SIDEBAR_COLLECTED_TOPIC = "zen-sidebar-data-collected";
export const RECORD_KINDS = Object.freeze({
CONTAINER: "container",
SPACE: "space",
TAB: "tab",
FOLDER: "folder",
SPLIT: "split",
LAYOUT: "layout",
});
export const LAYOUT_RECORD_ID = "layout";
// Firefox ships four built-in containers with fixed userContextIds. They
// exist on every device, so they map to well-known guids instead of the
// per-profile generated ones.
const BUILTIN_CONTAINER_MAX = 4;
const BUILTIN_GUID_PREFIX = "builtin-";
const STORE_FILE_NAME = "zen-spaces-sync.json";
const STORE_VERSION = 1;
// Everything setIcon accepts without a loading principal.
const LOCAL_ICON_PROTOCOLS = ["data:", "chrome:", "about:", "resource:"];
/**
* Normalizes a tab icon for syncing. The image attribute can hold a
* moz-remote-image: wrapper around the actual (svg data:) favicon, carrying
* a per-process contentParentId that means nothing on another device — sync
* the wrapped URL instead. Anything else setIcon would reject as remote is
* dropped.
*
* @param {?string} icon
* @returns {string}
*/
export function syncableIconUrl(icon) {
if (!icon || typeof icon !== "string") {
return "";
}
if (icon.startsWith("moz-remote-image:")) {
try {
const uri = Services.io.newURI(icon);
icon = new URLSearchParams(uri.query).get("url") || "";
} catch (e) {
return "";
}
}
return LOCAL_ICON_PROTOCOLS.some(protocol => icon.startsWith(protocol))
? icon
: "";
}
function sortedClone(value) {
if (Array.isArray(value)) {
return value.map(sortedClone);
}
if (value && typeof value === "object") {
const out = {};
for (const key of Object.keys(value).sort()) {
out[key] = sortedClone(value[key]);
}
return out;
}
return value === undefined ? null : value;
}
/**
* Deterministic JSON serialization (recursively sorted keys), so that two
* structurally equal payloads always stringify identically.
*
* @param {object} value
*/
export function canonicalJSON(value) {
return JSON.stringify(sortedClone(value));
}
/**
* Digest of a record's canonical form. The uploaded-state snapshot only ever
* needs equality checks, so it stores these short digests instead of the
* full canonical strings, keeping the store file small and cheap to write.
*
* @param {string} kind
* @param {object} data
* @returns {string} base64 SHA-256 of the canonical record payload.
*/
const textEncoder = new TextEncoder();
export function recordDigest(kind, data) {
const hasher = Cc["@mozilla.org/security/hash;1"].createInstance(
Ci.nsICryptoHash
);
hasher.init(Ci.nsICryptoHash.SHA256);
const bytes = textEncoder.encode(canonicalJSON({ kind, data }));
hasher.update(bytes, bytes.length);
return hasher.finish(/* base64 = */ true);
}
/**
* Owns the synced-data model for the Spaces engine.
*/
class nsZenSpacesSyncModel {
#file = null;
#cache = null;
#data() {
if (!this.#file) {
this.#file = new JSONFile({
path: PathUtils.join(PathUtils.profileDir, STORE_FILE_NAME),
dataPostProcessor: data => {
if (data.version !== STORE_VERSION) {
// Drop the uploaded snapshot so everything re-diffs against
// the (also wiped) server, but keep the container identity mappings.
data.uploaded = {};
}
data.version = STORE_VERSION;
data.uploaded ||= {};
data.containers ||= {};
// The container map used to be keyed by guid. It is keyed by
// userContextId now so that registering a guid replaces any
// previous one for the same container. Flip old stores over
// (old values are numeric ids, new values are guid strings).
for (const [key, value] of Object.entries(data.containers)) {
if (typeof value === "number") {
delete data.containers[key];
data.containers[value] = key;
}
}
return data;
},
});
this.#file.ensureDataReady();
}
return this.#file.data;
}
invalidate() {
this.#cache = null;
}
/* Mark: container identity */
guidForContextId(userContextId, { create = false } = {}) {
const id = Number(userContextId);
if (!Number.isSafeInteger(id) || id <= 0) {
return null;
}
const data = this.#data();
if (!lazy.ContextualIdentityService.getPublicIdentityFromId(id)) {
// A space or tab still pointing at a container that was deleted.
if (id in data.containers) {
delete data.containers[id];
this.#file.saveSoon();
}
return null;
}
if (id <= BUILTIN_CONTAINER_MAX) {
return `${BUILTIN_GUID_PREFIX}${id}`;
}
const existing = data.containers[id];
if (existing) {
return existing;
}
if (!create) {
return null;
}
const guid = Services.uuid.generateUUID().toString().slice(1, -1);
data.containers[id] = guid;
this.#file.saveSoon();
return guid;
}
contextIdForGuid(guid) {
if (typeof guid !== "string" || !guid) {
return null;
}
const data = this.#data();
for (const [id, mapped] of Object.entries(data.containers)) {
if (mapped === guid) {
const contextId = Number(id);
if (lazy.ContextualIdentityService.getPublicIdentityFromId(contextId)) {
return contextId;
}
delete data.containers[id];
this.#file.saveSoon();
return null;
}
}
if (guid.startsWith(BUILTIN_GUID_PREFIX)) {
const id = Number(guid.slice(BUILTIN_GUID_PREFIX.length));
return Number.isSafeInteger(id) &&
id > 0 &&
id <= BUILTIN_CONTAINER_MAX &&
lazy.ContextualIdentityService.getPublicIdentityFromId(id)
? id
: null;
}
return null;
}
/**
* Adopts an incoming guid as the identity's synced name.
*
* @param {string} guid
* @param {number} userContextId
*/
registerContainerGuid(guid, userContextId) {
this.#data().containers[userContextId] = guid;
this.#file.saveSoon();
}
forgetContainerGuid(guid) {
const data = this.#data();
for (const [id, mapped] of Object.entries(data.containers)) {
if (mapped === guid) {
delete data.containers[id];
this.#file.saveSoon();
return;
}
}
}
/* Mark: projections */
#isTabRecordMaterial(tabData) {
return !!(
tabData &&
tabData.zenSyncId &&
!tabData.zenIsEmpty &&
!tabData.zenIsGlance &&
!tabData.zenLiveFolderItemId
);
}
#isSyncableTab(tabData) {
return (
this.#isTabRecordMaterial(tabData) &&
(!!tabData.pinned || !!tabData.zenEssential || lazy.syncNormalTabs)
);
}
#tabIdentity(tabData) {
// Pin identity freezes url/title at pin time. A normal tab's identity
// follows its live entry (a leftover initial state from a past pin must
// not shadow it).
const initial =
tabData.pinned || tabData.zenEssential
? tabData._zenPinnedInitialState
: null;
let url = initial?.entry?.url;
let title = initial?.entry?.title;
if (!url || url === "about:blank") {
const entries = tabData.entries || [];
if (entries.length) {
let index = Math.min(
Math.max((tabData.index || entries.length) - 1, 0),
entries.length - 1
);
const entry = entries[index] || entries[0];
if (entry?.url && entry.url !== "about:blank") {
url = entry.url;
title ??= entry.title;
}
}
}
if (!url || url === "about:blank") {
return null;
}
const icon = syncableIconUrl(
(tabData.zenHasStaticIcon
? tabData.image
: initial?.image || tabData.image) || ""
);
return { url, title: title || "", icon };
}
/**
* Builds the ordered child id list for one parent (a space's pinned
* section or a folder) from the collected tab list, which is in strip
* order.
*
* @param {object} ctx - The projection context.
* @param {{space?: string, folder?: string}} scope
*/
#childSequence(ctx, scope) {
const seq = [];
const seen = new Set();
const push = id => {
if (!seen.has(id)) {
seen.add(id);
seq.push(id);
}
};
for (const tab of ctx.allTabs) {
const groupId = tab.groupId || null;
if (!groupId) {
if (
scope.space &&
!tab.zenEssential &&
(tab.zenWorkspace || null) === scope.space &&
ctx.syncableTabIds.has(tab.zenSyncId)
) {
push(tab.zenSyncId);
}
continue;
}
if (scope.folder && groupId === scope.folder) {
if (ctx.syncableTabIds.has(tab.zenSyncId)) {
push(tab.zenSyncId);
}
continue;
}
if (ctx.splitIds.has(groupId) || ctx.splitParents.has(groupId)) {
const parent = ctx.splitParents.get(groupId) ?? null;
const isDirect = scope.folder ? parent === scope.folder : !parent;
if (isDirect) {
if (
ctx.splitIds.has(groupId) &&
(!scope.space || (ctx.splitWs.get(groupId) ?? null) === scope.space)
) {
push(groupId);
}
} else if (parent) {
const child = this.#scopeChildFolder(ctx, scope, parent);
if (child) {
push(child);
}
}
continue;
}
if (ctx.folderById.has(groupId)) {
const child = this.#scopeChildFolder(ctx, scope, groupId);
if (child) {
push(child);
}
}
}
return seq;
}
/**
* Walks a folder's parent chain up to the direct child of the given
* scope, or null when the chain leads somewhere else.
*
* @param {object} ctx - The projection context.
* @param {{space?: string, folder?: string}} scope
* @param {string} folderId
* @returns {?string}
*/
#scopeChildFolder(ctx, scope, folderId) {
// The seen set only guards against a corrupt parentId cycle.
const seen = new Set();
let current = ctx.folderById.get(folderId);
while (current && !seen.has(current.id)) {
seen.add(current.id);
const parentId = current.parentId || null;
if (scope.folder ? parentId === scope.folder : !parentId) {
if (scope.space && (current.workspaceId || null) !== scope.space) {
return null;
}
return current.id;
}
current = parentId ? ctx.folderById.get(parentId) : null;
}
return null;
}
/**
* Derives the shared projection context from one sidebar collection.
* Syncable tabs, folders, the syncable splits and their parents and
* workspaces, plus the folder-membership resolver.
*
* @param {object} sidebar
*/
#projectionContext(sidebar) {
const allTabs = sidebar.tabs || [];
const tabs = allTabs.filter(t => this.#isSyncableTab(t));
const folders = (sidebar.folders || []).filter(
f => f?.id && !f.splitViewGroup
);
const folderIds = new Set(folders.map(f => f.id));
const folderById = new Map(folders.map(f => [f.id, f]));
const allSplits = (sidebar.splitViewData || []).filter(g => g?.groupId);
const splitParents = new Map();
for (const entry of sidebar.folders || []) {
if (entry?.splitViewGroup && entry.id) {
splitParents.set(entry.id, entry.parentId || null);
}
}
// Only splits whose members are all synced tabs are synced themselves.
const syncableTabIds = new Set(tabs.map(t => t.zenSyncId));
const splits = allSplits.filter(
g =>
Array.isArray(g.tabs) &&
g.tabs.length >= 2 &&
g.tabs.every(id => syncableTabIds.has(id))
);
const splitIds = new Set(splits.map(g => g.groupId));
const splitWs = new Map();
for (const tab of tabs) {
const groupId = tab.groupId || null;
if (groupId && splitIds.has(groupId) && !splitWs.has(groupId)) {
splitWs.set(groupId, tab.zenWorkspace || null);
}
}
const folderOf = groupId => {
if (!groupId) {
return null;
}
if (splitIds.has(groupId)) {
return splitParents.get(groupId) || null;
}
return folderIds.has(groupId) ? groupId : null;
};
return {
allTabs,
tabs,
folders,
folderById,
syncableTabIds,
splits,
splitIds,
splitParents,
splitWs,
folderOf,
};
}
/**
* @param {Map} map
* @param {object} ctx - The projection context.
*/
#projectTabs(map, ctx) {
for (const tab of ctx.tabs) {
const identity = this.#tabIdentity(tab);
if (!identity) {
continue;
}
const essential = !!tab.zenEssential;
map.set(tab.zenSyncId, {
kind: RECORD_KINDS.TAB,
data: {
tabId: tab.zenSyncId,
url: identity.url,
title: identity.title,
icon: identity.icon,
containerGuid: this.guidForContextId(tab.userContextId, {
create: true,
}),
essential,
pinned: !!(tab.pinned || tab.zenEssential),
workspaceUuid: essential ? null : tab.zenWorkspace || null,
folderId: ctx.folderOf(tab.groupId || null),
staticLabel:
typeof tab.zenStaticLabel === "string" ? tab.zenStaticLabel : null,
hasStaticIcon: !!tab.zenHasStaticIcon,
defaultContainer: !!tab.zenDefaultUserContextId,
},
});
}
}
projections() {
const sidebar = lazy.ZenSessionStore.getSidebarData() || {};
const stamp = sidebar.lastCollected || 0;
if (this.#cache && this.#cache.stamp === stamp) {
return this.#cache.map;
}
const map = new Map();
const pending = new Set();
const ctx = this.#projectionContext(sidebar);
const { tabs, folders, splits, splitParents, splitWs } = ctx;
if (!lazy.syncNormalTabs) {
// Items excluded only by the normal-tabs option are held back, not
// deleted. Flipping the option off must not tombstone them remotely.
const held = new Set();
for (const tab of ctx.allTabs) {
if (this.#isTabRecordMaterial(tab) && !this.#isSyncableTab(tab)) {
held.add(tab.zenSyncId);
pending.add(tab.zenSyncId);
}
}
for (const split of sidebar.splitViewData || []) {
if (
split?.groupId &&
!ctx.splitIds.has(split.groupId) &&
Array.isArray(split.tabs) &&
split.tabs.some(id => held.has(id))
) {
pending.add(split.groupId);
}
}
}
for (const identity of lazy.ContextualIdentityService.getPublicIdentities()) {
if (!identity.name) {
continue;
}
const guid = this.guidForContextId(identity.userContextId, {
create: true,
});
if (!guid) {
continue;
}
map.set(guid, {
kind: RECORD_KINDS.CONTAINER,
data: {
guid,
name: identity.name,
icon: identity.icon || "",
color: identity.color || "",
},
});
}
const spaces = sidebar.spaces || [];
for (const space of spaces) {
if (!space?.uuid) {
continue;
}
const uuid = space.uuid;
map.set(uuid, {
kind: RECORD_KINDS.SPACE,
data: {
uuid,
name: space.name ?? "",
icon: space.icon ?? null,
theme: space.theme ?? null,
containerGuid: this.guidForContextId(space.containerTabId, {
create: true,
}),
children: this.#childSequence(ctx, { space: uuid }),
},
});
}
for (const folder of folders) {
const fid = folder.id;
let live = null;
if (folder.isLiveFolder) {
try {
live = lazy.ZenLiveFoldersManager.getSyncableFolderData(fid);
} catch (e) {
console.error("ZenSpacesSync: failed to project live folder", e);
}
if (!live) {
pending.add(fid);
continue;
}
}
map.set(fid, {
kind: RECORD_KINDS.FOLDER,
data: {
folderId: fid,
name: folder.name ?? "",
icon: folder.userIcon || null,
workspaceUuid: folder.workspaceId || null,
parentFolderId: folder.parentId || null,
live,
children: this.#childSequence(ctx, { folder: fid }),
},
});
}
this.#projectTabs(map, ctx);
const tabById = new Map(tabs.map(t => [t.zenSyncId, t]));
for (const split of splits) {
const member = tabById.get(split.tabs[0]);
map.set(split.groupId, {
kind: RECORD_KINDS.SPLIT,
data: {
splitId: split.groupId,
gridType: split.gridType || "grid",
pinned: !!(member?.pinned || member?.zenEssential),
tabs: [...split.tabs],
workspaceUuid: splitWs.get(split.groupId) ?? null,
folderId: splitParents.get(split.groupId) || null,
},
});
}
if (spaces.length) {
const essentials = {};
for (const tab of tabs) {
if (!tab.zenEssential) {
continue;
}
const key =
this.guidForContextId(tab.userContextId, { create: true }) ||
"default";
(essentials[key] ||= []).push(tab.zenSyncId);
}
map.set(LAYOUT_RECORD_ID, {
kind: RECORD_KINDS.LAYOUT,
data: {
spaces: spaces.map(s => s.uuid).filter(Boolean),
essentials,
},
});
}
this.#cache = { stamp, map, pending };
return map;
}
/**
* Ids that are locally present but deliberately not projected this cycle
* (live folders whose provider config isn't available yet). The diff must
* not read their absence as a deletion.
*/
#pendingIds() {
this.projections();
return this.#cache.pending;
}
#digestCache = null;
/**
* Digests of all current projections. Change detection runs several times per
* sync cycle (tracker observe, changed-id computation, upload bookkeeping)
* and only needs to hash again after a fresh sidebar collection.
*/
#digestAll() {
const map = this.projections();
if (this.#digestCache?.map === map) {
return this.#digestCache.digests;
}
const digests = new Map();
for (const [id, projected] of map) {
digests.set(id, recordDigest(projected.kind, projected.data));
}
this.#digestCache = { map, digests };
return digests;
}
/* Mark: engine-facing API */
getAllRecordIds() {
const ids = {};
for (const id of this.projections().keys()) {
ids[id] = true;
}
return ids;
}
itemExists(id) {
return this.projections().has(id);
}
projectRecord(id) {
return this.projections().get(id) ?? null;
}
/**
* Before the session file is read the sidebar reads as empty. Diffing that
* against the uploaded snapshot would tombstone every synced item. An
* initialized sidebar always holds at least one space.
*/
#sidebarReady() {
return !!lazy.ZenSessionStore.getSidebarData()?.spaces?.length;
}
/**
* Changes = diff between the current projections and the last state the
* server acknowledged. Ids present locally with different content are
* modified; ids only present in the uploaded snapshot are deletions.
*/
computeChangedIDs() {
if (!this.#sidebarReady()) {
return {};
}
const uploaded = this.#data().uploaded;
const current = this.#digestAll();
const pending = this.#pendingIds();
const now = Date.now() / 1000;
const changes = {};
for (const [id, digest] of current) {
if (uploaded[id] !== digest) {
changes[id] = now;
}
}
for (const id of Object.keys(uploaded)) {
if (!current.has(id) && !pending.has(id)) {
changes[id] = now;
}
}
if (lazy.syncDebug && Object.keys(changes).length) {
const map = this.projections();
syncLog(
"outgoing diff:",
Object.keys(changes).map(id =>
current.has(id) ? `${map.get(id)?.kind} ${id}` : `TOMBSTONE ${id}`
)
);
}
return changes;
}
hasPendingChanges() {
if (!this.#sidebarReady()) {
return false;
}
const uploaded = this.#data().uploaded;
const current = this.#digestAll();
const pending = this.#pendingIds();
for (const [id, digest] of current) {
if (uploaded[id] !== digest) {
return true;
}
}
for (const id of Object.keys(uploaded)) {
if (!current.has(id) && !pending.has(id)) {
return true;
}
}
return false;
}
/**
* Called after records were successfully uploaded. Remember exactly what
* the server now holds for them.
*
* @param {Array<string>} ids
*/
markUploaded(ids) {
const data = this.#data();
const current = this.#digestAll();
for (const id of ids) {
if (current.has(id)) {
data.uploaded[id] = current.get(id);
} else {
delete data.uploaded[id];
}
}
if (lazy.syncDebug && ids.length) {
syncLog(
"server acknowledged upload:",
ids.map(id => (current.has(id) ? id : `${id} (tombstone)`))
);
}
this.#file.saveSoon();
}
/**
* Called after an incoming record was applied locally. Storing the
* incoming payload as the uploaded state means a faithful local
* materialization produces no re-upload, while a divergent one re-uploads
* the local truth (self-healing).
*
* @param {string} id
* @param {?object} cleartext - null for tombstones.
*/
noteApplied(id, cleartext) {
const data = this.#data();
if (!cleartext) {
delete data.uploaded[id];
} else {
data.uploaded[id] = recordDigest(cleartext.kind, cleartext.data);
}
syncLog(
`acknowledged incoming ${cleartext ? cleartext.kind : "tombstone"} ${id}`
);
this.#file.saveSoon();
}
}
export const ZenSpacesSyncModel = new nsZenSpacesSyncModel();