From 07050bb91c6c9fb36eeb1127492c1ddf1e053bd0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Wed, 13 May 2026 18:42:30 +0800 Subject: [PATCH] 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 --- desktop/src-tauri/tauri-config.test.ts | 22 +++++++++++++ desktop/src-tauri/tauri.conf.json | 2 +- .../__tests__/open-target-service.test.ts | 32 +++++++++++++++---- src/server/services/openTargetService.ts | 27 ++++++++++------ 4 files changed, 65 insertions(+), 18 deletions(-) create mode 100644 desktop/src-tauri/tauri-config.test.ts diff --git a/desktop/src-tauri/tauri-config.test.ts b/desktop/src-tauri/tauri-config.test.ts new file mode 100644 index 00000000..8f526c2d --- /dev/null +++ b/desktop/src-tauri/tauri-config.test.ts @@ -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:*') + }) +}) diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index e7bf52cc..cb407f00 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -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": { diff --git a/src/server/__tests__/open-target-service.test.ts b/src/server/__tests__/open-target-service.test.ts index 3815bd19..9fbc80b0 100644 --- a/src/server/__tests__/open-target-service.test.ts +++ b/src/server/__tests__/open-target-service.test.ts @@ -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' }, }) diff --git a/src/server/services/openTargetService.ts b/src/server/services/openTargetService.ts index 43ca7573..dc6029b3 100644 --- a/src/server/services/openTargetService.ts +++ b/src/server/services/openTargetService.ts @@ -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] } } }