mirror of
https://github.com/NanmiCoder/claude-code-haha.git
synced 2026-10-10 11:53:10 +08:00
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:
@@ -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
|
||||
},
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user