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/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)} -
- )} -
- )} +
+ +
+
+
+ ); +} diff --git a/src/components/terminal/DeepLinkJoinModal.test.tsx b/src/components/terminal/DeepLinkJoinModal.test.tsx deleted file mode 100644 index e5564b01e..000000000 --- a/src/components/terminal/DeepLinkJoinModal.test.tsx +++ /dev/null @@ -1,100 +0,0 @@ -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 { useDeepLinkStore } from "@/stores/deepLinkStore"; - -vi.mock("react-i18next", () => ({ - useTranslation: () => ({ t: (k: string) => k }), - initReactI18next: { type: "3rdParty", init: () => {} }, -})); -vi.mock("@iconify/react", () => ({ Icon: () => null })); - -const joinMock = vi.fn(async (..._args: unknown[]) => "local-1"); -vi.mock("@/services/teamSessionJoin", () => ({ - joinTeamSessionAndOpenTab: (...args: unknown[]) => joinMock(...args), -})); - -const SESSION = "3f2504e0-4f89-11d3-9a0c-0305e82c3301"; -const intent = { route: "join" as const, sessionId: SESSION, token: "tok" }; - -beforeEach(() => { - joinMock.mockClear().mockResolvedValue("local-1"); - useDeepLinkStore.setState({ ready: true, queue: [], prompt: null }); -}); - -afterEach(() => cleanup()); - -test("renders nothing without a prompt", () => { - const { container } = render(); - expect(container.innerHTML).toBe(""); -}); - -test("does not join until the user confirms", () => { - useDeepLinkStore.setState({ prompt: intent }); - 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(); - await userEvent.click(screen.getByText("terminal.share.deepLinkJoinAction")); - await waitFor(() => - expect(joinMock).toHaveBeenCalledWith( - expect.objectContaining({ sessionId: SESSION, inviteToken: "tok" }), - ), - ); - await waitFor(() => expect(useDeepLinkStore.getState().prompt).toBeNull()); -}); - -test("cancelling clears the prompt without joining", async () => { - useDeepLinkStore.setState({ prompt: intent }); - render(); - await userEvent.click(screen.getByText("common.action.cancel")); - expect(joinMock).not.toHaveBeenCalled(); - expect(useDeepLinkStore.getState().prompt).toBeNull(); -}); - -test("a failed join shows the error and keeps the sheet open", async () => { - joinMock.mockRejectedValue(new Error("nope")); - useDeepLinkStore.setState({ prompt: intent }); - render(); - await userEvent.click(screen.getByText("terminal.share.deepLinkJoinAction")); - await waitFor(() => - expect(screen.getByText("terminal.share.deepLinkJoinFailed")).toBeTruthy(), - ); - expect(useDeepLinkStore.getState().prompt).not.toBeNull(); -}); - -test("a second click while the first join is in flight does not join twice", async () => { - let resolveJoin!: (v: string) => void; - joinMock.mockReturnValue( - new Promise((resolve) => { - resolveJoin = resolve; - }), - ); - useDeepLinkStore.setState({ prompt: intent }); - render(); - const button = screen.getByText("terminal.share.deepLinkJoinAction"); - await userEvent.click(button); - await userEvent.click(button); - expect(joinMock).toHaveBeenCalledTimes(1); - resolveJoin("local-1"); -}); - -test("a stale error is cleared when a new link is prompted", async () => { - joinMock.mockRejectedValue(new Error("nope")); - useDeepLinkStore.setState({ prompt: intent }); - render(); - await userEvent.click(screen.getByText("terminal.share.deepLinkJoinAction")); - await waitFor(() => - expect(screen.getByText("terminal.share.deepLinkJoinFailed")).toBeTruthy(), - ); - const other = { route: "join" as const, sessionId: "11111111-2222-3333-4444-555555555555", token: "tok2" }; - useDeepLinkStore.setState({ prompt: other }); - await waitFor(() => - expect(screen.queryByText("terminal.share.deepLinkJoinFailed")).toBeNull(), - ); -}); 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..d3b59fc27 --- /dev/null +++ b/src/components/terminal/deepLinkConfirmSpecs.tsx @@ -0,0 +1,219 @@ +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"; +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"; +import { PluginPermissionList } from "@/components/settings/sections/PluginPermissionList"; +import { useMarketplaceStore, type MarketplacePlugin } from "@/stores/marketplaceStore"; +import type { PluginManifest } from "@/plugins/api"; + +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; +} + +export interface InviteLoad { + target: InviteTarget | null; + /** The local session this device can invite into, or null when there is none. */ + localSessionId: string | null; +} + +export interface PluginInstallLoad { + plugin: MarketplacePlugin; + manifest: PluginManifest; + /** The exact reviewed manifest text, handed to installPlugin so what was + * disclosed and what is loaded are the same bytes. */ + manifestText: string; + sourceName: string; +} + +/** What each route's `load` produces. `void` for a route with nothing to fetch. */ +export interface ConfirmLoad { + join: void; + invite: InviteLoad; + "snippet-install": CatalogEntry; + "plugin-install": PluginInstallLoad; +} + +/** + * 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; +} + +/** The vault an install lands in — read directly since the spec is not a component. */ +function installTargetVault(): { id: string; name: string } { + return resolveInstallVault(useVaultStore.getState()); +} + +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, + }); + }, + }, + invite: { + icon: "lucide:user-plus", + acceptLabelKey: "terminal.share.deepLinkInviteAction", + errorKey: "terminal.share.deepLinkInviteFailed", + load: async ({ handle }) => { + 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); + }, + }, + "snippet-install": { + icon: "lucide:scroll-text", + acceptLabelKey: "snippets.deepLinkInstall.action", + errorKey: "snippets.deepLinkInstall.failed", + load: async ({ entryId }) => { + 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); + }, + }, + "plugin-install": { + icon: "lucide:puzzle", + acceptLabelKey: "settings.plugins.deepLinkInstall.action", + errorKey: "settings.plugins.deepLinkInstall.failed", + load: async ({ pluginId, sourceId }) => { + await useMarketplaceStore.getState().loadSources(); + const source = useMarketplaceStore + .getState() + .sources.find((candidate) => candidate.id === sourceId && candidate.enabled); + // A link can only point at a catalogue this device already trusts. Failing + // here — before any fetch — is what stops a link introducing a code source. + if (!source) throw new Error("unknown or disabled plugin source"); + + await useMarketplaceStore.getState().fetchCatalog(); + const plugin = useMarketplaceStore + .getState() + .catalog.find((candidate) => candidate.id === pluginId && candidate.sourceId === sourceId); + if (!plugin) throw new Error("plugin not listed by that source"); + + const { manifest, manifestText } = await useMarketplaceStore.getState().fetchManifest(plugin); + return { plugin, manifest, manifestText, sourceName: source.name }; + }, + details: (_intent, loaded, t) => ({ + title: t("settings.plugins.deepLinkInstall.title"), + body: t("settings.plugins.deepLinkInstall.body"), + note: loaded ? t("settings.plugins.deepLinkInstall.source", { source: loaded.sourceName }) : undefined, + }), + extra: (loaded, t) => + loaded ? ( + <> +

+ {t("settings.plugins.deepLinkInstall.summary", { + name: loaded.plugin.name, + author: loaded.plugin.author, + version: loaded.plugin.version, + })} +

+ {/* The permission list is carried here rather than chaining to + PluginPermissionModal: two consecutive consent dialogs for one click + train the user to click through both. */} + + + ) : null, + accept: async (_intent, loaded) => { + if (!loaded) return; + await useMarketplaceStore.getState().installPlugin(loaded.plugin, loaded.manifestText); + }, + }, +}; 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/settings.json b/src/i18n/locales/en/settings.json index 35cc65a22..7f1aeaaf2 100644 --- a/src/i18n/locales/en/settings.json +++ b/src/i18n/locales/en/settings.json @@ -797,6 +797,14 @@ "failed": "Failed to install plugin.", "versionUnsupported": "This plugin requires app version {{version}} or later." }, + "deepLinkInstall": { + "title": "Install this plugin?", + "body": "This link installs a plugin. A plugin runs code inside Voltius with the permissions listed below.", + "summary": "{{name}} {{version}} by {{author}}", + "source": "From the {{source}} catalogue.", + "action": "Install", + "failed": "This link does not name a plugin Voltius can install" + }, "permissionModal": { "installTitle": "Install {{name}}?", "installBody": "This plugin runs with the app's full privileges. It requests these permissions:", 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/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/settings.json b/src/i18n/locales/fr/settings.json index 3033933a9..bfae89cfb 100644 --- a/src/i18n/locales/fr/settings.json +++ b/src/i18n/locales/fr/settings.json @@ -797,6 +797,14 @@ "failed": "Échec de l'installation du module.", "versionUnsupported": "Ce module nécessite la version {{version}} ou supérieure de l'application." }, + "deepLinkInstall": { + "title": "Installer ce plugin ?", + "body": "Ce lien installe un plugin. Un plugin exécute du code dans Voltius avec les permissions listées ci-dessous.", + "summary": "{{name}} {{version}} par {{author}}", + "source": "Depuis le catalogue {{source}}.", + "action": "Installer", + "failed": "Ce lien ne désigne aucun plugin que Voltius puisse installer" + }, "permissionModal": { "installTitle": "Installer {{name}} ?", "installBody": "Ce plugin s'exécute avec tous les privilèges de l'application. Il demande ces autorisations :", 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/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/settings.json b/src/i18n/locales/ru/settings.json index 7d5697224..b9d961d76 100644 --- a/src/i18n/locales/ru/settings.json +++ b/src/i18n/locales/ru/settings.json @@ -897,6 +897,14 @@ "integrityFailed": "Пакет плагина не прошёл проверку целостности — установка заблокирована.", "failed": "Не удалось установить плагин.", "versionUnsupported": "Этот плагин требует версию приложения {{version}} или новее." + }, + "deepLinkInstall": { + "title": "Установить этот плагин?", + "body": "Эта ссылка устанавливает плагин. Плагин выполняет код внутри Voltius с перечисленными ниже разрешениями.", + "summary": "{{name}} {{version}}, автор {{author}}", + "source": "Из каталога {{source}}.", + "action": "Установить", + "failed": "Эта ссылка не указывает на плагин, который Voltius может установить" } }, "sftp": { 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/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/settings.json b/src/i18n/locales/zh/settings.json index a9a1a07f0..e06947402 100644 --- a/src/i18n/locales/zh/settings.json +++ b/src/i18n/locales/zh/settings.json @@ -617,6 +617,14 @@ "failed": "安装插件失败。", "versionUnsupported": "此插件需要应用版本 {{version}} 或更高版本。" }, + "deepLinkInstall": { + "title": "安装此插件?", + "body": "此链接将安装一个插件。插件会在 Voltius 内部以下列权限运行代码。", + "summary": "{{name}} {{version}},作者 {{author}}", + "source": "来自 {{source}} 目录。", + "action": "安装", + "failed": "此链接未指向 Voltius 可安装的插件" + }, "permissionModal": { "installTitle": "安装 {{name}}?", "installBody": "此插件将以应用的完整权限运行。它请求以下权限:", 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/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/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", () => { diff --git a/src/services/deepLinkUrl.test.ts b/src/services/deepLinkUrl.test.ts index 8074cc912..2f8a00e42 100644 --- a/src/services/deepLinkUrl.test.ts +++ b/src/services/deepLinkUrl.test.ts @@ -1,5 +1,5 @@ import { test, expect } from "vitest"; -import { intentKey, isConfirmIntent, isNavigateIntent, isSilentIntent, parseDeepLink, buildDeepLink } from "./deepLinkUrl"; +import { intentKey, isConfirmIntent, isNavigateIntent, isSilentIntent, parseDeepLink, buildDeepLink, DEFAULT_PLUGIN_SOURCE_ID } from "./deepLinkUrl"; const SESSION = "3f2504e0-4f89-11d3-9a0c-0305e82c3301"; const TOKEN = "deadbeefdeadbeefdeadbeefdeadbeef"; @@ -211,3 +211,62 @@ 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(); +}); + +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(); +}); + +test("a plugin-install link round-trips through both forms", () => { + const intent = { route: "plugin-install" as const, pluginId: "docker", sourceId: "voltius" }; + expect(parseDeepLink(buildDeepLink(intent, "scheme"))).toEqual(intent); + expect(parseDeepLink(buildDeepLink(intent, "https"))).toEqual(intent); +}); + +test("a plugin-install link with no source falls back to the first-party one", () => { + expect(parseDeepLink("voltius://plugin-install?id=docker")).toEqual({ + route: "plugin-install", + pluginId: "docker", + sourceId: DEFAULT_PLUGIN_SOURCE_ID, + }); +}); + +test("a plugin-install link whose id could escape the plugins directory is rejected", () => { + expect(parseDeepLink("voltius://plugin-install?id=../evil")).toBeNull(); + expect(parseDeepLink("voltius://plugin-install?id=__meta__")).toBeNull(); + expect(parseDeepLink("voltius://plugin-install?id=Docker")).toBeNull(); + expect(parseDeepLink("voltius://plugin-install?id=")).toBeNull(); + expect(parseDeepLink("voltius://plugin-install")).toBeNull(); +}); + +test("a plugin-install link naming an over-long source id is rejected", () => { + expect(parseDeepLink("voltius://plugin-install?id=docker&src=" + "a".repeat(101))).toBeNull(); +}); diff --git a/src/services/deepLinkUrl.ts b/src/services/deepLinkUrl.ts index fa352005b..f5f2c31a3 100644 --- a/src/services/deepLinkUrl.ts +++ b/src/services/deepLinkUrl.ts @@ -1,17 +1,24 @@ import { isSessionId } from "@/services/sessionId"; import { isSettingsSection, type SettingsSection } from "@/stores/uiStore"; +import { isValidPluginId } from "@/plugins/pluginId"; 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 SnippetInstallIntent = { route: "snippet-install"; entryId: string }; +export type PluginInstallIntent = { route: "plugin-install"; pluginId: string; sourceId: string }; export type DeepLinkIntent = | JoinIntent + | InviteIntent | VerifiedIntent | NotificationIntent | SettingsIntent - | BillingIntent; + | BillingIntent + | SnippetInstallIntent + | PluginInstallIntent; type TrustClass = "confirm" | "silent" | "navigate"; type Route = DeepLinkIntent["route"]; @@ -32,10 +39,20 @@ 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", billing: "navigate", + // Writes snippets — shell commands the user will later run — into a vault, so + // nothing lands until the user accepts. + "snippet-install": "confirm", + // Executes third-party code on this machine. The strongest confirm on the list: + // the sheet names the plugin, its catalogue and its permissions before the + // accept button does anything. + "plugin-install": "confirm", } as const satisfies Record; type RouteOfClass = { @@ -60,6 +77,30 @@ 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; + +/** + * The catalogue a `plugin-install` link means when it names none. Kept as a + * literal rather than importing `FIRST_PARTY_SOURCE`, so the parser stays free of + * the marketplace store (and of `@tauri-apps/api`, which every parser test would + * then have to stub). A test pins the two together. + */ +export const DEFAULT_PLUGIN_SOURCE_ID = "voltius"; + +/** A source id is a catalogue key, not a URL; this only stops an absurd one. */ +const MAX_SOURCE_ID = 100; + +/** + * 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 +111,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") ?? ""; @@ -101,6 +152,33 @@ 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 }), + }, + "plugin-install": { + parse: (params) => { + const pluginId = params.get("id") ?? ""; + // Validated here rather than at install time: the id becomes a directory + // name under the plugins folder, and `assertValidPluginId` throws far too + // late to render a sheet from. + if (!isValidPluginId(pluginId)) return null; + // A source *id* already configured on this device, never a URL. A link able + // to name a new source is a link able to introduce a new code source; the + // sheet resolves this against the user's own enabled sources and fails when + // it matches none. + const sourceId = params.get("src") || DEFAULT_PLUGIN_SOURCE_ID; + if (sourceId.length > MAX_SOURCE_ID) return null; + return { route: "plugin-install", pluginId, sourceId }; + }, + params: ({ pluginId, sourceId }) => ({ id: pluginId, src: sourceId }), + }, }; diff --git a/src/services/import-export/storeAccess.ts b/src/services/import-export/storeAccess.ts new file mode 100644 index 000000000..d517eb092 --- /dev/null +++ b/src/services/import-export/storeAccess.ts @@ -0,0 +1,72 @@ +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 { Vault } from "@/stores/vaultStore"; +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), + ); +} + +/** 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 }; +} 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); + } +} diff --git a/src/stores/marketplaceStore.test.ts b/src/stores/marketplaceStore.test.ts index f1582143c..459ecdcbb 100644 --- a/src/stores/marketplaceStore.test.ts +++ b/src/stores/marketplaceStore.test.ts @@ -21,8 +21,9 @@ vi.mock("@/stores/pluginRegistryStore", () => ({ usePluginRegistryStore: { getState: () => ({ isEnabled: () => true }) }, })); -import { useMarketplaceStore, restoreMissingPlugins, type MarketplacePlugin } from "./marketplaceStore"; +import { useMarketplaceStore, restoreMissingPlugins, FIRST_PARTY_SOURCE, type MarketplacePlugin } from "./marketplaceStore"; import { PluginHashMismatchError } from "@/plugins/integrity"; +import { DEFAULT_PLUGIN_SOURCE_ID } from "@/services/deepLinkUrl"; const JS_TEXT = "export default () => {}"; const JS_HASH = "324c9070eb5daa71308b5ca39ce5c17b5274acc6f053df1ca19111d834b79f56"; @@ -372,3 +373,11 @@ test("a manifest id that differs from the catalogue id aborts before anything is expect(h.loadPlugin).not.toHaveBeenCalled(); expect(useMarketplaceStore.getState().installedMeta).toEqual([]); }); + +// A plugin-install deep link with no explicit source falls back to this id +// (deepLinkUrl.ts, kept as a literal there to keep the parser free of this +// store and of @tauri-apps/api). If the two ever drift, a link with no `src` +// would resolve to a source id nothing on the device recognises. +test("the deep-link default plugin source id is the first-party source's own id", () => { + expect(DEFAULT_PLUGIN_SOURCE_ID).toBe(FIRST_PARTY_SOURCE.id); +});