feat: QR code scanning for gateway onboarding

iOS:
- QR scanner view using DataScannerViewController
- Photo library QR detection via CIDetector for saved QR images
- Deep link parser for openclaw://gateway URLs and base64url setup codes
- Onboarding wizard: full-screen welcome with "Scan QR Code" button,
  auto-connect on scan, back navigation, step indicators for manual flow

Backend:
- Add /pair qr action to device-pair extension for QR code generation
- TUI/WebUI differentiation: ASCII QR for TUI, markdown image for WebUI
- Telegram: send QR as media attachment via sendMessageTelegram
- Add data URI support to loadWebMedia for generic base64 media handling
- Export renderQrPngBase64 from plugin SDK for extension use

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
(cherry picked from commit d79ed65be0)
This commit is contained in:
Hongwei Ma
2026-02-14 21:47:33 +08:00
committed by Mariano Belinky
parent df6d0ee92b
commit 06adadc759
6 changed files with 238 additions and 1 deletions

View File

@@ -1,6 +1,15 @@
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import os from "node:os";
import { approveDevicePairing, listDevicePairing } from "openclaw/plugin-sdk";
import { approveDevicePairing, listDevicePairing, renderQrPngBase64 } from "openclaw/plugin-sdk";
import qrcode from "qrcode-terminal";
function renderQrAscii(data: string): Promise<string> {
return new Promise((resolve) => {
qrcode.generate(data, { small: true }, (output: string) => {
resolve(output);
});
});
}
const DEFAULT_GATEWAY_PORT = 18789;
@@ -434,6 +443,84 @@ export default function register(api: OpenClawPluginApi) {
password: auth.password,
};
if (action === "qr") {
const setupCode = encodeSetupCode(payload);
const [qrBase64, qrAscii] = await Promise.all([
renderQrPngBase64(setupCode),
renderQrAscii(setupCode),
]);
const authLabel = auth.label ?? "auth";
const dataUrl = `data:image/png;base64,${qrBase64}`;
const channel = ctx.channel;
const target = ctx.senderId?.trim() || ctx.from?.trim() || ctx.to?.trim() || "";
if (channel === "telegram" && target) {
try {
const send = api.runtime?.channel?.telegram?.sendMessageTelegram;
if (send) {
await send(target, "Scan this QR code with the OpenClaw iOS app:", {
...(ctx.messageThreadId != null ? { messageThreadId: ctx.messageThreadId } : {}),
...(ctx.accountId ? { accountId: ctx.accountId } : {}),
mediaUrl: dataUrl,
});
return {
text: [
`Gateway: ${payload.url}`,
`Auth: ${authLabel}`,
"",
"After scanning, come back here and run `/pair approve` to complete pairing.",
].join("\n"),
};
}
} catch (err) {
api.logger.warn?.(
`device-pair: telegram QR send failed, falling back (${String(
(err as Error)?.message ?? err,
)})`,
);
}
}
// Render based on channel capability
api.logger.info?.(`device-pair: QR fallback channel=${channel} target=${target}`);
const infoLines = [
`Gateway: ${payload.url}`,
`Auth: ${authLabel}`,
"",
"After scanning, run `/pair approve` to complete pairing.",
];
// TUI (gateway-client) needs ASCII, WebUI can render markdown images
const isTui = target === "gateway-client" || channel !== "webchat";
if (!isTui) {
// WebUI: markdown image only
return {
text: [
"Scan this QR code with the OpenClaw iOS app:",
"",
`![Pairing QR](${dataUrl})`,
"",
...infoLines,
].join("\n"),
};
}
// CLI/TUI: ASCII QR only
return {
text: [
"Scan this QR code with the OpenClaw iOS app:",
"",
"```",
qrAscii,
"```",
"",
...infoLines,
].join("\n"),
};
}
const channel = ctx.channel;
const target = ctx.senderId?.trim() || ctx.from?.trim() || ctx.to?.trim() || "";
const authLabel = auth.label ?? "auth";