diff --git a/packages/apn-relay/src/apns.ts b/packages/apn-relay/src/apns.ts index e0256175fa..bed6f286f8 100644 --- a/packages/apn-relay/src/apns.ts +++ b/packages/apn-relay/src/apns.ts @@ -19,6 +19,10 @@ type PushResult = { error?: string } +function tokenSuffix(input: string) { + return input.length > 8 ? input.slice(-8) : input +} + let jwt = "" let exp = 0 let pk: Awaited> | undefined @@ -100,10 +104,27 @@ function post(input: { } export async function send(input: PushInput): Promise { + 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) => { return `error:${String(err)}` }) if (auth.startsWith("error:")) { + console.log("[ APN RELAY ] push:auth-failed", { + env: input.env, + host: apnsHost, + bundle: input.bundle, + tokenSuffix: suffix, + error: auth, + }) return { ok: false, code: 0, @@ -117,13 +138,13 @@ export async function send(input: PushInput): Promise { title: input.title, body: input.body, }, - sound: "default", + sound: "alert.wav", }, ...input.data, }) const out = await post({ - host: host(input.env), + host: apnsHost, token: input.token, auth, bundle: input.bundle, @@ -134,12 +155,28 @@ export async function send(input: PushInput): Promise { })) if (out.code === 200) { + console.log("[ APN RELAY ] push:sent", { + env: input.env, + host: apnsHost, + bundle: input.bundle, + tokenSuffix: suffix, + code: out.code, + }) return { ok: true, 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 { ok: false, code: out.code, diff --git a/packages/mobile-voice/app.json b/packages/mobile-voice/app.json index a8b46ea0f0..033229c4b5 100644 --- a/packages/mobile-voice/app.json +++ b/packages/mobile-voice/app.json @@ -1,7 +1,7 @@ { "expo": { "name": "Control", - "slug": "mobile-voice", + "slug": "control", "version": "1.0.0", "orientation": "portrait", "icon": "./assets/images/icon.png", @@ -66,7 +66,8 @@ [ "expo-notifications", { - "enableBackgroundRemoteNotifications": true + "enableBackgroundRemoteNotifications": true, + "sounds": ["./assets/sounds/alert.wav"] } ] ], @@ -77,8 +78,9 @@ "extra": { "router": {}, "eas": { - "projectId": "89248f34-51fc-49e9-acb3-728497520c5a" + "projectId": "50b3dac3-8b5e-4142-b749-65ecf7b2904d" } - } + }, + "owner": "anomaly-co" } } diff --git a/packages/mobile-voice/assets/sounds/alert.wav b/packages/mobile-voice/assets/sounds/alert.wav new file mode 100644 index 0000000000..1a4bf311f8 Binary files /dev/null and b/packages/mobile-voice/assets/sounds/alert.wav differ diff --git a/packages/mobile-voice/assets/sounds/complete.wav b/packages/mobile-voice/assets/sounds/complete.wav new file mode 100644 index 0000000000..3178ecabea Binary files /dev/null and b/packages/mobile-voice/assets/sounds/complete.wav differ diff --git a/packages/mobile-voice/babel.config.js b/packages/mobile-voice/babel.config.js deleted file mode 100644 index d872de3f55..0000000000 --- a/packages/mobile-voice/babel.config.js +++ /dev/null @@ -1,7 +0,0 @@ -module.exports = function (api) { - api.cache(true); - return { - presets: ['babel-preset-expo'], - plugins: ['react-native-reanimated/plugin'], - }; -}; diff --git a/packages/mobile-voice/eslint.config.js b/packages/mobile-voice/eslint.config.js new file mode 100644 index 0000000000..ba708ed9ff --- /dev/null +++ b/packages/mobile-voice/eslint.config.js @@ -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/*"], + } +]); diff --git a/packages/mobile-voice/notes.md b/packages/mobile-voice/notes.md index 83ebcbe186..3fc5678715 100644 --- a/packages/mobile-voice/notes.md +++ b/packages/mobile-voice/notes.md @@ -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. \ No newline at end of file +- 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. \ No newline at end of file diff --git a/packages/mobile-voice/src/app/index.tsx b/packages/mobile-voice/src/app/index.tsx index 4048e7a415..4b33fd4bbb 100644 --- a/packages/mobile-voice/src/app/index.tsx +++ b/packages/mobile-voice/src/app/index.tsx @@ -246,6 +246,51 @@ function mergeTranscriptChunk(previous: string, chunk: string): string { 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 = { id: string name: string @@ -354,20 +399,48 @@ function parsePair(input: string): Pair | undefined { } } -function pickHost(list: string[]): string | undefined { - const next = list.find((item) => { +function isLoopback(hostname: string): boolean { + 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 { + const candidates = list.filter((item) => { try { - const url = new URL(item) - 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 + return !isLoopback(new URL(item).hostname) } catch { 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) { @@ -473,6 +546,8 @@ export default function DictationScreen() { const [isSending, setIsSending] = useState(false) const [monitorJob, setMonitorJob] = useState(null) const [monitorStatus, setMonitorStatus] = useState("") + const [latestAssistantResponse, setLatestAssistantResponse] = useState("") + const [agentStateDismissed, setAgentStateDismissed] = useState(false) const [devicePushToken, setDevicePushToken] = useState(null) const [appState, setAppState] = useState(AppState.currentState) const [dropdownMode, setDropdownMode] = useState("none") @@ -488,6 +563,7 @@ export default function DictationScreen() { const serversRef = useRef([]) const lastWaveformCommitRef = useRef(0) const sendPlayer = useAudioPlayer(require("../../assets/sounds/send-whoosh.mp3")) + const completePlayer = useAudioPlayer(require("../../assets/sounds/complete.wav")) const isRecordingRef = useRef(false) const isStartingRef = useRef(false) @@ -513,6 +589,8 @@ export default function DictationScreen() { const restoredRef = useRef(false) const whisperRestoredRef = useRef(false) const refreshSeqRef = useRef>({}) + const activeSessionIdRef = useRef(null) + const latestAssistantRequestRef = useRef(0) useEffect(() => { serversRef.current = servers @@ -600,6 +678,10 @@ export default function DictationScreen() { monitorJobRef.current = monitorJob }, [monitorJob]) + useEffect(() => { + activeSessionIdRef.current = activeSessionId + }, [activeSessionId]) + const modelPath = useCallback((modelID: WhisperModelID) => `${WHISPER_MODELS_DIR}/${modelID}`, []) const refreshInstalledWhisperModels = useCallback(async () => { @@ -931,20 +1013,22 @@ export default function DictationScreen() { useEffect(() => { const notificationSub = Notifications.addNotificationReceivedListener((notification: unknown) => { - const data = (notification as { request?: { content?: { data?: unknown } } }).request?.content?.data as Record< - string, - unknown - > - const eventType = data.eventType + const data = (notification as { request?: { content?: { data?: unknown } } }).request?.content?.data + if (!data || typeof data !== "object") return + const eventType = (data as { eventType?: unknown }).eventType if (eventType === "complete" || eventType === "permission" || eventType === "error") { setMonitorStatus(formatMonitorEventLabel(eventType)) } - if (eventType === "complete" || eventType === "error") { + if (eventType === "complete") { + completePlayer.seekTo(0) + completePlayer.play() + setMonitorJob(null) + } else if (eventType === "error") { setMonitorJob(null) } }) return () => notificationSub.remove() - }, []) + }, [completePlayer]) const finalizeRecordingState = useCallback(() => { isRecordingRef.current = false @@ -1254,6 +1338,11 @@ export default function DictationScreen() { setIsSending(false) }, [clearIconRotation, clearWaveform, sendOutProgress, stopRecording]) + const handleHideAgentState = useCallback(() => { + Haptics.selectionAsync().catch(() => {}) + setAgentStateDismissed(true) + }, []) + const resetTranscriptState = useCallback(() => { if (isRecordingRef.current) { 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( - (eventType: MonitorEventType) => { + (eventType: MonitorEventType, job: MonitorJob) => { setMonitorStatus(formatMonitorEventLabel(eventType)) if (eventType === "permission") { @@ -1462,8 +1579,11 @@ export default function DictationScreen() { if (eventType === "complete") { Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success).catch(() => {}) + completePlayer.seekTo(0) + completePlayer.play() stopForegroundMonitor() setMonitorJob(null) + void loadLatestAssistantResponse(job.opencodeBaseURL, job.sessionID) return } @@ -1471,7 +1591,7 @@ export default function DictationScreen() { stopForegroundMonitor() setMonitorJob(null) }, - [stopForegroundMonitor], + [completePlayer, loadLatestAssistantResponse, stopForegroundMonitor], ) const startForegroundMonitor = useCallback( @@ -1514,7 +1634,7 @@ export default function DictationScreen() { const active = monitorJobRef.current if (!active || active.id !== job.id) return - handleMonitorEvent(eventType) + handleMonitorEvent(eventType, job) } } catch { if (abortController.signal.aborted) return @@ -1555,6 +1675,16 @@ export default function DictationScreen() { setMonitorStatus("") }, [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(() => { return () => { stopForegroundMonitor() @@ -1685,6 +1815,11 @@ export default function DictationScreen() { ? WHISPER_MODEL_LABELS[downloadingModelID] : WHISPER_MODEL_LABELS[defaultWhisperModel] 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 activeServer = servers.find((s) => s.id === activeServerId) ?? 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), }) : withTiming(0, { - duration: 220, - easing: Easing.bezier(0.4, 0, 0.2, 1), + duration: 360, + easing: Easing.bezier(0.22, 0.61, 0.36, 1), }) }, [shouldShowSend, sendVisibility]) @@ -2241,20 +2376,21 @@ export default function DictationScreen() { return } - const host = pickHost(pair.hosts) - if (!host) { - scanLockRef.current = false - return - } + void pickHost(pair.hosts).then((host) => { + if (!host) { + scanLockRef.current = false + return + } - const ok = addServer(host, pair.relayURL, pair.relaySecret) - if (!ok) { - scanLockRef.current = false - return - } + const ok = addServer(host, pair.relayURL, pair.relaySecret) + if (!ok) { + scanLockRef.current = false + return + } - setScanOpen(false) - Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success).catch(() => {}) + setScanOpen(false) + Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success).catch(() => {}) + }) }, [addServer], ) @@ -2273,11 +2409,22 @@ export default function DictationScreen() { return () => clearInterval(timer) }, [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(() => { if (Platform.OS !== "ios") 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 const bundleId = Constants.expoConfig?.ios?.bundleIdentifier ?? "com.anomalyco.mobilevoice" @@ -2322,7 +2469,7 @@ export default function DictationScreen() { } }), ).catch(() => {}) - }, [devicePushToken, servers]) + }, [devicePushToken, relayServersKey]) useEffect(() => { if (Platform.OS !== "ios") return @@ -2331,7 +2478,7 @@ export default function DictationScreen() { previousPushTokenRef.current = devicePushToken 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 console.log("[Relay] unregister:batch", { previousSuffix: previous.slice(-8), @@ -2365,7 +2512,7 @@ export default function DictationScreen() { } }), ).catch(() => {}) - }, [devicePushToken, servers]) + }, [devicePushToken, relayServersKey]) const defaultModelInstalled = installedWhisperModels.includes(defaultWhisperModel) const onboardingProgressRaw = downloadingModelID @@ -2628,68 +2775,152 @@ export default function DictationScreen() { {/* Transcription area */} - - [styles.clearButton, pressed && styles.clearButtonPressed]} - hitSlop={8} - > - - - [styles.clearButton, pressed && styles.clearButtonPressed]} - hitSlop={8} - > - - - - - {monitorStatus ? ( - - {monitorStatus} - - ) : null} - - {whisperError ? ( - - {whisperError} - - ) : null} - - scrollViewRef.current?.scrollToEnd({ animated: true })} - > - - {transcribedText ? ( - {transcribedText} - ) : ( - Your transcription will appear here… - )} - - - - - {Array.from({ length: WAVEFORM_ROWS }).map((_, row) => ( - - {waveformLevels.map((_, col) => ( - - ))} + {shouldShowAgentStateCard ? ( + + + + + + {agentStateIcon === "loading" ? ( + + ) : ( + + )} + + Agent + + + + + + + {agentStateText} + - ))} - + + + + [styles.clearButton, pressed && styles.clearButtonPressed]} + hitSlop={8} + > + + + [styles.clearButton, pressed && styles.clearButtonPressed]} + hitSlop={8} + > + + + + + {whisperError ? ( + + {whisperError} + + ) : null} + + scrollViewRef.current?.scrollToEnd({ animated: true })} + > + + {transcribedText ? ( + {transcribedText} + ) : ( + Your transcription will appear here… + )} + + + + + {Array.from({ length: WAVEFORM_ROWS }).map((_, row) => ( + + {waveformLevels.map((_, col) => ( + + ))} + + ))} + + + + ) : ( + + + [styles.clearButton, pressed && styles.clearButtonPressed]} + hitSlop={8} + > + + + [styles.clearButton, pressed && styles.clearButtonPressed]} + hitSlop={8} + > + + + + + {whisperError ? ( + + {whisperError} + + ) : null} + + scrollViewRef.current?.scrollToEnd({ animated: true })} + > + + {transcribedText ? ( + {transcribedText} + ) : ( + Your transcription will appear here… + )} + + + + + {Array.from({ length: WAVEFORM_ROWS }).map((_, row) => ( + + {waveformLevels.map((_, col) => ( + + ))} + + ))} + + + )} {/* Record button */} @@ -3223,6 +3454,13 @@ const styles = StyleSheet.create({ flex: 1, marginHorizontal: 6, marginTop: 6, + }, + splitCardStack: { + flex: 1, + gap: 8, + }, + splitCard: { + flex: 1, backgroundColor: "#151515", borderRadius: 20, borderWidth: 3, @@ -3230,6 +3468,57 @@ const styles = StyleSheet.create({ overflow: "hidden", 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: { flex: 1, }, @@ -3266,24 +3555,6 @@ const styles = StyleSheet.create({ fontWeight: "600", 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: { fontSize: 28, fontWeight: "500", diff --git a/packages/mobile-voice/src/notifications/monitoring-notifications.ts b/packages/mobile-voice/src/notifications/monitoring-notifications.ts index 227338f2bc..05dfbf8dfc 100644 --- a/packages/mobile-voice/src/notifications/monitoring-notifications.ts +++ b/packages/mobile-voice/src/notifications/monitoring-notifications.ts @@ -23,6 +23,8 @@ TaskManager.defineTask(BACKGROUND_TASK_NAME, async ({ data }: { data?: unknown } title, body, data: payload ?? {}, + sound: "alert.wav", + ...(Platform.OS === "android" ? { channelId: "monitoring" } : {}), }, trigger: null, }) @@ -55,6 +57,7 @@ export async function ensureNotificationPermissions(): Promise { await Notifications.setNotificationChannelAsync("monitoring", { name: "OpenCode Monitoring", importance: Notifications.AndroidImportance.HIGH, + sound: "alert.wav", }) } diff --git a/packages/opencode/src/cli/cmd/serve.ts b/packages/opencode/src/cli/cmd/serve.ts index c723cad882..ddca13f181 100644 --- a/packages/opencode/src/cli/cmd/serve.ts +++ b/packages/opencode/src/cli/cmd/serve.ts @@ -10,13 +10,30 @@ import { Installation } from "../../installation" import { PushRelay } from "../../server/push-relay" 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) { - const list = new Set() + const seen = new Set() + const entries: Array<{ url: string; tier: number }> = [] const add = (item: string) => { if (!item) return if (item === "0.0.0.0") 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("127.0.0.1") @@ -25,7 +42,8 @@ function hosts(hostname: string, port: number) { .filter((item) => item.family === "IPv4" && !item.internal) .map((item) => item.address) .forEach(add) - return [...list] + entries.sort((a, b) => a.tier - b.tier) + return entries.map((item) => item.url) } export const ServeCommand = cmd({ diff --git a/packages/opencode/src/server/push-relay.ts b/packages/opencode/src/server/push-relay.ts index d4c5eecb3a..f612f78698 100644 --- a/packages/opencode/src/server/push-relay.ts +++ b/packages/opencode/src/server/push-relay.ts @@ -56,13 +56,48 @@ function norm(input: string) { 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) { - const urls = new Set() + const seen = new Set() + const hosts: Array<{ url: string; tier: number }> = [] const add = (host: string) => { if (!host) return if (host === "0.0.0.0") 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) @@ -75,7 +110,10 @@ function list(hostname: string, port: number) { 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 { @@ -216,6 +254,20 @@ async function post(input: { type: Type; sessionID: string }) { 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`, { method: "POST", headers: { @@ -230,7 +282,22 @@ async function post(input: { type: Type; sessionID: string }) { }), }) .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(() => "") log.warn("relay post failed", { status: res.status,