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
11 changes: 4 additions & 7 deletions src/components/settings/sections/PluginsSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -179,15 +179,12 @@ function usePluginInstaller() {
const busy = new Set<string>([...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,
});
};
Expand Down
54 changes: 51 additions & 3 deletions src/components/terminal/DeepLinkConfirmModal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, { sessionKeyBytes?: Uint8Array }> = {};
let activeLocalSessionId: string | null = null;
Expand Down Expand Up @@ -171,22 +176,34 @@ 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(<DeepLinkConfirmModal />);
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(<DeepLinkConfirmModal />);
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";
teamConnections = { "local-1": { sessionKeyBytes: new Uint8Array(32) } };
useDeepLinkStore.setState({ prompt: { route: "invite", handle: "kevin-p" } });
render(<DeepLinkConfirmModal />);
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();
});
Expand All @@ -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(<DeepLinkConfirmModal />);
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();
});
Expand All @@ -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(<DeepLinkConfirmModal />);
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();
});
Expand Down Expand Up @@ -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(<DeepLinkConfirmModal />);
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(<DeepLinkConfirmModal />);
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" }];
Expand Down
25 changes: 18 additions & 7 deletions src/components/terminal/DeepLinkConfirmModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -14,6 +15,10 @@ export function DeepLinkConfirmModal() {
return prompt ? <ConfirmSheet key={intentKey(prompt)} intent={prompt} /> : null;
}

