diff --git a/docs/deep-links.md b/docs/deep-links.md index a235a83aaf..4507716f1c 100644 --- a/docs/deep-links.md +++ b/docs/deep-links.md @@ -18,6 +18,7 @@ its `#buzz=` address form. The OS ingress accepts the Buzz link forms and nothing else: - `buzz://message?channel=&id=[&thread=]` +- `buzz://plugin-install?repository=[&ref=]` - `buzz://channel/` - `buzz://channel//` (the original desktop message alias) - `buzz://repo?owner=<64-hex key>&d=[&tab=
]` @@ -27,6 +28,20 @@ The OS ingress accepts the Buzz link forms and nothing else: - `buzz://connect?relay=` - `buzz://add-community?relay=[&name=]` +Plugin install links open **Settings > Plugins > Manage plugins** and fetch a +preview from the Git repository and optional branch or tag. The repository accepts +HTTPS, `ssh://`, `git@host:path`, `github.com/owner/repo`, or `owner/repo`. +Percent-encode each query value. Review the plugin and computer access, then select +**Install plugin**. Opening the link does not install, enable, or grant access. +You can close a linked preview while Git loads; its late result is discarded. +An omitted `ref` uses the repository default. Duplicate known parameters and +repository credentials are rejected. + +The Settings target has no community scope. A new installation completes identity +setup before the app can show it. Repositories hosted by a Buzz community require +that community to be selected and connected for authentication. See +[Git imports and SSH agents](plugin-architecture.md#open-a-git-import-from-a-link). + Both query-based invite and message links also accept a single `/` before `?`, as supplied by Windows URL activation (for example, `buzz://join/?relay=…&code=…`). Non-root paths remain unsupported for these forms; channel paths are unchanged. diff --git a/docs/plugin-architecture.md b/docs/plugin-architecture.md index e61c2c72d1..f2ee769c81 100644 --- a/docs/plugin-architecture.md +++ b/docs/plugin-architecture.md @@ -100,6 +100,32 @@ create a second connection, cache or outbox inside a page. The conversation prev source imports are not a versioned external SDK. See [conversation component ownership](channels.md#reusing-conversation-ui). +## Open a Git import from a link + +Open `buzz://plugin-install?repository=[&ref=]` +to open a preview in **Settings > Plugins > Manage plugins > Add**. The repository +accepts HTTPS, `ssh://`, `git@host:path`, `github.com/owner/repo`, or `owner/repo`. +Omit `ref` to use the repository default. For example, on macOS: + +```sh +open 'buzz://plugin-install?repository=https%3A%2F%2Fgithub.com%2FOWNER%2FPLUGIN.git&ref=main' +open 'buzz://plugin-install?repository=git%40github.com%3AOWNER%2FPLUGIN.git&ref=main' +``` + +The link fetches a preview through the existing importer. Review the plugin and +computer access, then select **Install plugin**. Opening the link does not install, +enable, or grant access. You can close the dialog while the preview loads. A late +result is discarded. Confirmed installation remains protected from dismissal. + +SSH uses the desktop process's inherited `SSH_AUTH_SOCK` and known hosts. +The importer disables SSH config and interactive prompts. If terminal Git uses +an `IdentityAgent`, launch Buzz with that socket as `SSH_AUTH_SOCK`. + +A new installation completes identity setup before the app can show the linked +preview. For a repository hosted by a Buzz community, select that community and +wait for its relay connection before opening the link. Other repositories do not +require a selected community. See [OS deep links](deep-links.md). + ## The `buzzodz` CLI Run `pnpm buzzodz --help` from this repository, or install the standalone executable: diff --git a/src/app/App.tsx b/src/app/App.tsx index 008c16734c..964a665a25 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -575,6 +575,9 @@ function ConnectedApp({ services }: { services: AppServices }) { ); }} onSection={openSettingsSection} + onPluginImportClosed={() => + route.dismissPluginImport(route.request) + } onProvider={route.setSettingsProvider} onUnsavedChange={setSettingsUnsaved} /> diff --git a/src/app/PluginImport.test.tsx b/src/app/PluginImport.test.tsx index 8871d8ed46..31eb8af13b 100644 --- a/src/app/PluginImport.test.tsx +++ b/src/app/PluginImport.test.tsx @@ -1873,7 +1873,7 @@ it.each(["acquisition", "authorization"])( fireEvent.click( screen.getByRole("button", { name: "Browse plugin folder" }), ); - await screen.findByRole("dialog", { name: "Review plugin access" }); + await screen.findByRole("dialog", { name: /^Review .+ access$/ }); await act(async () => { release(); await gate; @@ -1953,3 +1953,380 @@ it.each(["folder", "git"])( if (source === "git") expect(authorizeGit).toHaveBeenCalledTimes(3); }, ); + +it("previews a linked Git source once and waits for install confirmation", async () => { + const source = { + repository: "https://github.com/example/plugin", + reference: "release/stable", + }; + const preview: ImportPreview = { + token: "linked-preview", + source: source.repository, + commit: "a".repeat(40), + warnings: [], + candidates: [ + { + path: ".", + manifest: { + id: "example.plugin", + name: "Example", + apiVersion: 1, + host: { files: true }, + }, + revision: "one", + }, + ], + }; + const git = vi.fn(async () => preview); + const installImport = vi.fn(async () => true); + const manager = { + imports: { git, discard: vi.fn(async () => {}) }, + installImport, + change: vi.fn(async () => {}), + } as unknown as PluginManager; + const view = render( + + + , + ); + const dialog = screen.getByRole("dialog", { name: "Add a plugin" }); + expect(within(dialog).getByLabelText("Git or GitHub repository")).toHaveValue( + source.repository, + ); + expect(within(dialog).getByLabelText("Branch or tag (optional)")).toHaveValue( + source.reference, + ); + expect(installImport).not.toHaveBeenCalled(); + const install = await screen.findByRole("button", { name: "Install plugin" }); + expect(git).toHaveBeenCalledExactlyOnceWith( + source.repository, + source.reference, + ); + expect(screen.getByRole("region", { name: "Computer access" })).toBeVisible(); + expect(installImport).not.toHaveBeenCalled(); + await userEvent.click(install); + await waitFor(() => + expect(installImport).toHaveBeenCalledExactlyOnceWith( + "linked-preview", + ".", + ), + ); + view.unmount(); +}); + +it.each([true, false])( + "keeps the confirmed enable choice after a new link replaces the dialog (%s)", + async (enabled) => { + let finish!: (success: boolean) => void; + const installed = new Promise((resolve) => { + finish = resolve; + }); + const change = vi.fn(async () => {}); + const onClosed = vi.fn(); + const tokens = new Set(); + const git = vi.fn(async (repository: string) => { + tokens.add(repository); + return { + ...rememberedPreview, + token: repository, + candidates: rememberedPreview.candidates.slice(0, 1), + }; + }); + const discard = vi.fn(async (token: string) => { + tokens.delete(token); + }); + const manager = { + imports: { git, discard }, + installImport: vi.fn(async (token: string) => { + await installed; + return tokens.has(token); + }), + change, + } as unknown as PluginManager; + const props = { + plugins: manager, + catalog: { profile: "test", location: "test", plugins: [] }, + busy: false, + }; + const view = render( + , + ); + const install = await screen.findByRole("button", { + name: "Install plugin", + }); + if (!enabled) + await userEvent.click( + screen.getByRole("switch", { name: "Enable plugin" }), + ); + await userEvent.click(install); + expect(manager.installImport).toHaveBeenCalledTimes(1); + view.rerender( + , + ); + await act(async () => {}); + expect(discard).not.toHaveBeenCalled(); + expect(git).toHaveBeenCalledTimes(1); + await act(async () => finish(true)); + if (enabled) + expect(change).toHaveBeenCalledExactlyOnceWith( + "enable", + rememberedPreview.candidates[0]?.manifest.id, + ); + else expect(change).not.toHaveBeenCalled(); + expect(onClosed).toHaveBeenCalledTimes(1); + }, +); + +it("keeps a late close bound to the replaced import", async () => { + let finish!: () => void; + const discarded = new Promise((resolve) => { + finish = resolve; + }); + const firstClosed = vi.fn(); + const secondClosed = vi.fn(); + const manager = { + imports: { + git: vi.fn(async () => ({ + ...rememberedPreview, + candidates: [rememberedPreview.candidates[0]], + })), + discard: vi.fn(() => discarded), + }, + } as unknown as PluginManager; + const props = { + plugins: manager, + catalog: { profile: "test", location: "test", plugins: [] }, + busy: false, + }; + const view = render( + , + ); + const review = await screen.findByRole("dialog", { + name: /^Review .+ access$/, + }); + fireEvent.keyDown(review, { key: "Escape" }); + expect(manager.imports?.discard).toHaveBeenCalledTimes(1); + view.rerender( + , + ); + await screen.findByRole("dialog", { name: /^Review .+ access$/ }); + await act(async () => finish()); + expect(firstClosed).toHaveBeenCalledTimes(1); + expect(secondClosed).not.toHaveBeenCalled(); + expect( + screen.getByRole("dialog", { name: /^Review .+ access$/ }), + ).toBeVisible(); +}); + +it("waits for an occupied native acquisition slot and previews the latest link", async () => { + let finish!: (preview: ImportPreview) => void; + const first = new Promise((resolve) => { + finish = resolve; + }); + let acquiring = false; + const git = vi.fn(async (repository: string) => { + if (acquiring) throw new Error("Another import is in progress"); + acquiring = true; + try { + return repository.endsWith("first.git") + ? await first + : { + ...rememberedPreview, + token: "latest", + candidates: rememberedPreview.candidates.slice(0, 1), + }; + } finally { + acquiring = false; + } + }); + const discard = vi.fn(async () => {}); + const manager = { imports: { git, discard } } as unknown as PluginManager; + const props = { + plugins: manager, + catalog: { profile: "test", location: "test", plugins: [] }, + busy: false, + }; + const source = (name: string) => ({ + repository: `https://example.com/${name}.git`, + reference: "", + }); + const view = render( + , + ); + await waitFor(() => expect(git).toHaveBeenCalledTimes(1)); + view.rerender( + , + ); + await screen.findByRole("status"); + view.rerender( + , + ); + await act(async () => + finish({ + ...rememberedPreview, + candidates: rememberedPreview.candidates.slice(0, 1), + }), + ); + await screen.findByRole("dialog", { name: /^Review .+ access$/ }); + expect(git.mock.calls.map(([repository]) => repository)).toEqual([ + "https://example.com/first.git", + "https://example.com/latest.git", + ]); + expect(discard).toHaveBeenCalledWith("remembered"); + expect(screen.queryByRole("alert")).toBeNull(); +}); + +it.each(["Escape", "Close"])( + "abandons a linked preview with %s and discards its late result", + async (action) => { + let finish!: (preview: ImportPreview) => void; + const gate = new Promise((resolve) => { + finish = resolve; + }); + const git = vi + .fn() + .mockReturnValueOnce(gate) + .mockResolvedValue({ + ...rememberedPreview, + candidates: rememberedPreview.candidates.slice(0, 1), + }); + const discard = vi.fn(async () => {}); + const installImport = vi.fn(); + const onClosed = vi.fn(); + const manager = { + imports: { git, discard }, + installImport, + } as unknown as PluginManager; + const props = { + plugins: manager, + catalog: { profile: "test", location: "test", plugins: [] }, + busy: false, + }; + const view = render( + , + ); + try { + await waitFor(() => expect(git).toHaveBeenCalledTimes(1)); + const dialog = screen.getByRole("dialog", { name: "Add a plugin" }); + expect( + within(dialog).getByRole("button", { name: "Close" }), + ).toBeEnabled(); + if (action === "Escape") fireEvent.keyDown(dialog, { key: "Escape" }); + else + await userEvent.click( + within(dialog).getByRole("button", { name: "Close" }), + ); + await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); + expect(onClosed).toHaveBeenCalledTimes(1); + finish({ + ...rememberedPreview, + candidates: rememberedPreview.candidates.slice(0, 1), + }); + await waitFor(() => expect(discard).toHaveBeenCalledWith("remembered")); + expect(screen.queryByRole("dialog")).toBeNull(); + expect(installImport).not.toHaveBeenCalled(); + view.rerender( + , + ); + await screen.findByRole("dialog", { name: /^Review .+ access$/ }); + expect(git).toHaveBeenCalledTimes(2); + } finally { + finish({ ...rememberedPreview, candidates: [] }); + } + }, +); + +it("keeps a manual acquisition protected after returning from a linked preview", async () => { + let finish!: (preview: ImportPreview) => void; + const gate = new Promise((resolve) => { + finish = resolve; + }); + const preview = { + ...rememberedPreview, + candidates: rememberedPreview.candidates.slice(0, 1), + }; + const git = vi.fn().mockResolvedValueOnce(preview).mockReturnValueOnce(gate); + const manager = { + imports: { git, discard: vi.fn(async () => {}) }, + } as unknown as PluginManager; + render( + , + ); + try { + const review = await screen.findByRole("dialog", { + name: /^Review .+ access$/, + }); + await userEvent.click(within(review).getByRole("button", { name: "Back" })); + const dialog = screen.getByRole("dialog", { name: "Add a plugin" }); + await userEvent.click( + within(dialog).getByRole("button", { name: "Find plugins" }), + ); + await waitFor(() => expect(git).toHaveBeenCalledTimes(2)); + expect( + within(dialog).getByRole("button", { name: "Close" }), + ).toBeDisabled(); + fireEvent.keyDown(dialog, { key: "Escape" }); + expect(dialog).toBeVisible(); + } finally { + await act(async () => finish(preview)); + } +}); diff --git a/src/app/PluginImport.tsx b/src/app/PluginImport.tsx index 50f0b3f710..8f89e3ebb3 100644 --- a/src/app/PluginImport.tsx +++ b/src/app/PluginImport.tsx @@ -20,6 +20,10 @@ import { FolderOpenIcon } from "../shared/design-system/icons/index"; import { PluginAccessReview } from "./PluginAccessReview"; import type { PluginManager } from "../plugins/manager"; import type { Catalog, ImportPreview, PluginInfo } from "../plugins/types"; +import type { GitPluginSource } from "../features/navigation/targets"; + +// The native importer has one slot, shared by successive dialogs. +const importOperations = new WeakMap>(); function importLocation( preview: Pick, @@ -34,6 +38,8 @@ function importLocation( } export function PluginImport({ + initialGit, + requestSignal, initialUpdateAvailable = false, plugins, catalog, @@ -49,6 +55,8 @@ export function PluginImport({ onReviewHandled, returnFocus, }: { + initialGit?: GitPluginSource | undefined; + requestSignal?: AbortSignal | undefined; initialUpdateAvailable?: boolean; plugins: PluginManager; catalog: Catalog; @@ -68,7 +76,7 @@ export function PluginImport({ ) => Promise<{ repository: string; token: string } | null>; }) { const imports = plugins.imports; - const [open, setOpen] = useState(false); + const [open, setOpen] = useState(!!initialGit); const [dragging, setDragging] = useState(false); const trigger = useRef(null); const checkAgain = useRef(null); @@ -77,15 +85,26 @@ export function PluginImport({ ); const savedSource = currentPlugin?.updateSource; const [repository, setRepository] = useState( - savedSource && "repository" in savedSource ? savedSource.repository : "", + initialGit?.repository ?? + (savedSource && "repository" in savedSource + ? savedSource.repository + : ""), ); const [reference, setReference] = useState( - savedSource && "reference" in savedSource ? savedSource.reference : "", + initialGit?.reference ?? + (savedSource && "reference" in savedSource ? savedSource.reference : ""), ); + useEffect(() => { + if (!initialGit || requestSignal?.aborted) return; + setRepository(initialGit.repository); + setReference(initialGit.reference); + setOpen(true); + }, [initialGit, requestSignal]); // Snapshot at review entry: our own install catalog refresh must not turn a // new install into an update while its enable operation is still pending. const [existing, setExisting] = useState(); const [loading, setLoading] = useState(false); + const [abandonable, setAbandonable] = useState(false); const [preview, setPreview] = useState(null); const [checkedRevision, setCheckedRevision] = useState(); const [checking, setChecking] = useState(false); @@ -151,7 +170,7 @@ export function PluginImport({ setError(String(reason)); } const [enableOnInstall, setEnableOnInstall] = useState(true); - const lifetime = useRef({ active: true, token: "" }); + const lifetime = useRef({ active: true, token: "", installing: false }); const pending = useRef(false); async function checkSource(operation: () => Promise) { if (checkPending.current) return; @@ -184,7 +203,7 @@ export function PluginImport({ void checkSource(() => imports.checkFolder(updateId)); }); useEffect(() => { - const current = { active: true, token: "" }; + const current = { active: true, token: "", installing: false }; lifetime.current = current; // Defer until effects settle so Strict Mode's retired mount never acquires. queueMicrotask(() => { @@ -192,7 +211,8 @@ export function PluginImport({ }); return () => { current.active = false; - if (current.token) void imports?.discard(current.token).catch(() => {}); + if (current.token && !current.installing) + void imports?.discard(current.token).catch(() => {}); }; }, [imports]); @@ -264,22 +284,66 @@ export function PluginImport({ }); }, [reviewRequested]); + const linkedRequest = useRef(undefined); + const startLinkedGit = useEffectEvent(() => { + if (!initialGit || requestSignal?.aborted || !imports || busy) return; + const request = requestSignal ?? initialGit; + if (linkedRequest.current === request) return; + linkedRequest.current = request; + void loadGit(initialGit.repository, initialGit.reference, true); + }); + useEffect(() => { + if (!initialGit || requestSignal?.aborted || !imports || busy) return; + const current = lifetime.current; + queueMicrotask(() => { + if (current.active) startLinkedGit(); + }); + }, [initialGit, requestSignal, imports, busy]); + const blocked = busy || loading || discarding; + const canAbandonPreview = + abandonable && + loading && + !busy && + !discarding && + !lifetime.current.installing; if (!imports) return null; - async function load(operation: () => Promise) { - if (pending.current || blocked) return; + async function load( + operation: () => Promise, + linked = false, + ) { + if (!imports || pending.current || blocked) return; const returnTo = document.activeElement as HTMLElement | null; sourceTrigger.current = returnTo; pending.current = true; const current = lifetime.current; setLoading(true); + setAbandonable(linked); if (!savedSource) setError(null); setPreview(null); setShowUnchanged(false); setEnableOnInstall(true); - try { + const previous = importOperations.get(imports); + const acquisition = (async () => { + if (previous) await previous; + if (!current.active) return null; const result = await operation(); + if (!current.active && result) { + await imports?.discard(result.token); + return null; + } + return result; + })(); + importOperations.set( + imports, + acquisition.then( + () => {}, + () => {}, + ), + ); + try { + const result = await acquisition; if (!result && updateId && savedSource) throw new Error("No update source found. Try again."); const next = @@ -341,6 +405,19 @@ export function PluginImport({ } } } + async function loadGit( + repository: string, + reference: string, + linked = false, + ) { + if (!imports) return; + await load(async () => { + const signed = await authorizeGit?.(repository); + return signed + ? imports.git(signed.repository, reference, signed.token) + : imports.git(repository, reference); + }, linked); + } async function dismiss() { if (blocked || pending.current || !preview) return; pending.current = true; @@ -358,14 +435,25 @@ export function PluginImport({ } } async function close() { + if (canAbandonPreview) { + lifetime.current.active = false; + setOpen(false); + onClosed?.(); + return true; + } if (blocked || pending.current) return false; + const current = lifetime.current; const returnTo = document.activeElement; pending.current = true; setDiscarding(true); try { if (lifetime.current.token) await imports?.discard(lifetime.current.token); - lifetime.current.token = ""; + current.token = ""; + if (!current.active) { + onClosed?.(); + return false; + } setPreview(null); errorTrigger.current = null; setError(null); @@ -375,11 +463,11 @@ export function PluginImport({ onClosed?.(); return true; } catch (reason) { - showError(reason, returnTo); + if (current.active) showError(reason, returnTo); return false; } finally { pending.current = false; - setDiscarding(false); + if (current.active) setDiscarding(false); } } const rollbackWarning = @@ -557,21 +645,31 @@ export function PluginImport({ if (!next) void close(); }} title={ - showUnchanged - ? "Unchanged declarations" - : error || !preview - ? updateId - ? "Update plugin" - : "Add a plugin" - : reviewing - ? duplicate - ? "Already installed" - : existing - ? `Update ${existing.manifest.name}` - : "Review plugin access" - : preview.candidates.length - ? "Choose a plugin" - : "No plugins found" + showUnchanged ? ( + "Unchanged declarations" + ) : error || !preview ? ( + updateId ? ( + "Update plugin" + ) : ( + "Add a plugin" + ) + ) : reviewing ? ( + duplicate ? ( + "Already installed" + ) : existing ? ( + `Update ${existing.manifest.name}` + ) : ( + <> + Review{" "} + {candidate?.manifest.name ?? "Unknown"} Plugin{" "} + access + + ) + ) : preview.candidates.length ? ( + "Choose a plugin" + ) : ( + "No plugins found" + ) } description={ error || showUnchanged @@ -695,6 +793,12 @@ export function PluginImport({ onClick={async () => { if (blocked || pending.current) return; const current = lifetime.current; + current.installing = true; + let release!: () => void; + const settled = new Promise((resolve) => { + release = resolve; + }); + importOperations.set(imports, settled); pending.current = true; setLoading(true); setError(null); @@ -703,8 +807,8 @@ export function PluginImport({ preview.token, candidate.path, ); - if (!current.active) return; if (!success) { + if (!current.active) return; showError( plugins.snapshot().error ?? "Plugin installation wasn’t confirmed. Check the preview and try again.", @@ -717,7 +821,10 @@ export function PluginImport({ await imports.discard(preview.token).catch(() => {}); if (!existing && enableOnInstall) await plugins.change("enable", candidate.manifest.id); - if (!current.active) return; + if (!current.active) { + onClosed?.(); + return; + } if (!updateId) setPreview(null); else if (existing) setExisting({ @@ -727,6 +834,12 @@ export function PluginImport({ setOpen(false); onClosed?.(); } finally { + current.installing = false; + if (!current.active && current.token) { + await imports.discard(current.token).catch(() => {}); + current.token = ""; + } + release(); pending.current = false; if (current.active) setLoading(false); } @@ -744,7 +857,7 @@ export function PluginImport({ return true; }} dismissOnOutsideClick - preventClose={blocked} + preventClose={blocked && !canAbandonPreview} finalFocus={() => returnFocus?.() ?? trigger.current ?? checkAgain.current } @@ -822,17 +935,7 @@ export function PluginImport({ className={styles.git} onSubmit={(event) => { event.preventDefault(); - if (repository.trim()) - void load(async () => { - const signed = await authorizeGit?.(repository); - return signed - ? imports.git( - signed.repository, - reference, - signed.token, - ) - : imports.git(repository, reference); - }); + if (repository.trim()) void loadGit(repository, reference); }} > diff --git a/src/app/Settings.tsx b/src/app/Settings.tsx index 4736c6d4ae..b0f30e6066 100644 --- a/src/app/Settings.tsx +++ b/src/app/Settings.tsx @@ -592,6 +592,7 @@ export function Settings({ navigation, onSection, onProvider, + onPluginImportClosed, navigationPane = false, onDeparturePending, onUnsavedChange, @@ -613,6 +614,7 @@ export function Settings({ | undefined; onSection?: (section: string) => void; onProvider?: ((provider?: string) => void) | undefined; + onPluginImportClosed?: (() => void) | undefined; navigationPane?: boolean; onDeparturePending?: (pending: boolean) => void; onUnsavedChange?: (unsaved: boolean) => void; @@ -1315,6 +1317,19 @@ export function Settings({ )} {catalog ? ( onPluginImportClosed?.()} actionTarget={pluginImportActions} plugins={plugins} catalog={catalog} diff --git a/src/app/navigation.ts b/src/app/navigation.ts index f587fc8d63..72f26c8bae 100644 --- a/src/app/navigation.ts +++ b/src/app/navigation.ts @@ -412,6 +412,13 @@ export function useAppNavigation( }, settings); } }, + dismissPluginImport(request: PageNavigation | undefined) { + const current = request?.target; + if (!request || current?.kind !== "settings" || !current.pluginImport) + return; + const { pluginImport: _pluginImport, ...settings } = current; + navigation.dismiss([request.entryId], settings); + }, leaveSettings() { const current = navigation.snapshot().entry.target; if (current.kind !== "settings") return; diff --git a/src/features/navigation/buzz-links.test.ts b/src/features/navigation/buzz-links.test.ts index 3e8f7b0730..5c63539d50 100644 --- a/src/features/navigation/buzz-links.test.ts +++ b/src/features/navigation/buzz-links.test.ts @@ -185,3 +185,73 @@ it.each(["//", "/extra", "/%2F", "/./", "/extra/..", "/%2e/"])( ).toBeNull(); }, ); + +const pluginRepository = "ssh://git@github.com/example/plugin.git"; +const pluginLink = `buzz://plugin-install?repository=${encodeURIComponent(pluginRepository)}&ref=release%2Fstable`; +it("opens Git plugin links in Settings without a community or account", () => { + const target = { + version: 1, + kind: "settings", + section: "plugins", + pluginImport: { repository: pluginRepository, reference: "release/stable" }, + }; + expect(buzzLinkKind(pluginLink)).toBe("buzz"); + expect(buzzLinkTarget(pluginLink, scope)).toEqual(target); + expect(deepLinkStep(pluginLink, { selected: null })).toEqual({ + open: target, + }); + expect( + buzzLinkTarget( + pluginLink.replace("plugin-install?", "plugin-install/?"), + scope, + ), + ).toEqual(target); + expect( + buzzLinkTarget(`${pluginLink}&enable=true&enable=false`, scope), + ).toEqual(target); +}); +it.each([ + "buzz://plugin-install", + `${pluginLink}&ref=other`, + `${pluginLink}&repository=https://example.com/plugin`, + pluginLink.replace("plugin-install?", "plugin-install/other?"), + pluginLink.replace("plugin-install?", "plugin-install/other/../?"), + ...[ + "file:///tmp/plugin", + "http://example.com/plugin", + "https://user:secret@example.com/plugin", + "https://example.com/plugin?token=secret", + "https://example.com/plugin#fragment", + ].map( + (repository) => + `buzz://plugin-install?repository=${encodeURIComponent(repository)}`, + ), +])("rejects invalid or ambiguous plugin import links: %s", (href) => { + expect(parseBuzzLink(href)).toBeNull(); + expect(deepLinkStep(href, clientForPluginLink)).toEqual({ + fail: "invalid-target", + }); +}); +const clientForPluginLink = { + selected: scope.communityOrigin, + viewer: scope.viewer, +}; + +it.each([ + ["git@github.com:org/repo.git", "ssh://git@github.com/org/repo.git"], + ["github.com/org/repo", "https://github.com/org/repo"], + ["org/repo", "https://github.com/org/repo"], +])( + "normalizes a Settings repository address in a plugin link: %s", + (input, repository) => { + const href = `buzz://plugin-install?repository=${encodeURIComponent(input)}`; + const target = { + version: 1, + kind: "settings", + section: "plugins", + pluginImport: { repository, reference: "" }, + }; + expect(buzzLinkTarget(href, scope)).toEqual(target); + expect(deepLinkStep(href, { selected: null })).toEqual({ open: target }); + }, +); diff --git a/src/features/navigation/buzz-links.ts b/src/features/navigation/buzz-links.ts index edbaec9147..43547e5a2c 100644 --- a/src/features/navigation/buzz-links.ts +++ b/src/features/navigation/buzz-links.ts @@ -3,9 +3,15 @@ import { parseEntityLink, type EntityRoute, } from "../repos/routes"; -import { parseOpenTarget, type NavigationScope } from "./targets"; +import { + parseGitPluginSource, + parseOpenTarget, + type GitPluginSource, + type NavigationScope, +} from "./targets"; type BuzzLink = + | { format: "plugin"; source: GitPluginSource } | { format: "removed" } | { format: "entity"; route: EntityRoute } | { @@ -39,6 +45,24 @@ export function parseBuzzLink(href: string): BuzzLink | null { url.hash ) return null; + if (url.hostname === "plugin-install") { + if ( + (url.pathname !== "" && + (url.pathname !== "/" || + !/^buzz:\/\/plugin-install\/\?/i.test(href))) || + ["repository", "ref"].some( + (key) => url.searchParams.getAll(key).length > 1, + ) + ) + return null; + return { + format: "plugin", + source: parseGitPluginSource({ + repository: url.searchParams.get("repository") ?? "", + reference: url.searchParams.get("ref") ?? "", + }), + }; + } if ( url.hostname === "project" && (url.pathname === "" || url.pathname === "/") @@ -111,6 +135,13 @@ export function buzzLinkKind(href: string) { export function buzzLinkTarget(href: string, scope: NavigationScope) { const link = parseBuzzLink(href); if (!link) return null; + if (link.format === "plugin") + return parseOpenTarget({ + version: 1, + kind: "settings", + section: "plugins", + pluginImport: link.source, + }); if (link.format === "entity") return entityTarget(link.route, scope); if (link.format === "removed") return { diff --git a/src/features/navigation/deep-links.test.ts b/src/features/navigation/deep-links.test.ts index 3507896d9f..f18138b12a 100644 --- a/src/features/navigation/deep-links.test.ts +++ b/src/features/navigation/deep-links.test.ts @@ -743,3 +743,55 @@ it("does not revive a disposed bridge when native registration finishes", async await invoked.fn.mock.results[0]?.value; expect(listener).not.toHaveBeenCalled(); }); + +const localPluginLink = "buzz://plugin-install?repository=org%2Frepo"; +it.each(["viewer", "selected"] as const)( + "keeps a plugin link across a %s change while loading", + async (key) => { + const t = harness({ ...ready, status: "loading" }, [localPluginLink]); + try { + await vi.waitFor(() => expect(t.take).toHaveBeenCalledTimes(1)); + t.become({ + ...ready, + [key]: key === "viewer" ? "f".repeat(64) : elsewhere, + }); + await vi.waitFor(() => + expect(t.host.navigation.open).toHaveBeenCalledTimes(1), + ); + expect(t.host.fail).not.toHaveBeenCalled(); + expect(t.host.navigation.snapshot().entry.target).toMatchObject({ + kind: "settings", + section: "plugins", + }); + } finally { + t.stop(); + } + }, +); +it.each(["viewer", "selected"] as const)( + "keeps a pending plugin shell read across a %s change", + async (key) => { + const t = harness(ready); + let release!: (urls: string[]) => void; + try { + await vi.waitFor(() => expect(t.take).toHaveBeenCalledTimes(1)); + t.take.mockImplementationOnce( + () => + new Promise((resolve) => { + release = resolve; + }), + ); + t.arrive(localPluginLink); + await vi.waitFor(() => expect(release).toBeTypeOf("function")); + t.become({ [key]: key === "viewer" ? "f".repeat(64) : elsewhere }); + release([localPluginLink]); + await vi.waitFor(() => + expect(t.host.navigation.open).toHaveBeenCalledTimes(1), + ); + expect(t.host.fail).not.toHaveBeenCalled(); + } finally { + release?.([]); + t.stop(); + } + }, +); diff --git a/src/features/navigation/deep-links.ts b/src/features/navigation/deep-links.ts index 32242c2fff..c7fe842ba9 100644 --- a/src/features/navigation/deep-links.ts +++ b/src/features/navigation/deep-links.ts @@ -47,6 +47,15 @@ export function deepLinkStep( // of its own beyond the Buzz ones. if (!link) return { fail: "invalid-target" }; try { + if (link.format === "plugin") + return { + open: parseOpenTarget({ + version: 1, + kind: "settings", + section: "plugins", + pluginImport: link.source, + }), + }; if (!client.viewer || !client.selected) return { fail: "unavailable" }; if (link.format === "removed") return { @@ -101,6 +110,7 @@ export function bindDeepLinks( | { url: string; invite: InviteLink | null; + plugin: boolean; viewer: string | undefined; selected: string | null; reported: boolean; @@ -114,10 +124,11 @@ export function bindDeepLinks( const client = communities.snapshot(); const incoming = held; if ( - (incoming.viewer && incoming.viewer !== client.viewer) || - (!incoming.invite && - incoming.selected && - incoming.selected !== client.selected) + !incoming.plugin && + ((incoming.viewer && incoming.viewer !== client.viewer) || + (!incoming.invite && + incoming.selected && + incoming.selected !== client.selected)) ) { held = undefined; host.fail("denied"); @@ -152,8 +163,9 @@ export function bindDeepLinks( held.viewer ??= client.viewer; held.selected ??= client.selected; if ( - (held.viewer && held.viewer !== client.viewer) || - (!held.invite && held.selected && held.selected !== client.selected) + !held.plugin && + ((held.viewer && held.viewer !== client.viewer) || + (!held.invite && held.selected && held.selected !== client.selected)) ) { held = undefined; host.fail("denied"); @@ -181,11 +193,7 @@ export function bindDeepLinks( console.error("Could not read pending deep links", error); continue; } - if ( - closed || - epoch !== clientEpoch || - started.id !== host.navigation.snapshot().attempt.id - ) + if (closed || started.id !== host.navigation.snapshot().attempt.id) continue; let url: string | undefined; for (let index = urls.length - 1; index >= 0; index--) { @@ -196,10 +204,13 @@ export function bindDeepLinks( } } if (url === undefined) continue; + const plugin = parseBuzzLink(url)?.format === "plugin"; + if (epoch !== clientEpoch && !plugin) continue; const client = communities.snapshot(); held = { url, invite: parseInviteLink(url), + plugin, viewer: client.viewer, selected: client.selected, reported: false, diff --git a/src/features/navigation/targets.test.ts b/src/features/navigation/targets.test.ts index 6daacf2b6e..1959c55af0 100644 --- a/src/features/navigation/targets.test.ts +++ b/src/features/navigation/targets.test.ts @@ -258,3 +258,22 @@ it("keeps explicit Personal space distinct from an unspecified page scope", () = expect(targetKey(personal)).not.toBe(targetKey(page)); expect(() => parseOpenTarget({ ...conversation, scope: null })).toThrow(); }); + +it("retains copied Git source fields only on the Plugins settings destination", () => { + const source = { + repository: "https://github.com/example/plugin", + reference: "release", + }; + const target = parseOpenTarget({ + version: 1, + kind: "settings", + section: "plugins", + pluginImport: source, + }); + source.reference = "changed"; + expect(target).toMatchObject({ pluginImport: { reference: "release" } }); + expect(() => parseOpenTarget({ ...target, section: "profile" })).toThrow(); + expect(() => + parseOpenTarget({ ...target, pluginImport: { ...source, enable: true } }), + ).toThrow(); +}); diff --git a/src/features/navigation/targets.ts b/src/features/navigation/targets.ts index 9a16a70402..00f1af622c 100644 --- a/src/features/navigation/targets.ts +++ b/src/features/navigation/targets.ts @@ -11,6 +11,10 @@ export type NavigationScope = Readonly<{ viewer: string; communityOrigin: string; }>; +export type GitPluginSource = Readonly<{ + repository: string; + reference: string; +}>; export type OpenTarget = | Readonly<{ version: 1; kind: "home" }> | Readonly<{ @@ -20,6 +24,7 @@ export type OpenTarget = /** Nested provider controls within Agent setup; presentation only. */ provider?: string; scope?: NavigationScope | null; + pluginImport?: GitPluginSource; }> | Readonly<{ version: 1; @@ -74,6 +79,52 @@ function text(value: unknown, pattern = token): string { if (typeof value !== "string" || !pattern.test(value)) throw invalid(); return value; } +export function parseGitPluginSource(input: unknown): GitPluginSource { + const value = record(input); + fields(value, ["repository", "reference"]); + if ( + typeof value.repository !== "string" || + value.repository.length > 2048 || + typeof value.reference !== "string" || + value.reference.length > 256 + ) + throw invalid(); + if ( + /\s/.test(value.repository) || + [...(value.repository + value.reference)].some( + (char) => char.charCodeAt(0) < 32 || char.charCodeAt(0) === 127, + ) + ) + throw invalid(); + const repositoryText = value.repository; + const scp = /^([^/@:]+@[^/:]+):(.+)$/.exec(repositoryText); + const expanded = + !repositoryText.includes("://") && scp + ? `ssh://${scp[1]}/${scp[2]}` + : repositoryText.startsWith("github.com/") + ? `https://${repositoryText}` + : !repositoryText.includes(":") && + repositoryText.split("/").length === 2 + ? `https://github.com/${repositoryText}` + : repositoryText; + const repository = new URL(expanded); + if ( + !["https:", "ssh:"].includes(repository.protocol) || + !repository.hostname || + repository.hostname.startsWith("-") || + repository.username.startsWith("-") || + !repository.pathname.replaceAll("/", "") || + repository.password || + (repository.protocol === "https:" && repository.username) || + repository.search || + repository.hash + ) + throw invalid(); + return Object.freeze({ + repository: repository.href, + reference: value.reference, + }); +} function boundScope(input: unknown): NavigationScope { const value = record(input); fields(value, ["viewer", "communityOrigin"]); @@ -130,9 +181,18 @@ export function parseOpenTarget(input: unknown): OpenTarget { target = { version: 1, kind: "home" }; break; case "settings": - fields(value, ["version", "kind", "section", "provider", "scope"]); + fields(value, [ + "version", + "kind", + "section", + "provider", + "scope", + "pluginImport", + ]); if (value.provider !== undefined && value.section !== "agents") throw invalid(); + if (value.pluginImport !== undefined && value.section !== "plugins") + throw invalid(); target = { version: 1, kind: "settings", @@ -145,6 +205,9 @@ export function parseOpenTarget(input: unknown): OpenTarget { ...(value.scope !== undefined ? { scope: value.scope === null ? null : boundScope(value.scope) } : {}), + ...(value.pluginImport !== undefined + ? { pluginImport: parseGitPluginSource(value.pluginImport) } + : {}), }; break; case "page": { diff --git a/tests/browser/plugin-import.spec.mjs b/tests/browser/plugin-import.spec.mjs index 46aa057478..05f4d6e05b 100644 --- a/tests/browser/plugin-import.spec.mjs +++ b/tests/browser/plugin-import.spec.mjs @@ -27,12 +27,18 @@ async function openPlugins(page, origin) { await selectSettingsSection(page, "Manage plugins"); } // Native IPC is the boundary fixture; Settings -> manager -> platform adapter are production. -async function nativeImports(page, samples = [], previewOptions = {}) { - const input = { samples, previewOptions }; - await page.addInitScript(({ samples, previewOptions }) => { +async function nativeImports( + page, + samples = [], + previewOptions = {}, + links = [], + hold = false, +) { + const input = { samples, previewOptions, links, hold }; + await page.addInitScript(({ samples, previewOptions, links, hold }) => { window.isTauri = true; window.importCalls = []; - window.importDelay = false; + window.importDelay = hold; const plugins = ["channels", "github", "bestie", "repos", "workflows"].map( (name) => ({ manifest: { id: `buzz.${name}`, name, apiVersion: 1 }, @@ -94,7 +100,7 @@ async function nativeImports(page, samples = [], previewOptions = {}) { }, invoke: async (command, args) => { window.importCalls.push({ command, args }); - if (command === "deep_link_take") return []; + if (command === "deep_link_take") return links.splice(0); if (command === "deep_link_watch") { const wireChannel = args.onEvent.toJSON(); if ( @@ -102,6 +108,14 @@ async function nativeImports(page, samples = [], previewOptions = {}) { !callbacks.has(args.onEvent.id) ) throw new Error("Invalid deep-link channel"); + let index = 0; + window.deliverPluginLink = (url) => { + links.push(url); + callbacks.get(args.onEvent.id)({ + index: index++, + message: { pending: links.length }, + }); + }; return; } if (command === "plugin_catalog") return ready(); @@ -622,7 +636,7 @@ test("folder/Git preview selects the exact subfolder and preserves enabled state .screenshot({ path: info.outputPath("plugin-choose-light.png") }); await button(page, "Continue").click(); await expect(page.getByRole("dialog")).toHaveAccessibleName( - "Review plugin access", + /^Review .+ access$/, ); await expect(page.getByRole("dialog")).toHaveAccessibleDescription( "Only install code you trust.", @@ -1208,13 +1222,15 @@ test("Add keeps a rejected install reachable and retries the same preview", asyn const dialog = page .getByRole("dialog") .and(page.locator('[aria-modal="true"]')); - await expect(dialog).toHaveAccessibleName("Review plugin access"); + await expect(dialog).toHaveAccessibleName( + "Review Retry plugin Plugin access", + ); await expect(dialog.getByRole("radio")).toHaveCount(0); await expect( dialog.getByText("Choose a plugin folder", { exact: true }), ).toHaveCount(0); await expect( - dialog.getByRole("heading", { name: "Retry plugin" }), + dialog.getByRole("heading", { name: "Retry plugin", exact: true }), ).toHaveCount(0); await expect(dialog.getByText("example.retry", { exact: true })).toHaveCount( 0, @@ -1516,3 +1532,163 @@ for (const source of ["folder", "git"]) { ).toBeFocused(); }); } + +for (const mode of ["Light", "Dark"]) { + test(`plugin deep link opens the consent flow in ${mode} mode`, async ({ + page, + app, + }, info) => { + const repository = "ssh://git@github.com/example/plugin.git"; + const reference = "release/stable"; + const link = `buzz://plugin-install?repository=${encodeURIComponent(repository)}&ref=${encodeURIComponent(reference)}`; + await nativeImports( + page, + [ + { + path: ".", + manifest: { + id: "example.linked", + name: "Linked plugin", + apiVersion: 1, + host: { files: true }, + }, + revision: "one", + }, + ], + { warnings: [] }, + [link], + ); + await page.goto(app.origin); + const review = page.getByRole("dialog", { + name: "Review Linked plugin Plugin access", + exact: true, + }); + await expect(review).toBeVisible(); + await expect( + page.getByRole("region", { name: "Computer access" }), + ).toBeVisible(); + await review.getByRole("button", { name: "Back", exact: true }).click(); + const dialog = page.getByRole("dialog", { + name: "Add a plugin", + exact: true, + }); + await expect(dialog.getByLabel("Git or GitHub repository")).toHaveValue( + repository, + ); + await expect(dialog.getByLabel("Branch or tag (optional)")).toHaveValue( + reference, + ); + await dialog.press("Escape"); + await expect(dialog).toBeHidden(); + await selectSettingsSection(page, "Appearance"); + await page.goBack(); + await expect(dialog).toBeHidden(); + await expect(review).toBeHidden(); + await selectSettingsSection(page, "Appearance"); + await chooseColorMode(page, mode); + await page.evaluate((link) => window.deliverPluginLink(link), link); + await expect(review).toBeVisible(); + await review.press("Escape"); + await expect(review).toBeHidden(); + await page.evaluate((link) => window.deliverPluginLink(link), link); + await expect(review).toBeVisible(); + const install = page.getByRole("button", { + name: "Install plugin", + exact: true, + }); + await expect(install).toBeVisible(); + await expect( + page.getByRole("region", { name: "Computer access" }), + ).toBeVisible(); + expect( + await page.evaluate(() => + window.importCalls.filter( + (call) => call.command === "plugin_import_install", + ), + ), + ).toHaveLength(0); + await page.screenshot({ + path: info.outputPath(`plugin-link-${mode.toLowerCase()}.png`), + }); + await install.click(); + await expect + .poll(() => + page.evaluate( + () => + window.importCalls.filter( + (call) => call.command === "plugin_import_install", + ).length, + ), + ) + .toBe(1); + await expect(review).toBeHidden(); + await selectSettingsSection(page, "Appearance"); + await page.goBack(); + await expect(review).toBeHidden(); + await expect(dialog).toBeHidden(); + }); +} + +test("a linked preview can close during acquisition and a later link still opens", async ({ + page, + app, +}) => { + const link = "buzz://plugin-install?repository=org%2Frepo&ref=first"; + await nativeImports( + page, + [ + { + path: ".", + manifest: { + id: "example.linked", + name: "Linked", + apiVersion: 1, + host: { files: true }, + }, + revision: "one", + }, + ], + { warnings: [] }, + [link], + true, + ); + await page.goto(app.origin); + const loading = page.getByRole("dialog", { + name: "Add a plugin", + exact: true, + }); + await expect(loading).toBeVisible(); + await expect + .poll(() => page.evaluate(() => typeof window.resolveImport)) + .toBe("function"); + await expect( + loading.getByRole("button", { name: "Close", exact: true }), + ).toBeEnabled(); + await loading.press("Escape"); + await expect(loading).toBeHidden(); + await page.evaluate((link) => { + window.importDelay = false; + window.deliverPluginLink(link.replace("ref=first", "ref=next")); + }, link); + await expect(loading).toBeVisible(); + await page.evaluate(() => window.resolveImport()); + const review = page.getByRole("dialog", { + name: "Review Linked Plugin access", + exact: true, + }); + await expect(review).toBeVisible(); + expect( + await page.evaluate(() => + window.importCalls.filter( + (call) => call.command === "plugin_import_install", + ), + ), + ).toHaveLength(0); + expect( + await page.evaluate(() => + window.importCalls + .filter((call) => call.command === "plugin_import_git") + .map((call) => call.args.reference), + ), + ).toEqual(["first", "next"]); +});