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:
parent
909dc115ec
commit
7b5a5bdeef
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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'"
|
||||
|
|
|
|||
10
src/composables/use-external-link.ts
Normal file
10
src/composables/use-external-link.ts
Normal 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
1
src/global.d.ts
vendored
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue