update to the apn and server management

pull/19545/head
Ryan Vogel 2026-03-29 16:17:57 -04:00
parent ddd30ef304
commit eadb0e25da
11 changed files with 541 additions and 137 deletions

View File

@ -19,6 +19,10 @@ type PushResult = {
error?: string error?: string
} }
function tokenSuffix(input: string) {
return input.length > 8 ? input.slice(-8) : input
}
let jwt = "" let jwt = ""
let exp = 0 let exp = 0
let pk: Awaited<ReturnType<typeof importPKCS8>> | undefined let pk: Awaited<ReturnType<typeof importPKCS8>> | undefined
@ -100,10 +104,27 @@ function post(input: {
} }
export async function send(input: PushInput): Promise<PushResult> { export async function send(input: PushInput): Promise<PushResult> {
const apnsHost = host(input.env)
const suffix = tokenSuffix(input.token)
console.log("[ APN RELAY ] push:start", {
env: input.env,
host: apnsHost,
bundle: input.bundle,
tokenSuffix: suffix,
})
const auth = await sign().catch((err) => { const auth = await sign().catch((err) => {
return `error:${String(err)}` return `error:${String(err)}`
}) })
if (auth.startsWith("error:")) { if (auth.startsWith("error:")) {
console.log("[ APN RELAY ] push:auth-failed", {
env: input.env,
host: apnsHost,
bundle: input.bundle,
tokenSuffix: suffix,
error: auth,
})
return { return {
ok: false, ok: false,
code: 0, code: 0,
@ -117,13 +138,13 @@ export async function send(input: PushInput): Promise<PushResult> {
title: input.title, title: input.title,
body: input.body, body: input.body,
}, },
sound: "default", sound: "alert.wav",
}, },
...input.data, ...input.data,
}) })
const out = await post({ const out = await post({
host: host(input.env), host: apnsHost,
token: input.token, token: input.token,
auth, auth,
bundle: input.bundle, bundle: input.bundle,
@ -134,12 +155,28 @@ export async function send(input: PushInput): Promise<PushResult> {
})) }))
if (out.code === 200) { if (out.code === 200) {
console.log("[ APN RELAY ] push:sent", {
env: input.env,
host: apnsHost,
bundle: input.bundle,
tokenSuffix: suffix,
code: out.code,
})
return { return {
ok: true, ok: true,
code: 200, code: 200,
} }
} }
console.log("[ APN RELAY ] push:failed", {
env: input.env,
host: apnsHost,
bundle: input.bundle,
tokenSuffix: suffix,
code: out.code,
error: out.body,
})
return { return {
ok: false, ok: false,
code: out.code, code: out.code,

View File

@ -1,7 +1,7 @@
{ {
"expo": { "expo": {
"name": "Control", "name": "Control",
"slug": "mobile-voice", "slug": "control",
"version": "1.0.0", "version": "1.0.0",
"orientation": "portrait", "orientation": "portrait",
"icon": "./assets/images/icon.png", "icon": "./assets/images/icon.png",
@ -66,7 +66,8 @@
[ [
"expo-notifications", "expo-notifications",
{ {
"enableBackgroundRemoteNotifications": true "enableBackgroundRemoteNotifications": true,
"sounds": ["./assets/sounds/alert.wav"]
} }
] ]
], ],
@ -77,8 +78,9 @@
"extra": { "extra": {
"router": {}, "router": {},
"eas": { "eas": {
"projectId": "89248f34-51fc-49e9-acb3-728497520c5a" "projectId": "50b3dac3-8b5e-4142-b749-65ecf7b2904d"
} }
} },
"owner": "anomaly-co"
} }
} }

Binary file not shown.

View File

@ -1,7 +0,0 @@
module.exports = function (api) {
api.cache(true);
return {
presets: ['babel-preset-expo'],
plugins: ['react-native-reanimated/plugin'],
};
};

View File

@ -0,0 +1,10 @@
// https://docs.expo.dev/guides/using-eslint/
const { defineConfig } = require('eslint/config');
const expoConfig = require("eslint-config-expo/flat");
module.exports = defineConfig([
expoConfig,
{
ignores: ["dist/*"],
}
]);

View File

