@@ -13,6 +13,49 @@ import { errors } from "../error"
1313import { lazy } from "../../util/lazy"
1414import { WorkspaceRoutes } from "./workspace"
1515import { PushRelay } from "../push-relay"
16+ import * as QRCode from "qrcode"
17+
18+ const PushPairPayload = z
19+ . object ( {
20+ v : z . literal ( 1 ) ,
21+ serverID : z . string ( ) . optional ( ) ,
22+ relayURL : z . string ( ) ,
23+ relaySecret : z . string ( ) ,
24+ hosts : z . array ( z . string ( ) ) ,
25+ } )
26+ . meta ( { ref : "PushPairPayload" } )
27+
28+ const PushPairResult = z
29+ . discriminatedUnion ( "enabled" , [
30+ z . object ( {
31+ enabled : z . literal ( false ) ,
32+ } ) ,
33+ z . object ( {
34+ enabled : z . literal ( true ) ,
35+ hosts : z . array ( z . string ( ) ) ,
36+ qr : z . string ( ) ,
37+ } ) ,
38+ ] )
39+ . meta ( { ref : "PushPairResult" } )
40+
41+ const pushPairQROptions = {
42+ errorCorrectionLevel : "M" as const ,
43+ margin : 1 ,
44+ width : 256 ,
45+ }
46+
47+ function pushPairLink ( payload : z . infer < typeof PushPairPayload > ) {
48+ return `mobilevoice:///?pair=${ encodeURIComponent ( JSON . stringify ( payload ) ) } `
49+ }
50+
51+ async function pushPairQRCode ( payload : z . infer < typeof PushPairPayload > ) {
52+ return QRCode . toDataURL ( pushPairLink ( payload ) , pushPairQROptions )
53+ }
54+
55+ async function pushPairQRCodePNG ( payload : z . infer < typeof PushPairPayload > ) {
56+ const data = await pushPairQRCode ( payload )
57+ return Buffer . from ( data . replace ( / ^ d a t a : i m a g e \/ p n g ; b a s e 6 4 , / , "" ) , "base64" )
58+ }
1659
1760export const ExperimentalRoutes = lazy ( ( ) =>
1861 new Hono ( )
@@ -269,6 +312,70 @@ export const ExperimentalRoutes = lazy(() =>
269312 return c . json ( await MCP . resources ( ) )
270313 } ,
271314 )
315+ . get (
316+ "/push/pair" ,
317+ describeRoute ( {
318+ summary : "Get push relay pairing QR" ,
319+ description : "Get the active push relay pairing payload and QR code for mobile setup." ,
320+ operationId : "experimental.push.pair" ,
321+ responses : {
322+ 200 : {
323+ description : "Push relay pairing info" ,
324+ content : {
325+ "application/json" : {
326+ schema : resolver ( PushPairResult ) ,
327+ } ,
328+ } ,
329+ } ,
330+ } ,
331+ } ) ,
332+ async ( c ) => {
333+ const pair = PushRelay . pair ( )
334+ if ( ! pair ) {
335+ return c . json ( {
336+ enabled : false ,
337+ } )
338+ }
339+
340+ const qr = await pushPairQRCode ( pair )
341+
342+ return c . json ( {
343+ enabled : true ,
344+ hosts : pair . hosts ,
345+ qr,
346+ } )
347+ } ,
348+ )
349+ . get (
350+ "/push/pair/qr" ,
351+ describeRoute ( {
352+ summary : "Get push relay pairing QR image" ,
353+ description : "Render the active push relay pairing QR code as a PNG image." ,
354+ operationId : "experimental.push.pair.qr" ,
355+ responses : {
356+ 200 : {
357+ description : "Push relay pairing QR image" ,
358+ content : {
359+ "image/png" : {
360+ schema : { type : "string" , format : "binary" } as any ,
361+ } ,
362+ } ,
363+ } ,
364+ 404 : {
365+ description : "Push relay pairing is not enabled" ,
366+ } ,
367+ } ,
368+ } ) ,
369+ async ( c ) => {
370+ const pair = PushRelay . pair ( )
371+ if ( ! pair ) {
372+ return c . text ( "Push pairing is not enabled" , 404 )
373+ }
374+
375+ c . header ( "Content-Type" , "image/png" )
376+ return c . body ( await pushPairQRCodePNG ( pair ) )
377+ } ,
378+ )
272379 . get (
273380 "/push" ,
274381 describeRoute ( {
0 commit comments