Files
NetMesh/electron/plugins/pluginSyncSidecarService.cjs
zhaolei 3c72efcb7f
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
[Init] Initial commit - NetMesh terminal manager
2026-09-13 18:24:01 +08:00

411 lines
16 KiB
JavaScript

"use strict";
/**
* Host-side collection and apply path for plugin encrypted sync sidecars.
* Uses non-cascade tables so uninstall/missing plugins do not destroy data.
*/
const {
collectPluginSyncSidecars,
excludeSecretPluginSettingsFromSidecars,
mergePluginSyncSidecars,
parseSettingsSidecarKey,
resolveSettingsSidecarTarget,
settingsSidecarKey,
isCloudSyncablePluginSetting,
} = require("./pluginSyncSidecarHelpers.cjs");
class PluginSyncSidecarService {
constructor(options) {
if (!options?.database) {
throw new TypeError("Plugin sync sidecar service requires a database");
}
this.database = options.database;
this.contributionService = options.contributionService ?? null;
}
#declaredSettingsByPlugin() {
const map = new Map();
const snapshot = this.contributionService?.snapshot?.();
const plugins = snapshot?.plugins ?? [];
for (const plugin of plugins) {
const fields = (plugin.settings ?? []).map((setting) => ({
id: setting.id,
secret: setting.secret === true,
sync: setting.sync === true,
scope: setting.scope,
}));
map.set(plugin.id, fields);
}
return map;
}
/**
* Build the sidecar bundle for inclusion in the encrypted cloud payload.
*/
collectForSync() {
const declared = this.#declaredSettingsByPlugin();
const storedSettings = typeof this.database.listAllSettings === "function"
? this.database.listAllSettings()
: [];
const existingSidecars = typeof this.database.listAllSyncSidecars === "function"
? this.database.listAllSyncSidecars()
: [];
// Keep retained settings sidecars for installed plugins even when
// plugin_settings has no row yet (downloaded while plugin was missing).
// Do not write them into plugin_settings here — that would bypass the
// contribution service's schema validation. applyFromSync / enable paths
// rehydrate through updateSetting. User resets clear the sidecar row via
// contributionService.resetSetting so deliberate deletions stay deleted.
const filteredExisting = existingSidecars.filter((entry) => {
if (entry.kind !== "settings") return true;
if (!declared.has(entry.pluginId)) return true; // missing plugin — preserve
const parsed = parseSettingsSidecarKey(entry.key);
if (!parsed) return false;
const fields = declared.get(entry.pluginId) ?? [];
const field = fields.find((item) => item.id === parsed.settingId);
// Drop secret / non-sync declarations; unknown fields stay for schema lag.
if (field && !isCloudSyncablePluginSetting(field)) return false;
return true;
});
const bundle = collectPluginSyncSidecars({
declaredSettingsByPlugin: declared,
storedSettings,
existingSidecars: filteredExisting,
});
bundle.entries = excludeSecretPluginSettingsFromSidecars(bundle.entries, declared);
// Persist collected settings into the non-cascade table so a later uninstall
// still has rows to re-emit (plugin_settings alone is not enough once the
// declaration disappears and collection skips undeclared stored values).
if (typeof this.database.replaceAllSyncSidecars === "function") {
this.database.replaceAllSyncSidecars(bundle.entries);
}
return bundle;
}
/**
* Merge a remote sidecar bundle into local non-cascade storage.
* Does not delete local rows for plugins absent from the remote bundle.
* Installed-plugin settings go through the contribution service when available
* so values are validated and runtime change events fire.
*/
async applyFromSync(remoteBundle) {
const declared = this.#declaredSettingsByPlugin();
const local = typeof this.database.listAllSyncSidecars === "function"
? this.database.listAllSyncSidecars()
: [];
const remoteEntries = Array.isArray(remoteBundle?.entries) ? remoteBundle.entries : [];
// Explicit empty remote bundle is an authoritative wipe (including rows for
// missing plugins). Legacy payloads without pluginSidecars never reach here
// (apply path only runs when the field is present).
const remoteIsAuthoritativeEmpty = Array.isArray(remoteBundle?.entries)
&& remoteBundle.entries.length === 0;
// Remote is authoritative for installed plugins. For missing plugins, if the
// remote bundle mentions the plugin at all, remote is authoritative for that
// plugin's entire sidecar set (do not resurrect local-only keys another
// device deleted). Only plugins completely absent from remote keep local
// retention so first-time missing-plugin baselines are not wiped.
const remotePluginIds = new Set(
remoteEntries.map((entry) => entry.pluginId).filter((id) => typeof id === "string"),
);
const remoteKeys = new Set(
remoteEntries.map((entry) => `${entry.pluginId}\0${entry.kind}\0${entry.key}`),
);
// Settings: remote is authoritative for installed plugins and for missing
// plugins mentioned in the remote bundle. Baselines (account/crdt) always
// enter merge so domain LWW can compare local vs remote; orphans survive.
const preservedLocal = local.filter((entry) => {
if (entry.kind !== "settings") {
return true;
}
if (remoteIsAuthoritativeEmpty) return false;
if (declared.has(entry.pluginId)) return false; // installed: remote wins
if (remotePluginIds.has(entry.pluginId)) return false;
return true;
});
const merged = mergePluginSyncSidecars({
local: preservedLocal,
remote: { version: 1, entries: remoteEntries },
declaredSettingsByPlugin: declared,
});
const safe = excludeSecretPluginSettingsFromSidecars(merged, declared);
if (typeof this.database.replaceAllSyncSidecars === "function") {
this.database.replaceAllSyncSidecars(safe);
}
// Drop installed-plugin settings that remote no longer carries.
// Prefer contributionService.resetSetting so plugins and the renderer
// receive change events; fall back to direct DB delete when unavailable.
// Include live plugin_settings rows that were never mirrored into
// plugin_sync_sidecars (e.g. pre-sidecar schema migrations) so remote
// deletions still clear them instead of being republished on next collect.
const resetCandidates = [];
const seenResetKeys = new Set();
const pushResetCandidate = (entry) => {
const mapKey = `${entry.pluginId}\0${entry.kind}\0${entry.key}`;
if (seenResetKeys.has(mapKey)) return;
seenResetKeys.add(mapKey);
resetCandidates.push(entry);
};
for (const entry of local) {
if (entry.kind !== "settings") continue;
if (!declared.has(entry.pluginId)) continue;
if (remoteKeys.has(`${entry.pluginId}\0${entry.kind}\0${entry.key}`)) continue;
pushResetCandidate(entry);
}
if (typeof this.database.listAllSettings === "function") {
for (const row of this.database.listAllSettings()) {
if (!declared.has(row.pluginId)) continue;
const fields = declared.get(row.pluginId) ?? [];
const field = fields.find((item) => item.id === row.settingId);
if (!field || !isCloudSyncablePluginSetting(field)) continue;
const key = settingsSidecarKey(row.settingId, row.scope, row.scopeId);
if (remoteKeys.has(`${row.pluginId}\0settings\0${key}`)) continue;
pushResetCandidate({
pluginId: row.pluginId,
kind: "settings",
key,
value: row.value,
updatedAt: row.updatedAt,
});
}
}
for (const entry of resetCandidates) {
const parsed = parseSettingsSidecarKey(entry.key);
if (!parsed) continue;
if (typeof this.contributionService?.resetSetting === "function") {
try {
await this.contributionService.resetSetting(
entry.pluginId,
parsed.settingId,
parsed.scopeId,
);
} catch {
// Validation rejected the reset (e.g. required setting) — still try
// the stored-coordinate delete below for stale-scope rows.
}
// resetSetting normalizes against the *current* declared scope. After a
// scope migration (application → device), that may miss the stale row
// still stored under the old coordinates; always delete those too.
try {
this.database.deleteSetting(
entry.pluginId,
parsed.settingId,
parsed.scope,
parsed.scopeId,
);
} catch {
// Best-effort cleanup of the stored-coordinate row.
}
continue;
}
this.database.deleteSetting(entry.pluginId, parsed.settingId, parsed.scope, parsed.scopeId);
}
for (const entry of safe) {
if (entry.kind !== "settings") continue;
const fields = declared.get(entry.pluginId);
if (!fields) continue;
const parsed = parseSettingsSidecarKey(entry.key);
if (!parsed) continue;
const field = fields.find((item) => item.id === parsed.settingId);
if (!field || !isCloudSyncablePluginSetting(field)) continue;
// Always write under the currently declared scope/key coordinates.
// Older sidecars may encode an obsolete scope after a plugin update;
// recreating that scope would leave duplicate rows that collection
// republishes. Application scope always uses the fixed "application" id.
const target = resolveSettingsSidecarTarget(field, parsed);
if (!target) continue;
const { targetScope, targetScopeId, nextKey } = target;
if (typeof this.contributionService?.updateSetting === "function") {
try {
await this.contributionService.updateSetting(
entry.pluginId,
parsed.settingId,
entry.value,
targetScopeId,
{ source: "host" },
);
// Preserve remote LWW timestamp after validated write (updateSetting uses clock).
this.database.setSetting(
entry.pluginId,
parsed.settingId,
targetScope,
targetScopeId,
entry.value,
entry.updatedAt,
);
if (targetScope !== parsed.scope || targetScopeId !== parsed.scopeId) {
try {
this.database.deleteSetting(
entry.pluginId,
parsed.settingId,
parsed.scope,
parsed.scopeId,
);
} catch {
// Best-effort cleanup of the obsolete scope row.
}
if (typeof this.database.setSyncSidecar === "function") {
try {
this.database.setSyncSidecar(
entry.pluginId,
"settings",
nextKey,
entry.value,
entry.updatedAt,
);
if (nextKey !== entry.key && typeof this.database.deleteSyncSidecar === "function") {
this.database.deleteSyncSidecar(entry.pluginId, "settings", entry.key);
}
} catch {
// Sidecar re-key is best-effort after settings write.
}
}
}
} catch {
// Invalid against current schema — keep sidecar row only.
}
continue;
}
this.database.setSetting(
entry.pluginId,
parsed.settingId,
targetScope,
targetScopeId,
entry.value,
entry.updatedAt,
);
if (targetScope !== parsed.scope || targetScopeId !== parsed.scopeId) {
try {
this.database.deleteSetting(
entry.pluginId,
parsed.settingId,
parsed.scope,
parsed.scopeId,
);
} catch {
// Best-effort cleanup of the obsolete scope row.
}
if (typeof this.database.setSyncSidecar === "function") {
try {
this.database.setSyncSidecar(
entry.pluginId,
"settings",
nextKey,
entry.value,
entry.updatedAt,
);
if (nextKey !== entry.key && typeof this.database.deleteSyncSidecar === "function") {
this.database.deleteSyncSidecar(entry.pluginId, "settings", entry.key);
}
} catch {
// Sidecar re-key is best-effort after settings write.
}
}
}
}
return safe;
}
/**
* After a plugin is installed/enabled, validate and apply retained settings
* sidecars into plugin_settings so the running plugin sees cloud values.
*/
async hydrateInstalledPluginSettings(pluginId) {
if (typeof pluginId !== "string" || pluginId.length < 1) return;
const declared = this.#declaredSettingsByPlugin().get(pluginId);
if (!declared) return;
const rows = typeof this.database.listSyncSidecars === "function"
? this.database.listSyncSidecars(pluginId)
: [];
for (const entry of rows) {
if (entry.kind !== "settings") continue;
const parsed = parseSettingsSidecarKey(entry.key);
if (!parsed) continue;
const field = declared.find((item) => item.id === parsed.settingId);
if (!field || !isCloudSyncablePluginSetting(field)) continue;
const target = resolveSettingsSidecarTarget(field, parsed);
if (!target) continue;
const { targetScope, targetScopeId, nextKey } = target;
// Do not overwrite a newer local edit that has not been collected yet.
if (typeof this.database.listSettings === "function") {
const localRows = this.database.listSettings(pluginId);
const local = localRows.find((row) => (
row.settingId === parsed.settingId
&& row.scope === targetScope
&& row.scopeId === targetScopeId
));
if (local && Number(local.updatedAt) > Number(entry.updatedAt)) {
continue;
}
}
if (typeof this.contributionService?.updateSetting === "function") {
try {
await this.contributionService.updateSetting(
pluginId,
parsed.settingId,
entry.value,
targetScopeId,
{ source: "host" },
);
this.database.setSetting(
pluginId,
parsed.settingId,
targetScope,
targetScopeId,
entry.value,
entry.updatedAt,
);
if (targetScope !== parsed.scope || targetScopeId !== parsed.scopeId) {
try {
this.database.deleteSetting(
pluginId,
parsed.settingId,
parsed.scope,
parsed.scopeId,
);
} catch {
// Best-effort cleanup of the obsolete scope row.
}
if (typeof this.database.setSyncSidecar === "function") {
try {
this.database.setSyncSidecar(
pluginId,
"settings",
nextKey,
entry.value,
entry.updatedAt,
);
if (nextKey !== entry.key && typeof this.database.deleteSyncSidecar === "function") {
this.database.deleteSyncSidecar(pluginId, "settings", entry.key);
}
} catch {
// Sidecar re-key is best-effort after settings write.
}
}
}
} catch {
// Invalid against current schema — keep sidecar only.
}
}
}
}
/**
* Persist an account or CRDT baseline without cascading on uninstall.
*/
setBaseline(pluginId, kind, key, value) {
if (kind !== "account_baseline" && kind !== "crdt_baseline") {
throw new TypeError("Baseline kind must be account_baseline or crdt_baseline");
}
this.database.setSyncSidecar(pluginId, kind, key, value);
}
getBaseline(pluginId, kind, key) {
return this.database.getSyncSidecar(pluginId, kind, key);
}
}
module.exports = {
PluginSyncSidecarService,
};