739 lines
50 KiB
TypeScript
739 lines
50 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { createHash } from "node:crypto";
|
|
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { basename, join } from "node:path";
|
|
|
|
import { FAMILIAROS_PLUGIN_MANIFEST_FILENAME, type FamiliarOSDeclarativePluginManifest } from "../src/plugin-manifest.js";
|
|
import { PluginService, executeDefaultPetPluginCommand, getDefaultPetPluginCommands, setPluginServiceForTests, stopPluginService } from "../src/plugin-service.js";
|
|
import { PluginStateStore, type PluginStateRecord } from "../src/plugin-state.js";
|
|
|
|
let lastRoot = "";
|
|
|
|
class FakeRuntime {
|
|
reloads: string[] = [];
|
|
logs: Array<{ level: string; message: string; fields?: Record<string, unknown> }> = [];
|
|
commandState: Record<string, Array<{ id: string; title: string; description?: string; form?: { submitLabel?: string; fields: Array<{ id: string; type: string; label: string }> } }>> = {};
|
|
executed: Array<{ pluginId: string; commandId: string }> = [];
|
|
commandError: Error | null = null;
|
|
stopped = false;
|
|
async start(): Promise<void> {}
|
|
stop(): void { this.stopped = true; }
|
|
async reloadPlugin(id: string): Promise<void> { this.reloads.push(id); }
|
|
getPluginState(id: string): { commands: Array<{ id: string; title: string; description?: string; form?: { submitLabel?: string; fields: Array<{ id: string; type: string; label: string }> } }> } { return { commands: this.commandState[id] ?? [] }; }
|
|
async executeCommand(pluginId: string, commandId: string): Promise<void> { this.executed.push({ pluginId, commandId }); if (this.commandError) throw this.commandError; }
|
|
log(level: string, message: string, fields?: Record<string, unknown>): void { this.logs.push({ level, message, fields }); }
|
|
}
|
|
|
|
class ThrowingStateStore extends PluginStateStore {
|
|
failNextUpsert = false;
|
|
override upsertRecord(record: PluginStateRecord): PluginStateRecord {
|
|
if (this.failNextUpsert) {
|
|
this.failNextUpsert = false;
|
|
throw new Error("state write failed");
|
|
}
|
|
return super.upsertRecord(record);
|
|
}
|
|
}
|
|
|
|
await scenario("initializes store and roots", async ({ userData }) => {
|
|
const service = new PluginService({ userDataPath: userData, petApi: { speak() {}, react() {}, moveBy() {}, wander() {}, moveToHome() {} } });
|
|
await service.start();
|
|
assert.equal(existsSync(join(userData, "plugins")), true);
|
|
assert.equal(existsSync(join(userData, "plugins-dev")), true);
|
|
assert.deepEqual(await service.getSnapshot(), { plugins: [] });
|
|
service.stop();
|
|
});
|
|
|
|
await scenario("snapshot omits paths and includes manifest config", async ({ service, store }) => {
|
|
addPlugin(store, { config: { interval: 7 } }, manifest({ configSchema: { interval: { type: "number", default: 5 } }, everyMinutes: { config: "interval" } }));
|
|
const snapshot = await service.getSnapshot();
|
|
assert.equal(snapshot.plugins[0].name, "Plug");
|
|
assert.deepEqual(snapshot.plugins[0].effectiveConfig, { interval: 7 });
|
|
assert.equal("manifestPath" in snapshot.plugins[0], false);
|
|
assert.equal("installPath" in snapshot.plugins[0], false);
|
|
});
|
|
|
|
await scenario("snapshot exposes declared v3 svg icon data url", async ({ root, service, store }) => {
|
|
const installPath = join(root, "familiaros.reminders");
|
|
mkdirSync(join(installPath, "assets"), { recursive: true });
|
|
writeFileSync(join(installPath, "index.js"), "export default {};", "utf8");
|
|
writeFileSync(join(installPath, "assets", "reminders.svg"), "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\"><circle cx=\"12\" cy=\"12\" r=\"10\"/></svg>", "utf8");
|
|
addPlugin(store, { id: "familiaros.reminders", version: "1.0.0", installPath }, { manifestVersion: 3, id: "familiaros.reminders", name: "Reminders", version: "1.0.0", runtime: "javascript", sdkVersion: "3.0.0", entry: "index.js", icon: "bell", permissions: [], assets: { icons: { reminders: "assets/reminders.svg" } } });
|
|
const snapshot = await service.getSnapshot();
|
|
assert.match(snapshot.plugins[0].iconDataUrl ?? "", /^data:image\/svg\+xml;base64,/);
|
|
assert.equal(Buffer.from((snapshot.plugins[0].iconDataUrl ?? "").split(",")[1] ?? "", "base64").toString("utf8").includes("<svg"), true);
|
|
});
|
|
|
|
await scenario("invalid manifest appears safe broken", async ({ service, store }) => {
|
|
addPlugin(store, {}, { bad: true });
|
|
const snapshot = await service.getSnapshot();
|
|
assert.equal(snapshot.plugins[0].brokenReason, "Plugin manifest validation failed.");
|
|
});
|
|
|
|
await scenario("missing manifest does not leak absolute paths", async ({ service, store, root }) => {
|
|
const missingPath = join(root, "missing", FAMILIAROS_PLUGIN_MANIFEST_FILENAME);
|
|
store.upsertRecord({ id: "missing", version: "1.0.0", manifestPath: missingPath, installPath: join(root, "missing"), source: "local", enabled: true, approvedPermissions: ["timer"], config: {} });
|
|
const snapshot = await service.getSnapshot();
|
|
const reason = snapshot.plugins[0].brokenReason ?? "";
|
|
assert.equal(reason, "Plugin manifest is unavailable.");
|
|
assert.equal(reason.includes(root), false);
|
|
});
|
|
|
|
await scenario("stored path-like broken reason is sanitized", async ({ service, store, root }) => {
|
|
addPlugin(store, { brokenReason: `ENOENT: no such file or directory, open '${join(root, "plug", FAMILIAROS_PLUGIN_MANIFEST_FILENAME)}'` });
|
|
const snapshot = await service.getSnapshot();
|
|
const reason = snapshot.plugins[0].brokenReason ?? "";
|
|
assert.equal(reason, "Plugin needs attention. Check logs for details.");
|
|
assert.equal(reason.includes(root), false);
|
|
});
|
|
|
|
await scenario("config save rejects unknown and non plain", async ({ service, store }) => {
|
|
addPlugin(store, {}, manifest({ configSchema: { interval: { type: "number", default: 5 } }, everyMinutes: { config: "interval" } }));
|
|
assert.equal((await service.saveConfig("plug", { unknown: true })).ok, false);
|
|
assert.equal((await service.saveConfig("plug", new Date())).ok, false);
|
|
});
|
|
|
|
await scenario("config save replaces and reloads", async ({ service, store, runtime }) => {
|
|
addPlugin(store, { config: { text: "old", keep: true } }, manifest({ configSchema: { text: { type: "text" } } }));
|
|
const result = await service.saveConfig("plug", { text: "new" });
|
|
assert.equal(result.ok, true);
|
|
assert.deepEqual(store.getRecord("plug")?.config, { text: "new" });
|
|
assert.deepEqual(runtime.reloads, ["plug"]);
|
|
});
|
|
|
|
await scenario("config save reload preserves plugin user sounds", async ({ root, userData, store, runtime }) => {
|
|
const service = new PluginService({ userDataPath: userData, stateStore: store, runtime: runtime as never, allowedPluginRoots: [root] });
|
|
addPlugin(store, { manifestVersion: 3, runtime: "javascript", sdkVersion: "3.0.0", config: { customSound: { kind: "user-sound", id: "a".repeat(32), name: "Bell" } } }, { manifestVersion: 3, id: "plug", name: "Plug", version: "1.0.0", runtime: "javascript", sdkVersion: "3.0.0", entry: "index.js", permissions: [], configSchema: { customSound: { type: "sound" } } });
|
|
const soundDir = join(userData, "plugin-user-sounds", "plug");
|
|
const soundPath = join(soundDir, `${"a".repeat(32)}.ogg`);
|
|
mkdirSync(soundDir, { recursive: true });
|
|
writeFileSync(soundPath, "sound");
|
|
const result = await service.saveConfig("plug", { customSound: { kind: "user-sound", id: "a".repeat(32), name: "Bell" } });
|
|
assert.equal(result.ok, true);
|
|
assert.deepEqual(runtime.reloads, ["plug"]);
|
|
assert.equal(existsSync(soundPath), true);
|
|
});
|
|
|
|
await scenario("pickConfigSound logs stages and useful unsupported format error", async ({ root, store, runtime }) => {
|
|
const selectedPath = join(root, "tone.flac");
|
|
writeFileSync(selectedPath, "sound");
|
|
const service = new PluginService({
|
|
stateStore: store,
|
|
runtime: runtime as never,
|
|
allowedPluginRoots: [root],
|
|
showSoundOpenDialog: async () => ({ canceled: false, filePaths: [selectedPath] }),
|
|
capabilities: { audio: { importUserSoundFromPath: async () => { throw new Error("Plugin sound format is not supported."); } } } as never,
|
|
});
|
|
addPlugin(store, { manifestVersion: 3, runtime: "javascript", sdkVersion: "3.0.0" }, { manifestVersion: 3, id: "plug", name: "Plug", version: "1.0.0", runtime: "javascript", sdkVersion: "3.0.0", entry: "index.js", permissions: [], configSchema: { customSound: { type: "sound" } } });
|
|
const result = await service.pickConfigSound("plug");
|
|
assert.equal(result.ok, false);
|
|
assert.equal(result.error, "Plugin sound format is not supported.");
|
|
assert.equal(runtime.logs.some((entry) => entry.message === "Plugin config sound pick requested." && entry.fields?.pluginId === "plug"), true);
|
|
assert.equal(runtime.logs.some((entry) => entry.message === "Plugin config sound picker opened."), true);
|
|
const selectedLog = runtime.logs.find((entry) => entry.message === "Plugin config sound file selected.");
|
|
assert.equal(selectedLog?.fields?.basename, basename(selectedPath));
|
|
assert.equal(selectedLog?.fields?.ext, ".flac");
|
|
assert.equal(selectedLog?.fields?.sizeBytes, 5);
|
|
assert.equal(Object.values(selectedLog?.fields ?? {}).some((value) => typeof value === "string" && value.includes(root)), false);
|
|
assert.equal(runtime.logs.some((entry) => entry.message === "Plugin config sound import failed." && entry.fields?.reason === "Plugin sound format is not supported."), true);
|
|
});
|
|
|
|
await scenario("pickConfigSound returns opaque sound and logs success", async ({ root, store, runtime }) => {
|
|
const selectedPath = join(root, "ding.ogg");
|
|
writeFileSync(selectedPath, "sound");
|
|
const service = new PluginService({
|
|
stateStore: store,
|
|
runtime: runtime as never,
|
|
allowedPluginRoots: [root],
|
|
showSoundOpenDialog: async () => ({ canceled: false, filePaths: [selectedPath] }),
|
|
capabilities: { audio: { importUserSoundFromPath: async (pluginId: string, path: string) => ({ kind: "user-sound", id: "abc123", name: basename(path) }) } } as never,
|
|
});
|
|
addPlugin(store, { manifestVersion: 3, runtime: "javascript", sdkVersion: "3.0.0" }, { manifestVersion: 3, id: "plug", name: "Plug", version: "1.0.0", runtime: "javascript", sdkVersion: "3.0.0", entry: "index.js", permissions: [], configSchema: { customSound: { type: "sound" } } });
|
|
const result = await service.pickConfigSound("plug");
|
|
assert.equal(result.ok, true);
|
|
assert.equal("sound" in result, true);
|
|
if (!("sound" in result)) throw new Error("Expected picked sound result.");
|
|
assert.deepEqual(result.sound, { kind: "user-sound", id: "abc123", name: "ding.ogg" });
|
|
assert.equal(runtime.logs.some((entry) => entry.message === "Plugin config sound import succeeded." && entry.fields?.pluginId === "plug" && entry.fields?.soundId === "abc123" && entry.fields?.name === "ding.ogg"), true);
|
|
});
|
|
|
|
await scenario("enable disable persists and reloads", async ({ service, store, runtime }) => {
|
|
addPlugin(store, { enabled: false });
|
|
const result = await service.setEnabled("plug", true);
|
|
assert.equal(result.ok, true);
|
|
assert.equal(store.getRecord("plug")?.enabled, true);
|
|
assert.deepEqual(runtime.reloads, ["plug"]);
|
|
});
|
|
|
|
await scenario("reload unknown is safe error", async ({ service }) => {
|
|
const result = await service.reload("missing");
|
|
assert.equal(result.ok, false);
|
|
assert.match(result.error, /not installed/);
|
|
});
|
|
|
|
await scenario("uninstall clears plugin user sounds", async ({ userData, root, store }) => {
|
|
mkdirSync(join(userData, "plugins"), { recursive: true });
|
|
mkdirSync(join(userData, "plugins-dev"), { recursive: true });
|
|
const runtime = new FakeRuntime();
|
|
const service = new PluginService({ userDataPath: userData, stateStore: store, runtime: runtime as never, allowedPluginRoots: [root] });
|
|
addPlugin(store, { source: "catalog", installPath: join(userData, "plugins", "plug") });
|
|
const soundDir = join(userData, "plugin-user-sounds", "plug");
|
|
mkdirSync(soundDir, { recursive: true });
|
|
writeFileSync(join(soundDir, "a".repeat(32) + ".ogg"), "sound");
|
|
const result = await service.uninstall("plug");
|
|
assert.equal(result.ok, true);
|
|
assert.equal(existsSync(soundDir), false);
|
|
});
|
|
|
|
await scenario("stop cancels runtime", async ({ service, runtime }) => {
|
|
service.stop();
|
|
assert.equal(runtime.stopped, true);
|
|
});
|
|
|
|
await localScenario("loadLocal snapshots manifest disabled", async ({ service, store, source }) => {
|
|
writeManifest(source, manifest({ id: "local-plug", permissions: ["timer", "familiar:speak"] }));
|
|
const result = await service.loadLocal();
|
|
assert.equal(result.ok, true);
|
|
const record = store.getRecord("local-plug");
|
|
assert.equal(record?.enabled, false);
|
|
assert.equal(record?.source, "local");
|
|
assert.deepEqual(record?.approvedPermissions, ["familiar:speak", "timer"]);
|
|
assert.equal(existsSync(join(record?.installPath ?? "", FAMILIAROS_PLUGIN_MANIFEST_FILENAME)), true);
|
|
assert.equal("installPath" in result.snapshot.plugins[0], false);
|
|
});
|
|
|
|
await localScenario("loadLocal picker cancel does not install", async ({ service, store }) => {
|
|
service["__cancelPicker"] = true;
|
|
const result = await service.loadLocal();
|
|
assert.equal(result.ok, true);
|
|
assert.deepEqual(store.listRecords(), []);
|
|
});
|
|
|
|
await localScenario("loadLocal permission cancel does not install", async ({ service, store, source }) => {
|
|
writeManifest(source, manifest({ id: "cancel-plug" }));
|
|
service["__denyPermissions"] = true;
|
|
const result = await service.loadLocal();
|
|
assert.equal(result.ok, true);
|
|
assert.deepEqual(store.listRecords(), []);
|
|
assert.equal(existsSync(join(service["__userData"] as string, "plugins-dev", "cancel-plug", FAMILIAROS_PLUGIN_MANIFEST_FILENAME)), false);
|
|
});
|
|
|
|
await localScenario("loadLocal permission cancel preserves previous snapshot", async ({ service, store, source, userData }) => {
|
|
writeManifest(source, manifest({ id: "keep-plug", version: "2.0.0", permissions: ["timer", "familiar:speak", "familiar:reaction"], triggers: [{ on: "timer", everyMinutes: 5, actions: [{ type: "familiar.react", reaction: "celebrating" }] }] }));
|
|
const install = join(userData, "plugins-dev", "keep-plug");
|
|
const oldManifestPath = writeManifest(install, manifest({ id: "keep-plug", version: "1.0.0", permissions: ["timer", "familiar:speak"] }));
|
|
store.upsertRecord({ id: "keep-plug", version: "1.0.0", installPath: install, manifestPath: oldManifestPath, source: "local", enabled: true, approvedPermissions: ["timer", "familiar:speak"], config: {} });
|
|
service["__denyPermissions"] = true;
|
|
const result = await service.loadLocal();
|
|
assert.equal(result.ok, true);
|
|
assert.equal(store.getRecord("keep-plug")?.version, "1.0.0");
|
|
assert.equal(JSON.parse(readFileSync(oldManifestPath, "utf8")).version, "1.0.0");
|
|
});
|
|
|
|
await localScenario("loadLocal rejects invalid manifest safely", async ({ service, source, root }) => {
|
|
writeManifest(source, { bad: true });
|
|
const result = await service.loadLocal();
|
|
assert.equal(result.ok, false);
|
|
assert.equal(result.error.includes(root), false);
|
|
assert.equal(result.error, "Plugin manifest validation failed.");
|
|
});
|
|
|
|
await localScenario("loadLocal rejects source symlink", async ({ service, source, root }) => {
|
|
writeManifest(source, manifest({ id: "symlink-folder" }));
|
|
const link = join(root, "source-link");
|
|
symlinkSync(source, link, "dir");
|
|
service["__source"] = link;
|
|
const result = await service.loadLocal();
|
|
assert.equal(result.ok, false);
|
|
});
|
|
|
|
await localScenario("loadLocal rejects manifest symlink", async ({ service, source, root }) => {
|
|
const real = join(root, "real-manifest.json");
|
|
writeFileSync(real, JSON.stringify(manifest({ id: "symlink-manifest" })), "utf8");
|
|
symlinkSync(real, join(source, FAMILIAROS_PLUGIN_MANIFEST_FILENAME));
|
|
const result = await service.loadLocal();
|
|
assert.equal(result.ok, false);
|
|
});
|
|
|
|
await localScenario("loadLocal reload preserves enabled for subset permissions", async ({ service, store, runtime, source, userData }) => {
|
|
writeManifest(source, manifest({ id: "reload-plug", version: "2.0.0", permissions: ["timer", "familiar:speak"] }));
|
|
const existingInstall = join(userData, "plugins-dev", "reload-plug");
|
|
const existingManifest = writeManifest(existingInstall, manifest({ id: "reload-plug", permissions: ["timer", "familiar:speak"] }));
|
|
store.upsertRecord({ id: "reload-plug", version: "1.0.0", installPath: existingInstall, manifestPath: existingManifest, source: "local", enabled: true, approvedPermissions: ["timer", "familiar:speak"], config: {} });
|
|
const result = await service.loadLocal();
|
|
assert.equal(result.ok, true);
|
|
assert.equal(store.getRecord("reload-plug")?.enabled, true);
|
|
assert.equal(store.getRecord("reload-plug")?.version, "2.0.0");
|
|
assert.deepEqual(runtime.reloads, ["reload-plug"]);
|
|
});
|
|
|
|
await localScenario("loadLocal rejects catalog collision", async ({ service, store, source, userData }) => {
|
|
writeManifest(source, manifest({ id: "catalog-plug" }));
|
|
const install = join(userData, "plugins", "catalog-plug");
|
|
const manifestPath = writeManifest(install, manifest({ id: "catalog-plug" }));
|
|
store.upsertRecord({ id: "catalog-plug", version: "1.0.0", installPath: install, manifestPath, source: "catalog", enabled: false, approvedPermissions: ["timer", "familiar:speak"], config: {} });
|
|
const result = await service.loadLocal();
|
|
assert.equal(result.ok, false);
|
|
assert.match(result.error, /catalog plugin/);
|
|
assert.equal(existsSync(join(userData, "plugins-dev", "catalog-plug", FAMILIAROS_PLUGIN_MANIFEST_FILENAME)), false);
|
|
});
|
|
|
|
await localScenario("loadLocal rejects destination symlink before write", async ({ service, source, userData, root }) => {
|
|
writeManifest(source, manifest({ id: "dest-link" }));
|
|
const outside = join(root, "outside-target");
|
|
mkdirSync(outside, { recursive: true });
|
|
symlinkSync(outside, join(userData, "plugins-dev", "dest-link"), "dir");
|
|
const result = await service.loadLocal();
|
|
assert.equal(result.ok, false);
|
|
assert.equal(existsSync(join(outside, FAMILIAROS_PLUGIN_MANIFEST_FILENAME)), false);
|
|
});
|
|
|
|
await localScenario("loadLocal permission change disables", async ({ service, store, source, userData }) => {
|
|
writeManifest(source, manifest({ id: "perm-plug", permissions: ["timer", "familiar:speak", "familiar:reaction"], triggers: [{ on: "timer", everyMinutes: 5, actions: [{ type: "familiar.react", reaction: "celebrating" }] }] }));
|
|
const install = join(userData, "plugins-dev", "perm-plug");
|
|
const manifestPath = writeManifest(install, manifest({ id: "perm-plug", permissions: ["timer", "familiar:speak"] }));
|
|
store.upsertRecord({ id: "perm-plug", version: "1.0.0", installPath: install, manifestPath, source: "local", enabled: true, approvedPermissions: ["timer", "familiar:speak"], config: {} });
|
|
const result = await service.loadLocal();
|
|
assert.equal(result.ok, true);
|
|
assert.equal(store.getRecord("perm-plug")?.enabled, false);
|
|
assert.deepEqual(store.getRecord("perm-plug")?.approvedPermissions, ["familiar:speak", "familiar:reaction", "timer"]);
|
|
assert.deepEqual(service["__runtimeReloads"], ["perm-plug"]);
|
|
});
|
|
|
|
await localScenario("loadLocal uses real source path", async ({ service, source }) => {
|
|
writeManifest(source, manifest({ id: "realpath-plug" }));
|
|
const result = await service.loadLocal();
|
|
assert.equal(result.ok, true);
|
|
assert.equal(result.snapshot.plugins[0].id, "realpath-plug");
|
|
});
|
|
|
|
await localScenario("loadLocal copies only manifest", async ({ service, source, store, userData }) => {
|
|
writeManifest(source, manifest({ id: "only-manifest" }));
|
|
writeFileSync(join(source, "extra.txt"), "extra", "utf8");
|
|
const result = await service.loadLocal();
|
|
assert.equal(result.ok, true);
|
|
const install = store.getRecord("only-manifest")?.installPath ?? join(userData, "plugins-dev", "only-manifest");
|
|
assert.equal(existsSync(join(install, FAMILIAROS_PLUGIN_MANIFEST_FILENAME)), true);
|
|
assert.equal(existsSync(join(install, "extra.txt")), false);
|
|
});
|
|
|
|
await localScenario("loadLocal snapshots javascript entry", async ({ service, source, store, userData }) => {
|
|
writeManifest(source, { manifestVersion: 2, id: "js-local", name: "JS Local", version: "1.0.0", runtime: "javascript", sdkVersion: "1.0.0", entry: "index.mjs", permissions: ["familiar:speak"] });
|
|
writeFileSync(join(source, "index.mjs"), "export default {};\n", "utf8");
|
|
const result = await service.loadLocal();
|
|
assert.equal(result.ok, true);
|
|
const install = store.getRecord("js-local")?.installPath ?? join(userData, "plugins-dev", "js-local");
|
|
assert.equal(existsSync(join(install, FAMILIAROS_PLUGIN_MANIFEST_FILENAME)), true);
|
|
assert.equal(readFileSync(join(install, "index.mjs"), "utf8"), "export default {};\n");
|
|
});
|
|
|
|
await localScenario("loadLocal snapshots v3 locale catalogs for translated UI", async ({ service, source, store, userData, runtime }) => {
|
|
writeManifest(source, {
|
|
manifestVersion: 3,
|
|
id: "i18n-local",
|
|
name: "$t:plugin.name",
|
|
description: "$t:plugin.description",
|
|
version: "1.0.0",
|
|
runtime: "javascript",
|
|
sdkVersion: "3.0.0",
|
|
entry: "index.js",
|
|
permissions: ["familiar:speak"],
|
|
configSchema: {
|
|
enabled: { type: "boolean", default: true, label: "$t:config.enabled.label", description: "$t:config.enabled.description" },
|
|
},
|
|
});
|
|
writeFileSync(join(source, "index.js"), "FamiliarOSPlugin.register({ start() {} });\n", "utf8");
|
|
mkdirSync(join(source, "locales"), { recursive: true });
|
|
writeFileSync(join(source, "locales", "en.json"), JSON.stringify({
|
|
"plugin.name": "Translated Plugin",
|
|
"plugin.description": "Translated description.",
|
|
"config.enabled.label": "Translated toggle",
|
|
"config.enabled.description": "Translated toggle description.",
|
|
"command.run.title": "Translated command",
|
|
"command.run.description": "Translated command description.",
|
|
"command.run.submit": "Translated submit",
|
|
"form.message.label": "Translated message",
|
|
}), "utf8");
|
|
|
|
const result = await service.loadLocal();
|
|
assert.equal(result.ok, true);
|
|
const install = store.getRecord("i18n-local")?.installPath ?? join(userData, "plugins-dev", "i18n-local");
|
|
assert.equal(existsSync(join(install, "locales", "en.json")), true);
|
|
const plugin = result.snapshot.plugins[0];
|
|
assert.equal(plugin.name, "Translated Plugin");
|
|
assert.equal(plugin.description, "Translated description.");
|
|
assert.equal(plugin.configSchema?.enabled?.label, "Translated toggle");
|
|
assert.equal(plugin.configSchema?.enabled?.description, "Translated toggle description.");
|
|
|
|
runtime.commandState["i18n-local"] = [{ id: "run", title: "$t:command.run.title", description: "$t:command.run.description", form: { submitLabel: "$t:command.run.submit", fields: [{ id: "message", type: "text", label: "$t:form.message.label" }] } } as never];
|
|
const refreshed = (await service.getSnapshot()).plugins[0];
|
|
assert.equal(refreshed.commands?.[0]?.title, "Translated command");
|
|
assert.equal(refreshed.commands?.[0]?.description, "Translated command description.");
|
|
assert.equal(refreshed.commands?.[0]?.form?.submitLabel, "Translated submit");
|
|
assert.equal(refreshed.commands?.[0]?.form?.fields[0]?.label, "Translated message");
|
|
});
|
|
|
|
await localScenario("bundled seeding copies manifest and preserves user choices", async ({ userData, root, store }) => {
|
|
const official = join(root, "official");
|
|
const source = join(official, "familiaros.reminders");
|
|
writeManifest(source, { manifestVersion: 2, id: "familiaros.reminders", name: "Quick Reminders", version: "1.0.0", runtime: "javascript", sdkVersion: "1.0.0", entry: "index.js", permissions: ["familiar:speak"], configSchema: { minutes: { type: "number", default: 30 } } });
|
|
writeFileSync(join(source, "index.js"), "FamiliarOSPlugin.register({ start() {} });\n", "utf8");
|
|
const service = new PluginService({ userDataPath: userData, stateStore: store, runtime: new FakeRuntime() as never, bundledPluginSourceDirs: [official] });
|
|
await service.start();
|
|
let record = store.getRecord("familiaros.reminders");
|
|
assert.equal(record?.source, "catalog");
|
|
assert.equal(record?.bundled, true);
|
|
assert.equal(record?.enabled, true);
|
|
assert.equal(readFileSync(join(record?.installPath ?? "", "index.js"), "utf8"), "FamiliarOSPlugin.register({ start() {} });\n");
|
|
store.replaceConfig("familiaros.reminders", { minutes: 45 });
|
|
store.setEnabled("familiaros.reminders", false);
|
|
writeManifest(source, { manifestVersion: 2, id: "familiaros.reminders", name: "Quick Reminders", version: "2.0.0", runtime: "javascript", sdkVersion: "1.0.0", entry: "index.js", permissions: ["familiar:speak", "familiar:reaction"] });
|
|
await service.seedBundledPlugins();
|
|
record = store.getRecord("familiaros.reminders");
|
|
assert.equal(record?.version, "2.0.0");
|
|
assert.equal(record?.enabled, false);
|
|
assert.deepEqual(record?.config, { minutes: 45 });
|
|
assert.deepEqual(record?.approvedPermissions, ["familiar:speak", "familiar:reaction"]);
|
|
});
|
|
|
|
await localScenario("bundled seeding prunes stale ids and blocks uninstall update", async ({ userData, root, store }) => {
|
|
const oldInstall = join(userData, "plugins", "familiaros.pomodoro");
|
|
const oldManifest = writeManifest(oldInstall, manifest({ id: "familiaros.pomodoro" }));
|
|
store.upsertRecord({ id: "familiaros.pomodoro", version: "1.0.0", installPath: oldInstall, manifestPath: oldManifest, source: "catalog", enabled: true, approvedPermissions: ["timer", "familiar:speak"], config: {} });
|
|
const official = join(root, "official");
|
|
const source = join(official, "familiaros.reminders");
|
|
writeManifest(source, { manifestVersion: 2, id: "familiaros.reminders", name: "Quick Reminders", version: "1.0.0", runtime: "javascript", sdkVersion: "1.0.0", entry: "index.js", permissions: ["network"], network: { hosts: ["api.github.com"] } });
|
|
writeFileSync(join(source, "index.js"), "FamiliarOSPlugin.register({ start() {} });\n", "utf8");
|
|
const service = new PluginService({ userDataPath: userData, stateStore: store, runtime: new FakeRuntime() as never, bundledPluginSourceDirs: [official] });
|
|
await service.start();
|
|
assert.equal(store.getRecord("familiaros.pomodoro"), undefined);
|
|
assert.equal(store.getRecord("familiaros.reminders")?.enabled, true);
|
|
assert.deepEqual(store.getRecord("familiaros.reminders")?.approvedNetworkHosts, ["api.github.com"]);
|
|
assert.equal((await service.uninstall("familiaros.reminders")).ok, false);
|
|
const update = await service.updateCatalog("familiaros.reminders");
|
|
assert.equal(update.ok, false);
|
|
assert.match(update.error, /Bundled plugins update/);
|
|
});
|
|
|
|
await localScenario("bundled seeding prunes stale local old ids", async ({ userData, store }) => {
|
|
const oldInstall = join(userData, "plugins-dev", "familiaros.daily-reminders");
|
|
const oldManifest = writeManifest(oldInstall, manifest({ id: "familiaros.daily-reminders" }));
|
|
store.upsertRecord({ id: "familiaros.daily-reminders", version: "1.0.0", installPath: oldInstall, manifestPath: oldManifest, source: "local", enabled: true, approvedPermissions: ["timer", "familiar:speak"], config: {} });
|
|
const service = new PluginService({ userDataPath: userData, stateStore: store, runtime: new FakeRuntime() as never, bundledPluginSourceDirs: [] });
|
|
await service.seedBundledPlugins();
|
|
assert.equal(store.getRecord("familiaros.daily-reminders"), undefined);
|
|
assert.equal(existsSync(oldInstall), false);
|
|
});
|
|
|
|
await localScenario("bundled stale prune refuses unsafe path", async ({ userData, root, store }) => {
|
|
const outside = join(root, "outside-stale");
|
|
mkdirSync(outside, { recursive: true });
|
|
const link = join(userData, "plugins", "familiaros.pomodoro");
|
|
symlinkSync(outside, link, "dir");
|
|
store.upsertRecord({ id: "familiaros.pomodoro", version: "1.0.0", installPath: link, manifestPath: join(link, FAMILIAROS_PLUGIN_MANIFEST_FILENAME), source: "catalog", enabled: true, approvedPermissions: ["timer", "familiar:speak"], config: {} });
|
|
const runtime = new FakeRuntime();
|
|
const service = new PluginService({ userDataPath: userData, stateStore: store, runtime: runtime as never, bundledPluginSourceDirs: [] });
|
|
await service.seedBundledPlugins();
|
|
assert.equal(store.getRecord("familiaros.pomodoro")?.id, "familiaros.pomodoro");
|
|
assert.equal(existsSync(outside), true);
|
|
assert.equal(runtime.logs.some((entry) => entry.message.includes("Refused to prune")), true);
|
|
});
|
|
|
|
await localScenario("bundled seeding rejects plugins root symlink", async ({ userData, root, store }) => {
|
|
rmSync(join(userData, "plugins"), { recursive: true, force: true });
|
|
const outsideRoot = join(root, "outside-plugins");
|
|
mkdirSync(outsideRoot, { recursive: true });
|
|
symlinkSync(outsideRoot, join(userData, "plugins"), "dir");
|
|
const official = join(root, "official");
|
|
const source = join(official, "familiaros.reminders");
|
|
writeManifest(source, { manifestVersion: 2, id: "familiaros.reminders", name: "Quick Reminders", version: "1.0.0", runtime: "javascript", sdkVersion: "1.0.0", entry: "index.js", permissions: ["familiar:speak"] });
|
|
writeFileSync(join(source, "index.js"), "FamiliarOSPlugin.register({ start() {} });\n", "utf8");
|
|
const runtime = new FakeRuntime();
|
|
const service = new PluginService({ userDataPath: userData, stateStore: store, runtime: runtime as never, bundledPluginSourceDirs: [official] });
|
|
await service.seedBundledPlugins();
|
|
assert.equal(store.getRecord("familiaros.reminders"), undefined);
|
|
assert.equal(existsSync(join(outsideRoot, "familiaros.reminders")), false);
|
|
assert.equal(runtime.logs.some((entry) => entry.message.includes("Bundled plugin seed failed")), true);
|
|
});
|
|
|
|
await localScenario("start skips bundled seeding when disabled", async ({ userData, root, store }) => {
|
|
const official = join(root, "official");
|
|
const source = join(official, "familiaros.reminders");
|
|
writeManifest(source, { manifestVersion: 2, id: "familiaros.reminders", name: "Quick Reminders", version: "1.0.0", runtime: "javascript", sdkVersion: "1.0.0", entry: "index.js", permissions: ["familiar:speak"] });
|
|
writeFileSync(join(source, "index.js"), "FamiliarOSPlugin.register({ start() {} });\n", "utf8");
|
|
const staleInstall = join(userData, "plugins-dev", "familiaros.daily-reminders");
|
|
const staleManifest = writeManifest(staleInstall, manifest({ id: "familiaros.daily-reminders" }));
|
|
store.upsertRecord({ id: "familiaros.daily-reminders", version: "1.0.0", installPath: staleInstall, manifestPath: staleManifest, source: "local", enabled: true, approvedPermissions: ["timer", "familiar:speak"], config: {} });
|
|
const cleared: string[] = [];
|
|
const service = new PluginService({
|
|
userDataPath: userData,
|
|
stateStore: store,
|
|
runtime: new FakeRuntime() as never,
|
|
bundledPluginSourceDirs: [official],
|
|
seedBundledPlugins: false,
|
|
capabilities: { clearPlugin: (pluginId: string) => { cleared.push(pluginId); } } as never,
|
|
});
|
|
await service.start();
|
|
assert.equal(store.getRecord("familiaros.reminders"), undefined);
|
|
assert.equal(store.getRecord("familiaros.daily-reminders"), undefined);
|
|
assert.deepEqual(cleared, ["familiaros.daily-reminders"]);
|
|
});
|
|
|
|
await scenario("catalog metadata ignores bundled records", async ({ userData, store, runtime }) => {
|
|
const install = join(userData, "plugins", "familiaros.break-buddy");
|
|
const manifestPath = writeManifest(install, manifest({ id: "familiaros.break-buddy" }));
|
|
store.upsertRecord({ id: "familiaros.break-buddy", version: "1.0.0", installPath: install, manifestPath, source: "catalog", bundled: true, enabled: true, approvedPermissions: ["timer", "familiar:speak"], config: {} });
|
|
const fetchImpl = async (): Promise<Response> => new Response(JSON.stringify({ version: 1, generatedAt: new Date().toISOString(), plugins: [{ ...catalogEntry("familiaros.break-buddy", "1.0.0"), disabled: true, statusReason: "disabled" }] }), { status: 200 });
|
|
const service = new PluginService({ userDataPath: userData, stateStore: store, runtime: runtime as never, fetchImpl });
|
|
await service.getCatalogSnapshot(true);
|
|
assert.equal(store.getRecord("familiaros.break-buddy")?.enabled, true);
|
|
assert.equal(store.getRecord("familiaros.break-buddy")?.catalogDisabled, undefined);
|
|
});
|
|
|
|
await localScenario("loadLocalPath auto-approves explicit dev path", async ({ service, source, store }) => {
|
|
writeManifest(source, { manifestVersion: 2, id: "dev-js", name: "Dev JS", version: "1.0.0", runtime: "javascript", sdkVersion: "1.0.0", entry: "index.js", permissions: ["network"], network: { hosts: ["api.github.com"] } });
|
|
writeFileSync(join(source, "index.js"), "FamiliarOSPlugin.register({ start() {} });\n", "utf8");
|
|
service["__denyPermissions"] = true;
|
|
const result = await service.loadLocalPath(source, { autoApprove: true });
|
|
assert.equal(result.ok, true);
|
|
assert.deepEqual(store.getRecord("dev-js")?.approvedPermissions, ["network"]);
|
|
assert.deepEqual(store.getRecord("dev-js")?.approvedNetworkHosts, ["api.github.com"]);
|
|
});
|
|
|
|
await localScenario("loadLocalRoots loads children and reports bad plugins", async ({ service, root, store }) => {
|
|
const pluginsRoot = join(root, "official");
|
|
const good = join(pluginsRoot, "good");
|
|
const bad = join(pluginsRoot, "bad");
|
|
writeManifest(good, manifest({ id: "root-good" }));
|
|
writeManifest(bad, { bad: true });
|
|
const results = await service.loadLocalRoots([pluginsRoot], { autoApprove: true });
|
|
assert.equal(results.length, 2);
|
|
assert.equal(results.some((result) => result.ok), true);
|
|
assert.equal(results.some((result) => !result.ok), true);
|
|
assert.equal(store.getRecord("root-good")?.source, "local");
|
|
});
|
|
|
|
await localScenario("loadLocalRoots prunes stale plugins-dev records", async ({ service, root, userData, store }) => {
|
|
const pluginsRoot = join(root, "official");
|
|
const good = join(pluginsRoot, "good");
|
|
writeManifest(good, manifest({ id: "root-good" }));
|
|
const staleInstall = join(userData, "plugins-dev", "old-sample");
|
|
const staleManifest = writeManifest(staleInstall, manifest({ id: "old-sample" }));
|
|
store.upsertRecord({ id: "old-sample", version: "1.0.0", installPath: staleInstall, manifestPath: staleManifest, source: "local", enabled: true, approvedPermissions: ["timer", "familiar:speak"], config: {} });
|
|
const results = await service.loadLocalRoots([pluginsRoot], { autoApprove: true, pruneStale: true });
|
|
assert.equal(results.some((result) => result.ok && result.id === "root-good"), true);
|
|
assert.equal(store.getRecord("root-good")?.source, "local");
|
|
assert.equal(store.getRecord("old-sample"), undefined);
|
|
assert.equal(existsSync(staleInstall), false);
|
|
});
|
|
|
|
await localScenario("loadLocal rejects javascript nested entry symlink", async ({ service, source, root }) => {
|
|
writeManifest(source, { manifestVersion: 2, id: "js-entry-link", name: "JS Entry Link", version: "1.0.0", runtime: "javascript", sdkVersion: "1.0.0", entry: "nested/index.mjs", permissions: ["familiar:speak"] });
|
|
mkdirSync(join(source, "nested"), { recursive: true });
|
|
const real = join(root, "real-entry.mjs");
|
|
writeFileSync(real, "export default {};\n", "utf8");
|
|
symlinkSync(real, join(source, "nested", "index.mjs"));
|
|
const result = await service.loadLocal();
|
|
assert.equal(result.ok, false);
|
|
});
|
|
|
|
await localScenario("loadLocal rejects javascript symlinked entry parent", async ({ service, source, root }) => {
|
|
writeManifest(source, { manifestVersion: 2, id: "js-parent-link", name: "JS Parent Link", version: "1.0.0", runtime: "javascript", sdkVersion: "1.0.0", entry: "nested/index.mjs", permissions: ["familiar:speak"] });
|
|
const realNested = join(root, "real-nested");
|
|
mkdirSync(realNested, { recursive: true });
|
|
writeFileSync(join(realNested, "index.mjs"), "export default {};\n", "utf8");
|
|
symlinkSync(realNested, join(source, "nested"), "dir");
|
|
const result = await service.loadLocal();
|
|
assert.equal(result.ok, false);
|
|
});
|
|
|
|
await localScenario("uninstall removes state reloads and rejects symlink deletion", async ({ service, store, runtime, userData, root }) => {
|
|
const install = join(userData, "plugins", "remove-plug");
|
|
const manifestPath = writeManifest(install, manifest({ id: "remove-plug" }));
|
|
store.upsertRecord({ id: "remove-plug", version: "1.0.0", installPath: install, manifestPath, source: "catalog", enabled: true, approvedPermissions: ["timer", "familiar:speak"], config: {} });
|
|
const result = await service.uninstall("remove-plug");
|
|
assert.equal(result.ok, true);
|
|
assert.equal(store.getRecord("remove-plug"), undefined);
|
|
assert.deepEqual(runtime.reloads, ["remove-plug"]);
|
|
|
|
const outside = join(root, "outside-remove");
|
|
mkdirSync(outside, { recursive: true });
|
|
const link = join(userData, "plugins", "link-plug");
|
|
symlinkSync(outside, link, "dir");
|
|
store.upsertRecord({ id: "link-plug", version: "1.0.0", installPath: link, manifestPath: join(link, FAMILIAROS_PLUGIN_MANIFEST_FILENAME), source: "catalog", enabled: true, approvedPermissions: ["timer", "familiar:speak"], config: {} });
|
|
const rejected = await service.uninstall("link-plug");
|
|
assert.equal(rejected.ok, false);
|
|
assert.equal(store.getRecord("link-plug")?.id, "link-plug");
|
|
assert.equal(existsSync(outside), true);
|
|
|
|
const rootOutside = join(root, "outside-root");
|
|
mkdirSync(rootOutside, { recursive: true });
|
|
rmSync(join(userData, "plugins"), { recursive: true, force: true });
|
|
symlinkSync(rootOutside, join(userData, "plugins"), "dir");
|
|
store.upsertRecord({ id: "root-link", version: "1.0.0", installPath: join(userData, "plugins", "root-link"), manifestPath: join(userData, "plugins", "root-link", FAMILIAROS_PLUGIN_MANIFEST_FILENAME), source: "catalog", enabled: true, approvedPermissions: ["timer", "familiar:speak"], config: {} });
|
|
const rootRejected = await service.uninstall("root-link");
|
|
assert.equal(rootRejected.ok, false);
|
|
assert.equal(store.getRecord("root-link")?.id, "root-link");
|
|
});
|
|
|
|
await catalogRollbackScenario("catalog update rolls back manifest if state write fails", async ({ service, store, runtime, userData }) => {
|
|
const oldManifest = manifest({ id: "rollback-plug", version: "1.0.0" });
|
|
const install = join(userData, "plugins", "rollback-plug");
|
|
const manifestPath = writeManifest(install, oldManifest);
|
|
store.upsertRecord({ id: "rollback-plug", version: "1.0.0", installPath: install, manifestPath, source: "catalog", enabled: true, approvedPermissions: ["timer", "familiar:speak"], config: {} });
|
|
|
|
store.failNextUpsert = true;
|
|
const result = await service.updateCatalog("rollback-plug");
|
|
assert.equal(result.ok, false);
|
|
assert.equal(store.getRecord("rollback-plug")?.version, "1.0.0");
|
|
assert.equal(JSON.parse(readFileSync(manifestPath, "utf8")).version, "1.0.0");
|
|
assert.deepEqual(runtime.reloads, []);
|
|
});
|
|
|
|
await catalogCompatibilityScenario("catalog filters and blocks incompatible plugins", async ({ service }) => {
|
|
const snapshot = await service.getCatalogSnapshot(true);
|
|
assert.deepEqual(snapshot.plugins.map((plugin) => plugin.id), ["compatible-plug"]);
|
|
const result = await service.installCatalog("future-plug");
|
|
assert.equal(result.ok, false);
|
|
assert.match(result.error, /newer FamiliarOS/);
|
|
});
|
|
|
|
await scenario("disabled catalog returns no discover plugins", async ({ userData, store, runtime }) => {
|
|
const fetchImpl = async (): Promise<Response> => new Response(JSON.stringify({ version: 1, generatedAt: new Date().toISOString(), plugins: [catalogEntry("hidden-plug", "1.0.0")] }), { status: 200 });
|
|
const service = new PluginService({ userDataPath: userData, stateStore: store, runtime: runtime as never, fetchImpl, disableCatalog: true });
|
|
const snapshot = await service.getCatalogSnapshot(true);
|
|
assert.deepEqual(snapshot.plugins, []);
|
|
});
|
|
|
|
await scenario("right-click command helper groups caps and ignores stale commands", async ({ runtime }) => {
|
|
setPluginServiceForTests({
|
|
getSnapshot: async () => ({ plugins: [
|
|
{ id: "zeta", name: "Zeta", version: "1.0.0", source: "catalog", enabled: true, approvedPermissions: [], commands: [{ id: "b", title: "Beta" }, { id: "a", title: "Alpha" }, { id: "c", title: "Gamma" }] },
|
|
{ id: "alpha", name: "Alpha", version: "1.0.0", source: "catalog", enabled: true, approvedPermissions: [], commands: [{ id: "run", title: "Run" }] },
|
|
{ id: "disabled", name: "Disabled", version: "1.0.0", source: "catalog", enabled: false, approvedPermissions: [], commands: [{ id: "run", title: "Run" }] },
|
|
{ id: "broken", name: "Broken", version: "1.0.0", source: "catalog", enabled: true, brokenReason: "broken", approvedPermissions: [], commands: [{ id: "run", title: "Run" }] },
|
|
] }),
|
|
executeCommand: async (pluginId: string, commandId: string) => { runtime.executed.push({ pluginId, commandId }); },
|
|
stop() {},
|
|
} as unknown as PluginService);
|
|
const commands = await getDefaultPetPluginCommands(2, 2);
|
|
assert.deepEqual(commands.map((command) => `${command.pluginId}:${command.commandId}`), ["alpha:run", "zeta:b", "zeta:a"]);
|
|
setPluginServiceForTests({ getSnapshot: async () => ({ plugins: [{ id: "alpha", name: "Alpha", version: "1.0.0", source: "catalog", enabled: true, approvedPermissions: [], commands: [{ id: "run", title: "Run" }] }, { id: "zeta", name: "Zeta", version: "1.0.0", source: "catalog", enabled: true, approvedPermissions: [], commands: [] }] }), executeCommand: async (pluginId: string, commandId: string) => { runtime.executed.push({ pluginId, commandId }); }, stop() {} } as unknown as PluginService);
|
|
assert.deepEqual((await getDefaultPetPluginCommands()).map((command) => command.pluginId), ["alpha"]);
|
|
await executeDefaultPetPluginCommand("alpha", "run");
|
|
assert.deepEqual(runtime.executed, [{ pluginId: "alpha", commandId: "run" }]);
|
|
stopPluginService();
|
|
});
|
|
|
|
await scenario("executeCommand returns plugin command validation errors", async ({ service, store, runtime }) => {
|
|
addPlugin(store);
|
|
runtime.commandError = new Error("Message is required.");
|
|
const result = await service.executeCommand("plug", "set-reminder");
|
|
assert.equal(result.ok, false);
|
|
assert.equal(result.error, "Message is required.");
|
|
});
|
|
|
|
console.error("Plugin service validation passed.");
|
|
|
|
async function scenario(name: string, fn: (ctx: { root: string; userData: string; store: PluginStateStore; service: PluginService; runtime: FakeRuntime }) => Promise<void>): Promise<void> {
|
|
const root = mkdtempSync(join(tmpdir(), "familiaros-plugin-service-root-"));
|
|
lastRoot = root;
|
|
const userData = mkdtempSync(join(tmpdir(), "familiaros-plugin-service-user-"));
|
|
const store = new PluginStateStore({ statePath: join(userData, "state.json") });
|
|
store.initialize();
|
|
const runtime = new FakeRuntime();
|
|
const service = new PluginService({ stateStore: store, runtime: runtime as never, allowedPluginRoots: [root] });
|
|
try { await fn({ root, userData, store, service, runtime }); } catch (error) { throw new Error(`${name}: ${error instanceof Error ? error.message : String(error)}`); }
|
|
}
|
|
|
|
async function localScenario(name: string, fn: (ctx: { root: string; userData: string; source: string; store: PluginStateStore; service: PluginService & Record<string, unknown>; runtime: FakeRuntime }) => Promise<void>): Promise<void> {
|
|
const root = mkdtempSync(join(tmpdir(), "familiaros-plugin-local-root-"));
|
|
const userData = mkdtempSync(join(tmpdir(), "familiaros-plugin-local-user-"));
|
|
const source = join(root, "source");
|
|
mkdirSync(source, { recursive: true });
|
|
mkdirSync(join(userData, "plugins"), { recursive: true });
|
|
mkdirSync(join(userData, "plugins-dev"), { recursive: true });
|
|
const store = new PluginStateStore({ statePath: join(userData, "state.json") });
|
|
store.initialize();
|
|
const runtime = new FakeRuntime();
|
|
let service: PluginService & Record<string, unknown>;
|
|
service = new PluginService({ userDataPath: userData, stateStore: store, runtime: runtime as never, showOpenDialog: async () => service["__cancelPicker"] ? { canceled: true, filePaths: [] } : { canceled: false, filePaths: [String(service["__source"] ?? source)] }, confirmPermissions: async () => !service["__denyPermissions"] }) as PluginService & Record<string, unknown>;
|
|
service["__userData"] = userData;
|
|
service["__runtimeReloads"] = runtime.reloads;
|
|
try { await fn({ root, userData, source, store, service, runtime }); } catch (error) { throw new Error(`${name}: ${error instanceof Error ? error.message : String(error)}`); }
|
|
}
|
|
|
|
async function catalogRollbackScenario(name: string, fn: (ctx: { root: string; userData: string; store: ThrowingStateStore; service: PluginService; runtime: FakeRuntime }) => Promise<void>): Promise<void> {
|
|
const root = mkdtempSync(join(tmpdir(), "familiaros-plugin-catalog-root-"));
|
|
const userData = mkdtempSync(join(tmpdir(), "familiaros-plugin-catalog-user-"));
|
|
mkdirSync(join(userData, "plugins"), { recursive: true });
|
|
mkdirSync(join(userData, "plugins-dev"), { recursive: true });
|
|
const store = new ThrowingStateStore({ statePath: join(userData, "state.json") });
|
|
store.initialize();
|
|
const runtime = new FakeRuntime();
|
|
const nextManifest = manifest({ id: "rollback-plug", version: "2.0.0" });
|
|
const zip = makeZip(FAMILIAROS_PLUGIN_MANIFEST_FILENAME, Buffer.from(JSON.stringify(nextManifest), "utf8"));
|
|
const downloadUrl = "https://zip.familiaros.dev/plugins/rollback-plug.zip";
|
|
const catalog = { version: 1, generatedAt: new Date().toISOString(), plugins: [{ id: nextManifest.id, name: nextManifest.name, version: nextManifest.version, description: "Rollback", runtime: "declarative", permissions: nextManifest.permissions, downloadUrl, sha256: createHash("sha256").update(zip).digest("hex") }] };
|
|
const fetchImpl = async (url: string | URL | Request): Promise<Response> => {
|
|
const value = String(url);
|
|
if (value === downloadUrl) return new Response(new Uint8Array(zip), { status: 200 });
|
|
return new Response(JSON.stringify(catalog), { status: 200 });
|
|
};
|
|
const service = new PluginService({ userDataPath: userData, stateStore: store, runtime: runtime as never, fetchImpl, confirmPermissions: async () => true });
|
|
try { await fn({ root, userData, store, service, runtime }); } catch (error) { throw new Error(`${name}: ${error instanceof Error ? error.message : String(error)}`); }
|
|
}
|
|
|
|
async function catalogCompatibilityScenario(name: string, fn: (ctx: { service: PluginService }) => Promise<void>): Promise<void> {
|
|
const userData = mkdtempSync(join(tmpdir(), "familiaros-plugin-compat-user-"));
|
|
const store = new PluginStateStore({ statePath: join(userData, "state.json") });
|
|
store.initialize();
|
|
const catalog = { version: 1, generatedAt: new Date().toISOString(), plugins: [catalogEntry("compatible-plug", "1.0.0"), catalogEntry("future-plug", "9.0.0")] };
|
|
const fetchImpl = async (): Promise<Response> => new Response(JSON.stringify(catalog), { status: 200 });
|
|
const service = new PluginService({ userDataPath: userData, stateStore: store, runtime: new FakeRuntime() as never, fetchImpl, currentAppVersion: "2.0.0", confirmPermissions: async () => true });
|
|
try { await fn({ service }); } catch (error) { throw new Error(`${name}: ${error instanceof Error ? error.message : String(error)}`); }
|
|
}
|
|
|
|
function catalogEntry(id: string, minFamiliarOSVersion: string): object {
|
|
return { id, name: id, version: "1.0.0", description: "Test", runtime: "declarative", permissions: ["timer", "familiar:speak"], downloadUrl: `https://zip.familiaros.dev/plugins/${id}.zip`, sha256: "0".repeat(64), minFamiliarOSVersion };
|
|
}
|
|
|
|
function addPlugin(store: PluginStateStore, patch: Partial<PluginStateRecord> = {}, data: unknown = manifest()): void {
|
|
const id = patch.id ?? "plug";
|
|
const installPath = patch.installPath ?? join(currentRootFromStore(store), id);
|
|
const manifestPath = patch.manifestPath ?? writeManifest(installPath, data);
|
|
store.upsertRecord({ id, version: patch.version ?? "1.0.0", manifestPath, installPath, source: patch.source ?? "local", bundled: patch.bundled, enabled: patch.enabled ?? true, approvedPermissions: patch.approvedPermissions ?? ["timer", "familiar:speak"], config: patch.config ?? {}, brokenReason: patch.brokenReason });
|
|
}
|
|
|
|
function addCommandPlugin(store: PluginStateStore, userData: string, id: string, name: string, _commands: Array<{ id: string; title: string }>, patch: Partial<PluginStateRecord> = {}): void {
|
|
const data = manifest({ id, permissions: ["timer", "familiar:speak"] });
|
|
data.name = name;
|
|
const installPath = join(userData, "plugins", id);
|
|
const manifestPath = writeManifest(installPath, data);
|
|
store.upsertRecord({ id, version: "1.0.0", manifestPath, installPath, source: "catalog", enabled: patch.enabled ?? true, approvedPermissions: ["timer", "familiar:speak"], config: {}, brokenReason: patch.brokenReason });
|
|
}
|
|
|
|
function currentRootFromStore(_store: PluginStateStore): string { return lastRoot; }
|
|
|
|
function manifest(patch: Partial<FamiliarOSDeclarativePluginManifest> & { everyMinutes?: FamiliarOSDeclarativePluginManifest["triggers"][number]["everyMinutes"] } = {}): FamiliarOSDeclarativePluginManifest {
|
|
return { manifestVersion: 1, id: patch.id ?? "plug", name: "Plug", version: patch.version ?? "1.0.0", runtime: "declarative", permissions: patch.permissions ?? ["timer", "familiar:speak"], configSchema: patch.configSchema, triggers: patch.triggers ?? [{ on: "timer", everyMinutes: patch.everyMinutes ?? 5, actions: [{ type: "familiar.speak", message: "Stretch" }] }] };
|
|
}
|
|
|
|
function writeManifest(dir: string, data: unknown): string {
|
|
mkdirSync(dir, { recursive: true });
|
|
const path = join(dir, FAMILIAROS_PLUGIN_MANIFEST_FILENAME);
|
|
writeFileSync(path, JSON.stringify(data), "utf8");
|
|
return path;
|
|
}
|
|
|
|
function makeZip(name: string, data: Buffer): Buffer {
|
|
const nameBuffer = Buffer.from(name); const crc = crc32(data); const now = 0;
|
|
const local = Buffer.alloc(30); local.writeUInt32LE(0x04034b50, 0); local.writeUInt16LE(20, 4); local.writeUInt16LE(0, 6); local.writeUInt16LE(0, 8); local.writeUInt32LE(now, 10); local.writeUInt32LE(crc, 14); local.writeUInt32LE(data.length, 18); local.writeUInt32LE(data.length, 22); local.writeUInt16LE(nameBuffer.length, 26);
|
|
const central = Buffer.alloc(46); central.writeUInt32LE(0x02014b50, 0); central.writeUInt16LE(20, 4); central.writeUInt16LE(20, 6); central.writeUInt16LE(0, 8); central.writeUInt16LE(0, 10); central.writeUInt32LE(now, 12); central.writeUInt32LE(crc, 16); central.writeUInt32LE(data.length, 20); central.writeUInt32LE(data.length, 24); central.writeUInt16LE(nameBuffer.length, 28); central.writeUInt32LE((0o100644 << 16) >>> 0, 38);
|
|
const localPart = Buffer.concat([local, nameBuffer, data]); const centralPart = Buffer.concat([central, nameBuffer]);
|
|
const end = Buffer.alloc(22); end.writeUInt32LE(0x06054b50, 0); end.writeUInt16LE(1, 8); end.writeUInt16LE(1, 10); end.writeUInt32LE(centralPart.length, 12); end.writeUInt32LE(localPart.length, 16);
|
|
return Buffer.concat([localPart, centralPart, end]);
|
|
}
|
|
|
|
function crc32(buffer: Buffer): number { let crc = -1; for (const byte of buffer) { crc ^= byte; for (let i = 0; i < 8; i++) crc = (crc >>> 1) ^ (0xedb88320 & -(crc & 1)); } return (crc ^ -1) >>> 0; }
|