Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions main/handlers/providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,10 @@ import {
normalizeAppearanceConfig,
parseAppearanceConfig,
} from "../../renderer/shared/appearance.js";
import { normalizeProviderArtwork } from "../../renderer/shared/provider-artwork.js";
import { normalizeProviderArtworkInput } from "../services/provider-artwork.js";
import {
normalizeProviderArtworkInput,
persistableProviderArtwork,
} from "../services/provider-artwork.js";
import { isGenerationThinkingLevel } from "../../renderer/shared/generation-thinking.js";
import { isGeminiUsageScope } from "../../renderer/shared/gemini-usage-scope.js";
import { isGeminiTranscriptionModel } from "../../renderer/shared/voice-models.js";
Expand Down Expand Up @@ -158,7 +160,7 @@ function parseProvider(value: unknown): StoredProvider {
id: asProviderId(p.id),
kind,
label: asString(p.label, "label"),
artwork: normalizeProviderArtwork(p.artwork),
artwork: persistableProviderArtwork(p.artwork),
baseUrl,
models,
modelMetadata,
Expand Down
65 changes: 64 additions & 1 deletion main/services/provider-artwork-core.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
import assert from "node:assert/strict";
import test from "node:test";
import { decodeProviderArtworkSource } from "./provider-artwork-core.js";
import {
decodeProviderArtworkSource,
persistStoredProviderArtwork,
} from "./provider-artwork-core.js";
import { normalizeProviderArtwork } from "../../renderer/shared/provider-artwork.js";

const VALID_PNG =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=";

test("provider artwork accepts PNG and inert SVG sources", () => {
assert.equal(
Expand Down Expand Up @@ -51,3 +58,59 @@ test("provider artwork rejects malformed base64 and oversized PNG dimensions bef
/dimensions/u,
);
});

test("already-normalized artwork persists without another decode", () => {
let reencodeCalls = 0;
const artwork = persistStoredProviderArtwork(
{ mimeType: "image/png", dataBase64: VALID_PNG },
() => {
reencodeCalls += 1;
throw new Error("should not re-encode valid artwork");
},
);
assert.equal(reencodeCalls, 0);
assert.deepEqual(
artwork,
normalizeProviderArtwork({ mimeType: "image/png", dataBase64: VALID_PNG }),
);
});

test("invalid artwork is dropped, and oversized PNG bytes are re-encoded", () => {
assert.equal(persistStoredProviderArtwork(undefined, () => {
throw new Error("unused");
}), undefined);
assert.equal(
persistStoredProviderArtwork({ mimeType: "image/svg+xml" }, () => {
throw new Error("unused");
}),
undefined,
);

const oversizedPng = Buffer.alloc(24);
Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]).copy(oversizedPng);
Buffer.from("IHDR", "ascii").copy(oversizedPng, 12);
oversizedPng.writeUInt32BE(65, 16);
oversizedPng.writeUInt32BE(65, 20);
const oversizedBase64 = oversizedPng.toString("base64");
assert.equal(
normalizeProviderArtwork({ mimeType: "image/png", dataBase64: oversizedBase64 }),
undefined,
);

const recovered = persistStoredProviderArtwork(
{ mimeType: "image/png", dataBase64: oversizedBase64 },
(input) => {
assert.equal(input.name, "icon.png");
assert.equal(input.dataBase64, oversizedBase64);
return { mimeType: "image/png", dataBase64: VALID_PNG };
},
);
assert.deepEqual(recovered, { mimeType: "image/png", dataBase64: VALID_PNG });

assert.equal(
persistStoredProviderArtwork({ mimeType: "image/png", dataBase64: "not-png" }, () => {
throw new Error("decode failed");
}),
undefined,
);
});
22 changes: 22 additions & 0 deletions main/services/provider-artwork-core.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,27 @@
import {
normalizeProviderArtwork,
type ProviderArtwork,
} from "../../renderer/shared/provider-artwork.js";

export const PROVIDER_ARTWORK_MAX_SOURCE_BYTES = 512 * 1024;

/** Keep artwork that already matches the display contract, or re-encode PNG bytes. */
export function persistStoredProviderArtwork(
value: unknown,
reencode: (input: { name: string; dataBase64: string }) => ProviderArtwork,
): ProviderArtwork | undefined {
const validated = normalizeProviderArtwork(value);
if (validated) return validated;
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
const dataBase64 = (value as { dataBase64?: unknown }).dataBase64;
if (typeof dataBase64 !== "string" || dataBase64.length === 0) return undefined;
try {
return reencode({ name: "icon.png", dataBase64 });
} catch {
return undefined;
}
}

