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
}
function tokenSuffix(input: string) {
return input.length > 8 ? input.slice(-8) : input
}
let jwt = ""
let exp = 0
let pk: Awaited<ReturnType<typeof importPKCS8>> | undefined
@ -100,10 +104,27 @@ function post(input: {
}
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) => {
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<PushResult> {
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<PushResult> {
}))
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,

View File

@ -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"
}
}

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.
- 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}`
}
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<string | undefined> {
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<MonitorJob | null>(null)
const [monitorStatus, setMonitorStatus] = useState<string>("")
const [latestAssistantResponse, setLatestAssistantResponse] = useState("")
const [agentStateDismissed, setAgentStateDismissed] = useState(false)
const [devicePushToken, setDevicePushToken] = useState<string | null>(null)
const [appState, setAppState] = useState<AppStateStatus>(AppState.currentState)
const [dropdownMode, setDropdownMode] = useState<DropdownMode>("none")
@ -488,6 +563,7 @@ export default function DictationScreen() {
const serversRef = useRef<ServerItem[]>([])
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<Record<string, number>>({})
const activeSessionIdRef = useRef<string | null>(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,7 +2376,7 @@ export default function DictationScreen() {
return
}
const host = pickHost(pair.hosts)
void pickHost(pair.hosts).then((host) => {
if (!host) {
scanLockRef.current = false
return
@ -2255,6 +2390,7 @@ export default function DictationScreen() {
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,6 +2775,34 @@ export default function DictationScreen() {
{/* Transcription area */}
<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">
<Pressable
onPress={handleOpenWhisperSettings}
@ -2650,12 +2825,6 @@ export default function DictationScreen() {
</Pressable>
</View>
{monitorStatus ? (
<View style={styles.monitorBadge}>
<Text style={styles.monitorBadgeText}>{monitorStatus}</Text>
</View>
) : null}
{whisperError ? (
<View style={styles.modelErrorBadge}>
<Text style={styles.modelErrorText}>{whisperError}</Text>
@ -2691,6 +2860,68 @@ export default function DictationScreen() {
))}
</Animated.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 */}
<View style={styles.controlsRow} onLayout={handleControlsLayout}>
@ -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",

View File

@ -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<boolean> {
await Notifications.setNotificationChannelAsync("monitoring", {
name: "OpenCode Monitoring",
importance: Notifications.AndroidImportance.HIGH,
sound: "alert.wav",
})
}

View File

@ -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<string>()
const seen = new Set<string>()
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({

View File

@ -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<string>()
const seen = new Set<string>()
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,