Sfoglia il codice sorgente

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.
Ryan Vogel 3 settimane fa
parent
commit
d3ec6f75f4

+ 4 - 0
packages/apn-relay/src/index.ts

@@ -50,6 +50,7 @@ const unreg = z.object({
 
 const evt = z.object({
   secret: z.string().min(1),
+  serverID: z.string().min(1).optional(),
   eventType: z.enum(["complete", "permission", "error"]),
   sessionID: z.string().min(1),
   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))
   console.log("[relay] event", {
     type: check.data.eventType,
+    serverID: check.data.serverID,
     session: check.data.sessionID,
     secretHash: `${key.slice(0, 12)}...`,
     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)
     console.log("[relay] event:no-matching-devices", {
       type: check.data.eventType,
+      serverID: check.data.serverID,
       session: check.data.sessionID,
       secretHash: `${key.slice(0, 12)}...`,
       totalDevices: Number(total?.value ?? 0),
@@ -354,6 +357,7 @@ app.post("/v1/event", async (c) => {
         title: check.data.title ?? title(check.data.eventType),
         body: check.data.body ?? body(check.data.eventType),
         data: {
+          serverID: check.data.serverID,
           eventType: check.data.eventType,
           sessionID: check.data.sessionID,
         },

+ 311 - 18
packages/mobile-voice/src/app/index.tsx

@@ -295,6 +295,7 @@ type ServerItem = {
   id: string
   name: string
   url: string
+  serverID: string | null
   relayURL: string
   relaySecret: string
   status: "checking" | "online" | "offline"
@@ -341,6 +342,7 @@ type DropdownMode = "none" | "server" | "session"
 
 type Pair = {
   v: 1
+  serverID?: string
   relayURL: string
   relaySecret: string
   hosts: string[]
@@ -354,6 +356,7 @@ type SavedServer = {
   id: string
   name: string
   url: string
+  serverID: string | null
   relayURL: string
   relaySecret: string
 }
@@ -373,6 +376,41 @@ type OnboardingSavedState = {
   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 = {
   CameraView: (typeof import("expo-camera"))["CameraView"]
   requestCameraPermissionsAsync: () => Promise<{ granted: boolean }>
@@ -388,8 +426,11 @@ function parsePair(input: string): Pair | undefined {
     if (!Array.isArray((data as { hosts?: unknown }).hosts)) return
     const hosts = (data as { hosts: unknown[] }).hosts.filter((item): item is string => typeof item === "string")
     if (!hosts.length) return
+    const serverIDRaw = (data as { serverID?: unknown }).serverID
+    const serverID = typeof serverIDRaw === "string" && serverIDRaw.length > 0 ? serverIDRaw : undefined
     return {
       v: 1,
+      serverID,
       relayURL: (data as { relayURL: string }).relayURL,
       relaySecret: (data as { relaySecret: string }).relaySecret,
       hosts,
@@ -487,6 +528,7 @@ function toSaved(servers: ServerItem[], activeServerId: string | null, activeSes
       id: item.id,
       name: item.name,
       url: item.url,
+      serverID: item.serverID,
       relayURL: item.relayURL,
       relaySecret: item.relaySecret,
     })),
@@ -504,6 +546,7 @@ function fromSaved(input: SavedState): {
     id: item.id,
     name: item.name,
     url: item.url,
+    serverID: item.serverID ?? null,
     relayURL: item.relayURL,
     relaySecret: item.relaySecret,
     status: "checking" as const,
@@ -584,11 +627,19 @@ export default function DictationScreen() {
   const sendSettleTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
   const foregroundMonitorAbortRef = useRef<AbortController | 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 previousAppStateRef = useRef<AppStateStatus>(AppState.currentState)
   const scanLockRef = useRef(false)
   const restoredRef = useRef(false)
   const whisperRestoredRef = useRef(false)
   const refreshSeqRef = useRef<Record<string, number>>({})
+  const activeServerIdRef = useRef<string | null>(null)
   const activeSessionIdRef = useRef<string | null>(null)
   const latestAssistantRequestRef = useRef(0)
 
@@ -678,6 +729,10 @@ export default function DictationScreen() {
     monitorJobRef.current = monitorJob
   }, [monitorJob])
 
+  useEffect(() => {
+    activeServerIdRef.current = activeServerId
+  }, [activeServerId])
+
   useEffect(() => {
     activeSessionIdRef.current = activeSessionId
   }, [activeSessionId])
@@ -1014,21 +1069,35 @@ export default function DictationScreen() {
   useEffect(() => {
     const notificationSub = Notifications.addNotificationReceivedListener((notification: unknown) => {
       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") {
-        completePlayer.seekTo(0)
-        completePlayer.play()
-        setMonitorJob(null)
-      } else if (eventType === "error") {
-        setMonitorJob(null)
-      }
+      const payload = parseNotificationPayload(data)
+      if (!payload) return
+      notificationHandlerRef.current(payload, "received")
+    })
+
+    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")
     })
-    return () => notificationSub.remove()
-  }, [completePlayer])
+
+    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(() => {
     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(
     (eventType: MonitorEventType, job: MonitorJob) => {
       setMonitorStatus(formatMonitorEventLabel(eventType))
@@ -1818,7 +1916,9 @@ export default function DictationScreen() {
   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 showsCompleteState = monitorStatus.toLowerCase().includes("complete")
+  const agentStateIcon =
+    monitorJob !== null ? "loading" : hasAssistantResponse || showsCompleteState ? "done" : "loading"
   const agentStateText = hasAssistantResponse ? latestAssistantResponse : "Waiting for agent…"
   const shouldShowSend = hasCompletedSession && hasTranscript
   const activeServer = servers.find((s) => s.id === activeServerId) ?? null
@@ -2171,6 +2271,190 @@ export default function DictationScreen() {
     })
   }, [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(() => {
     Haptics.selectionAsync().catch(() => {})
     setDropdownMode((prev) => {
@@ -2233,7 +2517,7 @@ export default function DictationScreen() {
   )
 
   const addServer = useCallback(
-    (serverURL: string, relayURL: string, relaySecretRaw: string) => {
+    (serverURL: string, relayURL: string, relaySecretRaw: string, serverIDRaw?: string) => {
       const raw = serverURL.trim()
       if (!raw) return false
 
@@ -2257,14 +2541,22 @@ export default function DictationScreen() {
 
       const id = `srv-${Date.now()}`
       const relaySecret = relaySecretRaw.trim()
+      const serverID = typeof serverIDRaw === "string" && serverIDRaw.length > 0 ? serverIDRaw : null
       const url = `${parsed.protocol}//${parsed.host}`
       const inferredName =
         parsed.hostname === "127.0.0.1" || parsed.hostname === "localhost" ? "Local OpenCode" : parsed.hostname
       const relay = `${relayParsed.protocol}//${relayParsed.host}`
       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 (serverID && existing.serverID !== serverID) {
+          setServers((prev) => prev.map((item) => (item.id === existing.id ? { ...item, serverID } : item)))
+        }
         setActiveServerId(existing.id)
         setActiveSessionId(null)
         setDropdownMode("none")
@@ -2278,6 +2570,7 @@ export default function DictationScreen() {
           id,
           name: inferredName,
           url,
+          serverID,
           relayURL: relay,
           relaySecret,
           status: "offline",
@@ -2382,7 +2675,7 @@ export default function DictationScreen() {
           return
         }
 
-        const ok = addServer(host, pair.relayURL, pair.relaySecret)
+        const ok = addServer(host, pair.relayURL, pair.relaySecret, pair.serverID)
         if (!ok) {
           scanLockRef.current = false
           return

+ 11 - 0
packages/opencode/src/server/push-relay.ts

@@ -8,6 +8,7 @@ type Type = "complete" | "permission" | "error"
 
 type Pair = {
   v: 1
+  serverID?: string
   relayURL: string
   relaySecret: string
   hosts: string[]
@@ -62,6 +63,10 @@ function secretHash(input: string) {
   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.
  * 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)
 
   console.log("[ APN RELAY ] posting event", {
+    serverID: next.pair.serverID,
     relayURL: next.relayURL,
     secretHash: secretHash(next.relaySecret),
     type: input.type,
@@ -269,6 +275,7 @@ async function post(input: { type: Type; sessionID: string }) {
   })
 
   log.info("[ APN RELAY ] posting event", {
+    serverID: next.pair.serverID,
     relayURL: next.relayURL,
     secretHash: secretHash(next.relaySecret),
     type: input.type,
@@ -283,6 +290,7 @@ async function post(input: { type: Type; sessionID: string }) {
     },
     body: JSON.stringify({
       secret: next.relaySecret,
+      serverID: next.pair.serverID,
       eventType: input.type,
       sessionID: input.sessionID,
       title: content.title,
@@ -293,6 +301,7 @@ async function post(input: { type: Type; sessionID: string }) {
       if (res.ok) {
         console.log("[ APN RELAY ] relay accepted event", {
           status: res.status,
+          serverID: next.pair.serverID,
           secretHash: secretHash(next.relaySecret),
           type: input.type,
           sessionID: input.sessionID,
@@ -301,6 +310,7 @@ async function post(input: { type: Type; sessionID: string }) {
 
         log.info("[ APN RELAY ] relay accepted event", {
           status: res.status,
+          serverID: next.pair.serverID,
           secretHash: secretHash(next.relaySecret),
           type: input.type,
           sessionID: input.sessionID,
@@ -340,6 +350,7 @@ export namespace PushRelay {
 
     const pair: Pair = {
       v: 1,
+      serverID: serverID({ relayURL, relaySecret }),
       relayURL,
       relaySecret,
       hosts: list(input.hostname, input.port),