From 660b4d3cecbbd63b1d3df69ea651196815a31f2b Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 4 May 2026 21:50:19 +0800 Subject: [PATCH] refactor(app): fix type-aware lint errors in voice, tabs store, host-connection, audio recorder (#706) Clear all type-aware lint errors in four app state/runtime files: - voice-context.tsx: bind runtime methods before passing to useSyncExternalStore and object spreads to satisfy the unbound-method rule - workspace-tabs-store.ts: replace unsafe `as` casts with isPlainRecord type predicate + toObjectRecord; add coerceWorkspaceTabTarget to bridge unknown storage data to WorkspaceTabTarget without assertions - host-connection.ts: same isPlainRecord/toObjectRecord pattern; replace String(record.x ?? "") calls with typeof guards to fix no-base-to-string - use-audio-recorder.native.ts: construct real Blob instead of casting an object literal; restructure useEffect for consistent-return; drop await on void recorder.record(); use instanceof Error for message extraction; remove redundant Boolean() wrapping --- packages/app/src/contexts/voice-context.tsx | 20 ++-- .../src/hooks/use-audio-recorder.native.ts | 58 ++++-------- .../app/src/stores/workspace-tabs-store.ts | 94 ++++++++++++------- packages/app/src/types/host-connection.ts | 30 ++++-- 4 files changed, 107 insertions(+), 95 deletions(-) diff --git a/packages/app/src/contexts/voice-context.tsx b/packages/app/src/contexts/voice-context.tsx index 6c9d7af98..f421ea801 100644 --- a/packages/app/src/contexts/voice-context.tsx +++ b/packages/app/src/contexts/voice-context.tsx @@ -57,9 +57,9 @@ export function useVoice() { export function useVoiceOptional(): VoiceContextValue | null { const runtime = useContext(VoiceRuntimeContext); const snapshot = useSyncExternalStore( - runtime ? runtime.subscribe : noopSubscribe, - runtime ? runtime.getSnapshot : getEmptySnapshot, - runtime ? runtime.getSnapshot : getEmptySnapshot, + runtime ? runtime.subscribe.bind(runtime) : noopSubscribe, + runtime ? runtime.getSnapshot.bind(runtime) : getEmptySnapshot, + runtime ? runtime.getSnapshot.bind(runtime) : getEmptySnapshot, ); if (!runtime) { @@ -68,10 +68,10 @@ export function useVoiceOptional(): VoiceContextValue | null { return { ...snapshot, - startVoice: runtime.startVoice, - stopVoice: runtime.stopVoice, - isVoiceModeForAgent: runtime.isVoiceModeForAgent, - toggleMute: runtime.toggleMute, + startVoice: runtime.startVoice.bind(runtime), + stopVoice: runtime.stopVoice.bind(runtime), + isVoiceModeForAgent: runtime.isVoiceModeForAgent.bind(runtime), + toggleMute: runtime.toggleMute.bind(runtime), }; } @@ -86,9 +86,9 @@ export function useVoiceTelemetry() { export function useVoiceTelemetryOptional(): VoiceRuntimeTelemetrySnapshot | null { const runtime = useContext(VoiceRuntimeContext); const snapshot = useSyncExternalStore( - runtime ? runtime.subscribeTelemetry : noopSubscribe, - runtime ? runtime.getTelemetrySnapshot : getEmptyTelemetry, - runtime ? runtime.getTelemetrySnapshot : getEmptyTelemetry, + runtime ? runtime.subscribeTelemetry.bind(runtime) : noopSubscribe, + runtime ? runtime.getTelemetrySnapshot.bind(runtime) : getEmptyTelemetry, + runtime ? runtime.getTelemetrySnapshot.bind(runtime) : getEmptyTelemetry, ); return runtime ? snapshot : null; diff --git a/packages/app/src/hooks/use-audio-recorder.native.ts b/packages/app/src/hooks/use-audio-recorder.native.ts index 14952f4ad..12e473d58 100644 --- a/packages/app/src/hooks/use-audio-recorder.native.ts +++ b/packages/app/src/hooks/use-audio-recorder.native.ts @@ -72,33 +72,15 @@ async function getActualRecordingUri(createdAt: Date): Promise { } } -/** - * Convert audio file URI to Blob for daemon transport - * Returns a Blob-like object that works in React Native - */ async function uriToBlob(uri: string): Promise { - // Use expo-file-system to read the file const file = new File(uri); const base64 = await file.base64(); - const size = file.size; - - // React Native doesn't support creating Blobs from binary data - // Create a Blob-like object that has the methods we need - const blobLike = { - type: "audio/m4a", - size: size, - arrayBuffer: async () => { - // Convert base64 to ArrayBuffer - const binaryString = atob(base64); - const bytes = new Uint8Array(binaryString.length); - for (let i = 0; i < binaryString.length; i++) { - bytes[i] = binaryString.charCodeAt(i); - } - return bytes.buffer; - }, - } as Blob; - - return blobLike; + const binaryString = atob(base64); + const bytes = new Uint8Array(binaryString.length); + for (let i = 0; i < binaryString.length; i++) { + bytes[i] = binaryString.charCodeAt(i); + } + return new Blob([bytes], { type: "audio/m4a" }); } /** @@ -163,19 +145,17 @@ export function useAudioRecorder(config?: AudioCaptureConfig) { // Monitor audio levels if metering is enabled // Use configRef to access the latest callback without recreating the effect useEffect(() => { - const currentConfig = configRef.current; - if (!currentConfig?.onAudioLevel || !recorderState.isRecording) return; + if (!configRef.current?.onAudioLevel || !recorderState.isRecording) { + return undefined; + } const interval = setInterval(() => { const metering = recorderState.metering; if (metering !== undefined && metering !== null) { - // Normalize metering value (typically ranges from -160 to 0 dB) - // Convert to 0-1 range where 0 is silence and 1 is loud - // We'll use -40 dB as the threshold for "loud" const normalized = Math.max(0, Math.min(1, (metering + 40) / 40)); configRef.current?.onAudioLevel?.(normalized); } - }, 100); // Check every 100ms + }, 100); return () => clearInterval(interval); }, [recorderState.metering, recorderState.isRecording]); @@ -217,20 +197,18 @@ export function useAudioRecorder(config?: AudioCaptureConfig) { await recorder.prepareToRecordAsync(); attemptGuardRef.current.assertCurrent(attemptId); - await recorder.record(); + recorder.record(); attemptGuardRef.current.assertCurrent(attemptId); } catch (error) { setRecordingStartTime(null); if (error instanceof AttemptCancelledError) { return; } - if ((error as { message?: string })?.message !== "Recording cancelled") { + const message = error instanceof Error ? error.message : String(error); + if (message !== "Recording cancelled") { console.error("[AudioRecorder] Failed to start recording:", error); } - throw new Error( - `Failed to start audio recording: ${(error as { message?: string })?.message ?? String(error)}`, - { cause: error }, - ); + throw new Error(`Failed to start audio recording: ${message}`, { cause: error }); } }, []); @@ -296,10 +274,8 @@ export function useAudioRecorder(config?: AudioCaptureConfig) { } catch (error) { setRecordingStartTime(null); console.error("[AudioRecorder] Failed to stop recording:", error); - throw new Error( - `Failed to stop audio recording: ${(error as { message?: string })?.message ?? String(error)}`, - { cause: error }, - ); + const message = error instanceof Error ? error.message : String(error); + throw new Error(`Failed to stop audio recording: ${message}`, { cause: error }); } finally { startStopMutexRef.current = null; } @@ -312,7 +288,7 @@ export function useAudioRecorder(config?: AudioCaptureConfig) { }, []); const isRecording = useCallback(() => { - return Boolean(recorderState.isRecording); + return recorderState.isRecording; }, [recorderState.isRecording]); // Return stable object using useMemo diff --git a/packages/app/src/stores/workspace-tabs-store.ts b/packages/app/src/stores/workspace-tabs-store.ts index 594993afe..cde9e31f9 100644 --- a/packages/app/src/stores/workspace-tabs-store.ts +++ b/packages/app/src/stores/workspace-tabs-store.ts @@ -41,6 +41,14 @@ export function buildWorkspaceTabPersistenceKey(input: { return `${serverId}:${workspaceId}`; } +function isPlainRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function toObjectRecord(value: unknown): Record | undefined { + return isPlainRecord(value) ? value : undefined; +} + function normalizeTabOrder(list: unknown): string[] { if (!Array.isArray(list)) { return []; @@ -94,17 +102,6 @@ function buildNextTabsForEnsure(args: { ); } -interface LegacyPersistedWorkspaceTabsState { - version?: number; - state?: unknown; - openTabsByWorkspace?: Record; - uiTabsByWorkspace?: Record; - focusedTabIdByWorkspace?: Record; - tabOrderByWorkspace?: Record; - lastFocusedTabByWorkspace?: Record; - tabOrderLegacyByWorkspace?: Record; -} - interface MigrationRawSources { rawUiTabsByWorkspace: Record; rawFocused: Record; @@ -113,42 +110,69 @@ interface MigrationRawSources { } function extractMigrationRawSources(persistedState: unknown): MigrationRawSources { - const legacy = persistedState as LegacyPersistedWorkspaceTabsState | undefined; - const rawState = ((legacy as { state?: Record } | undefined)?.state ?? - legacy ?? - {}) as Record; + const top = toObjectRecord(persistedState) ?? {}; + const rawState = toObjectRecord(top.state) ?? top; return { - rawUiTabsByWorkspace: (rawState.uiTabsByWorkspace ?? - rawState.openTabsByWorkspace ?? - legacy?.uiTabsByWorkspace ?? - legacy?.openTabsByWorkspace ?? - {}) as Record, - rawFocused: (rawState.focusedTabIdByWorkspace ?? - legacy?.focusedTabIdByWorkspace ?? - rawState.lastFocusedTabByWorkspace ?? - {}) as Record, - rawOrder: (rawState.tabOrderByWorkspace ?? - legacy?.tabOrderByWorkspace ?? - rawState.tabOrderByWorkspace ?? - {}) as Record, - legacyOrder: (rawState.tabOrderByWorkspace ?? - rawState.tabOrderLegacyByWorkspace ?? - {}) as Record, + rawUiTabsByWorkspace: + toObjectRecord( + rawState.uiTabsByWorkspace ?? + rawState.openTabsByWorkspace ?? + top.uiTabsByWorkspace ?? + top.openTabsByWorkspace, + ) ?? {}, + rawFocused: + toObjectRecord( + rawState.focusedTabIdByWorkspace ?? + rawState.lastFocusedTabByWorkspace ?? + top.focusedTabIdByWorkspace, + ) ?? {}, + rawOrder: toObjectRecord(rawState.tabOrderByWorkspace ?? top.tabOrderByWorkspace) ?? {}, + legacyOrder: + toObjectRecord( + rawState.tabOrderByWorkspace ?? + rawState.tabOrderLegacyByWorkspace ?? + top.tabOrderLegacyByWorkspace, + ) ?? {}, }; } +function coerceWorkspaceTabTarget(raw: Record): WorkspaceTabTarget | null { + const kind = typeof raw.kind === "string" ? raw.kind : null; + if (kind === "draft" && typeof raw.draftId === "string") { + return normalizeWorkspaceTabTarget({ kind: "draft", draftId: raw.draftId }); + } + if (kind === "agent" && typeof raw.agentId === "string") { + return normalizeWorkspaceTabTarget({ kind: "agent", agentId: raw.agentId }); + } + if (kind === "terminal" && typeof raw.terminalId === "string") { + return normalizeWorkspaceTabTarget({ kind: "terminal", terminalId: raw.terminalId }); + } + if (kind === "browser" && typeof raw.browserId === "string") { + return normalizeWorkspaceTabTarget({ kind: "browser", browserId: raw.browserId }); + } + if (kind === "file" && typeof raw.path === "string") { + return normalizeWorkspaceTabTarget({ kind: "file", path: raw.path }); + } + if (kind === "setup" && typeof raw.workspaceId === "string") { + return normalizeWorkspaceTabTarget({ kind: "setup", workspaceId: raw.workspaceId }); + } + return null; +} + function migrateSingleTab(rawTab: unknown): WorkspaceTab | null { - if (!rawTab || typeof rawTab !== "object") { + const record = toObjectRecord(rawTab); + if (!record) { return null; } - const normalizedTarget = normalizeWorkspaceTabTarget((rawTab as WorkspaceTab).target); + const rawTarget = toObjectRecord(record.target); + const normalizedTarget = rawTarget ? coerceWorkspaceTabTarget(rawTarget) : null; if (!normalizedTarget) { return null; } - const rawTabId = trimNonEmpty((rawTab as WorkspaceTab).tabId); + const rawTabId = trimNonEmpty(typeof record.tabId === "string" ? record.tabId : null); const tabId = rawTabId ?? buildDeterministicWorkspaceTabId(normalizedTarget); - const rawCreatedAt = (rawTab as WorkspaceTab).createdAt; + const rawCreatedAt = record.createdAt; return { tabId, target: normalizedTarget, diff --git a/packages/app/src/types/host-connection.ts b/packages/app/src/types/host-connection.ts index 4c6f72b75..9984bfefe 100644 --- a/packages/app/src/types/host-connection.ts +++ b/packages/app/src/types/host-connection.ts @@ -230,16 +230,24 @@ export function connectionFromListen(listen: string): HostConnection | null { } } +function isPlainRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function toObjectRecord(value: unknown): Record | undefined { + return isPlainRecord(value) ? value : undefined; +} + function normalizeStoredConnection(connection: unknown): HostConnection | null { - if (!connection || typeof connection !== "object") { + const record = toObjectRecord(connection); + if (!record) { return null; } - const record = connection as Record; const type = typeof record.type === "string" ? record.type : null; if (type === "directTcp") { try { const endpoint = normalizeLoopbackToLocalhost( - normalizeHostPort(String(record.endpoint ?? "")), + normalizeHostPort(typeof record.endpoint === "string" ? record.endpoint : ""), ); return DirectTcpHostConnectionSchema.parse({ id: `direct:${endpoint}`, @@ -253,17 +261,21 @@ function normalizeStoredConnection(connection: unknown): HostConnection | null { } } if (type === "directSocket") { - const path = String(record.path ?? "").trim(); + const path = (typeof record.path === "string" ? record.path : "").trim(); return path ? { id: `socket:${path}`, type: "directSocket", path } : null; } if (type === "directPipe") { - const path = String(record.path ?? "").trim(); + const path = (typeof record.path === "string" ? record.path : "").trim(); return path ? { id: `pipe:${path}`, type: "directPipe", path } : null; } if (type === "relay") { try { - const relayEndpoint = normalizeHostPort(String(record.relayEndpoint ?? "")); - const daemonPublicKeyB64 = String(record.daemonPublicKeyB64 ?? "").trim(); + const relayEndpoint = normalizeHostPort( + typeof record.relayEndpoint === "string" ? record.relayEndpoint : "", + ); + const daemonPublicKeyB64 = ( + typeof record.daemonPublicKeyB64 === "string" ? record.daemonPublicKeyB64 : "" + ).trim(); if (!daemonPublicKeyB64) return null; return { id: `relay:${relayEndpoint}`, @@ -280,10 +292,10 @@ function normalizeStoredConnection(connection: unknown): HostConnection | null { } export function normalizeStoredHostProfile(entry: unknown): HostProfile | null { - if (!entry || typeof entry !== "object") { + const record = toObjectRecord(entry); + if (!record) { return null; } - const record = entry as Record; const serverId = typeof record.serverId === "string" ? record.serverId.trim() : ""; if (!serverId) { return null;