[Init] Initial commit - NetMesh terminal manager
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
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
This commit is contained in:
410
electron/plugins/pluginSyncSidecarService.cjs
Normal file
410
electron/plugins/pluginSyncSidecarService.cjs
Normal file
@@ -0,0 +1,410 @@
|
||||
"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,
|
||||
};
|
||||
Reference in New Issue
Block a user