fix(tauri): use opener plugin for external links instead of shell (#197)

External links (<a target="_blank">) in the AI panel triggered
"Command plugin:shell|open not allowed by ACL" on Tauri desktop.

Root cause: Tauri v2 intercepts <a target="_blank"> via the shell plugin,
but only the opener plugin had ACL permissions. Replace all external links
with programmatic openUrl() from @tauri-apps/plugin-opener, which is already
whitelisted via "opener:default" in capabilities.

- Extract openExternalLink() composable for Tauri/browser switch
- Replace <a target="_blank"> with <button @click> in ProviderSetup/Settings
- Add E2E test verifying window.open is called in browser context

Fixes #193

Co-authored-by: Danila Poyarkov <dev@dannote.net>
This commit is contained in:
Anton Soldatov 2026-04-15 11:02:00 +03:00 committed by GitHub
parent 909dc115ec
commit 7b5a5bdeef
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 68 additions and 20 deletions

View file

@ -10,6 +10,7 @@
- Fix color picker dragging flooding the undo stack — fill/stroke/effect color and opacity drags now collapse into a single undo entry per interaction via debounced batching in `PropertyListRoot`
- Fix .fig import crash on alias variables without a GUID
- Fix external links in AI panel blocked by Tauri ACL — use opener plugin instead of shell
## 0.11.6 — 2026-04-08

View file

@ -16,6 +16,7 @@ import ProviderSelectField from '@/components/chat/ProviderSelectField.vue'
import { useInputUI } from '@/components/ui/input'
import { usePopoverUI } from '@/components/ui/popover'
import { useAIChat } from '@/composables/use-chat'
import { openExternalLink } from '@/composables/use-external-link'
const cls = usePopoverUI({ content: 'isolate z-[51] w-64 p-3' })
@ -167,13 +168,13 @@ function clearUnsplashKey() {
:class="useInputUI({ size: 'sm' }).base"
@change="save"
/>
<a
href="https://www.pexels.com/api/"
target="_blank"
class="text-[9px] text-muted underline hover:text-surface"
<button
type="button"
class="cursor-pointer text-[9px] text-muted underline hover:text-surface"
@click="openExternalLink('https://www.pexels.com/api/')"
>
Get free Pexels API key
</a>
</button>
</div>
<!-- Unsplash stock photos -->
@ -201,13 +202,13 @@ function clearUnsplashKey() {
:class="useInputUI({ size: 'sm' }).base"
@change="save"
/>
<a
href="https://unsplash.com/oauth/applications"
target="_blank"
class="text-[9px] text-muted underline hover:text-surface"
<button
type="button"
class="cursor-pointer text-[9px] text-muted underline hover:text-surface"
@click="openExternalLink('https://unsplash.com/oauth/applications')"
>
Get free Unsplash access key
</a>
</button>
</div>
<template v-if="!isACP">
@ -293,14 +294,14 @@ function clearUnsplashKey() {
:class="useInputUI({ size: 'sm' }).base"
@change="save"
/>
<a
<button
v-if="providerDef.keyURL"
:href="providerDef.keyURL"
target="_blank"
class="text-[9px] text-muted underline hover:text-surface"
type="button"
class="cursor-pointer text-[9px] text-muted underline hover:text-surface"
@click="openExternalLink(providerDef.keyURL!)"
>
Get API key
</a>
</button>
</div>
</template>

View file

@ -5,6 +5,7 @@ import ProviderSelectField from '@/components/chat/ProviderSelectField.vue'
import { useInputUI } from '@/components/ui/input'
import { useAIChat } from '@/composables/use-chat'
import { ACP_AGENTS } from '@open-pencil/core'
import { openExternalLink } from '@/composables/use-external-link'
import { useI18n } from '@open-pencil/vue'
const { providerID, providerDef, setAPIKey, customBaseURL, customModelID } = useAIChat()
@ -104,15 +105,15 @@ function save() {
</p>
</div>
<a
<button
v-if="!isACP && providerDef.keyURL"
:href="providerDef.keyURL"
target="_blank"
type="button"
data-test-id="api-key-get-link"
class="mt-2.5 text-[10px] text-muted underline hover:text-surface"
class="mt-2.5 cursor-pointer text-[10px] text-muted underline hover:text-surface"
@click="openExternalLink(providerDef.keyURL!)"
>
{{ dialogs.getAPIKey({ provider: providerDef.name }) }}
</a>
</button>
<p
v-if="providerID === 'openrouter'"

View file

@ -0,0 +1,10 @@
import { IS_TAURI } from '@open-pencil/core'
export async function openExternalLink(url: string) {
if (IS_TAURI) {
const { openUrl } = await import('@tauri-apps/plugin-opener')
await openUrl(url)
} else {
window.open(url, '_blank')
}
}

1
src/global.d.ts vendored
View file

@ -41,4 +41,5 @@ interface Window {
// The assignment in EditorView.vue is type-safe; test code uses `!` assertion.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
__OPEN_PENCIL_STORE__?: any
__mocked_window_open?: (url?: string | URL, target?: string) => Window | null
}

View file

@ -217,3 +217,37 @@ test('transport errors show an actionable toast', async () => {
}),
).toBeVisible({ timeout: 5000 })
})
test('"Get API key" link opens external URL via window.open', async () => {
// Clear the key to return to provider setup
await page.locator('[data-test-id="provider-settings-trigger"]').click()
await page.locator('[data-test-id="provider-settings-clear-key"]').click()
await page.locator('[data-test-id="provider-settings-done"]').click()
// Now we're back in ProviderSetup — the link should be visible
const link = page.locator('[data-test-id="api-key-get-link"]')
await expect(link).toBeVisible()
// Intercept window.open to verify it's called with the right URL
const openedUrls: string[] = []
await page.exposeFunction('mockWindowOpen', (url: string) => openedUrls.push(url))
await page.evaluate(() => {
window.__mocked_window_open = window.open
window.open = (url: string | URL) => {
;(window as any).mockWindowOpen(String(url))
return null
}
})
await link.click()
await expect(() => {
expect(openedUrls.length).toBeGreaterThan(0)
expect(openedUrls[0]).toMatch(/^https:\/\//)
}).toPass({ timeout: 3000 })
// Restore
await page.evaluate(() => {
window.open = window.__mocked_window_open
})
})