fix(computer-use): restore the sidecar signing exclusion, and show app icons

Two things, in one commit because both touch `src/server/api/computer-use.ts`
and the halves cannot be split without rewriting the file twice.

## The regression

`desktop/package.json` had lost `"claude-sidecar-[^/]+$"` from `mac.signIgnore`.
That entry is not a hardening nicety — it is load-bearing for attestation:

  1. `build-sidecars.ts` signs the sidecar with an explicit
     `--identifier com.claude-code-haha.desktop.sidecar`
  2. `ClientAttestation.swift` compares that identifier EXACTLY in
     `validDesktopChain`
  3. without the exclusion, electron-builder re-signs the sidecar and drops the
     flag, so codesign derives the identifier from the file name
     (`claude-sidecar-aarch64-apple-darwin`), which never matches

Measured on the shipped 0.5.3 build: host and helper identifiers were correct,
the sidecar's was not. `validDesktopChain` backs BOTH `authorizeOneShot` and
`authorizeDaemon`, so this was not merely a broken permission probe — every
Computer Use call in that build failed closed with `unauthorized_client`.

The existing guard was a literal `toEqual` on the whole signIgnore array, which
does not survive an edit that changes the array and the expectation together —
exactly how the entry was lost. The replacement asserts the behaviour instead:
real sidecar file names must match some exclusion pattern, and the identifier
constants in `sign-identity.ts` and `ClientAttestation.swift` must agree.

`checkCuHelperPermissions` swallowed the failure into nulls, which the settings
page renders as a permanent "checking…" — indistinguishable from a probe still
in flight, and the only symptom this bug ever produced. It now records an
error-level diagnostic: the helper binary is present, so the user did nothing
wrong and the check still did not complete.

## App icons

Rows in the picker and the authorized list showed a letter tile. They now show
the application's own icon, resolved the way Finder does it: `CFBundleIconFile`
from Info.plist, the `.icns` under `Contents/Resources`, rasterised with `sips`.

`openTargetService` already does this, but its resolver is keyed on a
`TargetDefinition` and cannot answer for an arbitrary installed app, so the
path-only half lives in `macAppIcon.ts` and that service is left alone.

The endpoint takes a bundle id and resolves the path itself. There is
deliberately no parameter that names a file — it rasterises and returns bytes,
so its input surface is a security property, and a test drives paths at it.

Enumeration is shared across concurrent lookups: opening the picker fires one
icon request per visible row while the cache is still cold, and a plain
check-then-fill cache would walk every application root once per row.

macOS only, matching where this engine exists. The Windows list renders no icon
slot at all, and Linux is not a supported Computer Use platform.

## Verification

- 208 installed applications on the dev machine: 204 icons resolved; the 4
  misses are background bundles (Adobe sync extension, a URL handler, an
  updater, a token host) that ship no icon and correctly fall back
- check:server 3345 pass, desktop 4101 pass, check:policy 243 pass, lint clean
  (the 2 failures in each suite are `*.golden.test.ts`, pre-existing on main)
- mutation-checked that the new guards actually fail: removing the signIgnore
  entry, dropping the icon `onError` fallback, and breaking the in-flight share
  each turn a test red
