Skip to content
Merged
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: 8 additions & 0 deletions .papercuts/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -379,3 +379,11 @@ owns; reopen the terminal before judging the final live state.

- Zsh does not split scalar loop values by default; use explicit delimiters in pairwise merge probes so branch names are not accidentally concatenated.
- Standalone green PRs still conflicted in shared settings, test registries, and UI fixtures. Assemble the exact combined stack and retain every feature's test registration before merging to main.

## 2026-09-10 — PR #102 readiness

- The initial source-scanning theory incorrectly credited explicit 1x encode arguments that are already Electron's defaults. Exercise the actual fix with a valid oversized PNG through `providers:save`, relaunch, and verify the recovered, decodable 64px-or-smaller result.
- Treat user-supplied provider PNGs as original-color artwork; an alpha mask turns fully opaque icons into solid squares and disagrees with native clients.
- Model Pad animation settling must ignore infinite animations and retain a bounded timeout so hosted Electron runs cannot wait forever.
- The cold hosted responsive matrix can reach its last 390px case only as the shared 90-second test budget expires, while a warm retry passes in 24 seconds. Give this exhaustive case an explicit bounded 180-second budget without relaxing geometry assertions.
- On hosted Electron, Playwright `fill("")` can leave a controlled search unchanged; use the native value setter plus a bubbling input event for deterministic test cleanup.
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
22 changes: 22 additions & 0 deletions main/services/provider-artwork.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";

test("normalized provider icons are validated and oversize stored artwork can be recovered", () => {
const source = readFileSync(new URL("./provider-artwork.ts", import.meta.url), "utf8");
assert.match(
source,
/nativeImage\.createFromDataURL\(\s*`data:image\/svg\+xml;base64,\$\{Buffer\.from\(source\.safeSvg!, "utf8"\)\.toString\("base64"\)\}`,?\s*\)/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);
});
18 changes: 16 additions & 2 deletions main/services/provider-artwork.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
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;

export function normalizeProviderArtworkInput(value: unknown): ProviderArtwork {
Expand Down Expand Up @@ -36,5 +41,14 @@ export function normalizeProviderArtworkInput(value: unknown): ProviderArtwork {
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