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 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 .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
|
## 0.11.6 — 2026-04-08
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import ProviderSelectField from '@/components/chat/ProviderSelectField.vue'
|
||||||
import { useInputUI } from '@/components/ui/input'
|
import { useInputUI } from '@/components/ui/input'
|
||||||
import { usePopoverUI } from '@/components/ui/popover'
|
import { usePopoverUI } from '@/components/ui/popover'
|
||||||
import { useAIChat } from '@/composables/use-chat'
|
import { useAIChat } from '@/composables/use-chat'
|
||||||
|
import { openExternalLink } from '@/composables/use-external-link'
|
||||||
|
|
||||||
const cls = usePopoverUI({ content: 'isolate z-[51] w-64 p-3' })
|
const cls = usePopoverUI({ content: 'isolate z-[51] w-64 p-3' })
|
||||||
|
|
||||||
|
|
@ -167,13 +168,13 @@ function clearUnsplashKey() {
|
||||||
:class="useInputUI({ size: 'sm' }).base"
|
:class="useInputUI({ size: 'sm' }).base"
|
||||||
@change="save"
|
@change="save"
|
||||||
/>
|
/>
|
||||||
<a
|
<button
|
||||||
href="https://www.pexels.com/api/"
|
type="button"
|
||||||
target="_blank"
|
class="cursor-pointer text-[9px] text-muted underline hover:text-surface"
|
||||||
class="text-[9px] text-muted underline hover:text-surface"
|
@click="openExternalLink('https://www.pexels.com/api/')"
|
||||||
>
|
>
|
||||||
Get free Pexels API key →
|
Get free Pexels API key →
|
||||||
</a>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Unsplash stock photos -->
|
<!-- Unsplash stock photos -->
|
||||||
|
|
@ -201,13 +202,13 @@ function clearUnsplashKey() {
|
||||||
:class="useInputUI({ size: 'sm' }).base"
|
:class="useInputUI({ size: 'sm' }).base"
|
||||||
@change="save"
|
@change="save"
|
||||||
/>
|
/>
|
||||||
<a
|
<button
|
||||||
href="https://unsplash.com/oauth/applications"
|
type="button"
|
||||||
target="_blank"
|
class="cursor-pointer text-[9px] text-muted underline hover:text-surface"
|
||||||
class="text-[9px] text-muted underline hover:text-surface"
|
@click="openExternalLink('https://unsplash.com/oauth/applications')"
|
||||||
>
|
>
|
||||||
Get free Unsplash access key →
|
Get free Unsplash access key →
|
||||||
</a>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template v-if="!isACP">
|
<template v-if="!isACP">
|
||||||
|
|
@ -293,14 +294,14 @@ function clearUnsplashKey() {
|
||||||
:class="useInputUI({ size: 'sm' }).base"
|
:class="useInputUI({ size: 'sm' }).base"
|
||||||
@change="save"
|
@change="save"
|
||||||
/>
|
/>
|
||||||
<a
|
<button
|
||||||
v-if="providerDef.keyURL"
|
v-if="providerDef.keyURL"
|
||||||
:href="providerDef.keyURL"
|
type="button"
|
||||||
target="_blank"
|
class="cursor-pointer text-[9px] text-muted underline hover:text-surface"
|
||||||
class="text-[9px] text-muted underline hover:text-surface"
|
@click="openExternalLink(providerDef.keyURL!)"
|
||||||
>
|
>
|
||||||
Get API key →
|
Get API key →
|
||||||
</a>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import ProviderSelectField from '@/components/chat/ProviderSelectField.vue'
|
||||||
import { useInputUI } from '@/components/ui/input'
|
import { useInputUI } from '@/components/ui/input'
|
||||||
import { useAIChat } from '@/composables/use-chat'
|
import { useAIChat } from '@/composables/use-chat'
|
||||||
import { ACP_AGENTS } from '@open-pencil/core'
|
import { ACP_AGENTS } from '@open-pencil/core'
|
||||||
|
import { openExternalLink } from '@/composables/use-external-link'
|
||||||
import { useI18n } from '@open-pencil/vue'
|
import { useI18n } from '@open-pencil/vue'
|
||||||
|
|
||||||
const { providerID, providerDef, setAPIKey, customBaseURL, customModelID } = useAIChat()
|
const { providerID, providerDef, setAPIKey, customBaseURL, customModelID } = useAIChat()
|
||||||
|
|
@ -104,15 +105,15 @@ function save() {
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<a
|
<button
|
||||||
v-if="!isACP && providerDef.keyURL"
|
v-if="!isACP && providerDef.keyURL"
|
||||||
:href="providerDef.keyURL"
|
type="button"
|
||||||
target="_blank"
|
|
||||||
data-test-id="api-key-get-link"
|
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 }) }}
|
{{ dialogs.getAPIKey({ provider: providerDef.name }) }}
|
||||||
</a>
|
</button>
|
||||||
|
|
||||||
<p
|
<p
|
||||||
v-if="providerID === 'openrouter'"
|
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.
|
// The assignment in EditorView.vue is type-safe; test code uses `!` assertion.
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
__OPEN_PENCIL_STORE__?: 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 })
|
).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