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
1 change: 1 addition & 0 deletions src/i18n/locales/en/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -794,6 +794,7 @@
},
"install": {
"integrityFailed": "Plugin bundle failed its integrity check — install blocked.",
"alreadyInstalling": "This plugin is already being installed — check Settings › Plugins.",
"failed": "Failed to install plugin.",
"versionUnsupported": "This plugin requires app version {{version}} or later."
},
Expand Down
1 change: 1 addition & 0 deletions src/i18n/locales/fr/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -794,6 +794,7 @@
},
"install": {
"integrityFailed": "L'intégrité du module a échoué — installation bloquée.",
"alreadyInstalling": "Ce module est déjà en cours d'installation — voir Paramètres › Modules.",
"failed": "Échec de l'installation du module.",
"versionUnsupported": "Ce module nécessite la version {{version}} ou supérieure de l'application."
},
Expand Down
1 change: 1 addition & 0 deletions src/i18n/locales/ru/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -895,6 +895,7 @@
},
"install": {
"integrityFailed": "Пакет плагина не прошёл проверку целостности — установка заблокирована.",
"alreadyInstalling": "Этот плагин уже устанавливается — откройте «Настройки › Плагины».",
"failed": "Не удалось установить плагин.",
"versionUnsupported": "Этот плагин требует версию приложения {{version}} или новее."
},
Expand Down
1 change: 1 addition & 0 deletions src/i18n/locales/zh/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -614,6 +614,7 @@
},
"install": {
"integrityFailed": "插件包未通过完整性校验——已阻止安装。",
"alreadyInstalling": "该插件正在安装中——请查看“设置 › 插件”。",
"failed": "安装插件失败。",
"versionUnsupported": "此插件需要应用版本 {{version}} 或更高版本。"
},
Expand Down
12 changes: 12 additions & 0 deletions src/plugins/installErrors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,17 @@ export interface TranslatableMessage {
params?: Record<string, string>;
}

/** Raised when an install is requested for an id whose install is still running.
* The second request is refused rather than joined: the two callers may have
* reviewed different manifests for the same id, so the running install cannot
* stand in for the one this caller consented to. */
export class PluginInstallInProgressError extends Error {
constructor(public readonly id: string) {
super(`An install of "${id}" is already in progress.`);
this.name = "PluginInstallInProgressError";
}
}

/**
* The user-facing message for a failed plugin install, matched on the error's
* type rather than its text. A hash mismatch is the user's only tamper signal,
Expand All @@ -15,6 +26,7 @@ export interface TranslatableMessage {
*/
export function pluginInstallErrorMessage(e: unknown, fallbackKey: string): TranslatableMessage {
if (e instanceof PluginHashMismatchError) return { key: "settings.plugins.install.integrityFailed" };
if (e instanceof PluginInstallInProgressError) return { key: "settings.plugins.install.alreadyInstalling" };
if (e instanceof MinAppVersionError)
return { key: "settings.plugins.install.versionUnsupported", params: { version: e.required } };
return { key: fallbackKey };
Expand Down
57 changes: 57 additions & 0 deletions src/stores/marketplaceStore.installUpdate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import { getExposedApi, getLoadedPlugins, unloadPlugin } from "@/plugins/runtime
import * as runtimeModule from "@/plugins/runtime";
import { injectPluginStyle } from "@/plugins/importPluginModule";
import { PluginHashMismatchError } from "@/plugins/integrity";
import { PluginInstallInProgressError } from "@/plugins/installErrors";
import { sha256Hex } from "@/plugins/integrity";
import type { PluginRegisterFn } from "@/plugins/api";

Expand Down Expand Up @@ -246,3 +247,59 @@ test("installing a plugin that was never loaded does not call unloadPlugin", asy
spy.mockRestore();
}
});

/** Parks installPlugin inside its first plugin_fetch_url until the returned
* release() runs, so a second install can race a genuinely in-flight one. */
function gatedFetch(): () => void {
let release!: () => void;
const gate = new Promise<void>((resolve) => { release = resolve; });
h.invoke.mockImplementation(async (cmd: string, args: { url?: string }) => {
if (cmd === "plugin_fetch_url") {
await gate;
return args.url!.endsWith("manifest.json") ? manifestFor("1.0.0") : "v1-js";
}
return undefined;
});
return release;
}

test("a second install of an id already installing is refused rather than resolving as a no-op", async () => {
mockBundle("v1");
const release = gatedFetch();

const first = useMarketplaceStore.getState().installPlugin(basePlugin());

// A caller that resolves here — the deep-link confirm sheet — would report a
// success for an install it never performed.
await expect(
useMarketplaceStore.getState().installPlugin(basePlugin()),
).rejects.toBeInstanceOf(PluginInstallInProgressError);

release();
await first;
expect(getExposedApi("p1")).toBe("v1");
});

test("a refused concurrent install does not clear the running install's busy state", async () => {
mockBundle("v1");
const release = gatedFetch();

const first = useMarketplaceStore.getState().installPlugin(basePlugin());
await expect(useMarketplaceStore.getState().installPlugin(basePlugin())).rejects.toThrow();

expect(useMarketplaceStore.getState().installing.has("p1")).toBe(true);
release();
await first;
expect(useMarketplaceStore.getState().installing.has("p1")).toBe(false);
});

test("an id is installable again once its previous install has settled", async () => {
mockBundle("v1");
h.invoke.mockImplementation(async (cmd: string, args: { url?: string }) => {
if (cmd === "plugin_fetch_url") return args.url!.endsWith("manifest.json") ? manifestFor("1.0.0") : "v1-js";
return undefined;
});
await useMarketplaceStore.getState().installPlugin(basePlugin());

await expect(useMarketplaceStore.getState().installPlugin(basePlugin())).resolves.toBeUndefined();
});
6 changes: 5 additions & 1 deletion src/stores/marketplaceStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { usePluginRegistryStore } from "@/stores/pluginRegistryStore";
import { appFetch } from "@/services/http";
import { resolveVerifiedHash } from "@/plugins/integrity";
import { assertValidPluginId } from "@/plugins/pluginId";
import { PluginInstallInProgressError } from "@/plugins/installErrors";
import { satisfiesMinAppVersion, MinAppVersionError, beatsSeededVersion, isParsableVersion } from "@/plugins/version";
import { useSeededTombstoneStore, loadSeededEntries } from "@/stores/seededTombstoneStore";

Expand Down Expand Up @@ -349,7 +350,10 @@ export const useMarketplaceStore = create<MarketplaceState>((set, get) => ({
// checked separately by loadPlugin, since the two are not required to match.
assertValidPluginId(plugin.id);
const { installing, installedMeta } = get();
if (installing.has(plugin.id)) return;
// Refused, not silently skipped: resolving here told every caller the install
// succeeded — a deep-link confirm sheet accepted while the Settings tab was
// installing the same id closed reporting success having written nothing.
if (installing.has(plugin.id)) throw new PluginInstallInProgressError(plugin.id);

set((s) => ({ installing: new Set([...s.installing, plugin.id]) }));
try {
Expand Down