feat: route push notifications by server and session

Include serverID in relay event payloads and prefer server+session matching in mobile notification handling so taps reliably open the correct context and stale state is refreshed.
This commit is contained in:
Ryan Vogel 2026-03-29 17:52:07 -04:00
commit d3ec6f75f4
3 changed files with 326 additions and 18 deletions

View file

@ -50,6 +50,7 @@ const unreg = z.object({
const evt = z.object({ const evt = z.object({
secret: z.string().min(1), secret: z.string().min(1),
serverID: z.string().min(1).optional(),
eventType: z.enum(["complete", "permission", "error"]), eventType: z.enum(["complete", "permission", "error"]),
sessionID: z.string().min(1), sessionID: z.string().min(1),
title: z.string().min(1).optional(), title: z.string().min(1).optional(),
@ -325,6 +326,7 @@ app.post("/v1/event", async (c) => {
const list = await db.select().from(device_registration).where(eq(device_registration.secret_hash, key)) const list = await db.select().from(device_registration).where(eq(device_registration.secret_hash, key))
console.log("[relay] event", { console.log("[relay] event", {
type: check.data.eventType, type: check.data.eventType,
serverID: check.data.serverID,
session: check.data.sessionID, session: check.data.sessionID,
secretHash: `${key.slice(0, 12)}...`, secretHash: `${key.slice(0, 12)}...`,
devices: list.length, devices: list.length,
@ -333,6 +335,7 @@ app.post("/v1/event", async (c) => {
const [total] = await db.select({ value: sql<number>`count(*)` }).from(device_registration) const [total] = await db.select({ value: sql<number>`count(*)` }).from(device_registration)
console.log("[relay] event:no-matching-devices", { console.log("[relay] event:no-matching-devices", {
type: check.data.eventType, type: check.data.eventType,
serverID: check.data.serverID,
session: check.data.sessionID, session: check.data.sessionID,
secretHash: `${key.slice(0, 12)}...`, secretHash: `${key.slice(0, 12)}...`,
totalDevices: Number(total?.value ?? 0), totalDevices: Number(total?.value ?? 0),
@ -354,6 +357,7 @@ app.post("/v1/event", async (c) => {
title: check.data.title ?? title(check.data.eventType), title: check.data.title ?? title(check.data.eventType),
body: check.data.body ?? body(check.data.eventType), body: check.data.body ?? body(check.data.eventType),
data: { data: {
serverID: check.data.serverID,
eventType: check.data.eventType, eventType: check.data.eventType,
sessionID: check.data.sessionID, sessionID: check.data.sessionID,
}, },

View file

@ -295,6 +295,7 @@ type ServerItem = {
id: string id: string
name: string name: string
url: string url: string
serverID: string | null
relayURL: string relayURL: string
relaySecret: string relaySecret: string
status: "checking" | "online" | "offline" status: "checking" | "online" | "offline"
@ -341,6 +342,7 @@ type DropdownMode = "none" | "server" | "session"
type Pair = { type Pair = {
v: 1 v: 1
serverID?: string
relayURL: string relayURL: string
relaySecret: string relaySecret: string
hosts: string[] hosts: string[]
@ -354,6 +356,7 @@ type SavedServer = {
id: string id: string
name: string name: string
url: string url: string
serverID: string | null
relayURL: string relayURL: string
relaySecret: string relaySecret: string
} }
@ -373,6 +376,41 @@ type OnboardingSavedState = {
completed: boolean completed: boolean
} }
type SessionRuntimeStatus = "idle" | "busy" | "retry"
type NotificationPayload = {
serverID: string | null
eventType: MonitorEventType | null
sessionID: string | null
}
function parseMonitorEventType(value: unknown): MonitorEventType | null {
if (value === "complete" || value === "permission" || value === "error") {
return value
}
return null
}
function parseNotificationPayload(data: unknown): NotificationPayload | null {
if (!data || typeof data !== "object") return null
const serverIDRaw = (data as { serverID?: unknown }).serverID
const serverID = typeof serverIDRaw === "string" && serverIDRaw.length > 0 ? serverIDRaw : null
const eventType = parseMonitorEventType((data as { eventType?: unknown }).eventType)
const sessionIDRaw = (data as { sessionID?: unknown }).sessionID
const sessionID = typeof sessionIDRaw === "string" && sessionIDRaw.length > 0 ? sessionIDRaw : null
if (!eventType && !sessionID && !serverID) return null
return {
serverID,
eventType,
sessionID,
}
}
type Cam = { type Cam = {
CameraView: (typeof import("expo-camera"))["CameraView"] CameraView: (typeof import("expo-camera"))["CameraView"]
requestCameraPermissionsAsync: () => Promise<{ granted: boolean }> requestCameraPermissionsAsync: () => Promise<{ granted: boolean }>
@ -388,8 +426,11 @@ function parsePair(input: string): Pair | undefined {
if (!Array.isArray((data as { hosts?: unknown }).hosts)) return if (!Array.isArray((data as { hosts?: unknown }).hosts)) return
const hosts = (data as { hosts: unknown[] }).hosts.filter((item): item is string => typeof item === "string") const hosts = (data as { hosts: unknown[] }).hosts.filter((item): item is string => typeof item === "string")
if (!hosts.length) return if (!hosts.length) return
const serverIDRaw = (data as { serverID?: unknown }).serverID
const serverID = typeof serverIDRaw === "string" && serverIDRaw.length > 0 ? serverIDRaw : undefined
return { return {
v: 1, v: 1,
serverID,
relayURL: (data as { relayURL: string }).relayURL, relayURL: (data as { relayURL: string }).relayURL,
relaySecret: (data as { relaySecret: string }).relaySecret, relaySecret: (data as { relaySecret: string }).relaySecret,
hosts, hosts,
@ -487,6 +528,7 @@ function toSaved(servers: ServerItem[], activeServerId: string | null, activeSes
id: item.id, id: item.id,
name: item.name, name: item.name,
url: item.url, url: item.url,
serverID: item.serverID,
relayURL: item.relayURL, relayURL: item.relayURL,
relaySecret: item.relaySecret, relaySecret: item.relaySecret,
})), })),
@ -504,6 +546,7 @@ function fromSaved(input: SavedState): {
id: item.id, id: item.id,
name: item.name, name: item.name,
url: item.url, url: item.url,
serverID: item.serverID ?? null,
relayURL: item.relayURL, relayURL: item.relayURL,
relaySecret: item.relaySecret, relaySecret: item.relaySecret,
status: "checking" as const, status: "checking" as const,
@ -584,11 +627,19 @@ export default function DictationScreen() {
const sendSettleTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null) const sendSettleTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const foregroundMonitorAbortRef = useRef<AbortController | null>(null) const foregroundMonitorAbortRef = useRef<AbortController | null>(null)
const monitorJobRef = useRef<MonitorJob | null>(null) const monitorJobRef = useRef<MonitorJob | null>(null)
const pendingNotificationEventsRef = useRef<{ payload: NotificationPayload; source: "received" | "response" }[]>([])
const notificationHandlerRef = useRef<(payload: NotificationPayload, source: "received" | "response") => void>(
(payload, source) => {
pendingNotificationEventsRef.current.push({ payload, source })
},
)
const previousPushTokenRef = useRef<string | null>(null) const previousPushTokenRef = useRef<string | null>(null)
const previousAppStateRef = useRef<AppStateStatus>(AppState.currentState)
const scanLockRef = useRef(false) const scanLockRef = useRef(false)
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 activeServerIdRef = useRef<string | null>(null)
const activeSessionIdRef = useRef<string | null>(null) const activeSessionIdRef = useRef<string | null>(null)
const latestAssistantRequestRef = useRef(0) const latestAssistantRequestRef = useRef(0)
@ -678,6 +729,10 @@ export default function DictationScreen() {
monitorJobRef.current = monitorJob monitorJobRef.current = monitorJob
}, [monitorJob]) }, [monitorJob])
useEffect(() => {
activeServerIdRef.current = activeServerId
}, [activeServerId])
useEffect(() => { useEffect(() => {
activeSessionIdRef.current = activeSessionId activeSessionIdRef.current = activeSessionId
}, [activeSessionId]) }, [activeSessionId])
@ -1014,21 +1069,35 @@ 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 const data = (notification as { request?: { content?: { data?: unknown } } }).request?.content?.data
if (!data || typeof data !== "object") return const payload = parseNotificationPayload(data)
const eventType = (data as { eventType?: unknown }).eventType if (!payload) return
if (eventType === "complete" || eventType === "permission" || eventType === "error") { notificationHandlerRef.current(payload, "received")
setMonitorStatus(formatMonitorEventLabel(eventType))
}
if (eventType === "complete") {
completePlayer.seekTo(0)
completePlayer.play()
setMonitorJob(null)
} else if (eventType === "error") {
setMonitorJob(null)
}
}) })
return () => notificationSub.remove()
}, [completePlayer]) const responseSub = Notifications.addNotificationResponseReceivedListener((response: unknown) => {
const data = (response as { notification?: { request?: { content?: { data?: unknown } } } }).notification?.request
?.content?.data
const payload = parseNotificationPayload(data)
if (!payload) return
notificationHandlerRef.current(payload, "response")
})
void Notifications.getLastNotificationResponseAsync()
.then((response) => {
if (!response) return
const data = (response as { notification?: { request?: { content?: { data?: unknown } } } }).notification
?.request?.content?.data
const payload = parseNotificationPayload(data)
if (!payload) return
notificationHandlerRef.current(payload, "response")
})
.catch(() => {})
return () => {
notificationSub.remove()
responseSub.remove()
}
}, [])
const finalizeRecordingState = useCallback(() => { const finalizeRecordingState = useCallback(() => {
isRecordingRef.current = false isRecordingRef.current = false
@ -1568,6 +1637,35 @@ export default function DictationScreen() {
} }
}, []) }, [])
const fetchSessionRuntimeStatus = useCallback(
async (baseURL: string, sessionID: string): Promise<SessionRuntimeStatus | null> => {
const base = baseURL.replace(/\/+$/, "")
try {
const response = await fetch(`${base}/session/status`)
if (!response.ok) {
throw new Error(`Session status failed (${response.status})`)
}
const payload = (await response.json()) as unknown
if (!payload || typeof payload !== "object") return null
const status = (payload as Record<string, unknown>)[sessionID]
if (!status || typeof status !== "object") return "idle"
const type = (status as { type?: unknown }).type
if (type === "busy" || type === "retry" || type === "idle") {
return type
}
return null
} catch {
return null
}
},
[],
)
const handleMonitorEvent = useCallback( const handleMonitorEvent = useCallback(
(eventType: MonitorEventType, job: MonitorJob) => { (eventType: MonitorEventType, job: MonitorJob) => {
setMonitorStatus(formatMonitorEventLabel(eventType)) setMonitorStatus(formatMonitorEventLabel(eventType))
@ -1818,7 +1916,9 @@ export default function DictationScreen() {
const hasAssistantResponse = latestAssistantResponse.trim().length > 0 const hasAssistantResponse = latestAssistantResponse.trim().length > 0
const hasAgentActivity = hasAssistantResponse || monitorStatus.trim().length > 0 || monitorJob !== null const hasAgentActivity = hasAssistantResponse || monitorStatus.trim().length > 0 || monitorJob !== null
const shouldShowAgentStateCard = hasAgentActivity && !agentStateDismissed const shouldShowAgentStateCard = hasAgentActivity && !agentStateDismissed
const agentStateIcon = monitorJob !== null ? "loading" : hasAssistantResponse ? "done" : "loading" const showsCompleteState = monitorStatus.toLowerCase().includes("complete")
const agentStateIcon =
monitorJob !== null ? "loading" : hasAssistantResponse || showsCompleteState ? "done" : "loading"
const agentStateText = hasAssistantResponse ? latestAssistantResponse : "Waiting for agent…" 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
@ -2171,6 +2271,190 @@ export default function DictationScreen() {
}) })
}, [refreshServerStatusAndSessions]) }, [refreshServerStatusAndSessions])
const syncSessionState = useCallback(
async (input: { serverID: string; sessionID: string; preserveStatusLabel?: boolean }) => {
await refreshServerStatusAndSessions(input.serverID)
const server = serversRef.current.find((item) => item.id === input.serverID)
if (!server || server.status !== "online") return
const runtimeStatus = await fetchSessionRuntimeStatus(server.url, input.sessionID)
await loadLatestAssistantResponse(server.url, input.sessionID)
if (runtimeStatus === "busy" || runtimeStatus === "retry") {
const nextJob: MonitorJob = {
id: `job-resume-${Date.now()}`,
sessionID: input.sessionID,
opencodeBaseURL: server.url.replace(/\/+$/, ""),
startedAt: Date.now(),
}
setMonitorJob(nextJob)
setMonitorStatus("Monitoring…")
if (appState === "active") {
startForegroundMonitor(nextJob)
}
return
}
if (runtimeStatus === "idle") {
stopForegroundMonitor()
setMonitorJob(null)
if (!input.preserveStatusLabel) {
setMonitorStatus("")
}
}
},
[
appState,
fetchSessionRuntimeStatus,
loadLatestAssistantResponse,
refreshServerStatusAndSessions,
startForegroundMonitor,
stopForegroundMonitor,
],
)
const findServerForSession = useCallback(
async (sessionID: string, preferredServerID?: string | null): Promise<ServerItem | null> => {
if (!serversRef.current.length && !restoredRef.current) {
for (let attempt = 0; attempt < 20; attempt += 1) {
await new Promise((resolve) => setTimeout(resolve, 150))
if (serversRef.current.length > 0 || restoredRef.current) {
break
}
}
}
if (preferredServerID) {
const preferred = serversRef.current.find((server) => server.serverID === preferredServerID)
if (preferred?.sessions.some((session) => session.id === sessionID)) {
return preferred
}
if (preferred) {
await refreshServerStatusAndSessions(preferred.id)
const refreshed = serversRef.current.find((server) => server.id === preferred.id)
if (refreshed?.sessions.some((session) => session.id === sessionID)) {
return refreshed
}
}
}
const direct = serversRef.current.find((server) => server.sessions.some((session) => session.id === sessionID))
if (direct) return direct
const ids = serversRef.current.map((server) => server.id)
for (const id of ids) {
await refreshServerStatusAndSessions(id)
const matched = serversRef.current.find(
(server) => server.id === id && server.sessions.some((session) => session.id === sessionID),
)
if (matched) {
return matched
}
}
return null
},
[refreshServerStatusAndSessions],
)
const handleNotificationPayload = useCallback(
async (payload: NotificationPayload, source: "received" | "response") => {
const activeServer = activeServerIdRef.current
? serversRef.current.find((server) => server.id === activeServerIdRef.current)
: null
const matchesActiveSession =
!!payload.sessionID &&
activeSessionIdRef.current === payload.sessionID &&
(!payload.serverID || activeServer?.serverID === payload.serverID)
if (payload.eventType && (source === "response" || matchesActiveSession || !payload.sessionID)) {
setMonitorStatus(formatMonitorEventLabel(payload.eventType))
}
if (payload.eventType === "complete" && source === "received") {
completePlayer.seekTo(0)
completePlayer.play()
}
if (
(payload.eventType === "complete" || payload.eventType === "error") &&
(source === "response" || matchesActiveSession)
) {
stopForegroundMonitor()
setMonitorJob(null)
}
if (!payload.sessionID) return
if (source === "response") {
const matched = await findServerForSession(payload.sessionID, payload.serverID)
if (!matched) {
console.log("[Notification] open:session-not-found", {
serverID: payload.serverID,
sessionID: payload.sessionID,
eventType: payload.eventType,
})
return
}
activeServerIdRef.current = matched.id
activeSessionIdRef.current = payload.sessionID
setActiveServerId(matched.id)
setActiveSessionId(payload.sessionID)
setDropdownMode("none")
setAgentStateDismissed(false)
await syncSessionState({
serverID: matched.id,
sessionID: payload.sessionID,
preserveStatusLabel: Boolean(payload.eventType),
})
return
}
if (!matchesActiveSession) return
const activeServerID = activeServerIdRef.current
if (!activeServerID) return
await syncSessionState({
serverID: activeServerID,
sessionID: payload.sessionID,
preserveStatusLabel: Boolean(payload.eventType),
})
},
[completePlayer, findServerForSession, stopForegroundMonitor, syncSessionState],
)
useEffect(() => {
notificationHandlerRef.current = (payload, source) => {
void handleNotificationPayload(payload, source)
}
if (!pendingNotificationEventsRef.current.length) return
const queued = [...pendingNotificationEventsRef.current]
pendingNotificationEventsRef.current = []
queued.forEach(({ payload, source }) => {
void handleNotificationPayload(payload, source)
})
}, [handleNotificationPayload])
useEffect(() => {
const previous = previousAppStateRef.current
previousAppStateRef.current = appState
if (appState !== "active" || previous === "active") return
const serverID = activeServerIdRef.current
const sessionID = activeSessionIdRef.current
if (!serverID || !sessionID) return
void syncSessionState({ serverID, sessionID })
}, [appState, syncSessionState])
const toggleServerMenu = useCallback(() => { const toggleServerMenu = useCallback(() => {
Haptics.selectionAsync().catch(() => {}) Haptics.selectionAsync().catch(() => {})
setDropdownMode((prev) => { setDropdownMode((prev) => {
@ -2233,7 +2517,7 @@ export default function DictationScreen() {
) )
const addServer = useCallback( const addServer = useCallback(
(serverURL: string, relayURL: string, relaySecretRaw: string) => { (serverURL: string, relayURL: string, relaySecretRaw: string, serverIDRaw?: string) => {
const raw = serverURL.trim() const raw = serverURL.trim()
if (!raw) return false if (!raw) return false
@ -2257,14 +2541,22 @@ export default function DictationScreen() {
const id = `srv-${Date.now()}` const id = `srv-${Date.now()}`
const relaySecret = relaySecretRaw.trim() const relaySecret = relaySecretRaw.trim()
const serverID = typeof serverIDRaw === "string" && serverIDRaw.length > 0 ? serverIDRaw : null
const url = `${parsed.protocol}//${parsed.host}` const url = `${parsed.protocol}//${parsed.host}`
const inferredName = const inferredName =
parsed.hostname === "127.0.0.1" || parsed.hostname === "localhost" ? "Local OpenCode" : parsed.hostname parsed.hostname === "127.0.0.1" || parsed.hostname === "localhost" ? "Local OpenCode" : parsed.hostname
const relay = `${relayParsed.protocol}//${relayParsed.host}` const relay = `${relayParsed.protocol}//${relayParsed.host}`
const existing = serversRef.current.find( const existing = serversRef.current.find(
(item) => item.url === url && item.relayURL === relay && item.relaySecret.trim() === relaySecret, (item) =>
item.url === url &&
item.relayURL === relay &&
item.relaySecret.trim() === relaySecret &&
(!serverID || item.serverID === serverID || item.serverID === null),
) )
if (existing) { if (existing) {
if (serverID && existing.serverID !== serverID) {
setServers((prev) => prev.map((item) => (item.id === existing.id ? { ...item, serverID } : item)))
}
setActiveServerId(existing.id) setActiveServerId(existing.id)
setActiveSessionId(null) setActiveSessionId(null)
setDropdownMode("none") setDropdownMode("none")
@ -2278,6 +2570,7 @@ export default function DictationScreen() {
id, id,
name: inferredName, name: inferredName,
url, url,
serverID,
relayURL: relay, relayURL: relay,
relaySecret, relaySecret,
status: "offline", status: "offline",
@ -2382,7 +2675,7 @@ export default function DictationScreen() {
return return
} }
const ok = addServer(host, pair.relayURL, pair.relaySecret) const ok = addServer(host, pair.relayURL, pair.relaySecret, pair.serverID)
if (!ok) { if (!ok) {
scanLockRef.current = false scanLockRef.current = false
return return

View file

@ -8,6 +8,7 @@ type Type = "complete" | "permission" | "error"
type Pair = { type Pair = {
v: 1 v: 1
serverID?: string
relayURL: string relayURL: string
relaySecret: string relaySecret: string
hosts: string[] hosts: string[]
@ -62,6 +63,10 @@ function secretHash(input: string) {
return `${createHash("sha256").update(input).digest("hex").slice(0, 12)}...` return `${createHash("sha256").update(input).digest("hex").slice(0, 12)}...`
} }
function serverID(input: { relayURL: string; relaySecret: string }) {
return createHash("sha256").update(`${input.relayURL}|${input.relaySecret}`).digest("hex").slice(0, 16)
}
/** /**
* Classify an IPv4 address into a reachability tier. * Classify an IPv4 address into a reachability tier.
* Lower number = more likely reachable from an external/overlay network device. * Lower number = more likely reachable from an external/overlay network device.
@ -261,6 +266,7 @@ async function post(input: { type: Type; sessionID: string }) {
const content = await notify(input) const content = await notify(input)
console.log("[ APN RELAY ] posting event", { console.log("[ APN RELAY ] posting event", {
serverID: next.pair.serverID,
relayURL: next.relayURL, relayURL: next.relayURL,
secretHash: secretHash(next.relaySecret), secretHash: secretHash(next.relaySecret),
type: input.type, type: input.type,
@ -269,6 +275,7 @@ async function post(input: { type: Type; sessionID: string }) {
}) })
log.info("[ APN RELAY ] posting event", { log.info("[ APN RELAY ] posting event", {
serverID: next.pair.serverID,
relayURL: next.relayURL, relayURL: next.relayURL,
secretHash: secretHash(next.relaySecret), secretHash: secretHash(next.relaySecret),
type: input.type, type: input.type,
@ -283,6 +290,7 @@ async function post(input: { type: Type; sessionID: string }) {
}, },
body: JSON.stringify({ body: JSON.stringify({
secret: next.relaySecret, secret: next.relaySecret,
serverID: next.pair.serverID,
eventType: input.type, eventType: input.type,
sessionID: input.sessionID, sessionID: input.sessionID,
title: content.title, title: content.title,
@ -293,6 +301,7 @@ async function post(input: { type: Type; sessionID: string }) {
if (res.ok) { if (res.ok) {
console.log("[ APN RELAY ] relay accepted event", { console.log("[ APN RELAY ] relay accepted event", {
status: res.status, status: res.status,
serverID: next.pair.serverID,
secretHash: secretHash(next.relaySecret), secretHash: secretHash(next.relaySecret),
type: input.type, type: input.type,
sessionID: input.sessionID, sessionID: input.sessionID,
@ -301,6 +310,7 @@ async function post(input: { type: Type; sessionID: string }) {
log.info("[ APN RELAY ] relay accepted event", { log.info("[ APN RELAY ] relay accepted event", {
status: res.status, status: res.status,
serverID: next.pair.serverID,
secretHash: secretHash(next.relaySecret), secretHash: secretHash(next.relaySecret),
type: input.type, type: input.type,
sessionID: input.sessionID, sessionID: input.sessionID,
@ -340,6 +350,7 @@ export namespace PushRelay {
const pair: Pair = { const pair: Pair = {
v: 1, v: 1,
serverID: serverID({ relayURL, relaySecret }),
relayURL, relayURL,
relaySecret, relaySecret,
hosts: list(input.hostname, input.port), hosts: list(input.hostname, input.port),