Improve pairing reliability and UX by letting users choose among scanned hosts with health checks and cleaner row styling while shrinking QR payloads. Handle iOS call-time audio session conflicts more gracefully with user-friendly messaging and lower-noise logs.
450 lines
11 KiB
TypeScript
450 lines
11 KiB
TypeScript
import os from "node:os"
|
||
import { createHash } from "node:crypto"
|
||
import { SessionID } from "@/session/schema"
|
||
import { GlobalBus } from "@/bus/global"
|
||
import { Log } from "@/util/log"
|
||
|
||
type Type = "complete" | "permission" | "error"
|
||
|
||
type Pair = {
|
||
v: 1
|
||
serverID?: string
|
||
relayURL: string
|
||
relaySecret: string
|
||
hosts: string[]
|
||
}
|
||
|
||
type Input = {
|
||
relayURL: string
|
||
relaySecret: string
|
||
hostname: string
|
||
port: number
|
||
advertiseHosts?: string[]
|
||
}
|
||
|
||
type State = {
|
||
relayURL: string
|
||
relaySecret: string
|
||
pair: Pair
|
||
stop: () => void
|
||
seen: Map<string, number>
|
||
gc: number
|
||
}
|
||
|
||
type Event = {
|
||
type: string
|
||
properties: unknown
|
||
}
|
||
|
||
type Notify = {
|
||
type: Type
|
||
sessionID: string
|
||
title?: string
|
||
body?: string
|
||
}
|
||
|
||
const log = Log.create({ service: "push-relay" })
|
||
|
||
let state: State | undefined
|
||
|
||
function obj(input: unknown): input is Record<string, unknown> {
|
||
return typeof input === "object" && input !== null
|
||
}
|
||
|
||
function str(input: unknown) {
|
||
return typeof input === "string" && input.length > 0 ? input : undefined
|
||
}
|
||
|
||
function norm(input: string) {
|
||
return input.replace(/\/+$/, "")
|
||
}
|
||
|
||
function secretHash(input: string) {
|
||
if (!input) return "none"
|
||
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.
|
||
*
|
||
* 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 advertiseURL(input: string, port: number): string | undefined {
|
||
const raw = input.trim()
|
||
if (!raw) return
|
||
|
||
try {
|
||
const parsed = new URL(raw.includes("://") ? raw : `http://${raw}`)
|
||
if (!parsed.hostname) return
|
||
if (!parsed.port) {
|
||
parsed.port = String(port)
|
||
}
|
||
return norm(`${parsed.protocol}//${parsed.host}`)
|
||
} catch {
|
||
return
|
||
}
|
||
}
|
||
|
||
function list(hostname: string, port: number, advertised: string[] = []) {
|
||
const seen = new Set<string>()
|
||
const preferred: string[] = []
|
||
const hosts: Array<{ url: string; tier: number }> = []
|
||
|
||
const addPreferred = (input: string) => {
|
||
const url = advertiseURL(input, port)
|
||
if (!url) return
|
||
if (seen.has(url)) return
|
||
seen.add(url)
|
||
preferred.push(url)
|
||
}
|
||
|
||
const add = (host: string) => {
|
||
if (!host) return
|
||
if (host === "0.0.0.0") return
|
||
if (host === "::") return
|
||
const url = `http://${host}:${port}`
|
||
if (seen.has(url)) return
|
||
seen.add(url)
|
||
hosts.push({ url, tier: ipTier(host) })
|
||
}
|
||
|
||
advertised.forEach(addPreferred)
|
||
|
||
add(hostname)
|
||
|
||
const nets = Object.values(os.networkInterfaces())
|
||
.flatMap((item) => item ?? [])
|
||
.filter((item) => item.family === "IPv4" && !item.internal)
|
||
.map((item) => item.address)
|
||
|
||
nets.forEach(add)
|
||
|
||
// sort: most externally reachable first, loopback last
|
||
hosts.sort((a, b) => a.tier - b.tier)
|
||
|
||
return [...preferred, ...hosts.map((item) => item.url)]
|
||
}
|
||
|
||
function map(event: Event): { type: Type; sessionID: string } | undefined {
|
||
if (!obj(event.properties)) return
|
||
|
||
if (event.type === "permission.asked") {
|
||
const sessionID = str(event.properties.sessionID)
|
||
if (!sessionID) return
|
||
return { type: "permission", sessionID }
|
||
}
|
||
|
||
if (event.type === "session.error") {
|
||
const sessionID = str(event.properties.sessionID)
|
||
if (!sessionID) return
|
||
return { type: "error", sessionID }
|
||
}
|
||
|
||
if (event.type === "session.idle") {
|
||
const sessionID = str(event.properties.sessionID)
|
||
if (!sessionID) return
|
||
return { type: "complete", sessionID }
|
||
}
|
||
|
||
if (event.type !== "session.status") return
|
||
const sessionID = str(event.properties.sessionID)
|
||
if (!sessionID) return
|
||
if (!obj(event.properties.status)) return
|
||
if (event.properties.status.type !== "idle") return
|
||
return { type: "complete", sessionID }
|
||
}
|
||
|
||
function text(input: string) {
|
||
return input.replace(/\s+/g, " ").trim()
|
||
}
|
||
|
||
function words(input: string, max = 18, chars = 140) {
|
||
const clean = text(input)
|
||
if (!clean) return ""
|
||
const split = clean.split(" ")
|
||
const cut = split.slice(0, max).join(" ")
|
||
if (cut.length <= chars && split.length <= max) return cut
|
||
const short = cut.slice(0, chars).trim()
|
||
return short.endsWith("…") ? short : `${short}…`
|
||
}
|
||
|
||
function fallback(input: Type) {
|
||
if (input === "complete") return "Session complete."
|
||
if (input === "permission") return "OpenCode needs your permission decision."
|
||
return "OpenCode reported an error for your session."
|
||
}
|
||
|
||
async function notify(input: { type: Type; sessionID: string }): Promise<Notify> {
|
||
const out: Notify = {
|
||
type: input.type,
|
||
sessionID: input.sessionID,
|
||
}
|
||
|
||
try {
|
||
const [{ Session }, { MessageV2 }] = await Promise.all([import("@/session"), import("@/session/message-v2")])
|
||
const sessionID = SessionID.make(input.sessionID)
|
||
const session = await Session.get(sessionID)
|
||
out.title = session.title
|
||
|
||
let latestUser: string | undefined
|
||
for await (const msg of MessageV2.stream(sessionID)) {
|
||
const body = msg.parts
|
||
.map((part) => {
|
||
if (part.type !== "text") return ""
|
||
if (part.ignored) return ""
|
||
return part.text
|
||
})
|
||
.filter(Boolean)
|
||
.join(" ")
|
||
const next = words(body)
|
||
if (!next) continue
|
||
|
||
if (msg.info.role === "assistant") {
|
||
out.body = next
|
||
break
|
||
}
|
||
|
||
if (!latestUser && msg.info.role === "user") {
|
||
latestUser = next
|
||
}
|
||
}
|
||
|
||
if (!out.body) {
|
||
out.body = latestUser
|
||
}
|
||
} catch (error) {
|
||
log.info("notification metadata unavailable", {
|
||
type: input.type,
|
||
sessionID: input.sessionID,
|
||
error: String(error),
|
||
})
|
||
}
|
||
|
||
if (!out.title) out.title = `Session ${input.type}`
|
||
if (!out.body) out.body = fallback(input.type)
|
||
return out
|
||
}
|
||
|
||
function dedupe(input: { type: Type; sessionID: string }) {
|
||
if (input.type !== "complete") return false
|
||
const next = state
|
||
if (!next) return false
|
||
const now = Date.now()
|
||
|
||
if (next.seen.size > 2048 || now - next.gc > 60_000) {
|
||
next.gc = now
|
||
for (const [key, time] of next.seen) {
|
||
if (now - time > 60_000) {
|
||
next.seen.delete(key)
|
||
}
|
||
}
|
||
const drop = next.seen.size - 2048
|
||
if (drop > 0) {
|
||
let i = 0
|
||
for (const key of next.seen.keys()) {
|
||
next.seen.delete(key)
|
||
i += 1
|
||
if (i >= drop) break
|
||
}
|
||
}
|
||
}
|
||
|
||
const key = `${input.type}:${input.sessionID}`
|
||
const prev = next.seen.get(key)
|
||
next.seen.set(key, now)
|
||
if (!prev) return false
|
||
return now - prev < 5_000
|
||
}
|
||
|
||
async function post(input: { type: Type; sessionID: string }) {
|
||
const next = state
|
||
if (!next) return false
|
||
if (dedupe(input)) return true
|
||
|
||
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,
|
||
sessionID: input.sessionID,
|
||
title: content.title,
|
||
})
|
||
|
||
log.info("[ APN RELAY ] posting event", {
|
||
serverID: next.pair.serverID,
|
||
relayURL: next.relayURL,
|
||
secretHash: secretHash(next.relaySecret),
|
||
type: input.type,
|
||
sessionID: input.sessionID,
|
||
title: content.title,
|
||
})
|
||
|
||
void fetch(`${next.relayURL}/v1/event`, {
|
||
method: "POST",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
},
|
||
body: JSON.stringify({
|
||
secret: next.relaySecret,
|
||
serverID: next.pair.serverID,
|
||
eventType: input.type,
|
||
sessionID: input.sessionID,
|
||
title: content.title,
|
||
body: content.body,
|
||
}),
|
||
})
|
||
.then(async (res) => {
|
||
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,
|
||
title: content.title,
|
||
})
|
||
|
||
log.info("[ APN RELAY ] relay accepted event", {
|
||
status: res.status,
|
||
serverID: next.pair.serverID,
|
||
secretHash: secretHash(next.relaySecret),
|
||
type: input.type,
|
||
sessionID: input.sessionID,
|
||
title: content.title,
|
||
})
|
||
return
|
||
}
|
||
const error = await res.text().catch(() => "")
|
||
log.warn("relay post failed", {
|
||
status: res.status,
|
||
type: input.type,
|
||
sessionID: input.sessionID,
|
||
title: content.title,
|
||
error,
|
||
})
|
||
})
|
||
.catch((error) => {
|
||
log.warn("relay post failed", {
|
||
type: input.type,
|
||
sessionID: input.sessionID,
|
||
title: content.title,
|
||
error: String(error),
|
||
})
|
||
})
|
||
|
||
return true
|
||
}
|
||
|
||
export namespace PushRelay {
|
||
export function start(input: Input) {
|
||
const relayURL = norm(input.relayURL.trim())
|
||
const relaySecret = input.relaySecret.trim()
|
||
if (!relayURL) return
|
||
if (!relaySecret) return
|
||
|
||
stop()
|
||
|
||
const pair: Pair = {
|
||
v: 1,
|
||
serverID: serverID({ relayURL, relaySecret }),
|
||
relayURL,
|
||
relaySecret,
|
||
hosts: list(input.hostname, input.port, input.advertiseHosts ?? []),
|
||
}
|
||
|
||
const callback = (event: { payload: Event }) => {
|
||
const next = map(event.payload)
|
||
if (!next) return
|
||
void post(next)
|
||
}
|
||
GlobalBus.on("event", callback)
|
||
const unsub = () => {
|
||
GlobalBus.off("event", callback)
|
||
}
|
||
|
||
state = {
|
||
relayURL,
|
||
relaySecret,
|
||
pair,
|
||
stop: unsub,
|
||
seen: new Map(),
|
||
gc: 0,
|
||
}
|
||
|
||
log.info("enabled", {
|
||
relayURL,
|
||
hosts: pair.hosts,
|
||
})
|
||
|
||
return pair
|
||
}
|
||
|
||
export function stop() {
|
||
const next = state
|
||
if (!next) return
|
||
state = undefined
|
||
next.stop()
|
||
}
|
||
|
||
export function status() {
|
||
const next = state
|
||
if (!next) {
|
||
return {
|
||
enabled: false,
|
||
relaySecretSet: false,
|
||
} as const
|
||
}
|
||
return {
|
||
enabled: true,
|
||
relaySecretSet: next.relaySecret.length > 0,
|
||
} as const
|
||
}
|
||
|
||
export function pair() {
|
||
return state?.pair
|
||
}
|
||
|
||
export function test(input: { type: Type; sessionID: string }) {
|
||
void post(input)
|
||
return true
|
||
}
|
||
|
||
export function auth(input: string) {
|
||
const next = state
|
||
if (!next) return false
|
||
return next.relaySecret === input
|
||
}
|
||
}
|