Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,9 @@ jobs:
- name: Run plugin lifecycle tests
run: node --test tests/plugin-lifecycle.test.js

- name: Run plugin precedence tests
run: node --test tests/plugin-precedence.test.js

- name: Run plugin selection tests
run: node --test tests/plugin-selection.test.js

Expand Down
24 changes: 21 additions & 3 deletions docs/PLUGIN_CATALOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,12 @@ process; `src/main/plugin-archive.ts` is its dependency-free ZIP reader. The
renderer reaches it through `window.feedBackDesktop.plugins`:

- `catalog()` — `{ ok, entries, message? }`, where each entry carries
`installedVersion`, `bundled`, and `canRollback`. `ok: false` means the
bundled catalog file is missing or unreadable, which the catalog view shows
as an error rather than as an empty catalog.
`installedVersion` (the version of any user-installed copy, `null` when only
the packaged copy exists), `bundled` (the packaged copy is a bundled
baseline), `activeSource` (which copy the backend loads — `bundled`,
`writable-override`, `installed`, or `none`), and `canRollback`. `ok: false`
means the bundled catalog file is missing or unreadable, which the catalog
view shows as an error rather than as an empty catalog.
- `installCatalog(ids)` — install a batch by catalog id.
- `rollbackCatalog(id)` — restore the version kept in the backup slot.
- `onInstallProgress(cb)` — progress ticks of the in-flight batch, broadcast to
Expand Down Expand Up @@ -151,6 +154,21 @@ has `"bundled": true` in its manifest; that copy always wins (see core's
to install over those ids, because the installed copy would be silently
ignored. The packaged application itself is never modified.

`plugins:catalog` reports the resolution per entry as `activeSource`: the
packaged copy ("bundled"), a user copy shadowing a packaged one that did not
claim `bundled: true` ("writable-override"), a user copy with no packaged one
beneath it ("installed"), or nothing on disk ("none"). `bundled` still means
"the packaged copy is a bundled baseline" — the two fields answer different
questions, so a packaged non-baseline with no user copy reports
`bundled: false` with `activeSource: 'bundled'`. The rule lives in
`src/main/plugin-precedence.ts` (issue #22).

One deliberate divergence from the backend: when two directories in the same
root claim the same id (a hand-broken layout), the desktop resolves duplicates
by sorted directory name, which the backend's unsorted scan does not guarantee.
This only affects which copy's version `installedVersion` names; `activeSource`
distinguishes only between the core and user roots and is unaffected.

The legacy `plugins:install` / `plugins:update` git paths remain for
developer-supplied repository URLs. They still require Git.

Expand Down
76 changes: 25 additions & 51 deletions src/main/plugin-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import {
resolveSafePluginDir,
rollbackInstall,
} from './plugin-installer';
import { activeSourceFor, scanPluginCopies, type PluginCopy } from './plugin-precedence';
import {
IPC_PLUGIN_CATALOG_CANCEL,
IPC_PLUGIN_CATALOG_PROGRESS,
Expand Down Expand Up @@ -348,39 +349,12 @@ async function recordQuietly(entries: CatalogEntry[]): Promise<void> {
}
}

function readManifest(dir: string): Record<string, any> | null {
try {
// dir is a scanned plugin directory; the file name is a literal
const parsed = JSON.parse(fs.readFileSync(path.join(dir, 'plugin.json'), 'utf-8'));
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null;
} catch {
return null;
}
}

// Mirrors the backend's _is_bundled(): a core plugin wins over a user copy
// only when it sits directly in the core plugins dir, its manifest says
// `"bundled": true`, and its directory name equals its id. Any other core
// plugin with the same id is overridden by the user-installed copy, because
// the backend scans the user plugins dir first.
function scanPluginDir(dir: string): { ids: Set<string>; bundledIds: Set<string> } {
// Which core copies are bundled baselines: install is refused over those ids,
// because the backend would silently load the baseline instead of the copy.
function bundledBaselineIds(copies: Map<string, PluginCopy>): Set<string> {
const ids = new Set<string>();
const bundledIds = new Set<string>();
let entries: fs.Dirent[] = [];
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch {
return { ids, bundledIds };
}
for (const entry of entries) {
if (entry.name.startsWith('.')) continue;
// entry.name comes from readdirSync of the app's own plugins directory
const manifest = readManifest(path.join(dir, entry.name));
if (!manifest || typeof manifest.id !== 'string') continue;
ids.add(manifest.id);
if (manifest.bundled === true && entry.name === manifest.id) bundledIds.add(manifest.id);
}
return { ids, bundledIds };
for (const [id, copy] of copies) if (copy.bundled) ids.add(id);
return ids;
}

// A cold backend enumerating its plugins can take well over 5s to answer, and
Expand Down Expand Up @@ -561,8 +535,8 @@ export async function installFromCatalog(
try {
const pluginsDir = getPluginsDir();
cleanupStaging(pluginsDir);
const user = scanPluginDir(pluginsDir);
const core = scanPluginDir(getCorePluginsDir());
const user = scanPluginCopies(pluginsDir);
const core = scanPluginCopies(getCorePluginsDir());
const catalog = getCatalog();
// A pin or a disable is the user's decision about an installed copy, so
// the batch path honours it too — selecting a pinned plugin in the
Expand All @@ -573,8 +547,8 @@ export async function installFromCatalog(
const results = await installCatalogBatch(allowed, catalog, {
pluginsDir,
fetch: fetch as unknown as FetchLike,
protectedIds: core.bundledIds,
installedIds: new Set([...user.ids, ...core.ids]),
protectedIds: bundledBaselineIds(core),
installedIds: new Set([...user.keys(), ...core.keys()]),
activate: activateInstalled,
restartAfterRollback: async () => { await restartPythonAndWait(); },
onProgress,
Expand Down Expand Up @@ -625,21 +599,21 @@ export function listCatalog(): { ok: boolean; entries: unknown[]; message?: stri
};
}
const pluginsDir = getPluginsDir();
const installed = new Map<string, string>();
for (const entry of fs.existsSync(pluginsDir) ? fs.readdirSync(pluginsDir) : []) {
if (entry.startsWith('.')) continue;
const manifest = readManifest(path.join(pluginsDir, entry));
if (manifest && typeof manifest.id === 'string') installed.set(manifest.id, String(manifest.version ?? ''));
}
const bundled = scanPluginDir(getCorePluginsDir()).bundledIds;
const overrides = scanPluginCopies(pluginsDir);
const baselines = scanPluginCopies(getCorePluginsDir());
return {
ok: true,
entries: catalog.entries.map(entry => ({
...entry,
installedVersion: installed.get(entry.id) ?? null,
bundled: bundled.has(entry.id),
canRollback: hasBackup(pluginsDir, entry.installDir),
})),
entries: catalog.entries.map(entry => {
const baseline = baselines.get(entry.id) ?? null;
const override = overrides.get(entry.id) ?? null;
return {
...entry,
installedVersion: override?.version ?? null,
bundled: baseline?.bundled === true,
activeSource: activeSourceFor(baseline, override),
canRollback: hasBackup(pluginsDir, entry.installDir),
};
}),
};
}

Expand Down Expand Up @@ -789,11 +763,11 @@ async function installOverInstalled(
catalogBusy = true;
try {
cleanupStaging(pluginsDir);
const core = scanPluginDir(getCorePluginsDir());
const core = scanPluginCopies(getCorePluginsDir());
const outcome = await installCatalogEntry(entry, {
pluginsDir,
fetch: fetch as unknown as FetchLike,
protectedIds: core.bundledIds,
protectedIds: bundledBaselineIds(core),
onProgress: options.onProgress ?? throttledProgress(),
});
const status = (await activateInstalled([outcome])).get(entry.id);
Expand Down
111 changes: 111 additions & 0 deletions src/main/plugin-precedence.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
// Bundled baseline vs writable override precedence (issue #22, lifecycle 3/6 of
// #6): which on-disk copy of a plugin id the backend loads, plus the directory
// scan that classifies copies the way the backend does. The catalog projection
// reports that one answer as the row's `activeSource`.
//
// A plugin id can exist twice: as a packaged copy inside the core plugins dir
// (shipped with the app, replaced only by a desktop update) and as a writable
// copy inside the user plugins dir (what this app installs and uninstalls). The
// backend loads at most one copy per id. The rule mirrors core's own
// resolution — see `_is_bundled` in core and "User copies vs. bundled plugins"
// in docs/PLUGIN_CATALOG.md:
//
// * the backend scans the user plugins dir before the packaged core plugins
// dir, so a writable copy shadows a packaged one with the same id — unless
// * the packaged copy is a *bundled baseline*: a directory named after its id
// whose manifest says `"bundled": true`. That copy always wins.
//
// A bundled baseline also refuses overwrites at install time
// (plugin-installer.ts protectedIds), so the both-present state for a baseline
// can only be reached by hand-placing a copy; it still resolves
// deterministically, with the baseline winning and the override shadowed.
//
// Free of any electron import (like plugin-archive.ts) so the whole decision is
// unit-testable under node:test, and the scan sorts readdir results so two
// copies claiming one id resolve the same way on every platform.

import * as fs from 'fs';
import * as path from 'path';

/**
* Which on-disk copy of a plugin id the backend loads:
* - `bundled` — the packaged core copy: a bundled baseline that beats a
* hand-placed writable copy, or a packaged copy with no writable copy beneath
* it at all.
* - `writable-override` — a writable copy shadows a packaged copy that did not
* claim `bundled: true` (the backend scans the user plugins dir first).
* - `installed` — a writable copy loads and no packaged copy of the id exists.
* - `none` — no copy of the id is on disk.
*/
export type ActiveSource = 'bundled' | 'writable-override' | 'installed' | 'none';

/** One on-disk copy of a plugin id, as the scan saw it. */
export interface PluginCopy {
/** The directory name the copy lives in. */
dir: string;
/** The copy's version string (`''` when its manifest has none). */
version: string;
/**
* True only for a bundled baseline: a copy whose directory is named after
* its id and whose manifest says `"bundled": true`. The scan does not know
* which root it is under; the callers that load the *core* plugins dir are
* the ones that treat such a copy as a protected baseline.
*/
bundled: boolean;
}

/**
* Which copy of an id is active, given the packaged core copy (`baseline`) and
* the writable copy (`override`), either of which may be absent. The answer is
* always exactly one source.
*/
export function activeSourceFor(baseline: PluginCopy | null, override: PluginCopy | null): ActiveSource {
if (!baseline) return override ? 'installed' : 'none';
if (baseline.bundled) return 'bundled';
return override ? 'writable-override' : 'bundled';
}

function readManifest(dir: string): Record<string, unknown> | null {
try {
// dir is a scanned plugin directory from the app's own directories
const parsed = JSON.parse(fs.readFileSync(path.join(dir, 'plugin.json'), 'utf-8'));

Check failure on line 71 in src/main/plugin-precedence.ts

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

src/main/plugin-precedence.ts#L71

Detected possible user input going into a `path.join` or `path.resolve` function.
Comment thread
cchacs marked this conversation as resolved.
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null;
} catch {
return null;
}
}

/**
* Scan a plugins directory and return the copy of each manifest id it holds.
*
* readdir order is not defined, so entries are sorted first: when two
* directories within one root claim the same id, the first in sorted order
* keeps the claim unless the other is a bundled baseline, which always outranks
* a sibling. A missing or unreadable root yields an empty map.
*/
export function scanPluginCopies(dir: string): Map<string, PluginCopy> {
const copies = new Map<string, PluginCopy>();
let entries: string[] = [];
try {
entries = fs.readdirSync(dir);
} catch {
return copies;
}
entries.sort();
for (const name of entries) {
if (name.startsWith('.')) continue;
const manifest = readManifest(path.join(dir, name));

Check failure on line 97 in src/main/plugin-precedence.ts

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

src/main/plugin-precedence.ts#L97

Detected possible user input going into a `path.join` or `path.resolve` function.
Comment thread
cchacs marked this conversation as resolved.
if (!manifest) continue;
const id = manifest.id;
if (typeof id !== 'string') continue;
const copy: PluginCopy = {
dir: name,
version: String(manifest.version ?? ''),
bundled: manifest.bundled === true && name === id,
};
const existing = copies.get(id);
if (existing && (existing.bundled || !copy.bundled)) continue;
copies.set(id, copy);
}
return copies;
}
10 changes: 5 additions & 5 deletions src/renderer/plugin-manager/screen.js
Original file line number Diff line number Diff line change
Expand Up @@ -532,11 +532,11 @@
// Whether an entry is present on this machine, and whether the catalog pins a
// different version than the one in use. A bundled copy is the one the
// backend loads ahead of any user copy — the _is_bundled rule that
// scanPluginDir in src/main/plugin-manager.ts mirrors — so it decides on its
// own and is never reported as upgradable: a bundled plugin can only change
// with a new desktop build, and the catalog installer refuses those ids
// outright. A user copy installed through git need not carry a version in
// its manifest, which is a copy present but not comparable.
// activeSource in src/main/plugin-precedence.ts mirrors — so it decides on
// its own and is never reported as upgradable: a bundled plugin can only
// change with a new desktop build, and the catalog installer refuses those
// ids outright. A user copy installed through git need not carry a version
// in its manifest, which is a copy present but not comparable.
function pmCatalogState(entry) {
if (entry.bundled) return 'installed';
if (entry.installedVersion == null) return 'available';
Expand Down
5 changes: 3 additions & 2 deletions tests/plugin-catalog-view.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ function entry(overrides) {
selection: { tier: 'optional', defaultSelected: false },
installedVersion: null,
bundled: false,
activeSource: 'none',
canRollback: false,
...overrides,
};
Expand Down Expand Up @@ -326,7 +327,7 @@ test('the bundled catalog lists and filters with no network', () => {
// on disk plus the main process's install-state fields. Nothing here can
// reach the network, which is the offline guarantee for this view.
const raw = JSON.parse(fs.readFileSync(CATALOG_JSON, 'utf8'));
const rows = raw.entries.map((row) => ({ ...row, installedVersion: null, bundled: false, canRollback: false }));
const rows = raw.entries.map((row) => ({ ...row, installedVersion: null, bundled: false, activeSource: 'none', canRollback: false }));

assert.ok(rows.length > 0, 'the bundled catalog should ship entries');
assert.deepEqual(ids(view.pmFilterCatalog(rows, filters())), ids(rows));
Expand Down Expand Up @@ -371,7 +372,7 @@ test('the row fields this suite builds are the ones plugins:catalog sends', () =
const start = main.indexOf('function listCatalog(');
assert.ok(start !== -1, 'listCatalog should exist in plugin-manager.ts');
const listCatalogSource = main.slice(start, main.indexOf('\n}\n', start));
for (const field of ['installedVersion', 'bundled', 'canRollback']) {
for (const field of ['installedVersion', 'bundled', 'activeSource', 'canRollback']) {
assert.ok(listCatalogSource.includes(field), `listCatalog should attach '${field}' to every row`);
}
// The error state depends on the handler being able to report a failure at
Expand Down
Loading
Loading