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)} -
- )} -
- )} +