From 1daa552d5f429f9c57daa3ed050c34239b8a22dc Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Thu, 23 Apr 2026 15:41:45 -0700 Subject: [PATCH] refactor(qr): share PNG data URL helpers (#70784) --- docs/plugins/sdk-runtime.md | 7 +++ extensions/device-pair/api.ts | 2 +- extensions/device-pair/index.test.ts | 15 ++++-- extensions/device-pair/index.ts | 29 ++++------- extensions/device-pair/qr-image.ts | 6 ++- extensions/whatsapp/src/login-qr.test.ts | 1 + extensions/whatsapp/src/login-qr.ts | 5 +- extensions/whatsapp/src/qr-image.ts | 2 +- src/media/qr-image.test.ts | 64 ++++++++++++++++++++++-- src/media/qr-image.ts | 62 ++++++++++++++++++++++- 10 files changed, 160 insertions(+), 33 deletions(-) diff --git a/docs/plugins/sdk-runtime.md b/docs/plugins/sdk-runtime.md index fc016036b4d..2935869a8f2 100644 --- a/docs/plugins/sdk-runtime.md +++ b/docs/plugins/sdk-runtime.md @@ -261,6 +261,13 @@ const pngQr = await api.runtime.media.renderQrPngBase64("https://openclaw.ai", { scale: 6, // 1-12 marginModules: 4, // 0-16 }); +const pngQrDataUrl = await api.runtime.media.renderQrPngDataUrl("https://openclaw.ai"); +const tmpRoot = resolvePreferredOpenClawTmpDir(); +const pngQrFile = await api.runtime.media.writeQrPngTempFile("https://openclaw.ai", { + tmpRoot, + dirPrefix: "my-plugin-qr-", + fileName: "qr.png", +}); ``` ### `api.runtime.config` diff --git a/extensions/device-pair/api.ts b/extensions/device-pair/api.ts index 7248c894237..2ec7be950d4 100644 --- a/extensions/device-pair/api.ts +++ b/extensions/device-pair/api.ts @@ -17,4 +17,4 @@ export { resolvePreferredOpenClawTmpDir, runPluginCommandWithTimeout, } from "openclaw/plugin-sdk/sandbox"; -export { renderQrPngBase64 } from "./qr-image.js"; +export { renderQrPngBase64, renderQrPngDataUrl, writeQrPngTempFile } from "./qr-image.js"; diff --git a/extensions/device-pair/index.test.ts b/extensions/device-pair/index.test.ts index 66d6a897984..23fea6accaa 100644 --- a/extensions/device-pair/index.test.ts +++ b/extensions/device-pair/index.test.ts @@ -17,9 +17,15 @@ const pluginApiMocks = vi.hoisted(() => ({ expiresAtMs: Date.now() + 10 * 60_000, })), revokeDeviceBootstrapToken: vi.fn(async () => ({ removed: true })), - renderQrPngBase64: vi.fn(async () => "ZmFrZXBuZw=="), + renderQrPngDataUrl: vi.fn(async () => "data:image/png;base64,ZmFrZXBuZw=="), resolveGatewayPort: vi.fn(() => 18789), resolvePreferredOpenClawTmpDir: vi.fn(() => path.join(os.tmpdir(), "openclaw-device-pair-tests")), + writeQrPngTempFile: vi.fn(async (_data: string, opts: { tmpRoot: string }) => { + const dirPath = await fs.mkdtemp(path.join(opts.tmpRoot, "device-pair-qr-")); + const filePath = path.join(dirPath, "pair-qr.png"); + await fs.writeFile(filePath, "fakepng"); + return { filePath, dirPath, mediaLocalRoots: [dirPath] }; + }), })); vi.mock("./api.js", () => { @@ -33,13 +39,14 @@ vi.mock("./api.js", () => { definePluginEntry: vi.fn((entry) => entry), issueDeviceBootstrapToken: pluginApiMocks.issueDeviceBootstrapToken, listDevicePairing: vi.fn(async () => ({ pending: [] })), - renderQrPngBase64: pluginApiMocks.renderQrPngBase64, + renderQrPngDataUrl: pluginApiMocks.renderQrPngDataUrl, revokeDeviceBootstrapToken: pluginApiMocks.revokeDeviceBootstrapToken, resolvePreferredOpenClawTmpDir: pluginApiMocks.resolvePreferredOpenClawTmpDir, resolveGatewayBindUrl: vi.fn(), resolveGatewayPort: pluginApiMocks.resolveGatewayPort, resolveTailnetHostWithRunner: vi.fn(), runPluginCommandWithTimeout: vi.fn(), + writeQrPngTempFile: pluginApiMocks.writeQrPngTempFile, }; }); @@ -254,7 +261,7 @@ describe("device-pair /pair qr", () => { const payload = result as { text?: string; mediaUrl?: string; sensitiveMedia?: boolean }; const text = requireText(result); - expect(pluginApiMocks.renderQrPngBase64).toHaveBeenCalledTimes(1); + expect(pluginApiMocks.renderQrPngDataUrl).toHaveBeenCalledTimes(1); expect(pluginApiMocks.issueDeviceBootstrapToken).toHaveBeenCalledWith({ profile: { roles: ["node"], @@ -297,7 +304,7 @@ describe("device-pair /pair qr", () => { token: "second-token", expiresAtMs: Date.now() + 10 * 60_000, }); - pluginApiMocks.renderQrPngBase64.mockRejectedValueOnce(new Error("render failed")); + pluginApiMocks.renderQrPngDataUrl.mockRejectedValueOnce(new Error("render failed")); const command = registerPairCommand(); const result = await command.handler( diff --git a/extensions/device-pair/index.ts b/extensions/device-pair/index.ts index 8e928223af3..6dfeec22e70 100644 --- a/extensions/device-pair/index.ts +++ b/extensions/device-pair/index.ts @@ -1,4 +1,4 @@ -import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { rm } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { @@ -11,7 +11,8 @@ import { issueDeviceBootstrapToken, listDevicePairing, PAIRING_SETUP_BOOTSTRAP_PROFILE, - renderQrPngBase64, + renderQrPngDataUrl, + writeQrPngTempFile, revokeDeviceBootstrapToken, resolveGatewayBindUrl, resolveGatewayPort, @@ -35,20 +36,6 @@ import { resolvePairingCommandAuthState, } from "./pair-command-auth.js"; -async function renderQrDataUrl(data: string): Promise { - const pngBase64 = await renderQrPngBase64(data); - return `data:image/png;base64,${pngBase64}`; -} - -async function writeQrPngTempFile(data: string): Promise { - const pngBase64 = await renderQrPngBase64(data); - const tmpRoot = resolvePreferredOpenClawTmpDir(); - const qrDir = await mkdtemp(path.join(tmpRoot, "device-pair-qr-")); - const filePath = path.join(qrDir, "pair-qr.png"); - await writeFile(filePath, Buffer.from(pngBase64, "base64")); - return filePath; -} - function formatDurationMinutes(expiresAtMs: number): string { const msRemaining = Math.max(0, expiresAtMs - Date.now()); const minutes = Math.max(1, Math.ceil(msRemaining / 60_000)); @@ -671,7 +658,13 @@ export default definePluginEntry({ if (target && canSendQrPngToChannel(channel)) { let qrFilePath: string | undefined; try { - qrFilePath = await writeQrPngTempFile(setupCode); + qrFilePath = ( + await writeQrPngTempFile(setupCode, { + tmpRoot: resolvePreferredOpenClawTmpDir(), + dirPrefix: "device-pair-qr-", + fileName: "pair-qr.png", + }) + ).filePath; const sent = await sendQrPngToSupportedChannel({ api, ctx, @@ -709,7 +702,7 @@ export default definePluginEntry({ if (channel === "webchat") { let qrDataUrl: string; try { - qrDataUrl = await renderQrDataUrl(setupCode); + qrDataUrl = await renderQrPngDataUrl(setupCode); } catch (err) { api.logger.warn?.( `device-pair: webchat QR render failed, falling back (${(err as Error)?.message ?? err})`, diff --git a/extensions/device-pair/qr-image.ts b/extensions/device-pair/qr-image.ts index fe28c771a4e..13914efca68 100644 --- a/extensions/device-pair/qr-image.ts +++ b/extensions/device-pair/qr-image.ts @@ -1 +1,5 @@ -export { renderQrPngBase64 } from "openclaw/plugin-sdk/media-runtime"; +export { + renderQrPngBase64, + renderQrPngDataUrl, + writeQrPngTempFile, +} from "openclaw/plugin-sdk/media-runtime"; diff --git a/extensions/whatsapp/src/login-qr.test.ts b/extensions/whatsapp/src/login-qr.test.ts index a7bfe80f9ac..cdc8af48303 100644 --- a/extensions/whatsapp/src/login-qr.test.ts +++ b/extensions/whatsapp/src/login-qr.test.ts @@ -40,6 +40,7 @@ vi.mock("./session.js", async () => { vi.mock("./qr-image.js", () => ({ renderQrPngBase64: vi.fn(async () => "base64"), + renderQrPngDataUrl: vi.fn(async () => "data:image/png;base64,base64"), })); const createWaSocketMock = vi.mocked(createWaSocket); diff --git a/extensions/whatsapp/src/login-qr.ts b/extensions/whatsapp/src/login-qr.ts index ff1d1354212..38510a7fb3b 100644 --- a/extensions/whatsapp/src/login-qr.ts +++ b/extensions/whatsapp/src/login-qr.ts @@ -9,7 +9,7 @@ import { waitForWhatsAppLoginResult, WHATSAPP_LOGGED_OUT_QR_MESSAGE, } from "./connection-controller.js"; -import { renderQrPngBase64 } from "./qr-image.js"; +import { renderQrPngDataUrl } from "./qr-image.js"; import { createWaSocket, readWebAuthExistsForDecision, @@ -278,8 +278,7 @@ export async function startWebLoginWithQr( }; } - const base64 = await renderQrPngBase64(loginStartResult.qr); - login.qrDataUrl = `data:image/png;base64,${base64}`; + login.qrDataUrl = await renderQrPngDataUrl(loginStartResult.qr); return { qrDataUrl: login.qrDataUrl, message: "Scan this QR in WhatsApp → Linked Devices.", diff --git a/extensions/whatsapp/src/qr-image.ts b/extensions/whatsapp/src/qr-image.ts index fe28c771a4e..2fc757f645d 100644 --- a/extensions/whatsapp/src/qr-image.ts +++ b/extensions/whatsapp/src/qr-image.ts @@ -1 +1 @@ -export { renderQrPngBase64 } from "openclaw/plugin-sdk/media-runtime"; +export { renderQrPngBase64, renderQrPngDataUrl } from "openclaw/plugin-sdk/media-runtime"; diff --git a/src/media/qr-image.test.ts b/src/media/qr-image.test.ts index 54537e4613c..f86bc0225f5 100644 --- a/src/media/qr-image.test.ts +++ b/src/media/qr-image.test.ts @@ -1,21 +1,41 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -const renderPngBase64 = vi.hoisted(() => vi.fn(async () => "mocked-base64")); +const { MOCK_PNG_BASE64, renderPngBase64 } = vi.hoisted(() => { + const MOCK_PNG_BASE64 = "ZmFrZXBuZw=="; + return { + MOCK_PNG_BASE64, + renderPngBase64: vi.fn(async () => MOCK_PNG_BASE64), + }; +}); vi.mock("@vincentkoc/qrcode-tui", () => ({ renderPngBase64, })); -import { renderQrPngBase64 } from "./qr-image.ts"; +import { + formatQrPngDataUrl, + renderQrPngBase64, + renderQrPngDataUrl, + writeQrPngTempFile, +} from "./qr-image.ts"; describe("renderQrPngBase64", () => { + const tmpRoot = path.join(os.tmpdir(), "openclaw-qr-image-tests"); + beforeEach(() => { renderPngBase64.mockClear(); }); + afterEach(async () => { + await fs.rm(tmpRoot, { recursive: true, force: true }); + }); + it("delegates PNG rendering to qrcode-tui", async () => { await expect(renderQrPngBase64("openclaw", { scale: 8, marginModules: 2 })).resolves.toBe( - "mocked-base64", + MOCK_PNG_BASE64, ); expect(renderPngBase64).toHaveBeenCalledWith("openclaw", { margin: 2, @@ -50,4 +70,40 @@ describe("renderQrPngBase64", () => { await expect(renderQrPngBase64("openclaw", { scale, marginModules })).rejects.toThrow(message); expect(renderPngBase64).not.toHaveBeenCalled(); }); + + it("formats QR PNG data URLs", async () => { + expect(formatQrPngDataUrl(MOCK_PNG_BASE64)).toBe(`data:image/png;base64,${MOCK_PNG_BASE64}`); + await expect(renderQrPngDataUrl("openclaw")).resolves.toBe( + `data:image/png;base64,${MOCK_PNG_BASE64}`, + ); + }); + + it("writes QR PNGs to a scoped temp file", async () => { + await fs.mkdir(tmpRoot, { recursive: true }); + + const result = await writeQrPngTempFile("openclaw", { + tmpRoot, + dirPrefix: "pair-", + fileName: "pair-qr.png", + }); + + expect(path.basename(result.filePath)).toBe("pair-qr.png"); + expect(path.basename(result.dirPath)).toMatch(/^pair-/); + expect(result.mediaLocalRoots).toEqual([result.dirPath]); + await expect(fs.readFile(result.filePath, "utf8")).resolves.toBe("fakepng"); + }); + + it.each([ + ["dirPrefix", { dirPrefix: "../pair-", fileName: "qr.png" }], + ["fileName", { dirPrefix: "pair-", fileName: "../qr.png" }], + ])("rejects pathful QR temp %s values", async (name, opts) => { + await expect( + writeQrPngTempFile("openclaw", { + tmpRoot, + dirPrefix: opts.dirPrefix, + fileName: opts.fileName, + }), + ).rejects.toThrow(`${name} must be a non-empty filename segment.`); + expect(renderPngBase64).not.toHaveBeenCalled(); + }); }); diff --git a/src/media/qr-image.ts b/src/media/qr-image.ts index b954648f6eb..536b515f97c 100644 --- a/src/media/qr-image.ts +++ b/src/media/qr-image.ts @@ -1,3 +1,5 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import path from "node:path"; import { loadQrCodeTuiRuntime } from "./qr-runtime.ts"; const DEFAULT_QR_PNG_SCALE = 6; @@ -6,6 +8,24 @@ const MIN_QR_PNG_SCALE = 1; const MAX_QR_PNG_SCALE = 12; const MIN_QR_PNG_MARGIN_MODULES = 0; const MAX_QR_PNG_MARGIN_MODULES = 16; +const QR_PNG_DATA_URL_PREFIX = "data:image/png;base64,"; + +export type QrPngRenderOptions = { + scale?: number; + marginModules?: number; +}; + +export type QrPngTempFileOptions = QrPngRenderOptions & { + tmpRoot: string; + dirPrefix: string; + fileName?: string; +}; + +export type QrPngTempFile = { + filePath: string; + dirPath: string; + mediaLocalRoots: string[]; +}; function resolveQrPngIntegerOption(params: { name: string; @@ -27,9 +47,16 @@ function resolveQrPngIntegerOption(params: { return value; } +function resolveQrTempPathSegment(name: string, value: string): string { + if (!value || value === "." || value === ".." || path.basename(value) !== value) { + throw new RangeError(`${name} must be a non-empty filename segment.`); + } + return value; +} + export async function renderQrPngBase64( input: string, - opts: { scale?: number; marginModules?: number } = {}, + opts: QrPngRenderOptions = {}, ): Promise { const scale = resolveQrPngIntegerOption({ name: "scale", @@ -51,3 +78,36 @@ export async function renderQrPngBase64( scale, }); } + +export function formatQrPngDataUrl(base64: string): string { + return `${QR_PNG_DATA_URL_PREFIX}${base64}`; +} + +export async function renderQrPngDataUrl( + input: string, + opts: QrPngRenderOptions = {}, +): Promise { + return formatQrPngDataUrl(await renderQrPngBase64(input, opts)); +} + +export async function writeQrPngTempFile( + input: string, + opts: QrPngTempFileOptions, +): Promise { + const dirPrefix = resolveQrTempPathSegment("dirPrefix", opts.dirPrefix); + const fileName = resolveQrTempPathSegment("fileName", opts.fileName ?? "qr.png"); + const pngBase64 = await renderQrPngBase64(input, opts); + const dirPath = await mkdtemp(path.join(opts.tmpRoot, dirPrefix)); + const filePath = path.join(dirPath, fileName); + try { + await writeFile(filePath, Buffer.from(pngBase64, "base64")); + } catch (err) { + await rm(dirPath, { recursive: true, force: true }).catch(() => {}); + throw err; + } + return { + filePath, + dirPath, + mediaLocalRoots: [dirPath], + }; +}