From 776e61d1ece3c8d8718cf63ffeebc4041935f133 Mon Sep 17 00:00:00 2001 From: Ryan Vogel Date: Tue, 31 Mar 2026 13:58:57 -0400 Subject: [PATCH] update to build proc --- bun.lock | 3 + packages/mobile-voice/AGENTS.md | 40 +++ packages/mobile-voice/app.json | 16 +- packages/mobile-voice/package.json | 1 + packages/mobile-voice/src/app/index.tsx | 335 +++++++++++++++--- .../src/hooks/use-mdns-discovery.ts | 281 +++++++++++++++ .../mobile-voice/src/hooks/use-monitoring.ts | 75 +++- .../mobile-voice/src/types/css-modules.d.ts | 4 + .../src/types/react-native-zeroconf.d.ts | 23 ++ packages/opencode/src/server/push-relay.ts | 18 - 10 files changed, 707 insertions(+), 89 deletions(-) create mode 100644 packages/mobile-voice/src/hooks/use-mdns-discovery.ts create mode 100644 packages/mobile-voice/src/types/css-modules.d.ts create mode 100644 packages/mobile-voice/src/types/react-native-zeroconf.d.ts diff --git a/bun.lock b/bun.lock index 33df62f49e..ad047015c7 100644 --- a/bun.lock +++ b/bun.lock @@ -353,6 +353,7 @@ "react-native-screens": "~4.23.0", "react-native-web": "~0.21.0", "react-native-worklets": "0.7.2", + "react-native-zeroconf": "0.14.0", "whisper.rn": "0.5.5", }, "devDependencies": { @@ -4764,6 +4765,8 @@ "react-native-worklets": ["react-native-worklets@0.7.2", "", { "dependencies": { "@babel/plugin-transform-arrow-functions": "7.27.1", "@babel/plugin-transform-class-properties": "7.27.1", "@babel/plugin-transform-classes": "7.28.4", "@babel/plugin-transform-nullish-coalescing-operator": "7.27.1", "@babel/plugin-transform-optional-chaining": "7.27.1", "@babel/plugin-transform-shorthand-properties": "7.27.1", "@babel/plugin-transform-template-literals": "7.27.1", "@babel/plugin-transform-unicode-regex": "7.27.1", "@babel/preset-typescript": "7.27.1", "convert-source-map": "2.0.0", "semver": "7.7.3" }, "peerDependencies": { "@babel/core": "*", "react": "*", "react-native": "*" } }, "sha512-DuLu1kMV/Uyl9pQHp3hehAlThoLw7Yk2FwRTpzASOmI+cd4845FWn3m2bk9MnjUw8FBRIyhwLqYm2AJaXDXsog=="], + "react-native-zeroconf": ["react-native-zeroconf@0.14.0", "", { "dependencies": { "events": "^3.0.0" }, "peerDependencies": { "react-native": ">=0.60" } }, "sha512-TqjORroaVZrBYLzk3YtviQy8lUl/iiMacknxixRYlmGaqgsv4LJXIYafpnvPa3y2SC4/qu2mvF8D1/VTTxylgQ=="], + "react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="], "react-remove-scroll": ["react-remove-scroll@2.5.5", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.3", "react-style-singleton": "^2.2.1", "tslib": "^2.1.0", "use-callback-ref": "^1.3.0", "use-sidecar": "^1.1.2" }, "peerDependencies": { "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0", "react": "^16.8.0 || ^17.0.0 || ^18.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-ImKhrzJJsyXJfBZ4bzu8Bwpka14c/fQt0k+cyFp/PBhTfyDnU5hjOtM4AG/0AMyy8oKzOTR0lDgJIM7pYXI0kw=="], diff --git a/packages/mobile-voice/AGENTS.md b/packages/mobile-voice/AGENTS.md index a239731503..53ddc77028 100644 --- a/packages/mobile-voice/AGENTS.md +++ b/packages/mobile-voice/AGENTS.md @@ -123,6 +123,46 @@ Run all commands from `packages/mobile-voice`. - Rebuild the dev client after native module additions or changes. - For optional native capability usage, prefer runtime fallback paths instead of hard crashes. +## Expo Native Config (EAS) + +- Treat `packages/mobile-voice/app.json` as the source of truth for iOS native metadata in EAS cloud builds. +- Do not rely on manual edits in `ios/mobilevoice/Info.plist`, entitlements files, or `PrivacyInfo.xcprivacy`; for this package they are generated outputs. +- Keep generated native folders untracked in git (`/ios`, `/android`) to avoid mixed CNG/bare behavior during EAS builds. +- Put App Store compliance and permission metadata in app config using these fields: + - `expo.ios.infoPlist` for Info.plist keys (usage strings, ATS, Bonjour, and related keys). + - `expo.ios.config.usesNonExemptEncryption` for export-compliance encryption declaration. + - `expo.ios.entitlements` for iOS entitlements. + - `expo.ios.privacyManifests` for Apple privacy manifest declarations. +- Keep `app.json` entries explicit and review-friendly: + - Permission descriptions should be complete, product-specific sentences. + - Compliance keys should be set intentionally rather than relying on implicit defaults. + - Preserve existing JSON style in this package (concise arrays and stable key grouping). +- After native config changes, verify resolved config with `bunx expo config --type prebuild --json` and check the resulting `ios` fields. + +Example shape: + +```json +{ + "expo": { + "ios": { + "infoPlist": { + "NSCameraUsageDescription": "...", + "NSMicrophoneUsageDescription": "..." + }, + "config": { + "usesNonExemptEncryption": false + }, + "entitlements": { + "com.apple.developer.kernel.extended-virtual-addressing": true + }, + "privacyManifests": { + "NSPrivacyAccessedAPITypes": [] + } + } + } +} +``` + ## Common Pitfalls - Black screen + "No script URL provided" often means a stale dev client binary. diff --git a/packages/mobile-voice/app.json b/packages/mobile-voice/app.json index f426749c68..3dcbf0232b 100644 --- a/packages/mobile-voice/app.json +++ b/packages/mobile-voice/app.json @@ -10,11 +10,17 @@ "ios": { "icon": "./assets/images/icon.png", "bundleIdentifier": "com.anomalyco.mobilevoice", + "config": { + "usesNonExemptEncryption": false + }, "entitlements": { "com.apple.developer.kernel.extended-virtual-addressing": true }, "infoPlist": { - "NSMicrophoneUsageDescription": "This app needs microphone access for live speech-to-text dictation.", + "NSMicrophoneUsageDescription": "Control uses the microphone while you hold Record to turn your speech into text for an OpenCode session.", + "NSCameraUsageDescription": "Control uses the camera to scan the OpenCode pairing QR code shown on your computer.", + "NSLocalNetworkUsageDescription": "Control uses your local network to discover and connect to OpenCode servers running on your computer.", + "NSBonjourServices": ["_http._tcp."], "NSAppTransportSecurity": { "NSAllowsLocalNetworking": true, "NSExceptionDomains": { @@ -23,8 +29,7 @@ "NSExceptionAllowsInsecureHTTPLoads": true } } - }, - "ITSAppUsesNonExemptEncryption": false + } } }, "android": { @@ -40,7 +45,10 @@ "android.permission.FOREGROUND_SERVICE", "android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK", "android.permission.RECORD_AUDIO", - "android.permission.MODIFY_AUDIO_SETTINGS" + "android.permission.MODIFY_AUDIO_SETTINGS", + "android.permission.ACCESS_NETWORK_STATE", + "android.permission.ACCESS_WIFI_STATE", + "android.permission.CHANGE_WIFI_MULTICAST_STATE" ], "predictiveBackGestureEnabled": false }, diff --git a/packages/mobile-voice/package.json b/packages/mobile-voice/package.json index 4186248411..41535a8b00 100644 --- a/packages/mobile-voice/package.json +++ b/packages/mobile-voice/package.json @@ -50,6 +50,7 @@ "react-native-screens": "~4.23.0", "react-native-web": "~0.21.0", "react-native-worklets": "0.7.2", + "react-native-zeroconf": "0.14.0", "whisper.rn": "0.5.5" }, "devDependencies": { diff --git a/packages/mobile-voice/src/app/index.tsx b/packages/mobile-voice/src/app/index.tsx index e749d7be60..c45171ded4 100644 --- a/packages/mobile-voice/src/app/index.tsx +++ b/packages/mobile-voice/src/app/index.tsx @@ -37,8 +37,9 @@ import * as FileSystem from "expo-file-system/legacy" import { fetch as expoFetch } from "expo/fetch" import { buildPermissionCardModel } from "@/lib/pending-permissions" import { unregisterRelayDevice } from "@/lib/relay-client" +import { useMdnsDiscovery } from "@/hooks/use-mdns-discovery" import { useMonitoring, type MonitorJob, type PermissionDecision } from "@/hooks/use-monitoring" -import { looksLikeLocalHost, useServerSessions } from "@/hooks/use-server-sessions" +import { DEFAULT_RELAY_URL, looksLikeLocalHost, useServerSessions } from "@/hooks/use-server-sessions" import { ensureNotificationPermissions, getDevicePushToken } from "@/notifications/monitoring-notifications" const CONTROL_HEIGHT = 86 @@ -229,6 +230,20 @@ function formatSessionUpdated(updatedMs: number): string { } } +function formatWorkingDirectory(directory?: string): string { + if (!directory) return "Not available" + + if (directory.startsWith("/Users/")) { + const segments = directory.split("/") + if (segments.length >= 4) { + const tail = segments.slice(3).join("/") + return tail.length > 0 ? `~/${tail}` : "~" + } + } + + return directory +} + type DropdownMode = "none" | "server" | "session" type Pair = { @@ -638,10 +653,17 @@ export default function DictationScreen() { findServerForSession, } = useServerSessions() + const { discoveredServers, discoveryStatus, discoveryError, discoveryAvailable, refreshDiscovery } = useMdnsDiscovery( + { + enabled: onboardingComplete && localNetworkPermissionState !== "denied", + }, + ) + const { beginMonitoring, activePermissionRequest, devicePushToken, + latestAssistantContext, latestAssistantResponse, monitorJob, monitorStatus, @@ -1755,7 +1777,29 @@ export default function DictationScreen() { const agentStateText = hasAssistantResponse ? latestAssistantResponse : "Waiting for agent…" const shouldShowSend = hasCompletedSession && hasTranscript && !hasPendingPermission const activeServer = servers.find((s) => s.id === activeServerId) ?? null + const discoveredServerOptions = useMemo(() => { + const saved = new Set(servers.map((server) => server.url.replace(/\/+$/, ""))) + return discoveredServers.filter((server) => !saved.has(server.url.replace(/\/+$/, ""))) + }, [discoveredServers, servers]) + const discoveredServerEmptyLabel = + discoveryStatus === "error" + ? "Unable to discover local servers" + : discoveryStatus === "scanning" + ? "Scanning local network..." + : "No local servers found" const activeSession = activeServer?.sessions.find((s) => s.id === activeSessionId) ?? null + let currentSessionModelLabel = "Not available" + if (latestAssistantContext?.modelID) { + currentSessionModelLabel = latestAssistantContext.modelID + if (latestAssistantContext.providerID) { + currentSessionModelLabel = `${latestAssistantContext.providerID}/${latestAssistantContext.modelID}` + } + } + const currentSessionDirectory = latestAssistantContext?.workingDirectory ?? activeSession?.directory + const currentSessionUpdated = activeSession ? formatSessionUpdated(activeSession.updated) : "" + const sessionList = activeSession + ? (activeServer?.sessions ?? []).filter((session) => session.id !== activeSession.id) + : (activeServer?.sessions ?? []) const canSendToSession = !!activeServer && activeServer.status === "online" && !!activeSession const isReplyingToActivePermission = activePermissionRequest !== null && respondingPermissionID === activePermissionRequest.id @@ -1956,8 +2000,8 @@ export default function DictationScreen() { ], })) - const menuRows = - effectiveDropdownMode === "server" ? Math.max(servers.length, 1) : Math.max(activeServer?.sessions.length ?? 0, 1) + const serverMenuRows = 2 + Math.max(servers.length, 1) + Math.max(discoveredServerOptions.length, 1) + const menuRows = effectiveDropdownMode === "server" ? serverMenuRows : Math.max(activeServer?.sessions.length ?? 0, 1) const expandedRowsHeight = Math.min(menuRows, DROPDOWN_VISIBLE_ROWS) * 42 const dropdownFooterExtraHeight = effectiveDropdownMode === "server" @@ -2069,10 +2113,11 @@ export default function DictationScreen() { } if (next === "server") { refreshAllServerHealth() + refreshDiscovery() } return next }) - }, [refreshAllServerHealth]) + }, [refreshAllServerHealth, refreshDiscovery]) const toggleSessionMenu = useCallback(() => { if (!activeServer || activeServer.status !== "online") return @@ -2158,6 +2203,20 @@ export default function DictationScreen() { [devicePushToken, removeServer, serversRef], ) + const handleConnectDiscoveredServer = useCallback( + (url: string) => { + const ok = addServer(url, DEFAULT_RELAY_URL, "") + if (!ok) { + Alert.alert("Could not add server", "The discovered server could not be added. Try scanning the QR code.") + return + } + + setDropdownMode("none") + void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success).catch(() => {}) + }, + [addServer], + ) + const handleStartScan = useCallback(async () => { scanLockRef.current = false const current = @@ -2467,9 +2526,9 @@ export default function DictationScreen() { } const onboardingSteps = [ { - title: "Allow mic access.", + title: "Microphone access.", body: "Control only listens while you hold the record button.", - primaryLabel: microphonePermissionState === "pending" ? "Requesting microphone..." : "Allow microphone", + primaryLabel: microphonePermissionState === "pending" ? "Requesting microphone access..." : "Continue", primaryDisabled: microphonePermissionState === "pending", secondaryLabel: "Continue without granting", visualTag: "MIC", @@ -2480,7 +2539,7 @@ export default function DictationScreen() { { title: "Turn on notifications.", body: "Get alerts when your OpenCode run finishes, fails, or needs your attention.", - primaryLabel: notificationPermissionState === "pending" ? "Requesting notifications..." : "Allow notifications", + primaryLabel: notificationPermissionState === "pending" ? "Requesting notification access..." : "Continue", primaryDisabled: notificationPermissionState === "pending", secondaryLabel: "Continue without granting", visualTag: "PUSH", @@ -2489,9 +2548,9 @@ export default function DictationScreen() { visualTagStyle: styles.onboardingVisualTagNotifications, }, { - title: "Enable local network.", + title: "Local network access.", body: "This lets Control discover your machine on the same network.", - primaryLabel: localNetworkPermissionState === "pending" ? "Requesting local network..." : "Allow local network", + primaryLabel: localNetworkPermissionState === "pending" ? "Requesting local network access..." : "Continue", primaryDisabled: localNetworkPermissionState === "pending", secondaryLabel: "Continue without granting", visualTag: "LAN", @@ -2501,10 +2560,10 @@ export default function DictationScreen() { }, { title: "Pair your computer.", - body: "Start `opencode serve` on your computer, then scan the QR code to pair.", - primaryLabel: "Scan OpenCode QR", + body: "Start `opencode serve --mdns` on your computer. Control can discover nearby servers automatically, or you can scan a QR code.", + primaryLabel: "Scan OpenCode QR (optional)", primaryDisabled: false, - secondaryLabel: "I will do this later", + secondaryLabel: "Skip and use discovery", visualTag: "PAIR", visualSurfaceStyle: styles.onboardingVisualSurfacePair, visualOrbStyle: styles.onboardingVisualOrbPair, @@ -2705,52 +2764,133 @@ export default function DictationScreen() { bounces={false} > {effectiveDropdownMode === "server" ? ( - servers.length === 0 ? ( - No servers yet - ) : ( - servers.map((server) => ( - handleSelectServer(server.id)} - style={({ pressed }) => [styles.serverRow, pressed && styles.serverRowPressed]} - > - - {server.name} - handleDeleteServer(server.id)} hitSlop={8}> - + <> + Saved: + + {servers.length === 0 ? ( + No saved servers + ) : ( + servers.map((server) => ( + handleSelectServer(server.id)} + style={({ pressed }) => [styles.serverRow, pressed && styles.serverRowPressed]} + > + + {server.name} + handleDeleteServer(server.id)} hitSlop={8}> + + - - )) - ) + )) + )} + + + Discovered: + {discoveryStatus === "scanning" ? : null} + + + {!discoveryAvailable ? ( + + Discovery unavailable in this build + + ) : discoveredServerOptions.length === 0 ? ( + + {discoveredServerEmptyLabel} + + ) : ( + discoveredServerOptions.map((server, index) => ( + handleConnectDiscoveredServer(server.url)} + style={({ pressed }) => [ + styles.serverRow, + index === discoveredServerOptions.length - 1 && styles.serverRowLast, + pressed && styles.serverRowPressed, + ]} + > + + + + {server.name} + + + {server.url} + + + Connect + + )) + )} + + {discoveryStatus === "error" && discoveryError ? ( + + {discoveryError} + + ) : null} + ) : activeServer ? ( - activeServer.sessions.length === 0 ? ( - activeServer.sessionsLoading ? null : ( - No sessions available - ) - ) : ( - activeServer.sessions.map((session, index) => ( - handleSelectSession(session.id)} - style={({ pressed }) => [ - styles.serverRow, - index === activeServer.sessions.length - 1 && styles.serverRowLast, - pressed && styles.serverRowPressed, - ]} - > - - - {session.title} + <> + {activeSession ? ( + <> + + Current session + + + Working dir + + {formatWorkingDirectory(currentSessionDirectory)} + + + + + Model + + {currentSessionModelLabel} + + + + + Updated + {currentSessionUpdated || "Just now"} + + + + + + ) : null} + + {sessionList.length === 0 ? ( + activeServer.sessionsLoading ? null : ( + + {activeSession ? "No other sessions available" : "No sessions available"} - {formatSessionUpdated(session.updated)} - - )) - ) + ) + ) : ( + sessionList.map((session, index) => ( + handleSelectSession(session.id)} + style={({ pressed }) => [ + styles.serverRow, + index === sessionList.length - 1 && styles.serverRowLast, + pressed && styles.serverRowPressed, + ]} + > + + + {session.title} + + {formatSessionUpdated(session.updated)} + + )) + )} + ) : ( Select a server first )} @@ -3756,12 +3896,68 @@ const styles = StyleSheet.create({ dropdownListContent: { paddingBottom: 2, }, + currentSessionSummary: { + paddingHorizontal: 4, + paddingTop: 2, + paddingBottom: 8, + gap: 5, + }, + currentSessionLabel: { + color: "#A3ACC0", + fontSize: 12, + fontWeight: "700", + letterSpacing: 0.4, + textTransform: "uppercase", + }, + currentSessionMetaRow: { + flexDirection: "row", + alignItems: "center", + gap: 8, + }, + currentSessionMetaKey: { + width: 74, + color: "#7C8599", + fontSize: 12, + fontWeight: "600", + }, + currentSessionMetaValue: { + flex: 1, + color: "#D7DCE6", + fontSize: 13, + fontWeight: "500", + }, + currentSessionDivider: { + width: "100%", + height: 1, + backgroundColor: "#222733", + marginBottom: 4, + }, + serverGroupHeaderRow: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + marginTop: 8, + }, + serverGroupLabel: { + color: "#8F97AA", + fontSize: 12, + fontWeight: "700", + letterSpacing: 0.4, + textTransform: "uppercase", + paddingHorizontal: 4, + paddingVertical: 4, + }, serverEmptyText: { color: "#6F7686", - fontSize: 13, + fontSize: 14, textAlign: "center", paddingVertical: 10, }, + serverGroupEmptyText: { + textAlign: "left", + paddingHorizontal: 4, + paddingVertical: 8, + }, serverRow: { flexDirection: "row", alignItems: "center", @@ -3794,15 +3990,36 @@ const styles = StyleSheet.create({ serverNameText: { flex: 1, color: "#D6DAE4", - fontSize: 14, + fontSize: 16, fontWeight: "500", }, sessionUpdatedText: { color: "#8E96A8", - fontSize: 12, + fontSize: 14, fontWeight: "500", marginLeft: 8, }, + discoveredServerCopy: { + flex: 1, + gap: 2, + }, + discoveredServerMeta: { + color: "#818A9E", + fontSize: 12, + fontWeight: "500", + }, + discoveredServerAction: { + color: "#B9C2D8", + fontSize: 13, + fontWeight: "700", + }, + discoveryErrorText: { + color: "#7D8598", + fontSize: 11, + fontWeight: "500", + paddingHorizontal: 4, + paddingTop: 4, + }, serverDeleteIcon: { color: "#8C93A3", fontSize: 15, @@ -3845,7 +4062,7 @@ const styles = StyleSheet.create({ sessionMenuActionText: { flex: 1, color: "#D6DAE4", - fontSize: 14, + fontSize: 16, fontWeight: "500", }, statusLeft: { diff --git a/packages/mobile-voice/src/hooks/use-mdns-discovery.ts b/packages/mobile-voice/src/hooks/use-mdns-discovery.ts new file mode 100644 index 0000000000..20ac379fff --- /dev/null +++ b/packages/mobile-voice/src/hooks/use-mdns-discovery.ts @@ -0,0 +1,281 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react" +import { Platform } from "react-native" + +type ZeroconfService = { + name?: unknown + fullName?: unknown + host?: unknown + port?: unknown + addresses?: unknown +} + +type ZeroconfInstance = { + scan: (type?: string, protocol?: string, domain?: string, implType?: string) => void + stop: (implType?: string) => void + removeDeviceListeners: () => void + getServices: () => Record + on: (event: string, listener: (...args: unknown[]) => void) => void +} + +type ZeroconfModule = { + default: new () => ZeroconfInstance + ImplType?: { + DNSSD?: string + } +} + +export type DiscoveredServer = { + id: string + name: string + host: string + port: number + url: string +} + +type DiscoveryStatus = "idle" | "scanning" | "error" + +type UseMdnsDiscoveryInput = { + enabled: boolean +} + +function toErrorMessage(error: unknown): string { + if (error instanceof Error && error.message.trim().length > 0) { + return error.message + } + + const next = String(error ?? "") + return next.trim().length > 0 ? next : "Unknown discovery error" +} + +function cleanHost(input: string): string { + const trimmed = input.trim().replace(/\.$/, "") + if (!trimmed) return "" + if (trimmed.startsWith("[") && trimmed.endsWith("]")) { + return trimmed.slice(1, -1) + } + return trimmed +} + +function isIPv4(input: string): boolean { + return /^\d{1,3}(?:\.\d{1,3}){3}$/.test(input) +} + +function hostTier(input: string): number { + if (input.endsWith(".local")) return 0 + if (isIPv4(input)) { + if (input === "127.0.0.1") return 4 + if (input.startsWith("10.") || input.startsWith("192.168.") || /^172\.(1[6-9]|2\d|3[0-1])\./.test(input)) { + return 1 + } + if (/^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./.test(input)) { + return 1 + } + return 2 + } + if (input.includes(":")) return 3 + return 2 +} + +function formatHostForURL(input: string): string { + return input.includes(":") ? `[${input}]` : input +} + +function isOpenCodeService(service: ZeroconfService): boolean { + if (typeof service.name === "string" && service.name.toLowerCase().startsWith("opencode-")) { + return true + } + + if (typeof service.fullName === "string" && service.fullName.toLowerCase().includes("opencode-")) { + return true + } + + return false +} + +function parseService(service: ZeroconfService): DiscoveredServer | null { + const port = typeof service.port === "number" ? service.port : Number(service.port) + if (!Number.isFinite(port) || port <= 0) { + return null + } + + const hosts = new Set() + + if (typeof service.host === "string") { + const host = cleanHost(service.host) + if (host.length > 0) { + hosts.add(host) + } + } + + if (Array.isArray(service.addresses)) { + for (const address of service.addresses) { + if (typeof address !== "string") continue + const host = cleanHost(address) + if (host.length > 0) { + hosts.add(host) + } + } + } + + const sortedHosts = [...hosts].sort((a, b) => hostTier(a) - hostTier(b)) + const host = sortedHosts[0] + if (!host) { + return null + } + + const name = typeof service.name === "string" && service.name.trim().length > 0 ? service.name.trim() : host + const fullName = + typeof service.fullName === "string" && service.fullName.trim().length > 0 + ? service.fullName.trim() + : `${name}:${port}` + const url = `http://${formatHostForURL(host)}:${port}` + + return { + id: `${fullName}|${url}`, + name, + host, + port, + url, + } +} + +export function useMdnsDiscovery(input: UseMdnsDiscoveryInput) { + const [discoveredServers, setDiscoveredServers] = useState([]) + const [discoveryStatus, setDiscoveryStatus] = useState("idle") + const [discoveryError, setDiscoveryError] = useState(null) + const [discoveryAvailable, setDiscoveryAvailable] = useState(Platform.OS !== "web") + const startScanRef = useRef<(() => void) | null>(null) + + const refreshDiscovery = useCallback(() => { + startScanRef.current?.() + }, []) + + useEffect(() => { + if (!input.enabled) { + startScanRef.current = null + setDiscoveredServers([]) + setDiscoveryStatus("idle") + setDiscoveryError(null) + return + } + + if (Platform.OS === "web") { + setDiscoveryAvailable(false) + setDiscoveryStatus("idle") + setDiscoveryError(null) + return + } + + let active = true + let zeroconf: ZeroconfInstance | null = null + let androidImplType: string | undefined + + const rebuildServices = () => { + if (!active || !zeroconf) return + const values = Object.values(zeroconf.getServices() ?? {}) + const next = new Map() + + for (const value of values) { + if (!isOpenCodeService(value)) continue + const parsed = parseService(value) + if (!parsed) continue + if (!next.has(parsed.url)) { + next.set(parsed.url, parsed) + } + } + + setDiscoveredServers( + [...next.values()].sort((a, b) => { + const nameOrder = a.name.localeCompare(b.name) + if (nameOrder !== 0) return nameOrder + return a.url.localeCompare(b.url) + }), + ) + } + + const startScan = () => { + if (!active || !zeroconf) return + + try { + zeroconf.stop(androidImplType) + } catch { + // noop + } + + try { + zeroconf.scan("http", "tcp", "local.", androidImplType) + setDiscoveryStatus("scanning") + setDiscoveryError(null) + } catch (error) { + setDiscoveryStatus("error") + setDiscoveryError(toErrorMessage(error)) + } + } + + startScanRef.current = startScan + + void import("react-native-zeroconf") + .then((module) => { + if (!active) return + + const mod = module as ZeroconfModule + const Zeroconf = mod.default + if (typeof Zeroconf !== "function") { + setDiscoveryAvailable(false) + setDiscoveryStatus("error") + setDiscoveryError("mDNS module unavailable") + return + } + + zeroconf = new Zeroconf() + androidImplType = Platform.OS === "android" ? (mod.ImplType?.DNSSD ?? "DNSSD") : undefined + setDiscoveryAvailable(true) + + zeroconf.on("resolved", rebuildServices) + zeroconf.on("remove", rebuildServices) + zeroconf.on("update", rebuildServices) + zeroconf.on("error", (error) => { + if (!active) return + setDiscoveryStatus("error") + setDiscoveryError(toErrorMessage(error)) + }) + + startScan() + }) + .catch((error) => { + if (!active) return + setDiscoveryAvailable(false) + setDiscoveryStatus("error") + setDiscoveryError(toErrorMessage(error)) + }) + + return () => { + active = false + startScanRef.current = null + if (!zeroconf) return + + try { + zeroconf.stop(androidImplType) + } catch { + // noop + } + + try { + zeroconf.removeDeviceListeners() + } catch { + // noop + } + } + }, [input.enabled]) + + return useMemo( + () => ({ + discoveredServers, + discoveryStatus, + discoveryError, + discoveryAvailable, + refreshDiscovery, + }), + [discoveredServers, discoveryStatus, discoveryError, discoveryAvailable, refreshDiscovery], + ) +} diff --git a/packages/mobile-voice/src/hooks/use-monitoring.ts b/packages/mobile-voice/src/hooks/use-monitoring.ts index 5a8694dfbb..deb3e62be5 100644 --- a/packages/mobile-voice/src/hooks/use-monitoring.ts +++ b/packages/mobile-voice/src/hooks/use-monitoring.ts @@ -121,6 +121,7 @@ export function useMonitoring({ const [monitorJob, setMonitorJob] = useState(null) const [monitorStatus, setMonitorStatus] = useState("") const [latestAssistantResponse, setLatestAssistantResponse] = useState("") + const [latestAssistantContext, setLatestAssistantContext] = useState(null) const [pendingPermissions, setPendingPermissions] = useState([]) const [replyingPermissionID, setReplyingPermissionID] = useState(null) const [appState, setAppState] = useState(AppState.currentState) @@ -248,18 +249,20 @@ export function useMonitoring({ } const payload = (await response.json()) as unknown - const text = findLatestAssistantCompletionText(payload) + const latest = findLatestAssistantCompletion(payload) if (latestAssistantRequestRef.current !== requestID) return if (activeSessionIdRef.current !== sessionID) return - setLatestAssistantResponse(text) - if (text) { + setLatestAssistantResponse(latest.text) + setLatestAssistantContext(latest.context) + if (latest.text) { setAgentStateDismissed(false) } } catch { if (latestAssistantRequestRef.current !== requestID) return if (activeSessionIdRef.current !== sessionID) return setLatestAssistantResponse("") + setLatestAssistantContext(null) } }, [activeSessionIdRef, setAgentStateDismissed], @@ -443,6 +446,7 @@ export function useMonitoring({ useEffect(() => { setLatestAssistantResponse("") + setLatestAssistantContext(null) setPendingPermissions([]) setAgentStateDismissed(false) if (!activeServerId || !activeSessionId) return @@ -787,6 +791,7 @@ export function useMonitoring({ monitorStatus, setMonitorStatus, latestAssistantResponse, + latestAssistantContext, activePermissionRequest, pendingPermissionCount: pendingPermissions.length, respondingPermissionID: replyingPermissionID, @@ -798,6 +803,10 @@ export function useMonitoring({ type SessionMessageInfo = { role?: unknown time?: unknown + modelID?: unknown + providerID?: unknown + path?: unknown + agent?: unknown } type SessionMessagePart = { @@ -810,6 +819,18 @@ type SessionMessagePayload = { parts?: unknown } +type LatestAssistantContext = { + providerID: string | null + modelID: string | null + workingDirectory: string | null + agent: string | null +} + +type LatestAssistantSnapshot = { + text: string + context: LatestAssistantContext | null +} + function cleanTranscriptText(text: string): string { return text.replace(/[ \t]+$/gm, "").trimEnd() } @@ -818,8 +839,39 @@ function cleanSessionText(text: string): string { return cleanTranscriptText(text).trimStart() } -function findLatestAssistantCompletionText(payload: unknown): string { - if (!Array.isArray(payload)) return "" +function maybeString(value: unknown): string | null { + if (typeof value !== "string") return null + const trimmed = value.trim() + return trimmed.length > 0 ? trimmed : null +} + +function extractAssistantContext(info: SessionMessageInfo): LatestAssistantContext | null { + const providerID = maybeString(info.providerID) + const modelID = maybeString(info.modelID) + const pathValue = info.path + const pathRecord = pathValue && typeof pathValue === "object" ? (pathValue as { cwd?: unknown }) : null + const workingDirectory = maybeString(pathRecord?.cwd) + const agent = maybeString(info.agent) + + if (!providerID && !modelID && !workingDirectory && !agent) { + return null + } + + return { + providerID, + modelID, + workingDirectory, + agent, + } +} + +function findLatestAssistantCompletion(payload: unknown): LatestAssistantSnapshot { + if (!Array.isArray(payload)) { + return { + text: "", + context: null, + } + } for (let index = payload.length - 1; index >= 0; index -= 1) { const candidate = payload[index] as SessionMessagePayload @@ -832,6 +884,7 @@ function findLatestAssistantCompletionText(payload: unknown): string { const time = info.time as { completed?: unknown } | undefined if (!time || typeof time !== "object") continue if (typeof time.completed !== "number") continue + const context = extractAssistantContext(info) const parts = Array.isArray(candidate.parts) ? (candidate.parts as SessionMessagePart[]) : [] const text = parts @@ -840,10 +893,16 @@ function findLatestAssistantCompletionText(payload: unknown): string { .filter((part) => part.length > 0) .join("\n\n") - if (text.length > 0) { - return text + if (text.length > 0 || context) { + return { + text, + context, + } } } - return "" + return { + text: "", + context: null, + } } diff --git a/packages/mobile-voice/src/types/css-modules.d.ts b/packages/mobile-voice/src/types/css-modules.d.ts new file mode 100644 index 0000000000..51d191ff9d --- /dev/null +++ b/packages/mobile-voice/src/types/css-modules.d.ts @@ -0,0 +1,4 @@ +declare module "*.module.css" { + const classes: Record + export default classes +} diff --git a/packages/mobile-voice/src/types/react-native-zeroconf.d.ts b/packages/mobile-voice/src/types/react-native-zeroconf.d.ts new file mode 100644 index 0000000000..93aa5677be --- /dev/null +++ b/packages/mobile-voice/src/types/react-native-zeroconf.d.ts @@ -0,0 +1,23 @@ +declare module "react-native-zeroconf" { + export const ImplType: { + NSD: string + DNSSD: string + } + + export type ZeroconfService = { + name?: string + fullName?: string + host?: string + port?: number + addresses?: string[] + txt?: Record + } + + export default class Zeroconf { + scan(type?: string, protocol?: string, domain?: string, implType?: string): void + stop(implType?: string): void + removeDeviceListeners(): void + getServices(): Record + on(event: string, listener: (...args: unknown[]) => void): this + } +} diff --git a/packages/opencode/src/server/push-relay.ts b/packages/opencode/src/server/push-relay.ts index da03d70ebd..f0188d3de7 100644 --- a/packages/opencode/src/server/push-relay.ts +++ b/packages/opencode/src/server/push-relay.ts @@ -294,15 +294,6 @@ async function post(input: { type: Type; sessionID: string }) { const content = await notify(input) - console.log("[ APN RELAY ] posting event", { - serverID: next.pair.serverID, - relayURL: next.relayURL, - secretHash: secretHash(next.relaySecret), - type: input.type, - sessionID: input.sessionID, - title: content.title, - }) - log.info("[ APN RELAY ] posting event", { serverID: next.pair.serverID, relayURL: next.relayURL, @@ -328,15 +319,6 @@ async function post(input: { type: Type; sessionID: string }) { }) .then(async (res) => { if (res.ok) { - console.log("[ APN RELAY ] relay accepted event", { - status: res.status, - serverID: next.pair.serverID, - secretHash: secretHash(next.relaySecret), - type: input.type, - sessionID: input.sessionID, - title: content.title, - }) - log.info("[ APN RELAY ] relay accepted event", { status: res.status, serverID: next.pair.serverID,