diff --git a/src/components/settings/sections/PluginsSection.tsx b/src/components/settings/sections/PluginsSection.tsx index 0b696ec33..d3f893187 100644 --- a/src/components/settings/sections/PluginsSection.tsx +++ b/src/components/settings/sections/PluginsSection.tsx @@ -7,8 +7,8 @@ import { usePluginRegistryStore } from "@/stores/pluginRegistryStore"; import { useMarketplaceStore, type MarketplacePlugin } from "@/stores/marketplaceStore"; import { useUIStore } from "@/stores/uiStore"; import { useNotificationStore } from "@/stores/notificationStore"; -import { PluginHashMismatchError } from "@/plugins/integrity"; -import { satisfiesMinAppVersion, MinAppVersionError } from "@/plugins/version"; +import { pluginInstallErrorMessage } from "@/plugins/installErrors"; +import { satisfiesMinAppVersion } from "@/plugins/version"; import { availableUpdate, availableSeededUpdate, addedPermissions } from "@/plugins/updates"; import { mergeBrowseCatalog, seededActiveIds as computeSeededActiveIds } from "@/plugins/floor"; import { useSeededTombstoneStore, loadSeededEntries, type SeededEntry } from "@/stores/seededTombstoneStore"; @@ -179,15 +179,12 @@ function usePluginInstaller() { const busy = new Set([...installing, ...preparing]); const notifyError = (e: unknown) => { + const { key, params } = pluginInstallErrorMessage(e, "settings.plugins.install.failed"); useNotificationStore.getState().addToast({ source: { kind: "plugin", id: "system", name: "Voltius" }, type: "toast", severity: "error", - message: e instanceof PluginHashMismatchError - ? t("settings.plugins.install.integrityFailed") - : e instanceof MinAppVersionError - ? t("settings.plugins.install.versionUnsupported", { version: e.required }) - : t("settings.plugins.install.failed"), + message: t(key, params), duration: 0, }); }; diff --git a/src/components/terminal/DeepLinkConfirmModal.test.tsx b/src/components/terminal/DeepLinkConfirmModal.test.tsx index 99a7d9937..f33d53cae 100644 --- a/src/components/terminal/DeepLinkConfirmModal.test.tsx +++ b/src/components/terminal/DeepLinkConfirmModal.test.tsx @@ -3,6 +3,11 @@ import { render, screen, cleanup, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { DeepLinkConfirmModal } from "./DeepLinkConfirmModal"; import { useDeepLinkStore } from "@/stores/deepLinkStore"; +import { PluginHashMismatchError } from "@/plugins/integrity"; +import { MinAppVersionError } from "@/plugins/version"; + +const acceptButton = (labelKey: string) => + screen.getByText(labelKey).closest("button") as HTMLButtonElement; let teamConnections: Record = {}; let activeLocalSessionId: string | null = null; @@ -171,15 +176,26 @@ test("an invite sheet names the handle and invites the resolved user", async () teamConnections = { "local-1": { sessionKeyBytes: new Uint8Array(32) } }; useDeepLinkStore.setState({ prompt: { route: "invite", handle: "kevin-p" } }); render(); - await waitFor(() => - expect((screen.getByText("terminal.share.deepLinkInviteAction").closest("button") as HTMLButtonElement).disabled).toBe(false), - ); + await waitFor(() => expect(acceptButton("terminal.share.deepLinkInviteAction").disabled).toBe(false)); await userEvent.click(screen.getByText("terminal.share.deepLinkInviteAction")); await waitFor(() => expect(inviteMock).toHaveBeenCalledWith("local-1", expect.objectContaining({ user_id: "u1", handle: "kevin-p" })), ); }); +test("a re-render does not re-run load", async () => { + // `useTranslation` hands back a fresh `t` on every render, as the real hook does + // on a locale change. A `t` in the load effect's deps turns that into a second + // searchUsers — or, once the load's own setState re-renders, an unbounded loop. + searchUsersMock.mockResolvedValue([{ user_id: "u1", handle: "kevin-p", is_teammate: false }]); + activeLocalSessionId = "local-1"; + teamConnections = { "local-1": { sessionKeyBytes: new Uint8Array(32) } }; + useDeepLinkStore.setState({ prompt: { route: "invite", handle: "kevin-p" } }); + render(); + await waitFor(() => expect(acceptButton("terminal.share.deepLinkInviteAction").disabled).toBe(false)); + expect(searchUsersMock).toHaveBeenCalledTimes(1); +}); + test("an invite link whose handle only fuzzily matches invites nobody", async () => { searchUsersMock.mockResolvedValue([{ user_id: "u1", handle: "kevin-porter", is_teammate: false }]); activeLocalSessionId = "local-1"; @@ -187,6 +203,7 @@ test("an invite link whose handle only fuzzily matches invites nobody", async () useDeepLinkStore.setState({ prompt: { route: "invite", handle: "kevin-p" } }); render(); await waitFor(() => expect(screen.getByText("terminal.share.deepLinkInviteUnknownUser")).toBeTruthy()); + expect(acceptButton("terminal.share.deepLinkInviteAction").disabled).toBe(true); await userEvent.click(screen.getByText("terminal.share.deepLinkInviteAction")); expect(inviteMock).not.toHaveBeenCalled(); }); @@ -198,6 +215,7 @@ test("an invite link with no shareable session names the handle but cannot be ac useDeepLinkStore.setState({ prompt: { route: "invite", handle: "kevin-p" } }); render(); await waitFor(() => expect(screen.getByText("terminal.share.deepLinkInviteNoActiveSession")).toBeTruthy()); + expect(acceptButton("terminal.share.deepLinkInviteAction").disabled).toBe(true); await userEvent.click(screen.getByText("terminal.share.deepLinkInviteAction")); expect(inviteMock).not.toHaveBeenCalled(); }); @@ -223,6 +241,7 @@ test("a snippet-install link naming an entry the catalogue does not list cannot useDeepLinkStore.setState({ prompt: { route: "snippet-install", entryId: "docker-cleanup" } }); render(); await waitFor(() => expect(screen.getByText("snippets.deepLinkInstall.failed")).toBeTruthy()); + expect(acceptButton("snippets.deepLinkInstall.action").disabled).toBe(true); await userEvent.click(screen.getByText("snippets.deepLinkInstall.action")); expect(installEntriesMock).not.toHaveBeenCalled(); }); @@ -251,11 +270,40 @@ test("a plugin-install link naming a source this device does not have installs n useDeepLinkStore.setState({ prompt: { route: "plugin-install", pluginId: "docker", sourceId: "someone-elses" } }); render(); await waitFor(() => expect(screen.getByText("settings.plugins.deepLinkInstall.failed")).toBeTruthy()); + expect(acceptButton("settings.plugins.deepLinkInstall.action").disabled).toBe(true); await userEvent.click(screen.getByText("settings.plugins.deepLinkInstall.action")); expect(installPluginMock).not.toHaveBeenCalled(); expect(fetchManifestMock).not.toHaveBeenCalled(); }); +async function acceptPluginInstall() { + marketplaceSources = [{ id: "voltius", name: "Voltius Marketplace", enabled: true }]; + marketplaceCatalog = [{ id: "docker", name: "Docker", author: "Voltius", version: "1.2.0", sourceId: "voltius" }]; + useDeepLinkStore.setState({ prompt: { route: "plugin-install", pluginId: "docker", sourceId: "voltius" } }); + render(); + await waitFor(() => expect(acceptButton("settings.plugins.deepLinkInstall.action").disabled).toBe(false)); + await userEvent.click(screen.getByText("settings.plugins.deepLinkInstall.action")); +} + +test("a plugin bundle failing its hash check is reported as tampering, not as a bad link", async () => { + installPluginMock.mockRejectedValue(new PluginHashMismatchError("aaa", "bbb")); + await acceptPluginInstall(); + await waitFor(() => expect(screen.getByText("settings.plugins.install.integrityFailed")).toBeTruthy()); + expect(screen.queryByText("settings.plugins.deepLinkInstall.failed")).toBeNull(); +}); + +test("a plugin the running app is too old for names the version requirement", async () => { + installPluginMock.mockRejectedValue(new MinAppVersionError("2.0.0", "1.0.0")); + await acceptPluginInstall(); + await waitFor(() => expect(screen.getByText("settings.plugins.install.versionUnsupported")).toBeTruthy()); +}); + +test("any other plugin-install failure falls back to the deep-link message", async () => { + installPluginMock.mockRejectedValue(new Error("network down")); + await acceptPluginInstall(); + await waitFor(() => expect(screen.getByText("settings.plugins.deepLinkInstall.failed")).toBeTruthy()); +}); + test("a plugin-install link naming a disabled source installs nothing", async () => { marketplaceSources = [{ id: "voltius", name: "Voltius Marketplace", enabled: false }]; marketplaceCatalog = [{ id: "docker", name: "Docker", author: "Voltius", version: "1.2.0", sourceId: "voltius" }]; diff --git a/src/components/terminal/DeepLinkConfirmModal.tsx b/src/components/terminal/DeepLinkConfirmModal.tsx index 19a4bffb3..1f06cfe64 100644 --- a/src/components/terminal/DeepLinkConfirmModal.tsx +++ b/src/components/terminal/DeepLinkConfirmModal.tsx @@ -5,6 +5,7 @@ import { Modal, ModalCard } from "@/components/shared/Modal"; import { useDeepLinkStore } from "@/stores/deepLinkStore"; import { intentKey, type ConfirmIntent } from "@/services/deepLinkUrl"; import { CONFIRM_SPECS, type ConfirmRoute, type ConfirmSpec } from "./deepLinkConfirmSpecs"; +import type { TranslatableMessage } from "@/plugins/installErrors"; export function DeepLinkConfirmModal() { const prompt = useDeepLinkStore((s) => s.prompt); @@ -14,6 +15,10 @@ export function DeepLinkConfirmModal() { return prompt ? : null; } +function failureMessage(spec: ConfirmSpec, e: unknown): TranslatableMessage { + return spec.errorMessage?.(e, spec.errorKey) ?? { key: spec.errorKey }; +} + function ConfirmSheet({ intent }: { intent: ConfirmIntent }) { const { t } = useTranslation(); const dismissPrompt = useDeepLinkStore((s) => s.dismissPrompt); @@ -26,20 +31,26 @@ function ConfirmSheet({ intent }: { intent: ConfirmIntent }) { const [loading, setLoading] = useState(!!spec.load); const [loadFailed, setLoadFailed] = useState(false); const [busy, setBusy] = useState(false); - const [error, setError] = useState(null); + const [error, setError] = useState(null); + // `t` is deliberately absent from the deps: it is a new function on every + // locale change, and re-running `load` would fetch a second time without + // resetting the state below, leaving accept live over a stale result. useEffect(() => { if (!spec.load) return; let cancelled = false; + setLoading(true); + setLoadFailed(false); + setError(null); void spec .load(intent) .then((value) => { if (!cancelled) setLoaded(value); }) - .catch(() => { + .catch((e: unknown) => { if (cancelled) return; setLoadFailed(true); - setError(t(spec.errorKey)); + setError(failureMessage(spec, e)); }) .finally(() => { if (!cancelled) setLoading(false); @@ -47,7 +58,7 @@ function ConfirmSheet({ intent }: { intent: ConfirmIntent }) { return () => { cancelled = true; }; - }, [intent, spec, t]); + }, [intent, spec]); const details = spec.details(intent, loaded, t); // A sheet that could not name what it is about must never be acceptable. A @@ -61,8 +72,8 @@ function ConfirmSheet({ intent }: { intent: ConfirmIntent }) { try { await spec.accept(intent, loaded, t); dismissPrompt(); - } catch { - setError(t(spec.errorKey)); + } catch (e) { + setError(failureMessage(spec, e)); } finally { setBusy(false); } @@ -84,7 +95,7 @@ function ConfirmSheet({ intent }: { intent: ConfirmIntent }) { {loading &&

{t("common.state.loading")}

} {spec.extra?.(loaded, t)} {details.note &&

{details.note}

} - {error &&

{error}

} + {error &&

{t(error.key, error.params)}

}