function failureMessage(spec: ConfirmSpec<ConfirmRoute, unknown>, 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);
Expand All @@ -26,28 +31,34 @@ 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<string | null>(null);
const [error, setError] = useState<TranslatableMessage | null>(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);
});
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
Expand All @@ -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);
}
Expand All @@ -84,7 +95,7 @@ function ConfirmSheet({ intent }: { intent: ConfirmIntent }) {
{loading && <p className="text-xs text-(--t-text-dim)">{t("common.state.loading")}</p>}
{spec.extra?.(loaded, t)}
{details.note && <p className="text-xs text-(--t-text-dim)">{details.note}</p>}
{error && <p className="text-xs text-(--t-status-error)">{error}</p>}
{error && <p className="text-xs text-(--t-status-error)">{t(error.key, error.params)}</p>}
<div className="flex gap-2 justify-end">
<button
onClick={dismissPrompt}
Expand Down
11 changes: 11 additions & 0 deletions src/components/terminal/deepLinkConfirmSpecs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import type { CatalogEntry } from "@/services/snippetCatalog";
import { useVaultStore } from "@/stores/vaultStore";
import { PluginPermissionList } from "@/components/settings/sections/PluginPermissionList";
import { useMarketplaceStore, type MarketplacePlugin } from "@/stores/marketplaceStore";
import { pluginInstallErrorMessage, type TranslatableMessage } from "@/plugins/installErrors";
import type { PluginManifest } from "@/plugins/api";

export type ConfirmRoute = ConfirmIntent["route"];
Expand All @@ -29,6 +30,13 @@ export interface ConfirmSpec<K extends ConfirmRoute, L> {
icon: string;
acceptLabelKey: string;
errorKey: string;
/**
* Distinguishes failures that mean something specific to the user from the
* generic `errorKey`, which it receives as the fallback. Returns a key rather
* than translated text so the sheet can hold it across a locale change.
* Absent means every failure reads the same.
*/
errorMessage?: (e: unknown, fallbackKey: string) => TranslatableMessage;
/**
* Resolves what the link names. Omitted where the intent already says
* everything, so such a sheet paints complete in its first frame rather than
Expand Down Expand Up @@ -172,6 +180,9 @@ export const CONFIRM_SPECS: { [K in ConfirmRoute]: ConfirmSpec<K, ConfirmLoad[K]
icon: "lucide:puzzle",
acceptLabelKey: "settings.plugins.deepLinkInstall.action",
errorKey: "settings.plugins.deepLinkInstall.failed",
// An integrity mismatch is the user's only tamper signal, so it must not be
// reported as a link that did not work.
errorMessage: pluginInstallErrorMessage,
load: async ({ pluginId, sourceId }) => {
await useMarketplaceStore.getState().loadSources();
const source = useMarketplaceStore
Expand Down
21 changes: 21 additions & 0 deletions src/plugins/installErrors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { PluginHashMismatchError } from "./integrity";
import { MinAppVersionError } from "./version";

/** A message named by its i18n key, so a caller can hold one across a locale change. */
export interface TranslatableMessage {
key: string;
params?: Record<string, string>;
}

/**
* 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,
* so it must never collapse into whatever generic failure the caller shows;
* `fallbackKey` covers everything else.
*/
export function pluginInstallErrorMessage(e: unknown, fallbackKey: string): TranslatableMessage {
if (e instanceof PluginHashMismatchError) return { key: "settings.plugins.install.integrityFailed" };
if (e instanceof MinAppVersionError)
return { key: "settings.plugins.install.versionUnsupported", params: { version: e.required } };
return { key: fallbackKey };
}
24 changes: 21 additions & 3 deletions src/services/deepLink.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ const SESSION = "3f2504e0-4f89-11d3-9a0c-0305e82c3301";
const OTHER = "11111111-2222-3333-4444-555555555555";
const link = (s: string) => `voltius://join?s=${s}&t=tok`;

/** Waits out the store's promote hold, which keeps a queued sheet off the screen
* long enough that a double-click cannot carry into it. */
const settle = () => new Promise((resolve) => setTimeout(resolve, 350));

beforeEach(() => {
useDeepLinkStore.setState({ ready: false, queue: [], prompt: null });
useDeepLinkStore.getState().setUnpromptedHandler(null);
Expand Down Expand Up @@ -47,23 +51,36 @@ test("a duplicate arriving before ready is dropped", () => {
expect(useDeepLinkStore.getState().queue).toHaveLength(1);
});

test("dismissing then redelivering the same link prompts again", () => {
test("dismissing then redelivering the same link prompts again", async () => {
useDeepLinkStore.getState().setReady(true);
handleDeepLink(link(SESSION));
useDeepLinkStore.getState().dismissPrompt();
handleDeepLink(link(SESSION));
await settle();
expect(useDeepLinkStore.getState().prompt).toMatchObject({ route: "join", sessionId: SESSION });
});

test("two different links queued before ready are both delivered, in order", () => {
test("two different links queued before ready are both delivered, in order", async () => {
handleDeepLink(link(SESSION));
handleDeepLink(link(OTHER));
useDeepLinkStore.getState().setReady(true);
expect(useDeepLinkStore.getState().prompt).toMatchObject({ route: "join", sessionId: SESSION });
useDeepLinkStore.getState().dismissPrompt();
await settle();
expect(useDeepLinkStore.getState().prompt).toMatchObject({ route: "join", sessionId: OTHER });
});

test("the sheet behind the one just dismissed is not promoted into the same click", () => {
useDeepLinkStore.getState().setReady(true);
handleDeepLink(link(SESSION));
handleDeepLink(link(OTHER));
useDeepLinkStore.getState().dismissPrompt();
// Still queued, not on screen: a double-click's second half has nothing to hit.
const s = useDeepLinkStore.getState();
expect(s.prompt).toBeNull();
expect(s.queue).toHaveLength(1);
});

test("an unknown route is dropped without prompting or throwing", () => {
useDeepLinkStore.getState().setReady(true);
handleDeepLink(`voltius://vault?s=${SESSION}&t=tok`);
Expand All @@ -78,7 +95,7 @@ test("dropping a link never logs the query string", () => {
warn.mockRestore();
});

test("a different link while a prompt is on screen queues behind it", () => {
test("a different link while a prompt is on screen queues behind it", async () => {
useDeepLinkStore.getState().setReady(true);
handleDeepLink(link(SESSION));
const shown = useDeepLinkStore.getState().prompt;
Expand All @@ -87,6 +104,7 @@ test("a different link while a prompt is on screen queues behind it", () => {
expect(s.prompt).toBe(shown);
expect(s.queue).toHaveLength(1);
s.dismissPrompt();
await settle();
expect(useDeepLinkStore.getState().prompt).toMatchObject({ route: "join", sessionId: OTHER });
});

Expand Down
Loading