diff --git a/src/integrations/raycast-detect.ts b/src/integrations/raycast-detect.ts index 7ae70edf461..e0ef3191f6d 100644 --- a/src/integrations/raycast-detect.ts +++ b/src/integrations/raycast-detect.ts @@ -44,10 +44,19 @@ export interface RaycastDetectDeps { */ const RAYCAST_DEFAULTS_DOMAIN = "com.raycast.macos.v1"; const RAYCAST_SUBSCRIPTION_KEY = "subscriptions_active"; +const DEFAULTS_PATH = "/usr/bin/defaults"; +const DEFAULTS_TIMEOUT_MS = 2_000; -export function realRaycastDetectDeps(): RaycastDetectDeps { +interface RealRaycastDetectRuntime { + platform?: string; + spawnSync?: typeof Bun.spawnSync; +} + +export function realRaycastDetectDeps(runtime: RealRaycastDetectRuntime = {}): RaycastDetectDeps { + const platform = runtime.platform ?? process.platform; + const spawnSync = runtime.spawnSync ?? Bun.spawnSync; return { - platform: process.platform, + platform, homedir: homedir(), env: process.env, exists: path => { @@ -59,9 +68,15 @@ export function realRaycastDetectDeps(): RaycastDetectDeps { }, readDefault: (domain, key) => { // `defaults` is macOS-only; elsewhere the plan is simply unknown. - if (process.platform !== "darwin") return null; + if (platform !== "darwin") return null; try { - const result = Bun.spawnSync(["defaults", "read", domain, key], { stdout: "pipe", stderr: "pipe" }); + const result = spawnSync([DEFAULTS_PATH, "read", domain, key], { + stdout: "pipe", + stderr: "pipe", + timeout: DEFAULTS_TIMEOUT_MS, + }); + // A timed-out or signal-killed probe reports exitCode === null; that + // and any non-zero exit mean the preference was not read. if (result.exitCode !== 0) return null; return result.stdout.toString().trim(); } catch { diff --git a/tests/clients/raycast-detect.test.ts b/tests/clients/raycast-detect.test.ts index 4e4268b29b3..afdc3dbeb4f 100644 --- a/tests/clients/raycast-detect.test.ts +++ b/tests/clients/raycast-detect.test.ts @@ -1,5 +1,9 @@ import { describe, expect, test } from "bun:test"; -import { detectRaycast, type RaycastDetectDeps } from "../../src/integrations/raycast-detect"; +import { + detectRaycast, + realRaycastDetectDeps, + type RaycastDetectDeps, +} from "../../src/integrations/raycast-detect"; /** * Stubbed deps only. The real detector spawns `defaults` and reads the @@ -29,6 +33,21 @@ function fakeDeps( } describe("detectRaycast", () => { + test("the macOS preference probe uses the system binary with a bounded runtime", () => { + let invocation: { command: string[]; timeout?: number } | undefined; + const spawnSync = ((command: string[], options: { timeout?: number }) => { + invocation = { command, timeout: options.timeout }; + return { exitCode: 0, stdout: Buffer.from("1") }; + }) as typeof Bun.spawnSync; + + const deps = realRaycastDetectDeps({ platform: "darwin", spawnSync }); + expect(deps.readDefault("com.raycast.macos.v1", "subscriptions_active")).toBe("1"); + expect(invocation).toEqual({ + command: ["/usr/bin/defaults", "read", "com.raycast.macos.v1", "subscriptions_active"], + timeout: 2_000, + }); + }); + test("darwin: a Pro subscription, the app bundle and the revealed ai folder", () => { const deps = fakeDeps("darwin", ["/Applications/Raycast.app", "/home/u/.config/raycast/ai"], { defaultValue: "1" }); expect(detectRaycast(deps)).toEqual({ @@ -55,6 +74,14 @@ describe("detectRaycast", () => { expect(detectRaycast(fakeDeps("darwin", [], { defaultValue: "" })).plan).toBe("unknown"); }); + test("darwin: a timed-out or killed defaults probe is unknown, not a false positive", () => { + // Bun reports exitCode === null when the 2s timeout kills the process. + const spawnSync = (() => ({ exitCode: null, stdout: Buffer.from("") })) as typeof Bun.spawnSync; + const deps = realRaycastDetectDeps({ platform: "darwin", spawnSync }); + expect(deps.readDefault("com.raycast.macos.v1", "subscriptions_active")).toBeNull(); + expect(detectRaycast(deps).plan).toBe("unknown"); + }); + test("win32: LOCALAPPDATA\\Programs\\Raycast is the install path and the plan is unknown", () => { const local = "C:\\Users\\u\\AppData\\Local"; const deps = fakeDeps("win32", [`${local}\\Programs\\Raycast`, "C:\\Users\\u\\.config\\raycast\\ai"], {