feat: expand and harden plugin platform
This commit is contained in:
1 parent
d9ae5cb254
commit
39cf34b018
25 files changed
+1868
-50
No files matched your search
@@ -9,6 +9,8 @@
|
||||
node_modules/
|
||||
.idea/
|
||||
dist/
|
||||
!packages/plugin-api/dist/
|
||||
!packages/plugin-api/dist/**
|
||||
build/
|
||||
apps/extension/dist-firefox/
|
||||
apps/extension/web-ext-artifacts/
|
||||
|
||||
@@ -81,6 +81,7 @@ import {
|
||||
putLocalNotebook,
|
||||
} from "@/lib/local-mirror";
|
||||
import { createRepository } from "@/lib/repository";
|
||||
import { notifyRepositoryMutation } from "@/lib/repository-events";
|
||||
import {
|
||||
refreshWorkspaceData,
|
||||
shouldNavigateHomeWhenOpeningMemo,
|
||||
@@ -1494,6 +1495,7 @@ export const WorkspaceApp = ({
|
||||
desktopRuntime: Boolean(window.edgeeverDesktop?.isAvailable),
|
||||
});
|
||||
const data = requiresRemoteMemo ? await api.createMemo(input) : await repository.createMemo(input);
|
||||
if (requiresRemoteMemo) notifyRepositoryMutation(localDataScope, { type: "note.created", note: data.memo });
|
||||
await putLocalMemo(localDataScope, data.memo);
|
||||
return data;
|
||||
},
|
||||
@@ -1560,6 +1562,7 @@ export const WorkspaceApp = ({
|
||||
const data = requiresRemoteMemo
|
||||
? await api.useTemplate(input.templateId, input.notebookId)
|
||||
: await repository.useTemplate(input.templateId, input.notebookId);
|
||||
if (requiresRemoteMemo) notifyRepositoryMutation(localDataScope, { type: "note.created", note: data.memo });
|
||||
await putLocalMemo(localDataScope, data.memo);
|
||||
return data;
|
||||
},
|
||||
|
||||
@@ -12,12 +12,131 @@ import { loadPluginMarketplace } from "@/lib/plugins/plugin-marketplace";
|
||||
import { GitHubMark } from "@/components/GitHubRepositoryLink";
|
||||
import { checkPluginUpdates, type PluginUpdateInfo } from "@/lib/plugins/plugin-updates";
|
||||
import { PluginUpdateDialog } from "@/components/plugins/PluginUpdateDialog";
|
||||
import type { PluginManifest, PluginSettingValue } from "@edgeever/plugin-api";
|
||||
|
||||
const permissionLabel = (permission: string) => permission.replace(":", " · ");
|
||||
|
||||
const PluginSettingsSection = ({ host, manifest }: { host: EdgeEverPluginHost; manifest: PluginManifest }) => {
|
||||
const { t } = useTranslation();
|
||||
const fields = manifest.settings?.fields ?? [];
|
||||
const [values, setValues] = useState<Record<string, PluginSettingValue | "">>({});
|
||||
const [configuredSecrets, setConfiguredSecrets] = useState<Record<string, boolean>>({});
|
||||
const [loading, setLoading] = useState(fields.length > 0);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
setLoading(fields.length > 0);
|
||||
setMessage(null);
|
||||
void Promise.all(fields.map(async (field) => {
|
||||
if (field.type === "secret") return { key: field.key, value: "" as const, configured: await host.hasSettingValue(manifest.id, field.key) };
|
||||
return { key: field.key, value: await host.getSettingValue(manifest.id, field.key) ?? "", configured: false };
|
||||
})).then((loaded) => {
|
||||
if (!active) return;
|
||||
setValues(Object.fromEntries(loaded.map((item) => [item.key, item.value])));
|
||||
setConfiguredSecrets(Object.fromEntries(loaded.map((item) => [item.key, item.configured])));
|
||||
setLoading(false);
|
||||
}).catch((error) => {
|
||||
if (!active) return;
|
||||
setMessage(error instanceof Error ? error.message : String(error));
|
||||
setLoading(false);
|
||||
});
|
||||
return () => { active = false; };
|
||||
}, [host, manifest.id, manifest.version]);
|
||||
|
||||
if (fields.length === 0) return null;
|
||||
|
||||
const save = async () => {
|
||||
setSaving(true);
|
||||
setMessage(null);
|
||||
try {
|
||||
for (const field of fields) {
|
||||
const value = values[field.key];
|
||||
if (field.type === "secret" && value === "") {
|
||||
if (field.required && !configuredSecrets[field.key]) throw new Error(t("plugins.settings.required", { name: field.label }));
|
||||
continue;
|
||||
}
|
||||
if (value === "") {
|
||||
if (field.required) throw new Error(t("plugins.settings.required", { name: field.label }));
|
||||
await host.removeSettingValue(manifest.id, field.key);
|
||||
continue;
|
||||
}
|
||||
await host.setSettingValue(manifest.id, field.key, value);
|
||||
if (field.type === "secret") {
|
||||
setConfiguredSecrets((current) => ({ ...current, [field.key]: true }));
|
||||
setValues((current) => ({ ...current, [field.key]: "" }));
|
||||
}
|
||||
}
|
||||
setMessage(t("plugins.settings.saved"));
|
||||
} catch (error) {
|
||||
setMessage(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="rounded-lg border border-slate-200 p-4">
|
||||
<h3 className="text-xs font-semibold text-slate-700">{t("plugins.settings.title")}</h3>
|
||||
{loading ? <p className="mt-3 text-xs text-slate-400">{t("common.loading")}</p> : (
|
||||
<div className="mt-3 grid gap-4">
|
||||
{fields.map((field) => {
|
||||
const value = values[field.key] ?? "";
|
||||
return (
|
||||
<label key={field.key} className="grid gap-1.5 text-xs text-slate-700">
|
||||
<span className="font-medium">{field.label}{field.required ? " *" : ""}</span>
|
||||
{field.type === "boolean" ? (
|
||||
<Switch
|
||||
aria-label={field.label}
|
||||
checked={value === true}
|
||||
onCheckedChange={(checked) => setValues((current) => ({ ...current, [field.key]: checked }))}
|
||||
/>
|
||||
) : field.type === "select" ? (
|
||||
<select
|
||||
className="h-9 rounded-md border border-slate-200 bg-white px-3 text-sm text-slate-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500/70"
|
||||
value={String(value)}
|
||||
onChange={(event) => setValues((current) => ({ ...current, [field.key]: event.target.value }))}
|
||||
>
|
||||
{!field.required ? <option value="">{t("plugins.settings.none")}</option> : null}
|
||||
{field.options.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
|
||||
</select>
|
||||
) : (
|
||||
<Input
|
||||
type={field.type === "secret" ? "password" : field.type === "number" ? "number" : "text"}
|
||||
value={String(value)}
|
||||
placeholder={field.type === "secret" && configuredSecrets[field.key]
|
||||
? t("plugins.settings.secretConfigured")
|
||||
: field.type === "text" || field.type === "secret"
|
||||
? field.placeholder
|
||||
: undefined}
|
||||
min={field.type === "number" ? field.min : undefined}
|
||||
max={field.type === "number" ? field.max : undefined}
|
||||
step={field.type === "number" ? field.step : undefined}
|
||||
onChange={(event) => setValues((current) => ({
|
||||
...current,
|
||||
[field.key]: field.type === "number" && event.target.value !== "" ? Number(event.target.value) : event.target.value,
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
{field.description ? <span className="text-slate-400">{field.description}</span> : null}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
<div className="flex items-center gap-3">
|
||||
<Button size="sm" disabled={saving} onClick={() => void save()}>{saving ? t("common.saving") : t("common.save")}</Button>
|
||||
{message ? <span className="text-xs text-slate-500" role="status">{message}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
const PluginDetailView = ({
|
||||
commands,
|
||||
extension,
|
||||
host,
|
||||
panels,
|
||||
pendingId,
|
||||
update,
|
||||
@@ -29,6 +148,7 @@ const PluginDetailView = ({
|
||||
}: {
|
||||
commands: RegisteredPluginCommand[];
|
||||
extension: InstalledExtension;
|
||||
host: EdgeEverPluginHost;
|
||||
panels: RegisteredPluginPanel[];
|
||||
pendingId: string | null;
|
||||
update?: PluginUpdateInfo;
|
||||
@@ -110,6 +230,8 @@ const PluginDetailView = ({
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{manifest.type === "plugin" ? <PluginSettingsSection host={host} manifest={manifest} /> : null}
|
||||
|
||||
{extension.error ? <div className="text-sm text-rose-600">{extension.error}</div> : null}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 border-t border-slate-100 pt-4">
|
||||
@@ -306,6 +428,7 @@ export const PluginManagerCard = ({
|
||||
selectedExtension ? (
|
||||
<PluginDetailView
|
||||
extension={selectedExtension}
|
||||
host={host}
|
||||
update={updateQuery.data?.updates.find((update) => update.pluginId === selectedExtension.manifest.id)}
|
||||
commands={snapshot.commands.filter((command) => command.pluginId === selectedExtension.manifest.id)}
|
||||
panels={snapshot.panels.filter((panel) => panel.pluginId === selectedExtension.manifest.id)}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
export default {
|
||||
activate(context) {
|
||||
return context.commands.register({
|
||||
id: "exercise-capabilities",
|
||||
title: "Exercise capabilities",
|
||||
async run() {
|
||||
const notebook = await context.notebooks.create({ name: "Created by plugin" });
|
||||
const moved = await context.notes.move(["note-1"], notebook.id);
|
||||
const pinned = await context.notes.pin(["note-1"], true);
|
||||
const revisions = await context.notes.revisions.list("note-1");
|
||||
const resources = await context.resources.list("note-1");
|
||||
const uploaded = await context.resources.upload("note-1", new File(["hello"], "hello.txt", { type: "text/plain" }));
|
||||
const endpoint = await context.settings.get("endpoint");
|
||||
globalThis.edgeeverPluginCapabilityResult = { notebook, moved, pinned, revisions, resources, uploaded, endpoint };
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
export default {
|
||||
activate(context) {
|
||||
return context.events.on("note.updated", ({ note }) => {
|
||||
globalThis.edgeeverPluginObservedNote = note;
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -14,6 +14,20 @@ export default {
|
||||
await context.notes.query();
|
||||
},
|
||||
});
|
||||
const disposeUpdate = context.commands.register({
|
||||
id: "update-without-read-permission",
|
||||
title: "Update without read permission",
|
||||
async run() {
|
||||
await context.notes.update("note-1", { title: "Changed" });
|
||||
},
|
||||
});
|
||||
const disposeEvent = context.commands.register({
|
||||
id: "subscribe-without-read-permission",
|
||||
title: "Subscribe without read permission",
|
||||
run() {
|
||||
context.events.on("note.updated", () => undefined);
|
||||
},
|
||||
});
|
||||
const disposeEditor = context.commands.register({
|
||||
id: "replace-selection",
|
||||
title: "Replace selection",
|
||||
@@ -29,6 +43,13 @@ export default {
|
||||
await context.secrets.set("token", "secret-value");
|
||||
},
|
||||
});
|
||||
const disposeStorage = context.commands.register({
|
||||
id: "write-storage",
|
||||
title: "Write storage",
|
||||
async run() {
|
||||
await context.storage.set("preference", "stored-value");
|
||||
},
|
||||
});
|
||||
const disposePanel = context.ui.panels.register({
|
||||
id: "fixture",
|
||||
title: "Fixture panel",
|
||||
@@ -40,8 +61,11 @@ export default {
|
||||
return () => {
|
||||
disposeHello();
|
||||
disposeRead();
|
||||
disposeUpdate();
|
||||
disposeEvent();
|
||||
disposeEditor();
|
||||
disposeSecret();
|
||||
disposeStorage();
|
||||
disposePanel();
|
||||
};
|
||||
},
|
||||
|
||||
@@ -10,6 +10,8 @@ globalThis.window = {
|
||||
getItem: (key) => values.get(key) ?? null,
|
||||
setItem: (key, value) => values.set(key, String(value)),
|
||||
removeItem: (key) => values.delete(key),
|
||||
get length() { return values.size; },
|
||||
key: (index) => [...values.keys()][index] ?? null,
|
||||
},
|
||||
addEventListener: (name, listener) => eventListeners.set(name, listener),
|
||||
removeEventListener: (name) => eventListeners.delete(name),
|
||||
@@ -36,6 +38,7 @@ globalThis.MutationObserver = class {
|
||||
|
||||
const { EdgeEverPluginHost } = await import("./plugin-host.ts");
|
||||
const { sha256Hex } = await import("./github-plugin-distribution.ts");
|
||||
const { withRepositoryMutationEvents } = await import("../repository-events.ts");
|
||||
|
||||
const repository = {
|
||||
listMemos: async () => ({ memos: [], totalCount: 0, nextCursor: null }),
|
||||
@@ -80,8 +83,16 @@ describe("EdgeEverPluginHost", () => {
|
||||
get: async (pluginId, key) => secrets.get(`${pluginId}:${key}`) ?? null,
|
||||
set: async (pluginId, key, value) => secrets.set(`${pluginId}:${key}`, value),
|
||||
remove: async (pluginId, key) => secrets.delete(`${pluginId}:${key}`),
|
||||
clearNamespace: async (pluginId) => {
|
||||
for (const key of [...secrets.keys()]) if (key.startsWith(`${pluginId}:`)) secrets.delete(key);
|
||||
},
|
||||
};
|
||||
const host = new EdgeEverPluginHost({ repository, scope: "test", onNotice: (message) => notices.push(message), secretStorage });
|
||||
const packageStorage = {
|
||||
get: async () => null,
|
||||
put: async () => undefined,
|
||||
remove: async () => undefined,
|
||||
};
|
||||
const host = new EdgeEverPluginHost({ repository, scope: "test", onNotice: (message) => notices.push(message), secretStorage, packageStorage });
|
||||
let replacement = null;
|
||||
host.setEditorAdapter({
|
||||
getSelection: () => ({ noteId: "note-1", from: 1, to: 6, empty: false, text: "hello", contentMarkdown: "hello" }),
|
||||
@@ -96,21 +107,38 @@ describe("EdgeEverPluginHost", () => {
|
||||
version: "1.0.0",
|
||||
apiVersion: "1",
|
||||
entry,
|
||||
permissions: ["ui:commands", "ui:notices", "ui:panels", "editor:read", "editor:write", "secrets"],
|
||||
permissions: ["notes:write", "ui:commands", "ui:notices", "ui:panels", "editor:read", "editor:write", "secrets", "storage"],
|
||||
settings: {
|
||||
fields: [
|
||||
{ key: "endpoint", type: "text", label: "Endpoint", default: "https://default.example" },
|
||||
{ key: "token", type: "secret", label: "Token", required: true },
|
||||
{ key: "limit", type: "number", label: "Limit", min: 1, max: 10, default: 5 },
|
||||
],
|
||||
},
|
||||
}, "https://example.com/plugin/manifest.json");
|
||||
|
||||
await host.setEnabled("org.edgeever.test-plugin", true);
|
||||
await host.runCommand("org.edgeever.test-plugin", "hello");
|
||||
|
||||
expect(host.getSnapshot().commands).toHaveLength(4);
|
||||
expect(host.getSnapshot().commands).toHaveLength(7);
|
||||
expect(host.getSnapshot().panels).toHaveLength(1);
|
||||
expect(notices).toEqual(["hello from plugin"]);
|
||||
expect(host.getSnapshot().recentActions[0]).toMatchObject({ id: "hello", type: "command" });
|
||||
await expect(host.runCommand("org.edgeever.test-plugin", "read-without-permission")).rejects.toThrow("notes:read");
|
||||
await expect(host.runCommand("org.edgeever.test-plugin", "update-without-read-permission")).rejects.toThrow("notes:read");
|
||||
await expect(host.runCommand("org.edgeever.test-plugin", "subscribe-without-read-permission")).rejects.toThrow("notes:read");
|
||||
await host.runCommand("org.edgeever.test-plugin", "replace-selection");
|
||||
expect(replacement).toBe("HELLO");
|
||||
await host.runCommand("org.edgeever.test-plugin", "write-secret");
|
||||
expect(secrets.get("test:org.edgeever.test-plugin:token")).toBe("secret-value");
|
||||
await host.runCommand("org.edgeever.test-plugin", "write-storage");
|
||||
expect(values.get("edgeever.plugin-data.v1:test:org.edgeever.test-plugin:preference")).toBe('"stored-value"');
|
||||
await expect(host.getSettingValue("org.edgeever.test-plugin", "endpoint")).resolves.toBe("https://default.example");
|
||||
await host.setSettingValue("org.edgeever.test-plugin", "endpoint", "https://custom.example");
|
||||
await host.setSettingValue("org.edgeever.test-plugin", "token", "configured-token");
|
||||
await expect(host.getSettingValue("org.edgeever.test-plugin", "token")).resolves.toBeNull();
|
||||
await expect(host.getSettingValue("org.edgeever.test-plugin", "token", true)).resolves.toBe("configured-token");
|
||||
await expect(host.setSettingValue("org.edgeever.test-plugin", "limit", 11)).rejects.toThrow("at most 10");
|
||||
const container = {};
|
||||
const disposePanel = await host.mountPanel("org.edgeever.test-plugin", "fixture", container);
|
||||
expect(container.mountedByFixture).toBe(true);
|
||||
@@ -123,6 +151,11 @@ describe("EdgeEverPluginHost", () => {
|
||||
expect(mountedDuringDisable.mountedByFixture).toBe(false);
|
||||
expect(host.getSnapshot().panels).toHaveLength(0);
|
||||
expect(host.getSnapshot().recentActions).toHaveLength(0);
|
||||
await host.uninstall("org.edgeever.test-plugin");
|
||||
expect(secrets.has("test:org.edgeever.test-plugin:token")).toBe(false);
|
||||
expect(secrets.has("test:org.edgeever.test-plugin:setting:token")).toBe(false);
|
||||
expect(values.has("edgeever.plugin-data.v1:test:org.edgeever.test-plugin:preference")).toBe(false);
|
||||
expect(values.has("edgeever.plugin-settings.v1:test:org.edgeever.test-plugin:endpoint")).toBe(false);
|
||||
await host.dispose();
|
||||
});
|
||||
|
||||
@@ -146,11 +179,20 @@ describe("EdgeEverPluginHost", () => {
|
||||
};
|
||||
const packages = new Map();
|
||||
const packageStorage = {
|
||||
get: async (pluginId, version) => packages.get(pluginId)?.version === version ? packages.get(pluginId) : null,
|
||||
put: async (value) => packages.set(value.pluginId, value),
|
||||
remove: async (pluginId) => packages.delete(pluginId),
|
||||
get: async (pluginId, version) => packages.get(`${pluginId}:${version}`) ?? null,
|
||||
put: async (value) => packages.set(`${value.pluginId}:${value.version}`, value),
|
||||
remove: async (pluginId, version) => {
|
||||
if (version) packages.delete(`${pluginId}:${version}`);
|
||||
else for (const key of [...packages.keys()]) if (key.startsWith(`${pluginId}:`)) packages.delete(key);
|
||||
},
|
||||
};
|
||||
const host = new EdgeEverPluginHost({ repository, scope: "test", packageStorage });
|
||||
const secretStorage = {
|
||||
get: async () => null,
|
||||
set: async () => undefined,
|
||||
remove: async () => undefined,
|
||||
clearNamespace: async () => undefined,
|
||||
};
|
||||
const host = new EdgeEverPluginHost({ repository, scope: "test", packageStorage, secretStorage });
|
||||
const entry = {
|
||||
id: manifest.id,
|
||||
name: manifest.name,
|
||||
@@ -168,9 +210,57 @@ describe("EdgeEverPluginHost", () => {
|
||||
await host.installMarketplaceEntry(entry);
|
||||
|
||||
expect(host.getSnapshot().extensions[0].source).toMatchObject({ kind: "marketplace", verified: true });
|
||||
expect(packages.get(manifest.id)?.checksums.mainJs).toBe(entry.verification.checksums.mainJs);
|
||||
expect(packages.get(`${manifest.id}:${manifest.version}`)?.checksums.mainJs).toBe(entry.verification.checksums.mainJs);
|
||||
await host.uninstall(manifest.id);
|
||||
expect(packages.has(manifest.id)).toBe(false);
|
||||
expect([...packages.keys()].some((key) => key.startsWith(`${manifest.id}:`))).toBe(false);
|
||||
await host.dispose();
|
||||
});
|
||||
|
||||
test("rolls back an enabled plugin when an update cannot activate", async () => {
|
||||
const pluginId = "org.edgeever.rollback-test";
|
||||
const entry = new URL("./plugin-host.fixture.mjs", import.meta.url).href;
|
||||
const packages = new Map();
|
||||
const packageStorage = {
|
||||
get: async (id, version) => packages.get(`${id}:${version}`) ?? null,
|
||||
put: async (value) => packages.set(`${value.pluginId}:${value.version}`, value),
|
||||
remove: async (id, version) => {
|
||||
if (version) packages.delete(`${id}:${version}`);
|
||||
else for (const key of [...packages.keys()]) if (key.startsWith(`${id}:`)) packages.delete(key);
|
||||
},
|
||||
};
|
||||
const host = new EdgeEverPluginHost({ repository, scope: "test", packageStorage });
|
||||
host.installManifest({
|
||||
type: "plugin",
|
||||
id: pluginId,
|
||||
name: "Rollback Test",
|
||||
version: "1.0.0",
|
||||
apiVersion: "1",
|
||||
entry,
|
||||
permissions: ["notes:write", "ui:commands", "ui:notices", "ui:panels", "editor:read", "editor:write", "secrets", "storage"],
|
||||
}, "https://plugins.example/v1/manifest.json");
|
||||
await host.setEnabled(pluginId, true);
|
||||
|
||||
const nextManifest = {
|
||||
type: "plugin",
|
||||
id: pluginId,
|
||||
name: "Rollback Test",
|
||||
version: "2.0.0",
|
||||
apiVersion: "1",
|
||||
entry: "./main.js",
|
||||
permissions: [],
|
||||
};
|
||||
globalThis.window.fetch = async (input) => String(input).endsWith("main.js")
|
||||
? new Response("export default {};", { headers: { "content-type": "text/javascript" } })
|
||||
: Response.json(nextManifest);
|
||||
|
||||
await expect(host.installFromManifestUrl("https://plugins.example/v2/manifest.json", undefined, nextManifest))
|
||||
.rejects.toThrow("activate(context)");
|
||||
|
||||
const restored = host.getSnapshot().extensions.find((extension) => extension.manifest.id === pluginId);
|
||||
expect(restored?.manifest.version).toBe("1.0.0");
|
||||
expect(restored?.enabled).toBe(true);
|
||||
expect(host.getSnapshot().commands.some((command) => command.pluginId === pluginId && command.id === "hello")).toBe(true);
|
||||
expect(packages.has(`${pluginId}:2.0.0`)).toBe(false);
|
||||
await host.dispose();
|
||||
});
|
||||
|
||||
@@ -199,4 +289,118 @@ describe("EdgeEverPluginHost", () => {
|
||||
expect(host.getSnapshot().extensions).toHaveLength(0);
|
||||
await host.dispose();
|
||||
});
|
||||
|
||||
test("routes P1 notebook, note revision, resource, and setting capabilities through the repository", async () => {
|
||||
const resource = {
|
||||
id: "resource-1",
|
||||
memoId: "note-1",
|
||||
originalMemoId: null,
|
||||
kind: "attachment",
|
||||
mimeType: "text/plain",
|
||||
filename: "hello.txt",
|
||||
byteSize: 5,
|
||||
sha256: null,
|
||||
width: null,
|
||||
height: null,
|
||||
createdAt: "2026-09-01T00:00:00.000Z",
|
||||
updatedAt: "2026-09-01T00:00:00.000Z",
|
||||
url: "https://edgeever.example/resource-1",
|
||||
};
|
||||
const repositoryWithCapabilities = {
|
||||
...repository,
|
||||
createNotebook: async (input) => ({ notebook: { id: "notebook-created", parentId: null, name: input.name, memoCount: 0 } }),
|
||||
moveMemos: async () => ({ ok: true, moved: 1 }),
|
||||
pinMemos: async () => ({ ok: true, updated: 1 }),
|
||||
listMemoRevisions: async () => ({ revisions: [{
|
||||
id: "revision-1",
|
||||
memoId: "note-1",
|
||||
revision: 1,
|
||||
title: "First",
|
||||
tags: ["test"],
|
||||
contentMarkdown: "First",
|
||||
contentText: "First",
|
||||
createdAt: "2026-09-01T00:00:00.000Z",
|
||||
}] }),
|
||||
listResources: async () => ({ resources: [{ ...resource, memoTitle: "Note", memoExcerpt: "", memoDeleted: false }], summary: {} }),
|
||||
uploadMemoResource: async () => ({ resource }),
|
||||
};
|
||||
const packageStorage = { get: async () => null, put: async () => undefined, remove: async () => undefined };
|
||||
const host = new EdgeEverPluginHost({ repository: repositoryWithCapabilities, scope: "capabilities", packageStorage });
|
||||
const entry = new URL("./plugin-host-capabilities.fixture.mjs", import.meta.url).href;
|
||||
host.installManifest({
|
||||
type: "plugin",
|
||||
id: "org.edgeever.capabilities",
|
||||
name: "Capabilities",
|
||||
version: "1.0.0",
|
||||
apiVersion: "1",
|
||||
entry,
|
||||
permissions: ["notes:read", "notes:write", "metadata:write", "resources:read", "resources:write", "ui:commands"],
|
||||
settings: { fields: [{ key: "endpoint", type: "text", label: "Endpoint", default: "https://api.example" }] },
|
||||
}, "https://plugins.example/capabilities/manifest.json");
|
||||
|
||||
await host.setEnabled("org.edgeever.capabilities", true);
|
||||
await host.runCommand("org.edgeever.capabilities", "exercise-capabilities");
|
||||
|
||||
expect(globalThis.edgeeverPluginCapabilityResult).toMatchObject({
|
||||
notebook: { id: "notebook-created", name: "Created by plugin" },
|
||||
moved: 1,
|
||||
pinned: 1,
|
||||
revisions: [{ id: "revision-1", noteId: "note-1", contentMarkdown: "First" }],
|
||||
resources: [{ id: "resource-1", noteId: "note-1", filename: "hello.txt" }],
|
||||
uploaded: { id: "resource-1", noteId: "note-1" },
|
||||
endpoint: "https://api.example",
|
||||
});
|
||||
delete globalThis.edgeeverPluginCapabilityResult;
|
||||
await host.dispose();
|
||||
});
|
||||
|
||||
test("delivers successful user and plugin repository mutations through one workspace event stream", async () => {
|
||||
const updatedMemo = {
|
||||
id: "note-events",
|
||||
notebookId: "notebook-1",
|
||||
title: "Updated",
|
||||
excerpt: "Updated",
|
||||
tags: ["events"],
|
||||
isPinned: false,
|
||||
isArchived: false,
|
||||
isDeleted: false,
|
||||
revision: 2,
|
||||
createdAt: "2026-09-01T00:00:00.000Z",
|
||||
updatedAt: "2026-09-01T00:01:00.000Z",
|
||||
deletedAt: null,
|
||||
contentJson: { type: "doc", content: [] },
|
||||
contentMarkdown: "Updated",
|
||||
contentText: "Updated",
|
||||
contentHash: "updated-hash",
|
||||
sourceMemoIds: [],
|
||||
mergeSourceCount: 0,
|
||||
mergedIntoMemoId: null,
|
||||
};
|
||||
const repositoryWithEvents = withRepositoryMutationEvents({
|
||||
...repository,
|
||||
updateMemo: async () => ({ memo: updatedMemo, queued: true }),
|
||||
}, "event-workspace");
|
||||
const packageStorage = { get: async () => null, put: async () => undefined, remove: async () => undefined };
|
||||
const host = new EdgeEverPluginHost({ repository: repositoryWithEvents, scope: "event-workspace", packageStorage });
|
||||
host.installManifest({
|
||||
type: "plugin",
|
||||
id: "org.edgeever.events",
|
||||
name: "Events",
|
||||
version: "1.0.0",
|
||||
apiVersion: "1",
|
||||
entry: new URL("./plugin-host-events.fixture.mjs", import.meta.url).href,
|
||||
permissions: ["notes:read"],
|
||||
}, "https://plugins.example/events/manifest.json");
|
||||
await host.setEnabled("org.edgeever.events", true);
|
||||
await host.activateEnabled();
|
||||
|
||||
await repositoryWithEvents.updateMemo(updatedMemo, {});
|
||||
|
||||
expect(globalThis.edgeeverPluginObservedNote).toMatchObject({
|
||||
id: "note-events",
|
||||
contentMarkdown: "Updated",
|
||||
});
|
||||
delete globalThis.edgeeverPluginObservedNote;
|
||||
await host.dispose();
|
||||
});
|
||||
});
|
||||
@@ -8,9 +8,13 @@ import {
|
||||
type PluginEventMap,
|
||||
type PluginManifest,
|
||||
type PluginNote,
|
||||
type PluginNoteRevision,
|
||||
type PluginNoteSummary,
|
||||
type PluginPanel,
|
||||
type PluginPermission,
|
||||
type PluginResource,
|
||||
type PluginSettingField,
|
||||
type PluginSettingValue,
|
||||
type PluginEditorSelection,
|
||||
type ThemeManifest,
|
||||
type ThemeTokenName,
|
||||
@@ -21,10 +25,12 @@ import type { EdgeEverRepository } from "@/lib/repository";
|
||||
import { WebPluginSecretStore, type PluginSecretStorage } from "@/lib/plugins/plugin-secret-store";
|
||||
import { WebPluginPackageStore, type CachedPluginPackage, type PluginPackageStorage } from "@/lib/plugins/plugin-package-store";
|
||||
import { downloadGithubExtension, extensionManifestsEqual, parseGithubRepositoryUrl, sha256Hex } from "@/lib/plugins/github-plugin-distribution";
|
||||
import { subscribeRepositoryMutations, type RepositoryMutationEvent } from "@/lib/repository-events";
|
||||
|
||||
const INSTALLED_EXTENSIONS_STORAGE_KEY = "edgeever.extensions.installed.v1";
|
||||
const ACTIVE_THEME_STORAGE_KEY = "edgeever.extensions.active-theme.v1";
|
||||
const STORAGE_PREFIX = "edgeever.plugin-data.v1";
|
||||
const SETTINGS_STORAGE_PREFIX = "edgeever.plugin-settings.v1";
|
||||
const RECENT_ACTIONS_STORAGE_PREFIX = "edgeever.extensions.recent-actions.v1";
|
||||
|
||||
const readStorageItem = (key: string) => {
|
||||
@@ -51,6 +57,19 @@ const removeStorageItem = (key: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
const clearStoragePrefix = (prefix: string) => {
|
||||
const keys: string[] = [];
|
||||
try {
|
||||
for (let index = 0; index < window.localStorage.length; index += 1) {
|
||||
const key = window.localStorage.key(index);
|
||||
if (key?.startsWith(prefix)) keys.push(key);
|
||||
}
|
||||
for (const key of keys) window.localStorage.removeItem(key);
|
||||
} catch {
|
||||
throw new Error("Browser storage is unavailable.");
|
||||
}
|
||||
};
|
||||
|
||||
const THEME_TOKEN_CSS_VARIABLES: Record<ThemeTokenName, string> = {
|
||||
"color.background": "--edgeever-theme-background",
|
||||
"color.surface": "--edgeever-theme-surface",
|
||||
@@ -152,6 +171,38 @@ const toPluginNote = (note: Awaited<ReturnType<EdgeEverRepository["getMemo"]>>["
|
||||
contentText: note.contentText,
|
||||
});
|
||||
|
||||
const toPluginNotebook = (notebook: Awaited<ReturnType<EdgeEverRepository["listNotebooks"]>>["notebooks"][number]) => ({
|
||||
id: notebook.id,
|
||||
parentId: notebook.parentId,
|
||||
name: notebook.name,
|
||||
memoCount: notebook.memoCount,
|
||||
});
|
||||
|
||||
const toPluginRevision = (revision: Awaited<ReturnType<EdgeEverRepository["listMemoRevisions"]>>["revisions"][number]): PluginNoteRevision => ({
|
||||
id: revision.id,
|
||||
noteId: revision.memoId,
|
||||
revision: revision.revision,
|
||||
title: revision.title,
|
||||
tags: [...revision.tags],
|
||||
contentMarkdown: revision.contentMarkdown,
|
||||
contentText: revision.contentText,
|
||||
createdAt: revision.createdAt,
|
||||
});
|
||||
|
||||
const toPluginResource = (resource: Awaited<ReturnType<EdgeEverRepository["uploadMemoResource"]>>["resource"]): PluginResource => ({
|
||||
id: resource.id,
|
||||
noteId: resource.memoId,
|
||||
kind: resource.kind,
|
||||
mimeType: resource.mimeType,
|
||||
filename: resource.filename,
|
||||
byteSize: resource.byteSize,
|
||||
width: resource.width,
|
||||
height: resource.height,
|
||||
createdAt: resource.createdAt,
|
||||
updatedAt: resource.updatedAt,
|
||||
url: resource.url,
|
||||
});
|
||||
|
||||
const getErrorMessage = (error: unknown) => error instanceof Error ? error.message : String(error);
|
||||
|
||||
const normalizeInstallSource = (value: unknown): ExtensionInstallSource => {
|
||||
@@ -200,6 +251,13 @@ const assertPermission = (manifest: PluginManifest, permission: PluginPermission
|
||||
}
|
||||
};
|
||||
|
||||
const EVENT_PERMISSIONS: Partial<Record<keyof PluginEventMap, PluginPermission>> = {
|
||||
"note.created": "notes:read",
|
||||
"note.updated": "notes:read",
|
||||
"note.deleted": "notes:read",
|
||||
"tag.changed": "metadata:read",
|
||||
};
|
||||
|
||||
const isAllowedNetworkHost = (hostname: string, allowedHosts: string[]) =>
|
||||
allowedHosts.some((allowedHost) => {
|
||||
const normalized = allowedHost.trim().toLocaleLowerCase();
|
||||
@@ -218,6 +276,34 @@ const assertConfirmedManifest = (confirmedManifest: ExtensionManifest | undefine
|
||||
}
|
||||
};
|
||||
|
||||
const requireSettingField = (manifest: PluginManifest, key: string) => {
|
||||
const field = manifest.settings?.fields.find((candidate) => candidate.key === key);
|
||||
if (!field) throw new Error(`${manifest.name} has not declared the ${key} setting.`);
|
||||
return field;
|
||||
};
|
||||
|
||||
const validateSettingValue = (field: PluginSettingField, value: PluginSettingValue) => {
|
||||
if (field.type === "text" || field.type === "secret") {
|
||||
if (typeof value !== "string") throw new Error(`${field.label} must be a string.`);
|
||||
if (field.required && !value.trim()) throw new Error(`${field.label} is required.`);
|
||||
return value;
|
||||
}
|
||||
if (field.type === "number") {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) throw new Error(`${field.label} must be a finite number.`);
|
||||
if (field.min !== undefined && value < field.min) throw new Error(`${field.label} must be at least ${field.min}.`);
|
||||
if (field.max !== undefined && value > field.max) throw new Error(`${field.label} must be at most ${field.max}.`);
|
||||
return value;
|
||||
}
|
||||
if (field.type === "boolean") {
|
||||
if (typeof value !== "boolean") throw new Error(`${field.label} must be true or false.`);
|
||||
return value;
|
||||
}
|
||||
if (typeof value !== "string" || !field.options.some((option) => option.value === value)) {
|
||||
throw new Error(`${field.label} must match an available option.`);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
export class EdgeEverPluginHost {
|
||||
private readonly repository: EdgeEverRepository;
|
||||
private readonly scope: string;
|
||||
@@ -237,6 +323,7 @@ export class EdgeEverPluginHost {
|
||||
private recentActions: RegisteredPluginAction[];
|
||||
private editorAdapter: PluginEditorAdapter | null = null;
|
||||
private themeObserver: MutationObserver | null = null;
|
||||
private repositoryEventDisposer: (() => void) | null = null;
|
||||
private started = false;
|
||||
|
||||
constructor(options: PluginHostOptions) {
|
||||
@@ -282,17 +369,12 @@ export class EdgeEverPluginHost {
|
||||
const downloaded = await downloadGithubExtension(input);
|
||||
assertConfirmedManifest(confirmedManifest, downloaded.manifest);
|
||||
if (marketplaceEntry) this.assertMarketplaceDownload(marketplaceEntry, downloaded.manifest, downloaded.checksums);
|
||||
if (downloaded.pluginPackage) await this.packageStorage.put(downloaded.pluginPackage);
|
||||
const wasActive = this.activePlugins.has(downloaded.manifest.id);
|
||||
if (wasActive) await this.deactivatePlugin(downloaded.manifest.id);
|
||||
const installed = this.installManifest(downloaded.manifest, downloaded.manifestUrl, {
|
||||
return this.replaceInstalledExtension(downloaded.manifest, downloaded.manifestUrl, {
|
||||
kind: marketplaceEntry ? "marketplace" : "github",
|
||||
verified: Boolean(marketplaceEntry),
|
||||
repositoryUrl: downloaded.repositoryUrl,
|
||||
...(downloaded.releaseTag ? { releaseTag: downloaded.releaseTag } : {}),
|
||||
});
|
||||
if (installed.enabled && installed.manifest.type === "plugin") await this.activatePlugin(installed.manifest.id);
|
||||
return installed;
|
||||
}, downloaded.pluginPackage);
|
||||
}
|
||||
|
||||
async installMarketplaceEntry(entry: MarketplaceEntry, confirmedManifest?: ExtensionManifest) {
|
||||
@@ -340,16 +422,41 @@ export class EdgeEverPluginHost {
|
||||
}
|
||||
const actualChecksums = pluginPackage?.checksums ?? { manifestJson: await sha256Hex(manifestText) };
|
||||
if (marketplaceEntry) this.assertMarketplaceDownload(marketplaceEntry, manifest, actualChecksums);
|
||||
if (pluginPackage) await this.packageStorage.put(pluginPackage);
|
||||
const wasActive = this.activePlugins.has(manifest.id);
|
||||
if (wasActive) await this.deactivatePlugin(manifest.id);
|
||||
const installed = this.installManifest(manifest, manifestUrl.href, {
|
||||
return this.replaceInstalledExtension(manifest, manifestUrl.href, {
|
||||
kind: marketplaceEntry ? "marketplace" : "manifest",
|
||||
verified: Boolean(marketplaceEntry),
|
||||
repositoryUrl: marketplaceEntry?.repositoryUrl,
|
||||
});
|
||||
if (installed.enabled && installed.manifest.type === "plugin") await this.activatePlugin(installed.manifest.id);
|
||||
return installed;
|
||||
}, pluginPackage);
|
||||
}
|
||||
|
||||
private async replaceInstalledExtension(
|
||||
manifest: ExtensionManifest,
|
||||
manifestUrl: string,
|
||||
source: ExtensionInstallSource,
|
||||
pluginPackage: CachedPluginPackage | null,
|
||||
) {
|
||||
const previous = this.extensions.find((item) => item.manifest.id === manifest.id);
|
||||
const wasActive = this.activePlugins.has(manifest.id);
|
||||
if (pluginPackage) await this.packageStorage.put(pluginPackage);
|
||||
if (wasActive) await this.deactivatePlugin(manifest.id);
|
||||
const installed = this.installManifest(manifest, manifestUrl, source);
|
||||
try {
|
||||
if (installed.enabled && installed.manifest.type === "plugin") await this.activatePlugin(installed.manifest.id);
|
||||
return installed;
|
||||
} catch (error) {
|
||||
if (previous) {
|
||||
this.extensions = [...this.extensions.filter((item) => item.manifest.id !== previous.manifest.id), previous]
|
||||
.sort((left, right) => left.manifest.name.localeCompare(right.manifest.name));
|
||||
this.persist();
|
||||
if (previous.enabled && previous.manifest.type === "plugin") {
|
||||
await this.activatePlugin(previous.manifest.id).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
if (pluginPackage && previous?.manifest.version !== pluginPackage.version) {
|
||||
await this.packageStorage.remove(pluginPackage.pluginId, pluginPackage.version).catch(() => undefined);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
installManifest(manifest: ExtensionManifest, manifestUrl: string, source: ExtensionInstallSource = { kind: "manifest", verified: false }) {
|
||||
@@ -413,7 +520,56 @@ export class EdgeEverPluginHost {
|
||||
this.applyActiveTheme();
|
||||
}
|
||||
this.persist();
|
||||
await this.packageStorage.remove(extensionId).catch(() => undefined);
|
||||
await Promise.all([
|
||||
this.packageStorage.remove(extensionId),
|
||||
this.secretStorage.clearNamespace(`${this.scope}:${extensionId}`),
|
||||
]);
|
||||
clearStoragePrefix(`${STORAGE_PREFIX}:${this.scope}:${extensionId}:`);
|
||||
clearStoragePrefix(`${SETTINGS_STORAGE_PREFIX}:${this.scope}:${extensionId}:`);
|
||||
}
|
||||
|
||||
async getSettingValue(extensionId: string, key: string, revealSecret = false): Promise<PluginSettingValue | null> {
|
||||
const extension = this.requireExtension(extensionId);
|
||||
if (extension.manifest.type !== "plugin") throw new Error("Themes do not declare plugin settings.");
|
||||
const field = requireSettingField(extension.manifest, key);
|
||||
if (field.type === "secret") {
|
||||
if (!revealSecret) return null;
|
||||
return this.secretStorage.get(`${this.scope}:${extensionId}`, `setting:${key}`);
|
||||
}
|
||||
const stored = readStorageItem(`${SETTINGS_STORAGE_PREFIX}:${this.scope}:${extensionId}:${key}`);
|
||||
if (stored !== null) return validateSettingValue(field, JSON.parse(stored) as PluginSettingValue);
|
||||
return field.default ?? null;
|
||||
}
|
||||
|
||||
async hasSettingValue(extensionId: string, key: string) {
|
||||
const extension = this.requireExtension(extensionId);
|
||||
if (extension.manifest.type !== "plugin") return false;
|
||||
const field = requireSettingField(extension.manifest, key);
|
||||
if (field.type === "secret") return (await this.secretStorage.get(`${this.scope}:${extensionId}`, `setting:${key}`)) !== null;
|
||||
return readStorageItem(`${SETTINGS_STORAGE_PREFIX}:${this.scope}:${extensionId}:${key}`) !== null;
|
||||
}
|
||||
|
||||
async setSettingValue(extensionId: string, key: string, value: PluginSettingValue) {
|
||||
const extension = this.requireExtension(extensionId);
|
||||
if (extension.manifest.type !== "plugin") throw new Error("Themes do not declare plugin settings.");
|
||||
const field = requireSettingField(extension.manifest, key);
|
||||
const normalized = validateSettingValue(field, value);
|
||||
if (field.type === "secret") {
|
||||
await this.secretStorage.set(`${this.scope}:${extensionId}`, `setting:${key}`, String(normalized));
|
||||
return;
|
||||
}
|
||||
writeStorageItem(`${SETTINGS_STORAGE_PREFIX}:${this.scope}:${extensionId}:${key}`, JSON.stringify(normalized));
|
||||
}
|
||||
|
||||
async removeSettingValue(extensionId: string, key: string) {
|
||||
const extension = this.requireExtension(extensionId);
|
||||
if (extension.manifest.type !== "plugin") throw new Error("Themes do not declare plugin settings.");
|
||||
const field = requireSettingField(extension.manifest, key);
|
||||
if (field.type === "secret") {
|
||||
await this.secretStorage.remove(`${this.scope}:${extensionId}`, `setting:${key}`);
|
||||
return;
|
||||
}
|
||||
removeStorageItem(`${SETTINGS_STORAGE_PREFIX}:${this.scope}:${extensionId}:${key}`);
|
||||
}
|
||||
|
||||
async runCommand(pluginId: string, commandId: string) {
|
||||
@@ -452,6 +608,8 @@ export class EdgeEverPluginHost {
|
||||
window.removeEventListener("edgeever:sync-queue-changed", this.handleSyncQueueChanged);
|
||||
this.themeObserver?.disconnect();
|
||||
this.themeObserver = null;
|
||||
this.repositoryEventDisposer?.();
|
||||
this.repositoryEventDisposer = null;
|
||||
for (const pluginId of [...this.activePlugins.keys()]) await this.deactivatePlugin(pluginId);
|
||||
}
|
||||
|
||||
@@ -461,6 +619,7 @@ export class EdgeEverPluginHost {
|
||||
this.themeObserver = new MutationObserver(() => this.applyActiveTheme());
|
||||
this.themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });
|
||||
window.addEventListener("edgeever:sync-queue-changed", this.handleSyncQueueChanged);
|
||||
this.repositoryEventDisposer = subscribeRepositoryMutations(this.scope, this.handleRepositoryMutation);
|
||||
this.applyActiveTheme();
|
||||
}
|
||||
|
||||
@@ -468,6 +627,18 @@ export class EdgeEverPluginHost {
|
||||
this.emit("workspace.sync-queue-changed", {});
|
||||
};
|
||||
|
||||
private readonly handleRepositoryMutation = (event: RepositoryMutationEvent) => {
|
||||
if (event.type === "note.created") return this.emit(event.type, { note: toPluginNote(event.note) });
|
||||
if (event.type === "note.updated") return this.emit(event.type, { note: toPluginNote(event.note) });
|
||||
if (event.type === "note.deleted") return this.emit(event.type, { noteId: event.noteId });
|
||||
if (event.type === "tag.changed") return this.emit(event.type, {
|
||||
...(event.previousName ? { previousName: event.previousName } : {}),
|
||||
...(event.name ? { name: event.name } : {}),
|
||||
...(event.deleted ? { deleted: true } : {}),
|
||||
});
|
||||
this.emit("workspace.synced", { bootstrapped: event.bootstrapped, changed: event.changed });
|
||||
};
|
||||
|
||||
private requireExtension(extensionId: string) {
|
||||
const extension = this.extensions.find((item) => item.manifest.id === extensionId);
|
||||
if (!extension) throw new Error("Extension is not installed.");
|
||||
@@ -534,7 +705,7 @@ export class EdgeEverPluginHost {
|
||||
for (const dispose of active.disposers.reverse()) {
|
||||
try { dispose(); } catch { /* A broken disposer must not strand the plugin. */ }
|
||||
}
|
||||
await active.plugin.deactivate?.();
|
||||
try { await active.plugin.deactivate?.(); } catch { /* Cleanup must continue after a broken deactivate hook. */ }
|
||||
for (const key of [...this.commands.keys()]) {
|
||||
if (key.startsWith(`${pluginId}:`)) this.commands.delete(key);
|
||||
}
|
||||
@@ -578,12 +749,12 @@ export class EdgeEverPluginHost {
|
||||
create: async (input) => {
|
||||
assertPermission(manifest, "notes:write");
|
||||
const note = toPluginNote((await this.repository.createMemo(input)).memo);
|
||||
this.emit("note.created", { note });
|
||||
await this.onWorkspaceChanged?.();
|
||||
return note;
|
||||
},
|
||||
update: async (noteId, input) => {
|
||||
assertPermission(manifest, "notes:write");
|
||||
assertPermission(manifest, "notes:read");
|
||||
const current = (await this.repository.getMemo(noteId)).memo;
|
||||
const contentMarkdown = input.contentMarkdown ?? current.contentMarkdown;
|
||||
const updated = (await this.repository.updateMemo(current, {
|
||||
@@ -596,22 +767,70 @@ export class EdgeEverPluginHost {
|
||||
tags: input.tags ?? current.tags,
|
||||
})).memo;
|
||||
const note = toPluginNote(updated);
|
||||
this.emit("note.updated", { note });
|
||||
await this.onWorkspaceChanged?.();
|
||||
return note;
|
||||
},
|
||||
delete: async (noteId, options) => {
|
||||
assertPermission(manifest, "notes:delete");
|
||||
await this.repository.deleteMemo(noteId, Boolean(options?.permanent));
|
||||
this.emit("note.deleted", { noteId });
|
||||
await this.onWorkspaceChanged?.();
|
||||
},
|
||||
move: async (noteIds, notebookId) => {
|
||||
assertPermission(manifest, "notes:write");
|
||||
const { moved } = await this.repository.moveMemos({ memoIds: noteIds, notebookId });
|
||||
await this.onWorkspaceChanged?.();
|
||||
return moved;
|
||||
},
|
||||
pin: async (noteIds, isPinned) => {
|
||||
assertPermission(manifest, "notes:write");
|
||||
const { updated } = await this.repository.pinMemos({ memoIds: noteIds, isPinned });
|
||||
await this.onWorkspaceChanged?.();
|
||||
return updated;
|
||||
},
|
||||
restore: async (noteId) => {
|
||||
assertPermission(manifest, "notes:write");
|
||||
assertPermission(manifest, "notes:read");
|
||||
const note = toPluginNote((await this.repository.restoreMemo(noteId)).memo);
|
||||
await this.onWorkspaceChanged?.();
|
||||
return note;
|
||||
},
|
||||
revisions: {
|
||||
list: async (noteId) => {
|
||||
assertPermission(manifest, "notes:read");
|
||||
return (await this.repository.listMemoRevisions(noteId)).revisions.map(toPluginRevision);
|
||||
},
|
||||
restore: async (noteId, revisionId) => {
|
||||
assertPermission(manifest, "notes:read");
|
||||
assertPermission(manifest, "notes:write");
|
||||
const note = toPluginNote((await this.repository.restoreMemoRevision(noteId, revisionId)).memo);
|
||||
await this.onWorkspaceChanged?.();
|
||||
return note;
|
||||
},
|
||||
},
|
||||
},
|
||||
notebooks: {
|
||||
list: async () => {
|
||||
assertPermission(manifest, "metadata:read");
|
||||
const { notebooks } = await this.repository.listNotebooks();
|
||||
return notebooks.map(({ id, parentId, name, memoCount }) => ({ id, parentId, name, memoCount }));
|
||||
return notebooks.map(toPluginNotebook);
|
||||
},
|
||||
create: async (input) => {
|
||||
assertPermission(manifest, "metadata:write");
|
||||
const notebook = toPluginNotebook((await this.repository.createNotebook(input)).notebook);
|
||||
await this.onWorkspaceChanged?.();
|
||||
return notebook;
|
||||
},
|
||||
update: async (notebookId, input) => {
|
||||
assertPermission(manifest, "metadata:write");
|
||||
assertPermission(manifest, "metadata:read");
|
||||
const notebook = toPluginNotebook((await this.repository.updateNotebook(notebookId, input)).notebook);
|
||||
await this.onWorkspaceChanged?.();
|
||||
return notebook;
|
||||
},
|
||||
delete: async (notebookId) => {
|
||||
assertPermission(manifest, "metadata:write");
|
||||
await this.repository.deleteNotebook(notebookId);
|
||||
await this.onWorkspaceChanged?.();
|
||||
},
|
||||
},
|
||||
tags: {
|
||||
@@ -623,14 +842,12 @@ export class EdgeEverPluginHost {
|
||||
rename: async (name, nextName) => {
|
||||
assertPermission(manifest, "metadata:write");
|
||||
const { updated } = await this.repository.renameTag(name, nextName);
|
||||
this.emit("tag.changed", { previousName: name, name: nextName });
|
||||
await this.onWorkspaceChanged?.();
|
||||
return updated;
|
||||
},
|
||||
delete: async (name) => {
|
||||
assertPermission(manifest, "metadata:write");
|
||||
const { updated } = await this.repository.deleteTag(name);
|
||||
this.emit("tag.changed", { previousName: name, deleted: true });
|
||||
await this.onWorkspaceChanged?.();
|
||||
return updated;
|
||||
},
|
||||
@@ -653,6 +870,8 @@ export class EdgeEverPluginHost {
|
||||
},
|
||||
events: {
|
||||
on: (event, listener) => {
|
||||
const permission = EVENT_PERMISSIONS[event];
|
||||
if (permission) assertPermission(manifest, permission);
|
||||
const listeners = this.eventListeners.get(event) ?? new Set();
|
||||
listeners.add(listener as (payload: never) => void);
|
||||
this.eventListeners.set(event, listeners);
|
||||
@@ -706,6 +925,31 @@ export class EdgeEverPluginHost {
|
||||
this.editorAdapter.insertAtCursor(contentMarkdown);
|
||||
},
|
||||
},
|
||||
resources: {
|
||||
list: async (noteId) => {
|
||||
assertPermission(manifest, "resources:read");
|
||||
const { resources } = await this.repository.listResources();
|
||||
return resources.filter((resource) => !noteId || resource.memoId === noteId).map(toPluginResource);
|
||||
},
|
||||
upload: async (noteId, file) => {
|
||||
assertPermission(manifest, "resources:write");
|
||||
return toPluginResource((await this.repository.uploadMemoResource(noteId, file)).resource);
|
||||
},
|
||||
rename: async (resourceId, filename) => {
|
||||
assertPermission(manifest, "resources:write");
|
||||
assertPermission(manifest, "resources:read");
|
||||
return toPluginResource((await this.repository.renameResource(resourceId, filename)).resource);
|
||||
},
|
||||
delete: async (resourceId) => {
|
||||
assertPermission(manifest, "resources:write");
|
||||
await this.repository.deleteResource(resourceId);
|
||||
},
|
||||
},
|
||||
settings: {
|
||||
get: (key) => this.getSettingValue(manifest.id, key, true),
|
||||
set: (key, value) => this.setSettingValue(manifest.id, key, value),
|
||||
remove: (key) => this.removeSettingValue(manifest.id, key),
|
||||
},
|
||||
network: {
|
||||
fetch: async (input, init) => {
|
||||
assertPermission(manifest, "network");
|
||||
@@ -760,7 +1004,14 @@ export class EdgeEverPluginHost {
|
||||
|
||||
private emit<K extends keyof PluginEventMap>(event: K, payload: PluginEventMap[K]) {
|
||||
for (const listener of this.eventListeners.get(event) ?? []) {
|
||||
try { listener(payload as never); } catch (error) { console.error(`Plugin event listener failed for ${event}`, error); }
|
||||
try {
|
||||
const result = (listener as (value: never) => unknown)(payload as never);
|
||||
if (result && typeof (result as PromiseLike<unknown>).then === "function") {
|
||||
void Promise.resolve(result).catch((error) => console.error(`Plugin event listener failed for ${event}`, error));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Plugin event listener failed for ${event}`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
const DATABASE_NAME = "edgeever-plugin-packages-v1";
|
||||
const DATABASE_VERSION = 1;
|
||||
const PACKAGE_STORE = "packages";
|
||||
const DATABASE_VERSION = 2;
|
||||
const LEGACY_PACKAGE_STORE = "packages";
|
||||
const PACKAGE_STORE = "packageVersions";
|
||||
const PLUGIN_ID_INDEX = "pluginId";
|
||||
|
||||
export interface CachedPluginPackage {
|
||||
pluginId: string;
|
||||
@@ -18,7 +20,7 @@ export interface CachedPluginPackage {
|
||||
export interface PluginPackageStorage {
|
||||
get(pluginId: string, version: string): Promise<CachedPluginPackage | null>;
|
||||
put(value: CachedPluginPackage): Promise<void>;
|
||||
remove(pluginId: string): Promise<void>;
|
||||
remove(pluginId: string, version?: string): Promise<void>;
|
||||
}
|
||||
|
||||
const requestResult = <T>(request: IDBRequest<T>) => new Promise<T>((resolve, reject) => {
|
||||
@@ -36,7 +38,8 @@ const openDatabase = () => new Promise<IDBDatabase>((resolve, reject) => {
|
||||
const request = indexedDB.open(DATABASE_NAME, DATABASE_VERSION);
|
||||
request.addEventListener("upgradeneeded", () => {
|
||||
if (!request.result.objectStoreNames.contains(PACKAGE_STORE)) {
|
||||
request.result.createObjectStore(PACKAGE_STORE, { keyPath: "pluginId" });
|
||||
const store = request.result.createObjectStore(PACKAGE_STORE, { keyPath: ["pluginId", "version"] });
|
||||
store.createIndex(PLUGIN_ID_INDEX, "pluginId");
|
||||
}
|
||||
});
|
||||
request.addEventListener("success", () => resolve(request.result), { once: true });
|
||||
@@ -54,9 +57,16 @@ export class WebPluginPackageStore implements PluginPackageStorage {
|
||||
async get(pluginId: string, version: string) {
|
||||
const database = await this.database();
|
||||
const transaction = database.transaction(PACKAGE_STORE, "readonly");
|
||||
const value = await requestResult(transaction.objectStore(PACKAGE_STORE).get(pluginId)) as CachedPluginPackage | undefined;
|
||||
const value = await requestResult(transaction.objectStore(PACKAGE_STORE).get([pluginId, version])) as CachedPluginPackage | undefined;
|
||||
await transactionDone(transaction);
|
||||
return value?.version === version ? value : null;
|
||||
if (value) return value;
|
||||
if (!database.objectStoreNames.contains(LEGACY_PACKAGE_STORE)) return null;
|
||||
const legacyTransaction = database.transaction(LEGACY_PACKAGE_STORE, "readonly");
|
||||
const legacy = await requestResult(legacyTransaction.objectStore(LEGACY_PACKAGE_STORE).get(pluginId)) as CachedPluginPackage | undefined;
|
||||
await transactionDone(legacyTransaction);
|
||||
if (legacy?.version !== version) return null;
|
||||
await this.put(legacy);
|
||||
return legacy;
|
||||
}
|
||||
|
||||
async put(value: CachedPluginPackage) {
|
||||
@@ -66,10 +76,31 @@ export class WebPluginPackageStore implements PluginPackageStorage {
|
||||
await transactionDone(transaction);
|
||||
}
|
||||
|
||||
async remove(pluginId: string) {
|
||||
async remove(pluginId: string, version?: string) {
|
||||
const database = await this.database();
|
||||
const transaction = database.transaction(PACKAGE_STORE, "readwrite");
|
||||
transaction.objectStore(PACKAGE_STORE).delete(pluginId);
|
||||
const store = transaction.objectStore(PACKAGE_STORE);
|
||||
if (version) {
|
||||
store.delete([pluginId, version]);
|
||||
} else {
|
||||
const request = store.index(PLUGIN_ID_INDEX).openCursor(IDBKeyRange.only(pluginId));
|
||||
request.addEventListener("success", () => {
|
||||
const cursor = request.result;
|
||||
if (!cursor) return;
|
||||
cursor.delete();
|
||||
cursor.continue();
|
||||
});
|
||||
}
|
||||
await transactionDone(transaction);
|
||||
if (!database.objectStoreNames.contains(LEGACY_PACKAGE_STORE)) return;
|
||||
const legacyTransaction = database.transaction(LEGACY_PACKAGE_STORE, "readwrite");
|
||||
const legacyStore = legacyTransaction.objectStore(LEGACY_PACKAGE_STORE);
|
||||
if (!version) {
|
||||
legacyStore.delete(pluginId);
|
||||
} else {
|
||||
const legacy = await requestResult(legacyStore.get(pluginId)) as CachedPluginPackage | undefined;
|
||||
if (legacy?.version === version) legacyStore.delete(pluginId);
|
||||
}
|
||||
await transactionDone(legacyTransaction);
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,7 @@ export interface PluginSecretStorage {
|
||||
get(pluginId: string, key: string): Promise<string | null>;
|
||||
set(pluginId: string, key: string, value: string): Promise<void>;
|
||||
remove(pluginId: string, key: string): Promise<void>;
|
||||
clearNamespace(pluginId: string): Promise<void>;
|
||||
}
|
||||
|
||||
export class WebPluginSecretStore implements PluginSecretStorage {
|
||||
@@ -103,4 +104,19 @@ export class WebPluginSecretStore implements PluginSecretStorage {
|
||||
transaction.objectStore(SECRET_STORE).delete(this.recordId(pluginId, key));
|
||||
await transactionDone(transaction);
|
||||
}
|
||||
|
||||
async clearNamespace(pluginId: string) {
|
||||
const database = await this.database();
|
||||
const transaction = database.transaction(SECRET_STORE, "readwrite");
|
||||
const store = transaction.objectStore(SECRET_STORE);
|
||||
const request = store.openCursor();
|
||||
request.addEventListener("success", () => {
|
||||
const cursor = request.result;
|
||||
if (!cursor) return;
|
||||
const record = cursor.value as EncryptedSecret;
|
||||
if (record.id.startsWith(`${pluginId}:`)) cursor.delete();
|
||||
cursor.continue();
|
||||
});
|
||||
await transactionDone(transaction);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { subscribeRepositoryMutations, withRepositoryMutationEvents } from "./repository-events.ts";
|
||||
|
||||
const memo = {
|
||||
id: "note-1",
|
||||
notebookId: "notebook-1",
|
||||
title: "Note",
|
||||
excerpt: "Excerpt",
|
||||
tags: [],
|
||||
isPinned: false,
|
||||
isArchived: false,
|
||||
isDeleted: false,
|
||||
revision: 1,
|
||||
createdAt: "2026-09-01T00:00:00.000Z",
|
||||
updatedAt: "2026-09-01T00:00:00.000Z",
|
||||
deletedAt: null,
|
||||
contentJson: { type: "doc", content: [] },
|
||||
contentMarkdown: "Note",
|
||||
contentText: "Note",
|
||||
contentHash: "hash",
|
||||
sourceMemoIds: [],
|
||||
mergeSourceCount: 0,
|
||||
mergedIntoMemoId: null,
|
||||
};
|
||||
|
||||
describe("repository mutation events", () => {
|
||||
test("publishes successful host mutations once within the matching workspace", async () => {
|
||||
const base = {
|
||||
createMemo: async () => ({ memo }),
|
||||
updateMemo: async () => ({ memo: { ...memo, revision: 2 }, queued: true }),
|
||||
deleteMemo: async () => ({ ok: true }),
|
||||
renameTag: async () => ({ ok: true, updated: 1 }),
|
||||
deleteTag: async () => ({ ok: true, updated: 1 }),
|
||||
sync: async () => ({ bootstrapped: false, changed: 3 }),
|
||||
};
|
||||
const repository = withRepositoryMutationEvents(base, "workspace-a");
|
||||
const received = [];
|
||||
const otherWorkspace = [];
|
||||
const dispose = subscribeRepositoryMutations("workspace-a", (event) => received.push(event));
|
||||
const disposeOther = subscribeRepositoryMutations("workspace-b", (event) => otherWorkspace.push(event));
|
||||
|
||||
await repository.createMemo({ notebookId: "notebook-1" });
|
||||
await repository.updateMemo(memo, {});
|
||||
await repository.deleteMemo(memo.id);
|
||||
await repository.renameTag("old", "new");
|
||||
await repository.deleteTag("new");
|
||||
await repository.sync();
|
||||
|
||||
expect(received.map((event) => event.type)).toEqual([
|
||||
"note.created",
|
||||
"note.updated",
|
||||
"note.deleted",
|
||||
"tag.changed",
|
||||
"tag.changed",
|
||||
"workspace.synced",
|
||||
]);
|
||||
expect(otherWorkspace).toEqual([]);
|
||||
dispose();
|
||||
disposeOther();
|
||||
});
|
||||
|
||||
test("does not publish failed mutations", async () => {
|
||||
const repository = withRepositoryMutationEvents({
|
||||
createMemo: async () => { throw new Error("failed"); },
|
||||
}, "workspace-failed");
|
||||
const received = [];
|
||||
const dispose = subscribeRepositoryMutations("workspace-failed", (event) => received.push(event));
|
||||
|
||||
await expect(repository.createMemo({ notebookId: "notebook-1" })).rejects.toThrow("failed");
|
||||
expect(received).toEqual([]);
|
||||
dispose();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import type { MemoDetail } from "@edgeever/shared";
|
||||
import type { EdgeEverRepository } from "@/lib/repository";
|
||||
|
||||
export type RepositoryMutationEvent =
|
||||
| { type: "note.created"; note: MemoDetail }
|
||||
| { type: "note.updated"; note: MemoDetail }
|
||||
| { type: "note.deleted"; noteId: string }
|
||||
| { type: "tag.changed"; previousName?: string; name?: string; deleted?: boolean }
|
||||
| { type: "workspace.synced"; bootstrapped: boolean; changed: number };
|
||||
|
||||
type RepositoryMutationListener = (event: RepositoryMutationEvent) => void;
|
||||
|
||||
const listenersByScope = new Map<string, Set<RepositoryMutationListener>>();
|
||||
|
||||
export const notifyRepositoryMutation = (scope: string, event: RepositoryMutationEvent) => {
|
||||
for (const listener of listenersByScope.get(scope) ?? []) listener(event);
|
||||
};
|
||||
|
||||
export const subscribeRepositoryMutations = (scope: string, listener: RepositoryMutationListener) => {
|
||||
const listeners = listenersByScope.get(scope) ?? new Set<RepositoryMutationListener>();
|
||||
listeners.add(listener);
|
||||
listenersByScope.set(scope, listeners);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
if (listeners.size === 0) listenersByScope.delete(scope);
|
||||
};
|
||||
};
|
||||
|
||||
export const withRepositoryMutationEvents = (repository: EdgeEverRepository, scope: string): EdgeEverRepository => ({
|
||||
...repository,
|
||||
async createMemo(input) {
|
||||
const result = await repository.createMemo(input);
|
||||
notifyRepositoryMutation(scope, { type: "note.created", note: result.memo });
|
||||
return result;
|
||||
},
|
||||
async updateMemo(memo, input) {
|
||||
const result = await repository.updateMemo(memo, input);
|
||||
notifyRepositoryMutation(scope, { type: "note.updated", note: result.memo });
|
||||
return result;
|
||||
},
|
||||
async deleteMemo(noteId, permanent) {
|
||||
const result = await repository.deleteMemo(noteId, permanent);
|
||||
notifyRepositoryMutation(scope, { type: "note.deleted", noteId });
|
||||
return result;
|
||||
},
|
||||
async restoreMemo(noteId) {
|
||||
const result = await repository.restoreMemo(noteId);
|
||||
notifyRepositoryMutation(scope, { type: "note.updated", note: result.memo });
|
||||
return result;
|
||||
},
|
||||
async restoreMemoRevision(noteId, revisionId) {
|
||||
const result = await repository.restoreMemoRevision(noteId, revisionId);
|
||||
notifyRepositoryMutation(scope, { type: "note.updated", note: result.memo });
|
||||
return result;
|
||||
},
|
||||
async useTemplate(templateId, notebookId) {
|
||||
const result = await repository.useTemplate(templateId, notebookId);
|
||||
notifyRepositoryMutation(scope, { type: "note.created", note: result.memo });
|
||||
return result;
|
||||
},
|
||||
async mergeMemos(input) {
|
||||
const result = await repository.mergeMemos(input);
|
||||
notifyRepositoryMutation(scope, { type: "note.created", note: result.memo });
|
||||
return result;
|
||||
},
|
||||
async renameTag(previousName, name) {
|
||||
const result = await repository.renameTag(previousName, name);
|
||||
notifyRepositoryMutation(scope, { type: "tag.changed", previousName, name });
|
||||
return result;
|
||||
},
|
||||
async deleteTag(previousName) {
|
||||
const result = await repository.deleteTag(previousName);
|
||||
notifyRepositoryMutation(scope, { type: "tag.changed", previousName, deleted: true });
|
||||
return result;
|
||||
},
|
||||
async sync() {
|
||||
const result = await repository.sync();
|
||||
notifyRepositoryMutation(scope, { type: "workspace.synced", ...result });
|
||||
return result;
|
||||
},
|
||||
});
|
||||
@@ -46,6 +46,7 @@ import { createDesktopRepository } from "@/lib/desktop-repository";
|
||||
import { isBrowserOffline } from "@/lib/network-status";
|
||||
import { notifySyncQueueDeferred } from "@/lib/sync-events";
|
||||
import { isActiveLocalMemoUpdateStatus, shouldAcceptRemoteMemoDetail } from "@/lib/memo-detail-freshness";
|
||||
import { withRepositoryMutationEvents } from "@/lib/repository-events";
|
||||
|
||||
export type EdgeEverRepository = {
|
||||
listNotebooks(): Promise<{ notebooks: Notebook[] }>;
|
||||
@@ -549,7 +550,7 @@ export const createWebRepository = (scope: string): EdgeEverRepository => {
|
||||
|
||||
export const createRepository = (scope: string): EdgeEverRepository => {
|
||||
if (typeof window !== "undefined" && window.edgeeverDesktop?.isAvailable) {
|
||||
return createDesktopRepository();
|
||||
return withRepositoryMutationEvents(createDesktopRepository(), scope);
|
||||
}
|
||||
return createWebRepository(scope);
|
||||
return withRepositoryMutationEvents(createWebRepository(scope), scope);
|
||||
};
|
||||
@@ -44,6 +44,8 @@ GitHub plugins must use `./main.js` as `entry`, and `main.js` must be a single-f
|
||||
|
||||
EdgeEver checks for updates when the Plugin Marketplace opens, when the window regains focus, and every 30 minutes, but never installs silently. The user must click Update and confirm. If a new version adds plugin permissions or network hosts, the confirmation lists the additional access. For GitHub distribution, the Release `manifest.json` must exactly match the default-branch manifest used for the update prompt or installation is rejected. Marketplace installs only follow newer versions verified in the Registry.
|
||||
|
||||
Updates use a rollback-capable switch. Old and new package versions are cached separately. If the new version cannot activate, EdgeEver restores the previous manifest, enabled state, and package instead of leaving the plugin broken or disabled.
|
||||
|
||||
Users can freely install without marketplace admission by pasting this into the standalone Plugin Marketplace page:
|
||||
|
||||
```text
|
||||
@@ -84,7 +86,7 @@ Registry format:
|
||||
}
|
||||
```
|
||||
|
||||
Marketplace installs display a Verified badge. GitHub and manifest sideloads clearly display their unverified source, but EdgeEver does not block them. Uninstalling also deletes the device-local cached package.
|
||||
Marketplace installs display a Verified badge. GitHub and manifest sideloads clearly display their unverified source, but EdgeEver does not block them. Uninstalling also deletes all device-local cached package versions, ordinary plugin storage for the current workspace, and Secret Storage.
|
||||
|
||||
Supported permissions:
|
||||
|
||||
@@ -93,6 +95,8 @@ Supported permissions:
|
||||
- `notes:delete`
|
||||
- `metadata:read`
|
||||
- `metadata:write`
|
||||
- `resources:read`
|
||||
- `resources:write`
|
||||
- `network`
|
||||
- `storage`
|
||||
- `secrets`
|
||||
@@ -138,6 +142,23 @@ export default definePlugin({
|
||||
|
||||
Every registration returns a disposer. The host also disposes registered commands and events automatically when a plugin is disabled.
|
||||
|
||||
### SDK package
|
||||
|
||||
`@edgeever/plugin-api` is a publish-ready ESM package with generated JavaScript and TypeScript declarations. In the EdgeEver repository, rebuild it after changing public contracts:
|
||||
|
||||
```sh
|
||||
bun run build:plugin-api
|
||||
```
|
||||
|
||||
Maintainers can inspect the exact public package without publishing it:
|
||||
|
||||
```sh
|
||||
cd packages/plugin-api
|
||||
npm pack --dry-run
|
||||
```
|
||||
|
||||
The package build contains only `dist/index.js`, `dist/index.d.ts`, its README, and package metadata. Plugin projects should bundle SDK runtime helpers into their single-file `main.js`; they must not leave a runtime import of `@edgeever/plugin-api` in the distributed bundle.
|
||||
|
||||
## Notes API
|
||||
|
||||
```ts
|
||||
@@ -146,14 +167,64 @@ context.notes.get(noteId);
|
||||
context.notes.create({ notebookId, title, contentMarkdown, tags });
|
||||
context.notes.update(noteId, { title, contentMarkdown, tags });
|
||||
context.notes.delete(noteId, { permanent: false });
|
||||
context.notes.move([noteId], notebookId);
|
||||
context.notes.pin([noteId], true);
|
||||
context.notes.restore(noteId);
|
||||
context.notes.revisions.list(noteId);
|
||||
context.notes.revisions.restore(noteId, revisionId);
|
||||
context.notebooks.list();
|
||||
context.notebooks.create({ name, parentId });
|
||||
context.notebooks.update(notebookId, { name, parentId, sortOrder });
|
||||
context.notebooks.delete(notebookId);
|
||||
context.tags.list();
|
||||
context.tags.rename("old", "new");
|
||||
context.tags.delete("unused");
|
||||
```
|
||||
|
||||
All writes go through EdgeEver's shared repository/business layer, including offline queueing and desktop adapters. Plugins do not access a storage implementation directly.
|
||||
Notebook and tag reads require `metadata:read`; tag changes require `metadata:write`.
|
||||
`notes.update()` requires both `notes:write` and `notes:read` because the update flow reads the current revision and returns the complete updated note. This prevents write access from becoming an indirect note-content read capability.
|
||||
Notebook and tag reads require `metadata:read`; notebook and tag changes require `metadata:write`.
|
||||
|
||||
Attachments use their own permissions and still flow through the same Web/Desktop repository adapters:
|
||||
|
||||
```ts
|
||||
context.resources.list(noteId); // resources:read
|
||||
context.resources.upload(noteId, file); // resources:write
|
||||
context.resources.rename(resourceId, filename);
|
||||
context.resources.delete(resourceId);
|
||||
```
|
||||
|
||||
Subscribing to `note.*` events requires `notes:read`, and subscribing to `tag.changed` requires `metadata:read`. The sync-queue status event carries no note or metadata content and requires no additional read permission.
|
||||
|
||||
Successful note and tag changes made through EdgeEver's normal repository layer—including user actions and plugin actions—feed the same plugin event stream. `workspace.synced` reports completed repository sync passes. Failed mutations do not emit success events.
|
||||
|
||||
## Host-rendered settings
|
||||
|
||||
Plugins can declare settings that EdgeEver renders consistently in plugin details. Supported field types are `text`, `secret`, `number`, `boolean`, and `select`:
|
||||
|
||||
```json
|
||||
{
|
||||
"settings": {
|
||||
"fields": [
|
||||
{ "key": "endpoint", "type": "text", "label": "API endpoint", "required": true },
|
||||
{ "key": "token", "type": "secret", "label": "API token", "required": true },
|
||||
{ "key": "format", "type": "select", "label": "Format", "default": "md", "options": [
|
||||
{ "value": "md", "label": "Markdown" },
|
||||
{ "value": "html", "label": "HTML" }
|
||||
] }
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Plugins read the validated values through `context.settings`. Secret values are encrypted in the device-local Secret Storage, are never embedded as Manifest defaults, and are not filled back into the settings form:
|
||||
|
||||
```ts
|
||||
const endpoint = await context.settings.get("endpoint");
|
||||
const token = await context.settings.get("token");
|
||||
await context.settings.set("format", "html");
|
||||
await context.settings.remove("token");
|
||||
```
|
||||
|
||||
## Storage and network
|
||||
|
||||
|
||||
@@ -44,6 +44,8 @@ GitHub 插件的 `entry` 固定为 `./main.js`,`main.js` 必须是无需相对
|
||||
|
||||
EdgeEver 会在插件市场页面打开、窗口重新获得焦点及每 30 分钟检查一次更新,但不会静默安装。用户必须点击「更新」并确认;如果新版新增插件权限或网络域名,确认框会明确列出新增访问范围。GitHub 分发的 Release `manifest.json` 必须与默认分支中用于提示更新的 Manifest 完全一致,否则安装会被拒绝。市场安装只跟随 Registry 中已经验证的新版本。
|
||||
|
||||
升级采用可回滚切换:新旧版本的包会分别缓存;如果新版无法激活,EdgeEver 会恢复原 Manifest、原启用状态和上一版本代码,而不是留下一个被破坏或被停用的插件。
|
||||
|
||||
用户在独立的「插件市场」页面中粘贴以下地址即可自由安装,无需经过官方市场收录:
|
||||
|
||||
```text
|
||||
@@ -84,7 +86,7 @@ Registry 格式:
|
||||
}
|
||||
```
|
||||
|
||||
市场安装显示“已验证”,GitHub 或 Manifest 自由安装会明确显示未经验证的来源,但 EdgeEver 不阻止用户安装。卸载插件时会同时删除本机缓存的插件包。
|
||||
市场安装显示“已验证”,GitHub 或 Manifest 自由安装会明确显示未经验证的来源,但 EdgeEver 不阻止用户安装。卸载插件时会同时删除本机缓存的全部插件版本、当前工作区的普通插件存储和 Secret Storage。
|
||||
|
||||
当前支持以下权限:
|
||||
|
||||
@@ -93,6 +95,8 @@ Registry 格式:
|
||||
- `notes:delete`
|
||||
- `metadata:read`
|
||||
- `metadata:write`
|
||||
- `resources:read`
|
||||
- `resources:write`
|
||||
- `network`
|
||||
- `storage`
|
||||
- `secrets`
|
||||
@@ -138,6 +142,23 @@ export default definePlugin({
|
||||
|
||||
每次注册都会返回清理函数。插件停用时,宿主也会自动清理已注册的命令和事件。
|
||||
|
||||
### SDK 包
|
||||
|
||||
`@edgeever/plugin-api` 是可发布的 ESM 包,包含生成后的 JavaScript 和 TypeScript 声明。在 EdgeEver 仓库中修改公开契约后,需要重新构建:
|
||||
|
||||
```sh
|
||||
bun run build:plugin-api
|
||||
```
|
||||
|
||||
维护者可以在不发布的情况下检查最终公开包内容:
|
||||
|
||||
```sh
|
||||
cd packages/plugin-api
|
||||
npm pack --dry-run
|
||||
```
|
||||
|
||||
构建后的包只包含 `dist/index.js`、`dist/index.d.ts`、README 和包元数据。插件项目应把 SDK 运行时辅助函数一并打入单文件 `main.js`,发布包中不能残留对 `@edgeever/plugin-api` 的运行时导入。
|
||||
|
||||
## 笔记 API
|
||||
|
||||
```ts
|
||||
@@ -146,14 +167,64 @@ context.notes.get(noteId);
|
||||
context.notes.create({ notebookId, title, contentMarkdown, tags });
|
||||
context.notes.update(noteId, { title, contentMarkdown, tags });
|
||||
context.notes.delete(noteId, { permanent: false });
|
||||
context.notes.move([noteId], notebookId);
|
||||
context.notes.pin([noteId], true);
|
||||
context.notes.restore(noteId);
|
||||
context.notes.revisions.list(noteId);
|
||||
context.notes.revisions.restore(noteId, revisionId);
|
||||
context.notebooks.list();
|
||||
context.notebooks.create({ name, parentId });
|
||||
context.notebooks.update(notebookId, { name, parentId, sortOrder });
|
||||
context.notebooks.delete(notebookId);
|
||||
context.tags.list();
|
||||
context.tags.rename("old", "new");
|
||||
context.tags.delete("unused");
|
||||
```
|
||||
|
||||
所有写入都经过 EdgeEver 的共享 Repository 和业务层,包括离线队列与桌面端适配器。插件不能直接访问具体存储实现。
|
||||
读取笔记本和标签需要 `metadata:read`,修改标签需要 `metadata:write`。
|
||||
`notes.update()` 同时需要 `notes:write` 和 `notes:read`,因为更新流程需要读取当前版本并会返回更新后的完整笔记;这可以避免写权限被间接用于读取笔记正文。
|
||||
读取笔记本和标签需要 `metadata:read`,修改笔记本和标签需要 `metadata:write`。
|
||||
|
||||
附件使用独立权限,并继续通过 Web/桌面端共用的 Repository 适配器执行:
|
||||
|
||||
```ts
|
||||
context.resources.list(noteId); // resources:read
|
||||
context.resources.upload(noteId, file); // resources:write
|
||||
context.resources.rename(resourceId, filename);
|
||||
context.resources.delete(resourceId);
|
||||
```
|
||||
|
||||
订阅 `note.*` 事件需要 `notes:read`,订阅 `tag.changed` 需要 `metadata:read`。同步队列状态事件不包含笔记或元数据,因此无需额外读取权限。
|
||||
|
||||
通过 EdgeEver 正常 Repository 层成功完成的笔记和标签变更——无论来自用户操作还是插件操作——都会进入同一条插件事件流。`workspace.synced` 用于报告已完成的 Repository 同步;失败的变更不会发送成功事件。
|
||||
|
||||
## 宿主统一渲染的设置
|
||||
|
||||
插件可以在 Manifest 中声明设置,由 EdgeEver 在插件详情页统一渲染。目前支持 `text`、`secret`、`number`、`boolean` 和 `select`:
|
||||
|
||||
```json
|
||||
{
|
||||
"settings": {
|
||||
"fields": [
|
||||
{ "key": "endpoint", "type": "text", "label": "API Endpoint", "required": true },
|
||||
{ "key": "token", "type": "secret", "label": "API Token", "required": true },
|
||||
{ "key": "format", "type": "select", "label": "格式", "default": "md", "options": [
|
||||
{ "value": "md", "label": "Markdown" },
|
||||
{ "value": "html", "label": "HTML" }
|
||||
] }
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
插件通过 `context.settings` 读取校验后的值。Secret 会加密保存在当前设备的 Secret Storage 中,禁止在 Manifest 中声明默认明文,也不会回填到设置表单:
|
||||
|
||||
```ts
|
||||
const endpoint = await context.settings.get("endpoint");
|
||||
const token = await context.settings.get("token");
|
||||
await context.settings.set("format", "html");
|
||||
await context.settings.remove("token");
|
||||
```
|
||||
|
||||
## 插件存储与网络
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
"build:android:play:local": "bun --env-file=${EDGE_EVER_ANDROID_ENV_FILE:-$HOME/.config/edgeever/android/signing.env} run build:android:play",
|
||||
"dev:site": "bun --cwd apps/site dev",
|
||||
"build": "bun run build:web",
|
||||
"build:plugin-api": "bun run --cwd packages/plugin-api build",
|
||||
"build:extension": "vite build --config apps/extension/vite.config.ts",
|
||||
"build:extension:firefox": "bun run --cwd apps/extension build:firefox",
|
||||
"lint:extension:firefox": "bun run --cwd apps/extension lint:firefox",
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
# @edgeever/plugin-api
|
||||
|
||||
Public TypeScript contracts and runtime manifest helpers for EdgeEver client plugins and code-free themes.
|
||||
|
||||
```ts
|
||||
import { definePlugin } from "@edgeever/plugin-api";
|
||||
|
||||
export default definePlugin({
|
||||
activate(context) {
|
||||
return context.commands.register({
|
||||
id: "hello",
|
||||
title: "Hello",
|
||||
run: () => context.ui.showNotice("Hello from EdgeEver"),
|
||||
});
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
The package ships ESM JavaScript and TypeScript declarations. Plugin bundles must produce a single `main.js` file without relative imports before distribution. See the EdgeEver plugin development guide for the Manifest format, permissions, settings Schema, release assets, and marketplace verification rules.
|
||||
Vendored
+295
@@ -0,0 +1,295 @@
|
||||
export declare const PLUGIN_API_VERSION: "1";
|
||||
export declare const THEME_API_VERSION: "1";
|
||||
export declare const PLUGIN_PERMISSIONS: readonly ["notes:read", "notes:write", "notes:delete", "metadata:read", "metadata:write", "resources:read", "resources:write", "network", "storage", "secrets", "editor:read", "editor:write", "ui:commands", "ui:notices", "ui:panels"];
|
||||
export type PluginPermission = (typeof PLUGIN_PERMISSIONS)[number];
|
||||
export type ExtensionPlatform = "web" | "desktop" | "android" | "ios";
|
||||
export interface PluginManifest {
|
||||
type: "plugin";
|
||||
id: string;
|
||||
name: string;
|
||||
version: string;
|
||||
apiVersion: typeof PLUGIN_API_VERSION;
|
||||
description?: string;
|
||||
author?: string;
|
||||
entry: string;
|
||||
platforms?: ExtensionPlatform[];
|
||||
permissions: PluginPermission[];
|
||||
networkHosts?: string[];
|
||||
settings?: PluginSettingsSchema;
|
||||
}
|
||||
interface PluginSettingBase {
|
||||
key: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
required?: boolean;
|
||||
}
|
||||
export type PluginSettingField = (PluginSettingBase & {
|
||||
type: "text";
|
||||
default?: string;
|
||||
placeholder?: string;
|
||||
}) | (PluginSettingBase & {
|
||||
type: "secret";
|
||||
placeholder?: string;
|
||||
}) | (PluginSettingBase & {
|
||||
type: "number";
|
||||
default?: number;
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
}) | (PluginSettingBase & {
|
||||
type: "boolean";
|
||||
default?: boolean;
|
||||
}) | (PluginSettingBase & {
|
||||
type: "select";
|
||||
default?: string;
|
||||
options: Array<{
|
||||
value: string;
|
||||
label: string;
|
||||
}>;
|
||||
});
|
||||
export interface PluginSettingsSchema {
|
||||
fields: PluginSettingField[];
|
||||
}
|
||||
export type PluginSettingValue = string | number | boolean;
|
||||
export declare const THEME_TOKEN_NAMES: readonly ["color.background", "color.surface", "color.surfaceMuted", "color.text", "color.textMuted", "color.border", "color.accent", "color.accentForeground", "color.success", "color.warning", "color.danger", "font.body", "font.mono", "font.size", "lineHeight.body", "radius.medium", "density.scale", "editor.contentWidth"];
|
||||
export type ThemeTokenName = (typeof THEME_TOKEN_NAMES)[number];
|
||||
export type ThemeTokens = Partial<Record<ThemeTokenName, string>>;
|
||||
export interface ThemeManifest {
|
||||
type: "theme";
|
||||
id: string;
|
||||
name: string;
|
||||
version: string;
|
||||
themeApiVersion: typeof THEME_API_VERSION;
|
||||
description?: string;
|
||||
author?: string;
|
||||
modes: Array<"light" | "dark">;
|
||||
light: ThemeTokens;
|
||||
dark?: ThemeTokens;
|
||||
}
|
||||
export type ExtensionManifest = PluginManifest | ThemeManifest;
|
||||
export declare const MARKETPLACE_REGISTRY_VERSION: "1";
|
||||
export interface MarketplaceEntry {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
author: string;
|
||||
category: string;
|
||||
repositoryUrl: string;
|
||||
distribution: {
|
||||
type: "github";
|
||||
repositoryUrl: string;
|
||||
} | {
|
||||
type: "manifest";
|
||||
manifestUrl: string;
|
||||
};
|
||||
verification: {
|
||||
version: string;
|
||||
checksums?: {
|
||||
manifestJson?: string;
|
||||
mainJs?: string;
|
||||
stylesCss?: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
export interface MarketplaceRegistry {
|
||||
registryVersion: typeof MARKETPLACE_REGISTRY_VERSION;
|
||||
updatedAt: string;
|
||||
entries: MarketplaceEntry[];
|
||||
}
|
||||
export interface PluginNoteSummary {
|
||||
id: string;
|
||||
notebookId: string;
|
||||
title: string | null;
|
||||
excerpt: string;
|
||||
tags: string[];
|
||||
isPinned: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
export interface PluginNote extends PluginNoteSummary {
|
||||
contentMarkdown: string;
|
||||
contentText: string;
|
||||
}
|
||||
export interface PluginNoteQuery {
|
||||
notebookId?: string;
|
||||
text?: string;
|
||||
tags?: string[];
|
||||
sort?: "updated-desc" | "created-desc" | "title-asc";
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
export interface PluginNoteCreateInput {
|
||||
notebookId: string;
|
||||
title?: string;
|
||||
contentMarkdown?: string;
|
||||
tags?: string[];
|
||||
}
|
||||
export interface PluginNoteUpdateInput {
|
||||
title?: string;
|
||||
contentMarkdown?: string;
|
||||
tags?: string[];
|
||||
}
|
||||
export interface PluginNoteQueryResult {
|
||||
notes: PluginNoteSummary[];
|
||||
totalCount: number;
|
||||
nextOffset: number | null;
|
||||
}
|
||||
export interface PluginNotebook {
|
||||
id: string;
|
||||
parentId: string | null;
|
||||
name: string;
|
||||
memoCount: number;
|
||||
}
|
||||
export interface PluginNoteRevision {
|
||||
id: string;
|
||||
noteId: string;
|
||||
revision: number;
|
||||
title: string | null;
|
||||
tags: string[];
|
||||
contentMarkdown: string;
|
||||
contentText: string;
|
||||
createdAt: string;
|
||||
}
|
||||
export interface PluginResource {
|
||||
id: string;
|
||||
noteId: string;
|
||||
kind: "image" | "attachment";
|
||||
mimeType: string | null;
|
||||
filename: string | null;
|
||||
byteSize: number;
|
||||
width: number | null;
|
||||
height: number | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
url: string;
|
||||
}
|
||||
export interface PluginTag {
|
||||
name: string;
|
||||
noteCount: number;
|
||||
}
|
||||
export type PluginEventMap = {
|
||||
"note.created": {
|
||||
note: PluginNote;
|
||||
};
|
||||
"note.updated": {
|
||||
note: PluginNote;
|
||||
};
|
||||
"note.deleted": {
|
||||
noteId: string;
|
||||
};
|
||||
"tag.changed": {
|
||||
previousName?: string;
|
||||
name?: string;
|
||||
deleted?: boolean;
|
||||
};
|
||||
"workspace.sync-queue-changed": Record<string, never>;
|
||||
"workspace.synced": {
|
||||
bootstrapped: boolean;
|
||||
changed: number;
|
||||
};
|
||||
};
|
||||
export interface PluginCommand {
|
||||
id: string;
|
||||
title: string;
|
||||
run: () => void | Promise<void>;
|
||||
}
|
||||
export interface PluginEditorSelection {
|
||||
noteId: string;
|
||||
from: number;
|
||||
to: number;
|
||||
empty: boolean;
|
||||
text: string;
|
||||
contentMarkdown: string;
|
||||
}
|
||||
export interface PluginPanel {
|
||||
id: string;
|
||||
title: string;
|
||||
mount(container: HTMLElement): void | (() => void) | Promise<void | (() => void)>;
|
||||
}
|
||||
export interface PluginContext {
|
||||
pluginId: string;
|
||||
notes: {
|
||||
query(input?: PluginNoteQuery): Promise<PluginNoteQueryResult>;
|
||||
get(noteId: string): Promise<PluginNote>;
|
||||
create(input: PluginNoteCreateInput): Promise<PluginNote>;
|
||||
update(noteId: string, input: PluginNoteUpdateInput): Promise<PluginNote>;
|
||||
delete(noteId: string, options?: {
|
||||
permanent?: boolean;
|
||||
}): Promise<void>;
|
||||
move(noteIds: string[], notebookId: string): Promise<number>;
|
||||
pin(noteIds: string[], isPinned: boolean): Promise<number>;
|
||||
restore(noteId: string): Promise<PluginNote>;
|
||||
revisions: {
|
||||
list(noteId: string): Promise<PluginNoteRevision[]>;
|
||||
restore(noteId: string, revisionId: string): Promise<PluginNote>;
|
||||
};
|
||||
};
|
||||
notebooks: {
|
||||
list(): Promise<PluginNotebook[]>;
|
||||
create(input: {
|
||||
name: string;
|
||||
parentId?: string | null;
|
||||
}): Promise<PluginNotebook>;
|
||||
update(notebookId: string, input: {
|
||||
name?: string;
|
||||
parentId?: string | null;
|
||||
sortOrder?: number;
|
||||
}): Promise<PluginNotebook>;
|
||||
delete(notebookId: string): Promise<void>;
|
||||
};
|
||||
tags: {
|
||||
list(): Promise<PluginTag[]>;
|
||||
rename(name: string, nextName: string): Promise<number>;
|
||||
delete(name: string): Promise<number>;
|
||||
};
|
||||
commands: {
|
||||
register(command: PluginCommand): () => void;
|
||||
};
|
||||
events: {
|
||||
on<K extends keyof PluginEventMap>(event: K, listener: (payload: PluginEventMap[K]) => void): () => void;
|
||||
};
|
||||
storage: {
|
||||
get<T>(key: string): Promise<T | null>;
|
||||
set<T>(key: string, value: T): Promise<void>;
|
||||
remove(key: string): Promise<void>;
|
||||
};
|
||||
secrets: {
|
||||
get(key: string): Promise<string | null>;
|
||||
set(key: string, value: string): Promise<void>;
|
||||
remove(key: string): Promise<void>;
|
||||
};
|
||||
editor: {
|
||||
getSelection(): Promise<PluginEditorSelection | null>;
|
||||
replaceSelection(contentMarkdown: string): Promise<void>;
|
||||
insertAtCursor(contentMarkdown: string): Promise<void>;
|
||||
};
|
||||
resources: {
|
||||
list(noteId?: string): Promise<PluginResource[]>;
|
||||
upload(noteId: string, file: File): Promise<PluginResource>;
|
||||
rename(resourceId: string, filename: string): Promise<PluginResource>;
|
||||
delete(resourceId: string): Promise<void>;
|
||||
};
|
||||
settings: {
|
||||
get(key: string): Promise<PluginSettingValue | null>;
|
||||
set(key: string, value: PluginSettingValue): Promise<void>;
|
||||
remove(key: string): Promise<void>;
|
||||
};
|
||||
network: {
|
||||
fetch(input: string, init?: RequestInit): Promise<Response>;
|
||||
};
|
||||
ui: {
|
||||
showNotice(message: string): void;
|
||||
panels: {
|
||||
register(panel: PluginPanel): () => void;
|
||||
};
|
||||
};
|
||||
}
|
||||
export interface EdgeEverPlugin {
|
||||
activate(context: PluginContext): void | (() => void) | Promise<void | (() => void)>;
|
||||
deactivate?(): void | Promise<void>;
|
||||
}
|
||||
export declare const definePlugin: <T extends EdgeEverPlugin>(plugin: T) => T;
|
||||
export declare const defineTheme: <T extends ThemeManifest>(theme: T) => T;
|
||||
export declare const parseExtensionManifest: (value: unknown) => ExtensionManifest;
|
||||
export declare const parseMarketplaceRegistry: (value: unknown) => MarketplaceRegistry;
|
||||
export {};
|
||||
Vendored
+306
@@ -0,0 +1,306 @@
|
||||
// src/index.ts
|
||||
var PLUGIN_API_VERSION = "1";
|
||||
var THEME_API_VERSION = "1";
|
||||
var PLUGIN_PERMISSIONS = [
|
||||
"notes:read",
|
||||
"notes:write",
|
||||
"notes:delete",
|
||||
"metadata:read",
|
||||
"metadata:write",
|
||||
"resources:read",
|
||||
"resources:write",
|
||||
"network",
|
||||
"storage",
|
||||
"secrets",
|
||||
"editor:read",
|
||||
"editor:write",
|
||||
"ui:commands",
|
||||
"ui:notices",
|
||||
"ui:panels"
|
||||
];
|
||||
var THEME_TOKEN_NAMES = [
|
||||
"color.background",
|
||||
"color.surface",
|
||||
"color.surfaceMuted",
|
||||
"color.text",
|
||||
"color.textMuted",
|
||||
"color.border",
|
||||
"color.accent",
|
||||
"color.accentForeground",
|
||||
"color.success",
|
||||
"color.warning",
|
||||
"color.danger",
|
||||
"font.body",
|
||||
"font.mono",
|
||||
"font.size",
|
||||
"lineHeight.body",
|
||||
"radius.medium",
|
||||
"density.scale",
|
||||
"editor.contentWidth"
|
||||
];
|
||||
var MARKETPLACE_REGISTRY_VERSION = "1";
|
||||
var definePlugin = (plugin) => plugin;
|
||||
var defineTheme = (theme) => theme;
|
||||
var ID_PATTERN = /^[a-z0-9]+(?:[._-][a-z0-9]+)+$/;
|
||||
var VERSION_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
|
||||
var isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
var COLOR_THEME_TOKENS = new Set([
|
||||
"color.background",
|
||||
"color.surface",
|
||||
"color.surfaceMuted",
|
||||
"color.text",
|
||||
"color.textMuted",
|
||||
"color.border",
|
||||
"color.accent",
|
||||
"color.accentForeground",
|
||||
"color.success",
|
||||
"color.warning",
|
||||
"color.danger"
|
||||
]);
|
||||
var FONT_THEME_TOKENS = new Set(["font.body", "font.mono"]);
|
||||
var LENGTH_THEME_TOKENS = new Set(["font.size", "radius.medium", "editor.contentWidth"]);
|
||||
var validateThemeToken = (key, value) => {
|
||||
if (COLOR_THEME_TOKENS.has(key) && !/^#[0-9a-f]{6}(?:[0-9a-f]{2})?$/i.test(value)) {
|
||||
throw new Error(`Theme color token ${key} must use #RRGGBB or #RRGGBBAA.`);
|
||||
}
|
||||
if (FONT_THEME_TOKENS.has(key) && (!/^[a-z0-9 ,_'"-]+$/i.test(value) || value.length > 200)) {
|
||||
throw new Error(`Theme font token ${key} contains unsupported characters.`);
|
||||
}
|
||||
if (LENGTH_THEME_TOKENS.has(key) && !/^\d+(?:\.\d+)?(?:px|rem|em|%)$/.test(value)) {
|
||||
throw new Error(`Theme length token ${key} must use px, rem, em, or %.`);
|
||||
}
|
||||
if (key === "lineHeight.body" && !/^\d+(?:\.\d+)?(?:px|rem|em|%)?$/.test(value)) {
|
||||
throw new Error("Theme lineHeight.body must be a number or a supported CSS length.");
|
||||
}
|
||||
if (key === "density.scale" && !/^(?:0\.[5-9]\d?|1(?:\.\d{1,2})?)$/.test(value)) {
|
||||
throw new Error("Theme density.scale must be between 0.5 and 1.99.");
|
||||
}
|
||||
};
|
||||
var assertCommonManifest = (value) => {
|
||||
if (typeof value.id !== "string" || !ID_PATTERN.test(value.id)) {
|
||||
throw new Error("Extension id must be a reverse-domain style identifier using lowercase letters, numbers, dots, dashes, or underscores.");
|
||||
}
|
||||
if (typeof value.name !== "string" || !value.name.trim()) {
|
||||
throw new Error("Extension name is required.");
|
||||
}
|
||||
if (typeof value.version !== "string" || !VERSION_PATTERN.test(value.version)) {
|
||||
throw new Error("Extension version must use SemVer.");
|
||||
}
|
||||
};
|
||||
var normalizeThemeTokens = (value) => {
|
||||
if (!isRecord(value))
|
||||
throw new Error("Theme tokens must be an object.");
|
||||
const allowed = new Set(THEME_TOKEN_NAMES);
|
||||
const tokens = {};
|
||||
for (const [key, tokenValue] of Object.entries(value)) {
|
||||
if (!allowed.has(key))
|
||||
throw new Error(`Unsupported theme token: ${key}`);
|
||||
if (typeof tokenValue !== "string" || !tokenValue.trim())
|
||||
throw new Error(`Theme token ${key} must be a non-empty string.`);
|
||||
const normalizedValue = tokenValue.trim();
|
||||
validateThemeToken(key, normalizedValue);
|
||||
tokens[key] = normalizedValue;
|
||||
}
|
||||
return tokens;
|
||||
};
|
||||
var SETTING_KEY_PATTERN = /^[a-z][a-z0-9._-]*$/;
|
||||
var normalizePluginSettings = (value) => {
|
||||
if (!isRecord(value) || !Array.isArray(value.fields))
|
||||
throw new Error("Plugin settings must contain a fields array.");
|
||||
if (value.fields.length > 50)
|
||||
throw new Error("Plugin settings cannot contain more than 50 fields.");
|
||||
const keys = new Set;
|
||||
const fields = value.fields.map((field) => {
|
||||
if (!isRecord(field) || typeof field.key !== "string" || !SETTING_KEY_PATTERN.test(field.key)) {
|
||||
throw new Error("Plugin setting keys must start with a lowercase letter and contain only lowercase letters, numbers, dots, dashes, or underscores.");
|
||||
}
|
||||
if (keys.has(field.key))
|
||||
throw new Error(`Duplicate plugin setting key: ${field.key}`);
|
||||
keys.add(field.key);
|
||||
if (typeof field.label !== "string" || !field.label.trim() || field.label.length > 200)
|
||||
throw new Error(`Plugin setting ${field.key} requires a label of at most 200 characters.`);
|
||||
if (typeof field.description === "string" && field.description.length > 1000)
|
||||
throw new Error(`Plugin setting ${field.key} description is too long.`);
|
||||
const common = {
|
||||
key: field.key,
|
||||
label: field.label.trim(),
|
||||
...typeof field.description === "string" && field.description.trim() ? { description: field.description.trim() } : {},
|
||||
...field.required === true ? { required: true } : {}
|
||||
};
|
||||
if (field.type === "text" || field.type === "secret") {
|
||||
if (field.type === "secret" && field.default !== undefined)
|
||||
throw new Error(`Secret setting ${field.key} cannot declare a default value.`);
|
||||
if (field.default !== undefined && typeof field.default !== "string")
|
||||
throw new Error(`Plugin setting ${field.key} default must be a string.`);
|
||||
if (field.placeholder !== undefined && typeof field.placeholder !== "string")
|
||||
throw new Error(`Plugin setting ${field.key} placeholder must be a string.`);
|
||||
return {
|
||||
...common,
|
||||
type: field.type,
|
||||
...typeof field.default === "string" ? { default: field.default } : {},
|
||||
...typeof field.placeholder === "string" ? { placeholder: field.placeholder } : {}
|
||||
};
|
||||
}
|
||||
if (field.type === "number") {
|
||||
for (const key of ["default", "min", "max", "step"]) {
|
||||
if (field[key] !== undefined && (typeof field[key] !== "number" || !Number.isFinite(field[key]))) {
|
||||
throw new Error(`Plugin setting ${field.key} ${key} must be a finite number.`);
|
||||
}
|
||||
}
|
||||
if (typeof field.min === "number" && typeof field.max === "number" && field.min > field.max)
|
||||
throw new Error(`Plugin setting ${field.key} min cannot exceed max.`);
|
||||
if (typeof field.step === "number" && field.step <= 0)
|
||||
throw new Error(`Plugin setting ${field.key} step must be positive.`);
|
||||
if (typeof field.default === "number" && (typeof field.min === "number" && field.default < field.min || typeof field.max === "number" && field.default > field.max)) {
|
||||
throw new Error(`Plugin setting ${field.key} default is outside its allowed range.`);
|
||||
}
|
||||
return { ...common, type: "number", ...Object.fromEntries(["default", "min", "max", "step"].flatMap((key) => typeof field[key] === "number" ? [[key, field[key]]] : [])) };
|
||||
}
|
||||
if (field.type === "boolean") {
|
||||
if (field.default !== undefined && typeof field.default !== "boolean")
|
||||
throw new Error(`Plugin setting ${field.key} default must be a boolean.`);
|
||||
return { ...common, type: "boolean", ...typeof field.default === "boolean" ? { default: field.default } : {} };
|
||||
}
|
||||
if (field.type === "select") {
|
||||
if (!Array.isArray(field.options) || field.options.length === 0 || field.options.length > 100)
|
||||
throw new Error(`Plugin setting ${field.key} requires between 1 and 100 select options.`);
|
||||
const optionValues = new Set;
|
||||
const options = field.options.map((option) => {
|
||||
if (!isRecord(option) || typeof option.value !== "string" || !option.value || typeof option.label !== "string" || !option.label.trim()) {
|
||||
throw new Error(`Plugin setting ${field.key} has an invalid select option.`);
|
||||
}
|
||||
if (optionValues.has(option.value))
|
||||
throw new Error(`Plugin setting ${field.key} has a duplicate select value.`);
|
||||
optionValues.add(option.value);
|
||||
return { value: option.value, label: option.label.trim() };
|
||||
});
|
||||
if (field.default !== undefined && (typeof field.default !== "string" || !optionValues.has(field.default)))
|
||||
throw new Error(`Plugin setting ${field.key} default must match a select option.`);
|
||||
return { ...common, type: "select", options, ...typeof field.default === "string" ? { default: field.default } : {} };
|
||||
}
|
||||
throw new Error(`Plugin setting ${field.key} has an unsupported type.`);
|
||||
});
|
||||
return { fields };
|
||||
};
|
||||
var parseExtensionManifest = (value) => {
|
||||
if (!isRecord(value))
|
||||
throw new Error("Extension manifest must be an object.");
|
||||
assertCommonManifest(value);
|
||||
if (value.type === "plugin") {
|
||||
if (value.apiVersion !== PLUGIN_API_VERSION)
|
||||
throw new Error(`Unsupported plugin API version: ${String(value.apiVersion)}`);
|
||||
if (typeof value.entry !== "string" || !value.entry.trim())
|
||||
throw new Error("Plugin entry is required.");
|
||||
if (!Array.isArray(value.permissions))
|
||||
throw new Error("Plugin permissions must be an array.");
|
||||
const allowedPermissions = new Set(PLUGIN_PERMISSIONS);
|
||||
const permissions = [...new Set(value.permissions.map(String))];
|
||||
const unsupported = permissions.find((permission) => !allowedPermissions.has(permission));
|
||||
if (unsupported)
|
||||
throw new Error(`Unsupported plugin permission: ${unsupported}`);
|
||||
const networkHosts = value.networkHosts === undefined ? undefined : Array.isArray(value.networkHosts) ? value.networkHosts.map(String) : (() => {
|
||||
throw new Error("networkHosts must be an array.");
|
||||
})();
|
||||
if (networkHosts?.some((host) => !/^(?:\*\.)?[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/i.test(host))) {
|
||||
throw new Error("networkHosts entries must be hostnames without a scheme, port, or path.");
|
||||
}
|
||||
if (permissions.includes("network") && !networkHosts?.length) {
|
||||
throw new Error("Plugins requesting network permission must declare networkHosts.");
|
||||
}
|
||||
const platforms = value.platforms === undefined ? undefined : Array.isArray(value.platforms) && value.platforms.every((platform) => ["web", "desktop", "android", "ios"].includes(String(platform))) ? [...new Set(value.platforms.map(String))] : (() => {
|
||||
throw new Error("Plugin platforms contains an unsupported platform.");
|
||||
})();
|
||||
const settings = value.settings === undefined ? undefined : normalizePluginSettings(value.settings);
|
||||
return { ...value, type: "plugin", permissions, networkHosts, platforms, settings };
|
||||
}
|
||||
if (value.type === "theme") {
|
||||
if (value.themeApiVersion !== THEME_API_VERSION)
|
||||
throw new Error(`Unsupported theme API version: ${String(value.themeApiVersion)}`);
|
||||
if (!Array.isArray(value.modes) || value.modes.length === 0 || value.modes.some((mode) => mode !== "light" && mode !== "dark")) {
|
||||
throw new Error("Theme modes must contain light and/or dark.");
|
||||
}
|
||||
return {
|
||||
...value,
|
||||
type: "theme",
|
||||
modes: [...new Set(value.modes)],
|
||||
light: normalizeThemeTokens(value.light),
|
||||
dark: value.dark === undefined ? undefined : normalizeThemeTokens(value.dark)
|
||||
};
|
||||
}
|
||||
throw new Error("Extension type must be plugin or theme.");
|
||||
};
|
||||
var normalizeChecksum = (value, label) => {
|
||||
if (typeof value !== "string" || !/^[0-9a-f]{64}$/i.test(value))
|
||||
throw new Error(`${label} must be a SHA-256 hex digest.`);
|
||||
return value.toLocaleLowerCase();
|
||||
};
|
||||
var GITHUB_REPOSITORY_PATTERN = /^https:\/\/github\.com\/[^/]+\/[^/]+\/?$/i;
|
||||
var parseMarketplaceRegistry = (value) => {
|
||||
if (!isRecord(value) || value.registryVersion !== MARKETPLACE_REGISTRY_VERSION || !Array.isArray(value.entries)) {
|
||||
throw new Error("Unsupported marketplace registry format.");
|
||||
}
|
||||
if (typeof value.updatedAt !== "string" || Number.isNaN(Date.parse(value.updatedAt))) {
|
||||
throw new Error("Marketplace registry updatedAt must be an ISO date.");
|
||||
}
|
||||
const ids = new Set;
|
||||
const entries = value.entries.map((item) => {
|
||||
if (!isRecord(item) || typeof item.id !== "string" || !ID_PATTERN.test(item.id))
|
||||
throw new Error("Marketplace entry id is invalid.");
|
||||
if (ids.has(item.id))
|
||||
throw new Error(`Duplicate marketplace entry id: ${item.id}`);
|
||||
ids.add(item.id);
|
||||
for (const field of ["name", "description", "author", "category", "repositoryUrl"]) {
|
||||
if (typeof item[field] !== "string" || !item[field].trim())
|
||||
throw new Error(`Marketplace entry ${item.id} is missing ${field}.`);
|
||||
}
|
||||
const name = item.name;
|
||||
const description = item.description;
|
||||
const author = item.author;
|
||||
const category = item.category;
|
||||
const repositoryUrl = item.repositoryUrl;
|
||||
if (!GITHUB_REPOSITORY_PATTERN.test(repositoryUrl))
|
||||
throw new Error(`Marketplace entry ${item.id} repositoryUrl must be a GitHub repository.`);
|
||||
if (!isRecord(item.distribution) || item.distribution.type !== "github" && item.distribution.type !== "manifest") {
|
||||
throw new Error(`Marketplace entry ${item.id} has an invalid distribution.`);
|
||||
}
|
||||
const distribution = item.distribution.type === "github" ? typeof item.distribution.repositoryUrl === "string" && GITHUB_REPOSITORY_PATTERN.test(item.distribution.repositoryUrl) ? { type: "github", repositoryUrl: item.distribution.repositoryUrl } : (() => {
|
||||
throw new Error(`Marketplace entry ${item.id} has an invalid GitHub repository.`);
|
||||
})() : typeof item.distribution.manifestUrl === "string" && (/^https:\/\//i.test(item.distribution.manifestUrl) || item.distribution.manifestUrl.startsWith("/")) ? { type: "manifest", manifestUrl: item.distribution.manifestUrl } : (() => {
|
||||
throw new Error(`Marketplace entry ${item.id} has an invalid manifest URL.`);
|
||||
})();
|
||||
if (!isRecord(item.verification) || typeof item.verification.version !== "string" || !VERSION_PATTERN.test(item.verification.version)) {
|
||||
throw new Error(`Marketplace entry ${item.id} has an invalid verified version.`);
|
||||
}
|
||||
const checksums = item.verification.checksums === undefined ? undefined : isRecord(item.verification.checksums) ? Object.fromEntries(Object.entries(item.verification.checksums).map(([key, checksum]) => {
|
||||
if (!["manifestJson", "mainJs", "stylesCss"].includes(key))
|
||||
throw new Error(`Marketplace entry ${item.id} has an unsupported checksum.`);
|
||||
return [key, normalizeChecksum(checksum, `${item.id} ${key}`)];
|
||||
})) : (() => {
|
||||
throw new Error(`Marketplace entry ${item.id} checksums must be an object.`);
|
||||
})();
|
||||
if (!checksums?.manifestJson)
|
||||
throw new Error(`Marketplace entry ${item.id} must pin the manifest.json checksum.`);
|
||||
return {
|
||||
id: item.id,
|
||||
name: name.trim(),
|
||||
description: description.trim(),
|
||||
author: author.trim(),
|
||||
category: category.trim(),
|
||||
repositoryUrl: repositoryUrl.trim(),
|
||||
distribution,
|
||||
verification: { version: item.verification.version, checksums }
|
||||
};
|
||||
});
|
||||
return { registryVersion: MARKETPLACE_REGISTRY_VERSION, updatedAt: value.updatedAt, entries };
|
||||
};
|
||||
export {
|
||||
parseMarketplaceRegistry,
|
||||
parseExtensionManifest,
|
||||
defineTheme,
|
||||
definePlugin,
|
||||
THEME_TOKEN_NAMES,
|
||||
THEME_API_VERSION,
|
||||
PLUGIN_PERMISSIONS,
|
||||
PLUGIN_API_VERSION,
|
||||
MARKETPLACE_REGISTRY_VERSION
|
||||
};
|
||||
@@ -1,10 +1,23 @@
|
||||
{
|
||||
"name": "@edgeever/plugin-api",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "Public contracts and manifest helpers for EdgeEver client plugins and themes.",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"types": "./src/index.ts"
|
||||
"types": "./dist/index.d.ts",
|
||||
"files": ["dist", "README.md"],
|
||||
"sideEffects": false,
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "bun build ./src/index.ts --outdir ./dist --target browser --format esm && tsc -p tsconfig.build.json",
|
||||
"prepack": "bun run build"
|
||||
},
|
||||
"license": "AGPL-3.0-or-later"
|
||||
}
|
||||
@@ -61,6 +61,51 @@ describe("extension manifests", () => {
|
||||
permissions: ["network"],
|
||||
})).toThrow("must declare networkHosts");
|
||||
});
|
||||
|
||||
test("normalizes a host-rendered plugin settings schema", () => {
|
||||
const manifest = parseExtensionManifest({
|
||||
type: "plugin",
|
||||
id: "org.edgeever.settings",
|
||||
name: "Settings",
|
||||
version: "1.0.0",
|
||||
apiVersion: "1",
|
||||
entry: "./main.js",
|
||||
permissions: [],
|
||||
settings: {
|
||||
fields: [
|
||||
{ key: "endpoint", type: "text", label: "Endpoint", default: "https://example.com" },
|
||||
{ key: "token", type: "secret", label: "Token", required: true },
|
||||
{ key: "limit", type: "number", label: "Limit", default: 10, min: 1, max: 100 },
|
||||
{ key: "enabled", type: "boolean", label: "Enabled", default: true },
|
||||
{ key: "format", type: "select", label: "Format", options: [{ value: "md", label: "Markdown" }] },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(manifest.type).toBe("plugin");
|
||||
expect(manifest.settings?.fields).toHaveLength(5);
|
||||
expect(manifest.settings?.fields[0]).toMatchObject({ key: "endpoint", default: "https://example.com" });
|
||||
});
|
||||
|
||||
test("rejects unsafe or ambiguous plugin settings", () => {
|
||||
const base = {
|
||||
type: "plugin",
|
||||
id: "org.edgeever.settings-invalid",
|
||||
name: "Settings",
|
||||
version: "1.0.0",
|
||||
apiVersion: "1",
|
||||
entry: "./main.js",
|
||||
permissions: [],
|
||||
};
|
||||
expect(() => parseExtensionManifest({
|
||||
...base,
|
||||
settings: { fields: [{ key: "token", type: "secret", label: "Token", default: "plaintext" }] },
|
||||
})).toThrow("cannot declare a default");
|
||||
expect(() => parseExtensionManifest({
|
||||
...base,
|
||||
settings: { fields: [{ key: "mode", type: "select", label: "Mode", options: [{ value: "a", label: "A" }, { value: "a", label: "Again" }] }] },
|
||||
})).toThrow("duplicate select value");
|
||||
});
|
||||
});
|
||||
|
||||
describe("marketplace registry", () => {
|
||||
|
||||
@@ -7,6 +7,8 @@ export const PLUGIN_PERMISSIONS = [
|
||||
"notes:delete",
|
||||
"metadata:read",
|
||||
"metadata:write",
|
||||
"resources:read",
|
||||
"resources:write",
|
||||
"network",
|
||||
"storage",
|
||||
"secrets",
|
||||
@@ -32,8 +34,29 @@ export interface PluginManifest {
|
||||
platforms?: ExtensionPlatform[];
|
||||
permissions: PluginPermission[];
|
||||
networkHosts?: string[];
|
||||
settings?: PluginSettingsSchema;
|
||||
}
|
||||
|
||||
interface PluginSettingBase {
|
||||
key: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
required?: boolean;
|
||||
}
|
||||
|
||||
export type PluginSettingField =
|
||||
| (PluginSettingBase & { type: "text"; default?: string; placeholder?: string })
|
||||
| (PluginSettingBase & { type: "secret"; placeholder?: string })
|
||||
| (PluginSettingBase & { type: "number"; default?: number; min?: number; max?: number; step?: number })
|
||||
| (PluginSettingBase & { type: "boolean"; default?: boolean })
|
||||
| (PluginSettingBase & { type: "select"; default?: string; options: Array<{ value: string; label: string }> });
|
||||
|
||||
export interface PluginSettingsSchema {
|
||||
fields: PluginSettingField[];
|
||||
}
|
||||
|
||||
export type PluginSettingValue = string | number | boolean;
|
||||
|
||||
export const THEME_TOKEN_NAMES = [
|
||||
"color.background",
|
||||
"color.surface",
|
||||
@@ -152,6 +175,31 @@ export interface PluginNotebook {
|
||||
memoCount: number;
|
||||
}
|
||||
|
||||
export interface PluginNoteRevision {
|
||||
id: string;
|
||||
noteId: string;
|
||||
revision: number;
|
||||
title: string | null;
|
||||
tags: string[];
|
||||
contentMarkdown: string;
|
||||
contentText: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface PluginResource {
|
||||
id: string;
|
||||
noteId: string;
|
||||
kind: "image" | "attachment";
|
||||
mimeType: string | null;
|
||||
filename: string | null;
|
||||
byteSize: number;
|
||||
width: number | null;
|
||||
height: number | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface PluginTag {
|
||||
name: string;
|
||||
noteCount: number;
|
||||
@@ -163,6 +211,7 @@ export type PluginEventMap = {
|
||||
"note.deleted": { noteId: string };
|
||||
"tag.changed": { previousName?: string; name?: string; deleted?: boolean };
|
||||
"workspace.sync-queue-changed": Record<string, never>;
|
||||
"workspace.synced": { bootstrapped: boolean; changed: number };
|
||||
};
|
||||
|
||||
export interface PluginCommand {
|
||||
@@ -194,9 +243,19 @@ export interface PluginContext {
|
||||
create(input: PluginNoteCreateInput): Promise<PluginNote>;
|
||||
update(noteId: string, input: PluginNoteUpdateInput): Promise<PluginNote>;
|
||||
delete(noteId: string, options?: { permanent?: boolean }): Promise<void>;
|
||||
move(noteIds: string[], notebookId: string): Promise<number>;
|
||||
pin(noteIds: string[], isPinned: boolean): Promise<number>;
|
||||
restore(noteId: string): Promise<PluginNote>;
|
||||
revisions: {
|
||||
list(noteId: string): Promise<PluginNoteRevision[]>;
|
||||
restore(noteId: string, revisionId: string): Promise<PluginNote>;
|
||||
};
|
||||
};
|
||||
notebooks: {
|
||||
list(): Promise<PluginNotebook[]>;
|
||||
create(input: { name: string; parentId?: string | null }): Promise<PluginNotebook>;
|
||||
update(notebookId: string, input: { name?: string; parentId?: string | null; sortOrder?: number }): Promise<PluginNotebook>;
|
||||
delete(notebookId: string): Promise<void>;
|
||||
};
|
||||
tags: {
|
||||
list(): Promise<PluginTag[]>;
|
||||
@@ -224,6 +283,17 @@ export interface PluginContext {
|
||||
replaceSelection(contentMarkdown: string): Promise<void>;
|
||||
insertAtCursor(contentMarkdown: string): Promise<void>;
|
||||
};
|
||||
resources: {
|
||||
list(noteId?: string): Promise<PluginResource[]>;
|
||||
upload(noteId: string, file: File): Promise<PluginResource>;
|
||||
rename(resourceId: string, filename: string): Promise<PluginResource>;
|
||||
delete(resourceId: string): Promise<void>;
|
||||
};
|
||||
settings: {
|
||||
get(key: string): Promise<PluginSettingValue | null>;
|
||||
set(key: string, value: PluginSettingValue): Promise<void>;
|
||||
remove(key: string): Promise<void>;
|
||||
};
|
||||
network: {
|
||||
fetch(input: string, init?: RequestInit): Promise<Response>;
|
||||
};
|
||||
@@ -300,6 +370,73 @@ const normalizeThemeTokens = (value: unknown): ThemeTokens => {
|
||||
return tokens;
|
||||
};
|
||||
|
||||
const SETTING_KEY_PATTERN = /^[a-z][a-z0-9._-]*$/;
|
||||
|
||||
const normalizePluginSettings = (value: unknown): PluginSettingsSchema => {
|
||||
if (!isRecord(value) || !Array.isArray(value.fields)) throw new Error("Plugin settings must contain a fields array.");
|
||||
if (value.fields.length > 50) throw new Error("Plugin settings cannot contain more than 50 fields.");
|
||||
const keys = new Set<string>();
|
||||
const fields = value.fields.map((field): PluginSettingField => {
|
||||
if (!isRecord(field) || typeof field.key !== "string" || !SETTING_KEY_PATTERN.test(field.key)) {
|
||||
throw new Error("Plugin setting keys must start with a lowercase letter and contain only lowercase letters, numbers, dots, dashes, or underscores.");
|
||||
}
|
||||
if (keys.has(field.key)) throw new Error(`Duplicate plugin setting key: ${field.key}`);
|
||||
keys.add(field.key);
|
||||
if (typeof field.label !== "string" || !field.label.trim() || field.label.length > 200) throw new Error(`Plugin setting ${field.key} requires a label of at most 200 characters.`);
|
||||
if (typeof field.description === "string" && field.description.length > 1000) throw new Error(`Plugin setting ${field.key} description is too long.`);
|
||||
const common = {
|
||||
key: field.key,
|
||||
label: field.label.trim(),
|
||||
...(typeof field.description === "string" && field.description.trim() ? { description: field.description.trim() } : {}),
|
||||
...(field.required === true ? { required: true } : {}),
|
||||
};
|
||||
if (field.type === "text" || field.type === "secret") {
|
||||
if (field.type === "secret" && field.default !== undefined) throw new Error(`Secret setting ${field.key} cannot declare a default value.`);
|
||||
if (field.default !== undefined && typeof field.default !== "string") throw new Error(`Plugin setting ${field.key} default must be a string.`);
|
||||
if (field.placeholder !== undefined && typeof field.placeholder !== "string") throw new Error(`Plugin setting ${field.key} placeholder must be a string.`);
|
||||
return {
|
||||
...common,
|
||||
type: field.type,
|
||||
...(typeof field.default === "string" ? { default: field.default } : {}),
|
||||
...(typeof field.placeholder === "string" ? { placeholder: field.placeholder } : {}),
|
||||
} as PluginSettingField;
|
||||
}
|
||||
if (field.type === "number") {
|
||||
for (const key of ["default", "min", "max", "step"] as const) {
|
||||
if (field[key] !== undefined && (typeof field[key] !== "number" || !Number.isFinite(field[key]))) {
|
||||
throw new Error(`Plugin setting ${field.key} ${key} must be a finite number.`);
|
||||
}
|
||||
}
|
||||
if (typeof field.min === "number" && typeof field.max === "number" && field.min > field.max) throw new Error(`Plugin setting ${field.key} min cannot exceed max.`);
|
||||
if (typeof field.step === "number" && field.step <= 0) throw new Error(`Plugin setting ${field.key} step must be positive.`);
|
||||
if (typeof field.default === "number" && ((typeof field.min === "number" && field.default < field.min) || (typeof field.max === "number" && field.default > field.max))) {
|
||||
throw new Error(`Plugin setting ${field.key} default is outside its allowed range.`);
|
||||
}
|
||||
return { ...common, type: "number", ...Object.fromEntries(["default", "min", "max", "step"].flatMap((key) => typeof field[key] === "number" ? [[key, field[key]]] : [])) } as PluginSettingField;
|
||||
}
|
||||
if (field.type === "boolean") {
|
||||
if (field.default !== undefined && typeof field.default !== "boolean") throw new Error(`Plugin setting ${field.key} default must be a boolean.`);
|
||||
return { ...common, type: "boolean", ...(typeof field.default === "boolean" ? { default: field.default } : {}) };
|
||||
}
|
||||
if (field.type === "select") {
|
||||
if (!Array.isArray(field.options) || field.options.length === 0 || field.options.length > 100) throw new Error(`Plugin setting ${field.key} requires between 1 and 100 select options.`);
|
||||
const optionValues = new Set<string>();
|
||||
const options = field.options.map((option) => {
|
||||
if (!isRecord(option) || typeof option.value !== "string" || !option.value || typeof option.label !== "string" || !option.label.trim()) {
|
||||
throw new Error(`Plugin setting ${field.key} has an invalid select option.`);
|
||||
}
|
||||
if (optionValues.has(option.value)) throw new Error(`Plugin setting ${field.key} has a duplicate select value.`);
|
||||
optionValues.add(option.value);
|
||||
return { value: option.value, label: option.label.trim() };
|
||||
});
|
||||
if (field.default !== undefined && (typeof field.default !== "string" || !optionValues.has(field.default))) throw new Error(`Plugin setting ${field.key} default must match a select option.`);
|
||||
return { ...common, type: "select", options, ...(typeof field.default === "string" ? { default: field.default } : {}) };
|
||||
}
|
||||
throw new Error(`Plugin setting ${field.key} has an unsupported type.`);
|
||||
});
|
||||
return { fields };
|
||||
};
|
||||
|
||||
export const parseExtensionManifest = (value: unknown): ExtensionManifest => {
|
||||
if (!isRecord(value)) throw new Error("Extension manifest must be an object.");
|
||||
assertCommonManifest(value);
|
||||
@@ -328,7 +465,8 @@ export const parseExtensionManifest = (value: unknown): ExtensionManifest => {
|
||||
: Array.isArray(value.platforms) && value.platforms.every((platform) => ["web", "desktop", "android", "ios"].includes(String(platform)))
|
||||
? [...new Set(value.platforms.map(String))] as ExtensionPlatform[]
|
||||
: (() => { throw new Error("Plugin platforms contains an unsupported platform."); })();
|
||||
return { ...value, type: "plugin", permissions, networkHosts, platforms } as PluginManifest;
|
||||
const settings = value.settings === undefined ? undefined : normalizePluginSettings(value.settings);
|
||||
return { ...value, type: "plugin", permissions, networkHosts, platforms, settings } as PluginManifest;
|
||||
}
|
||||
|
||||
if (value.type === "theme") {
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"declaration": true,
|
||||
"emitDeclarationOnly": true,
|
||||
"noEmit": false,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src/index.ts"]
|
||||
}
|
||||
@@ -348,6 +348,13 @@ export const enUS = {
|
||||
empty: "No plugins or themes are installed on this device.",
|
||||
toggle: "Enable or disable {{name}}",
|
||||
uninstall: "Uninstall",
|
||||
settings: {
|
||||
title: "Plugin settings",
|
||||
saved: "Settings saved on this device.",
|
||||
required: "{{name}} is required.",
|
||||
secretConfigured: "Configured — leave blank to keep unchanged",
|
||||
none: "None",
|
||||
},
|
||||
details: {
|
||||
title: "Plugin details",
|
||||
open: "View details for {{name}}",
|
||||
|
||||
@@ -348,6 +348,13 @@ export const zhCN = {
|
||||
empty: "当前设备尚未安装插件或主题。",
|
||||
toggle: "启用或停用 {{name}}",
|
||||
uninstall: "卸载",
|
||||
settings: {
|
||||
title: "插件设置",
|
||||
saved: "设置已保存到当前设备。",
|
||||
required: "{{name}} 为必填项。",
|
||||
secretConfigured: "已配置,留空表示保持不变",
|
||||
none: "无",
|
||||
},
|
||||
details: {
|
||||
title: "插件详情",
|
||||
open: "查看 {{name}} 详情",
|
||||
|
||||
Reference in new issue
Block a user