This commit is contained in:
程序员阿江(Relakkes)
2026-08-05 22:59:26 +08:00
parent b8a90626ce
commit 8e033e2890
9 changed files with 814 additions and 12 deletions
+2 -1
View File
@@ -47,7 +47,8 @@
"signIgnore": [
"/Contents/Frameworks/.+\\.(?:pak|bin|dat|nib)$",
"/Contents/Resources/.+\\.(?:asar|pak|bin|dat|icns|png|jpg|jpeg|gif|svg|ttf|woff|woff2)$",
"cc-haha-computer-use\\.app"
"cc-haha-computer-use\\.app",
"claude-sidecar-[^/]+$"
],
"notarize": true
},
+14 -1
View File
@@ -1,4 +1,4 @@
import { api } from './client'
import { api, getApiUrl } from './client'
export type ComputerUseStatus = {
platform: string
@@ -110,6 +110,19 @@ export const computerUseApi = {
openSettings(pane: 'Privacy_ScreenCapture' | 'Privacy_Accessibility') {
return api.post<{ ok: true }>('/api/computer-use/open-settings', { pane })
},
/**
* URL of an installed app's own icon, for use as an `<img src>`.
*
* macOS-only, and 404s when the bundle declares no icon — callers render a
* letter placeholder on error rather than treating that as a failure. The
* parameter is a bundle id because the server resolves it against the
* installed-app list; there is deliberately no way to ask for a path.
*/
getAppIconUrl(bundleId: string, size = 72) {
return getApiUrl(
`/api/computer-use/app-icon?bundleId=${encodeURIComponent(bundleId)}&size=${size}`,
)
},
/**
* macOS-only. Spawns the native `cu-helper request-access` permission card and
* resolves ONLY when the user closes it (hence the long timeout, mirroring
@@ -14,6 +14,7 @@ const computerUseApiMock = vi.hoisted(() => ({
runSetup: vi.fn(),
openSettings: vi.fn(),
openPermissionCard: vi.fn(),
getAppIconUrl: vi.fn(),
}))
vi.mock('../api/computerUse', () => ({
@@ -74,6 +75,10 @@ describe('ComputerUseSettings', () => {
computerUseApiMock.runSetup.mockReset()
computerUseApiMock.openSettings.mockReset()
computerUseApiMock.openPermissionCard.mockReset()
computerUseApiMock.getAppIconUrl.mockReset()
computerUseApiMock.getAppIconUrl.mockImplementation(
(bundleId: string) => `/api/computer-use/app-icon?bundleId=${bundleId}`,
)
Reflect.deleteProperty(window, 'desktopHost')
computerUseApiMock.getStatus.mockResolvedValue(readyStatus)
@@ -313,6 +318,64 @@ describe('ComputerUseSettings', () => {
},
}
/**
* Rows show the application's own icon, served per bundle id. The letter
* tile is the fallback for bundles that ship no icon, so it must appear on
* image error and NOT before — a page that renders letters while perfectly
* good icons exist looks broken.
*/
describe('app icons', () => {
const authorizedConfig = {
...enabledConfig,
authorizedApps: [
{
bundleId: 'com.example.Preview',
displayName: 'Preview',
authorizedAt: '2026-01-01T00:00:00.000Z',
},
],
}
it('points each row at the icon endpoint for its bundle id', async () => {
computerUseApiMock.getStatus.mockResolvedValue(nativeStatus)
computerUseApiMock.getAuthorizedApps.mockResolvedValue(authorizedConfig)
render(<ComputerUseSettings />)
await screen.findByText('Preview')
const image = document.querySelector('img[src*="app-icon"]')
expect(image).not.toBeNull()
expect(image?.getAttribute('src')).toContain('com.example.Preview')
// The picker is a long scroller; eager loading would fetch and
// rasterise every row that is nowhere near the viewport.
expect(image?.getAttribute('loading')).toBe('lazy')
expect(computerUseApiMock.getAppIconUrl).toHaveBeenCalledWith(
'com.example.Preview',
)
})
it('falls back to the letter tile when the icon fails to load', async () => {
computerUseApiMock.getStatus.mockResolvedValue(nativeStatus)
computerUseApiMock.getAuthorizedApps.mockResolvedValue(authorizedConfig)
render(<ComputerUseSettings />)
await screen.findByText('Preview')
const image = document.querySelector('img[src*="app-icon"]')
expect(image).not.toBeNull()
// A bundle with no icon 404s, which reaches the DOM as an error event.
expect(screen.queryByText('P')).toBeNull()
await act(async () => {
fireEvent.error(image as Element)
await Promise.resolve()
})
expect(document.querySelector('img[src*="app-icon"]')).toBeNull()
expect(screen.getByText('P')).toBeInTheDocument()
})
})
it('pops the native permission card when enabling Any App with missing permissions', async () => {
computerUseApiMock.getStatus.mockResolvedValue(nativeStatus)
computerUseApiMock.getAuthorizedApps.mockResolvedValue({
+42 -5
View File
@@ -727,11 +727,48 @@ export function ComputerUseSettings() {
type Translate = ReturnType<typeof useTranslation>
/** Letter placeholder for an app icon (the /apps payload carries no icon). */
function AppIconPlaceholder({ name }: { name: string }) {
/**
* An app's real icon, falling back to a letter tile.
*
* The `/apps` payload deliberately carries no icon bytes — it lists every
* installed application, and inlining hundreds of PNGs would bloat one
* response. Each row instead points an `<img>` at the icon endpoint, which
* reads the bundle's own `.icns` the same way Finder does. `loading="lazy"`
* matters here: the picker is a long scroller, and without it every row off
* screen would still cost a request and a rasterisation.
*
* A bundle with no icon 404s, which is an ordinary outcome rather than a
* failure — that is what the letter tile is for.
*/
function AppIcon({ name, bundleId }: { name: string; bundleId?: string }) {
const [failed, setFailed] = useState(false)
useEffect(() => {
setFailed(false)
}, [bundleId])
const tileClass =
'flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-[10px] border border-[var(--color-border)] bg-[var(--color-surface-container-high)] shadow-[inset_0_1px_0_rgba(255,255,255,0.04)]'
if (bundleId && !failed) {
return (
<div className={tileClass}>
<img
src={computerUseApi.getAppIconUrl(bundleId)}
alt=""
aria-hidden="true"
draggable={false}
loading="lazy"
onError={() => setFailed(true)}
className="block h-7 w-7 object-contain"
/>
</div>
)
}
const letter = name.trim().charAt(0).toUpperCase() || '?'
return (
<div className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-[10px] border border-[var(--color-border)] bg-[var(--color-surface-container-high)] text-[13px] font-semibold text-[var(--color-text-secondary)] shadow-[inset_0_1px_0_rgba(255,255,255,0.04)]">
<div className={`${tileClass} text-[13px] font-semibold text-[var(--color-text-secondary)]`}>
{letter}
</div>
)
@@ -970,7 +1007,7 @@ function NativeComputerUse({
key={app.bundleId}
className="group flex items-center gap-3 px-3 py-2.5 transition-colors hover:bg-[var(--color-surface-hover)]"
>
<AppIconPlaceholder name={app.displayName} />
<AppIcon name={app.displayName} bundleId={app.bundleId} />
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-[var(--color-text-primary)]">
{app.displayName}
@@ -1053,7 +1090,7 @@ function NativeComputerUse({
onClick={() => onAddApp(app)}
className="group flex w-full items-center gap-3 px-4 py-2.5 text-left transition-colors hover:bg-[var(--color-surface-hover)]"
>
<AppIconPlaceholder name={app.displayName} />
<AppIcon name={app.displayName} bundleId={app.bundleId} />
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-[var(--color-text-primary)]">
{app.displayName}
+48
View File
@@ -537,13 +537,61 @@ describe('release desktop workflow', () => {
// macOS ties the user's Accessibility and Screen Recording grants to that
// signing identity — re-signing it here would rotate the identity and
// silently drop both permissions on every update.
//
// The sidecar is excluded for a different reason — see the dedicated test
// below, which states the causal chain this literal list cannot express.
expect(desktopPackage.build.mac?.signIgnore).toEqual([
'/Contents/Frameworks/.+\\.(?:pak|bin|dat|nib)$',
'/Contents/Resources/.+\\.(?:asar|pak|bin|dat|icns|png|jpg|jpeg|gif|svg|ttf|woff|woff2)$',
'cc-haha-computer-use\\.app',
'claude-sidecar-[^/]+$',
])
})
// Regression: this entry was once dropped from signIgnore while the literal
// assertion above was edited to match, so the suite stayed green and every
// Computer Use call in the shipped build failed closed with
// `unauthorized_client` — the settings page just said "checking…" forever.
//
// The causal chain: `build-sidecars.ts` signs the sidecar with an explicit
// `--identifier com.claude-code-haha.desktop.sidecar`, because
// `ClientAttestation.swift` compares that identifier EXACTLY when it walks the
// helper -> sidecar -> desktop process chain. If electron-builder re-signs the
// sidecar it drops that flag, and codesign falls back to deriving the
// identifier from the file name (`claude-sidecar-aarch64-apple-darwin`), which
// never matches. So this test asserts the behaviour (real sidecar file names
// are excluded) rather than the spelling of one array element.
test('macOS signIgnore keeps electron-builder off the attested sidecar', () => {
const desktopPackage = JSON.parse(readFileSync('desktop/package.json', 'utf8')) as {
build: { mac?: { signIgnore?: string[] } }
}
const patterns = (desktopPackage.build.mac?.signIgnore ?? []).map(
p => new RegExp(p),
)
// Both architectures ship under these names; ClientAttestation.swift accepts
// exactly these two, so both must survive electron-builder's signing pass.
for (const sidecar of [
'/Contents/Resources/app.asar.unpacked/src-tauri/binaries/claude-sidecar-aarch64-apple-darwin',
'/Contents/Resources/app.asar.unpacked/src-tauri/binaries/claude-sidecar-x86_64-apple-darwin',
]) {
expect(
patterns.some(p => p.test(sidecar)),
`${sidecar} must be in signIgnore, or electron-builder re-signs it and ` +
'the attestation chain breaks',
).toBe(true)
}
// The identifier the exclusion exists to protect. If this constant moves,
// ClientAttestation.swift's `sidecarIdentifier` has to move with it.
expect(
readFileSync('desktop/scripts/sign-identity.ts', 'utf8'),
).toContain("SIDECAR_SIGNING_IDENTIFIER = 'com.claude-code-haha.desktop.sidecar'")
expect(
readFileSync('native/cu-helper/Sources/cu-helper/ClientAttestation.swift', 'utf8'),
).toContain('sidecarIdentifier = "com.claude-code-haha.desktop.sidecar"')
})
test('Windows NSIS installer lets users choose the install directory', () => {
const desktopPackage = JSON.parse(readFileSync('desktop/package.json', 'utf8')) as {
build: {
+145 -1
View File
@@ -1,4 +1,5 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'bun:test'
import { afterAll, beforeAll, beforeEach, describe, expect, it, spyOn } from 'bun:test'
import { diagnosticsService } from '../services/diagnosticsService.js'
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
@@ -460,3 +461,146 @@ describe('parsePermissionSnapshot', () => {
})
})
})
/**
* The icon endpoint rasterises a file and returns its bytes, so what it accepts
* as input is a security property: it takes a bundle id and resolves the path
* itself, and there is deliberately no parameter that names a file.
*/
describe('app icon endpoint input', () => {
async function requestIcon(query: string): Promise<Response> {
const { handleComputerUseApi, __resetInstalledAppPathCacheForTests } =
await importComputerUseApi()
__resetInstalledAppPathCacheForTests()
const url = new URL(`http://localhost/api/computer-use/app-icon${query}`)
return handleComputerUseApi(new Request(url, { method: 'GET' }), url, [
'api',
'computer-use',
'app-icon',
])
}
it('rejects a request that names no bundle', async () => {
const response = await requestIcon('')
// 404 off darwin, where the endpoint does not exist at all.
expect([400, 404]).toContain(response.status)
})
it('refuses paths dressed up as bundle ids', async () => {
// None of these resolve through the installed-app list, so none of them can
// reach the filesystem — the point is that a path is not a way in.
for (const attempt of [
'/Applications/Safari.app',
'../../../etc/passwd',
'/etc/passwd',
'/System/Library/CoreServices/Finder.app/Contents/Resources/Finder.icns',
]) {
const response = await requestIcon(
`?bundleId=${encodeURIComponent(attempt)}`,
)
expect(response.status).toBe(404)
expect(response.headers.get('Content-Type')).not.toBe('image/png')
}
})
it('reports an unknown bundle id as missing rather than erroring', async () => {
const response = await requestIcon('?bundleId=com.example.not.installed')
expect(response.status).toBe(404)
})
it('enumerates applications once for a burst of concurrent lookups', async () => {
// Opening the picker fires one icon request per visible row at the same
// moment. A check-then-fill cache is still cold for all of them, so without
// in-flight sharing each row would walk every application root — hundreds
// of `plutil` spawns to answer one screen.
const { resolveInstalledAppPath, __resetInstalledAppPathCacheForTests } =
await importComputerUseApi()
__resetInstalledAppPathCacheForTests()
let scans = 0
const lister = async () => {
scans += 1
await new Promise(resolve => setTimeout(resolve, 5))
return [{ bundleId: 'com.example.App', path: '/Applications/App.app' }]
}
const results = await Promise.all(
Array.from({ length: 25 }, () =>
resolveInstalledAppPath('com.example.App', lister),
),
)
expect(scans).toBe(1)
expect(new Set(results)).toEqual(new Set(['/Applications/App.app']))
// The warm cache serves later lookups without scanning again.
expect(await resolveInstalledAppPath('com.example.App', lister)).toBe(
'/Applications/App.app',
)
expect(scans).toBe(1)
__resetInstalledAppPathCacheForTests()
})
})
/**
* Nulls render as a permanent "checking…" in the settings page, so a probe that
* fails silently is indistinguishable from one still in flight. That happened:
* the shipped sidecar got re-signed, the helper answered `unauthorized_client`,
* and the only visible symptom was a spinner that never resolved.
*/
describe('checkCuHelperPermissions failure reporting', () => {
it('records an error-level diagnostic when the helper probe throws', async () => {
const { checkCuHelperPermissions } = await importComputerUseApi()
const recorded: Array<Record<string, unknown>> = []
const spy = spyOn(diagnosticsService, 'recordEvent').mockImplementation(
async (input: unknown) => {
recorded.push(input as Record<string, unknown>)
return { written: true } as never
},
)
try {
const result = await checkCuHelperPermissions(async () => {
throw new Error(
'This helper command requires the signed Claude Code Haha desktop app.',
)
})
// Still degrades to unknown — the caller contract does not change.
expect(result).toEqual({ accessibility: null, screenRecording: null })
expect(recorded).toHaveLength(1)
expect(recorded[0]).toMatchObject({
type: 'computer_use_permission_probe_failed',
// The user did nothing wrong and the check did not complete, which is
// this project's definition of error rather than warn.
severity: 'error',
summary:
'This helper command requires the signed Claude Code Haha desktop app.',
})
} finally {
spy.mockRestore()
}
})
it('stays silent on the success path', async () => {
const { checkCuHelperPermissions } = await importComputerUseApi()
const spy = spyOn(diagnosticsService, 'recordEvent').mockImplementation(
async () => ({ written: true }) as never,
)
try {
const result = await checkCuHelperPermissions(
async () =>
({ accessibility: true, screenRecording: false }) as never,
)
expect(result).toEqual({ accessibility: true, screenRecording: false })
// A granted-or-denied answer is a completed check, not a diagnostic event.
expect(spy).not.toHaveBeenCalled()
} finally {
spy.mockRestore()
}
})
})
+115 -4
View File
@@ -14,6 +14,8 @@ import path from 'path'
import { fileURLToPath } from 'url'
import type { AppGrant, CuPermissionRequest } from '../../vendor/computer-use-mcp/types.js'
import { computerUseApprovalService } from '../services/computerUseApprovalService.js'
import { diagnosticsService } from '../services/diagnosticsService.js'
import { normalizeIconSize, readAppIconPng } from '../services/macAppIcon.js'
import { listInstalledMacApps } from './macInstalledApps.js'
import { detectPythonRuntime, isPythonVersionAtLeast } from './computer-use-python.js'
import { buildPipInstallAttempts } from '../../utils/computerUse/pipInstall.js'
@@ -223,17 +225,44 @@ function isCuHelperAvailableForServer(): boolean {
* with NO Python prerequisite. Returns nulls on any failure so the caller can
* surface "unknown" instead of throwing.
*/
async function checkCuHelperPermissions(): Promise<{
export async function checkCuHelperPermissions(
// Injected the same way `callCuHelper` injects its own exec, so the failure
// branch below is reachable from a test without a real helper binary.
call: typeof callCuHelper = callCuHelper,
): Promise<{
accessibility: boolean | null
screenRecording: boolean | null
}> {
try {
const result = await callCuHelper<CuHelperPermissions>('check_permissions')
const result = await call<CuHelperPermissions>('check_permissions')
return {
accessibility: result.accessibility ?? null,
screenRecording: result.screenRecording ?? null,
}
} catch {
} catch (error) {
// Nulls reach the settings page as a permanent "checking…" — the UI cannot
// tell "not probed yet" from "probe failed". Swallowing the reason silently
// once cost a long investigation to rediscover that the shipped sidecar had
// been re-signed and the helper was answering `unauthorized_client`.
//
// This is an error, not a warning: the caller only gets here when the helper
// binary IS present, so the user did nothing wrong and the check still did
// not complete. A helper that is merely un-granted answers with `false`.
void diagnosticsService.recordEvent({
type: 'computer_use_permission_probe_failed',
severity: 'error',
summary:
error instanceof Error
? error.message
: 'cu-helper check_permissions failed',
details: {
command: 'check_permissions',
// `unauthorized_client` means the process chain failed attestation —
// usually a signing-identity mismatch somewhere in helper -> sidecar
// -> desktop, not a missing OS grant.
hint: 'permissions stay unknown until this call succeeds',
},
})
return { accessibility: null, screenRecording: null }
}
}
@@ -878,13 +907,62 @@ async function listInstalledApps(): Promise<{ bundleId: string; displayName: str
}
}
/**
* Map a bundle id to its installed bundle path.
*
* Enumerating applications walks several directory trees, and the picker asks
* for one icon per visible row, so the mapping is cached briefly. The window is
* short enough that an app installed while the picker is open still appears on
* the next open, and long enough that a scroll through hundreds of rows scans
* once rather than once per row.
*/
const APP_PATH_CACHE_TTL_MS = 30_000
let appPathCache: { at: number; byBundleId: Map<string, string> } | null = null
let appPathScan: Promise<Map<string, string>> | null = null
export async function resolveInstalledAppPath(
bundleId: string,
// Injected so a test can count scans without walking the real disk.
lister: () => Promise<{ bundleId: string; path: string }[]> = listInstalledApps,
): Promise<string | null> {
const cached = appPathCache
if (cached && Date.now() - cached.at <= APP_PATH_CACHE_TTL_MS) {
return cached.byBundleId.get(bundleId) ?? null
}
// Share one scan across concurrent callers. Opening the picker fires an icon
// request per visible row at once, and a plain check-then-fill cache is still
// cold for all of them — each would launch its own enumeration of every
// application root.
if (!appPathScan) {
appPathScan = (async () => {
try {
const apps = await lister()
const byBundleId = new Map(apps.map(app => [app.bundleId, app.path]))
appPathCache = { at: Date.now(), byBundleId }
return byBundleId
} finally {
appPathScan = null
}
})()
}
return (await appPathScan).get(bundleId) ?? null
}
/** Test hook: forget the bundle-id mapping between cases. */
export function __resetInstalledAppPathCacheForTests(): void {
appPathCache = null
appPathScan = null
}
// ============================================================================
// Route handler
// ============================================================================
export async function handleComputerUseApi(
req: Request,
_url: URL,
url: URL,
segments: string[],
): Promise<Response> {
const action = segments[2]
@@ -905,6 +983,39 @@ export async function handleComputerUseApi(
return Response.json({ apps })
}
// GET /api/computer-use/app-icon?bundleId=…&size=… — the app's own icon.
//
// The parameter is a bundle id, never a path: the server resolves it against
// the installed-app enumeration, so a caller cannot name an arbitrary file
// and have it rasterised and returned.
if (action === 'app-icon' && req.method === 'GET') {
if (process.platform !== 'darwin') {
return new Response('Not found', { status: 404 })
}
const bundleId = url.searchParams.get('bundleId')?.trim()
if (!bundleId) {
return Response.json({ error: 'bundleId is required' }, { status: 400 })
}
const appPath = await resolveInstalledAppPath(bundleId)
if (!appPath) return new Response('Not found', { status: 404 })
const size = normalizeIconSize(url.searchParams.get('size'))
const png = await readAppIconPng(appPath, size)
// A bundle with no icon is an ordinary outcome, not a failure — the row
// renders its letter placeholder when this 404s.
if (!png) return new Response('Not found', { status: 404 })
return new Response(png as unknown as BodyInit, {
headers: {
'Content-Type': 'image/png',
// Icons change only when an app is reinstalled; the server keeps its
// own cache too, this just stops the picker refetching while scrolling.
'Cache-Control': 'private, max-age=3600',
},
})
}
// GET /api/computer-use/authorized-apps — current authorized app config
if (action === 'authorized-apps' && req.method === 'GET') {
const result = await loadStoredComputerUseConfigResult()
+205
View File
@@ -0,0 +1,205 @@
import { beforeEach, describe, expect, test } from 'bun:test'
import {
DEFAULT_ICON_SIZE,
MAX_ICON_SIZE,
MIN_ICON_SIZE,
__resetMacAppIconCacheForTests,
normalizeIconFileName,
normalizeIconSize,
readAppIconPng,
resolveAppIconPath,
} from './macAppIcon.js'
const APP = '/Applications/Example.app'
const RESOURCES = `${APP}/Contents/Resources`
/**
* Builds deps over a virtual bundle. Nothing here touches the filesystem or
* spawns `plutil`/`sips`, so the resolution ORDER is what these tests pin — the
* part that decides whether a row shows the app's mark or a document badge.
*/
function bundle(options: {
declaredIcon?: string | null
resourceFiles?: string[]
/** Files that "exist"; defaults to every entry in resourceFiles. */
present?: string[]
convert?: (iconPath: string, size: number) => Promise<Uint8Array>
}) {
const resourceFiles = options.resourceFiles ?? []
const present = new Set(
(options.present ?? resourceFiles).map(name => `${RESOURCES}/${name}`),
)
const converted: Array<{ iconPath: string; size: number }> = []
return {
converted,
deps: {
readIconFileName: async () => options.declaredIcon ?? null,
listResourceFiles: async () => resourceFiles,
pathExists: async (candidate: string) => present.has(candidate),
convertToPng: options.convert
?? (async (iconPath: string, size: number) => {
converted.push({ iconPath, size })
return new Uint8Array([1, 2, 3])
}),
},
}
}
beforeEach(() => {
__resetMacAppIconCacheForTests()
})
describe('normalizeIconFileName', () => {
test('appends .icns only when CFBundleIconFile omits an extension', () => {
// Both spellings ship in real bundles.
expect(normalizeIconFileName('AppIcon')).toBe('AppIcon.icns')
expect(normalizeIconFileName('AppIcon.icns')).toBe('AppIcon.icns')
expect(normalizeIconFileName(' AppIcon ')).toBe('AppIcon.icns')
expect(normalizeIconFileName('')).toBe('')
})
})
describe('normalizeIconSize', () => {
test('clamps to the renderable range and survives junk', () => {
expect(normalizeIconSize(72)).toBe(72)
expect(normalizeIconSize('72')).toBe(72)
expect(normalizeIconSize(4)).toBe(MIN_ICON_SIZE)
expect(normalizeIconSize(4096)).toBe(MAX_ICON_SIZE)
expect(normalizeIconSize('not a number')).toBe(DEFAULT_ICON_SIZE)
expect(normalizeIconSize(64.4)).toBe(64)
})
test('treats an absent size as the default, not the minimum', () => {
// `searchParams.get('size')` is null when omitted, and Number(null) is 0 —
// clamping that would serve a 16px icon to every caller that omits it.
expect(normalizeIconSize(null)).toBe(DEFAULT_ICON_SIZE)
expect(normalizeIconSize(undefined)).toBe(DEFAULT_ICON_SIZE)
expect(normalizeIconSize('')).toBe(DEFAULT_ICON_SIZE)
})
})
describe('resolveAppIconPath', () => {
test('prefers the icon the bundle declares', async () => {
const { deps } = bundle({
declaredIcon: 'AppIcon',
resourceFiles: ['AppIcon.icns', 'Other.icns'],
})
expect(await resolveAppIconPath(APP, deps)).toBe(`${RESOURCES}/AppIcon.icns`)
})
test('falls back to the sole .icns when the declared file is missing', async () => {
// Seen in the wild: Info.plist names an icon that was renamed or dropped.
const { deps } = bundle({
declaredIcon: 'Stale',
resourceFiles: ['Real.icns'],
present: ['Real.icns'],
})
expect(await resolveAppIconPath(APP, deps)).toBe(`${RESOURCES}/Real.icns`)
})
test('skips document icons when guessing', async () => {
// Picking these would put a file badge where the app's own mark belongs.
const { deps } = bundle({
declaredIcon: null,
resourceFiles: ['ProjectDocument.icns', 'document.icns', 'Brand.icns'],
})
expect(await resolveAppIconPath(APP, deps)).toBe(`${RESOURCES}/Brand.icns`)
})
test('ignores non-icns resources', async () => {
const { deps } = bundle({
declaredIcon: null,
resourceFiles: ['background.png', 'strings.plist', 'Brand.ICNS'],
})
expect(await resolveAppIconPath(APP, deps)).toBe(`${RESOURCES}/Brand.ICNS`)
})
test('returns null when the bundle ships no icon at all', async () => {
const { deps } = bundle({ declaredIcon: null, resourceFiles: ['strings.plist'] })
expect(await resolveAppIconPath(APP, deps)).toBeNull()
})
test('returns null when only document icons exist', async () => {
const { deps } = bundle({
declaredIcon: null,
resourceFiles: ['MyDocument.icns'],
})
expect(await resolveAppIconPath(APP, deps)).toBeNull()
})
})
describe('readAppIconPng', () => {
test('rasterises at the normalized size', async () => {
const { deps, converted } = bundle({
declaredIcon: 'AppIcon',
resourceFiles: ['AppIcon.icns'],
})
const png = await readAppIconPng(APP, 9999, deps)
expect(png).toEqual(new Uint8Array([1, 2, 3]))
expect(converted).toEqual([
{ iconPath: `${RESOURCES}/AppIcon.icns`, size: MAX_ICON_SIZE },
])
})
test('converts once per (bundle, size) and serves the rest from cache', async () => {
// The picker asks for one icon per visible row while scrolling; each miss
// is a subprocess.
const { deps, converted } = bundle({
declaredIcon: 'AppIcon',
resourceFiles: ['AppIcon.icns'],
})
await readAppIconPng(APP, 72, deps)
await readAppIconPng(APP, 72, deps)
expect(converted).toHaveLength(1)
// A different size is a different rendering, so it must not reuse bytes.
await readAppIconPng(APP, 128, deps)
expect(converted.map(c => c.size)).toEqual([72, 128])
})
test('returns null instead of throwing when rasterisation fails', async () => {
const { deps } = bundle({
declaredIcon: 'AppIcon',
resourceFiles: ['AppIcon.icns'],
convert: async () => {
throw new Error('sips: unsupported file format')
},
})
// A corrupt .icns must degrade to the letter tile, not break the page.
expect(await readAppIconPng(APP, 72, deps)).toBeNull()
})
test('does not cache a failure, so a later read can still succeed', async () => {
let attempt = 0
const { deps } = bundle({
declaredIcon: 'AppIcon',
resourceFiles: ['AppIcon.icns'],
convert: async () => {
attempt += 1
if (attempt === 1) throw new Error('transient')
return new Uint8Array([9])
},
})
expect(await readAppIconPng(APP, 72, deps)).toBeNull()
expect(await readAppIconPng(APP, 72, deps)).toEqual(new Uint8Array([9]))
})
test('returns null without converting when there is no icon', async () => {
const { deps, converted } = bundle({ declaredIcon: null, resourceFiles: [] })
expect(await readAppIconPng(APP, 72, deps)).toBeNull()
expect(converted).toHaveLength(0)
})
})
+180
View File
@@ -0,0 +1,180 @@
import { readdir, rm, mkdtemp, readFile, access } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { LRUCache } from 'lru-cache'
/**
* Render a macOS application's bundle icon as PNG bytes.
*
* This is the same extraction macOS itself performs for a Finder listing:
* `Info.plist` names the icon in `CFBundleIconFile`, the file lives in
* `Contents/Resources`, and `sips` rasterises the multi-resolution `.icns` to a
* single PNG. `openTargetService` does this too, but only for its fixed table of
* IDE targets — its resolver is keyed on a `TargetDefinition` (it also tries
* `<label>.icns` / `<icon>.icns`), so it cannot answer for an arbitrary
* installed app. This module takes only a bundle path.
*
* Everything is injectable because the real implementation shells out to
* `plutil` and `sips`; tests drive the resolution order without either.
*/
export type MacAppIconDeps = {
/** `CFBundleIconFile` from the bundle's Info.plist, or null when absent. */
readIconFileName?: (appPath: string) => Promise<string | null>
/** File names directly inside `Contents/Resources`. */
listResourceFiles?: (resourcesPath: string) => Promise<string[]>
pathExists?: (candidate: string) => Promise<boolean>
convertToPng?: (iconPath: string, size: number) => Promise<Uint8Array>
}
/** Icons are a few KB each; the cap bounds a machine with many applications. */
const iconCache = new LRUCache<string, Uint8Array>({ max: 512 })
export const MIN_ICON_SIZE = 16
export const MAX_ICON_SIZE = 256
export const DEFAULT_ICON_SIZE = 64
export function normalizeIconSize(raw: unknown): number {
// `searchParams.get` yields null when the caller omits `size`, and `Number`
// maps both null and '' to 0 — clamping that would silently serve a 16px
// icon instead of the default. Absent means default, not smallest.
if (raw === null || raw === undefined || raw === '') return DEFAULT_ICON_SIZE
const parsed = typeof raw === 'number' ? raw : Number(raw)
if (!Number.isFinite(parsed)) return DEFAULT_ICON_SIZE
return Math.min(MAX_ICON_SIZE, Math.max(MIN_ICON_SIZE, Math.round(parsed)))
}
/**
* `CFBundleIconFile` is allowed to omit the extension ("AppIcon" means
* "AppIcon.icns"), and some bundles store it with one. Both spellings appear in
* the wild, so normalise before probing the filesystem.
*/
export function normalizeIconFileName(iconFile: string): string {
const trimmed = iconFile.trim()
if (!trimmed) return ''
return path.extname(trimmed) ? trimmed : `${trimmed}.icns`
}
async function defaultPathExists(candidate: string): Promise<boolean> {
try {
await access(candidate)
return true
} catch {
return false
}
}
async function defaultReadIconFileName(appPath: string): Promise<string | null> {
const plistPath = path.join(appPath, 'Contents', 'Info.plist')
try {
const proc = Bun.spawn(
['/usr/bin/plutil', '-extract', 'CFBundleIconFile', 'raw', '-o', '-', plistPath],
{ stdout: 'pipe', stderr: 'ignore' },
)
const stdout = await new Response(proc.stdout).text()
if (await proc.exited !== 0) return null
const value = stdout.trim()
return value.length > 0 ? value : null
} catch {
return null
}
}
async function defaultListResourceFiles(resourcesPath: string): Promise<string[]> {
try {
return await readdir(resourcesPath)
} catch {
return []
}
}
async function defaultConvertToPng(iconPath: string, size: number): Promise<Uint8Array> {
const tmpRoot = await mkdtemp(path.join(tmpdir(), 'cc-haha-cu-app-icon-'))
const outputPath = path.join(tmpRoot, 'icon.png')
try {
const proc = Bun.spawn(
[
'/usr/bin/sips',
'-z', String(size), String(size),
'-s', 'format', 'png',
iconPath,
'--out', outputPath,
],
{ stdout: 'ignore', stderr: 'ignore' },
)
if (await proc.exited !== 0) {
throw new Error(`sips failed for ${iconPath}`)
}
return new Uint8Array(await readFile(outputPath))
} finally {
await rm(tmpRoot, { recursive: true, force: true })
}
}
/**
* Locate the bundle's icon file. Returns null rather than throwing when a
* bundle simply has no icon — the caller renders a letter placeholder then.
*/
export async function resolveAppIconPath(
appPath: string,
deps: MacAppIconDeps = {},
): Promise<string | null> {
const readIconFileName = deps.readIconFileName ?? defaultReadIconFileName
const listResourceFiles = deps.listResourceFiles ?? defaultListResourceFiles
const pathExists = deps.pathExists ?? defaultPathExists
const resourcesPath = path.join(appPath, 'Contents', 'Resources')
const declared = await readIconFileName(appPath)
if (declared) {
const candidate = path.join(resourcesPath, normalizeIconFileName(declared))
if (await pathExists(candidate)) return candidate
}
// Bundles that declare no icon (or declare a missing one) usually still ship
// exactly one .icns. Document-type icons are excluded: picking one would show
// a file badge where the app's own mark belongs.
const resourceFiles = await listResourceFiles(resourcesPath)
const fallback = resourceFiles
.filter(name => name.toLowerCase().endsWith('.icns'))
.find(name => !/document/i.test(name))
if (!fallback) return null
const fallbackPath = path.join(resourcesPath, fallback)
return await pathExists(fallbackPath) ? fallbackPath : null
}
/**
* PNG bytes for an application's icon, or null when the bundle has none.
* Results are cached per (bundle path, size) — rasterising is a subprocess, and
* the picker asks for hundreds of icons while the user scrolls.
*/
export async function readAppIconPng(
appPath: string,
size: number,
deps: MacAppIconDeps = {},
): Promise<Uint8Array | null> {
const normalizedSize = normalizeIconSize(size)
const cacheKey = `${appPath}:${normalizedSize}`
const cached = iconCache.get(cacheKey)
if (cached) return cached
const iconPath = await resolveAppIconPath(appPath, deps)
if (!iconPath) return null
const convertToPng = deps.convertToPng ?? defaultConvertToPng
try {
const png = await convertToPng(iconPath, normalizedSize)
iconCache.set(cacheKey, png)
return png
} catch {
// A malformed or unreadable .icns is not an error worth surfacing: the row
// falls back to its letter placeholder exactly as if there were no icon.
return null
}
}
/** Test hook: drop cached icons so a test cannot observe another test's bytes. */
export function __resetMacAppIconCacheForTests(): void {
iconCache.clear()
}