mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:40:44 +00:00
refactor(qr): share PNG data URL helpers (#70784)
This commit is contained in:
@@ -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`
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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})`,
|
||||
|
||||
@@ -1 +1,5 @@
|
||||
export { renderQrPngBase64 } from "openclaw/plugin-sdk/media-runtime";
|
||||
export {
|
||||
renderQrPngBase64,
|
||||
renderQrPngDataUrl,
|
||||
writeQrPngTempFile,
|
||||
} from "openclaw/plugin-sdk/media-runtime";
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -1 +1 @@
|
||||
export { renderQrPngBase64 } from "openclaw/plugin-sdk/media-runtime";
|
||||
export { renderQrPngBase64, renderQrPngDataUrl } from "openclaw/plugin-sdk/media-runtime";
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string> {
|
||||
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<string> {
|
||||
return formatQrPngDataUrl(await renderQrPngBase64(input, opts));
|
||||
}
|
||||
|
||||
export async function writeQrPngTempFile(
|
||||
input: string,
|
||||
opts: QrPngTempFileOptions,
|
||||
): Promise<QrPngTempFile> {
|
||||
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],
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user