export function decodeProviderArtworkSource(value: unknown): {
bytes: Buffer;
kind: "png" | "svg";
Expand Down
26 changes: 26 additions & 0 deletions main/services/provider-artwork.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";

test("normalized provider icons are encoded at 1x so the display validator keeps them", () => {
const source = readFileSync(new URL("./provider-artwork.ts", import.meta.url), "utf8");
assert.match(source, /const PIXEL_SCALE = 1 as const/u);
assert.match(source, /nativeImage\.createFromBuffer\(source\.bytes, \{ scaleFactor: PIXEL_SCALE \}\)/u);
assert.match(
source,
/nativeImage\.createFromDataURL\(\s*`data:image\/svg\+xml;base64,\$\{Buffer\.from\(source\.safeSvg!, "utf8"\)\.toString\("base64"\)\}`,?\s*\)/u,
);
assert.match(source, /image\.getSize\(PIXEL_SCALE\)/u);
assert.match(source, /normalized\.toPNG\(\{ scaleFactor: PIXEL_SCALE \}\)/u);
assert.match(source, /if \(!normalizeProviderArtwork\(artwork\)\)/u);
assert.match(
source,
/persistStoredProviderArtwork\(value, \(input\) => normalizeProviderArtworkInput\(input\)\)/u,
);
});

test("provider saves persist recovered artwork instead of dropping oversize icons", () => {
const source = readFileSync(new URL("../handlers/providers.ts", import.meta.url), "utf8");
assert.match(source, /artwork: persistableProviderArtwork\(p\.artwork\)/u);
assert.doesNotMatch(source, /artwork:\s*normalizeProviderArtwork\(p\.artwork\)/u);
});
25 changes: 20 additions & 5 deletions main/services/provider-artwork.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
import { nativeImage } from "../platform.js";
import {
PROVIDER_ARTWORK_MAX_PNG_BYTES,
normalizeProviderArtwork,
type ProviderArtwork,
} from "../../renderer/shared/provider-artwork.js";
import { decodeProviderArtworkSource } from "./provider-artwork-core.js";
import {
decodeProviderArtworkSource,
persistStoredProviderArtwork,
} from "./provider-artwork-core.js";

const TARGET_EDGE = 64;
const PIXEL_SCALE = 1 as const;

export function normalizeProviderArtworkInput(value: unknown): ProviderArtwork {
const source = decodeProviderArtworkSource(value);
Expand All @@ -15,12 +21,12 @@ export function normalizeProviderArtworkInput(value: unknown): ProviderArtwork {
throw new Error("Provider artwork dimensions are invalid.");
}
const image = source.kind === "png"
? nativeImage.createFromBuffer(source.bytes, { scaleFactor: 1 })
? nativeImage.createFromBuffer(source.bytes, { scaleFactor: PIXEL_SCALE })
: nativeImage.createFromDataURL(
`data:image/svg+xml;base64,${Buffer.from(source.safeSvg!, "utf8").toString("base64")}`,
);
if (image.isEmpty()) throw new Error("Aiden could not decode that provider icon.");
const size = image.getSize();
const size = image.getSize(PIXEL_SCALE);
if (size.width <= 0 || size.height <= 0 || size.width > 8_192 || size.height > 8_192) {
throw new Error("Provider artwork dimensions are invalid.");
}
Expand All @@ -32,9 +38,18 @@ export function normalizeProviderArtworkInput(value: unknown): ProviderArtwork {
quality: "best",
})
: image;
const png = normalized.toPNG();
const png = normalized.toPNG({ scaleFactor: PIXEL_SCALE });
if (png.length === 0 || png.length > PROVIDER_ARTWORK_MAX_PNG_BYTES) {
throw new Error("The normalized provider icon is too complex. Choose a simpler image.");
}
return { mimeType: "image/png", dataBase64: png.toString("base64") };
const artwork = { mimeType: "image/png" as const, dataBase64: png.toString("base64") };
if (!normalizeProviderArtwork(artwork)) {
throw new Error("The normalized provider icon is too complex. Choose a simpler image.");
}
return artwork;
}

/** Persist only artwork that already matches the display contract, or re-encode it. */
export function persistableProviderArtwork(value: unknown): ProviderArtwork | undefined {
return persistStoredProviderArtwork(value, (input) => normalizeProviderArtworkInput(input));
}
Loading
Loading