@ -1 +1,4 @@
- While the model is loading for the first time, there should be some fun little like onboarding sequence that you can go through that makes sure the model is automated properly. - While the model is loading for the first time, there should be some fun little like onboarding sequence that you can go through that makes sure the model is automated properly.
- When a permission/session complete notification is sent, if you click on it, the session/server should auto be selected.
- We need some sort of permissions UI in the top half of the generation.
- Need to figure out a good way to start new sessions.

View File

@ -246,6 +246,51 @@ function mergeTranscriptChunk(previous: string, chunk: string): string {
return `${cleanPrevious} ${normalizedChunk}` return `${cleanPrevious} ${normalizedChunk}`
} }
type SessionMessageInfo = {
role?: unknown
time?: unknown
}
type SessionMessagePart = {
type?: unknown
text?: unknown
}
type SessionMessagePayload = {
info?: unknown
parts?: unknown
}
function findLatestAssistantCompletionText(payload: unknown): string {
if (!Array.isArray(payload)) return ""
for (let index = payload.length - 1; index >= 0; index -= 1) {
const candidate = payload[index] as SessionMessagePayload
if (!candidate || typeof candidate !== "object") continue
const info = candidate.info as SessionMessageInfo
if (!info || typeof info !== "object") continue
if (info.role !== "assistant") continue
const time = info.time as { completed?: unknown } | undefined
if (!time || typeof time !== "object") continue
if (typeof time.completed !== "number") continue
const parts = Array.isArray(candidate.parts) ? (candidate.parts as SessionMessagePart[]) : []
const text = parts
.filter((part) => part && part.type === "text" && typeof part.text === "string")
.map((part) => cleanSessionText(part.text as string))
.filter((part) => part.length > 0)
.join("\n\n")
if (text.length > 0) {
return text
}
}
return ""
}
type ServerItem = { type ServerItem = {
id: string id: string
name: string name: string
@ -354,20 +399,48 @@ function parsePair(input: string): Pair | undefined {
} }
} }
function pickHost(list: string[]): string | undefined { function isLoopback(hostname: string): boolean {
const next = list.find((item) => { return hostname === "127.0.0.1" || hostname === "localhost" || hostname === "0.0.0.0" || hostname === "::1"
}
/**
* Race all non-loopback hosts in parallel by hitting /health.
* Returns the first one that responds with 200, or falls back to the
* first non-loopback entry (preserving server-side ordering) if none respond.
*/
async function pickHost(list: string[]): Promise<string | undefined> {
const candidates = list.filter((item) => {
try { try {
const url = new URL(item) return !isLoopback(new URL(item).hostname)
if (url.hostname === "127.0.0.1") return false
if (url.hostname === "localhost") return false
if (url.hostname === "0.0.0.0") return false
if (url.hostname === "::1") return false
return true
} catch { } catch {
return false return false
} }
}) })
return next ?? list[0]
if (!candidates.length) return list[0]
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 3000)
try {
const winner = await Promise.any(
candidates.map(async (host) => {
const res = await fetch(`${host.replace(/\/+$/, "")}/health`, {
method: "GET",
signal: controller.signal,
})
if (!res.ok) throw new Error(`${res.status}`)
return host
}),
)
return winner
} catch {
// all failed or timed out — fall back to first candidate (server already orders by reachability)
return candidates[0]
} finally {
clearTimeout(timeout)
controller.abort()
}
} }
function serverBases(input: string) { function serverBases(input: string) {
@ -473,6 +546,8 @@ export default function DictationScreen() {
const [isSending, setIsSending] = useState(false) const [isSending, setIsSending] = useState(false)
const [monitorJob, setMonitorJob] = useState<MonitorJob | null>(null) const [monitorJob, setMonitorJob] = useState<MonitorJob | null>(null)
const [monitorStatus, setMonitorStatus] = useState<string>("") const [monitorStatus, setMonitorStatus] = useState<string>("")
const [latestAssistantResponse, setLatestAssistantResponse] = useState("")
const [agentStateDismissed, setAgentStateDismissed] = useState(false)
const [devicePushToken, setDevicePushToken] = useState<string | null>(null) const [devicePushToken, setDevicePushToken] = useState<string | null>(null)
const [appState, setAppState] = useState<AppStateStatus>(AppState.currentState) const [appState, setAppState] = useState<AppStateStatus>(AppState.currentState)
const [dropdownMode, setDropdownMode] = useState<DropdownMode>("none") const [dropdownMode, setDropdownMode] = useState<DropdownMode>("none")
@ -488,6 +563,7 @@ export default function DictationScreen() {
const serversRef = useRef<ServerItem[]>([]) const serversRef = useRef<ServerItem[]>([])
const lastWaveformCommitRef = useRef(0) const lastWaveformCommitRef = useRef(0)
const sendPlayer = useAudioPlayer(require("../../assets/sounds/send-whoosh.mp3")) const sendPlayer = useAudioPlayer(require("../../assets/sounds/send-whoosh.mp3"))
const completePlayer = useAudioPlayer(require("../../assets/sounds/complete.wav"))
const isRecordingRef = useRef(false) const isRecordingRef = useRef(false)
const isStartingRef = useRef(false) const isStartingRef = useRef(false)
@ -513,6 +589,8 @@ export default function DictationScreen() {
const restoredRef = useRef(false) const restoredRef = useRef(false)
const whisperRestoredRef = useRef(false) const whisperRestoredRef = useRef(false)
const refreshSeqRef = useRef<Record<string, number>>({}) const refreshSeqRef = useRef<Record<string, number>>({})
const activeSessionIdRef = useRef<string | null>(null)
const latestAssistantRequestRef = useRef(0)
useEffect(() => { useEffect(() => {
serversRef.current = servers serversRef.current = servers
@ -600,6 +678,10 @@ export default function DictationScreen() {
monitorJobRef.current = monitorJob monitorJobRef.current = monitorJob
}, [monitorJob]) }, [monitorJob])
useEffect(() => {
activeSessionIdRef.current = activeSessionId
}, [activeSessionId])
const modelPath = useCallback((modelID: WhisperModelID) => `${WHISPER_MODELS_DIR}/${modelID}`, []) const modelPath = useCallback((modelID: WhisperModelID) => `${WHISPER_MODELS_DIR}/${modelID}`, [])
const refreshInstalledWhisperModels = useCallback(async () => { const refreshInstalledWhisperModels = useCallback(async () => {
@ -931,20 +1013,22 @@ export default function DictationScreen() {
useEffect(() => { useEffect(() => {
const notificationSub = Notifications.addNotificationReceivedListener((notification: unknown) => { const notificationSub = Notifications.addNotificationReceivedListener((notification: unknown) => {
const data = (notification as { request?: { content?: { data?: unknown } } }).request?.content?.data as Record< const data = (notification as { request?: { content?: { data?: unknown } } }).request?.content?.data
string, if (!data || typeof data !== "object") return
unknown const eventType = (data as { eventType?: unknown }).eventType
>
const eventType = data.eventType
if (eventType === "complete" || eventType === "permission" || eventType === "error") { if (eventType === "complete" || eventType === "permission" || eventType === "error") {
setMonitorStatus(formatMonitorEventLabel(eventType)) setMonitorStatus(formatMonitorEventLabel(eventType))
} }
if (eventType === "complete" || eventType === "error") { if (eventType === "complete") {
completePlayer.seekTo(0)
completePlayer.play()
setMonitorJob(null)
} else if (eventType === "error") {
setMonitorJob(null) setMonitorJob(null)
} }
}) })
return () => notificationSub.remove() return () => notificationSub.remove()
}, []) }, [completePlayer])
const finalizeRecordingState = useCallback(() => { const finalizeRecordingState = useCallback(() => {
isRecordingRef.current = false isRecordingRef.current = false
@ -1254,6 +1338,11 @@ export default function DictationScreen() {
setIsSending(false) setIsSending(false)
}, [clearIconRotation, clearWaveform, sendOutProgress, stopRecording]) }, [clearIconRotation, clearWaveform, sendOutProgress, stopRecording])
const handleHideAgentState = useCallback(() => {
Haptics.selectionAsync().catch(() => {})
setAgentStateDismissed(true)
}, [])
const resetTranscriptState = useCallback(() => { const resetTranscriptState = useCallback(() => {
if (isRecordingRef.current) { if (isRecordingRef.current) {
stopRecording() stopRecording()
@ -1451,8 +1540,36 @@ export default function DictationScreen() {
} }
}, []) }, [])
const loadLatestAssistantResponse = useCallback(async (baseURL: string, sessionID: string) => {
const requestID = latestAssistantRequestRef.current + 1
latestAssistantRequestRef.current = requestID
const base = baseURL.replace(/\/+$/, "")
try {
const response = await fetch(`${base}/session/${sessionID}/message?limit=60`)
if (!response.ok) {
throw new Error(`Session messages failed (${response.status})`)
}
const payload = (await response.json()) as unknown
const text = findLatestAssistantCompletionText(payload)
if (latestAssistantRequestRef.current !== requestID) return
if (activeSessionIdRef.current !== sessionID) return
setLatestAssistantResponse(text)
if (text) {
setAgentStateDismissed(false)
}
} catch {
if (latestAssistantRequestRef.current !== requestID) return
if (activeSessionIdRef.current !== sessionID) return
setLatestAssistantResponse("")
}
}, [])
const handleMonitorEvent = useCallback( const handleMonitorEvent = useCallback(
(eventType: MonitorEventType) => { (eventType: MonitorEventType, job: MonitorJob) => {
setMonitorStatus(formatMonitorEventLabel(eventType)) setMonitorStatus(formatMonitorEventLabel(eventType))
if (eventType === "permission") { if (eventType === "permission") {
@ -1462,8 +1579,11 @@ export default function DictationScreen() {
if (eventType === "complete") { if (eventType === "complete") {
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success).catch(() => {}) Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success).catch(() => {})
completePlayer.seekTo(0)
completePlayer.play()
stopForegroundMonitor() stopForegroundMonitor()
setMonitorJob(null) setMonitorJob(null)
void loadLatestAssistantResponse(job.opencodeBaseURL, job.sessionID)
return return
} }
@ -1471,7 +1591,7 @@ export default function DictationScreen() {
stopForegroundMonitor() stopForegroundMonitor()
setMonitorJob(null) setMonitorJob(null)
}, },
[stopForegroundMonitor], [completePlayer, loadLatestAssistantResponse, stopForegroundMonitor],
) )
const startForegroundMonitor = useCallback( const startForegroundMonitor = useCallback(
@ -1514,7 +1634,7 @@ export default function DictationScreen() {
const active = monitorJobRef.current const active = monitorJobRef.current
if (!active || active.id !== job.id) return if (!active || active.id !== job.id) return
handleMonitorEvent(eventType) handleMonitorEvent(eventType, job)
} }
} catch { } catch {
if (abortController.signal.aborted) return if (abortController.signal.aborted) return
@ -1555,6 +1675,16 @@ export default function DictationScreen() {
setMonitorStatus("") setMonitorStatus("")
}, [activeSessionId, stopForegroundMonitor]) }, [activeSessionId, stopForegroundMonitor])
useEffect(() => {
setLatestAssistantResponse("")
setAgentStateDismissed(false)
if (!activeServerId || !activeSessionId) return
const server = serversRef.current.find((item) => item.id === activeServerId)
if (!server || server.status !== "online") return
void loadLatestAssistantResponse(server.url, activeSessionId)
}, [activeServerId, activeSessionId, loadLatestAssistantResponse])
useEffect(() => { useEffect(() => {
return () => { return () => {
stopForegroundMonitor() stopForegroundMonitor()
@ -1685,6 +1815,11 @@ export default function DictationScreen() {
? WHISPER_MODEL_LABELS[downloadingModelID] ? WHISPER_MODEL_LABELS[downloadingModelID]
: WHISPER_MODEL_LABELS[defaultWhisperModel] : WHISPER_MODEL_LABELS[defaultWhisperModel]
const hasTranscript = transcribedText.trim().length > 0 const hasTranscript = transcribedText.trim().length > 0
const hasAssistantResponse = latestAssistantResponse.trim().length > 0
const hasAgentActivity = hasAssistantResponse || monitorStatus.trim().length > 0 || monitorJob !== null
const shouldShowAgentStateCard = hasAgentActivity && !agentStateDismissed
const agentStateIcon = monitorJob !== null ? "loading" : hasAssistantResponse ? "done" : "loading"
const agentStateText = hasAssistantResponse ? latestAssistantResponse : "Waiting for agent…"
const shouldShowSend = hasCompletedSession && hasTranscript const shouldShowSend = hasCompletedSession && hasTranscript
const activeServer = servers.find((s) => s.id === activeServerId) ?? null const activeServer = servers.find((s) => s.id === activeServerId) ?? null
const activeSession = activeServer?.sessions.find((s) => s.id === activeSessionId) ?? null const activeSession = activeServer?.sessions.find((s) => s.id === activeSessionId) ?? null
@ -1742,8 +1877,8 @@ export default function DictationScreen() {
easing: Easing.bezier(0.2, 0.8, 0.2, 1), easing: Easing.bezier(0.2, 0.8, 0.2, 1),
}) })
: withTiming(0, { : withTiming(0, {
duration: 220, duration: 360,
easing: Easing.bezier(0.4, 0, 0.2, 1), easing: Easing.bezier(0.22, 0.61, 0.36, 1),
}) })
}, [shouldShowSend, sendVisibility]) }, [shouldShowSend, sendVisibility])
@ -2241,7 +2376,7 @@ export default function DictationScreen() {
return return
} }
const host = pickHost(pair.hosts) void pickHost(pair.hosts).then((host) => {
if (!host) { if (!host) {
scanLockRef.current = false scanLockRef.current = false
return return
@ -2255,6 +2390,7 @@ export default function DictationScreen() {
setScanOpen(false) setScanOpen(false)
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success).catch(() => {}) Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success).catch(() => {})
})
}, },
[addServer], [addServer],
) )
@ -2273,11 +2409,22 @@ export default function DictationScreen() {
return () => clearInterval(timer) return () => clearInterval(timer)
}, [activeServerId, refreshServerStatusAndSessions]) }, [activeServerId, refreshServerStatusAndSessions])
// Stable key that only changes when relay-relevant server properties change
// (id, relayURL, relaySecret), not on status/session/sessionsLoading updates.
const relayServersKey = useMemo(
() =>
servers
.filter((s) => s.relaySecret.trim().length > 0)
.map((s) => `${s.id}:${s.relayURL}:${s.relaySecret.trim()}`)
.join("|"),
[servers],
)
useEffect(() => { useEffect(() => {
if (Platform.OS !== "ios") return if (Platform.OS !== "ios") return
if (!devicePushToken) return if (!devicePushToken) return
const list = servers.filter((server) => server.relaySecret.trim().length > 0) const list = serversRef.current.filter((server) => server.relaySecret.trim().length > 0)
if (!list.length) return if (!list.length) return
const bundleId = Constants.expoConfig?.ios?.bundleIdentifier ?? "com.anomalyco.mobilevoice" const bundleId = Constants.expoConfig?.ios?.bundleIdentifier ?? "com.anomalyco.mobilevoice"
@ -2322,7 +2469,7 @@ export default function DictationScreen() {
} }
}), }),
).catch(() => {}) ).catch(() => {})
}, [devicePushToken, servers]) }, [devicePushToken, relayServersKey])
useEffect(() => { useEffect(() => {
if (Platform.OS !== "ios") return if (Platform.OS !== "ios") return
@ -2331,7 +2478,7 @@ export default function DictationScreen() {
previousPushTokenRef.current = devicePushToken previousPushTokenRef.current = devicePushToken
if (!previous || previous === devicePushToken) return if (!previous || previous === devicePushToken) return
const list = servers.filter((server) => server.relaySecret.trim().length > 0) const list = serversRef.current.filter((server) => server.relaySecret.trim().length > 0)
if (!list.length) return if (!list.length) return
console.log("[Relay] unregister:batch", { console.log("[Relay] unregister:batch", {
previousSuffix: previous.slice(-8), previousSuffix: previous.slice(-8),
@ -2365,7 +2512,7 @@ export default function DictationScreen() {
} }
}), }),
).catch(() => {}) ).catch(() => {})
}, [devicePushToken, servers]) }, [devicePushToken, relayServersKey])
const defaultModelInstalled = installedWhisperModels.includes(defaultWhisperModel) const defaultModelInstalled = installedWhisperModels.includes(defaultWhisperModel)
const onboardingProgressRaw = downloadingModelID const onboardingProgressRaw = downloadingModelID
@ -2628,6 +2775,34 @@ export default function DictationScreen() {
{/* Transcription area */} {/* Transcription area */}
<View style={styles.transcriptionArea}> <View style={styles.transcriptionArea}>
{shouldShowAgentStateCard ? (
<View style={styles.splitCardStack}>
<View style={[styles.splitCard, styles.replyCard]}>
<View style={styles.agentStateHeaderRow}>
<View style={styles.agentStateTitleWrap}>
<View style={styles.agentStateIconWrap}>
{agentStateIcon === "loading" ? (
<ActivityIndicator size="small" color="#91A0C0" />
) : (
<SymbolView
name={{ ios: "checkmark.circle.fill", android: "check_circle", web: "check_circle" }}
size={16}
tintColor="#91C29D"
/>
)}
</View>
<Text style={styles.replyCardLabel}>Agent</Text>
</View>
<Pressable onPress={handleHideAgentState} hitSlop={8}>
<Text style={styles.agentStateClose}></Text>
</Pressable>
</View>
<ScrollView style={styles.replyScroll} contentContainerStyle={styles.replyContent}>
<Text style={styles.replyText}>{agentStateText}</Text>
</ScrollView>
</View>
<View style={styles.transcriptionPanel}>
<View style={styles.transcriptionTopActions} pointerEvents="box-none"> <View style={styles.transcriptionTopActions} pointerEvents="box-none">
<Pressable <Pressable
onPress={handleOpenWhisperSettings} onPress={handleOpenWhisperSettings}
@ -2650,12 +2825,6 @@ export default function DictationScreen() {
</Pressable> </Pressable>
</View> </View>
{monitorStatus ? (
<View style={styles.monitorBadge}>
<Text style={styles.monitorBadgeText}>{monitorStatus}</Text>
</View>
) : null}
{whisperError ? ( {whisperError ? (
<View style={styles.modelErrorBadge}> <View style={styles.modelErrorBadge}>
<Text style={styles.modelErrorText}>{whisperError}</Text> <Text style={styles.modelErrorText}>{whisperError}</Text>
@ -2691,6 +2860,68 @@ export default function DictationScreen() {
))} ))}
</Animated.View> </Animated.View>
</View> </View>
</View>
) : (
<View style={styles.transcriptionPanel}>
<View style={styles.transcriptionTopActions} pointerEvents="box-none">
<Pressable
onPress={handleOpenWhisperSettings}
style={({ pressed }) => [styles.clearButton, pressed && styles.clearButtonPressed]}
hitSlop={8}
>
<SymbolView
name={{ ios: "gearshape.fill", android: "settings", web: "settings" }}
size={18}
weight="semibold"
tintColor="#B8BDC9"
/>
</Pressable>
<Pressable
onPress={handleClearTranscript}
style={({ pressed }) => [styles.clearButton, pressed && styles.clearButtonPressed]}
hitSlop={8}
>
<Animated.Text style={[styles.clearIcon, animatedClearIconStyle]}></Animated.Text>
</Pressable>
</View>
{whisperError ? (
<View style={styles.modelErrorBadge}>
<Text style={styles.modelErrorText}>{whisperError}</Text>
</View>
) : null}
<ScrollView
ref={scrollViewRef}
style={styles.transcriptionScroll}
contentContainerStyle={styles.transcriptionContent}
onContentSizeChange={() => scrollViewRef.current?.scrollToEnd({ animated: true })}
>
<Animated.View style={animatedTranscriptSendStyle}>
{transcribedText ? (
<Text style={styles.transcriptionText}>{transcribedText}</Text>
) : (
<Text style={styles.placeholderText}>Your transcription will appear here</Text>
)}
</Animated.View>
</ScrollView>
<Animated.View
style={[styles.waveformBoxesRow, animatedWaveformRowStyle]}
pointerEvents="none"
onLayout={handleWaveformLayout}
>
{Array.from({ length: WAVEFORM_ROWS }).map((_, row) => (
<View key={`row-${row}`} style={styles.waveformGridRow}>
{waveformLevels.map((_, col) => (
<View key={`cell-${row}-${col}`} style={[styles.waveformBox, getWaveformCellStyle(row, col)]} />
))}
</View>
))}
</Animated.View>
</View>
)}
</View>
{/* Record button */} {/* Record button */}
<View style={styles.controlsRow} onLayout={handleControlsLayout}> <View style={styles.controlsRow} onLayout={handleControlsLayout}>
@ -3223,6 +3454,13 @@ const styles = StyleSheet.create({
flex: 1, flex: 1,
marginHorizontal: 6, marginHorizontal: 6,
marginTop: 6, marginTop: 6,
},
splitCardStack: {
flex: 1,
gap: 8,
},
splitCard: {
flex: 1,
backgroundColor: "#151515", backgroundColor: "#151515",
borderRadius: 20, borderRadius: 20,
borderWidth: 3, borderWidth: 3,
@ -3230,6 +3468,57 @@ const styles = StyleSheet.create({
overflow: "hidden", overflow: "hidden",
position: "relative", position: "relative",
}, },
replyCard: {
paddingTop: 16,
},
transcriptionPanel: {
flex: 1,
position: "relative",
overflow: "hidden",
},
replyCardLabel: {
color: "#AAB5CC",
fontSize: 15,
fontWeight: "600",
},
agentStateHeaderRow: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
marginHorizontal: 20,
marginBottom: 8,
},
agentStateTitleWrap: {
flexDirection: "row",
alignItems: "center",
gap: 8,
},
agentStateIconWrap: {
width: 16,
height: 16,
alignItems: "center",
justifyContent: "center",
},
agentStateClose: {
color: "#8D97AB",
fontSize: 18,
fontWeight: "700",
lineHeight: 18,
},
replyScroll: {
flex: 1,
},
replyContent: {
paddingHorizontal: 20,
paddingBottom: 18,
flexGrow: 1,
},
replyText: {
fontSize: 22,
fontWeight: "500",
lineHeight: 32,
color: "#F4F7FF",
},
transcriptionScroll: { transcriptionScroll: {
flex: 1, flex: 1,
}, },
@ -3266,24 +3555,6 @@ const styles = StyleSheet.create({
fontWeight: "600", fontWeight: "600",
letterSpacing: 0.1, letterSpacing: 0.1,
}, },
monitorBadge: {
alignSelf: "flex-start",
marginLeft: 14,
marginTop: 12,
marginBottom: 4,
paddingHorizontal: 10,
paddingVertical: 5,
borderRadius: 999,
backgroundColor: "#1B2438",
borderWidth: 1,
borderColor: "#2B3D66",
},
monitorBadgeText: {
color: "#BFD0FA",
fontSize: 12,
fontWeight: "600",
letterSpacing: 0.2,
},
transcriptionText: { transcriptionText: {
fontSize: 28, fontSize: 28,
fontWeight: "500", fontWeight: "500",

View File

@ -23,6 +23,8 @@ TaskManager.defineTask(BACKGROUND_TASK_NAME, async ({ data }: { data?: unknown }
title, title,
body, body,
data: payload ?? {}, data: payload ?? {},
sound: "alert.wav",
...(Platform.OS === "android" ? { channelId: "monitoring" } : {}),
}, },
trigger: null, trigger: null,
}) })
@ -55,6 +57,7 @@ export async function ensureNotificationPermissions(): Promise<boolean> {
await Notifications.setNotificationChannelAsync("monitoring", { await Notifications.setNotificationChannelAsync("monitoring", {
name: "OpenCode Monitoring", name: "OpenCode Monitoring",
importance: Notifications.AndroidImportance.HIGH, importance: Notifications.AndroidImportance.HIGH,
sound: "alert.wav",
}) })
} }

