From 6aa6e79b95093adc1e89fdff3b8855cae7ce3010 Mon Sep 17 00:00:00 2001 From: gugli4ifenix-design Date: Thu, 9 Apr 2026 19:46:30 +0300 Subject: [PATCH 1/5] fix: Bounty: Deeplinks support + Raycast Extension --- packages/utils/src/deeplinks.ts | 137 ++++++++++++++++++++++++++++++++ 1 file changed, 137 insertions(+) create mode 100644 packages/utils/src/deeplinks.ts diff --git a/packages/utils/src/deeplinks.ts b/packages/utils/src/deeplinks.ts new file mode 100644 index 0000000000..8e1fa95bcc --- /dev/null +++ b/packages/utils/src/deeplinks.ts @@ -0,0 +1,137 @@ +export type DeeplinkAction = + | 'record' + | 'stop' + | 'pause' + | 'resume' + | 'switch-microphone' + | 'switch-camera'; + +export interface DeeplinkParams { + action: DeeplinkAction; + deviceId?: string; + [key: string]: string | undefined; +} + +export const DEEPLINK_PREFIX = 'cap://'; + +// Validate action against known types +function isValidAction(action: string): action is DeeplinkAction { + const validActions: DeeplinkAction[] = [ + 'record', + 'stop', + 'pause', + 'resume', + 'switch-microphone', + 'switch-camera', + ]; + return validActions.includes(action as DeeplinkAction); +} + +export function parseDeeplink(url: string): DeeplinkParams | null { + try { + if (!url || typeof url !== 'string') { + return null; + } + + if (!url.startsWith(DEEPLINK_PREFIX)) { + return null; + } + + const urlPart = url.slice(DEEPLINK_PREFIX.length).trim(); + + if (!urlPart) { + return null; + } + + const [pathSegment, queryString] = urlPart.split('?'); + const segments = pathSegment.split('/').filter(Boolean); + const action = segments[0]; + + if (!action || !isValidAction(action)) { + return null; + } + + const queryParams: Record = {}; + + if (queryString) { + try { + new URLSearchParams(queryString).forEach((value, key) => { + if (value) { + queryParams[key] = value; + } + }); + } catch { + // Invalid query string, continue with parsed params + } + } + + return { + action, + ...queryParams, + }; + } catch { + return null; + } +} + +export function createDeeplink( + action: DeeplinkAction, + params?: Record, +): string { + let url = `${DEEPLINK_PREFIX}${action}`; + + // Filter out undefined/empty values + const validParams = Object.entries(params || {}) + .filter(([, value]) => value !== undefined && value !== '') + .reduce((acc, [key, value]) => { + acc[key] = value as string; + return acc; + }, {} as Record); + + if (Object.keys(validParams).length > 0) { + const searchParams = new URLSearchParams(validParams); + url += `?${searchParams.toString()}`; + } + + return url; +} + +// Builder pattern for fluent API +export class DeeplinkBuilder { + private action: DeeplinkAction; + private params: Record = {}; + + constructor(action: DeeplinkAction) { + this.action = action; + } + + withParam(key: string, value: string): this { + if (key && value) { + this.params[key] = value; + } + return this; + } + + withDeviceId(deviceId: string): this { + return this.withParam('deviceId', deviceId); + } + + build(): string { + return createDeeplink(this.action, this.params); + } +} + +export const DeeplinkActions = { + startRecording: (): string => createDeeplink('record'), + stopRecording: (): string => createDeeplink('stop'), + pauseRecording: (): string => createDeeplink('pause'), + resumeRecording: (): string => createDeeplink('resume'), + switchMicrophone: (deviceId: string): string => { + if (!deviceId) throw new Error('deviceId is required for switchMicrophone'); + return createDeeplink('switch-microphone', { deviceId }); + }, + switchCamera: (deviceId: string): string => { + if (!deviceId) throw new Error('deviceId is required for switchCamera'); + return createDeeplink('switch-camera', { deviceId }); + }, +}; \ No newline at end of file From 78554f419084488c6201e10df6aede341e6e9e24 Mon Sep 17 00:00:00 2001 From: gugli4ifenix-design Date: Thu, 9 Apr 2026 19:46:32 +0300 Subject: [PATCH 2/5] fix: Bounty: Deeplinks support + Raycast Extension --- .../utils/src/__tests__/deeplinks.test.ts | 185 ++++++++++++++++++ 1 file changed, 185 insertions(+) create mode 100644 packages/utils/src/__tests__/deeplinks.test.ts diff --git a/packages/utils/src/__tests__/deeplinks.test.ts b/packages/utils/src/__tests__/deeplinks.test.ts new file mode 100644 index 0000000000..a98584a493 --- /dev/null +++ b/packages/utils/src/__tests__/deeplinks.test.ts @@ -0,0 +1,185 @@ +import { describe, it, expect } from 'vitest'; +import { + parseDeeplink, + createDeeplink, + DeeplinkBuilder, + DeeplinkActions, + DEEPLINK_PREFIX, +} from '../deeplinks'; + +describe('parseDeeplink', () => { + describe('valid deeplinks', () => { + it('should parse simple action deeplink', () => { + const result = parseDeeplink('cap://record'); + expect(result).toEqual({ action: 'record' }); + }); + + it('should parse deeplink with query parameters', () => { + const result = parseDeeplink('cap://switch-microphone?deviceId=mic-123'); + expect(result).toEqual({ + action: 'switch-microphone', + deviceId: 'mic-123', + }); + }); + + it('should parse deeplink with multiple parameters', () => { + const result = parseDeeplink('cap://switch-camera?deviceId=cam-456&format=1080p'); + expect(result).toEqual({ + action: 'switch-camera', + deviceId: 'cam-456', + format: '1080p', + }); + }); + + it('should handle URL-encoded parameters', () => { + const result = parseDeeplink('cap://record?name=My%20Recording'); + expect(result).toEqual({ + action: 'record', + name: 'My Recording', + }); + }); + + it('should ignore empty query values', () => { + const result = parseDeeplink('cap://record?empty='); + expect(result).toEqual({ action: 'record' }); + }); + + it('should trim whitespace from URL', () => { + const result = parseDeeplink(' cap://pause '); + expect(result).toEqual({ action: 'pause' }); + }); + }); + + describe('invalid deeplinks', () => { + it('should return null for wrong prefix', () => { + expect(parseDeeplink('http://example.com')).toBeNull(); + }); + + it('should return null for empty string', () => { + expect(parseDeeplink('')).toBeNull(); + }); + + it('should return null for null/undefined', () => { + expect(parseDeeplink(null as unknown as string)).toBeNull(); + expect(parseDeeplink(undefined as unknown as string)).toBeNull(); + }); + + it('should return null for invalid action', () => { + expect(parseDeeplink('cap://invalid-action')).toBeNull(); + }); + + it('should return null for malformed URL', () => { + expect(parseDeeplink('cap://')).toBeNull(); + }); + + it('should handle malformed query string gracefully', () => { + // Invalid percent encoding should not throw + const result = parseDeeplink('cap://record?name=%ZZ'); + expect(result?.action).toBe('record'); + }); + + it('should return null for non-string input', () => { + expect(parseDeeplink(123 as unknown as string)).toBeNull(); + }); + }); +}); + +describe('createDeeplink', () => { + it('should create simple deeplink', () => { + expect(createDeeplink('record')).toBe('cap://record'); + }); + + it('should create deeplink with parameters', () => { + expect(createDeeplink('switch-microphone', { deviceId: 'mic-123' })) + .toBe('cap://switch-microphone?deviceId=mic-123'); + }); + + it('should filter out undefined parameters', () => { + const result = createDeeplink('switch-camera', { + deviceId: 'cam-456', + unused: undefined, + }); + expect(result).toBe('cap://switch-camera?deviceId=cam-456'); + }); + + it('should filter out empty string parameters', () => { + const result = createDeeplink('record', { name: '' }); + expect(result).toBe('cap://record'); + }); + + it('should URL-encode special characters', () => { + const result = createDeeplink('record', { name: 'My Recording' }); + expect(result).toBe('cap://record?name=My+Recording'); + }); + + it('should handle no parameters', () => { + expect(createDeeplink('stop')).toBe('cap://stop'); + }); +}); + +describe('DeeplinkBuilder', () => { + it('should build simple deeplink', () => { + const result = new DeeplinkBuilder('record').build(); + expect(result).toBe('cap://record'); + }); + + it('should build deeplink with parameters', () => { + const result = new DeeplinkBuilder('switch-microphone') + .withDeviceId('mic-789') + .build(); + expect(result).toBe('cap://switch-microphone?deviceId=mic-789'); + }); + + it('should chain multiple parameters', () => { + const result = new DeeplinkBuilder('record') + .withParam('name', 'Test') + .withParam('format', 'mp4') + .build(); + expect(result).toContain('cap://record?'); + expect(result).toContain('name=Test'); + expect(result).toContain('format=mp4'); + }); + + it('should ignore empty parameters', () => { + const result = new DeeplinkBuilder('record') + .withParam('empty', '') + .build(); + expect(result).toBe('cap://record'); + }); +}); + +describe('DeeplinkActions', () => { + it('should create startRecording deeplink', () => { + expect(DeeplinkActions.startRecording()).toBe('cap://record'); + }); + + it('should create stopRecording deeplink', () => { + expect(DeeplinkActions.stopRecording()).toBe('cap://stop'); + }); + + it('should create pauseRecording deeplink', () => { + expect(DeeplinkActions.pauseRecording()).toBe('cap://pause'); + }); + + it('should create resumeRecording deeplink', () => { + expect(DeeplinkActions.resumeRecording()).toBe('cap://resume'); + }); + + it('should create switchMicrophone deeplink with deviceId', () => { + expect(DeeplinkActions.switchMicrophone('mic-123')) + .toBe('cap://switch-microphone?deviceId=mic-123'); + }); + + it('should throw error for switchMicrophone without deviceId', () => { + expect(() => DeeplinkActions.switchMicrophone('')).toThrow(); + }); + + it('should create switchCamera deeplink with deviceId', () => { + expect(DeeplinkActions.switchCamera('cam-456')) + .toBe('cap://switch-camera?deviceId=cam-456'); + }); + + it('should throw error for switchCamera without deviceId', () => { + expect(() => DeeplinkActions.switchCamera('')).toThrow(); + }); +}); \ No newline at end of file From 2abdd323ae8471f00967d1337870f1c80e92ef25 Mon Sep 17 00:00:00 2001 From: gugli4ifenix-design Date: Thu, 9 Apr 2026 19:46:32 +0300 Subject: [PATCH 3/5] fix: Bounty: Deeplinks support + Raycast Extension --- .../deeplink-handler.ts | 107 ++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 packages/web-api-contract-effect/deeplink-handler.ts diff --git a/packages/web-api-contract-effect/deeplink-handler.ts b/packages/web-api-contract-effect/deeplink-handler.ts new file mode 100644 index 0000000000..9fc986cfbe --- /dev/null +++ b/packages/web-api-contract-effect/deeplink-handler.ts @@ -0,0 +1,107 @@ +import { DeeplinkParams, parseDeeplink, DeeplinkAction } from '@cap/utils'; + +export interface DeeplinkHandlerContext { + onStartRecording?: () => void | Promise; + onStopRecording?: () => void | Promise; + onPauseRecording?: () => void | Promise; + onResumeRecording?: () => void | Promise; + onSwitchMicrophone?: (deviceId: string) => void | Promise; + onSwitchCamera?: (deviceId: string) => void | Promise; + onError?: (error: Error) => void; +} + +export class DeeplinkHandlerError extends Error { + constructor( + public readonly action: DeeplinkAction | string | null, + message: string, + ) { + super(message); + this.name = 'DeeplinkHandlerError'; + } +} + +export class DeeplinkHandler { + constructor(private context: DeeplinkHandlerContext) { + if (!context) { + throw new Error('DeeplinkHandler context is required'); + } + } + + async handle(url: string): Promise { + try { + if (!url || typeof url !== 'string') { + throw new DeeplinkHandlerError(null, 'Invalid URL provided'); + } + + const params = parseDeeplink(url); + + if (!params) { + throw new DeeplinkHandlerError(null, `Unable to parse deeplink: ${url}`); + } + + return await this.handleAction(params); + } catch (error) { + this.context.onError?.( + error instanceof Error ? error : new Error(String(error)), + ); + return false; + } + } + + private async handleAction(params: DeeplinkParams): Promise { + const { action, deviceId } = params; + + try { + switch (action) { + case 'record': + await this.context.onStartRecording?.(); + return true; + + case 'stop': + await this.context.onStopRecording?.(); + return true; + + case 'pause': + await this.context.onPauseRecording?.(); + return true; + + case 'resume': + await this.context.onResumeRecording?.(); + return true; + + case 'switch-microphone': { + if (!deviceId) { + throw new DeeplinkHandlerError( + action, + 'deviceId is required for switch-microphone action', + ); + } + await this.context.onSwitchMicrophone?.(deviceId); + return true; + } + + case 'switch-camera': { + if (!deviceId) { + throw new DeeplinkHandlerError( + action, + 'deviceId is required for switch-camera action', + ); + } + await this.context.onSwitchCamera?.(deviceId); + return true; + } + + default: + return false; + } + } catch (error) { + if (error instanceof DeeplinkHandlerError) { + throw error; + } + throw new DeeplinkHandlerError( + action, + error instanceof Error ? error.message : 'Unknown error', + ); + } + } +} \ No newline at end of file From 4d9206673205219e4264f1a2cf0d975c946a21bc Mon Sep 17 00:00:00 2001 From: gugli4ifenix-design Date: Thu, 9 Apr 2026 19:46:33 +0300 Subject: [PATCH 4/5] fix: Bounty: Deeplinks support + Raycast Extension --- .../__tests__/deeplink-handler.test.ts | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 packages/web-api-contract-effect/__tests__/deeplink-handler.test.ts diff --git a/packages/web-api-contract-effect/__tests__/deeplink-handler.test.ts b/packages/web-api-contract-effect/__tests__/deeplink-handler.test.ts new file mode 100644 index 0000000000..ce8b5055b7 --- /dev/null +++ b/packages/web-api-contract-effect/__tests__/deeplink-handler.test.ts @@ -0,0 +1,41 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { DeeplinkHandler, DeeplinkHandlerError } from '../deeplink-handler'; + +describe('DeeplinkHandler', () => { + describe('initialization', () => { + it('should throw if context is not provided', () => { + expect(() => new DeeplinkHandler(null as any)).toThrow(); + }); + + it('should accept empty context object', () => { + const handler = new DeeplinkHandler({}); + expect(handler).toBeDefined(); + }); + }); + + describe('handle valid actions', () => { + it('should handle record action', async () => { + const onStartRecording = vi.fn(); + const handler = new DeeplinkHandler({ onStartRecording }); + + const result = await handler.handle('cap://record'); + + expect(result).toBe(true); + expect(onStartRecording).toHaveBeenCalledOnce(); + }); + + it('should handle stop action', async () => { + const onStopRecording = vi.fn(); + const handler = new DeeplinkHandler({ onStopRecording }); + + const result = await handler.handle('cap://stop'); + + expect(result).toBe(true); + expect(onStopRecording).toHaveBeenCalledOnce(); + }); + + it('should handle pause action', async () => { + const onPauseRecording = vi.fn(); + const handler = new DeeplinkHandler({ onPauseRecording }); + + const result = await handler.handle('cap:// \ No newline at end of file From 3ba952167a781e35a48b2ab4db03ad2944a440b4 Mon Sep 17 00:00:00 2001 From: gugli4ifenix-design Date: Sun, 12 Apr 2026 11:35:32 +0000 Subject: [PATCH 5/5] fix: address review feedback - trim before parse, complete tests, remove comments, add export --- packages/utils/src/deeplinks.ts | 13 ++- packages/utils/src/index.ts | 1 + .../__tests__/deeplink-handler.test.ts | 80 ++++++++++++++++++- 3 files changed, 86 insertions(+), 8 deletions(-) diff --git a/packages/utils/src/deeplinks.ts b/packages/utils/src/deeplinks.ts index 8e1fa95bcc..2f868d5dbb 100644 --- a/packages/utils/src/deeplinks.ts +++ b/packages/utils/src/deeplinks.ts @@ -14,7 +14,6 @@ export interface DeeplinkParams { export const DEEPLINK_PREFIX = 'cap://'; -// Validate action against known types function isValidAction(action: string): action is DeeplinkAction { const validActions: DeeplinkAction[] = [ 'record', @@ -33,11 +32,13 @@ export function parseDeeplink(url: string): DeeplinkParams | null { return null; } - if (!url.startsWith(DEEPLINK_PREFIX)) { + const trimmedUrl = url.trim(); + + if (!trimmedUrl.startsWith(DEEPLINK_PREFIX)) { return null; } - const urlPart = url.slice(DEEPLINK_PREFIX.length).trim(); + const urlPart = trimmedUrl.slice(DEEPLINK_PREFIX.length); if (!urlPart) { return null; @@ -61,7 +62,7 @@ export function parseDeeplink(url: string): DeeplinkParams | null { } }); } catch { - // Invalid query string, continue with parsed params + } } @@ -80,7 +81,6 @@ export function createDeeplink( ): string { let url = `${DEEPLINK_PREFIX}${action}`; - // Filter out undefined/empty values const validParams = Object.entries(params || {}) .filter(([, value]) => value !== undefined && value !== '') .reduce((acc, [key, value]) => { @@ -96,7 +96,6 @@ export function createDeeplink( return url; } -// Builder pattern for fluent API export class DeeplinkBuilder { private action: DeeplinkAction; private params: Record = {}; @@ -134,4 +133,4 @@ export const DeeplinkActions = { if (!deviceId) throw new Error('deviceId is required for switchCamera'); return createDeeplink('switch-camera', { deviceId }); }, -}; \ No newline at end of file +}; diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index dd239b6816..c4b0529ece 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -3,3 +3,4 @@ export * from "./helpers.ts"; export * from "./lib/dub.ts"; export * from "./lib/stripe/stripe.ts"; export * from "./types/database.ts"; +export * from "./deeplinks.ts"; diff --git a/packages/web-api-contract-effect/__tests__/deeplink-handler.test.ts b/packages/web-api-contract-effect/__tests__/deeplink-handler.test.ts index ce8b5055b7..1e4975f2f2 100644 --- a/packages/web-api-contract-effect/__tests__/deeplink-handler.test.ts +++ b/packages/web-api-contract-effect/__tests__/deeplink-handler.test.ts @@ -38,4 +38,82 @@ describe('DeeplinkHandler', () => { const onPauseRecording = vi.fn(); const handler = new DeeplinkHandler({ onPauseRecording }); - const result = await handler.handle('cap:// \ No newline at end of file + const result = await handler.handle('cap://pause'); + + expect(result).toBe(true); + expect(onPauseRecording).toHaveBeenCalledOnce(); + }); + + it('should handle resume action', async () => { + const onResumeRecording = vi.fn(); + const handler = new DeeplinkHandler({ onResumeRecording }); + + const result = await handler.handle('cap://resume'); + + expect(result).toBe(true); + expect(onResumeRecording).toHaveBeenCalledOnce(); + }); + + it('should handle switch-microphone with deviceId', async () => { + const onSwitchMicrophone = vi.fn(); + const handler = new DeeplinkHandler({ onSwitchMicrophone }); + + const result = await handler.handle('cap://switch-microphone?deviceId=mic-1'); + + expect(result).toBe(true); + expect(onSwitchMicrophone).toHaveBeenCalledWith('mic-1'); + }); + + it('should handle switch-camera with deviceId', async () => { + const onSwitchCamera = vi.fn(); + const handler = new DeeplinkHandler({ onSwitchCamera }); + + const result = await handler.handle('cap://switch-camera?deviceId=cam-1'); + + expect(result).toBe(true); + expect(onSwitchCamera).toHaveBeenCalledWith('cam-1'); + }); + }); + + describe('error handling', () => { + it('should return false for invalid URL', async () => { + const onError = vi.fn(); + const handler = new DeeplinkHandler({ onError }); + + const result = await handler.handle('invalid-url'); + + expect(result).toBe(false); + expect(onError).toHaveBeenCalled(); + }); + + it('should return false for empty string', async () => { + const onError = vi.fn(); + const handler = new DeeplinkHandler({ onError }); + + const result = await handler.handle(''); + + expect(result).toBe(false); + expect(onError).toHaveBeenCalled(); + }); + + it('should throw for switch-microphone without deviceId', async () => { + const onError = vi.fn(); + const handler = new DeeplinkHandler({ onError }); + + const result = await handler.handle('cap://switch-microphone'); + + expect(result).toBe(false); + expect(onError).toHaveBeenCalled(); + }); + + it('should return false for unknown action', async () => { + const onError = vi.fn(); + const handler = new DeeplinkHandler({ onError }); + + const result = await handler.handle('cap://unknown-action'); + + expect(result).toBe(false); + expect(onError).toHaveBeenCalled(); + }); + }); +});