Allow packaged opener icons to load

Tauri WebView was allowed to call the desktop sidecar API, but the CSP still blocked image loads from that same local server, so extracted IDE icons degraded to the generic code glyph in packaged builds. macOS detection also trusted command shims before app bundles, which let stale JetBrains Toolbox scripts keep removed IDEs visible.

Constraint: Opener icons are served by the per-run desktop sidecar on localhost or 127.0.0.1

Rejected: Bundle a fixed static icon set | the product should use detected installed application icons across platforms

Rejected: Trust macOS command shims for detection | stale shims survive app removal and make the menu lie

Confidence: high

Scope-risk: narrow

Directive: Keep macOS IDE detection anchored to real app bundles before accepting command shims

Tested: bun test src/server/__tests__/open-target-service.test.ts src/server/__tests__/open-target-api.test.ts desktop/src-tauri/tauri-config.test.ts

Tested: cd desktop && bun run test -- src/api/client.test.ts src/api/openTargets.test.ts src/stores/openTargetStore.test.ts src/components/layout/OpenProjectMenu.test.tsx src/components/layout/TabBar.test.tsx

Tested: cd desktop && bun run lint
This commit is contained in:
程序员阿江(Relakkes) 2026-05-13 18:42:30 +08:00
parent c03cb1f297
commit 07050bb91c
4 changed files with 65 additions and 18 deletions

View File

@ -0,0 +1,22 @@
import { describe, expect, it } from 'bun:test'
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
describe('tauri security config', () => {
it('allows desktop sidecar image URLs for opener icons', () => {
const config = JSON.parse(
readFileSync(join(import.meta.dir, 'tauri.conf.json'), 'utf8'),
) as {
app?: {
security?: {
csp?: string
}
}
}
const csp = config.app?.security?.csp ?? ''
expect(csp).toContain('img-src')
expect(csp).toContain('http://127.0.0.1:*')
expect(csp).toContain('http://localhost:*')
})
})

View File

@ -24,7 +24,7 @@
],
"security": {
"dangerousDisableAssetCspModification": ["style-src"],
"csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: asset: https://asset.localhost; font-src 'self' data:; connect-src 'self' ws://127.0.0.1:* http://127.0.0.1:* ws://localhost:* http://localhost:*; media-src 'self' blob:"
"csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: asset: https://asset.localhost http://127.0.0.1:* http://localhost:*; font-src 'self' data:; connect-src 'self' ws://127.0.0.1:* http://127.0.0.1:* ws://localhost:* http://localhost:*; media-src 'self' blob:"
}
},
"plugins": {

View File

@ -76,8 +76,8 @@ function createService(
describe('openTargetService', () => {
it('returns only detected IDE targets plus Finder on macOS', async () => {
const { service } = createService('darwin', {
commands: { code: true },
paths: {
'/Applications/Visual Studio Code.app': true,
'/Applications/Sublime Text.app': true,
},
})
@ -96,6 +96,21 @@ describe('openTargetService', () => {
.toBe('/api/open-targets/icons/vscode')
})
it('does not treat macOS command shims as installed IDEs without the app bundle', async () => {
const { service } = createService('darwin', {
commands: {
code: true,
goland: true,
pycharm: true,
},
})
const result = await service.listTargets()
expect(result.targets.map((target) => target.id)).toEqual(['finder'])
expect(result.primaryTargetId).toBe('finder')
})
it('falls back to Explorer when no Windows IDE is detected', async () => {
const { service } = createService('win32')
@ -120,7 +135,7 @@ describe('openTargetService', () => {
it('caches detection results until the TTL expires', async () => {
const now = { value: 100 }
const state = createService('darwin', {
const state = createService('linux', {
commands: { code: true },
now,
})
@ -153,7 +168,9 @@ describe('openTargetService', () => {
const dir = await makeDir()
const file = join(dir, 'note.txt')
await writeFile(file, 'not a directory')
const { service } = createService('darwin', { commands: { code: true } })
const { service } = createService('darwin', {
paths: { '/Applications/Visual Studio Code.app': true },
})
try {
await expect(service.openTarget({ targetId: 'vscode', path: file }))
@ -163,9 +180,9 @@ describe('openTargetService', () => {
}
})
it('launches with argument arrays and the path as one argument', async () => {
it('launches command-first targets with argument arrays and the path as one argument', async () => {
const dir = await makeDir('cc-haha open-target-')
const { service, launched } = createService('darwin', {
const { service, launched } = createService('linux', {
commands: { code: true },
})
@ -178,9 +195,10 @@ describe('openTargetService', () => {
}
})
it('opens macOS app bundles through open -a when no command is present', async () => {
it('opens macOS app bundles through open -a instead of command shims', async () => {
const dir = await makeDir()
const { service, launched } = createService('darwin', {
commands: { cursor: true },
paths: { '/Applications/Cursor.app': true },
})
@ -198,7 +216,7 @@ describe('openTargetService', () => {
it('reports launch failures instead of returning success', async () => {
const dir = await makeDir()
const { service } = createService('darwin', {
commands: { code: true },
paths: { '/Applications/Visual Studio Code.app': true },
launchResult: { code: 1, stdout: '', stderr: 'failed' },
})

View File

@ -457,6 +457,15 @@ async function isDetected(definition: TargetDefinition, runtime: Runtime): Promi
return true
}
if (runtime.platform === 'darwin' && definition.appPaths?.darwin?.length) {
for (const appPath of definition.appPaths.darwin) {
if (await runtime.pathExists(appPath)) {
return true
}
}
return false
}
for (const appPath of definition.appPaths?.[runtime.platform] ?? []) {
if (await runtime.pathExists(appPath)) {
return true
@ -494,19 +503,17 @@ async function resolveLaunchPlan(
}
}
for (const command of definition.commands?.[runtime.platform] ?? []) {
if (await runtime.commandExists(command)) {
return { command, args: [targetPath] }
if (runtime.platform === 'darwin') {
for (const appPath of definition.appPaths?.darwin ?? []) {
if (await runtime.pathExists(appPath)) {
return { command: 'open', args: ['-a', appPath, targetPath] }
}
}
}
if (runtime.platform !== 'darwin') {
return null
}
for (const appPath of definition.appPaths?.darwin ?? []) {
if (await runtime.pathExists(appPath)) {
return { command: 'open', args: ['-a', appPath, targetPath] }
for (const command of definition.commands?.[runtime.platform] ?? []) {
if (await runtime.commandExists(command)) {
return { command, args: [targetPath] }
}
}