chore: update dependencies and enhance mobile-voice functionality

- Updated package dependencies in bun.lock and package.json for mobile-voice and opencode.
- Added expo-camera and improved camera permission handling in mobile-voice.
- Introduced QR code generation for relay setup in opencode serve command.
- Enhanced server management and logging in DictationScreen component.
pull/19545/head
Ryan Vogel 2026-03-28 17:05:35 -04:00
parent 62fae6d182
commit 0a9fcab56f
6 changed files with 501 additions and 416 deletions

425
bun.lock

File diff suppressed because it is too large Load Diff

View File

@ -19,7 +19,9 @@
"expo": "~55.0.9", "expo": "~55.0.9",
"expo-asset": "~55.0.10", "expo-asset": "~55.0.10",
"expo-audio": "~55.0.9", "expo-audio": "~55.0.9",
"expo-camera": "~55.0.11",
"expo-constants": "~55.0.9", "expo-constants": "~55.0.9",
"expo-dev-client": "~55.0.19",
"expo-device": "~55.0.10", "expo-device": "~55.0.10",
"expo-file-system": "~55.0.12", "expo-file-system": "~55.0.12",
"expo-font": "~55.0.4", "expo-font": "~55.0.4",

View File

@ -6,6 +6,8 @@ import {
Pressable, Pressable,
ScrollView, ScrollView,
TextInput, TextInput,
Modal,
Alert,
LayoutChangeEvent, LayoutChangeEvent,
AppState, AppState,
AppStateStatus, AppStateStatus,
@ -107,7 +109,63 @@ function formatSessionUpdated(updatedMs: number): string {
type DropdownMode = "none" | "server" | "session" type DropdownMode = "none" | "server" | "session"
type Pair = {
v: 1
relayURL: string
relaySecret: string
hosts: string[]
}
type Scan = {
data: string
}
function parsePair(input: string): Pair | undefined {
try {
const data = JSON.parse(input)
if (!data || typeof data !== "object") return
if ((data as { v?: unknown }).v !== 1) return
if (typeof (data as { relayURL?: unknown }).relayURL !== "string") return
if (typeof (data as { relaySecret?: unknown }).relaySecret !== "string") 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")
if (!hosts.length) return
return {
v: 1,
relayURL: (data as { relayURL: string }).relayURL,
relaySecret: (data as { relaySecret: string }).relaySecret,
hosts,
}
} catch {
return
}
}
function pickHost(list: string[]): string | undefined {
const next = list.find((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
} catch {
return false
}
})
return next ?? list[0]
}
export default function DictationScreen() { export default function DictationScreen() {
const [camera, setCamera] = useState<{
CameraView: React.ComponentType<{
style?: unknown
barcodeScannerSettings?: { barcodeTypes?: string[] }
onBarcodeScanned?: (event: Scan) => void
}>
requestCameraPermissionsAsync: () => Promise<{ granted: boolean | undefined }>
} | null>(null)
const [modelReset, setModelReset] = useState(false) const [modelReset, setModelReset] = useState(false)
const model = useSpeechToText({ const model = useSpeechToText({
model: WHISPER_BASE_EN, model: WHISPER_BASE_EN,
@ -130,6 +188,8 @@ export default function DictationScreen() {
const [serverDraftURL, setServerDraftURL] = useState("http://127.0.0.1:4096") const [serverDraftURL, setServerDraftURL] = useState("http://127.0.0.1:4096")
const [serverDraftRelayURL, setServerDraftRelayURL] = useState(DEFAULT_RELAY_URL) const [serverDraftRelayURL, setServerDraftRelayURL] = useState(DEFAULT_RELAY_URL)
const [serverDraftRelaySecret, setServerDraftRelaySecret] = useState("") const [serverDraftRelaySecret, setServerDraftRelaySecret] = useState("")
const [scanOpen, setScanOpen] = useState(false)
const [camGranted, setCamGranted] = useState(false)
const [servers, setServers] = useState<ServerItem[]>([ const [servers, setServers] = useState<ServerItem[]>([
{ {
id: "srv-1", id: "srv-1",
@ -178,6 +238,7 @@ export default function DictationScreen() {
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 previousPushTokenRef = useRef<string | null>(null) const previousPushTokenRef = useRef<string | null>(null)
const scanLockRef = useRef(false)
const [recorder] = useState(() => new AudioRecorder()) const [recorder] = useState(() => new AudioRecorder())
@ -913,7 +974,7 @@ export default function DictationScreen() {
const menuRows = const menuRows =
effectiveDropdownMode === "server" ? Math.max(servers.length, 1) : Math.max(activeServer?.sessions.length ?? 0, 1) effectiveDropdownMode === "server" ? Math.max(servers.length, 1) : Math.max(activeServer?.sessions.length ?? 0, 1)
const expandedRowsHeight = Math.min(menuRows, DROPDOWN_VISIBLE_ROWS) * 42 const expandedRowsHeight = Math.min(menuRows, DROPDOWN_VISIBLE_ROWS) * 42
const addServerExtraHeight = effectiveDropdownMode === "server" ? (isAddingServer ? 142 : 38) : 8 const addServerExtraHeight = effectiveDropdownMode === "server" ? (isAddingServer ? 188 : 38) : 8
const expandedHeaderHeight = 51 + 12 + expandedRowsHeight + addServerExtraHeight const expandedHeaderHeight = 51 + 12 + expandedRowsHeight + addServerExtraHeight
const animatedHeaderStyle = useAnimatedStyle(() => ({ const animatedHeaderStyle = useAnimatedStyle(() => ({
@ -996,6 +1057,12 @@ export default function DictationScreen() {
if (!server) return if (!server) return
const base = server.url.replace(/\/+$/, "") const base = server.url.replace(/\/+$/, "")
console.log("[Server] refresh:start", {
id: server.id,
name: server.name,
base,
includeSessions,
})
setServers((prev) => setServers((prev) =>
prev.map((s) => { prev.map((s) => {
@ -1008,11 +1075,18 @@ export default function DictationScreen() {
try { try {
const healthRes = await fetch(`${base}/health`) const healthRes = await fetch(`${base}/health`)
const online = healthRes.ok const online = healthRes.ok
console.log("[Server] health", {
id: server.id,
base,
status: healthRes.status,
online,
})
if (!online) { if (!online) {
setServers((prev) => setServers((prev) =>
prev.map((s) => (s.id === serverID ? { ...s, status: "offline", sessionsLoading: false, sessions: [] } : s)), prev.map((s) => (s.id === serverID ? { ...s, status: "offline", sessionsLoading: false, sessions: [] } : s)),
) )
console.log("[Server] refresh:offline", { id: server.id, base })
return return
} }
@ -1020,6 +1094,7 @@ export default function DictationScreen() {
setServers((prev) => setServers((prev) =>
prev.map((s) => (s.id === serverID ? { ...s, status: "online", sessionsLoading: false } : s)), prev.map((s) => (s.id === serverID ? { ...s, status: "online", sessionsLoading: false } : s)),
) )
console.log("[Server] refresh:online", { id: server.id, base })
return return
} }
@ -1039,10 +1114,15 @@ export default function DictationScreen() {
setServers((prev) => setServers((prev) =>
prev.map((s) => (s.id === serverID ? { ...s, status: "online", sessionsLoading: false, sessions } : s)), prev.map((s) => (s.id === serverID ? { ...s, status: "online", sessionsLoading: false, sessions } : s)),
) )
console.log("[Server] sessions", { id: server.id, count: sessions.length })
} catch { } catch {
setServers((prev) => setServers((prev) =>
prev.map((s) => (s.id === serverID ? { ...s, status: "offline", sessionsLoading: false, sessions: [] } : s)), prev.map((s) => (s.id === serverID ? { ...s, status: "offline", sessionsLoading: false, sessions: [] } : s)),
) )
console.log("[Server] refresh:error", {
id: server.id,
base,
})
} }
}, []) }, [])
@ -1127,53 +1207,140 @@ export default function DictationScreen() {
setServerDraftRelaySecret("") setServerDraftRelaySecret("")
}, []) }, [])
const addServer = useCallback(
(serverURL: string, relayURL: string, relaySecretRaw: string) => {
const raw = serverURL.trim()
if (!raw) return false
const normalized = raw.startsWith("http://") || raw.startsWith("https://") ? raw : `http://${raw}`
const rawRelay = relayURL.trim()
const relayNormalizedRaw = rawRelay.length > 0 ? rawRelay : DEFAULT_RELAY_URL
const normalizedRelay =
relayNormalizedRaw.startsWith("http://") || relayNormalizedRaw.startsWith("https://")
? relayNormalizedRaw
: `http://${relayNormalizedRaw}`
let parsed: URL
let relayParsed: URL
try {
parsed = new URL(normalized)
relayParsed = new URL(normalizedRelay)
} catch {
return false
}
const id = `srv-${Date.now()}`
const relaySecret = relaySecretRaw.trim()
const inferredName =
parsed.hostname === "127.0.0.1" || parsed.hostname === "localhost" ? "Local OpenCode" : parsed.hostname
const url = `${parsed.protocol}//${parsed.host}`
const relay = `${relayParsed.protocol}//${relayParsed.host}`
const existing = serversRef.current.find(
(item) => item.url === url && item.relayURL === relay && item.relaySecret.trim() === relaySecret,
)
if (existing) {
setActiveServerId(existing.id)
setActiveSessionId(null)
setIsAddingServer(false)
setServerDraftRelaySecret("")
setDropdownMode("none")
refreshServerStatusAndSessions(existing.id)
return true
}
setServers((prev) => [
...prev,
{
id,
name: inferredName,
url,
relayURL: relay,
relaySecret,
status: "offline",
sessions: [],
sessionsLoading: false,
},
])
setActiveServerId(id)
setActiveSessionId(null)
setIsAddingServer(false)
setServerDraftRelaySecret("")
setDropdownMode("none")
refreshServerStatusAndSessions(id)
return true
},
[refreshServerStatusAndSessions],
)
const handleConfirmAddServer = useCallback(() => { const handleConfirmAddServer = useCallback(() => {
const raw = serverDraftURL.trim() addServer(serverDraftURL, serverDraftRelayURL, serverDraftRelaySecret)
if (!raw) return }, [addServer, serverDraftRelaySecret, serverDraftRelayURL, serverDraftURL])
const normalized = raw.startsWith("http://") || raw.startsWith("https://") ? raw : `http://${raw}` const handleStartScan = useCallback(async () => {
scanLockRef.current = false
const rawRelay = serverDraftRelayURL.trim() const current =
const relayNormalizedRaw = rawRelay.length > 0 ? rawRelay : DEFAULT_RELAY_URL camera ??
const normalizedRelay = (await import("expo-camera")
relayNormalizedRaw.startsWith("http://") || relayNormalizedRaw.startsWith("https://") .catch(() => null)
? relayNormalizedRaw .then((mod) => {
: `http://${relayNormalizedRaw}` if (!mod) return null
const next = {
let parsed: URL CameraView: mod.CameraView,
let relayParsed: URL requestCameraPermissionsAsync: mod.Camera.requestCameraPermissionsAsync,
try { }
parsed = new URL(normalized) setCamera(next)
relayParsed = new URL(normalizedRelay) return next
} catch { }))
if (!current) {
Alert.alert("Scanner unavailable", "This build does not include camera support. Reinstall the latest dev build.")
return return
} }
if (camGranted) {
setScanOpen(true)
return
}
const res = await current.requestCameraPermissionsAsync()
if (!res.granted) return
setCamGranted(true)
setScanOpen(true)
}, [camGranted, camera])
const id = `srv-${Date.now()}` const handleScan = useCallback(
const relaySecret = serverDraftRelaySecret.trim() (event: Scan) => {
const inferredName = if (scanLockRef.current) return
parsed.hostname === "127.0.0.1" || parsed.hostname === "localhost" ? "Local OpenCode" : parsed.hostname scanLockRef.current = true
const pair = parsePair(event.data)
if (!pair) {
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error).catch(() => {})
setTimeout(() => {
scanLockRef.current = false
}, 750)
return
}
setServers((prev) => [ const host = pickHost(pair.hosts)
...prev, if (!host) {
{ scanLockRef.current = false
id, return
name: inferredName, }
url: `${parsed.protocol}//${parsed.host}`,
relayURL: `${relayParsed.protocol}//${relayParsed.host}`, const ok = addServer(host, pair.relayURL, pair.relaySecret)
relaySecret, if (!ok) {
status: "offline", scanLockRef.current = false
sessions: [], return
sessionsLoading: false, }
},
]) setScanOpen(false)
setActiveServerId(id) Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success).catch(() => {})
setActiveSessionId(null) },
setIsAddingServer(false) [addServer],
setServerDraftRelaySecret("") )
setDropdownMode("none")
refreshServerStatusAndSessions(id) useEffect(() => {
}, [refreshServerStatusAndSessions, serverDraftRelaySecret, serverDraftRelayURL, serverDraftURL]) if (scanOpen) return
scanLockRef.current = false
}, [scanOpen])
useEffect(() => { useEffect(() => {
if (!activeServerId) return if (!activeServerId) return
@ -1192,18 +1359,46 @@ export default function DictationScreen() {
if (!list.length) return if (!list.length) return
const bundleId = Constants.expoConfig?.ios?.bundleIdentifier ?? "com.anomalyco.mobilevoice" const bundleId = Constants.expoConfig?.ios?.bundleIdentifier ?? "com.anomalyco.mobilevoice"
const apnsEnv = process.env.NODE_ENV === "production" ? "production" : "sandbox" const apnsEnv = "production"
console.log("[Relay] env", {
dev: __DEV__,
node: process.env.NODE_ENV,
apnsEnv,
})
console.log("[Relay] register:batch", {
tokenSuffix: devicePushToken.slice(-8),
count: list.length,
apnsEnv,
bundleId,
})
Promise.allSettled( Promise.allSettled(
list.map((server) => list.map(async (server) => {
registerRelayDevice({ const secret = server.relaySecret.trim()
relayBaseURL: server.relayURL, const relay = server.relayURL
secret: server.relaySecret.trim(), console.log("[Relay] register:start", {
deviceToken: devicePushToken, id: server.id,
bundleId, relay,
apnsEnv, tokenSuffix: devicePushToken.slice(-8),
}), secretLength: secret.length,
), })
try {
await registerRelayDevice({
relayBaseURL: relay,
secret,
deviceToken: devicePushToken,
bundleId,
apnsEnv,
})
console.log("[Relay] register:ok", { id: server.id, relay })
} catch (err) {
console.log("[Relay] register:error", {
id: server.id,
relay,
error: err instanceof Error ? err.message : String(err),
})
}
}),
).catch(() => {}) ).catch(() => {})
}, [devicePushToken, servers]) }, [devicePushToken, servers])
@ -1216,15 +1411,37 @@ export default function DictationScreen() {
const list = servers.filter((server) => server.relaySecret.trim().length > 0) const list = servers.filter((server) => server.relaySecret.trim().length > 0)
if (!list.length) return if (!list.length) return
console.log("[Relay] unregister:batch", {
previousSuffix: previous.slice(-8),
nextSuffix: devicePushToken.slice(-8),
count: list.length,
})
Promise.allSettled( Promise.allSettled(
list.map((server) => list.map(async (server) => {
unregisterRelayDevice({ const secret = server.relaySecret.trim()
relayBaseURL: server.relayURL, const relay = server.relayURL
secret: server.relaySecret.trim(), console.log("[Relay] unregister:start", {
deviceToken: previous, id: server.id,
}), relay,
), tokenSuffix: previous.slice(-8),
secretLength: secret.length,
})
try {
await unregisterRelayDevice({
relayBaseURL: relay,
secret,
deviceToken: previous,
})
console.log("[Relay] unregister:ok", { id: server.id, relay })
} catch (err) {
console.log("[Relay] unregister:error", {
id: server.id,
relay,
error: err instanceof Error ? err.message : String(err),
})
}
}),
).catch(() => {}) ).catch(() => {})
}, [devicePushToken, servers]) }, [devicePushToken, servers])
@ -1335,6 +1552,9 @@ export default function DictationScreen() {
{effectiveDropdownMode === "server" ? ( {effectiveDropdownMode === "server" ? (
isAddingServer ? ( isAddingServer ? (
<View style={styles.addServerComposer}> <View style={styles.addServerComposer}>
<Pressable onPress={() => void handleStartScan()} style={styles.scanButton}>
<Text style={styles.scanButtonText}>Scan server QR</Text>
</Pressable>
<TextInput <TextInput
value={serverDraftURL} value={serverDraftURL}
onChangeText={setServerDraftURL} onChangeText={setServerDraftURL}
@ -1482,6 +1702,33 @@ export default function DictationScreen() {
</Pressable> </Pressable>
</Animated.View> </Animated.View>
</View> </View>
<Modal
visible={scanOpen}
animationType="slide"
presentationStyle="formSheet"
onRequestClose={() => setScanOpen(false)}
>
<SafeAreaView style={styles.scanRoot}>
<View style={styles.scanTop}>
<Text style={styles.scanTitle}>Scan server QR</Text>
<Pressable onPress={() => setScanOpen(false)}>
<Text style={styles.scanClose}>Close</Text>
</Pressable>
</View>
{camGranted && camera ? (
<camera.CameraView
style={styles.scanCam}
barcodeScannerSettings={{ barcodeTypes: ["qr"] }}
onBarcodeScanned={handleScan}
/>
) : (
<View style={styles.scanEmpty}>
<Text style={styles.scanHint}>Camera permission is required to scan setup QR codes.</Text>
</View>
)}
</SafeAreaView>
</Modal>
</SafeAreaView> </SafeAreaView>
) )
} }
@ -1636,6 +1883,21 @@ const styles = StyleSheet.create({
paddingHorizontal: 4, paddingHorizontal: 4,
gap: 8, gap: 8,
}, },
scanButton: {
height: 38,
borderRadius: 10,
borderWidth: 1,
borderColor: "#2F4D84",
backgroundColor: "#142544",
alignItems: "center",
justifyContent: "center",
},
scanButtonText: {
color: "#A8C7FF",
fontSize: 14,
fontWeight: "700",
letterSpacing: 0.2,
},
addServerInput: { addServerInput: {
height: 38, height: 38,
borderRadius: 10, borderRadius: 10,
@ -1829,6 +2091,44 @@ const styles = StyleSheet.create({
fontWeight: "700", fontWeight: "700",
letterSpacing: 0.2, letterSpacing: 0.2,
}, },
scanRoot: {
flex: 1,
backgroundColor: "#101014",
paddingHorizontal: 16,
paddingTop: 12,
gap: 12,
},
scanTop: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
},
scanTitle: {
color: "#E8EAF0",
fontSize: 18,
fontWeight: "700",
},
scanClose: {
color: "#8FA4CC",
fontSize: 15,
fontWeight: "600",
},
scanCam: {
flex: 1,
borderRadius: 18,
overflow: "hidden",
},
scanEmpty: {
flex: 1,
alignItems: "center",
justifyContent: "center",
paddingHorizontal: 24,
},
scanHint: {
color: "#A6ABBA",
fontSize: 14,
textAlign: "center",
},
sendSlot: { sendSlot: {
height: CONTROL_HEIGHT, height: CONTROL_HEIGHT,
overflow: "hidden", overflow: "hidden",

View File

@ -53,6 +53,7 @@
"@types/bun": "catalog:", "@types/bun": "catalog:",
"@types/cross-spawn": "6.0.6", "@types/cross-spawn": "6.0.6",
"@types/mime-types": "3.0.1", "@types/mime-types": "3.0.1",
"@types/qrcode": "1.5.5",
"@types/semver": "^7.5.8", "@types/semver": "^7.5.8",
"@types/turndown": "5.0.5", "@types/turndown": "5.0.5",
"@types/which": "3.0.4", "@types/which": "3.0.4",
@ -137,6 +138,7 @@
"opencode-poe-auth": "0.0.1", "opencode-poe-auth": "0.0.1",
"opentui-spinner": "0.0.6", "opentui-spinner": "0.0.6",
"partial-json": "0.1.7", "partial-json": "0.1.7",
"qrcode": "1.5.4",
"remeda": "catalog:", "remeda": "catalog:",
"semver": "^7.6.3", "semver": "^7.6.3",
"solid-js": "catalog:", "solid-js": "catalog:",

View File

@ -1,3 +1,5 @@
import { randomBytes } from "node:crypto"
import os from "node:os"
import { Server } from "../../server/server" import { Server } from "../../server/server"
import { cmd } from "./cmd" import { cmd } from "./cmd"
import { withNetworkOptions, resolveNetworkOptions } from "../network" import { withNetworkOptions, resolveNetworkOptions } from "../network"
@ -6,6 +8,25 @@ import { Workspace } from "../../control-plane/workspace"
import { Project } from "../../project/project" import { Project } from "../../project/project"
import { Installation } from "../../installation" import { Installation } from "../../installation"
import { PushRelay } from "../../server/push-relay" import { PushRelay } from "../../server/push-relay"
import * as QRCode from "qrcode"
function hosts(hostname: string, port: number) {
const list = new Set<string>()
const add = (item: string) => {
if (!item) return
if (item === "0.0.0.0") return
if (item === "::") return
list.add(`http://${item}:${port}`)
}
add(hostname)
add("127.0.0.1")
Object.values(os.networkInterfaces())
.flatMap((item) => item ?? [])
.filter((item) => item.family === "IPv4" && !item.internal)
.map((item) => item.address)
.forEach(add)
return [...list]
}
export const ServeCommand = cmd({ export const ServeCommand = cmd({
command: "serve", command: "serve",
@ -33,18 +54,40 @@ export const ServeCommand = cmd({
process.env.OPENCODE_EXPERIMENTAL_PUSH_RELAY_URL ?? process.env.OPENCODE_EXPERIMENTAL_PUSH_RELAY_URL ??
"https://apn.dev.opencode.ai" "https://apn.dev.opencode.ai"
).trim() ).trim()
const relaySecret = (args["relay-secret"] ?? process.env.OPENCODE_EXPERIMENTAL_PUSH_RELAY_SECRET ?? "").trim() const input = (args["relay-secret"] ?? process.env.OPENCODE_EXPERIMENTAL_PUSH_RELAY_SECRET ?? "").trim()
const relaySecret = input || randomBytes(18).toString("base64url")
if (!input) {
console.log("experimental push relay secret generated")
}
if (relayURL && relaySecret) { if (relayURL && relaySecret) {
const host = server.hostname ?? opts.hostname const host = server.hostname ?? opts.hostname
const port = server.port || opts.port || 4096 const port = server.port || opts.port || 4096
const pair = PushRelay.start({ const started = PushRelay.start({
relayURL, relayURL,
relaySecret, relaySecret,
hostname: host, hostname: host,
port, port,
}) })
const pair = started ??
PushRelay.pair() ?? {
v: 1 as const,
relayURL,
relaySecret,
hosts: hosts(host, port),
}
if (!started) {
console.log("experimental push relay failed to initialize; showing setup qr anyway")
}
if (pair) { if (pair) {
console.log("experimental push relay enabled") console.log("experimental push relay enabled")
const payload = JSON.stringify(pair)
const code = await QRCode.toString(payload, {
type: "terminal",
small: true,
errorCorrectionLevel: "M",
})
console.log("scan qr code in mobile app")
console.log(code)
console.log("qr payload") console.log("qr payload")
console.log(JSON.stringify(pair, null, 2)) console.log(JSON.stringify(pair, null, 2))
} }

View File

@ -1,5 +1,5 @@
import os from "node:os" import os from "node:os"
import { Bus } from "@/bus" import { GlobalBus } from "@/bus/global"
import { Log } from "@/util/log" import { Log } from "@/util/log"
type Type = "complete" | "permission" | "error" type Type = "complete" | "permission" | "error"
@ -183,20 +183,15 @@ export namespace PushRelay {
hosts: list(input.hostname, input.port), hosts: list(input.hostname, input.port),
} }
let unsub: (() => void) | undefined const callback = (event: { payload: Event }) => {
try { const next = map(event.payload)
unsub = Bus.subscribeAll((event) => { if (!next) return
const next = map(event) post(next)
if (!next) return }
post(next) GlobalBus.on("event", callback)
}) const unsub = () => {
} catch (error) { GlobalBus.off("event", callback)
log.warn("failed to subscribe", {
error: String(error),
})
return
} }
if (!unsub) return
state = { state = {
relayURL, relayURL,