View File

@ -10,13 +10,30 @@ import { Installation } from "../../installation"
import { PushRelay } from "../../server/push-relay" import { PushRelay } from "../../server/push-relay"
import * as QRCode from "qrcode" import * as QRCode from "qrcode"
function ipTier(address: string): number {
const parts = address.split(".")
if (parts.length !== 4) return 4
const a = Number(parts[0])
const b = Number(parts[1])
if (a === 127) return 4
if (a === 169 && b === 254) return 3
if (a === 10) return 2
if (a === 172 && b >= 16 && b <= 31) return 2
if (a === 192 && b === 168) return 2
if (a === 100 && b >= 64 && b <= 127) return 1
return 0
}
function hosts(hostname: string, port: number) { function hosts(hostname: string, port: number) {
const list = new Set<string>() const seen = new Set<string>()
const entries: Array<{ url: string; tier: number }> = []
const add = (item: string) => { const add = (item: string) => {
if (!item) return if (!item) return
if (item === "0.0.0.0") return if (item === "0.0.0.0") return
if (item === "::") return if (item === "::") return
list.add(`http://${item}:${port}`) if (seen.has(item)) return
seen.add(item)
entries.push({ url: `http://${item}:${port}`, tier: ipTier(item) })
} }
add(hostname) add(hostname)
add("127.0.0.1") add("127.0.0.1")
@ -25,7 +42,8 @@ function hosts(hostname: string, port: number) {
.filter((item) => item.family === "IPv4" && !item.internal) .filter((item) => item.family === "IPv4" && !item.internal)
.map((item) => item.address) .map((item) => item.address)
.forEach(add) .forEach(add)
return [...list] entries.sort((a, b) => a.tier - b.tier)
return entries.map((item) => item.url)
} }
export const ServeCommand = cmd({ export const ServeCommand = cmd({

View File

@ -56,13 +56,48 @@ function norm(input: string) {
return input.replace(/\/+$/, "") return input.replace(/\/+$/, "")
} }
/**
* Classify an IPv4 address into a reachability tier.
* Lower number = more likely reachable from an external/overlay network device.
*
* 0 public / routable
* 1 CGNAT / shared (100.64.0.0/10) used by Tailscale, Cloudflare WARP, carrier NAT, etc.
* 2 private LAN (10.0.0.0/8, 172.16-31.x, 192.168.x)
* 3 link-local (169.254.x)
* 4 loopback (127.x)
*/
function ipTier(address: string): number {
const parts = address.split(".")
if (parts.length !== 4) return 4
const a = Number(parts[0])
const b = Number(parts[1])
// loopback 127.0.0.0/8
if (a === 127) return 4
// link-local 169.254.0.0/16
if (a === 169 && b === 254) return 3
// private 10.0.0.0/8
if (a === 10) return 2
// private 172.16.0.0/12
if (a === 172 && b >= 16 && b <= 31) return 2
// private 192.168.0.0/16
if (a === 192 && b === 168) return 2
// CGNAT / shared address space 100.64.0.0/10 (100.64.x 100.127.x)
if (a === 100 && b >= 64 && b <= 127) return 1
// everything else is routable
return 0
}
function list(hostname: string, port: number) { function list(hostname: string, port: number) {
const urls = new Set<string>() const seen = new Set<string>()
const hosts: Array<{ url: string; tier: number }> = []
const add = (host: string) => { const add = (host: string) => {
if (!host) return if (!host) return
if (host === "0.0.0.0") return if (host === "0.0.0.0") return
if (host === "::") return if (host === "::") return
urls.add(`http://${host}:${port}`) if (seen.has(host)) return
seen.add(host)
hosts.push({ url: `http://${host}:${port}`, tier: ipTier(host) })
} }
add(hostname) add(hostname)
@ -75,7 +110,10 @@ function list(hostname: string, port: number) {
nets.forEach(add) nets.forEach(add)
return [...urls] // sort: most externally reachable first, loopback last
hosts.sort((a, b) => a.tier - b.tier)
return hosts.map((item) => item.url)
} }
function map(event: Event): { type: Type; sessionID: string } | undefined { function map(event: Event): { type: Type; sessionID: string } | undefined {
@ -216,6 +254,20 @@ async function post(input: { type: Type; sessionID: string }) {
const content = await notify(input) const content = await notify(input)
console.log("[ APN RELAY ] posting event", {
relayURL: next.relayURL,
type: input.type,
sessionID: input.sessionID,
title: content.title,
})
log.info("[ APN RELAY ] posting event", {
relayURL: next.relayURL,
type: input.type,
sessionID: input.sessionID,
title: content.title,
})
void fetch(`${next.relayURL}/v1/event`, { void fetch(`${next.relayURL}/v1/event`, {
method: "POST", method: "POST",
headers: { headers: {
@ -230,7 +282,22 @@ async function post(input: { type: Type; sessionID: string }) {
}), }),
}) })
.then(async (res) => { .then(async (res) => {
if (res.ok) return if (res.ok) {
console.log("[ APN RELAY ] relay accepted event", {
status: res.status,
type: input.type,
sessionID: input.sessionID,
title: content.title,
})
log.info("[ APN RELAY ] relay accepted event", {
status: res.status,
type: input.type,
sessionID: input.sessionID,
title: content.title,
})
return
}
const error = await res.text().catch(() => "") const error = await res.text().catch(() => "")
log.warn("relay post failed", { log.warn("relay post failed", {
status: res.status, status: res.status,