update to build proc
This commit is contained in:
parent
28aebb2772
commit
776e61d1ec
10 changed files with 706 additions and 88 deletions
3
bun.lock
3
bun.lock
|
|
@ -353,6 +353,7 @@
|
||||||
"react-native-screens": "~4.23.0",
|
"react-native-screens": "~4.23.0",
|
||||||
"react-native-web": "~0.21.0",
|
"react-native-web": "~0.21.0",
|
||||||
"react-native-worklets": "0.7.2",
|
"react-native-worklets": "0.7.2",
|
||||||
|
"react-native-zeroconf": "0.14.0",
|
||||||
"whisper.rn": "0.5.5",
|
"whisper.rn": "0.5.5",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"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-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-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=="],
|
"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=="],
|
||||||
|
|
|
||||||
|
|
@ -123,6 +123,46 @@ Run all commands from `packages/mobile-voice`.
|
||||||
- Rebuild the dev client after native module additions or changes.
|
- Rebuild the dev client after native module additions or changes.
|
||||||
- For optional native capability usage, prefer runtime fallback paths instead of hard crashes.
|
- 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
|
## Common Pitfalls
|
||||||
|
|
||||||
- Black screen + "No script URL provided" often means a stale dev client binary.
|
- Black screen + "No script URL provided" often means a stale dev client binary.
|
||||||
|
|
|
||||||
|
|
@ -10,11 +10,17 @@
|
||||||
"ios": {
|
"ios": {
|
||||||
"icon": "./assets/images/icon.png",
|
"icon": "./assets/images/icon.png",
|
||||||
"bundleIdentifier": "com.anomalyco.mobilevoice",
|
"bundleIdentifier": "com.anomalyco.mobilevoice",
|
||||||
|
"config": {
|
||||||
|
"usesNonExemptEncryption": false
|
||||||
|
},
|
||||||
"entitlements": {
|
"entitlements": {
|
||||||
"com.apple.developer.kernel.extended-virtual-addressing": true
|
"com.apple.developer.kernel.extended-virtual-addressing": true
|
||||||
},
|
},
|
||||||
"infoPlist": {
|
"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": {
|
"NSAppTransportSecurity": {
|
||||||
"NSAllowsLocalNetworking": true,
|
"NSAllowsLocalNetworking": true,
|
||||||
"NSExceptionDomains": {
|
"NSExceptionDomains": {
|
||||||
|
|
@ -23,8 +29,7 @@
|
||||||
"NSExceptionAllowsInsecureHTTPLoads": true
|
"NSExceptionAllowsInsecureHTTPLoads": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
"ITSAppUsesNonExemptEncryption": false
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
|
|
@ -40,7 +45,10 @@
|
||||||
"android.permission.FOREGROUND_SERVICE",
|
"android.permission.FOREGROUND_SERVICE",
|
||||||
"android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK",
|
"android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK",
|
||||||
"android.permission.RECORD_AUDIO",
|
"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
|
"predictiveBackGestureEnabled": false
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,7 @@
|
||||||
"react-native-screens": "~4.23.0",
|
"react-native-screens": "~4.23.0",
|
||||||
"react-native-web": "~0.21.0",
|
"react-native-web": "~0.21.0",
|
||||||
"react-native-worklets": "0.7.2",
|
"react-native-worklets": "0.7.2",
|
||||||
|
"react-native-zeroconf": "0.14.0",
|
||||||
"whisper.rn": "0.5.5"
|
"whisper.rn": "0.5.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
|
||||||
|
|
@ -37,8 +37,9 @@ import * as FileSystem from "expo-file-system/legacy"
|
||||||
import { fetch as expoFetch } from "expo/fetch"
|
import { fetch as expoFetch } from "expo/fetch"
|
||||||
import { buildPermissionCardModel } from "@/lib/pending-permissions"
|
import { buildPermissionCardModel } from "@/lib/pending-permissions"
|
||||||
import { unregisterRelayDevice } from "@/lib/relay-client"
|
import { unregisterRelayDevice } from "@/lib/relay-client"
|
||||||
|
import { useMdnsDiscovery } from "@/hooks/use-mdns-discovery"
|
||||||
import { useMonitoring, type MonitorJob, type PermissionDecision } from "@/hooks/use-monitoring"
|
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"
|
import { ensureNotificationPermissions, getDevicePushToken } from "@/notifications/monitoring-notifications"
|
||||||
|
|
||||||
const CONTROL_HEIGHT = 86
|
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 DropdownMode = "none" | "server" | "session"
|
||||||
|
|
||||||
type Pair = {
|
type Pair = {
|
||||||
|
|
@ -638,10 +653,17 @@ export default function DictationScreen() {
|
||||||
findServerForSession,
|
findServerForSession,
|
||||||
} = useServerSessions()
|
} = useServerSessions()
|
||||||
|
|
||||||
|
const { discoveredServers, discoveryStatus, discoveryError, discoveryAvailable, refreshDiscovery } = useMdnsDiscovery(
|
||||||
|
{
|
||||||
|
enabled: onboardingComplete && localNetworkPermissionState !== "denied",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
const {
|
const {
|
||||||
beginMonitoring,
|
beginMonitoring,
|
||||||
activePermissionRequest,
|
activePermissionRequest,
|
||||||
devicePushToken,
|
devicePushToken,
|
||||||
|
latestAssistantContext,
|
||||||
latestAssistantResponse,
|
latestAssistantResponse,
|
||||||
monitorJob,
|
monitorJob,
|
||||||
monitorStatus,
|
monitorStatus,
|
||||||
|
|
@ -1755,7 +1777,29 @@ export default function DictationScreen() {
|
||||||
const agentStateText = hasAssistantResponse ? latestAssistantResponse : "Waiting for agent…"
|
const agentStateText = hasAssistantResponse ? latestAssistantResponse : "Waiting for agent…"
|
||||||
const shouldShowSend = hasCompletedSession && hasTranscript && !hasPendingPermission
|
const shouldShowSend = hasCompletedSession && hasTranscript && !hasPendingPermission
|
||||||
const activeServer = servers.find((s) => s.id === activeServerId) ?? null
|
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
|
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 canSendToSession = !!activeServer && activeServer.status === "online" && !!activeSession
|
||||||
const isReplyingToActivePermission =
|
const isReplyingToActivePermission =
|
||||||
activePermissionRequest !== null && respondingPermissionID === activePermissionRequest.id
|
activePermissionRequest !== null && respondingPermissionID === activePermissionRequest.id
|
||||||
|
|
@ -1956,8 +2000,8 @@ export default function DictationScreen() {
|
||||||
],
|
],
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const menuRows =
|
const serverMenuRows = 2 + Math.max(servers.length, 1) + Math.max(discoveredServerOptions.length, 1)
|
||||||
effectiveDropdownMode === "server" ? Math.max(servers.length, 1) : Math.max(activeServer?.sessions.length ?? 0, 1)
|
const menuRows = effectiveDropdownMode === "server" ? serverMenuRows : Math.max(activeServer?.sessions.length ?? 0, 1)
|
||||||
const expandedRowsHeight = Math.min(menuRows, DROPDOWN_VISIBLE_ROWS) * 42
|
const expandedRowsHeight = Math.min(menuRows, DROPDOWN_VISIBLE_ROWS) * 42
|
||||||
const dropdownFooterExtraHeight =
|
const dropdownFooterExtraHeight =
|
||||||
effectiveDropdownMode === "server"
|
effectiveDropdownMode === "server"
|
||||||
|
|
@ -2069,10 +2113,11 @@ export default function DictationScreen() {
|
||||||
}
|
}
|
||||||
if (next === "server") {
|
if (next === "server") {
|
||||||
refreshAllServerHealth()
|
refreshAllServerHealth()
|
||||||
|
refreshDiscovery()
|
||||||
}
|
}
|
||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
}, [refreshAllServerHealth])
|
}, [refreshAllServerHealth, refreshDiscovery])
|
||||||
|
|
||||||
const toggleSessionMenu = useCallback(() => {
|
const toggleSessionMenu = useCallback(() => {
|
||||||
if (!activeServer || activeServer.status !== "online") return
|
if (!activeServer || activeServer.status !== "online") return
|
||||||
|
|
@ -2158,6 +2203,20 @@ export default function DictationScreen() {
|
||||||
[devicePushToken, removeServer, serversRef],
|
[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 () => {
|
const handleStartScan = useCallback(async () => {
|
||||||
scanLockRef.current = false
|
scanLockRef.current = false
|
||||||
const current =
|
const current =
|
||||||
|
|
@ -2467,9 +2526,9 @@ export default function DictationScreen() {
|
||||||
}
|
}
|
||||||
const onboardingSteps = [
|
const onboardingSteps = [
|
||||||
{
|
{
|
||||||
title: "Allow mic access.",
|
title: "Microphone access.",
|
||||||
body: "Control only listens while you hold the record button.",
|
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",
|
primaryDisabled: microphonePermissionState === "pending",
|
||||||
secondaryLabel: "Continue without granting",
|
secondaryLabel: "Continue without granting",
|
||||||
visualTag: "MIC",
|
visualTag: "MIC",
|
||||||
|
|
@ -2480,7 +2539,7 @@ export default function DictationScreen() {
|
||||||
{
|
{
|
||||||
title: "Turn on notifications.",
|
title: "Turn on notifications.",
|
||||||
body: "Get alerts when your OpenCode run finishes, fails, or needs your attention.",
|
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",
|
primaryDisabled: notificationPermissionState === "pending",
|
||||||
secondaryLabel: "Continue without granting",
|
secondaryLabel: "Continue without granting",
|
||||||
visualTag: "PUSH",
|
visualTag: "PUSH",
|
||||||
|
|
@ -2489,9 +2548,9 @@ export default function DictationScreen() {
|
||||||
visualTagStyle: styles.onboardingVisualTagNotifications,
|
visualTagStyle: styles.onboardingVisualTagNotifications,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Enable local network.",
|
title: "Local network access.",
|
||||||
body: "This lets Control discover your machine on the same network.",
|
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",
|
primaryDisabled: localNetworkPermissionState === "pending",
|
||||||
secondaryLabel: "Continue without granting",
|
secondaryLabel: "Continue without granting",
|
||||||
visualTag: "LAN",
|
visualTag: "LAN",
|
||||||
|
|
@ -2501,10 +2560,10 @@ export default function DictationScreen() {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Pair your computer.",
|
title: "Pair your computer.",
|
||||||
body: "Start `opencode serve` on your computer, then scan the QR code to pair.",
|
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",
|
primaryLabel: "Scan OpenCode QR (optional)",
|
||||||
primaryDisabled: false,
|
primaryDisabled: false,
|
||||||
secondaryLabel: "I will do this later",
|
secondaryLabel: "Skip and use discovery",
|
||||||
visualTag: "PAIR",
|
visualTag: "PAIR",
|
||||||
visualSurfaceStyle: styles.onboardingVisualSurfacePair,
|
visualSurfaceStyle: styles.onboardingVisualSurfacePair,
|
||||||
visualOrbStyle: styles.onboardingVisualOrbPair,
|
visualOrbStyle: styles.onboardingVisualOrbPair,
|
||||||
|
|
@ -2705,52 +2764,133 @@ export default function DictationScreen() {
|
||||||
bounces={false}
|
bounces={false}
|
||||||
>
|
>
|
||||||
{effectiveDropdownMode === "server" ? (
|
{effectiveDropdownMode === "server" ? (
|
||||||
servers.length === 0 ? (
|
<>
|
||||||
<Text style={styles.serverEmptyText}>No servers yet</Text>
|
<Text style={styles.serverGroupLabel}>Saved:</Text>
|
||||||
) : (
|
|
||||||
servers.map((server) => (
|
{servers.length === 0 ? (
|
||||||
<Pressable
|
<Text style={[styles.serverEmptyText, styles.serverGroupEmptyText]}>No saved servers</Text>
|
||||||
key={server.id}
|
) : (
|
||||||
onPress={() => handleSelectServer(server.id)}
|
servers.map((server) => (
|
||||||
style={({ pressed }) => [styles.serverRow, pressed && styles.serverRowPressed]}
|
<Pressable
|
||||||
>
|
key={server.id}
|
||||||
<View
|
onPress={() => handleSelectServer(server.id)}
|
||||||
style={[
|
style={({ pressed }) => [styles.serverRow, pressed && styles.serverRowPressed]}
|
||||||
styles.serverStatusDot,
|
>
|
||||||
server.status === "online" ? styles.serverStatusActive : styles.serverStatusOffline,
|
<View
|
||||||
]}
|
style={[
|
||||||
/>
|
styles.serverStatusDot,
|
||||||
<Text style={styles.serverNameText}>{server.name}</Text>
|
server.status === "online" ? styles.serverStatusActive : styles.serverStatusOffline,
|
||||||
<Pressable onPress={() => handleDeleteServer(server.id)} hitSlop={8}>
|
]}
|
||||||
<Text style={styles.serverDeleteIcon}>✕</Text>
|
/>
|
||||||
|
<Text style={styles.serverNameText}>{server.name}</Text>
|
||||||
|
<Pressable onPress={() => handleDeleteServer(server.id)} hitSlop={8}>
|
||||||
|
<Text style={styles.serverDeleteIcon}>✕</Text>
|
||||||
|
</Pressable>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</Pressable>
|
))
|
||||||
))
|
)}
|
||||||
)
|
|
||||||
|
<View style={styles.serverGroupHeaderRow}>
|
||||||
|
<Text style={styles.serverGroupLabel}>Discovered:</Text>
|
||||||
|
{discoveryStatus === "scanning" ? <ActivityIndicator size="small" color="#8790A3" /> : null}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{!discoveryAvailable ? (
|
||||||
|
<Text style={[styles.serverEmptyText, styles.serverGroupEmptyText]}>
|
||||||
|
Discovery unavailable in this build
|
||||||
|
</Text>
|
||||||
|
) : discoveredServerOptions.length === 0 ? (
|
||||||
|
<Text style={[styles.serverEmptyText, styles.serverGroupEmptyText]}>
|
||||||
|
{discoveredServerEmptyLabel}
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
discoveredServerOptions.map((server, index) => (
|
||||||
|
<Pressable
|
||||||
|
key={server.id}
|
||||||
|
onPress={() => handleConnectDiscoveredServer(server.url)}
|
||||||
|
style={({ pressed }) => [
|
||||||
|
styles.serverRow,
|
||||||
|
index === discoveredServerOptions.length - 1 && styles.serverRowLast,
|
||||||
|
pressed && styles.serverRowPressed,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<View style={[styles.serverStatusDot, styles.serverStatusChecking]} />
|
||||||
|
<View style={styles.discoveredServerCopy}>
|
||||||
|
<Text style={styles.serverNameText} numberOfLines={1}>
|
||||||
|
{server.name}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.discoveredServerMeta} numberOfLines={1} ellipsizeMode="middle">
|
||||||
|
{server.url}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.discoveredServerAction}>Connect</Text>
|
||||||
|
</Pressable>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
|
||||||
|
{discoveryStatus === "error" && discoveryError ? (
|
||||||
|
<Text style={styles.discoveryErrorText} numberOfLines={1} ellipsizeMode="tail">
|
||||||
|
{discoveryError}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
) : activeServer ? (
|
) : activeServer ? (
|
||||||
activeServer.sessions.length === 0 ? (
|
<>
|
||||||
activeServer.sessionsLoading ? null : (
|
{activeSession ? (
|
||||||
<Text style={styles.serverEmptyText}>No sessions available</Text>
|
<>
|
||||||
)
|
<View style={styles.currentSessionSummary}>
|
||||||
) : (
|
<Text style={styles.currentSessionLabel}>Current session</Text>
|
||||||
activeServer.sessions.map((session, index) => (
|
|
||||||
<Pressable
|
<View style={styles.currentSessionMetaRow}>
|
||||||
key={session.id}
|
<Text style={styles.currentSessionMetaKey}>Working dir</Text>
|
||||||
onPress={() => handleSelectSession(session.id)}
|
<Text style={styles.currentSessionMetaValue} numberOfLines={1} ellipsizeMode="middle">
|
||||||
style={({ pressed }) => [
|
{formatWorkingDirectory(currentSessionDirectory)}
|
||||||
styles.serverRow,
|
</Text>
|
||||||
index === activeServer.sessions.length - 1 && styles.serverRowLast,
|
</View>
|
||||||
pressed && styles.serverRowPressed,
|
|
||||||
]}
|
<View style={styles.currentSessionMetaRow}>
|
||||||
>
|
<Text style={styles.currentSessionMetaKey}>Model</Text>
|
||||||
<View style={[styles.serverStatusDot, styles.serverStatusActive]} />
|
<Text style={styles.currentSessionMetaValue} numberOfLines={1} ellipsizeMode="middle">
|
||||||
<Text style={styles.serverNameText} numberOfLines={1}>
|
{currentSessionModelLabel}
|
||||||
{session.title}
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.currentSessionMetaRow}>
|
||||||
|
<Text style={styles.currentSessionMetaKey}>Updated</Text>
|
||||||
|
<Text style={styles.currentSessionMetaValue}>{currentSessionUpdated || "Just now"}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.currentSessionDivider} />
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{sessionList.length === 0 ? (
|
||||||
|
activeServer.sessionsLoading ? null : (
|
||||||
|
<Text style={styles.serverEmptyText}>
|
||||||
|
{activeSession ? "No other sessions available" : "No sessions available"}
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={styles.sessionUpdatedText}>{formatSessionUpdated(session.updated)}</Text>
|
)
|
||||||
</Pressable>
|
) : (
|
||||||
))
|
sessionList.map((session, index) => (
|
||||||
)
|
<Pressable
|
||||||
|
key={session.id}
|
||||||
|
onPress={() => handleSelectSession(session.id)}
|
||||||
|
style={({ pressed }) => [
|
||||||
|
styles.serverRow,
|
||||||
|
index === sessionList.length - 1 && styles.serverRowLast,
|
||||||
|
pressed && styles.serverRowPressed,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<View style={[styles.serverStatusDot, styles.serverStatusActive]} />
|
||||||
|
<Text style={styles.serverNameText} numberOfLines={1}>
|
||||||
|
{session.title}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.sessionUpdatedText}>{formatSessionUpdated(session.updated)}</Text>
|
||||||
|
</Pressable>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<Text style={styles.serverEmptyText}>Select a server first</Text>
|
<Text style={styles.serverEmptyText}>Select a server first</Text>
|
||||||
)}
|
)}
|
||||||
|
|
@ -3756,12 +3896,68 @@ const styles = StyleSheet.create({
|
||||||
dropdownListContent: {
|
dropdownListContent: {
|
||||||
paddingBottom: 2,
|
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: {
|
serverEmptyText: {
|
||||||
color: "#6F7686",
|
color: "#6F7686",
|
||||||
fontSize: 13,
|
fontSize: 14,
|
||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
paddingVertical: 10,
|
paddingVertical: 10,
|
||||||
},
|
},
|
||||||
|
serverGroupEmptyText: {
|
||||||
|
textAlign: "left",
|
||||||
|
paddingHorizontal: 4,
|
||||||
|
paddingVertical: 8,
|
||||||
|
},
|
||||||
serverRow: {
|
serverRow: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
|
|
@ -3794,15 +3990,36 @@ const styles = StyleSheet.create({
|
||||||
serverNameText: {
|
serverNameText: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
color: "#D6DAE4",
|
color: "#D6DAE4",
|
||||||
fontSize: 14,
|
fontSize: 16,
|
||||||
fontWeight: "500",
|
fontWeight: "500",
|
||||||
},
|
},
|
||||||
sessionUpdatedText: {
|
sessionUpdatedText: {
|
||||||
color: "#8E96A8",
|
color: "#8E96A8",
|
||||||
fontSize: 12,
|
fontSize: 14,
|
||||||
fontWeight: "500",
|
fontWeight: "500",
|
||||||
marginLeft: 8,
|
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: {
|
serverDeleteIcon: {
|
||||||
color: "#8C93A3",
|
color: "#8C93A3",
|
||||||
fontSize: 15,
|
fontSize: 15,
|
||||||
|
|
@ -3845,7 +4062,7 @@ const styles = StyleSheet.create({
|
||||||
sessionMenuActionText: {
|
sessionMenuActionText: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
color: "#D6DAE4",
|
color: "#D6DAE4",
|
||||||
fontSize: 14,
|
fontSize: 16,
|
||||||
fontWeight: "500",
|
fontWeight: "500",
|
||||||
},
|
},
|
||||||
statusLeft: {
|
statusLeft: {
|
||||||
|
|
|
||||||
281
packages/mobile-voice/src/hooks/use-mdns-discovery.ts
Normal file
281
packages/mobile-voice/src/hooks/use-mdns-discovery.ts
Normal file
|
|
@ -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<string, ZeroconfService>
|
||||||
|
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<string>()
|
||||||
|
|
||||||
|
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<DiscoveredServer[]>([])
|
||||||
|
const [discoveryStatus, setDiscoveryStatus] = useState<DiscoveryStatus>("idle")
|
||||||
|
const [discoveryError, setDiscoveryError] = useState<string | null>(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<string, DiscoveredServer>()
|
||||||
|
|
||||||
|
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],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -121,6 +121,7 @@ export function useMonitoring({
|
||||||
const [monitorJob, setMonitorJob] = useState<MonitorJob | null>(null)
|
const [monitorJob, setMonitorJob] = useState<MonitorJob | null>(null)
|
||||||
const [monitorStatus, setMonitorStatus] = useState("")
|
const [monitorStatus, setMonitorStatus] = useState("")
|
||||||
const [latestAssistantResponse, setLatestAssistantResponse] = useState("")
|
const [latestAssistantResponse, setLatestAssistantResponse] = useState("")
|
||||||
|
const [latestAssistantContext, setLatestAssistantContext] = useState<LatestAssistantContext | null>(null)
|
||||||
const [pendingPermissions, setPendingPermissions] = useState<PendingPermissionRequest[]>([])
|
const [pendingPermissions, setPendingPermissions] = useState<PendingPermissionRequest[]>([])
|
||||||
const [replyingPermissionID, setReplyingPermissionID] = useState<string | null>(null)
|
const [replyingPermissionID, setReplyingPermissionID] = useState<string | null>(null)
|
||||||
const [appState, setAppState] = useState<AppStateStatus>(AppState.currentState)
|
const [appState, setAppState] = useState<AppStateStatus>(AppState.currentState)
|
||||||
|
|
@ -248,18 +249,20 @@ export function useMonitoring({
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload = (await response.json()) as unknown
|
const payload = (await response.json()) as unknown
|
||||||
const text = findLatestAssistantCompletionText(payload)
|
const latest = findLatestAssistantCompletion(payload)
|
||||||
|
|
||||||
if (latestAssistantRequestRef.current !== requestID) return
|
if (latestAssistantRequestRef.current !== requestID) return
|
||||||
if (activeSessionIdRef.current !== sessionID) return
|
if (activeSessionIdRef.current !== sessionID) return
|
||||||
setLatestAssistantResponse(text)
|
setLatestAssistantResponse(latest.text)
|
||||||
if (text) {
|
setLatestAssistantContext(latest.context)
|
||||||
|
if (latest.text) {
|
||||||
setAgentStateDismissed(false)
|
setAgentStateDismissed(false)
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
if (latestAssistantRequestRef.current !== requestID) return
|
if (latestAssistantRequestRef.current !== requestID) return
|
||||||
if (activeSessionIdRef.current !== sessionID) return
|
if (activeSessionIdRef.current !== sessionID) return
|
||||||
setLatestAssistantResponse("")
|
setLatestAssistantResponse("")
|
||||||
|
setLatestAssistantContext(null)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[activeSessionIdRef, setAgentStateDismissed],
|
[activeSessionIdRef, setAgentStateDismissed],
|
||||||
|
|
@ -443,6 +446,7 @@ export function useMonitoring({
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLatestAssistantResponse("")
|
setLatestAssistantResponse("")
|
||||||
|
setLatestAssistantContext(null)
|
||||||
setPendingPermissions([])
|
setPendingPermissions([])
|
||||||
setAgentStateDismissed(false)
|
setAgentStateDismissed(false)
|
||||||
if (!activeServerId || !activeSessionId) return
|
if (!activeServerId || !activeSessionId) return
|
||||||
|
|
@ -787,6 +791,7 @@ export function useMonitoring({
|
||||||
monitorStatus,
|
monitorStatus,
|
||||||
setMonitorStatus,
|
setMonitorStatus,
|
||||||
latestAssistantResponse,
|
latestAssistantResponse,
|
||||||
|
latestAssistantContext,
|
||||||
activePermissionRequest,
|
activePermissionRequest,
|
||||||
pendingPermissionCount: pendingPermissions.length,
|
pendingPermissionCount: pendingPermissions.length,
|
||||||
respondingPermissionID: replyingPermissionID,
|
respondingPermissionID: replyingPermissionID,
|
||||||
|
|
@ -798,6 +803,10 @@ export function useMonitoring({
|
||||||
type SessionMessageInfo = {
|
type SessionMessageInfo = {
|
||||||
role?: unknown
|
role?: unknown
|
||||||
time?: unknown
|
time?: unknown
|
||||||
|
modelID?: unknown
|
||||||
|
providerID?: unknown
|
||||||
|
path?: unknown
|
||||||
|
agent?: unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
type SessionMessagePart = {
|
type SessionMessagePart = {
|
||||||
|
|
@ -810,6 +819,18 @@ type SessionMessagePayload = {
|
||||||
parts?: unknown
|
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 {
|
function cleanTranscriptText(text: string): string {
|
||||||
return text.replace(/[ \t]+$/gm, "").trimEnd()
|
return text.replace(/[ \t]+$/gm, "").trimEnd()
|
||||||
}
|
}
|
||||||
|
|
@ -818,8 +839,39 @@ function cleanSessionText(text: string): string {
|
||||||
return cleanTranscriptText(text).trimStart()
|
return cleanTranscriptText(text).trimStart()
|
||||||
}
|
}
|
||||||
|
|
||||||
function findLatestAssistantCompletionText(payload: unknown): string {
|
function maybeString(value: unknown): string | null {
|
||||||
if (!Array.isArray(payload)) return ""
|
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) {
|
for (let index = payload.length - 1; index >= 0; index -= 1) {
|
||||||
const candidate = payload[index] as SessionMessagePayload
|
const candidate = payload[index] as SessionMessagePayload
|
||||||
|
|
@ -832,6 +884,7 @@ function findLatestAssistantCompletionText(payload: unknown): string {
|
||||||
const time = info.time as { completed?: unknown } | undefined
|
const time = info.time as { completed?: unknown } | undefined
|
||||||
if (!time || typeof time !== "object") continue
|
if (!time || typeof time !== "object") continue
|
||||||
if (typeof time.completed !== "number") continue
|
if (typeof time.completed !== "number") continue
|
||||||
|
const context = extractAssistantContext(info)
|
||||||
|
|
||||||
const parts = Array.isArray(candidate.parts) ? (candidate.parts as SessionMessagePart[]) : []
|
const parts = Array.isArray(candidate.parts) ? (candidate.parts as SessionMessagePart[]) : []
|
||||||
const text = parts
|
const text = parts
|
||||||
|
|
@ -840,10 +893,16 @@ function findLatestAssistantCompletionText(payload: unknown): string {
|
||||||
.filter((part) => part.length > 0)
|
.filter((part) => part.length > 0)
|
||||||
.join("\n\n")
|
.join("\n\n")
|
||||||
|
|
||||||
if (text.length > 0) {
|
if (text.length > 0 || context) {
|
||||||
return text
|
return {
|
||||||
|
text,
|
||||||
|
context,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return ""
|
return {
|
||||||
|
text: "",
|
||||||
|
context: null,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
4
packages/mobile-voice/src/types/css-modules.d.ts
vendored
Normal file
4
packages/mobile-voice/src/types/css-modules.d.ts
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
declare module "*.module.css" {
|
||||||
|
const classes: Record<string, string>
|
||||||
|
export default classes
|
||||||
|
}
|
||||||
23
packages/mobile-voice/src/types/react-native-zeroconf.d.ts
vendored
Normal file
23
packages/mobile-voice/src/types/react-native-zeroconf.d.ts
vendored
Normal file
|
|
@ -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<string, string>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class Zeroconf {
|
||||||
|
scan(type?: string, protocol?: string, domain?: string, implType?: string): void
|
||||||
|
stop(implType?: string): void
|
||||||
|
removeDeviceListeners(): void
|
||||||
|
getServices(): Record<string, ZeroconfService>
|
||||||
|
on(event: string, listener: (...args: unknown[]) => void): this
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -294,15 +294,6 @@ async function post(input: { type: Type; sessionID: string }) {
|
||||||
|
|
||||||
const content = await notify(input)
|
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", {
|
log.info("[ APN RELAY ] posting event", {
|
||||||
serverID: next.pair.serverID,
|
serverID: next.pair.serverID,
|
||||||
relayURL: next.relayURL,
|
relayURL: next.relayURL,
|
||||||
|
|
@ -328,15 +319,6 @@ async function post(input: { type: Type; sessionID: string }) {
|
||||||
})
|
})
|
||||||
.then(async (res) => {
|
.then(async (res) => {
|
||||||
if (res.ok) {
|
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", {
|
log.info("[ APN RELAY ] relay accepted event", {
|
||||||
status: res.status,
|
status: res.status,
|
||||||
serverID: next.pair.serverID,
|
serverID: next.pair.serverID,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue