feat: expose push pairing QR endpoints

Return both JSON metadata and a PNG QR so clients can consume mobile pairing without rebuilding the payload themselves.
pull/19545/head
Ryan Vogel 2026-04-02 13:00:08 +00:00
parent 36b51cad33
commit c90640e0e1
1 changed files with 107 additions and 0 deletions

View File

@ -13,6 +13,49 @@ import { errors } from "../error"
import { lazy } from "../../util/lazy" import { lazy } from "../../util/lazy"
import { WorkspaceRoutes } from "./workspace" import { WorkspaceRoutes } from "./workspace"
import { PushRelay } from "../push-relay" import { PushRelay } from "../push-relay"
import * as QRCode from "qrcode"
const PushPairPayload = z
.object({
v: z.literal(1),
serverID: z.string().optional(),
relayURL: z.string(),
relaySecret: z.string(),
hosts: z.array(z.string()),
})
.meta({ ref: "PushPairPayload" })
const PushPairResult = z
.discriminatedUnion("enabled", [
z.object({
enabled: z.literal(false),
}),
z.object({
enabled: z.literal(true),
hosts: z.array(z.string()),
qr: z.string(),
}),
])
.meta({ ref: "PushPairResult" })
const pushPairQROptions = {
errorCorrectionLevel: "M" as const,
margin: 1,
width: 256,
}
function pushPairLink(payload: z.infer<typeof PushPairPayload>) {
return `mobilevoice:///?pair=${encodeURIComponent(JSON.stringify(payload))}`
}
async function pushPairQRCode(payload: z.infer<typeof PushPairPayload>) {
return QRCode.toDataURL(pushPairLink(payload), pushPairQROptions)
}
async function pushPairQRCodePNG(payload: z.infer<typeof PushPairPayload>) {
const data = await pushPairQRCode(payload)
return Buffer.from(data.replace(/^data:image\/png;base64,/, ""), "base64")
}
export const ExperimentalRoutes = lazy(() => export const ExperimentalRoutes = lazy(() =>
new Hono() new Hono()
@ -269,6 +312,70 @@ export const ExperimentalRoutes = lazy(() =>
return c.json(await MCP.resources()) return c.json(await MCP.resources())
}, },
) )
.get(
"/push/pair",
describeRoute({
summary: "Get push relay pairing QR",
description: "Get the active push relay pairing payload and QR code for mobile setup.",
operationId: "experimental.push.pair",
responses: {
200: {
description: "Push relay pairing info",
content: {
"application/json": {
schema: resolver(PushPairResult),
},
},
},
},
}),
async (c) => {
const pair = PushRelay.pair()
if (!pair) {
return c.json({
enabled: false,
})
}
const qr = await pushPairQRCode(pair)
return c.json({
enabled: true,
hosts: pair.hosts,
qr,
})
},
)
.get(
"/push/pair/qr",
describeRoute({
summary: "Get push relay pairing QR image",
description: "Render the active push relay pairing QR code as a PNG image.",
operationId: "experimental.push.pair.qr",
responses: {
200: {
description: "Push relay pairing QR image",
content: {
"image/png": {
schema: { type: "string", format: "binary" } as any,
},
},
},
404: {
description: "Push relay pairing is not enabled",
},
},
}),
async (c) => {
const pair = PushRelay.pair()
if (!pair) {
return c.text("Push pairing is not enabled", 404)
}
c.header("Content-Type", "image/png")
return c.body(await pushPairQRCodePNG(pair))
},
)
.get( .get(
"/push", "/push",
describeRoute({ describeRoute({