Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
106 commits
Select commit Hold shift + click to select a range
94d1a8e
fix(server): restore OpenCode adapter test typecheck (#17810)
Yash-Singh1 Oct 10, 2026
98beed1
fix(server): Claude subagents show the reasoning effort they run at (…
RakshithBhat03 Oct 10, 2026
8c777fb
feat(server): stop Claude subagents without stopping their owner (#17…
Yash-Singh1 Oct 10, 2026
afd98d7
fix(server): stopped native subagents no longer read as Running (#17223)
im-kvijay Oct 10, 2026
a11f464
perf(server): t3_thread_list reads only the listed project's threads …
only21mil Oct 10, 2026
cfbd145
fix(server): show diffs for projects outside the server cwd (#17724)
maria-rcks Oct 10, 2026
50647de
fix(server): check the specific scope for scripts, preview input, and…
juliusmarminge Oct 10, 2026
dacd2cb
fix(source-control): stop Forgejo status refresh from scanning every …
loispostula Oct 10, 2026
fd3549c
refactor(contracts): source control provider kind is an open branded …
juliusmarminge Oct 10, 2026
7bc7328
feat(source-control): each host package ships a client definition (#1…
juliusmarminge Oct 10, 2026
c8a6033
refactor(client-runtime): add project clone sources come from host de…
juliusmarminge Oct 10, 2026
579159d
refactor(web): host presentation and behavior come from client defini…
juliusmarminge Oct 10, 2026
b0249ac
refactor(source-control): reference parsing and project matching are …
juliusmarminge Oct 10, 2026
9b40d37
feat(pull-requests): quick actions follow each host's capabilities, n…
juliusmarminge Oct 10, 2026
a4c8745
feat(projects): new projects can be published to any ready host (#17860)
juliusmarminge Oct 10, 2026
f82796a
fix(server): send Claude MCP servers over the control channel (#17898)
juliusmarminge Oct 10, 2026
ec43786
fix(web): a finished reply replaced by a steer is no longer labeled p…
juliusmarminge Oct 10, 2026
61eb4af
fix(client-runtime): queued runs that start after a steer show up in …
juliusmarminge Oct 10, 2026
2a4ee33
feat(mobile): choose the microphone order for voice input (#17896)
juliusmarminge Oct 10, 2026
4f63275
feat(source-control): host settings live on each host's definition, w…
juliusmarminge Oct 10, 2026
38c187b
fix(pi): preserve tool images and structured results (#17851)
StiensWout Oct 10, 2026
a8bdfdb
fix(server): Claude 5 task lists reach the tasks drawer (#14964)
Mnigos Oct 10, 2026
f92afe7
fix(web): find bar and thread details panel stop covering each other …
MatthewFeroz Oct 10, 2026
0442f5b
fix(web): use server metadata for file chip icons (#17923)
Yash-Singh1 Oct 10, 2026
c8b89a0
fix(desktop): copy images from HTML previews (#17555)
Bil0000 Oct 10, 2026
e408cac
docs(pi): update installation and remote login guidance (#17836)
StiensWout Oct 10, 2026
7e8071f
fix(pi): preserve native abort outcomes (#17853)
StiensWout Oct 10, 2026
160b18c
fix(pi): keep thinking defaults specific to each model (#17835)
StiensWout Oct 10, 2026
62da1cd
fix(pi): preserve shell command exit codes (#17834)
StiensWout Oct 10, 2026
6e9c5a8
fix(pi): expire and cancel extension approvals (#17840)
StiensWout Oct 10, 2026
9afde1c
feat(pi): include native sessions in usage reports (#17848)
StiensWout Oct 10, 2026
818e2be
fix(server): route Copilot ACP subagent output into subagent threads …
maria-rcks Oct 10, 2026
74dfbb6
fix(web): composer banner titles truncate beside their icon instead o…
maria-rcks Oct 10, 2026
953edee
fix(server): Muse turns no longer fail on Windows (#17163)
ntindle Oct 10, 2026
5f7294d
fix(pi): allow known read-only T3 tools without approval (#17852)
StiensWout Oct 10, 2026
4772822
fix: worktree threads keep their worktree when the agent starts, and …
maria-rcks Oct 11, 2026
e4ef8d3
fix(server): keep Claude workflows alive while they report progress (…
maria-rcks Oct 11, 2026
0ecb25a
fix(web): media preview centers its content and pins the close button…
maria-rcks Oct 11, 2026
c73b454
fix(server): threads without a project no longer need Git installed (…
t3dotgg Oct 11, 2026
1cfd1c1
fix(web): toggling tools and thinking at the bottom keeps you at the …
t3dotgg Oct 11, 2026
c727d12
fix(web): Compact chip follows Claude's real prompt cache TTL (#17945)
t3dotgg Oct 11, 2026
75c439f
fix(usage): bound OpenCode history reads to prevent backend OOM (#17961)
Yash-Singh1 Oct 11, 2026
0f36a11
refactor: format diff line counts through one shared helper (#17948)
maria-rcks Oct 11, 2026
72fbad7
fix: new projects start their first thread in the project folder, not…
t3dotgg Oct 11, 2026
cf8479e
perf(server): drive shell last-error lookup from thread bindings (#17…
only21mil Oct 11, 2026
7f0b789
perf(server): skip parsing plain terminal history output (#17179)
StiensWout Oct 11, 2026
1c7b267
perf(server): batch terminal history writes every 250 ms (#17183)
StiensWout Oct 11, 2026
4258c8f
perf(server): storage cleanup no longer runs a full status or size wa…
tris203 Oct 11, 2026
febe1b2
perf(usage): usage scans skip OpenCode legacy messages older than the…
tris203 Oct 11, 2026
9ec472c
perf(usage): OpenCode legacy walk stats entries before checking for s…
tris203 Oct 11, 2026
29e09df
fix(web): MCP app permission prompts no longer overflow the screen (#…
juliusmarminge Oct 11, 2026
716357c
feat(provider-antigravity): support subscription quota and usage limi…
Maseeek Oct 11, 2026
43888b5
fix(desktop): keep generated annotation styles in sync (#17838)
t3-code[bot] Oct 11, 2026
a70ee2a
fix(web): Resume continues on the model picked in the composer (#17867)
akbarakma Oct 11, 2026
3410e43
fix(devices): bump agent-device to 0.21.24 for native Windows hosts (…
TanJeeSchuan Oct 11, 2026
c448e63
fix(web): keybinding group headings no longer touch the group above (…
fixfon Oct 11, 2026
e7db61f
feat(web): configure OpenTelemetry exports in diagnostics (#12538)
MatthewFeroz Oct 11, 2026
08f9b56
docs: update user count to 500k (#17783)
Kamkmgamer Oct 11, 2026
a2f4c7f
fix(mobile): working pill no longer bulges on its right end (#16143)
id0Sch Oct 11, 2026
b467be4
perf(opencode): stop retaining every message of a loaded OpenCode thr…
tris203 Oct 11, 2026
e61eba4
fix(web): switch thumbs stay in their track while scrolling (#15240)
raphaelpra Oct 11, 2026
77c8747
fix(keybindings): thread jumps no longer overlap model picker jumps (…
Fluffy-Bunny-23 Oct 11, 2026
d404a30
fix(mobile): restore QR scanning in iPad pairing sheet (#16019)
arhammahajan Oct 11, 2026
a4a1a80
fix(server): delegated Muse tasks no longer ask approval for every co…
t3dotgg Oct 11, 2026
0eef5d0
fix(codex): prevent unsupported agent history in ChatGPT sharing (#17…
connwalk Oct 11, 2026
4baba3b
fix(server): disabled providers stop checking for CLI updates (#16772)
yordis Oct 11, 2026
758d54a
fix(mobile): round Android queued message sheet corners (#14934)
PixPMusic Oct 11, 2026
584af5a
fix(ssh): record the archive lock owner's real PID (#14598)
Gigioxx Oct 11, 2026
bfbdfed
fix(desktop): keep annotation comment direction independent of host p…
abdelrhmanehab10 Oct 11, 2026
5ce5a8f
fix(ssh): qualify runner script builders in tunnel test (#18078)
juliusmarminge Oct 11, 2026
6c13016
fix(web): keep thread links open until the shell is live (#14697)
saphid Oct 11, 2026
75fca7d
fix(web): scope PR title collapse to tab scrollers (#14644)
Adamulek123 Oct 11, 2026
6cf0526
fix(server): detect fork PRs for branches without upstreams (#13894)
Adamulek123 Oct 11, 2026
a908a7a
fix(search): find threads by their branch PR number (#14658)
tris203 Oct 11, 2026
b9afba5
feat(web): show Claude workflow phases and members in Lineage (#12598)
Bil0000 Oct 11, 2026
2e194f8
fix(mobile): round Android Agents sheet corners (#14925)
PixPMusic Oct 11, 2026
b4542c5
fix(web): the Usage page sends a signed-out browser to pairing (#17938)
AdEx-Partners-DE Oct 11, 2026
cb7d073
fix(server): OpenCode 2 continuations end after a reconnect took a St…
juliusmarminge Oct 11, 2026
ae5fd18
fix(server): OpenCode 2 continuations replay only their own backgroun…
juliusmarminge Oct 11, 2026
f0a47b3
fix(server): ACP reapplies a model after a switch away from it failed…
juliusmarminge Oct 11, 2026
f769e01
fix(web): focus settings search with command-f (#17859)
extoci Oct 11, 2026
5047ee7
fix(mobile): keep Android project paths on one line (#14178)
wellorbetter Oct 11, 2026
0cb3840
fix(web): unpin preview dragging on narrow chat canvases (#15556)
MatthewFeroz Oct 11, 2026
8f43ced
fix(web): thread details toggle no longer covers the thread title on …
freddy-d Oct 11, 2026
8dfee38
fix(server): a directory no longer resolves as the resource monitor b…
Furox-Art Oct 11, 2026
119e61b
fix(server): a Claude usage limit no longer resets the context meter …
Vantrongs Oct 11, 2026
bf725cb
fix(server): GitLab merge requests can expand unchanged lines (#17528)
ScottN-PV Oct 11, 2026
6fa0dd8
fix(provider-cursor): a skill tree deeper than the scan limit no long…
ScottN-PV Oct 11, 2026
5310a4a
fix(web): pass hex theme colors to HTML renders (#16325)
RustedAperture Oct 11, 2026
7808950
fix(web): align the API estimate info icon (#15655)
RakshithBhat03 Oct 11, 2026
cf82199
test(server): ACP teardown tests pass on Windows hosts (#17080)
sheehanmunim Oct 11, 2026
bb9b9aa
fix(server): sync device tool license versions with installer pins (#…
Yash-Singh1 Oct 11, 2026
f845895
fix: sync model favorites and visibility across clients (#16816)
jakeleventhal Oct 11, 2026
4b6d9a0
chore(deps-dev): bump compression from 1.8.1 to 1.8.2 in the npm_and_…
dependabot[bot] Oct 11, 2026
5584d21
fix(server): update fff to stop runaway watcher rescans (#14543)
realhasanshoaib Oct 11, 2026
2025430
fix(mobile): license generation works with filtered installs (#18127)
Yash-Singh1 Oct 11, 2026
8d199b4
fix(mobile): image-only messages ask the agent to respond, like deskt…
whoisaldo Oct 11, 2026
1f6df6b
feat(pull-requests): guided reviews, a beta tour of a pull request (#…
juliusmarminge Oct 11, 2026
18656a3
feat(mcp): agents can write and read pull request review guides (#18143)
juliusmarminge Oct 11, 2026
ed0f7b9
fix(server): Sol Ultrafast stops vanishing from the Codex model picke…
t3dotgg Oct 11, 2026
2625327
fix(usage): say resets momentarily in the final minute (#17869)
extoci Oct 11, 2026
c09c235
fix(server): preserve ambient Cursor MCP servers (#15875)
aravhawk Oct 11, 2026
243c999
fix(server): skip Windows cleanup for exited child processes (#16007)
Alb11747 Oct 11, 2026
8ac6846
feat(settings): providers can be turned on or off per project (#17879)
yordis Oct 11, 2026
f23d008
perf(github): resolve credentials once per GraphQL request (#18289)
juliusmarminge Oct 11, 2026
c706b29
Merge upstream/main into the fork
jamesainslie Oct 11, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ You can think of T3 Code as an open source "bring-your-own-subscription" alterna

## What makes T3 Code special?

We have over 400,000 users who love T3 Code. It's important we maintain the things they love as we continue to iterate on the product. Here's a brief list of the things we can never compromise on.
We have over 500,000 users who love T3 Code. It's important we maintain the things they love as we continue to iterate on the product. Here's a brief list of the things we can never compromise on.

### 1. Open at the core

Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/preview/AnnotationStyles.generated.ts

Large diffs are not rendered by default.

21 changes: 21 additions & 0 deletions apps/desktop/src/preview/FaviconCapture.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
MAX_FAVICON_RESPONSE_BYTES,
captureFavicon,
selectFaviconCandidates,
sourceDimensions,
} from "./FaviconCapture.ts";

const PNG = "data:image/png;base64,cG5n";
Expand Down Expand Up @@ -47,6 +48,26 @@ function sourceGif(
return buffer;
}

it.each([
["logical pixels", sourceGif(2048, 1024), { width: 2048, height: 1024 }],
[
"cumulative frame pixels",
sourceGif(1024, 1024, 1024, 1024, [{ width: 1024, height: 1024 }]),
{ width: 1024, height: 1024 },
],
])("uses the caller's GIF limit for %s while keeping the favicon limit", (_, gif, dimensions) => {
expect(sourceDimensions(gif)).toBeNull();
expect(sourceDimensions(gif, 8_388_608)).toEqual(dimensions);
});

it("rejects GIF frames exceeding the caller's cumulative pixel limit", () => {
const gif = sourceGif(2048, 2048, 2048, 2048, [
{ width: 2048, height: 2048 },
{ width: 2048, height: 2048 },
]);
expect(sourceDimensions(gif, 8_388_608)).toBeNull();
});

function sourceJpeg(
width: number,
height: number,
Expand Down
30 changes: 18 additions & 12 deletions apps/desktop/src/preview/FaviconCapture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -246,14 +246,17 @@ interface ImageDimensions {
readonly height: number;
}

function safeDimensions(dimensions: ImageDimensions | null): dimensions is ImageDimensions {
function safeDimensions(
dimensions: ImageDimensions | null,
maxPixels = MAX_FAVICON_SOURCE_PIXELS,
): dimensions is ImageDimensions {
return (
dimensions !== null &&
Number.isSafeInteger(dimensions.width) &&
Number.isSafeInteger(dimensions.height) &&
dimensions.width > 0 &&
dimensions.height > 0 &&
dimensions.width * dimensions.height <= MAX_FAVICON_SOURCE_PIXELS
dimensions.width * dimensions.height <= maxPixels
);
}

Expand All @@ -276,13 +279,13 @@ function skipGifSubBlocks(buffer: Buffer, startOffset: number): number | null {
return null;
}

function gifDimensions(buffer: Buffer): ImageDimensions | null {
function gifDimensions(buffer: Buffer, maxPixels: number): ImageDimensions | null {
if (buffer.byteLength < 13 || !/^GIF8[79]a$/u.test(buffer.subarray(0, 6).toString("ascii"))) {
return null;
}
const logicalWidth = buffer.readUInt16LE(6);
const logicalHeight = buffer.readUInt16LE(8);
if (!safeDimensions({ width: logicalWidth, height: logicalHeight })) return null;
if (!safeDimensions({ width: logicalWidth, height: logicalHeight }, maxPixels)) return null;
const packed = buffer[10]!;
let offset = 13 + ((packed & 0x80) === 0 ? 0 : 3 * 2 ** ((packed & 0x07) + 1));
if (offset > buffer.byteLength) return null;
Expand All @@ -301,10 +304,10 @@ function gifDimensions(buffer: Buffer): ImageDimensions | null {
const frameHeight = buffer.readUInt16LE(offset + 7);
if (frameWidth === 0 || frameHeight === 0) return null;
framePixels += frameWidth * frameHeight;
if (framePixels > MAX_FAVICON_SOURCE_PIXELS) return null;
if (framePixels > maxPixels) return null;
width = Math.max(width, left + frameWidth);
height = Math.max(height, top + frameHeight);
if (!safeDimensions({ width, height })) return null;
if (!safeDimensions({ width, height }, maxPixels)) return null;
const framePacked = buffer[offset + 9]!;
offset += 10;
if ((framePacked & 0x80) !== 0) {
Expand Down Expand Up @@ -491,7 +494,7 @@ function dibDimensions(buffer: Buffer): ImageDimensions | null {
};
}

function icoDimensions(buffer: Buffer): ImageDimensions | null {
function icoDimensions(buffer: Buffer, maxPixels: number): ImageDimensions | null {
if (
buffer.byteLength < 22 ||
buffer.readUInt16LE(0) !== 0 ||
Expand All @@ -507,7 +510,7 @@ function icoDimensions(buffer: Buffer): ImageDimensions | null {
const offset = 6 + index * 16;
width = Math.max(width, buffer[offset] === 0 ? 256 : buffer[offset]!);
height = Math.max(height, buffer[offset + 1] === 0 ? 256 : buffer[offset + 1]!);
if (!safeDimensions({ width, height })) return null;
if (!safeDimensions({ width, height }, maxPixels)) return null;
const byteLength = buffer.readUInt32LE(offset + 8);
const imageOffset = buffer.readUInt32LE(offset + 12);
if (
Expand All @@ -519,18 +522,21 @@ function icoDimensions(buffer: Buffer): ImageDimensions | null {
return null;
const embedded = buffer.subarray(imageOffset, imageOffset + byteLength);
const embeddedDimensions = pngDimensions(embedded) ?? dibDimensions(embedded);
if (!safeDimensions(embeddedDimensions)) return null;
if (!safeDimensions(embeddedDimensions, maxPixels)) return null;
}
return { width, height };
}

function sourceDimensions(buffer: Buffer): ImageDimensions | null {
export function sourceDimensions(
buffer: Buffer,
maxPixels = MAX_FAVICON_SOURCE_PIXELS,
): ImageDimensions | null {
return (
pngDimensions(buffer) ??
gifDimensions(buffer) ??
gifDimensions(buffer, maxPixels) ??
jpegDimensions(buffer) ??
webpDimensions(buffer) ??
icoDimensions(buffer)
icoDimensions(buffer, maxPixels)
);
}

Expand Down
3 changes: 3 additions & 0 deletions apps/desktop/src/preview/PickPreload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -554,6 +554,7 @@ function startAnnotation(sendEnabled: boolean): void {
let finished = false;
const host = document.createElement("div");
host.setAttribute(OVERLAY_ATTRIBUTE, "");
host.setAttribute("dir", "ltr");
host.style.cssText = `position:fixed;inset:0;z-index:${Z_INDEX_OVERLAY};pointer-events:none`;
applyAnnotationTheme(host, annotationTheme);
const shadowRoot = host.attachShadow({ mode: "closed" });
Expand All @@ -563,6 +564,7 @@ function startAnnotation(sendEnabled: boolean): void {

const root = document.createElement("div");
root.setAttribute(OVERLAY_ATTRIBUTE, "");
root.setAttribute("dir", "ltr");
root.className = "fixed inset-0 font-sans text-foreground";
root.style.cssText = "pointer-events:none";
const cursorStyle = document.createElement("style");
Expand Down Expand Up @@ -612,6 +614,7 @@ function startAnnotation(sendEnabled: boolean): void {

const comment = document.createElement("textarea");
comment.placeholder = "Describe the change…";
comment.setAttribute("dir", "auto");
comment.rows = 1;
comment.className =
"min-h-8 max-h-24 min-w-0 flex-1 resize-none overflow-y-hidden border-0 border-b border-b-transparent bg-transparent px-0 py-1.5 font-sans text-sm leading-5 text-foreground outline-none ring-0 placeholder:text-muted-foreground focus:border-b-primary focus:outline-none focus:ring-0";
Expand Down
204 changes: 204 additions & 0 deletions apps/desktop/src/window/ContextMenuImage.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
import { beforeEach, expect, it, vi } from "vite-plus/test";
import type * as Electron from "electron";
import { copyContextMenuImage } from "./ContextMenuImage.ts";

const native = vi.hoisted(() => ({
fetch: vi.fn(),
createFromBuffer: vi.fn(),
write: vi.fn(),
image: { isEmpty: vi.fn(() => false), toPNG: vi.fn(() => Buffer.from("png")) },
}));

vi.mock("electron", () => ({
net: { fetch: native.fetch },
nativeImage: { createFromBuffer: native.createFromBuffer },
clipboard: { write: native.write },
ClipboardItem: class {
readonly blobs: Record<string, Blob>;
constructor(blobs: Record<string, Blob>) {
this.blobs = blobs;
}
},
}));

const mainFrame = { isDestroyed: vi.fn(() => false) };
const frame = { isDestroyed: vi.fn(() => false) };
const contents = {
isDestroyed: vi.fn(() => false),
copyImageAt: vi.fn(),
executeJavaScriptInIsolatedWorld: vi.fn(),
mainFrame,
};
const params = {
x: 12,
y: 34,
srcURL: "https://example.com/image.png",
frame,
};
const sourcePng = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAFklEQVR4nGP4TyFgGDVg1IBRA4aLAQBdePwur/3haQAAAABJRU5ErkJggg==",
"base64",
);

const copyImage = (overrides: Partial<typeof params> = {}) =>
copyContextMenuImage(
contents as unknown as Electron.WebContents,
{ ...params, ...overrides } as unknown as Electron.ContextMenuParams,
);

beforeEach(() => {
vi.clearAllMocks();
contents.isDestroyed.mockReturnValue(false);
frame.isDestroyed.mockReturnValue(false);
native.image.isEmpty.mockReturnValue(false);
native.createFromBuffer.mockReturnValue(native.image);
native.fetch.mockResolvedValue(new Response(Uint8Array.from(sourcePng)));
native.write.mockResolvedValue(undefined);
contents.executeJavaScriptInIsolatedWorld.mockRejectedValue(
new Error("Image could not be decoded"),
);
});

it("preserves the clipboard when the context-menu frame is missing", async () => {
await copyContextMenuImage(
contents as unknown as Electron.WebContents,
{ ...params, frame: null } as unknown as Electron.ContextMenuParams,
);
expect(contents.copyImageAt).not.toHaveBeenCalled();
expect(native.fetch).not.toHaveBeenCalled();
expect(native.write).not.toHaveBeenCalled();
});

it("keeps native image copying for the main frame", async () => {
await copyImage({ frame: mainFrame });
expect(contents.copyImageAt).toHaveBeenCalledWith(12, 34);
expect(native.fetch).not.toHaveBeenCalled();
});

it.each(["https://example.com/image.png", `data:image/png;base64,${sourcePng.toString("base64")}`])(
"copies an iframe image from %s",
async (srcURL) => {
await copyImage({ srcURL });
if (srcURL.startsWith("https:")) {
expect(native.fetch).toHaveBeenCalledWith(srcURL, {
credentials: "omit",
redirect: "error",
signal: expect.any(AbortSignal),
});
} else {
expect(native.fetch).not.toHaveBeenCalled();
}
expect(contents.copyImageAt).not.toHaveBeenCalled();
expect(native.createFromBuffer).toHaveBeenCalledWith(sourcePng);
const blob = native.write.mock.calls[0]?.[0][0].blobs["image/png"] as Blob;
expect(blob.type).toBe("image/png");
expect(Buffer.from(await blob.arrayBuffer())).toEqual(Buffer.from("png"));
},
);

it.each([
["WebP", "UklGRiQAAABXRUJQVlA4IBgAAAAwAQCdASoBAAEAAUAmJaQAA3AA/vz0AAA="],
["GIF", "R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAkQBADs="],
])("copies %s using Chromium when nativeImage cannot decode it", async (_, base64) => {
native.fetch.mockResolvedValue(new Response(Uint8Array.from(Buffer.from(base64, "base64"))));
native.image.isEmpty.mockReturnValue(true);
contents.executeJavaScriptInIsolatedWorld.mockResolvedValue(Uint8Array.from(sourcePng).buffer);
await copyImage();
const blob = native.write.mock.calls[0]?.[0][0].blobs["image/png"] as Blob;
expect(Buffer.from(await blob.arrayBuffer())).toEqual(sourcePng);
expect(native.image.toPNG).not.toHaveBeenCalled();
});

it("preserves the clipboard if the source frame closes during Chromium decoding", async () => {
native.image.isEmpty.mockReturnValue(true);
contents.executeJavaScriptInIsolatedWorld.mockImplementation(() => {
frame.isDestroyed.mockReturnValue(true);
return Promise.resolve(Uint8Array.from(sourcePng).buffer);
});
await copyImage();
expect(native.write).not.toHaveBeenCalled();
});

it("copies a GIF larger than the favicon pixel limit", async () => {
const gif = Buffer.from("R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAkQBADs=", "base64");
gif.writeUInt16LE(2048, 6);
gif.writeUInt16LE(1024, 8);
native.fetch.mockResolvedValue(new Response(Uint8Array.from(gif)));
native.image.isEmpty.mockReturnValue(true);
contents.executeJavaScriptInIsolatedWorld.mockResolvedValue(Uint8Array.from(sourcePng).buffer);
await copyImage();
const blob = native.write.mock.calls[0]?.[0][0].blobs["image/png"] as Blob;
expect(Buffer.from(await blob.arrayBuffer())).toEqual(sourcePng);
});

it.each([
"file:///private/image.png",
"blob:https://example.com/image",
"https://user:pass@example.com/image.png",
])("rejects unsupported or credential-bearing URLs: %s", async (srcURL) => {
await expect(copyImage({ srcURL })).rejects.toThrow("Unsupported image URL");
expect(native.fetch).not.toHaveBeenCalled();
expect(native.write).not.toHaveBeenCalled();
});

it("cancels responses whose declared size exceeds the limit", async () => {
const cancel = vi.fn();
native.fetch.mockResolvedValue(
new Response(new ReadableStream({ cancel }), {
headers: { "content-length": String(32 * 1024 * 1024 + 1) },
}),
);
await expect(copyImage()).rejects.toThrow("size limit");
expect(cancel).toHaveBeenCalled();
expect(native.createFromBuffer).not.toHaveBeenCalled();
});

it("cancels oversized streams without a content-length", async () => {
const cancel = vi.fn();
native.fetch.mockResolvedValue(
new Response(
new ReadableStream({
start(controller) {
controller.enqueue(new Uint8Array(32 * 1024 * 1024));
controller.enqueue(new Uint8Array(1));
},
cancel,
}),
),
);
await expect(copyImage()).rejects.toThrow("size limit");
expect(cancel).toHaveBeenCalled();
expect(native.write).not.toHaveBeenCalled();
});

it("preserves the clipboard when the response is not an image", async () => {
native.image.isEmpty.mockReturnValue(true);
await expect(copyImage()).rejects.toThrow("could not be decoded");
expect(native.write).not.toHaveBeenCalled();
});

it("rejects large decoded dimensions before image allocation", async () => {
const oversizedPng = Buffer.from(sourcePng);
oversizedPng.writeUInt32BE(4096, 16);
oversizedPng.writeUInt32BE(4096, 20);
native.fetch.mockResolvedValue(new Response(Uint8Array.from(oversizedPng)));
await expect(copyImage()).rejects.toThrow("size limit");
expect(native.createFromBuffer).not.toHaveBeenCalled();
expect(native.image.toPNG).not.toHaveBeenCalled();
expect(native.write).not.toHaveBeenCalled();
});

it("preserves the clipboard when the encoded PNG exceeds the limit", async () => {
native.image.toPNG.mockReturnValueOnce(Buffer.alloc(32 * 1024 * 1024 + 1));
await expect(copyImage()).rejects.toThrow("size limit");
expect(native.write).not.toHaveBeenCalled();
});

it("does not copy after the source frame closes during the request", async () => {
native.fetch.mockImplementation(() => {
frame.isDestroyed.mockReturnValue(true);
return Promise.resolve(new Response(Uint8Array.from(sourcePng)));
});
await copyImage();
expect(native.write).not.toHaveBeenCalled();
});
Loading
Loading