[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:
615
electron/plugins/contributionService.cjs
Normal file
615
electron/plugins/contributionService.cjs
Normal file
@@ -0,0 +1,615 @@
|
||||
"use strict";
|
||||
|
||||
const { createHash } = require("node:crypto");
|
||||
|
||||
const { assertPluginContextKey, evaluateContextKeyExpression } = require("./contextKeys.cjs");
|
||||
const { assertPluginJsonValue } = require("./jsonBoundary.cjs");
|
||||
const { compileRestrictedJsonSchema } = require("./restrictedJsonSchema.cjs");
|
||||
const { PluginRpcError, RPC_ERRORS } = require("./rpcRouter.cjs");
|
||||
|
||||
const MAX_SETTING_BYTES = 128 * 1024;
|
||||
const MAX_PATTERN_LENGTH = 512;
|
||||
const MAX_PATTERN_VALUE_LENGTH = 4_096;
|
||||
const MAX_SCOPE_ID_LENGTH = 256;
|
||||
const SETTINGS_SCOPES = new Set(["application", "workspace", "host", "session", "device"]);
|
||||
|
||||
function invalidArgument(message) {
|
||||
return new PluginRpcError(RPC_ERRORS.invalidArgument, message);
|
||||
}
|
||||
|
||||
function notFound(message) {
|
||||
return new PluginRpcError(RPC_ERRORS.notFound, message);
|
||||
}
|
||||
|
||||
function freezeJson(value) {
|
||||
const clone = structuredClone(value);
|
||||
const freeze = (item) => {
|
||||
if (!item || typeof item !== "object" || Object.isFrozen(item)) return item;
|
||||
for (const child of Array.isArray(item) ? item : Object.values(item)) freeze(child);
|
||||
return Object.freeze(item);
|
||||
};
|
||||
return freeze(clone);
|
||||
}
|
||||
|
||||
function assertJsonValue(value, label = "value") {
|
||||
try { assertPluginJsonValue(value, { maxBytes: MAX_SETTING_BYTES }); }
|
||||
catch (error) {
|
||||
throw invalidArgument(`Plugin ${label} must be a bounded JSON value: ${error?.message ?? error}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function resolveLocalizedText(value, locale = "en") {
|
||||
if (typeof value === "string") return value;
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return "";
|
||||
const base = locale.split("-")[0];
|
||||
return value[locale] ?? value[base] ?? value.en ?? value.default ?? Object.values(value)[0] ?? "";
|
||||
}
|
||||
|
||||
function normalizeScopeId(scope, scopeId) {
|
||||
if (!SETTINGS_SCOPES.has(scope)) throw invalidArgument("Plugin setting scope is invalid");
|
||||
if (scope === "application") return "application";
|
||||
const value = scope === "device" && scopeId == null ? "device" : scopeId;
|
||||
if (typeof value !== "string" || value.length < 1 || value.length > MAX_SCOPE_ID_LENGTH || value.includes("\0")) {
|
||||
throw invalidArgument(`Plugin ${scope} setting requires a valid scope ID`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function getContribution(manifest, kind, id) {
|
||||
return manifest?.contributes?.[kind]?.find((item) => item.id === id || item.command === id) ?? null;
|
||||
}
|
||||
|
||||
function compileSettingPattern(source, settingId = "setting") {
|
||||
if (typeof source !== "string" || source.length < 1 || source.length > MAX_PATTERN_LENGTH
|
||||
|| /\(\?/u.test(source) || /\\(?:[1-9]|k<)/u.test(source)
|
||||
|| /\)(?:[*+?]|\{\d+(?:,\d*)?\})/u.test(source)) {
|
||||
throw invalidArgument(`${settingId} has an unsafe pattern`);
|
||||
}
|
||||
try { return new RegExp(source, "u"); }
|
||||
catch { throw invalidArgument(`${settingId} has an invalid pattern`); }
|
||||
}
|
||||
|
||||
function assertSettingValue(setting, value) {
|
||||
assertJsonValue(value, "setting value");
|
||||
const options = new Set((setting.options ?? []).map((option) => option.value));
|
||||
switch (setting.control) {
|
||||
case "switch":
|
||||
if (typeof value !== "boolean") throw invalidArgument(`${setting.id} must be a boolean`);
|
||||
break;
|
||||
case "radio": case "select":
|
||||
if (typeof value !== "string" || (options.size && !options.has(value))) {
|
||||
throw invalidArgument(`${setting.id} must use a declared option`);
|
||||
}
|
||||
break;
|
||||
case "multiselect":
|
||||
if (!Array.isArray(value) || value.some((item) => typeof item !== "string" || (options.size && !options.has(item)))
|
||||
|| new Set(value).size !== value.length) {
|
||||
throw invalidArgument(`${setting.id} must be a unique list of declared options`);
|
||||
}
|
||||
break;
|
||||
case "number": case "slider":
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) throw invalidArgument(`${setting.id} must be finite`);
|
||||
if (setting.minimum != null && value < setting.minimum) throw invalidArgument(`${setting.id} is below its minimum`);
|
||||
if (setting.maximum != null && value > setting.maximum) throw invalidArgument(`${setting.id} is above its maximum`);
|
||||
if (setting.step != null) {
|
||||
const steps = (value - (setting.minimum ?? 0)) / setting.step;
|
||||
const tolerance = Number.EPSILON * Math.max(1, Math.abs(steps)) * 16;
|
||||
if (Math.abs(steps - Math.round(steps)) > tolerance) {
|
||||
throw invalidArgument(`${setting.id} does not align to its step`);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case "list": case "table":
|
||||
if (!Array.isArray(value)) throw invalidArgument(`${setting.id} must be an array`);
|
||||
try { compileRestrictedJsonSchema(setting.valueSchema, { rootType: "array" })(value); }
|
||||
catch (error) { throw invalidArgument(`${setting.id} ${error?.message ?? error}`); }
|
||||
break;
|
||||
default:
|
||||
if (typeof value !== "string") throw invalidArgument(`${setting.id} must be a string`);
|
||||
if (setting.pattern) {
|
||||
if (value.length > MAX_PATTERN_VALUE_LENGTH) {
|
||||
throw invalidArgument(`${setting.id} exceeds the patterned text limit`);
|
||||
}
|
||||
const pattern = compileSettingPattern(setting.pattern, setting.id);
|
||||
if (!pattern.test(value)) throw invalidArgument(`${setting.id} does not match its pattern`);
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function secretSettingKey(settingId, scope, scopeId) {
|
||||
const digest = createHash("sha256").update(`${scope}\0${scopeId}`).digest("hex").slice(0, 32);
|
||||
return `setting:${settingId}:${digest}`;
|
||||
}
|
||||
|
||||
function activationEvents(manifest) {
|
||||
return new Set(manifest?.activationEvents ?? []);
|
||||
}
|
||||
|
||||
function shouldActivateOnStartup(manifest) {
|
||||
return activationEvents(manifest).has("onStartupFinished");
|
||||
}
|
||||
|
||||
function resolvePlatformKeybinding(keybinding, platform = process.platform) {
|
||||
if (!keybinding) return undefined;
|
||||
if (platform === "darwin") return keybinding.mac ?? keybinding.key;
|
||||
if (platform === "win32") return keybinding.windows ?? keybinding.key;
|
||||
if (platform === "linux") return keybinding.linux ?? keybinding.key;
|
||||
return keybinding.key;
|
||||
}
|
||||
|
||||
function isContributionAvailable(plugin) {
|
||||
return plugin?.enabled === true
|
||||
&& plugin.manifest != null
|
||||
&& plugin.runtime?.quarantinedAt == null;
|
||||
}
|
||||
|
||||
class PluginContributionService {
|
||||
constructor(options) {
|
||||
this.database = options.database;
|
||||
this.runtimeSupervisor = options.runtimeSupervisor;
|
||||
this.secretStore = options.secretStore;
|
||||
this.getLocale = options.getLocale ?? (() => "en");
|
||||
this.contextKeys = new Map();
|
||||
this.contextKeyOwners = new Map();
|
||||
this.listeners = new Set();
|
||||
this.viewMessageListeners = new Set();
|
||||
this.environment = null;
|
||||
this.environmentRevision = 0;
|
||||
this.runtimeEnvironmentState = new Map();
|
||||
this.initialized = false;
|
||||
}
|
||||
|
||||
registerRpcCapabilities(registry) {
|
||||
registry.registerRequest("settings.get", (params, context) => this.#runtimeGetSetting(params, context), {
|
||||
metadata: { capability: "settings", mutating: false, permission: "settings.read" },
|
||||
authorization: { permission: "settings.read", resources: ["*"], reason: "Read plugin settings" },
|
||||
});
|
||||
registry.registerRequest("settings.update", (params, context) => this.#runtimeUpdateSetting(params, context), {
|
||||
metadata: { capability: "settings", mutating: true, permission: "settings.write" },
|
||||
authorization: { permission: "settings.write", resources: ["*"], reason: "Update plugin settings" },
|
||||
});
|
||||
registry.registerRequest("commands.execute", (params, context) => this.executeCommand(params?.command, params?.args, {
|
||||
source: "plugin",
|
||||
callerPluginId: context.pluginId,
|
||||
}), {
|
||||
metadata: { capability: "commands", mutating: true, permission: "commands" },
|
||||
authorization: { permission: "commands", resources: ["*"], reason: "Execute a plugin command" },
|
||||
});
|
||||
registry.registerRequest("contextKeys.set", (params, context) => {
|
||||
const key = assertPluginContextKey(context.pluginId, params?.key);
|
||||
assertJsonValue(params?.value, "Context Key value");
|
||||
const owner = this.contextKeyOwners.get(key);
|
||||
if (owner != null && owner !== context.pluginId) {
|
||||
throw invalidArgument("Plugin Context Key is owned by another plugin");
|
||||
}
|
||||
this.contextKeys.set(key, freezeJson(params.value));
|
||||
this.contextKeyOwners.set(key, context.pluginId);
|
||||
this.#emitChange("context-key", context.pluginId);
|
||||
return null;
|
||||
}, {
|
||||
metadata: { capability: "contextKeys", mutating: true, public: true },
|
||||
});
|
||||
registry.registerRequest("views.getState", (params, context) => {
|
||||
const view = this.#assertOwnedContribution(context.manifest, "views", params?.viewId, context.pluginId);
|
||||
const scopeId = normalizeScopeId("session", params?.scopeId);
|
||||
return { state: this.database.getViewState(context.pluginId, view.id, scopeId) };
|
||||
}, {
|
||||
metadata: { capability: "views", mutating: false, permission: "views" },
|
||||
authorization: { permission: "views", resources: ["*"], reason: "Restore plugin view state" },
|
||||
});
|
||||
registry.registerRequest("views.setState", (params, context) => {
|
||||
const view = this.#assertOwnedContribution(context.manifest, "views", params?.viewId, context.pluginId);
|
||||
const scopeId = normalizeScopeId("session", params?.scopeId);
|
||||
assertJsonValue(params?.state, "view state");
|
||||
this.database.setViewState(context.pluginId, view.id, scopeId, params.state);
|
||||
return null;
|
||||
}, {
|
||||
metadata: { capability: "views", mutating: true, permission: "views" },
|
||||
authorization: { permission: "views", resources: ["*"], reason: "Persist plugin view state" },
|
||||
});
|
||||
registry.registerNotification("views.postMessage", (params, context) => {
|
||||
const view = this.#assertOwnedContribution(context.manifest, "views", params?.viewId, context.pluginId);
|
||||
assertJsonValue(params?.message, "view message");
|
||||
const event = freezeJson({ pluginId: context.pluginId, viewId: view.id, message: params.message });
|
||||
for (const listener of [...this.viewMessageListeners]) {
|
||||
try { listener(event); } catch {}
|
||||
}
|
||||
}, {
|
||||
metadata: { capability: "views", mutating: true, permission: "views" },
|
||||
authorization: { permission: "views", resources: ["*"], reason: "Message a plugin view" },
|
||||
});
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
if (this.initialized) return;
|
||||
this.initialized = true;
|
||||
for (const plugin of this.database.listPlugins()) {
|
||||
if (!plugin.enabled || plugin.runtime.quarantinedAt != null || !shouldActivateOnStartup(plugin.manifest)) continue;
|
||||
try { await this.#startPlugin(plugin.id); } catch {}
|
||||
}
|
||||
this.#emitChange("initialized");
|
||||
}
|
||||
|
||||
onDidChange(listener) {
|
||||
if (typeof listener !== "function") throw new TypeError("Plugin contribution listener must be a function");
|
||||
this.listeners.add(listener);
|
||||
return Object.freeze({ dispose: () => this.listeners.delete(listener) });
|
||||
}
|
||||
|
||||
onDidPostViewMessage(listener) {
|
||||
if (typeof listener !== "function") throw new TypeError("Plugin view listener must be a function");
|
||||
this.viewMessageListeners.add(listener);
|
||||
return Object.freeze({ dispose: () => this.viewMessageListeners.delete(listener) });
|
||||
}
|
||||
|
||||
#emitChange(reason, pluginId = null) {
|
||||
const event = Object.freeze({ reason, pluginId, revision: Date.now() });
|
||||
for (const listener of [...this.listeners]) {
|
||||
try { listener(event); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
async onPluginEnabled(pluginId) {
|
||||
const plugin = this.database.getActivePlugin(pluginId);
|
||||
if (!plugin?.enabled) throw notFound(`Plugin is not enabled: ${pluginId}`);
|
||||
if (shouldActivateOnStartup(plugin.manifest)) await this.#startPlugin(pluginId);
|
||||
this.#emitChange("plugin-enabled", pluginId);
|
||||
return plugin;
|
||||
}
|
||||
|
||||
onPluginDisabled(pluginId) {
|
||||
this.#clearRuntimeOwnedState(pluginId);
|
||||
this.#emitChange("plugin-disabled", pluginId);
|
||||
}
|
||||
|
||||
onRuntimeStateChanged(event) {
|
||||
if (!["stopped", "error", "quarantined"].includes(event?.status) || typeof event.pluginId !== "string") return;
|
||||
this.#clearRuntimeOwnedState(event.pluginId);
|
||||
this.#emitChange(`runtime-${event.status}`, event.pluginId);
|
||||
}
|
||||
|
||||
getEnvironment() {
|
||||
return this.environment;
|
||||
}
|
||||
|
||||
#clearRuntimeOwnedState(pluginId) {
|
||||
this.runtimeEnvironmentState.delete(pluginId);
|
||||
for (const [key, owner] of [...this.contextKeyOwners.entries()]) {
|
||||
if (owner !== pluginId) continue;
|
||||
this.contextKeyOwners.delete(key);
|
||||
this.contextKeys.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
#findContribution(kind, id) {
|
||||
if (typeof id !== "string") throw invalidArgument(`Plugin ${kind} ID is required`);
|
||||
for (const plugin of this.database.listPlugins()) {
|
||||
if (!isContributionAvailable(plugin)) continue;
|
||||
const contribution = getContribution(plugin.manifest, kind, id);
|
||||
if (contribution) return { plugin, contribution };
|
||||
}
|
||||
throw notFound(`Plugin ${kind} contribution was not found: ${id}`);
|
||||
}
|
||||
|
||||
#assertOwnedContribution(manifest, kind, id, pluginId) {
|
||||
if (typeof id !== "string" || !id.startsWith(`${pluginId}.`)) throw invalidArgument(`Plugin ${kind} ID is invalid`);
|
||||
const contribution = getContribution(manifest, kind, id);
|
||||
if (!contribution) throw notFound(`Plugin ${kind} contribution was not found: ${id}`);
|
||||
return contribution;
|
||||
}
|
||||
|
||||
async #ensureActivated(plugin, event) {
|
||||
const events = activationEvents(plugin.manifest);
|
||||
const isContributionEvent = /^(?:onCommand|onView|onProvider):/u.test(event);
|
||||
if (!events.has(event) && !events.has("onStartupFinished") && !isContributionEvent) {
|
||||
throw new PluginRpcError(RPC_ERRORS.failedPrecondition, `Plugin activation event is not declared: ${event}`);
|
||||
}
|
||||
// Declared UI contributions are implicit lazy activation points. This keeps
|
||||
// manifest activationEvents backward compatible while avoiding eager start.
|
||||
return this.#startPlugin(plugin.id);
|
||||
}
|
||||
|
||||
async #startPlugin(pluginId) {
|
||||
const identity = await this.runtimeSupervisor.start(pluginId);
|
||||
if (this.environment == null) return identity;
|
||||
const runtimeId = identity?.runtimeId ?? null;
|
||||
const seeded = this.runtimeEnvironmentState.get(pluginId);
|
||||
if (runtimeId != null && seeded?.runtimeId === runtimeId && seeded.revision === this.environmentRevision) {
|
||||
return identity;
|
||||
}
|
||||
await this.runtimeSupervisor.notify(pluginId, "plugin.environment.changed", this.environment);
|
||||
if (runtimeId != null) {
|
||||
this.runtimeEnvironmentState.set(pluginId, { runtimeId, revision: this.environmentRevision });
|
||||
}
|
||||
return identity;
|
||||
}
|
||||
|
||||
async executeCommand(commandId, args, invocation = {}) {
|
||||
const { plugin, contribution } = this.#findContribution("commands", commandId);
|
||||
if (invocation.callerPluginId && invocation.callerPluginId !== plugin.id) {
|
||||
throw new PluginRpcError(RPC_ERRORS.permissionDenied, "Plugins may execute only their own declared commands");
|
||||
}
|
||||
const keys = this.#context(invocation.context);
|
||||
if (!evaluateContextKeyExpression(contribution.enablement, keys)) {
|
||||
throw new PluginRpcError(RPC_ERRORS.failedPrecondition, `Plugin command is disabled: ${commandId}`);
|
||||
}
|
||||
assertJsonValue(args ?? null, "command arguments");
|
||||
await this.#ensureActivated(plugin, `onCommand:${commandId}`);
|
||||
return this.runtimeSupervisor.request(plugin.id, "plugin.command.execute", {
|
||||
command: commandId,
|
||||
...(args === undefined ? {} : { args }),
|
||||
invocation: freezeJson({ source: invocation.source ?? "host", context: invocation.context ?? {} }),
|
||||
});
|
||||
}
|
||||
|
||||
async activateView(viewId, params = {}) {
|
||||
const { plugin, contribution } = this.#findContribution("views", viewId);
|
||||
const keys = this.#context(params.context);
|
||||
if (!evaluateContextKeyExpression(contribution.when, keys)) {
|
||||
throw new PluginRpcError(RPC_ERRORS.failedPrecondition, `Plugin view is unavailable: ${viewId}`);
|
||||
}
|
||||
const identity = await this.#ensureActivated(plugin, `onView:${viewId}`);
|
||||
return { plugin, view: contribution, identity: identity ?? null };
|
||||
}
|
||||
|
||||
assertViewActivationCurrent(expected, params = {}) {
|
||||
if (!expected || typeof expected !== "object") throw invalidArgument("Plugin view activation identity is invalid");
|
||||
const { plugin, contribution } = this.#findContribution("views", expected.viewId);
|
||||
if (plugin.id !== expected.pluginId || plugin.activeVersion !== expected.pluginVersion) {
|
||||
throw new PluginRpcError(RPC_ERRORS.failedPrecondition, "Plugin view ownership changed while opening");
|
||||
}
|
||||
const keys = this.#context(params.context);
|
||||
if (!evaluateContextKeyExpression(contribution.when, keys)) {
|
||||
throw new PluginRpcError(RPC_ERRORS.failedPrecondition, `Plugin view is unavailable: ${expected.viewId}`);
|
||||
}
|
||||
const identity = this.runtimeSupervisor.getRuntimeIdentity?.(plugin.id) ?? null;
|
||||
if (!identity?.runtimeId || identity.runtimeId !== expected.runtimeId
|
||||
|| identity.pluginVersion !== expected.pluginVersion) {
|
||||
throw new PluginRpcError(RPC_ERRORS.failedPrecondition, "Plugin view runtime ownership changed while opening");
|
||||
}
|
||||
return { plugin, view: contribution, identity };
|
||||
}
|
||||
|
||||
async activateProvider(providerId) {
|
||||
const { plugin, contribution } = this.#findContribution("providers", providerId);
|
||||
const identity = await this.#ensureActivated(plugin, `onProvider:${providerId}`);
|
||||
return Object.freeze({ plugin, provider: contribution, identity: identity ?? null });
|
||||
}
|
||||
|
||||
listProviders(options = {}) {
|
||||
if (options.kind != null && (typeof options.kind !== "string" || options.kind.length < 1)) {
|
||||
throw invalidArgument("Plugin Provider kind is invalid");
|
||||
}
|
||||
const locale = options.locale ?? this.getLocale();
|
||||
const providers = [];
|
||||
for (const plugin of this.database.listPlugins()) {
|
||||
if (!isContributionAvailable(plugin)) continue;
|
||||
for (const provider of plugin.manifest.contributes?.providers ?? []) {
|
||||
if (options.kind != null && provider.kind !== options.kind) continue;
|
||||
providers.push({
|
||||
pluginId: plugin.id,
|
||||
pluginVersion: plugin.activeVersion,
|
||||
pluginDisplayName: resolveLocalizedText(
|
||||
plugin.manifest.displayName ?? plugin.manifest.name,
|
||||
locale,
|
||||
),
|
||||
provider: {
|
||||
...provider,
|
||||
label: resolveLocalizedText(provider.label, locale),
|
||||
description: provider.description == null
|
||||
? undefined
|
||||
: resolveLocalizedText(provider.description, locale),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
return freezeJson(providers);
|
||||
}
|
||||
|
||||
#settingRecord(pluginId, settingId) {
|
||||
const plugin = this.database.getActivePlugin(pluginId);
|
||||
if (!isContributionAvailable(plugin)) throw notFound(`Plugin is not enabled: ${pluginId}`);
|
||||
const setting = this.#assertOwnedContribution(plugin.manifest, "settings", settingId, pluginId);
|
||||
return { plugin, setting };
|
||||
}
|
||||
|
||||
async getSetting(pluginId, settingId, scopeId) {
|
||||
const { setting } = this.#settingRecord(pluginId, settingId);
|
||||
const normalizedScopeId = normalizeScopeId(setting.scope, scopeId);
|
||||
if (setting.secret) {
|
||||
return this.secretStore.getReference(pluginId, secretSettingKey(setting.id, setting.scope, normalizedScopeId)) ?? undefined;
|
||||
}
|
||||
const stored = this.database.getSetting(pluginId, setting.id, setting.scope, normalizedScopeId);
|
||||
return stored === undefined ? freezeJson(setting.default) : stored;
|
||||
}
|
||||
|
||||
async updateSetting(pluginId, settingId, value, scopeId, options = {}) {
|
||||
const { setting } = this.#settingRecord(pluginId, settingId);
|
||||
const normalizedScopeId = normalizeScopeId(setting.scope, scopeId);
|
||||
assertSettingValue(setting, value);
|
||||
if (setting.secret) {
|
||||
this.secretStore.set(pluginId, secretSettingKey(setting.id, setting.scope, normalizedScopeId), value);
|
||||
} else {
|
||||
this.database.setSetting(pluginId, setting.id, setting.scope, normalizedScopeId, value);
|
||||
}
|
||||
this.#emitChange("setting-updated", pluginId);
|
||||
try {
|
||||
await this.runtimeSupervisor.notify(pluginId, "plugin.settings.changed", {
|
||||
settingId: setting.id,
|
||||
scope: setting.scope,
|
||||
scopeId: normalizedScopeId,
|
||||
source: options.source ?? "host",
|
||||
});
|
||||
} catch {}
|
||||
return { restartRequired: setting.restartRequired === true };
|
||||
}
|
||||
|
||||
async resetSetting(pluginId, settingId, scopeId) {
|
||||
const { setting } = this.#settingRecord(pluginId, settingId);
|
||||
if (setting.required && setting.default === undefined) throw invalidArgument(`${setting.id} is required`);
|
||||
const normalizedScopeId = normalizeScopeId(setting.scope, scopeId);
|
||||
if (setting.secret) this.secretStore.delete(pluginId, secretSettingKey(setting.id, setting.scope, normalizedScopeId));
|
||||
else this.database.deleteSetting(pluginId, setting.id, setting.scope, normalizedScopeId);
|
||||
// Clear the matching cloud-sync sidecar so collect does not rehydrate the
|
||||
// retained remote value after an intentional local reset.
|
||||
if (typeof this.database.deleteSyncSidecar === "function" && !setting.secret) {
|
||||
const sidecarKey = `${setting.id}\0${setting.scope}\0${normalizedScopeId}`;
|
||||
try {
|
||||
this.database.deleteSyncSidecar(pluginId, "settings", sidecarKey);
|
||||
} catch {}
|
||||
}
|
||||
this.#emitChange("setting-reset", pluginId);
|
||||
try {
|
||||
await this.runtimeSupervisor.notify(pluginId, "plugin.settings.changed", {
|
||||
settingId: setting.id,
|
||||
scope: setting.scope,
|
||||
scopeId: normalizedScopeId,
|
||||
source: "host",
|
||||
});
|
||||
} catch {}
|
||||
return { restartRequired: setting.restartRequired === true };
|
||||
}
|
||||
|
||||
#runtimeGetSetting(params, context) {
|
||||
return this.getSetting(context.pluginId, params?.settingId, params?.scopeId).then((value) => ({
|
||||
found: value !== undefined,
|
||||
...(value === undefined ? {} : { value }),
|
||||
}));
|
||||
}
|
||||
|
||||
#runtimeUpdateSetting(params, context) {
|
||||
return this.updateSetting(context.pluginId, params?.settingId, params?.value, params?.scopeId, { source: "plugin" });
|
||||
}
|
||||
|
||||
#context(extra) {
|
||||
return Object.assign(Object.create(null), extra ?? {}, Object.fromEntries(this.contextKeys));
|
||||
}
|
||||
|
||||
snapshot(options = {}) {
|
||||
const locale = options.locale ?? this.getLocale();
|
||||
const context = this.#context(options.context);
|
||||
const plugins = [];
|
||||
for (const plugin of this.database.listPlugins()) {
|
||||
if (!isContributionAvailable(plugin)) continue;
|
||||
const contributes = plugin.manifest.contributes ?? {};
|
||||
const localize = (value) => resolveLocalizedText(value, locale);
|
||||
const commands = (contributes.commands ?? []).map((command) => ({
|
||||
...command,
|
||||
title: localize(command.title),
|
||||
category: command.category == null ? undefined : localize(command.category),
|
||||
description: command.description == null ? undefined : localize(command.description),
|
||||
enabled: evaluateContextKeyExpression(command.enablement, context),
|
||||
}));
|
||||
const commandById = new Map(commands.map((command) => [command.id, command]));
|
||||
const keybindings = (contributes.keybindings ?? []).map((keybinding) => ({
|
||||
...keybinding,
|
||||
enabled: evaluateContextKeyExpression(keybinding.when, context)
|
||||
&& commandById.get(keybinding.command)?.enabled !== false,
|
||||
}));
|
||||
const keybindingByCommand = new Map();
|
||||
for (const keybinding of keybindings) {
|
||||
if (keybinding.enabled && !keybindingByCommand.has(keybinding.command)) {
|
||||
keybindingByCommand.set(keybinding.command, keybinding);
|
||||
}
|
||||
}
|
||||
const menus = (contributes.menus ?? []).map((menu, index) => {
|
||||
const menuContext = this.#context(options.menuContexts?.[menu.location] ?? options.context);
|
||||
const command = commandById.get(menu.command);
|
||||
return {
|
||||
...menu,
|
||||
id: `${plugin.id}:menu:${index}`,
|
||||
title: menu.title == null ? command?.title ?? menu.command : localize(menu.title),
|
||||
visible: evaluateContextKeyExpression(menu.when, menuContext),
|
||||
enabled: evaluateContextKeyExpression(menu.enablement, menuContext)
|
||||
&& evaluateContextKeyExpression(command?.enablement, menuContext),
|
||||
checked: menu.checked == null ? undefined : evaluateContextKeyExpression(menu.checked, menuContext),
|
||||
shortcut: menu.showKeybinding === false
|
||||
? undefined
|
||||
: resolvePlatformKeybinding(keybindingByCommand.get(menu.command), options.platform),
|
||||
};
|
||||
});
|
||||
const settings = (contributes.settings ?? []).map((setting) => {
|
||||
const requestedScopeId = options.scopeIds?.[setting.scope];
|
||||
const scopeId = setting.scope === "application"
|
||||
? "application"
|
||||
: setting.scope === "device"
|
||||
? (requestedScopeId ?? "device")
|
||||
: (requestedScopeId ?? null);
|
||||
const stored = scopeId == null
|
||||
? undefined
|
||||
: setting.secret
|
||||
? this.secretStore.getReference(plugin.id, secretSettingKey(setting.id, setting.scope, scopeId))
|
||||
: this.database.getSetting(plugin.id, setting.id, setting.scope, scopeId);
|
||||
return {
|
||||
...setting,
|
||||
label: localize(setting.label),
|
||||
description: setting.description == null ? undefined : localize(setting.description),
|
||||
placeholder: setting.placeholder == null ? undefined : localize(setting.placeholder),
|
||||
options: setting.options?.map((option) => ({
|
||||
...option,
|
||||
label: localize(option.label),
|
||||
description: option.description == null ? undefined : localize(option.description),
|
||||
})),
|
||||
visible: evaluateContextKeyExpression(setting.when, context),
|
||||
configured: stored !== undefined && stored !== null,
|
||||
value: setting.secret ? undefined : freezeJson(stored === undefined ? setting.default : stored),
|
||||
scopeId,
|
||||
};
|
||||
});
|
||||
const views = (contributes.views ?? []).map((view) => ({
|
||||
...view,
|
||||
title: localize(view.title),
|
||||
visible: evaluateContextKeyExpression(view.when, context),
|
||||
}));
|
||||
plugins.push({
|
||||
id: plugin.id,
|
||||
version: plugin.activeVersion,
|
||||
displayName: localize(plugin.manifest.displayName ?? plugin.manifest.name),
|
||||
description: localize(plugin.manifest.description ?? ""),
|
||||
commands,
|
||||
keybindings,
|
||||
menus,
|
||||
settings,
|
||||
views,
|
||||
});
|
||||
}
|
||||
return freezeJson({ locale, plugins });
|
||||
}
|
||||
|
||||
async setEnvironment(environment) {
|
||||
assertJsonValue(environment, "environment");
|
||||
this.environment = freezeJson(environment);
|
||||
this.environmentRevision += 1;
|
||||
for (const plugin of this.database.listPlugins()) {
|
||||
if (!plugin.enabled || plugin.runtime.status !== "running") continue;
|
||||
try {
|
||||
await this.runtimeSupervisor.notify(plugin.id, "plugin.environment.changed", this.environment);
|
||||
const identity = this.runtimeSupervisor.getRuntimeIdentity?.(plugin.id);
|
||||
if (identity?.runtimeId) {
|
||||
this.runtimeEnvironmentState.set(plugin.id, {
|
||||
runtimeId: identity.runtimeId,
|
||||
revision: this.environmentRevision,
|
||||
});
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
this.#emitChange("environment");
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
MAX_SETTING_BYTES,
|
||||
MAX_PATTERN_VALUE_LENGTH,
|
||||
PluginContributionService,
|
||||
assertJsonValue,
|
||||
assertSettingValue,
|
||||
compileSettingPattern,
|
||||
normalizeScopeId,
|
||||
resolveLocalizedText,
|
||||
resolvePlatformKeybinding,
|
||||
secretSettingKey,
|
||||
isContributionAvailable,
|
||||
shouldActivateOnStartup,
|
||||
};
|
||||
Reference in New Issue
Block a user