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
This commit is contained in:
@@ -57,9 +57,9 @@ export function useVoice() {
|
|||||||
export function useVoiceOptional(): VoiceContextValue | null {
|
export function useVoiceOptional(): VoiceContextValue | null {
|
||||||
const runtime = useContext(VoiceRuntimeContext);
|
const runtime = useContext(VoiceRuntimeContext);
|
||||||
const snapshot = useSyncExternalStore(
|
const snapshot = useSyncExternalStore(
|
||||||
runtime ? runtime.subscribe : noopSubscribe,
|
runtime ? runtime.subscribe.bind(runtime) : noopSubscribe,
|
||||||
runtime ? runtime.getSnapshot : getEmptySnapshot,
|
runtime ? runtime.getSnapshot.bind(runtime) : getEmptySnapshot,
|
||||||
runtime ? runtime.getSnapshot : getEmptySnapshot,
|
runtime ? runtime.getSnapshot.bind(runtime) : getEmptySnapshot,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!runtime) {
|
if (!runtime) {
|
||||||
@@ -68,10 +68,10 @@ export function useVoiceOptional(): VoiceContextValue | null {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
...snapshot,
|
...snapshot,
|
||||||
startVoice: runtime.startVoice,
|
startVoice: runtime.startVoice.bind(runtime),
|
||||||
stopVoice: runtime.stopVoice,
|
stopVoice: runtime.stopVoice.bind(runtime),
|
||||||
isVoiceModeForAgent: runtime.isVoiceModeForAgent,
|
isVoiceModeForAgent: runtime.isVoiceModeForAgent.bind(runtime),
|
||||||
toggleMute: runtime.toggleMute,
|
toggleMute: runtime.toggleMute.bind(runtime),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,9 +86,9 @@ export function useVoiceTelemetry() {
|
|||||||
export function useVoiceTelemetryOptional(): VoiceRuntimeTelemetrySnapshot | null {
|
export function useVoiceTelemetryOptional(): VoiceRuntimeTelemetrySnapshot | null {
|
||||||
const runtime = useContext(VoiceRuntimeContext);
|
const runtime = useContext(VoiceRuntimeContext);
|
||||||
const snapshot = useSyncExternalStore(
|
const snapshot = useSyncExternalStore(
|
||||||
runtime ? runtime.subscribeTelemetry : noopSubscribe,
|
runtime ? runtime.subscribeTelemetry.bind(runtime) : noopSubscribe,
|
||||||
runtime ? runtime.getTelemetrySnapshot : getEmptyTelemetry,
|
runtime ? runtime.getTelemetrySnapshot.bind(runtime) : getEmptyTelemetry,
|
||||||
runtime ? runtime.getTelemetrySnapshot : getEmptyTelemetry,
|
runtime ? runtime.getTelemetrySnapshot.bind(runtime) : getEmptyTelemetry,
|
||||||
);
|
);
|
||||||
|
|
||||||
return runtime ? snapshot : null;
|
return runtime ? snapshot : null;
|
||||||
|
|||||||
@@ -72,33 +72,15 @@ async function getActualRecordingUri(createdAt: Date): Promise<string | null> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 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<Blob> {
|
async function uriToBlob(uri: string): Promise<Blob> {
|
||||||
// Use expo-file-system to read the file
|
|
||||||
const file = new File(uri);
|
const file = new File(uri);
|
||||||
const base64 = await file.base64();
|
const base64 = await file.base64();
|
||||||
const size = file.size;
|
const binaryString = atob(base64);
|
||||||
|
const bytes = new Uint8Array(binaryString.length);
|
||||||
// React Native doesn't support creating Blobs from binary data
|
for (let i = 0; i < binaryString.length; i++) {
|
||||||
// Create a Blob-like object that has the methods we need
|
bytes[i] = binaryString.charCodeAt(i);
|
||||||
const blobLike = {
|
}
|
||||||
type: "audio/m4a",
|
return new Blob([bytes], { 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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -163,19 +145,17 @@ export function useAudioRecorder(config?: AudioCaptureConfig) {
|
|||||||
// Monitor audio levels if metering is enabled
|
// Monitor audio levels if metering is enabled
|
||||||
// Use configRef to access the latest callback without recreating the effect
|
// Use configRef to access the latest callback without recreating the effect
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const currentConfig = configRef.current;
|
if (!configRef.current?.onAudioLevel || !recorderState.isRecording) {
|
||||||
if (!currentConfig?.onAudioLevel || !recorderState.isRecording) return;
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
const metering = recorderState.metering;
|
const metering = recorderState.metering;
|
||||||
if (metering !== undefined && metering !== null) {
|
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));
|
const normalized = Math.max(0, Math.min(1, (metering + 40) / 40));
|
||||||
configRef.current?.onAudioLevel?.(normalized);
|
configRef.current?.onAudioLevel?.(normalized);
|
||||||
}
|
}
|
||||||
}, 100); // Check every 100ms
|
}, 100);
|
||||||
|
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [recorderState.metering, recorderState.isRecording]);
|
}, [recorderState.metering, recorderState.isRecording]);
|
||||||
@@ -217,20 +197,18 @@ export function useAudioRecorder(config?: AudioCaptureConfig) {
|
|||||||
await recorder.prepareToRecordAsync();
|
await recorder.prepareToRecordAsync();
|
||||||
attemptGuardRef.current.assertCurrent(attemptId);
|
attemptGuardRef.current.assertCurrent(attemptId);
|
||||||
|
|
||||||
await recorder.record();
|
recorder.record();
|
||||||
attemptGuardRef.current.assertCurrent(attemptId);
|
attemptGuardRef.current.assertCurrent(attemptId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setRecordingStartTime(null);
|
setRecordingStartTime(null);
|
||||||
if (error instanceof AttemptCancelledError) {
|
if (error instanceof AttemptCancelledError) {
|
||||||
return;
|
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);
|
console.error("[AudioRecorder] Failed to start recording:", error);
|
||||||
}
|
}
|
||||||
throw new Error(
|
throw new Error(`Failed to start audio recording: ${message}`, { cause: error });
|
||||||
`Failed to start audio recording: ${(error as { message?: string })?.message ?? String(error)}`,
|
|
||||||
{ cause: error },
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -296,10 +274,8 @@ export function useAudioRecorder(config?: AudioCaptureConfig) {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
setRecordingStartTime(null);
|
setRecordingStartTime(null);
|
||||||
console.error("[AudioRecorder] Failed to stop recording:", error);
|
console.error("[AudioRecorder] Failed to stop recording:", error);
|
||||||
throw new Error(
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
`Failed to stop audio recording: ${(error as { message?: string })?.message ?? String(error)}`,
|
throw new Error(`Failed to stop audio recording: ${message}`, { cause: error });
|
||||||
{ cause: error },
|
|
||||||
);
|
|
||||||
} finally {
|
} finally {
|
||||||
startStopMutexRef.current = null;
|
startStopMutexRef.current = null;
|
||||||
}
|
}
|
||||||
@@ -312,7 +288,7 @@ export function useAudioRecorder(config?: AudioCaptureConfig) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const isRecording = useCallback(() => {
|
const isRecording = useCallback(() => {
|
||||||
return Boolean(recorderState.isRecording);
|
return recorderState.isRecording;
|
||||||
}, [recorderState.isRecording]);
|
}, [recorderState.isRecording]);
|
||||||
|
|
||||||
// Return stable object using useMemo
|
// Return stable object using useMemo
|
||||||
|
|||||||
@@ -41,6 +41,14 @@ export function buildWorkspaceTabPersistenceKey(input: {
|
|||||||
return `${serverId}:${workspaceId}`;
|
return `${serverId}:${workspaceId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toObjectRecord(value: unknown): Record<string, unknown> | undefined {
|
||||||
|
return isPlainRecord(value) ? value : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeTabOrder(list: unknown): string[] {
|
function normalizeTabOrder(list: unknown): string[] {
|
||||||
if (!Array.isArray(list)) {
|
if (!Array.isArray(list)) {
|
||||||
return [];
|
return [];
|
||||||
@@ -94,17 +102,6 @@ function buildNextTabsForEnsure(args: {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface LegacyPersistedWorkspaceTabsState {
|
|
||||||
version?: number;
|
|
||||||
state?: unknown;
|
|
||||||
openTabsByWorkspace?: Record<string, WorkspaceTab[]>;
|
|
||||||
uiTabsByWorkspace?: Record<string, WorkspaceTab[]>;
|
|
||||||
focusedTabIdByWorkspace?: Record<string, string>;
|
|
||||||
tabOrderByWorkspace?: Record<string, string[]>;
|
|
||||||
lastFocusedTabByWorkspace?: Record<string, unknown>;
|
|
||||||
tabOrderLegacyByWorkspace?: Record<string, string[]>;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MigrationRawSources {
|
interface MigrationRawSources {
|
||||||
rawUiTabsByWorkspace: Record<string, unknown>;
|
rawUiTabsByWorkspace: Record<string, unknown>;
|
||||||
rawFocused: Record<string, unknown>;
|
rawFocused: Record<string, unknown>;
|
||||||
@@ -113,42 +110,69 @@ interface MigrationRawSources {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function extractMigrationRawSources(persistedState: unknown): MigrationRawSources {
|
function extractMigrationRawSources(persistedState: unknown): MigrationRawSources {
|
||||||
const legacy = persistedState as LegacyPersistedWorkspaceTabsState | undefined;
|
const top = toObjectRecord(persistedState) ?? {};
|
||||||
const rawState = ((legacy as { state?: Record<string, unknown> } | undefined)?.state ??
|
const rawState = toObjectRecord(top.state) ?? top;
|
||||||
legacy ??
|
|
||||||
{}) as Record<string, unknown>;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
rawUiTabsByWorkspace: (rawState.uiTabsByWorkspace ??
|
rawUiTabsByWorkspace:
|
||||||
rawState.openTabsByWorkspace ??
|
toObjectRecord(
|
||||||
legacy?.uiTabsByWorkspace ??
|
rawState.uiTabsByWorkspace ??
|
||||||
legacy?.openTabsByWorkspace ??
|
rawState.openTabsByWorkspace ??
|
||||||
{}) as Record<string, unknown>,
|
top.uiTabsByWorkspace ??
|
||||||
rawFocused: (rawState.focusedTabIdByWorkspace ??
|
top.openTabsByWorkspace,
|
||||||
legacy?.focusedTabIdByWorkspace ??
|
) ?? {},
|
||||||
rawState.lastFocusedTabByWorkspace ??
|
rawFocused:
|
||||||
{}) as Record<string, unknown>,
|
toObjectRecord(
|
||||||
rawOrder: (rawState.tabOrderByWorkspace ??
|
rawState.focusedTabIdByWorkspace ??
|
||||||
legacy?.tabOrderByWorkspace ??
|
rawState.lastFocusedTabByWorkspace ??
|
||||||
rawState.tabOrderByWorkspace ??
|
top.focusedTabIdByWorkspace,
|
||||||
{}) as Record<string, unknown>,
|
) ?? {},
|
||||||
legacyOrder: (rawState.tabOrderByWorkspace ??
|
rawOrder: toObjectRecord(rawState.tabOrderByWorkspace ?? top.tabOrderByWorkspace) ?? {},
|
||||||
rawState.tabOrderLegacyByWorkspace ??
|
legacyOrder:
|
||||||
{}) as Record<string, unknown>,
|
toObjectRecord(
|
||||||
|
rawState.tabOrderByWorkspace ??
|
||||||
|
rawState.tabOrderLegacyByWorkspace ??
|
||||||
|
top.tabOrderLegacyByWorkspace,
|
||||||
|
) ?? {},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function coerceWorkspaceTabTarget(raw: Record<string, unknown>): 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 {
|
function migrateSingleTab(rawTab: unknown): WorkspaceTab | null {
|
||||||
if (!rawTab || typeof rawTab !== "object") {
|
const record = toObjectRecord(rawTab);
|
||||||
|
if (!record) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const normalizedTarget = normalizeWorkspaceTabTarget((rawTab as WorkspaceTab).target);
|
const rawTarget = toObjectRecord(record.target);
|
||||||
|
const normalizedTarget = rawTarget ? coerceWorkspaceTabTarget(rawTarget) : null;
|
||||||
if (!normalizedTarget) {
|
if (!normalizedTarget) {
|
||||||
return null;
|
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 tabId = rawTabId ?? buildDeterministicWorkspaceTabId(normalizedTarget);
|
||||||
const rawCreatedAt = (rawTab as WorkspaceTab).createdAt;
|
const rawCreatedAt = record.createdAt;
|
||||||
return {
|
return {
|
||||||
tabId,
|
tabId,
|
||||||
target: normalizedTarget,
|
target: normalizedTarget,
|
||||||
|
|||||||
@@ -230,16 +230,24 @@ export function connectionFromListen(listen: string): HostConnection | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toObjectRecord(value: unknown): Record<string, unknown> | undefined {
|
||||||
|
return isPlainRecord(value) ? value : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeStoredConnection(connection: unknown): HostConnection | null {
|
function normalizeStoredConnection(connection: unknown): HostConnection | null {
|
||||||
if (!connection || typeof connection !== "object") {
|
const record = toObjectRecord(connection);
|
||||||
|
if (!record) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const record = connection as Record<string, unknown>;
|
|
||||||
const type = typeof record.type === "string" ? record.type : null;
|
const type = typeof record.type === "string" ? record.type : null;
|
||||||
if (type === "directTcp") {
|
if (type === "directTcp") {
|
||||||
try {
|
try {
|
||||||
const endpoint = normalizeLoopbackToLocalhost(
|
const endpoint = normalizeLoopbackToLocalhost(
|
||||||
normalizeHostPort(String(record.endpoint ?? "")),
|
normalizeHostPort(typeof record.endpoint === "string" ? record.endpoint : ""),
|
||||||
);
|
);
|
||||||
return DirectTcpHostConnectionSchema.parse({
|
return DirectTcpHostConnectionSchema.parse({
|
||||||
id: `direct:${endpoint}`,
|
id: `direct:${endpoint}`,
|
||||||
@@ -253,17 +261,21 @@ function normalizeStoredConnection(connection: unknown): HostConnection | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (type === "directSocket") {
|
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;
|
return path ? { id: `socket:${path}`, type: "directSocket", path } : null;
|
||||||
}
|
}
|
||||||
if (type === "directPipe") {
|
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;
|
return path ? { id: `pipe:${path}`, type: "directPipe", path } : null;
|
||||||
}
|
}
|
||||||
if (type === "relay") {
|
if (type === "relay") {
|
||||||
try {
|
try {
|
||||||
const relayEndpoint = normalizeHostPort(String(record.relayEndpoint ?? ""));
|
const relayEndpoint = normalizeHostPort(
|
||||||
const daemonPublicKeyB64 = String(record.daemonPublicKeyB64 ?? "").trim();
|
typeof record.relayEndpoint === "string" ? record.relayEndpoint : "",
|
||||||
|
);
|
||||||
|
const daemonPublicKeyB64 = (
|
||||||
|
typeof record.daemonPublicKeyB64 === "string" ? record.daemonPublicKeyB64 : ""
|
||||||
|
).trim();
|
||||||
if (!daemonPublicKeyB64) return null;
|
if (!daemonPublicKeyB64) return null;
|
||||||
return {
|
return {
|
||||||
id: `relay:${relayEndpoint}`,
|
id: `relay:${relayEndpoint}`,
|
||||||
@@ -280,10 +292,10 @@ function normalizeStoredConnection(connection: unknown): HostConnection | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeStoredHostProfile(entry: unknown): HostProfile | null {
|
export function normalizeStoredHostProfile(entry: unknown): HostProfile | null {
|
||||||
if (!entry || typeof entry !== "object") {
|
const record = toObjectRecord(entry);
|
||||||
|
if (!record) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const record = entry as Record<string, unknown>;
|
|
||||||
const serverId = typeof record.serverId === "string" ? record.serverId.trim() : "";
|
const serverId = typeof record.serverId === "string" ? record.serverId.trim() : "";
|
||||||
if (!serverId) {
|
if (!serverId) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
Reference in New Issue
Block a user