From eec961352b3019e74fadfa1f4de8384f6ae057a4 Mon Sep 17 00:00:00 2001 From: unraid Date: Wed, 22 Apr 2026 22:38:10 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=20napi=20=E5=8C=85?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E8=A6=86=E7=9B=96=E4=B8=8E=20stub=20?= =?UTF-8?q?=E6=94=B9=E8=BF=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- .../src/__tests__/index.test.ts | 112 ++++++++++++++++++ packages/modifiers-napi/src/index.ts | 15 ++- .../src/__tests__/index.test.ts | 50 ++++++++ packages/url-handler-napi/src/index.ts | 47 +++++++- 4 files changed, 215 insertions(+), 9 deletions(-) create mode 100644 packages/modifiers-napi/src/__tests__/index.test.ts create mode 100644 packages/url-handler-napi/src/__tests__/index.test.ts diff --git a/packages/modifiers-napi/src/__tests__/index.test.ts b/packages/modifiers-napi/src/__tests__/index.test.ts new file mode 100644 index 000000000..a17e698c8 --- /dev/null +++ b/packages/modifiers-napi/src/__tests__/index.test.ts @@ -0,0 +1,112 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test' + +let ffiShouldThrow = false +let nativeFlags = 0 +let dlopenCalls = 0 + +mock.module('bun:ffi', () => ({ + FFIType: { + i32: 0, + u64: 0, + }, + dlopen: () => { + dlopenCalls++ + if (ffiShouldThrow) { + throw new Error('ffi load failed') + } + return { + symbols: { + CGEventSourceFlagsState: () => nativeFlags, + }, + } + }, +})) + +const originalPlatform = process.platform + +async function loadModule() { + return import(`../index.ts?case=${Math.random()}`) +} + +beforeEach(() => { + ffiShouldThrow = false + nativeFlags = 0 + dlopenCalls = 0 + Object.defineProperty(process, 'platform', { + value: originalPlatform, + configurable: true, + }) +}) + +afterEach(() => { + Object.defineProperty(process, 'platform', { + value: originalPlatform, + configurable: true, + }) +}) + +describe('modifiers-napi', () => { + test('returns false for non-darwin platforms', async () => { + Object.defineProperty(process, 'platform', { + value: 'win32', + configurable: true, + }) + const mod = await loadModule() + + await mod.prewarm() + expect(dlopenCalls).toBe(0) + expect(mod.isModifierPressed('shift')).toBe(false) + expect(mod.isModifierPressed('command')).toBe(false) + }) + + test('prewarm is idempotent on darwin', async () => { + Object.defineProperty(process, 'platform', { + value: 'darwin', + configurable: true, + }) + const mod = await loadModule() + + await mod.prewarm() + await mod.prewarm() + + expect(dlopenCalls).toBe(1) + }) + + test('returns false when ffi loading fails on darwin', async () => { + Object.defineProperty(process, 'platform', { + value: 'darwin', + configurable: true, + }) + ffiShouldThrow = true + const mod = await loadModule() + + await mod.prewarm() + expect(mod.isModifierPressed('shift')).toBe(false) + }) + + test('returns false for unknown modifier names on darwin', async () => { + Object.defineProperty(process, 'platform', { + value: 'darwin', + configurable: true, + }) + nativeFlags = 0x20000 + const mod = await loadModule() + + await mod.prewarm() + expect(mod.isModifierPressed('unknown')).toBe(false) + }) + + test('uses native flag bits for known modifiers on darwin', async () => { + Object.defineProperty(process, 'platform', { + value: 'darwin', + configurable: true, + }) + nativeFlags = 0x20000 | 0x40000 + const mod = await loadModule() + + await mod.prewarm() + expect(mod.isModifierPressed('shift')).toBe(true) + expect(mod.isModifierPressed('control')).toBe(true) + expect(mod.isModifierPressed('option')).toBe(false) + }) +}) diff --git a/packages/modifiers-napi/src/index.ts b/packages/modifiers-napi/src/index.ts index a5cba592c..1f2f5026b 100644 --- a/packages/modifiers-napi/src/index.ts +++ b/packages/modifiers-napi/src/index.ts @@ -14,14 +14,16 @@ const modifierFlags: Record = { const kCGEventSourceStateCombinedSessionState = 0; let cgEventSourceFlagsState: ((stateID: number) => number) | null = null; +let ffiLoadAttempted = false; -function loadFFI(): void { - if (cgEventSourceFlagsState !== null || process.platform !== "darwin") { +async function loadFFI(): Promise { + if (ffiLoadAttempted || process.platform !== "darwin") { return; } + ffiLoadAttempted = true; try { - const ffi = require("bun:ffi") as typeof import("bun:ffi"); + const ffi = await import("bun:ffi"); const lib = ffi.dlopen( `/System/Library/Frameworks/Carbon.framework/Carbon`, { @@ -35,13 +37,12 @@ function loadFFI(): void { return Number(lib.symbols.CGEventSourceFlagsState(stateID)); }; } catch { - // If loading fails, keep the function null so isModifierPressed returns false cgEventSourceFlagsState = null; } } -export function prewarm(): void { - loadFFI(); +export async function prewarm(): Promise { + await loadFFI(); } export function isModifierPressed(modifier: string): boolean { @@ -49,8 +50,6 @@ export function isModifierPressed(modifier: string): boolean { return false; } - loadFFI(); - if (cgEventSourceFlagsState === null) { return false; } diff --git a/packages/url-handler-napi/src/__tests__/index.test.ts b/packages/url-handler-napi/src/__tests__/index.test.ts new file mode 100644 index 000000000..b062106ad --- /dev/null +++ b/packages/url-handler-napi/src/__tests__/index.test.ts @@ -0,0 +1,50 @@ +import { afterEach, describe, expect, test } from 'bun:test' +import { waitForUrlEvent } from '../index' + +const originalEnv = { + CLAUDE_CODE_URL_EVENT: process.env.CLAUDE_CODE_URL_EVENT, + CLAUDE_CODE_DEEP_LINK_URL: process.env.CLAUDE_CODE_DEEP_LINK_URL, + CLAUDE_CODE_URL: process.env.CLAUDE_CODE_URL, +} +const originalArgv = process.argv.slice() + +afterEach(() => { + for (const [key, value] of Object.entries(originalEnv)) { + if (value === undefined) { + delete process.env[key] + } else { + process.env[key] = value + } + } + process.argv = originalArgv.slice() +}) + +describe('waitForUrlEvent', () => { + test('resolves to null without a timeout', async () => { + await expect(waitForUrlEvent()).resolves.toBeNull() + }) + + test('resolves to null with an explicit timeout', async () => { + await expect(waitForUrlEvent(1)).resolves.toBeNull() + }) + + test('returns a Claude URL from environment variables', async () => { + process.env.CLAUDE_CODE_URL_EVENT = 'claude-cli://prompt?q=hello' + + await expect(waitForUrlEvent()).resolves.toBe( + 'claude-cli://prompt?q=hello', + ) + }) + + test('returns a Claude URL from argv', async () => { + process.argv = [...originalArgv, 'claude://prompt?q=hello'] + + await expect(waitForUrlEvent()).resolves.toBe('claude://prompt?q=hello') + }) + + test('rejects URLs exceeding the maximum length', async () => { + process.env.CLAUDE_CODE_URL_EVENT = `claude-cli://${'x'.repeat(2048)}` + + await expect(waitForUrlEvent()).resolves.toBeNull() + }) +}) diff --git a/packages/url-handler-napi/src/index.ts b/packages/url-handler-napi/src/index.ts index 0874abeff..643aee576 100644 --- a/packages/url-handler-napi/src/index.ts +++ b/packages/url-handler-napi/src/index.ts @@ -1,3 +1,48 @@ +const MAX_URL_LENGTH = 2048 + +/** + * Check for a pending URL event from environment variables or CLI arguments. + * + * This is a synchronous snapshot check, not an event listener. The optional + * timeout parameter is retained for API compatibility but has no practical + * effect since process.env and process.argv do not change at runtime. + * Callers that need to wait for an OS-level deep link activation should use + * an IPC channel or platform-specific event listener instead. + */ export async function waitForUrlEvent(timeoutMs?: number): Promise { - return null + return findUrlEvent() +} + +/** + * Checks three env var sources (set by the OS URL scheme handler or installer) + * and then CLI arguments for a claude:// deep link URL. + * + * Priority order: + * 1. CLAUDE_CODE_URL_EVENT — set by the OS URL scheme handler on activation + * 2. CLAUDE_CODE_DEEP_LINK_URL — set by the desktop app launcher + * 3. CLAUDE_CODE_URL — legacy / manual override + * 4. CLI arguments — e.g. `claude claude://...` + */ +function findUrlEvent(): string | null { + for (const key of [ + 'CLAUDE_CODE_URL_EVENT', + 'CLAUDE_CODE_DEEP_LINK_URL', + 'CLAUDE_CODE_URL', + ]) { + const value = process.env[key] + if (isClaudeUrl(value)) { + return value + } + } + + const arg = process.argv.find(isClaudeUrl) + return arg ?? null +} + +function isClaudeUrl(value: unknown): value is string { + return ( + typeof value === 'string' && + value.length <= MAX_URL_LENGTH && + (value.startsWith('claude-cli://') || value.startsWith('claude://')) + ) }