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
Binary file removed .pnpm-store/v11/index.db
Binary file not shown.
40 changes: 39 additions & 1 deletion apps/desktop/src/preview/BrowserSession.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ const { fromPartition, sessions } = vi.hoisted(() => ({
{
readonly clearCache: ReturnType<typeof vi.fn>;
readonly clearStorageData: ReturnType<typeof vi.fn>;
readonly getUserAgent: ReturnType<typeof vi.fn>;
readonly getUserAgent: ReturnType<typeof vi.fn<() => string>>;
readonly setPermissionRequestHandler: ReturnType<typeof vi.fn>;
readonly setPermissionCheckHandler: ReturnType<typeof vi.fn>;
readonly setUserAgent: ReturnType<typeof vi.fn>;
Expand Down Expand Up @@ -102,6 +102,44 @@ describe("BrowserSession", () => {
}).pipe(Effect.provide(layer)),
);

// A rewritten session UA — any variant, even ones that keep the Electron
// token — makes Cloudflare Turnstile loop with error 600010 (#5002), so
// the guest must end up with Electron's native User-Agent. The mock applies
// setUserAgent calls, so this fails on any reintroduced rewrite while still
// permitting a harmless re-set of the unchanged native string.
it.effect("keeps the guest's effective User-Agent equal to Electron's native one", () =>
Effect.gen(function* () {
// Electron's real UA shape: app token, then Chrome, then Electron, then
// Safari — the token order and casing matter to any strip regex.
const nativeUserAgent =
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) T3Code(Alpha)/0.0.33 Chrome/146.0.7680.216 Electron/41.5.0 Safari/537.36";
fromPartition.mockReset();
fromPartition.mockImplementation((partition: string) => {
let userAgent = nativeUserAgent;
const browserSession = {
clearCache: vi.fn(() => Promise.resolve()),
clearStorageData: vi.fn(() => Promise.resolve()),
getUserAgent: vi.fn(() => userAgent),
setPermissionRequestHandler: vi.fn(),
setPermissionCheckHandler: vi.fn(),
setUserAgent: vi.fn((next: string) => {
userAgent = next;
}),
};
sessions.set(partition, browserSession);
return browserSession;
});

const browserSessions = yield* BrowserSession.BrowserSession;
const partition = yield* browserSessions.getPartition("scope-a");
yield* browserSessions.getSession("scope-a");

const browserSession = sessions.get(partition);
assert.isDefined(browserSession);
assert.strictEqual(browserSession.getUserAgent(), nativeUserAgent);
}).pipe(Effect.provide(layer)),
);

it.effect("grants clipboard-sanitized-write through both the request and check handlers", () =>
Effect.gen(function* () {
const browserSessions = yield* BrowserSession.BrowserSession;
Expand Down
11 changes: 6 additions & 5 deletions apps/desktop/src/preview/BrowserSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,11 +197,12 @@ export const make = Effect.gen(function* BrowserSessionMake() {
return Effect.try({
try: () => {
const browserSession = session.fromPartition(partition);
const userAgent = browserSession
.getUserAgent()
.replace(/Electron\/[\d.]+ /, "")
.replace(/\s*t3code\/[\d.]+/, "");
browserSession.setUserAgent(userAgent);
// The guest keeps Electron's native User-Agent. Rewriting it in any
// form — even variants that keep the Electron token — makes Cloudflare
// Turnstile fail its integrity check with error 600010 and recreate
// the challenge every few seconds, so logins behind it never complete
// (#5002). Re-setting the unchanged native string is harmless, so it
// is the rewritten string itself that trips the check.
browserSession.setPermissionRequestHandler((_webContents, permission, callback) => {
callback(ALLOWED_PREVIEW_PERMISSIONS.has(permission));
});
Expand Down
3 changes: 2 additions & 1 deletion apps/mobile/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@
"effect": "catalog:",
"expo": "~57.0.18",
"expo-asset": "~57.0.15",
"expo-audio": "~57.0.4",
"expo-audio": "57.0.4",
"expo-auth-session": "~57.0.10",
"expo-blur": "~57.0.2",
"expo-build-properties": "~57.0.15",
Expand All @@ -91,6 +91,7 @@
"expo-glass-effect": "~57.0.1",
"expo-haptics": "~57.0.2",
"expo-image": "~57.0.3",
"expo-image-manipulator": "~57.0.17",
"expo-image-picker": "~57.0.14",
"expo-linking": "~57.0.8",
"expo-network": "~57.0.1",
Expand Down
161 changes: 137 additions & 24 deletions apps/mobile/src/lib/composerFiles.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ const mocks = vi.hoisted(() => ({
open: vi.fn(),
size: vi.fn(),
readBase64: vi.fn(),
manipulate: vi.fn(),
release: vi.fn(),
}));

vi.mock("expo-file-system", () => {
Expand Down Expand Up @@ -80,6 +82,10 @@ vi.mock("expo-file-system", () => {

vi.mock("expo-image-picker", () => ({ launchImageLibraryAsync: mocks.pickMedia }));
vi.mock("expo-document-picker", () => ({ getDocumentAsync: mocks.pickFile }));
vi.mock("expo-image-manipulator", () => ({
SaveFormat: { JPEG: "jpeg", PNG: "png", WEBP: "webp" },
ImageManipulator: { manipulate: mocks.manipulate },
}));
vi.mock("./uuid", () => ({ uuidv4: () => "attachment-id" }));

import {
Expand All @@ -106,20 +112,50 @@ describe("composer file attachments", () => {
});

describe("photo library image conversion", () => {
const jpeg = "/9j/2Q==";
const rendered = { uri: "file:///cache/ImageManipulator/photo.jpg", base64: "/9j/2Q==" };
const photo: ImagePickerAsset = {
uri: "file:///picker/photo.heic",
type: "image",
fileName: "photo.HEIC",
mimeType: "image/heic",
fileSize: 20 * 1024 * 1024,
base64: jpeg,
width: 1,
height: 1,
fileSize: 4 * 1024 * 1024,
width: 4032,
height: 3024,
};
/**
* Stands in for the native manipulator: `size` is what decoding the source yields,
* `resizes` records every requested resize, and `saved` is what saving returns.
*/
const native = {
size: { width: 1, height: 1 },
resizes: [] as Array<{ width?: number | null; height?: number | null }>,
saved: rendered as { uri: string; base64?: string },
};

beforeEach(() => {
native.size = { width: 1, height: 1 };
native.resizes = [];
native.saved = rendered;
mocks.manipulate.mockReset();
mocks.release.mockReset();
mocks.manipulate.mockImplementation(() => {
const context = {
resize(size: { width?: number | null; height?: number | null }) {
native.resizes.push(size);
return context;
},
renderAsync: async () => ({
...native.size,
release: mocks.release,
saveAsync: async () => native.saved,
}),
};
return context;
});
});

it.each(["image/heic", "image/heif", undefined])(
"attaches the native JPEG conversion with matching metadata when the source MIME is %s",
"renders a %s photo to JPEG natively and previews the rendered file",
async (mimeType) => {
mocks.pickMedia.mockResolvedValue({
canceled: false,
Expand All @@ -128,6 +164,9 @@ describe("composer file attachments", () => {

const result = await pickComposerImages({ existingCount: 0 });

expect(mocks.pickMedia).toHaveBeenCalledWith(expect.objectContaining({ base64: false }));
expect(mocks.manipulate).toHaveBeenCalledWith(photo.uri);
expect(mocks.readBase64).not.toHaveBeenCalled();
expect(result).toEqual({
images: [
{
Expand All @@ -136,20 +175,35 @@ describe("composer file attachments", () => {
name: "photo.jpg",
mimeType: "image/jpeg",
sizeBytes: 4,
dataUrl: `data:image/jpeg;base64,${jpeg}`,
previewUri: `data:image/jpeg;base64,${jpeg}`,
dataUrl: `data:image/jpeg;base64,${rendered.base64}`,
previewUri: rendered.uri,
},
],
error: null,
});
},
);

it.each([
{ size: { width: 4032, height: 3024 }, resizes: [{ width: 2048 }] },
{ size: { width: 3024, height: 4032 }, resizes: [{ height: 2048 }] },
{ size: { width: 2048, height: 1536 }, resizes: [] },
])("bounds a $size.width x $size.height photo to a 2048 px longest edge", async (input) => {
native.size = input.size;
mocks.pickMedia.mockResolvedValue({ canceled: false, assets: [photo] });

await pickComposerImages({ existingCount: 0 });

expect(native.resizes).toEqual(input.resizes);
// Every decoded bitmap is released, including the full-size one a resize replaces.
expect(mocks.release).toHaveBeenCalledTimes(input.resizes.length + 1);
});

it.each([
{ extension: "png", mimeType: "image/png", base64: "iVBORw0KGgo=" },
{ extension: "gif", mimeType: "image/gif", base64: "R0lGODlh" },
{ extension: "webp", mimeType: "image/webp", base64: "UklGRgQAAABXRUJQ" },
])("preserves original $extension bytes instead of the picker's JPEG", async (original) => {
])("keeps original $extension bytes from the picker file", async (original) => {
const name = `photo.${original.extension}`;
mocks.pickMedia.mockResolvedValue({
canceled: false,
Expand All @@ -159,44 +213,83 @@ describe("composer file attachments", () => {

const result = await pickComposerImages({ existingCount: 0 });

expect(mocks.manipulate).not.toHaveBeenCalled();
expect(mocks.readBase64).toHaveBeenCalledWith(photo.uri);
expect(result.error).toBeNull();
expect(result.images).toEqual([
expect.objectContaining({
name,
mimeType: original.mimeType,
dataUrl: `data:${original.mimeType};base64,${original.base64}`,
sizeBytes: Buffer.from(original.base64, "base64").byteLength,
previewUri: photo.uri,
}),
]);
});

it("checks the converted JPEG size even when the HEIC source was smaller", async () => {
const oversized =
jpeg.slice(0, 4) + "A".repeat(Math.ceil(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / 3) * 4);
it("renders a supported original that exceeds the image limit instead of rejecting it", async () => {
mocks.pickMedia.mockResolvedValue({
canceled: false,
assets: [{ ...photo, fileSize: 42, base64: oversized }],
assets: [{ ...photo, fileName: "photo.jpg", mimeType: "image/jpeg" }],
});
mocks.size.mockReturnValue(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES + 1);

await expect(pickComposerImages({ existingCount: 0 })).resolves.toEqual({
images: [],
error: "'photo.HEIC' exceeds the 10 MB attachment limit.",
const result = await pickComposerImages({ existingCount: 0 });

expect(mocks.manipulate).toHaveBeenCalledWith(photo.uri);
expect(result.error).toBeNull();
expect(result.images).toEqual([
expect.objectContaining({ name: "photo.jpg", mimeType: "image/jpeg", sizeBytes: 4 }),
]);
});

it("measures the picker file instead of trusting the reported size", async () => {
// A content stream can deliver more bytes than the picker advertises; a supported
// original only skips rendering when the file itself measures within the limit.
mocks.pickMedia.mockResolvedValue({
canceled: false,
assets: [{ ...photo, fileName: "photo.png", mimeType: "image/png", fileSize: 42 }],
});
mocks.size.mockReturnValue(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES + 1);

const result = await pickComposerImages({ existingCount: 0 });

expect(mocks.readBase64).not.toHaveBeenCalled();
expect(mocks.manipulate).toHaveBeenCalledWith(photo.uri);
expect(result.images).toEqual([expect.objectContaining({ mimeType: "image/jpeg" })]);
});

it("does not relabel unconverted HEIC bytes as JPEG", async () => {
it("renders a supported original whose size cannot be measured", async () => {
mocks.pickMedia.mockResolvedValue({
canceled: false,
assets: [{ ...photo, base64: "AAAAGGZ0eXBoZWlj" }],
assets: [
{ ...photo, uri: "content://media/1", fileName: "photo.png", mimeType: "image/png" },
],
});

const result = await pickComposerImages({ existingCount: 0 });

expect(result.images).toEqual([]);
expect(result.error).toContain("not a supported image type");
expect(mocks.readBase64).not.toHaveBeenCalled();
expect(mocks.manipulate).toHaveBeenCalledWith("content://media/1");
expect(result.images).toEqual([expect.objectContaining({ mimeType: "image/jpeg" })]);
});

it("retains a converted photo when another original cannot be read", async () => {
it("checks the rendered JPEG against the image limit", async () => {
native.saved = {
uri: rendered.uri,
base64:
rendered.base64.slice(0, 4) +
"A".repeat(Math.ceil(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / 3) * 4),
};
mocks.pickMedia.mockResolvedValue({ canceled: false, assets: [photo] });

await expect(pickComposerImages({ existingCount: 0 })).resolves.toEqual({
images: [],
error: "'photo.HEIC' exceeds the 10 MB attachment limit.",
});
});

it("retains a rendered photo when another original cannot be read", async () => {
mocks.pickMedia.mockResolvedValue({
canceled: false,
assets: [{ ...photo, fileName: "missing.gif", mimeType: "image/gif" }, photo],
Expand All @@ -208,6 +301,21 @@ describe("composer file attachments", () => {
expect(result.images).toEqual([expect.objectContaining({ name: "photo.jpg" })]);
expect(result.error).toBe("Failed to read 'missing.gif'.");
});

it("reports a photo the native renderer cannot decode", async () => {
mocks.manipulate.mockImplementation(() => ({
resize: () => {
throw new Error("unreachable");
},
renderAsync: () => Promise.reject(new Error("corrupt")),
}));
mocks.pickMedia.mockResolvedValue({ canceled: false, assets: [photo] });

await expect(pickComposerImages({ existingCount: 0 })).resolves.toEqual({
images: [],
error: "Failed to read 'photo.HEIC'.",
});
});
});

describe("photo library videos", () => {
Expand All @@ -217,10 +325,13 @@ describe("composer file attachments", () => {
fileName: "photo.png",
mimeType: "image/png",
fileSize: 3,
base64: "YWJj",
width: 1,
height: 1,
};

beforeEach(() => {
mocks.readBase64.mockResolvedValue("YWJj");
});
const video: ImagePickerAsset = {
uri: "file:///picker/clip.mov",
type: "video",
Expand All @@ -234,7 +345,9 @@ describe("composer file attachments", () => {

it("retains mixed photos and videos, keeping video bytes in durable file storage", async () => {
mocks.pickMedia.mockResolvedValue({ canceled: false, assets: [image, video] });
mocks.size.mockReturnValue(video.fileSize);
mocks.size.mockImplementation((uri: string) =>
uri.endsWith("clip.mov") ? video.fileSize : 3,
);

const result = await pickComposerMedia({ existingCount: 0, maxVideoBytes: 50 * 1024 * 1024 });

Expand Down Expand Up @@ -345,7 +458,7 @@ describe("composer file attachments", () => {
canceled: false,
assets: [{ ...video, fileSize: reported }, image],
});
mocks.size.mockReturnValue(stored);
mocks.size.mockImplementation((uri: string) => (uri.endsWith("clip.mov") ? stored : 3));

const result = await pickComposerMedia({ existingCount: 0, maxVideoBytes: limit });

Expand Down
Loading
Loading