From a9d8a7aa5ae85db407207041231c37298ac3abf7 Mon Sep 17 00:00:00 2001
From: kipavy
Date: Tue, 18 Aug 2026 21:56:13 +0000
Subject: [PATCH 1/6] refactor(deeplink): drive the confirm sheet from a
per-route spec
---
src/app/App.tsx | 4 +-
...test.tsx => DeepLinkConfirmModal.test.tsx} | 16 +--
.../terminal/DeepLinkConfirmModal.tsx | 106 ++++++++++++++++++
src/components/terminal/DeepLinkJoinModal.tsx | 80 -------------
.../terminal/deepLinkConfirmSpecs.tsx | 57 ++++++++++
5 files changed, 173 insertions(+), 90 deletions(-)
rename src/components/terminal/{DeepLinkJoinModal.test.tsx => DeepLinkConfirmModal.test.tsx} (91%)
create mode 100644 src/components/terminal/DeepLinkConfirmModal.tsx
delete mode 100644 src/components/terminal/DeepLinkJoinModal.tsx
create mode 100644 src/components/terminal/deepLinkConfirmSpecs.tsx
diff --git a/src/app/App.tsx b/src/app/App.tsx
index f30f32a26..84993787a 100644
--- a/src/app/App.tsx
+++ b/src/app/App.tsx
@@ -33,7 +33,7 @@ import { TrialExpiredModal } from "@/components/shared/TrialExpiredModal";
import CloudAuthModal from "@/components/layout/CloudAuthModal";
import WhatsNewModal from "@/components/changelog/WhatsNewModal";
import { EmailVerificationRequiredModal } from "@/components/notifications/EmailVerificationRequiredModal";
-import { DeepLinkJoinModal } from "@/components/terminal/DeepLinkJoinModal";
+import { DeepLinkConfirmModal } from "@/components/terminal/DeepLinkConfirmModal";
import { useDeepLinkStore } from "@/stores/deepLinkStore";
import { GlobalTransferQueue } from "@/components/filetransfer/GlobalTransferQueue";
@@ -85,7 +85,7 @@ function App() {
-
+
{/* Global snippet variable modal — triggered from OmniSearch, the
diff --git a/src/components/terminal/DeepLinkJoinModal.test.tsx b/src/components/terminal/DeepLinkConfirmModal.test.tsx
similarity index 91%
rename from src/components/terminal/DeepLinkJoinModal.test.tsx
rename to src/components/terminal/DeepLinkConfirmModal.test.tsx
index e5564b01e..7a920b8dd 100644
--- a/src/components/terminal/DeepLinkJoinModal.test.tsx
+++ b/src/components/terminal/DeepLinkConfirmModal.test.tsx
@@ -1,7 +1,7 @@
import { test, expect, beforeEach, afterEach, vi } from "vitest";
import { render, screen, cleanup, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
-import { DeepLinkJoinModal } from "./DeepLinkJoinModal";
+import { DeepLinkConfirmModal } from "./DeepLinkConfirmModal";
import { useDeepLinkStore } from "@/stores/deepLinkStore";
vi.mock("react-i18next", () => ({
@@ -26,20 +26,20 @@ beforeEach(() => {
afterEach(() => cleanup());
test("renders nothing without a prompt", () => {
- const { container } = render();
+ const { container } = render();
expect(container.innerHTML).toBe("");
});
test("does not join until the user confirms", () => {
useDeepLinkStore.setState({ prompt: intent });
- render();
+ render();
expect(screen.getByText("terminal.share.deepLinkJoinTitle")).toBeTruthy();
expect(joinMock).not.toHaveBeenCalled();
});
test("confirming joins with the link's session id and token", async () => {
useDeepLinkStore.setState({ prompt: intent });
- render();
+ render();
await userEvent.click(screen.getByText("terminal.share.deepLinkJoinAction"));
await waitFor(() =>
expect(joinMock).toHaveBeenCalledWith(
@@ -51,7 +51,7 @@ test("confirming joins with the link's session id and token", async () => {
test("cancelling clears the prompt without joining", async () => {
useDeepLinkStore.setState({ prompt: intent });
- render();
+ render();
await userEvent.click(screen.getByText("common.action.cancel"));
expect(joinMock).not.toHaveBeenCalled();
expect(useDeepLinkStore.getState().prompt).toBeNull();
@@ -60,7 +60,7 @@ test("cancelling clears the prompt without joining", async () => {
test("a failed join shows the error and keeps the sheet open", async () => {
joinMock.mockRejectedValue(new Error("nope"));
useDeepLinkStore.setState({ prompt: intent });
- render();
+ render();
await userEvent.click(screen.getByText("terminal.share.deepLinkJoinAction"));
await waitFor(() =>
expect(screen.getByText("terminal.share.deepLinkJoinFailed")).toBeTruthy(),
@@ -76,7 +76,7 @@ test("a second click while the first join is in flight does not join twice", asy
}),
);
useDeepLinkStore.setState({ prompt: intent });
- render();
+ render();
const button = screen.getByText("terminal.share.deepLinkJoinAction");
await userEvent.click(button);
await userEvent.click(button);
@@ -87,7 +87,7 @@ test("a second click while the first join is in flight does not join twice", asy
test("a stale error is cleared when a new link is prompted", async () => {
joinMock.mockRejectedValue(new Error("nope"));
useDeepLinkStore.setState({ prompt: intent });
- render();
+ render();
await userEvent.click(screen.getByText("terminal.share.deepLinkJoinAction"));
await waitFor(() =>
expect(screen.getByText("terminal.share.deepLinkJoinFailed")).toBeTruthy(),
diff --git a/src/components/terminal/DeepLinkConfirmModal.tsx b/src/components/terminal/DeepLinkConfirmModal.tsx
new file mode 100644
index 000000000..19a4bffb3
--- /dev/null
+++ b/src/components/terminal/DeepLinkConfirmModal.tsx
@@ -0,0 +1,106 @@
+import { useEffect, useState } from "react";
+import { useTranslation } from "react-i18next";
+import { Icon } from "@iconify/react";
+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";
+
+export function DeepLinkConfirmModal() {
+ const prompt = useDeepLinkStore((s) => s.prompt);
+ // Keyed by intent: a new link remounts the sheet, which is what discards a prior
+ // failure's error and re-runs `load` against the new target. The key embeds a
+ // join token, so it must never be logged.
+ return prompt ? : null;
+}
+
+function ConfirmSheet({ intent }: { intent: ConfirmIntent }) {
+ const { t } = useTranslation();
+ const dismissPrompt = useDeepLinkStore((s) => s.dismissPrompt);
+ // The spec is picked by the intent's own route, so the pairing is right by
+ // construction — TypeScript cannot prove that through the index. Same shape as
+ // `ROUTES[intent.route] as RouteCodec` in deepLinkUrl.ts.
+ const spec = CONFIRM_SPECS[intent.route] as ConfirmSpec;
+
+ const [loaded, setLoaded] = useState(null);
+ const [loading, setLoading] = useState(!!spec.load);
+ const [loadFailed, setLoadFailed] = useState(false);
+ const [busy, setBusy] = useState(false);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ if (!spec.load) return;
+ let cancelled = false;
+ void spec
+ .load(intent)
+ .then((value) => {
+ if (!cancelled) setLoaded(value);
+ })
+ .catch(() => {
+ if (cancelled) return;
+ setLoadFailed(true);
+ setError(t(spec.errorKey));
+ })
+ .finally(() => {
+ if (!cancelled) setLoading(false);
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [intent, spec, t]);
+
+ const details = spec.details(intent, loaded, t);
+ // A sheet that could not name what it is about must never be acceptable. A
+ // failed *accept* is different: it leaves the button live so the user can retry.
+ const acceptable = !loading && !loadFailed && (spec.canAccept?.(loaded) ?? true);
+
+ const handleAccept = async () => {
+ if (busy || !acceptable) return;
+ setBusy(true);
+ setError(null);
+ try {
+ await spec.accept(intent, loaded, t);
+ dismissPrompt();
+ } catch {
+ setError(t(spec.errorKey));
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ return (
+
+
+
+
+
+
+
{details.title}
+
+ {details.body}
+ {loading && {t("common.state.loading")}
}
+ {spec.extra?.(loaded, t)}
+ {details.note && {details.note}
}
+ {error && {error}
}
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/terminal/DeepLinkJoinModal.tsx b/src/components/terminal/DeepLinkJoinModal.tsx
deleted file mode 100644
index 134ba2ec6..000000000
--- a/src/components/terminal/DeepLinkJoinModal.tsx
+++ /dev/null
@@ -1,80 +0,0 @@
-import { useEffect, useState } from "react";
-import { useTranslation } from "react-i18next";
-import { Icon } from "@iconify/react";
-import { Modal, ModalCard } from "@/components/shared/Modal";
-import { useDeepLinkStore } from "@/stores/deepLinkStore";
-import { joinTeamSessionAndOpenTab } from "@/services/teamSessionJoin";
-
-export function DeepLinkJoinModal() {
- const { t } = useTranslation();
- const prompt = useDeepLinkStore((s) => s.prompt);
- const dismissPrompt = useDeepLinkStore((s) => s.dismissPrompt);
- const [loading, setLoading] = useState(false);
- const [error, setError] = useState(null);
-
- // Stays mounted between prompts, so a prior failure's error would leak in.
- useEffect(() => {
- setError(null);
- }, [prompt]);
-
- if (!prompt) return null;
-
- const handleJoin = async () => {
- if (loading) return;
- setLoading(true);
- setError(null);
- try {
- await joinTeamSessionAndOpenTab({
- sessionId: prompt.sessionId,
- connectionName: t("hosts.teamSessions.sharedTerminalFallback"),
- inviteToken: prompt.token,
- });
- dismissPrompt();
- } catch {
- setError(t("terminal.share.deepLinkJoinFailed"));
- } finally {
- setLoading(false);
- }
- };
-
- return (
-
-
-
-
-
-
-
- {t("terminal.share.deepLinkJoinTitle")}
-
-
-
- {t("terminal.share.deepLinkJoinBody")}
-
- {/* The link carries no host name, so naming one would mean inventing it. */}
-
- {t("terminal.share.deepLinkJoinUnknownHost")}
-
- {error && {error}
}
-
-
-
-
-
-
- );
-}
diff --git a/src/components/terminal/deepLinkConfirmSpecs.tsx b/src/components/terminal/deepLinkConfirmSpecs.tsx
new file mode 100644
index 000000000..766e9255e
--- /dev/null
+++ b/src/components/terminal/deepLinkConfirmSpecs.tsx
@@ -0,0 +1,57 @@
+import type { ReactNode } from "react";
+import type { TFunction } from "i18next";
+import type { ConfirmIntent } from "@/services/deepLinkUrl";
+import { joinTeamSessionAndOpenTab } from "@/services/teamSessionJoin";
+
+export type ConfirmRoute = ConfirmIntent["route"];
+type IntentOf = Extract;
+
+export interface ConfirmDetails {
+ title: string;
+ body: string;
+ /** Dim line under the body: what the link cannot tell us, or why accept is off. */
+ note?: string;
+}
+
+export interface ConfirmSpec {
+ icon: string;
+ acceptLabelKey: string;
+ errorKey: string;
+ /**
+ * Resolves what the link names. Omitted where the intent already says
+ * everything, so such a sheet paints complete in its first frame rather than
+ * flashing a spinner over copy it already has.
+ */
+ load?: (intent: IntentOf) => Promise;
+ details: (intent: IntentOf, loaded: L | null, t: TFunction) => ConfirmDetails;
+ extra?: (loaded: L | null, t: TFunction) => ReactNode;
+ /** Guards accept on what `load` found. Absent means "acceptable once loaded". */
+ canAccept?: (loaded: L | null) => boolean;
+ accept: (intent: IntentOf, loaded: L | null, t: TFunction) => Promise;
+}
+
+/** What each route's `load` produces. `void` for a route with nothing to fetch. */
+export interface ConfirmLoad {
+ join: void;
+}
+
+export const CONFIRM_SPECS: { [K in ConfirmRoute]: ConfirmSpec } = {
+ join: {
+ icon: "lucide:users",
+ acceptLabelKey: "terminal.share.deepLinkJoinAction",
+ errorKey: "terminal.share.deepLinkJoinFailed",
+ details: (_intent, _loaded, t) => ({
+ title: t("terminal.share.deepLinkJoinTitle"),
+ body: t("terminal.share.deepLinkJoinBody"),
+ // The link carries no host name, so naming one would mean inventing it.
+ note: t("terminal.share.deepLinkJoinUnknownHost"),
+ }),
+ accept: async (intent, _loaded, t) => {
+ await joinTeamSessionAndOpenTab({
+ sessionId: intent.sessionId,
+ connectionName: t("hosts.teamSessions.sharedTerminalFallback"),
+ inviteToken: intent.token,
+ });
+ },
+ },
+};
From c8ea1ba6036699f894b6c2a2076f08f68e386e75 Mon Sep 17 00:00:00 2001
From: kipavy
Date: Tue, 18 Aug 2026 22:08:30 +0000
Subject: [PATCH 2/6] feat(deeplink): add the invite-by-handle confirm route
---
.../terminal/DeepLinkConfirmModal.test.tsx | 57 +++++++++++++++++++
.../terminal/deepLinkConfirmSpecs.tsx | 53 +++++++++++++++++
src/i18n/locales/en/terminal.json | 6 ++
src/i18n/locales/fr/terminal.json | 6 ++
src/i18n/locales/ru/terminal.json | 6 ++
src/i18n/locales/zh/terminal.json | 6 ++
src/services/deepLinkUrl.test.ts | 21 +++++++
src/services/deepLinkUrl.ts | 25 ++++++++
8 files changed, 180 insertions(+)
diff --git a/src/components/terminal/DeepLinkConfirmModal.test.tsx b/src/components/terminal/DeepLinkConfirmModal.test.tsx
index 7a920b8dd..886724b00 100644
--- a/src/components/terminal/DeepLinkConfirmModal.test.tsx
+++ b/src/components/terminal/DeepLinkConfirmModal.test.tsx
@@ -10,16 +10,36 @@ vi.mock("react-i18next", () => ({
}));
vi.mock("@iconify/react", () => ({ Icon: () => null }));
+let teamConnections: Record = {};
+let activeLocalSessionId: string | null = null;
+
const joinMock = vi.fn(async (..._args: unknown[]) => "local-1");
vi.mock("@/services/teamSessionJoin", () => ({
joinTeamSessionAndOpenTab: (...args: unknown[]) => joinMock(...args),
}));
+const searchUsersMock = vi.fn(async (_q: string) => [] as { user_id: string; handle: string; is_teammate: boolean }[]);
+vi.mock("@/services/teamService", () => ({
+ searchUsers: (q: string) => searchUsersMock(q),
+}));
+
+const inviteMock = vi.fn(async (..._args: unknown[]) => {});
+vi.mock("@/stores/teamSessionStore", () => ({
+ useTeamSessionStore: { getState: () => ({ connections: teamConnections, inviteToActiveSession: inviteMock }) },
+}));
+vi.mock("@/stores/sessionStore", () => ({
+ useSessionStore: { getState: () => ({ activeSessionId: activeLocalSessionId }) },
+}));
+
const SESSION = "3f2504e0-4f89-11d3-9a0c-0305e82c3301";
const intent = { route: "join" as const, sessionId: SESSION, token: "tok" };
beforeEach(() => {
joinMock.mockClear().mockResolvedValue("local-1");
+ searchUsersMock.mockClear().mockResolvedValue([]);
+ inviteMock.mockClear();
+ teamConnections = {};
+ activeLocalSessionId = null;
useDeepLinkStore.setState({ ready: true, queue: [], prompt: null });
});
@@ -98,3 +118,40 @@ test("a stale error is cleared when a new link is prompted", async () => {
expect(screen.queryByText("terminal.share.deepLinkJoinFailed")).toBeNull(),
);
});
+
+test("an invite sheet names the handle and invites the resolved user", async () => {
+ 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((screen.getByText("terminal.share.deepLinkInviteAction").closest("button") as HTMLButtonElement).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("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();
+ await waitFor(() => expect(screen.getByText("terminal.share.deepLinkInviteUnknownUser")).toBeTruthy());
+ await userEvent.click(screen.getByText("terminal.share.deepLinkInviteAction"));
+ expect(inviteMock).not.toHaveBeenCalled();
+});
+
+test("an invite link with no shareable session names the handle but cannot be accepted", async () => {
+ searchUsersMock.mockResolvedValue([{ user_id: "u1", handle: "kevin-p", is_teammate: false }]);
+ activeLocalSessionId = null;
+ teamConnections = {};
+ useDeepLinkStore.setState({ prompt: { route: "invite", handle: "kevin-p" } });
+ render();
+ await waitFor(() => expect(screen.getByText("terminal.share.deepLinkInviteNoActiveSession")).toBeTruthy());
+ await userEvent.click(screen.getByText("terminal.share.deepLinkInviteAction"));
+ expect(inviteMock).not.toHaveBeenCalled();
+});
diff --git a/src/components/terminal/deepLinkConfirmSpecs.tsx b/src/components/terminal/deepLinkConfirmSpecs.tsx
index 766e9255e..a53e27abe 100644
--- a/src/components/terminal/deepLinkConfirmSpecs.tsx
+++ b/src/components/terminal/deepLinkConfirmSpecs.tsx
@@ -2,6 +2,10 @@ import type { ReactNode } from "react";
import type { TFunction } from "i18next";
import type { ConfirmIntent } from "@/services/deepLinkUrl";
import { joinTeamSessionAndOpenTab } from "@/services/teamSessionJoin";
+import { searchUsers } from "@/services/teamService";
+import { useSessionStore } from "@/stores/sessionStore";
+import { useTeamSessionStore } from "@/stores/teamSessionStore";
+import type { InviteTarget } from "@/services/teamSharing";
export type ConfirmRoute = ConfirmIntent["route"];
type IntentOf = Extract;
@@ -30,9 +34,27 @@ export interface ConfirmSpec {
accept: (intent: IntentOf, loaded: L | null, t: TFunction) => Promise;
}
+export interface InviteLoad {
+ target: InviteTarget | null;
+ /** The local session this device can invite into, or null when there is none. */
+ localSessionId: string | null;
+}
+
/** What each route's `load` produces. `void` for a route with nothing to fetch. */
export interface ConfirmLoad {
join: void;
+ invite: InviteLoad;
+}
+
+/**
+ * The local session this device is currently sharing *and* still holds a per-user
+ * session key for. An `invite_link` session keeps no such key, so inviting into
+ * one would always throw `cannotInviteWithoutSessionKey`.
+ */
+function shareableSessionId(): string | null {
+ const id = useSessionStore.getState().activeSessionId;
+ if (!id) return null;
+ return useTeamSessionStore.getState().connections[id]?.sessionKeyBytes ? id : null;
}
export const CONFIRM_SPECS: { [K in ConfirmRoute]: ConfirmSpec } = {
@@ -54,4 +76,35 @@ export const CONFIRM_SPECS: { [K in ConfirmRoute]: ConfirmSpec {
+ const results = await searchUsers(handle);
+ // Exact match only. A fuzzy hit would let `@kev` land on `@kevin-p`, which
+ // is the impersonation shape the unified invite design set out to close.
+ const match = results.find((user) => user.handle.toLowerCase() === handle);
+ return {
+ target: match ? { user_id: match.user_id, handle: match.handle } : null,
+ localSessionId: shareableSessionId(),
+ };
+ },
+ details: (intent, loaded, t) => ({
+ title: t("terminal.share.deepLinkInviteTitle", { handle: intent.handle }),
+ body: t("terminal.share.deepLinkInviteBody", { handle: intent.handle }),
+ note: !loaded
+ ? undefined
+ : !loaded.target
+ ? t("terminal.share.deepLinkInviteUnknownUser", { handle: intent.handle })
+ : !loaded.localSessionId
+ ? t("terminal.share.deepLinkInviteNoActiveSession")
+ : undefined,
+ }),
+ canAccept: (loaded) => !!loaded?.target && !!loaded.localSessionId,
+ accept: async (_intent, loaded) => {
+ if (!loaded?.target || !loaded.localSessionId) return;
+ await useTeamSessionStore.getState().inviteToActiveSession(loaded.localSessionId, loaded.target);
+ },
+ },
};
diff --git a/src/i18n/locales/en/terminal.json b/src/i18n/locales/en/terminal.json
index 8b64273de..e51d76b1d 100644
--- a/src/i18n/locales/en/terminal.json
+++ b/src/i18n/locales/en/terminal.json
@@ -166,6 +166,12 @@
"deepLinkJoinUnknownHost": "Voltius can't identify who shared it until you join.",
"deepLinkJoinAction": "Join session",
"deepLinkJoinFailed": "Couldn't join that session — the link may have expired.",
+ "deepLinkInviteTitle": "Invite @{{handle}}?",
+ "deepLinkInviteBody": "This link asks you to invite @{{handle}} into the terminal you are sharing. They will see everything in that terminal.",
+ "deepLinkInviteUnknownUser": "No Voltius account matches @{{handle}} exactly.",
+ "deepLinkInviteNoActiveSession": "You are not sharing a terminal right now. Start sharing one, then open this link again.",
+ "deepLinkInviteAction": "Invite",
+ "deepLinkInviteFailed": "Could not send the invite",
"getSpokenCode": "Get a code to read aloud",
"codeExpired": "Code expired — get a new one",
"newCode": "New code",
diff --git a/src/i18n/locales/fr/terminal.json b/src/i18n/locales/fr/terminal.json
index 109ea9ad0..448b7a119 100644
--- a/src/i18n/locales/fr/terminal.json
+++ b/src/i18n/locales/fr/terminal.json
@@ -166,6 +166,12 @@
"deepLinkJoinUnknownHost": "Voltius ne peut pas identifier l'expéditeur avant que vous rejoigniez la session.",
"deepLinkJoinAction": "Rejoindre la session",
"deepLinkJoinFailed": "Impossible de rejoindre cette session — le lien a peut-être expiré.",
+ "deepLinkInviteTitle": "Inviter @{{handle}} ?",
+ "deepLinkInviteBody": "Ce lien vous demande d'inviter @{{handle}} dans le terminal que vous partagez. Cette personne verra tout ce qui s'y passe.",
+ "deepLinkInviteUnknownUser": "Aucun compte Voltius ne correspond exactement à @{{handle}}.",
+ "deepLinkInviteNoActiveSession": "Vous ne partagez aucun terminal actuellement. Lancez un partage, puis rouvrez ce lien.",
+ "deepLinkInviteAction": "Inviter",
+ "deepLinkInviteFailed": "Impossible d'envoyer l'invitation",
"getSpokenCode": "Obtenir un code à dicter",
"codeExpired": "Code expiré — en obtenir un nouveau",
"newCode": "Nouveau code",
diff --git a/src/i18n/locales/ru/terminal.json b/src/i18n/locales/ru/terminal.json
index 439d89ce0..c7ea1adc2 100644
--- a/src/i18n/locales/ru/terminal.json
+++ b/src/i18n/locales/ru/terminal.json
@@ -176,6 +176,12 @@
"deepLinkJoinUnknownHost": "Voltius не сможет определить отправителя, пока вы не присоединитесь.",
"deepLinkJoinAction": "Присоединиться",
"deepLinkJoinFailed": "Не удалось присоединиться к сессии — возможно, срок действия ссылки истёк.",
+ "deepLinkInviteTitle": "Пригласить @{{handle}}?",
+ "deepLinkInviteBody": "Эта ссылка предлагает пригласить @{{handle}} в терминал, которым вы делитесь. Этот пользователь увидит всё, что в нём происходит.",
+ "deepLinkInviteUnknownUser": "Нет учётной записи Voltius, точно совпадающей с @{{handle}}.",
+ "deepLinkInviteNoActiveSession": "Сейчас вы не делитесь терминалом. Начните общий доступ и откройте ссылку снова.",
+ "deepLinkInviteAction": "Пригласить",
+ "deepLinkInviteFailed": "Не удалось отправить приглашение",
"getSpokenCode": "Получить код для передачи голосом",
"codeExpired": "Код истёк — получите новый",
"newCode": "Новый код",
diff --git a/src/i18n/locales/zh/terminal.json b/src/i18n/locales/zh/terminal.json
index e8eb737c2..68d07a54e 100644
--- a/src/i18n/locales/zh/terminal.json
+++ b/src/i18n/locales/zh/terminal.json
@@ -166,6 +166,12 @@
"deepLinkJoinUnknownHost": "在你加入之前,Voltius 无法识别共享者。",
"deepLinkJoinAction": "加入会话",
"deepLinkJoinFailed": "无法加入该会话——链接可能已失效。",
+ "deepLinkInviteTitle": "邀请 @{{handle}}?",
+ "deepLinkInviteBody": "此链接请求你将 @{{handle}} 邀请到你正在共享的终端。对方将看到该终端中的全部内容。",
+ "deepLinkInviteUnknownUser": "没有与 @{{handle}} 完全匹配的 Voltius 账户。",
+ "deepLinkInviteNoActiveSession": "你当前没有共享任何终端。请先开始共享,然后重新打开此链接。",
+ "deepLinkInviteAction": "邀请",
+ "deepLinkInviteFailed": "无法发送邀请",
"getSpokenCode": "获取口述验证码",
"codeExpired": "验证码已过期 — 请获取新的",
"newCode": "新验证码",
diff --git a/src/services/deepLinkUrl.test.ts b/src/services/deepLinkUrl.test.ts
index 8074cc912..b70f29fab 100644
--- a/src/services/deepLinkUrl.test.ts
+++ b/src/services/deepLinkUrl.test.ts
@@ -211,3 +211,24 @@ test("a parameterless route builds without a trailing question mark", () => {
expect(buildDeepLink({ route: "billing" }, "scheme")).toBe("voltius://billing");
expect(buildDeepLink({ route: "billing" }, "https")).toBe("https://voltius.app/open#billing");
});
+
+test("an invite link round-trips through both forms", () => {
+ const intent = { route: "invite" as const, handle: "kevin-p" };
+ expect(parseDeepLink(buildDeepLink(intent, "scheme"))).toEqual(intent);
+ expect(parseDeepLink(buildDeepLink(intent, "https"))).toEqual(intent);
+});
+
+test("an invite handle is accepted with or without its @, and normalised without it", () => {
+ expect(parseDeepLink("voltius://invite?h=%40kevin-p")).toEqual({ route: "invite", handle: "kevin-p" });
+ expect(parseDeepLink("voltius://invite?h=kevin-p")).toEqual({ route: "invite", handle: "kevin-p" });
+ expect(parseDeepLink("voltius://invite?h=Kevin-P")).toEqual({ route: "invite", handle: "kevin-p" });
+});
+
+test("an invite link with a handle the server could never issue is rejected", () => {
+ expect(parseDeepLink("voltius://invite?h=ab")).toBeNull();
+ expect(parseDeepLink("voltius://invite?h=-kevin")).toBeNull();
+ expect(parseDeepLink("voltius://invite?h=kevin-")).toBeNull();
+ expect(parseDeepLink("voltius://invite?h=kevin%20p")).toBeNull();
+ expect(parseDeepLink("voltius://invite?h=" + "a".repeat(31))).toBeNull();
+ expect(parseDeepLink("voltius://invite")).toBeNull();
+});
diff --git a/src/services/deepLinkUrl.ts b/src/services/deepLinkUrl.ts
index fa352005b..e76ce87e3 100644
--- a/src/services/deepLinkUrl.ts
+++ b/src/services/deepLinkUrl.ts
@@ -2,12 +2,14 @@ import { isSessionId } from "@/services/sessionId";
import { isSettingsSection, type SettingsSection } from "@/stores/uiStore";
export type JoinIntent = { route: "join"; sessionId: string; token: string };
+export type InviteIntent = { route: "invite"; handle: string };
export type VerifiedIntent = { route: "verified"; userId: string };
export type NotificationIntent = { route: "notification"; entryId: string | null };
export type SettingsIntent = { route: "settings"; section: SettingsSection };
export type BillingIntent = { route: "billing" };
export type DeepLinkIntent =
| JoinIntent
+ | InviteIntent
| VerifiedIntent
| NotificationIntent
| SettingsIntent
@@ -32,6 +34,9 @@ type Route = DeepLinkIntent["route"];
*/
const TRUST = {
join: "confirm",
+ // Grants a stranger access to a live terminal, so nothing happens until the
+ // host accepts.
+ invite: "confirm",
verified: "silent",
notification: "navigate",
settings: "navigate",
@@ -60,6 +65,16 @@ type RouteCodec = {
/** Long enough for any id the inbox builds, short enough to stay a lookup key. */
const MAX_ENTRY_ID = 200;
+/**
+ * Mirrors the server's custom-handle rule (server: `src/handles.rs`,
+ * `validate_custom_handle`): 3–30 ASCII lowercase/digit/`-`/`_`, never starting
+ * or ending in a separator. Generated handles (`adjective-noun-1234`) satisfy it
+ * too. The reserved-name list is deliberately not mirrored: it governs *claiming*
+ * a handle, not looking one up, and a link naming a reserved handle resolves to
+ * nobody anyway.
+ */
+const HANDLE_RE = /^[a-z0-9][a-z0-9_-]{1,28}[a-z0-9]$/;
+
const ROUTES: { [K in Route]: RouteCodec } = {
join: {
parse: (params) => {
@@ -70,6 +85,16 @@ const ROUTES: { [K in Route]: RouteCodec } = {
},
params: ({ sessionId, token }) => ({ s: sessionId, t: token }),
},
+ invite: {
+ // The `@` is how a handle is written throughout the UI, so links carry it;
+ // it is display sugar and never part of the stored value.
+ parse: (params) => {
+ const handle = (params.get("h") ?? "").replace(/^@/, "").toLowerCase();
+ if (!HANDLE_RE.test(handle)) return null;
+ return { route: "invite", handle };
+ },
+ params: ({ handle }) => ({ h: `@${handle}` }),
+ },
verified: {
parse: (params) => {
const userId = params.get("u") ?? "";
From 07de71ca36b99b4544bcc57c0693120f6035c1b2 Mon Sep 17 00:00:00 2001
From: kipavy
Date: Tue, 18 Aug 2026 22:24:38 +0000
Subject: [PATCH 3/6] fix(deeplink): narrow ConfirmIntent before reading
sessionId in tests
---
src/services/deepLink.start.test.ts | 6 +++---
src/services/deepLink.test.ts | 18 +++++++++---------
2 files changed, 12 insertions(+), 12 deletions(-)
diff --git a/src/services/deepLink.start.test.ts b/src/services/deepLink.start.test.ts
index a86fb57e5..cde1aa3ad 100644
--- a/src/services/deepLink.start.test.ts
+++ b/src/services/deepLink.start.test.ts
@@ -22,7 +22,7 @@ test("a cold-start url is handled", async () => {
getCurrent.mockResolvedValue([URL_A]);
startDeepLinks();
await vi.waitFor(() =>
- expect(useDeepLinkStore.getState().prompt?.sessionId).toBe(SESSION),
+ expect(useDeepLinkStore.getState().prompt).toMatchObject({ route: "join", sessionId: SESSION }),
);
});
@@ -30,14 +30,14 @@ test("a warm url delivered through onOpenUrl is handled", async () => {
startDeepLinks();
await vi.waitFor(() => expect(onOpenUrl).toHaveBeenCalled());
onOpenUrl.mock.calls[0][0]([URL_A]);
- expect(useDeepLinkStore.getState().prompt?.sessionId).toBe(SESSION);
+ expect(useDeepLinkStore.getState().prompt).toMatchObject({ route: "join", sessionId: SESSION });
});
test("the same url from both paths prompts once", async () => {
getCurrent.mockResolvedValue([URL_A]);
startDeepLinks();
await vi.waitFor(() =>
- expect(useDeepLinkStore.getState().prompt?.sessionId).toBe(SESSION),
+ expect(useDeepLinkStore.getState().prompt).toMatchObject({ route: "join", sessionId: SESSION }),
);
const first = useDeepLinkStore.getState().prompt;
await vi.waitFor(() => expect(onOpenUrl).toHaveBeenCalled());
diff --git a/src/services/deepLink.test.ts b/src/services/deepLink.test.ts
index 5a2abe65b..925b1e849 100644
--- a/src/services/deepLink.test.ts
+++ b/src/services/deepLink.test.ts
@@ -23,14 +23,14 @@ test("becoming ready drains the queued intent into the prompt", () => {
handleDeepLink(link(SESSION));
useDeepLinkStore.getState().setReady(true);
const s = useDeepLinkStore.getState();
- expect(s.prompt?.sessionId).toBe(SESSION);
+ expect(s.prompt).toMatchObject({ route: "join", sessionId: SESSION });
expect(s.queue).toHaveLength(0);
});
test("a link arriving while ready prompts immediately", () => {
useDeepLinkStore.getState().setReady(true);
handleDeepLink(link(SESSION));
- expect(useDeepLinkStore.getState().prompt?.sessionId).toBe(SESSION);
+ expect(useDeepLinkStore.getState().prompt).toMatchObject({ route: "join", sessionId: SESSION });
});
test("a warm echo while the prompt is open does not re-prompt", () => {
@@ -52,16 +52,16 @@ test("dismissing then redelivering the same link prompts again", () => {
handleDeepLink(link(SESSION));
useDeepLinkStore.getState().dismissPrompt();
handleDeepLink(link(SESSION));
- expect(useDeepLinkStore.getState().prompt?.sessionId).toBe(SESSION);
+ expect(useDeepLinkStore.getState().prompt).toMatchObject({ route: "join", sessionId: SESSION });
});
test("two different links queued before ready are both delivered, in order", () => {
handleDeepLink(link(SESSION));
handleDeepLink(link(OTHER));
useDeepLinkStore.getState().setReady(true);
- expect(useDeepLinkStore.getState().prompt?.sessionId).toBe(SESSION);
+ expect(useDeepLinkStore.getState().prompt).toMatchObject({ route: "join", sessionId: SESSION });
useDeepLinkStore.getState().dismissPrompt();
- expect(useDeepLinkStore.getState().prompt?.sessionId).toBe(OTHER);
+ expect(useDeepLinkStore.getState().prompt).toMatchObject({ route: "join", sessionId: OTHER });
});
test("an unknown route is dropped without prompting or throwing", () => {
@@ -87,7 +87,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();
- expect(useDeepLinkStore.getState().prompt?.sessionId).toBe(OTHER);
+ expect(useDeepLinkStore.getState().prompt).toMatchObject({ route: "join", sessionId: OTHER });
});
test("dismissing clears the prompt", () => {
@@ -116,7 +116,7 @@ test("a silent link does not wait behind an open prompt", () => {
handleDeepLink(link(SESSION));
handleDeepLink(`voltius://verified?u=${USER}`);
expect(seen).toEqual([USER]);
- expect(useDeepLinkStore.getState().prompt?.sessionId).toBe(SESSION);
+ expect(useDeepLinkStore.getState().prompt).toMatchObject({ route: "join", sessionId: SESSION });
});
test("a silent link arriving before ready runs once ready", () => {
@@ -156,7 +156,7 @@ test("a link enqueued by a silent handler survives the drain that ran it", () =>
useDeepLinkStore.getState().setUnpromptedHandler(() => handleDeepLink(link(SESSION)));
useDeepLinkStore.getState().setReady(true);
handleDeepLink(`voltius://verified?u=${USER}`);
- expect(useDeepLinkStore.getState().prompt?.sessionId).toBe(SESSION);
+ expect(useDeepLinkStore.getState().prompt).toMatchObject({ route: "join", sessionId: SESSION });
expect(useDeepLinkStore.getState().queue).toHaveLength(0);
});
@@ -204,7 +204,7 @@ test("a navigate link does not wait behind an open prompt", () => {
handleDeepLink(link(SESSION));
handleDeepLink("voltius://notification?n=invite%3A42");
expect(seen).toEqual(["notification"]);
- expect(useDeepLinkStore.getState().prompt?.sessionId).toBe(SESSION);
+ expect(useDeepLinkStore.getState().prompt).toMatchObject({ route: "join", sessionId: SESSION });
});
test("the same navigate link delivered twice reaches the handler once", () => {
From d43f56c5e4db8df099d89125326199cf71941ba1 Mon Sep 17 00:00:00 2001
From: kipavy
Date: Tue, 18 Aug 2026 22:41:28 +0000
Subject: [PATCH 4/6] feat(deeplink): add the snippet-install confirm route
---
.../snippets/community/useCommunityInstall.ts | 23 +------
.../terminal/DeepLinkConfirmModal.test.tsx | 47 +++++++++++++-
.../terminal/deepLinkConfirmSpecs.tsx | 49 +++++++++++++++
src/hooks/useAllSnippets.ts | 8 +--
src/i18n/locales/en/snippets.json | 9 +++
src/i18n/locales/fr/snippets.json | 9 +++
src/i18n/locales/ru/snippets.json | 9 +++
src/i18n/locales/zh/snippets.json | 9 +++
src/services/deepLinkUrl.test.ts | 12 ++++
src/services/deepLinkUrl.ts | 20 +++++-
src/services/import-export/storeAccess.ts | 62 +++++++++++++++++++
src/services/snippetCatalogInstall.ts | 30 +++++++++
12 files changed, 257 insertions(+), 30 deletions(-)
create mode 100644 src/services/import-export/storeAccess.ts
diff --git a/src/components/snippets/community/useCommunityInstall.ts b/src/components/snippets/community/useCommunityInstall.ts
index c9c77cbda..f6d9a22d0 100644
--- a/src/components/snippets/community/useCommunityInstall.ts
+++ b/src/components/snippets/community/useCommunityInstall.ts
@@ -1,9 +1,6 @@
import { useState } from "react";
-import { useImportStores, useReloadFns } from "@/components/import-export/useStores";
-import { useAllSnippets } from "@/hooks/useAllSnippets";
import { useVaultStore } from "@/stores/vaultStore";
-import { runImport, reloadAll } from "@/services/import-export/registry";
-import { bundleFromEntries, type EntrySelection } from "@/services/snippetCatalogInstall";
+import { installCatalogEntries, type EntrySelection } from "@/services/snippetCatalogInstall";
export function useInstallTargetVault() {
const selectedVaultIds = useVaultStore(s => s.selectedVaultIds);
@@ -13,30 +10,14 @@ export function useInstallTargetVault() {
}
export function useCommunityInstall() {
- const stores = useImportStores();
- const reloaders = useReloadFns();
- const existingSnippets = useAllSnippets();
const vault = useInstallTargetVault();
const [installing, setInstalling] = useState(false);
async function install(selections: EntrySelection[]) {
setInstalling(true);
try {
- // The folder a pack lands in is created by runImport, before the snippets
- // that reference it — calling the handler directly would drop folder_id.
- return await runImport(bundleFromEntries(selections), {
- vault_id: vault.id,
- tag: "",
- skipDupes: true,
- existingConnections: [], existingKeys: [], existingIdentities: [],
- existingSnippets,
- existingPfRules: [],
- folderEidMap: new Map(), snippetFolderEidMap: new Map(), keyEidMap: new Map(),
- identityEidMap: new Map(), connectionEidMap: new Map(),
- stores,
- });
+ return await installCatalogEntries(selections, vault.id);
} finally {
- await reloadAll(reloaders);
setInstalling(false);
}
}
diff --git a/src/components/terminal/DeepLinkConfirmModal.test.tsx b/src/components/terminal/DeepLinkConfirmModal.test.tsx
index 886724b00..0bd5c219a 100644
--- a/src/components/terminal/DeepLinkConfirmModal.test.tsx
+++ b/src/components/terminal/DeepLinkConfirmModal.test.tsx
@@ -4,14 +4,29 @@ import userEvent from "@testing-library/user-event";
import { DeepLinkConfirmModal } from "./DeepLinkConfirmModal";
import { useDeepLinkStore } from "@/stores/deepLinkStore";
+let teamConnections: Record = {};
+let activeLocalSessionId: string | null = null;
+let snippetEntries: { id: string; kind: string; name: string; author?: string; snippets: unknown[] }[] = [];
+
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (k: string) => k }),
initReactI18next: { type: "3rdParty", init: () => {} },
}));
vi.mock("@iconify/react", () => ({ Icon: () => null }));
-let teamConnections: Record = {};
-let activeLocalSessionId: string | null = null;
+const fetchSnippetCatalogMock = vi.fn(async () => ({ entries: snippetEntries, fromCache: false }));
+vi.mock("@/services/snippetCatalogFetch", () => ({
+ fetchCatalog: () => fetchSnippetCatalogMock(),
+}));
+
+const installEntriesMock = vi.fn(async (..._args: unknown[]) => ({ imported: 1, errors: 0 }));
+vi.mock("@/services/snippetCatalogInstall", () => ({
+ installCatalogEntries: (...args: unknown[]) => installEntriesMock(...args),
+}));
+
+vi.mock("@/stores/vaultStore", () => ({
+ useVaultStore: { getState: () => ({ selectedVaultIds: ["team-a"], vaults: [{ id: "team-a", name: "Ops" }] }) },
+}));
const joinMock = vi.fn(async (..._args: unknown[]) => "local-1");
vi.mock("@/services/teamSessionJoin", () => ({
@@ -40,6 +55,9 @@ beforeEach(() => {
inviteMock.mockClear();
teamConnections = {};
activeLocalSessionId = null;
+ snippetEntries = [];
+ fetchSnippetCatalogMock.mockClear();
+ installEntriesMock.mockClear().mockResolvedValue({ imported: 1, errors: 0 });
useDeepLinkStore.setState({ ready: true, queue: [], prompt: null });
});
@@ -155,3 +173,28 @@ test("an invite link with no shareable session names the handle but cannot be ac
await userEvent.click(screen.getByText("terminal.share.deepLinkInviteAction"));
expect(inviteMock).not.toHaveBeenCalled();
});
+
+test("a snippet-install sheet names the entry and its destination vault before installing", async () => {
+ snippetEntries = [{ id: "docker-cleanup", kind: "pack", name: "Docker cleanup", author: "kevin", snippets: [{}, {}] }];
+ useDeepLinkStore.setState({ prompt: { route: "snippet-install", entryId: "docker-cleanup" } });
+ render();
+ await waitFor(() => expect(screen.getByText("snippets.deepLinkInstall.summary")).toBeTruthy());
+ expect(screen.getByText("snippets.deepLinkInstall.destination")).toBeTruthy();
+ expect(installEntriesMock).not.toHaveBeenCalled();
+ await userEvent.click(screen.getByText("snippets.deepLinkInstall.action"));
+ await waitFor(() =>
+ expect(installEntriesMock).toHaveBeenCalledWith(
+ [expect.objectContaining({ entry: expect.objectContaining({ id: "docker-cleanup" }) })],
+ "team-a",
+ ),
+ );
+});
+
+test("a snippet-install link naming an entry the catalogue does not list cannot be accepted", async () => {
+ snippetEntries = [];
+ useDeepLinkStore.setState({ prompt: { route: "snippet-install", entryId: "docker-cleanup" } });
+ render();
+ await waitFor(() => expect(screen.getByText("snippets.deepLinkInstall.failed")).toBeTruthy());
+ await userEvent.click(screen.getByText("snippets.deepLinkInstall.action"));
+ expect(installEntriesMock).not.toHaveBeenCalled();
+});
diff --git a/src/components/terminal/deepLinkConfirmSpecs.tsx b/src/components/terminal/deepLinkConfirmSpecs.tsx
index a53e27abe..9999e8f74 100644
--- a/src/components/terminal/deepLinkConfirmSpecs.tsx
+++ b/src/components/terminal/deepLinkConfirmSpecs.tsx
@@ -6,6 +6,10 @@ import { searchUsers } from "@/services/teamService";
import { useSessionStore } from "@/stores/sessionStore";
import { useTeamSessionStore } from "@/stores/teamSessionStore";
import type { InviteTarget } from "@/services/teamSharing";
+import { fetchCatalog as fetchSnippetCatalog } from "@/services/snippetCatalogFetch";
+import { installCatalogEntries } from "@/services/snippetCatalogInstall";
+import type { CatalogEntry } from "@/services/snippetCatalog";
+import { useVaultStore } from "@/stores/vaultStore";
export type ConfirmRoute = ConfirmIntent["route"];
type IntentOf = Extract;
@@ -44,6 +48,7 @@ export interface InviteLoad {
export interface ConfirmLoad {
join: void;
invite: InviteLoad;
+ "snippet-install": CatalogEntry;
}
/**
@@ -57,6 +62,13 @@ function shareableSessionId(): string | null {
return useTeamSessionStore.getState().connections[id]?.sessionKeyBytes ? id : null;
}
+/** The vault an install lands in — the selected one, or the personal vault. */
+function installTargetVault(): { id: string; name: string } {
+ const { selectedVaultIds, vaults } = useVaultStore.getState();
+ const id = selectedVaultIds[0] ?? "personal";
+ return { id, name: vaults.find((vault) => vault.id === id)?.name ?? id };
+}
+
export const CONFIRM_SPECS: { [K in ConfirmRoute]: ConfirmSpec } = {
join: {
icon: "lucide:users",
@@ -107,4 +119,41 @@ export const CONFIRM_SPECS: { [K in ConfirmRoute]: ConfirmSpec {
+ const { entries } = await fetchSnippetCatalog();
+ const entry = entries.find((candidate) => candidate.id === entryId);
+ // Rejecting here is what leaves accept dead: a sheet that cannot name what
+ // it would install must never be acceptable.
+ if (!entry) throw new Error("snippet catalogue entry not found");
+ return entry;
+ },
+ details: (_intent, loaded, t) => ({
+ title: t("snippets.deepLinkInstall.title"),
+ body: t("snippets.deepLinkInstall.body"),
+ note: loaded
+ ? // Named on purpose: the install lands in whichever vault is selected, and a
+ // link the user did not author should not quietly write into one they were
+ // not thinking about.
+ t("snippets.deepLinkInstall.destination", { vault: installTargetVault().name })
+ : undefined,
+ }),
+ extra: (loaded, t) =>
+ loaded ? (
+
+ {t("snippets.deepLinkInstall.summary", {
+ name: loaded.name,
+ author: loaded.author ?? t("snippets.deepLinkInstall.unknownAuthor"),
+ count: loaded.snippets.length,
+ })}
+
+ ) : null,
+ accept: async (_intent, loaded) => {
+ if (!loaded) return;
+ await installCatalogEntries([{ entry: loaded }], installTargetVault().id);
+ },
+ },
};
diff --git a/src/hooks/useAllSnippets.ts b/src/hooks/useAllSnippets.ts
index b87066750..5b27cb4b8 100644
--- a/src/hooks/useAllSnippets.ts
+++ b/src/hooks/useAllSnippets.ts
@@ -2,16 +2,12 @@ import { useMemo } from "react";
import { useShallow } from "zustand/shallow";
import { useSnippetStore } from "@/stores/snippetStore";
import { useTeamStore } from "@/stores/teamStore";
+import { mergeSnippets } from "@/services/import-export/storeAccess";
import type { Snippet } from "@/types";
export function useAllSnippets(): Snippet[] {
const personal = useSnippetStore((s) => s.snippets);
const teamMap = useSnippetStore((s) => s.teamSnippets);
const teamIds = useTeamStore(useShallow((s) => s.teams.map((t) => t.id)));
- return useMemo(() => {
- const map = new Map();
- for (const s of personal) map.set(s.id, s);
- for (const id of teamIds) for (const s of teamMap[id] ?? []) map.set(s.id, s);
- return [...map.values()];
- }, [personal, teamMap, teamIds]);
+ return useMemo(() => mergeSnippets(personal, teamMap, teamIds), [personal, teamMap, teamIds]);
}
diff --git a/src/i18n/locales/en/snippets.json b/src/i18n/locales/en/snippets.json
index 055a6d897..14607898f 100644
--- a/src/i18n/locales/en/snippets.json
+++ b/src/i18n/locales/en/snippets.json
@@ -244,6 +244,15 @@
"error": {
"unsupportedCatalog": "This snippet catalogue is in a format this version of Voltius doesn’t understand. Try updating the app."
}
+ },
+ "deepLinkInstall": {
+ "title": "Install these snippets?",
+ "body": "This link installs snippets from the Voltius community catalogue. Snippets are shell commands that run on your hosts when you use them.",
+ "summary": "{{name}} by {{author}} — {{count}} snippets",
+ "unknownAuthor": "an unnamed author",
+ "destination": "They will be added to the {{vault}} vault.",
+ "action": "Install",
+ "failed": "Could not install these snippets"
}
}
}
diff --git a/src/i18n/locales/fr/snippets.json b/src/i18n/locales/fr/snippets.json
index d0c838e40..9b088dc50 100644
--- a/src/i18n/locales/fr/snippets.json
+++ b/src/i18n/locales/fr/snippets.json
@@ -50,6 +50,15 @@
"unsupportedCatalog": "Ce catalogue de snippets est dans un format que cette version de Voltius ne comprend pas. Essayez de mettre à jour l’application."
}
},
+ "deepLinkInstall": {
+ "title": "Installer ces extraits ?",
+ "body": "Ce lien installe des extraits depuis le catalogue communautaire Voltius. Les extraits sont des commandes shell qui s'exécutent sur vos hôtes lorsque vous les utilisez.",
+ "summary": "{{name}} par {{author}} — {{count}} extraits",
+ "unknownAuthor": "un auteur anonyme",
+ "destination": "Ils seront ajoutés au coffre {{vault}}.",
+ "action": "Installer",
+ "failed": "Impossible d'installer ces extraits"
+ },
"sequence": {
"summary": {
"success": "Snippet exécuté sur {{count}} cible(s)",
diff --git a/src/i18n/locales/ru/snippets.json b/src/i18n/locales/ru/snippets.json
index 6d1c11bf6..631c01b98 100644
--- a/src/i18n/locales/ru/snippets.json
+++ b/src/i18n/locales/ru/snippets.json
@@ -50,6 +50,15 @@
"unsupportedCatalog": "Этот каталог сниппетов имеет формат, который эта версия Voltius не понимает. Попробуйте обновить приложение."
}
},
+ "deepLinkInstall": {
+ "title": "Установить эти сниппеты?",
+ "body": "Эта ссылка устанавливает сниппеты из общего каталога Voltius. Сниппеты — это команды оболочки, которые выполняются на ваших хостах при их использовании.",
+ "summary": "{{name}}, автор {{author}} — сниппетов: {{count}}",
+ "unknownAuthor": "автор не указан",
+ "destination": "Они будут добавлены в хранилище {{vault}}.",
+ "action": "Установить",
+ "failed": "Не удалось установить сниппеты"
+ },
"sequence": {
"summary": {
"success_one": "Сниппет выполнен на {{count}} цели",
diff --git a/src/i18n/locales/zh/snippets.json b/src/i18n/locales/zh/snippets.json
index 8366d8910..3ae9913b9 100644
--- a/src/i18n/locales/zh/snippets.json
+++ b/src/i18n/locales/zh/snippets.json
@@ -50,6 +50,15 @@
"unsupportedCatalog": "此片段目录的格式不受当前版本的 Voltius 支持。请尝试更新应用。"
}
},
+ "deepLinkInstall": {
+ "title": "安装这些代码片段?",
+ "body": "此链接将从 Voltius 社区目录安装代码片段。代码片段是 shell 命令,使用时会在你的主机上执行。",
+ "summary": "{{name}},作者 {{author}} — 共 {{count}} 个片段",
+ "unknownAuthor": "未署名作者",
+ "destination": "它们将被添加到 {{vault}} 保险库。",
+ "action": "安装",
+ "failed": "无法安装这些代码片段"
+ },
"sequence": {
"summary": {
"success": "代码片段已在 {{count}} 个目标上运行",
diff --git a/src/services/deepLinkUrl.test.ts b/src/services/deepLinkUrl.test.ts
index b70f29fab..e4cf3782d 100644
--- a/src/services/deepLinkUrl.test.ts
+++ b/src/services/deepLinkUrl.test.ts
@@ -232,3 +232,15 @@ test("an invite link with a handle the server could never issue is rejected", ()
expect(parseDeepLink("voltius://invite?h=" + "a".repeat(31))).toBeNull();
expect(parseDeepLink("voltius://invite")).toBeNull();
});
+
+test("a snippet-install link round-trips through both forms", () => {
+ const intent = { route: "snippet-install" as const, entryId: "docker-cleanup" };
+ expect(parseDeepLink(buildDeepLink(intent, "scheme"))).toEqual(intent);
+ expect(parseDeepLink(buildDeepLink(intent, "https"))).toEqual(intent);
+});
+
+test("a snippet-install link with no id, or an over-long one, is rejected", () => {
+ expect(parseDeepLink("voltius://snippet-install")).toBeNull();
+ expect(parseDeepLink("voltius://snippet-install?id=")).toBeNull();
+ expect(parseDeepLink("voltius://snippet-install?id=" + "a".repeat(101))).toBeNull();
+});
diff --git a/src/services/deepLinkUrl.ts b/src/services/deepLinkUrl.ts
index e76ce87e3..9c6e88e35 100644
--- a/src/services/deepLinkUrl.ts
+++ b/src/services/deepLinkUrl.ts
@@ -7,13 +7,15 @@ export type VerifiedIntent = { route: "verified"; userId: string };
export type NotificationIntent = { route: "notification"; entryId: string | null };
export type SettingsIntent = { route: "settings"; section: SettingsSection };
export type BillingIntent = { route: "billing" };
+export type SnippetInstallIntent = { route: "snippet-install"; entryId: string };
export type DeepLinkIntent =
| JoinIntent
| InviteIntent
| VerifiedIntent
| NotificationIntent
| SettingsIntent
- | BillingIntent;
+ | BillingIntent
+ | SnippetInstallIntent;
type TrustClass = "confirm" | "silent" | "navigate";
type Route = DeepLinkIntent["route"];
@@ -41,6 +43,9 @@ const TRUST = {
notification: "navigate",
settings: "navigate",
billing: "navigate",
+ // Writes snippets — shell commands the user will later run — into a vault, so
+ // nothing lands until the user accepts.
+ "snippet-install": "confirm",
} as const satisfies Record;
type RouteOfClass = {
@@ -65,6 +70,9 @@ type RouteCodec = {
/** Long enough for any id the inbox builds, short enough to stay a lookup key. */
const MAX_ENTRY_ID = 200;
+/** Long enough for any catalogue id upstream authors, short enough to stay a key. */
+const MAX_CATALOG_ID = 100;
+
/**
* Mirrors the server's custom-handle rule (server: `src/handles.rs`,
* `validate_custom_handle`): 3–30 ASCII lowercase/digit/`-`/`_`, never starting
@@ -126,6 +134,16 @@ const ROUTES: { [K in Route]: RouteCodec } = {
parse: () => ({ route: "billing" }),
params: () => ({}),
},
+ "snippet-install": {
+ parse: (params) => {
+ const entryId = params.get("id") ?? "";
+ // Opaque beyond its length: the catalogue is fetched at confirm time, and an
+ // id it does not list fails there, where the sheet can say so.
+ if (!entryId || entryId.length > MAX_CATALOG_ID) return null;
+ return { route: "snippet-install", entryId };
+ },
+ params: ({ entryId }) => ({ id: entryId }),
+ },
};
diff --git a/src/services/import-export/storeAccess.ts b/src/services/import-export/storeAccess.ts
new file mode 100644
index 000000000..6016f3c62
--- /dev/null
+++ b/src/services/import-export/storeAccess.ts
@@ -0,0 +1,62 @@
+import { useConnectionStore } from "@/stores/connectionStore";
+import { useIdentityStore } from "@/stores/identityStore";
+import { useKeyStore } from "@/stores/keyStore";
+import { useFolderStore } from "@/stores/folderStore";
+import { useSnippetStore } from "@/stores/snippetStore";
+import { useSnippetFolderStore } from "@/stores/snippetFolderStore";
+import { usePortForwardingStore } from "@/stores/portForwardingStore";
+import { useTeamStore } from "@/stores/teamStore";
+import type { Snippet } from "@/types";
+import type { ImportStores, ReloadFns } from "./context";
+
+/** Personal snippets plus every team's, last write winning on a shared id. */
+export function mergeSnippets(
+ personal: Snippet[],
+ teamMap: Record,
+ teamIds: string[],
+): Snippet[] {
+ const map = new Map();
+ for (const snippet of personal) map.set(snippet.id, snippet);
+ for (const id of teamIds) for (const snippet of teamMap[id] ?? []) map.set(snippet.id, snippet);
+ return [...map.values()];
+}
+
+/**
+ * The same aggregation the import-export hooks expose, read outside React. An
+ * import triggered by a deep link has no component to hang hooks off, and a
+ * second hand-rolled copy of these reads is exactly what would drift.
+ */
+export function importStoresOf(): ImportStores {
+ return {
+ saveFolder: useFolderStore.getState().saveFolder,
+ saveSnippetFolder: useSnippetFolderStore.getState().saveFolder,
+ saveKey: useKeyStore.getState().saveKey,
+ saveIdentity: useIdentityStore.getState().saveIdentity,
+ saveConnection: useConnectionStore.getState().saveConnection,
+ updateConnection: useConnectionStore.getState().updateConnection,
+ createSnippet: useSnippetStore.getState().createSnippet,
+ updateSnippet: useSnippetStore.getState().updateSnippet,
+ createPfRule: usePortForwardingStore.getState().createRule,
+ };
+}
+
+export function reloadFnsOf(): ReloadFns {
+ return {
+ loadConnections: useConnectionStore.getState().loadConnections,
+ loadIdentities: useIdentityStore.getState().loadIdentities,
+ loadKeys: useKeyStore.getState().loadKeys,
+ loadFolders: useFolderStore.getState().loadFolders,
+ loadSnippets: useSnippetStore.getState().loadSnippets,
+ loadSnippetFolders: useSnippetFolderStore.getState().loadFolders,
+ loadPfRules: usePortForwardingStore.getState().loadRules,
+ };
+}
+
+export function allSnippetsNow(): Snippet[] {
+ const snippets = useSnippetStore.getState();
+ return mergeSnippets(
+ snippets.snippets,
+ snippets.teamSnippets,
+ useTeamStore.getState().teams.map((team) => team.id),
+ );
+}
diff --git a/src/services/snippetCatalogInstall.ts b/src/services/snippetCatalogInstall.ts
index 617a23317..04978534d 100644
--- a/src/services/snippetCatalogInstall.ts
+++ b/src/services/snippetCatalogInstall.ts
@@ -1,5 +1,7 @@
import type { ExportBundle, FolderExport, SnippetExport } from "./import-export/formats";
import { refEids } from "./import-export/snippetRefs";
+import { runImport, reloadAll } from "./import-export/registry";
+import { allSnippetsNow, importStoresOf, reloadFnsOf } from "./import-export/storeAccess";
import type { CatalogEntry } from "./snippetCatalog";
export interface EntrySelection {
@@ -60,3 +62,31 @@ export function bundleFromEntries(selections: EntrySelection[]): ExportBundle {
connections: [], identities: [], keys: [], portForwardingRules: [],
};
}
+
+/**
+ * Install catalogue entries into a vault. Runs the ordinary import path, so what
+ * lands is plain owned snippets — the folder a pack lands in is created by
+ * `runImport` before the snippets that reference it, which calling the handler
+ * directly would not do.
+ */
+export async function installCatalogEntries(
+ selections: EntrySelection[],
+ vaultId: string,
+): Promise<{ imported: number; errors: number }> {
+ const reloaders = reloadFnsOf();
+ try {
+ return await runImport(bundleFromEntries(selections), {
+ vault_id: vaultId,
+ tag: "",
+ skipDupes: true,
+ existingConnections: [], existingKeys: [], existingIdentities: [],
+ existingSnippets: allSnippetsNow(),
+ existingPfRules: [],
+ folderEidMap: new Map(), snippetFolderEidMap: new Map(), keyEidMap: new Map(),
+ identityEidMap: new Map(), connectionEidMap: new Map(),
+ stores: importStoresOf(),
+ });
+ } finally {
+ await reloadAll(reloaders);
+ }
+}
From e6e38c4dbcb4a81b184c37b343bb03a5cc81efc3 Mon Sep 17 00:00:00 2001
From: kipavy
Date: Tue, 18 Aug 2026 22:52:04 +0000
Subject: [PATCH 5/6] refactor(snippets): share the install-vault derivation
between both callers
---
.../snippets/community/useCommunityInstall.ts | 4 ++--
src/components/terminal/deepLinkConfirmSpecs.tsx | 7 +++----
src/services/import-export/storeAccess.ts | 10 ++++++++++
3 files changed, 15 insertions(+), 6 deletions(-)
diff --git a/src/components/snippets/community/useCommunityInstall.ts b/src/components/snippets/community/useCommunityInstall.ts
index f6d9a22d0..a1437647e 100644
--- a/src/components/snippets/community/useCommunityInstall.ts
+++ b/src/components/snippets/community/useCommunityInstall.ts
@@ -1,12 +1,12 @@
import { useState } from "react";
import { useVaultStore } from "@/stores/vaultStore";
import { installCatalogEntries, type EntrySelection } from "@/services/snippetCatalogInstall";
+import { resolveInstallVault } from "@/services/import-export/storeAccess";
export function useInstallTargetVault() {
const selectedVaultIds = useVaultStore(s => s.selectedVaultIds);
const vaults = useVaultStore(s => s.vaults);
- const id = selectedVaultIds[0] ?? "personal";
- return { id, name: vaults.find(v => v.id === id)?.name ?? id };
+ return resolveInstallVault({ selectedVaultIds, vaults });
}
export function useCommunityInstall() {
diff --git a/src/components/terminal/deepLinkConfirmSpecs.tsx b/src/components/terminal/deepLinkConfirmSpecs.tsx
index 9999e8f74..33b9ebd28 100644
--- a/src/components/terminal/deepLinkConfirmSpecs.tsx
+++ b/src/components/terminal/deepLinkConfirmSpecs.tsx
@@ -8,6 +8,7 @@ import { useTeamSessionStore } from "@/stores/teamSessionStore";
import type { InviteTarget } from "@/services/teamSharing";
import { fetchCatalog as fetchSnippetCatalog } from "@/services/snippetCatalogFetch";
import { installCatalogEntries } from "@/services/snippetCatalogInstall";
+import { resolveInstallVault } from "@/services/import-export/storeAccess";
import type { CatalogEntry } from "@/services/snippetCatalog";
import { useVaultStore } from "@/stores/vaultStore";
@@ -62,11 +63,9 @@ function shareableSessionId(): string | null {
return useTeamSessionStore.getState().connections[id]?.sessionKeyBytes ? id : null;
}
-/** The vault an install lands in — the selected one, or the personal vault. */
+/** The vault an install lands in — read directly since the spec is not a component. */
function installTargetVault(): { id: string; name: string } {
- const { selectedVaultIds, vaults } = useVaultStore.getState();
- const id = selectedVaultIds[0] ?? "personal";
- return { id, name: vaults.find((vault) => vault.id === id)?.name ?? id };
+ return resolveInstallVault(useVaultStore.getState());
}
export const CONFIRM_SPECS: { [K in ConfirmRoute]: ConfirmSpec } = {
diff --git a/src/services/import-export/storeAccess.ts b/src/services/import-export/storeAccess.ts
index 6016f3c62..d517eb092 100644
--- a/src/services/import-export/storeAccess.ts
+++ b/src/services/import-export/storeAccess.ts
@@ -6,6 +6,7 @@ import { useSnippetStore } from "@/stores/snippetStore";
import { useSnippetFolderStore } from "@/stores/snippetFolderStore";
import { usePortForwardingStore } from "@/stores/portForwardingStore";
import { useTeamStore } from "@/stores/teamStore";
+import type { Vault } from "@/stores/vaultStore";
import type { Snippet } from "@/types";
import type { ImportStores, ReloadFns } from "./context";
@@ -60,3 +61,12 @@ export function allSnippetsNow(): Snippet[] {
useTeamStore.getState().teams.map((team) => team.id),
);
}
+
+/** The vault an install lands in — the selected one, or the personal vault. */
+export function resolveInstallVault(state: {
+ selectedVaultIds: string[];
+ vaults: Vault[];
+}): { id: string; name: string } {
+ const id = state.selectedVaultIds[0] ?? "personal";
+ return { id, name: state.vaults.find((vault) => vault.id === id)?.name ?? id };
+}
From 3bc6246890540ad81b820184d6c0061456c68205 Mon Sep 17 00:00:00 2001
From: kipavy
Date: Tue, 18 Aug 2026 23:20:21 +0000
Subject: [PATCH 6/6] feat(deeplink): add the plugin-install confirm route
---
.../sections/PluginPermissionList.tsx | 104 ++++++++++++++++++
.../sections/PluginPermissionModal.tsx | 85 +-------------
.../terminal/DeepLinkConfirmModal.test.tsx | 66 +++++++++++
.../terminal/deepLinkConfirmSpecs.tsx | 61 ++++++++++
src/i18n/locales/en/settings.json | 8 ++
src/i18n/locales/fr/settings.json | 8 ++
src/i18n/locales/ru/settings.json | 8 ++
src/i18n/locales/zh/settings.json | 8 ++
src/services/deepLinkUrl.test.ts | 28 ++++-
src/services/deepLinkUrl.ts | 37 ++++++-
src/stores/marketplaceStore.test.ts | 11 +-
11 files changed, 342 insertions(+), 82 deletions(-)
create mode 100644 src/components/settings/sections/PluginPermissionList.tsx
diff --git a/src/components/settings/sections/PluginPermissionList.tsx b/src/components/settings/sections/PluginPermissionList.tsx
new file mode 100644
index 000000000..4c0af2d5e
--- /dev/null
+++ b/src/components/settings/sections/PluginPermissionList.tsx
@@ -0,0 +1,104 @@
+import { Icon } from "@iconify/react";
+import { useTranslation } from "react-i18next";
+import { describePermissions, type PermissionDescriptor } from "@/plugins/gatedPermissions";
+
+interface Props {
+ /** All permissions the plugin will hold after this action. */
+ permissions: string[];
+ /** For updates: permissions newly requested by this version (subset of `permissions`). */
+ addedPermissions?: string[];
+ /** Labels the block as "new permissions" — an update gate, not a first install. */
+ showNewHeading?: boolean;
+}
+
+/**
+ * Discloses what a plugin's code will be allowed to do. Shared by the settings
+ * install/update gate and the deep-link install sheet, so a link-driven install
+ * cannot end up disclosing less than a click-driven one.
+ */
+export function PluginPermissionList({ permissions, addedPermissions = [], showNewHeading = false }: Props) {
+ const { t } = useTranslation();
+ const added = new Set(addedPermissions);
+
+ const descriptors = describePermissions(permissions);
+ const ordinary = descriptors.filter((d) => !d.gated);
+ const readOnly = descriptors.filter((d) => d.gated && !d.danger);
+ const danger = descriptors.filter((d) => d.danger);
+
+ const renderRow = (d: PermissionDescriptor) => {
+ const isNew = added.has(d.perm);
+ // Three tones, not two: a gated read-only perm is neither ordinary (it still
+ // needs consent) nor destructive. Accent-tinted, no warning triangle.
+ const elevated = d.gated && !d.danger;
+ return (
+
+
+ {d.danger && }
+ {elevated && }
+ {isNew && }
+ {d.known ? t(d.labelKey) : d.perm}
+
+ {d.known && (
+
+ {t(d.descriptionKey)}
+
+ )}
+
+ );
+ };
+
+ if (descriptors.length === 0) {
+ return (
+
+ {t("settings.plugins.permissionModal.noPermissions")}
+
+ );
+ }
+
+ return (
+
+ {showNewHeading && (
+
+ {t("settings.plugins.permissionModal.newPermissions")}
+
+ )}
+ {ordinary.length > 0 && (
+
{ordinary.map(renderRow)}
+ )}
+ {readOnly.length > 0 && (
+
+
+
+ {t("settings.plugins.permissionModal.permissions.readOnlyHeading")}
+
+
+ {t("settings.plugins.permissionModal.permissions.readOnlyWarning")}
+
+ {readOnly.map(renderRow)}
+
+ )}
+ {danger.length > 0 && (
+
+
+
+ {t("settings.plugins.permissionModal.permissions.dangerHeading")}
+
+
+ {t("settings.plugins.permissionModal.permissions.dangerWarning")}
+
+ {danger.map(renderRow)}
+
+ )}
+
+ );
+}
diff --git a/src/components/settings/sections/PluginPermissionModal.tsx b/src/components/settings/sections/PluginPermissionModal.tsx
index a5234d0b6..0624c7ea3 100644
--- a/src/components/settings/sections/PluginPermissionModal.tsx
+++ b/src/components/settings/sections/PluginPermissionModal.tsx
@@ -1,7 +1,7 @@
import { Icon } from "@iconify/react";
import { useTranslation } from "react-i18next";
import { Modal, ModalCard } from "@/components/shared/Modal";
-import { describePermissions, type PermissionDescriptor } from "@/plugins/gatedPermissions";
+import { PluginPermissionList } from "@/components/settings/sections/PluginPermissionList";
interface Props {
mode: "install" | "update";
@@ -29,46 +29,8 @@ export function PluginPermissionModal({
onCancel,
}: Props) {
const { t } = useTranslation();
- const added = new Set(addedPermissions);
const isUpdate = mode === "update";
- const descriptors = describePermissions(permissions);
- const ordinary = descriptors.filter((d) => !d.gated);
- const readOnly = descriptors.filter((d) => d.gated && !d.danger);
- const danger = descriptors.filter((d) => d.danger);
-
- const renderRow = (d: PermissionDescriptor) => {
- const isNew = added.has(d.perm);
- // Three tones, not two: a gated read-only perm is neither ordinary (it still
- // needs consent) nor destructive. Accent-tinted, no warning triangle.
- const elevated = d.gated && !d.danger;
- return (
-
-
- {d.danger && }
- {elevated && }
- {isNew && }
- {d.known ? t(d.labelKey) : d.perm}
-
- {d.known && (
-
- {t(d.descriptionKey)}
-
- )}
-
- );
- };
-
return (
@@ -92,46 +54,11 @@ export function PluginPermissionModal({
: t("settings.plugins.permissionModal.installBody")}
- {descriptors.length === 0 ? (
-
- {t("settings.plugins.permissionModal.noPermissions")}
-
- ) : (
-
- {isUpdate && (
-
- {t("settings.plugins.permissionModal.newPermissions")}
-
- )}
- {ordinary.length > 0 && (
-
{ordinary.map(renderRow)}
- )}
- {readOnly.length > 0 && (
-
-
-
- {t("settings.plugins.permissionModal.permissions.readOnlyHeading")}
-
-
- {t("settings.plugins.permissionModal.permissions.readOnlyWarning")}
-
- {readOnly.map(renderRow)}
-
- )}
- {danger.length > 0 && (
-
-
-
- {t("settings.plugins.permissionModal.permissions.dangerHeading")}
-
-
- {t("settings.plugins.permissionModal.permissions.dangerWarning")}
-
- {danger.map(renderRow)}
-
- )}
-
- )}
+