refactor(qr): share PNG data URL helpers (#70784)

This commit is contained in:
Vincent Koc
2026-04-23 15:41:45 -07:00
committed by GitHub
parent 707e13f966
commit 1daa552d5f
10 changed files with 160 additions and 33 deletions

View File

@@ -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";

View File

@@ -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(

View File

@@ -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<string> {
const pngBase64 = await renderQrPngBase64(data);
return `data:image/png;base64,${pngBase64}`;
}
async function writeQrPngTempFile(data: string): Promise<string> {
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})`,

View File

@@ -1 +1,5 @@
export { renderQrPngBase64 } from "openclaw/plugin-sdk/media-runtime";
export {
renderQrPngBase64,
renderQrPngDataUrl,
writeQrPngTempFile,
} from "openclaw/plugin-sdk/media-runtime";

View File

@@ -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);

View File

@@ -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.",

View File

@@ -1 +1 @@
export { renderQrPngBase64 } from "openclaw/plugin-sdk/media-runtime";
export { renderQrPngBase64, renderQrPngDataUrl } from "openclaw/plugin-sdk/media-runtime";