From 81ff1dc14802184e99189be32fe36073ee47e109 Mon Sep 17 00:00:00 2001 From: CSXFanMeng <105690322+CSXFanMeng@users.noreply.github.com> Date: Mon, 2 Nov 2026 23:47:32 +0800 Subject: [PATCH] =?UTF-8?q?feat(plugin):=20=E6=94=AF=E6=8C=81=E7=BB=8F?= =?UTF-8?q?=E5=AE=BF=E4=B8=BB=E7=A1=AE=E8=AE=A4=E6=89=93=E5=BC=80=E5=A3=B0?= =?UTF-8?q?=E6=98=8E=E7=AB=99=E7=82=B9=E7=9A=84=E5=A4=96=E9=83=A8=E7=BD=91?= =?UTF-8?q?=E5=9D=80=20(#14089)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(plugin): open declared external URLs with host consent * style(test): format saved SQL sync callback for CI --- .../plugins/PluginOpenUrlDialog.spec.ts | 102 ++++++++++++++++++ .../plugins/PluginOpenUrlDialog.vue | 80 ++++++++++++++ .../plugins/PluginWorkbenchHost.vue | 5 + apps/desktop/src/i18n/locales/az.ts | 2 + apps/desktop/src/i18n/locales/en.ts | 2 + apps/desktop/src/i18n/locales/es.ts | 2 + apps/desktop/src/i18n/locales/id.ts | 2 + apps/desktop/src/i18n/locales/it.ts | 2 + apps/desktop/src/i18n/locales/ja.ts | 2 + apps/desktop/src/i18n/locales/ko.ts | 2 + apps/desktop/src/i18n/locales/pl.ts | 2 + apps/desktop/src/i18n/locales/pt-BR.ts | 2 + apps/desktop/src/i18n/locales/ru.ts | 2 + apps/desktop/src/i18n/locales/th.ts | 2 + apps/desktop/src/i18n/locales/tr.ts | 2 + apps/desktop/src/i18n/locales/zh-CN.ts | 2 + apps/desktop/src/i18n/locales/zh-TW.ts | 2 + .../src/lib/plugins/pluginHostBridge.ts | 11 ++ .../src/lib/plugins/pluginOpenUrl.spec.ts | 58 ++++++++++ apps/desktop/src/lib/plugins/pluginOpenUrl.ts | 32 ++++++ .../src/plugins/manifest.rs | 80 +++++++++++++- docs/content/docs/plugin-development.cn.mdx | 16 +++ docs/content/docs/plugin-development.mdx | 16 +++ .../issue-14017-open-url-mobile.png | Bin 0 -> 21055 bytes docs/screenshots/issue-14017-open-url.png | Bin 0 -> 25444 bytes plugins/manifest.schema.json | 3 +- plugins/sdk/dev-host/browser-bridge.mjs | 1 + .../dev-host/tests/browser-bridge.test.mjs | 16 +++ 28 files changed, 445 insertions(+), 3 deletions(-) create mode 100644 apps/desktop/src/components/plugins/PluginOpenUrlDialog.spec.ts create mode 100644 apps/desktop/src/components/plugins/PluginOpenUrlDialog.vue create mode 100644 apps/desktop/src/lib/plugins/pluginOpenUrl.spec.ts create mode 100644 apps/desktop/src/lib/plugins/pluginOpenUrl.ts create mode 100644 docs/screenshots/issue-14017-open-url-mobile.png create mode 100644 docs/screenshots/issue-14017-open-url.png diff --git a/apps/desktop/src/components/plugins/PluginOpenUrlDialog.spec.ts b/apps/desktop/src/components/plugins/PluginOpenUrlDialog.spec.ts new file mode 100644 index 0000000000..5e041c6924 --- /dev/null +++ b/apps/desktop/src/components/plugins/PluginOpenUrlDialog.spec.ts @@ -0,0 +1,102 @@ +// @vitest-environment happy-dom +import { createApp, h, nextTick, reactive, type App } from "vue"; +import { createI18n } from "vue-i18n"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import PluginOpenUrlDialog from "./PluginOpenUrlDialog.vue"; +const shell = vi.hoisted(() => ({ open: vi.fn(), native: false })); +vi.mock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => shell.native })); +vi.mock("@tauri-apps/plugin-shell", () => ({ open: shell.open })); +let app: App; +let host: HTMLElement; +afterEach(() => { + app?.unmount(); + host?.remove(); + vi.clearAllMocks(); + shell.native = false; +}); +async function mount() { + const props = reactive({ pluginId: "bookmarks", pluginName: "Bookmarks", permissions: ["host.openUrl:https://example.com"] }); + let dialog!: InstanceType; + host = document.createElement("div"); + document.body.append(host); + app = createApp({ + render: () => + h(PluginOpenUrlDialog, { + ...props, + ref: (value) => { + dialog = value as typeof dialog; + }, + }), + }); + app.use(createI18n({ legacy: false, locale: "en", messages: { en: { pluginPlatform: { openUrlTitle: "Open external URL", openUrlPrompt: "Plugin {name} wants to open this URL." }, common: { cancel: "Cancel" }, grid: { openUrl: "Open in browser" } } } })); + app.mount(host); + await nextTick(); + return { props, dialog }; +} +describe("PluginOpenUrlDialog", () => { + it("shows host-owned identity and full destination; cancel and unmount never navigate", async () => { + const { dialog } = await mount(); + const decision = dialog.request("https://example.com/path?q=visible"); + await nextTick(); + expect(document.body.textContent).toContain("Plugin Bookmarks"); + expect(document.body.textContent).toContain("https://example.com/path?q=visible"); + const link = document.querySelector('a[target="_blank"]')!; + expect(link.rel).toBe("noopener noreferrer"); + expect(link.getAttribute("referrerpolicy")).toBe("no-referrer"); + await expect(dialog.request("https://example.com/second")).rejects.toThrow("already awaiting"); + [...document.querySelectorAll("button")].find((b) => b.textContent === "Cancel")!.click(); + await expect(decision).resolves.toEqual({ opened: false }); + expect(shell.open).not.toHaveBeenCalled(); + const pending = dialog.request("https://example.com/again"); + app.unmount(); + await expect(pending).resolves.toEqual({ opened: false }); + }); + it("does not call the native shell until explicit approval and keeps failures retryable", async () => { + shell.native = true; + shell.open.mockRejectedValueOnce(new Error("Browser unavailable")).mockResolvedValueOnce(undefined); + const { dialog } = await mount(); + const result = dialog.request("https://example.com/path"); + await nextTick(); + expect(shell.open).not.toHaveBeenCalled(); + document.querySelector('a[target="_blank"]')!.click(); + await vi.waitFor(() => expect(document.querySelector('[role="alert"]')?.textContent).toBe("Browser unavailable")); + document.querySelector('a[target="_blank"]')!.click(); + await expect(result).resolves.toEqual({ opened: true }); + expect(shell.open).toHaveBeenCalledTimes(2); + expect(shell.open).toHaveBeenLastCalledWith("https://example.com/path"); + }); + it("cancels pending consent when plugin identity or permissions change", async () => { + const { dialog, props } = await mount(); + const result = dialog.request("https://example.com/path"); + props.permissions = []; + await nextTick(); + await expect(result).resolves.toEqual({ opened: false }); + expect(() => dialog.request("https://example.com/path")).toThrow("not declared"); + }); + it("does not let a late native opening settle a subsequent request", async () => { + shell.native = true; + let finish!: () => void; + shell.open.mockImplementationOnce( + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + const { dialog } = await mount(); + const first = dialog.request("https://example.com/first"); + await nextTick(); + document.querySelector('a[target="_blank"]')!.click(); + await vi.waitFor(() => expect(shell.open).toHaveBeenCalled()); + dialog.cancel(); + await expect(first).resolves.toEqual({ opened: false }); + await expect(dialog.request("https://example.com/second")).rejects.toThrow("already awaiting"); + finish(); + await nextTick(); + await nextTick(); + const second = dialog.request("https://example.com/second"); + await nextTick(); + expect(document.body.textContent).toContain("https://example.com/second"); + dialog.cancel(); + await expect(second).resolves.toEqual({ opened: false }); + }); +}); diff --git a/apps/desktop/src/components/plugins/PluginOpenUrlDialog.vue b/apps/desktop/src/components/plugins/PluginOpenUrlDialog.vue new file mode 100644 index 0000000000..c6bb5f669b --- /dev/null +++ b/apps/desktop/src/components/plugins/PluginOpenUrlDialog.vue @@ -0,0 +1,80 @@ + + + diff --git a/apps/desktop/src/components/plugins/PluginWorkbenchHost.vue b/apps/desktop/src/components/plugins/PluginWorkbenchHost.vue index dc8ff4cac0..4edb19c860 100644 --- a/apps/desktop/src/components/plugins/PluginWorkbenchHost.vue +++ b/apps/desktop/src/components/plugins/PluginWorkbenchHost.vue @@ -1,5 +1,6 @@