Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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
1 change: 1 addition & 0 deletions apps/desktop/src/electron/ElectronProtocol.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,7 @@ describe("ElectronProtocol", () => {
"http:",
"https:",
]);
assert.deepEqual(directives["media-src"], ["'self'", "t3code:", "blob:"]);
assert.deepEqual(directives["font-src"], ["'self'", "t3code:", "data:"]);
});
});
1 change: 1 addition & 0 deletions apps/desktop/src/electron/ElectronProtocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ export function makeDesktopContentSecurityPolicy(input: DesktopProtocolRegistrat
`script-src ${scriptSources.join(" ")}`,
`connect-src ${connectSources.join(" ")}`,
`img-src 'self' ${input.scheme}: blob: data: http: https:`,
`media-src 'self' ${input.scheme}: blob:`,
"style-src 'self' 'unsafe-inline'",
`font-src 'self' ${input.scheme}: data:`,
"worker-src 'self' blob:",
Expand Down
31 changes: 31 additions & 0 deletions apps/server/src/assets/AssetAccess.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,37 @@ describe("AssetAccess", () => {
}).pipe(Effect.provide(testLayer)),
);

it.effect("serves video attachments inline", () =>
Effect.gen(function* () {
const config = yield* ServerConfig.ServerConfig;
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const attachmentId = "thread-1-00000000-0000-4000-8000-000000000001-mp4";
const attachmentPath = path.join(config.attachmentsDir, `${attachmentId}.mp4`);
yield* fileSystem.makeDirectory(config.attachmentsDir, { recursive: true });
yield* fileSystem.writeFile(attachmentPath, new Uint8Array([1, 2, 3]));

const result = yield* issueAssetUrl({
resource: {
_tag: "attachment",
attachmentId,
fileName: "demo.mp4",
mimeType: 'video/mp4; codecs="avc1.42E01E"',
},
});
const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length);
const separatorIndex = suffix.indexOf("/");

expect(
yield* resolveAsset(suffix.slice(0, separatorIndex), suffix.slice(separatorIndex + 1)),
).toEqual({
kind: "file",
path: attachmentPath,
fileName: "demo.mp4",
mimeType: "video/mp4",
});
}).pipe(Effect.provide(testLayer)),
);
it.effect("issues project favicon capabilities with a signed fallback", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
Expand Down
13 changes: 9 additions & 4 deletions apps/server/src/assets/AssetAccess.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ const SIGNING_SECRET_NAME = "asset-access-signing-key";
const ASSET_TOKEN_TTL_MS = 60 * 60 * 1000;
const PROJECT_FAVICON_TOKEN_BUCKET_MS = 30 * 60 * 1000;
const PROJECT_FAVICON_VERSION_PREFIX = "v";
const INLINE_VIDEO_MIME_TYPE_PATTERN = /^video\/[\w!#$&^.+-]+$/i;
const PREVIEW_ASSET_EXTENSIONS = new Set([
...WORKSPACE_BROWSER_PREVIEW_EXTENSIONS,
...WORKSPACE_IMAGE_PREVIEW_EXTENSIONS,
Expand Down Expand Up @@ -300,16 +301,20 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i
});
}
// Generic files carry their extension inside the attachment id (that
// shape resolves the on-disk path); images do not. Only generic files
// download, images render inline in chat.
// shape resolves the on-disk path); images do not. Videos and images
// render inline; other generic files download.
const isGenericFile = parseAttachmentFileExtension(input.resource.attachmentId) !== null;
const videoMimeType = input.resource.mimeType?.split(";", 1)[0]?.trim() ?? "";
const isVideo = INLINE_VIDEO_MIME_TYPE_PATTERN.test(videoMimeType);
claims = {
version: 1,
kind: "attachment",
attachmentId: input.resource.attachmentId,
...(isGenericFile ? { download: true } : {}),
...(isGenericFile && !isVideo ? { download: true } : {}),
...(input.resource.fileName !== undefined ? { fileName: input.resource.fileName } : {}),
...(input.resource.mimeType !== undefined ? { mimeType: input.resource.mimeType } : {}),
...(input.resource.mimeType !== undefined
? { mimeType: isVideo ? videoMimeType : input.resource.mimeType }
: {}),
expiresAt,
};
fileName = input.resource.fileName ?? path.basename(attachmentPath);
Expand Down
11 changes: 11 additions & 0 deletions apps/server/src/http.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,17 @@ describe("assetResponseHeaders", () => {
});
});

it("serves inline videos with their declared mime type", () => {
expect(
assetResponseHeaders("/attachments/demo.bin", {
mimeType: 'video/mp4; codecs="avc1.42E01E"',
}),
).toEqual({
"Cache-Control": "private, max-age=3600",
"Content-Type": "video/mp4",
"X-Content-Type-Options": "nosniff",
});
});
it("declares utf-8 for HTML assets so non-ASCII content renders correctly", () => {
expect(assetResponseHeaders("/workspace/page.html")).toHaveProperty(
"Content-Type",
Expand Down
15 changes: 10 additions & 5 deletions apps/server/src/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ const DOWNLOAD_MIME_TYPE_PATTERN = /^[\w!#$&^.+-]+\/[\w!#$&^.+-]+$/;
const isSafeDownloadMimeType = (mimeType: string): boolean =>
DOWNLOAD_MIME_TYPE_PATTERN.test(mimeType) &&
!/(?:^text\/html$|\/xml(?:$|-)|\+xml$)/i.test(mimeType.trim().toLowerCase());
const isSafeInlineVideoMimeType = (mimeType: string): boolean =>
DOWNLOAD_MIME_TYPE_PATTERN.test(mimeType) && mimeType.toLowerCase().startsWith("video/");

/** RFC 6266 disposition with an ASCII fallback name plus a UTF-8 `filename*`. */
export function downloadContentDisposition(fileName?: string): string {
Expand Down Expand Up @@ -86,6 +88,7 @@ export function assetResponseHeaders(
},
): Record<string, string> {
const lowerPath = filePath.toLowerCase();
const inlineVideoMimeType = options?.mimeType?.split(";", 1)[0]?.trim();
return {
"Cache-Control": "private, max-age=3600",
"X-Content-Type-Options": "nosniff",
Expand All @@ -98,9 +101,11 @@ export function assetResponseHeaders(
? options.mimeType
: "application/octet-stream",
}
: lowerPath.endsWith(".html") || lowerPath.endsWith(".htm")
? { "Content-Type": "text/html; charset=utf-8" }
: {}),
: inlineVideoMimeType !== undefined && isSafeInlineVideoMimeType(inlineVideoMimeType)
? { "Content-Type": inlineVideoMimeType }
: lowerPath.endsWith(".html") || lowerPath.endsWith(".htm")
? { "Content-Type": "text/html; charset=utf-8" }
: {}),
...(!options?.download && lowerPath.endsWith(".svg")
? { "Content-Security-Policy": SVG_CONTENT_SECURITY_POLICY }
: {}),
Expand Down Expand Up @@ -276,9 +281,9 @@ export const assetRouteLayer = HttpRouter.add(
status: 200,
headers: assetResponseHeaders(
asset.path,
asset.download
asset.download || asset.mimeType !== undefined
? {
download: true,
...(asset.download ? { download: true } : {}),
...(asset.fileName !== undefined ? { fileName: asset.fileName } : {}),
...(asset.mimeType !== undefined ? { mimeType: asset.mimeType } : {}),
}
Expand Down
18 changes: 18 additions & 0 deletions apps/web/src/components/ChatView.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ import {
dismissBranchMismatchForSession,
ENVIRONMENT_RECONNECT_WARNING_GRACE_MS,
getStartedThreadModelChangeBlockReason,
loadDesktopVideoPreviewUrl,
isVideoPreviewRequestCurrent,
hasEnvironmentReconnectWarningGraceElapsed,
hasServerAcknowledgedLocalDispatch,
isBranchMismatchDismissedForSession,
Expand All @@ -41,6 +43,22 @@ import {
shouldWriteThreadErrorToCurrentServerThread,
} from "./ChatView.logic";

describe("loadDesktopVideoPreviewUrl", () => {
it("loads video bytes into an object URL", async () => {
const objectUrl = await loadDesktopVideoPreviewUrl("data:video/mp4;base64,AA==");
expect(objectUrl).toMatch(/^blob:/);
URL.revokeObjectURL(objectUrl);
});
});

describe("isVideoPreviewRequestCurrent", () => {
it("rejects changed threads and replaced previews", () => {
expect(isVideoPreviewRequestCurrent("thread-1", "thread-2", 1, 1)).toBe(false);
expect(isVideoPreviewRequestCurrent("thread-1", "thread-1", 1, 2)).toBe(false);
expect(isVideoPreviewRequestCurrent("thread-1", "thread-1", 2, 2)).toBe(true);
});
});

const environmentId = EnvironmentId.make("environment-local");
const projectId = ProjectId.make("project-1");
const threadId = ThreadId.make("thread-1");
Expand Down
15 changes: 15 additions & 0 deletions apps/web/src/components/ChatView.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,21 @@ export function revokeBlobPreviewUrl(previewUrl: string | undefined): void {
URL.revokeObjectURL(previewUrl);
}

export async function loadDesktopVideoPreviewUrl(url: string): Promise<string> {
const response = await fetch(url);
if (!response.ok) throw new Error(`Could not load video (${response.status}).`);
return URL.createObjectURL(await response.blob());
}

export function isVideoPreviewRequestCurrent(
requestThreadKey: string,
currentThreadKey: string,
requestId: number,
currentRequestId: number,
): boolean {
return requestThreadKey === currentThreadKey && requestId === currentRequestId;
Comment thread
Bil0000 marked this conversation as resolved.
}

export function revokeUserMessagePreviewUrls(message: ChatMessage): void {
if (message.role !== "user" || !message.attachments) {
return;
Expand Down
78 changes: 70 additions & 8 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ import {
MAX_TERMINALS_PER_GROUP,
type ChatMessage,
isImageAttachment,
isVideoAttachment,
type SessionPhase,
type Thread,
type TurnDiffSummary,
Expand Down Expand Up @@ -360,6 +361,8 @@ import {
cloneComposerImageForRetry,
deriveLockedProvider,
readFileAsDataUrl,
loadDesktopVideoPreviewUrl,
isVideoPreviewRequestCurrent,
reconcileMountedTerminalThreadIds,
resolveBackgroundDraftWorkspaceOptions,
resolveDraftHeroState,
Expand Down Expand Up @@ -1438,8 +1441,17 @@ function ChatViewContent(props: ChatViewProps) {
const localComposerRef = useRef<ChatComposerHandle | null>(null);
const composerRef = useComposerHandleContext() ?? localComposerRef;
const [isWorkspaceFileDragActive, setIsWorkspaceFileDragActive] = useState(false);
const routeThreadKeyRef = useRef(routeThreadKey);
routeThreadKeyRef.current = routeThreadKey;
const videoPreviewRequestIdRef = useRef(0);
const [openingVideoAttachmentId, setOpeningVideoAttachmentId] = useState<string | null>(null);
const [showScrollToBottom, setShowScrollToBottom] = useState(false);
const [expandedImage, setExpandedImage] = useState<ExpandedImagePreview | null>(null);
useEffect(() => {
const item = expandedImage?.images[expandedImage.index];
if (item?.type !== "video" || !item.src.startsWith("blob:")) return;
return () => revokeBlobPreviewUrl(item.src);
}, [expandedImage]);
const [optimisticUserMessages, setOptimisticUserMessages] = useState<ChatMessage[]>([]);
const [feedbackSubmissionsByThreadKey, setFeedbackSubmissionsByThreadKey] = useState<
Record<string, ReadonlyArray<CodexFeedbackSubmission>>
Expand Down Expand Up @@ -2504,17 +2516,33 @@ function ChatViewContent(props: ChatViewProps) {
});
}, []);
const serverMessages = activeThread?.messages;
const downloadFileAttachment = useCallback(
const openFileAttachment = useCallback(
async (attachment: ChatFileAttachment) => {
const connection = readPreparedConnection(environmentId);
if (!connection) {
toastManager.add({ type: "error", title: "The environment is not connected." });
return;
}
const isVideo = isVideoAttachment(attachment);
const action = isVideo ? "play" : "download";
const videoPreviewRequestId = isVideo ? ++videoPreviewRequestIdRef.current : 0;
const isCurrentRequest = () =>
!isVideo ||
isVideoPreviewRequestCurrent(
routeThreadKey,
routeThreadKeyRef.current,
videoPreviewRequestId,
videoPreviewRequestIdRef.current,
);
const finishVideoPreviewRequest = () => {
if (videoPreviewRequestIdRef.current === videoPreviewRequestId) {
setOpeningVideoAttachmentId(null);
}
};
if (isVideo) setOpeningVideoAttachmentId(attachment.id);

// fileName and mimeType ride in the signed claims so the download gets
// a real filename and Content-Type even when the anchor's `download`
// attribute is ignored (cross-origin environment servers).
// fileName and mimeType ride in the signed claims so videos render
// inline while other files keep their real download name and type.
const result = await createAttachmentAssetUrl({
environmentId,
input: {
Expand All @@ -2526,27 +2554,56 @@ function ChatViewContent(props: ChatViewProps) {
},
},
});
if (!isCurrentRequest()) {
finishVideoPreviewRequest();
return;
}
if (result._tag === "Failure") {
finishVideoPreviewRequest();
const error = squashAtomCommandFailure(result);
toastManager.add({
type: "error",
title: `Could not download ${attachment.name}`,
title: "Could not " + action + " " + attachment.name,
description: error instanceof Error ? error.message : "The attachment is unavailable.",
});
return;
}

const url = resolveAssetUrl(connection.httpBaseUrl, result.value.relativeUrl);
if (!url) {
toastManager.add({ type: "error", title: `Could not download ${attachment.name}` });
finishVideoPreviewRequest();
toastManager.add({ type: "error", title: "Could not " + action + " " + attachment.name });
return;
}
if (isVideo) {
try {
const previewUrl = window.desktopBridge ? await loadDesktopVideoPreviewUrl(url) : url;
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Outdated
if (!isCurrentRequest()) {
revokeBlobPreviewUrl(previewUrl);
return;
}
setExpandedImage({
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
images: [{ src: previewUrl, name: attachment.name, type: "video" }],
index: 0,
});
} catch (error) {
if (!isCurrentRequest()) return;
toastManager.add({
type: "error",
title: "Could not play " + attachment.name,
description: error instanceof Error ? error.message : "The attachment is unavailable.",
});
} finally {
finishVideoPreviewRequest();
}
return;
}
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = attachment.name;
anchor.click();
},
[createAttachmentAssetUrl, environmentId],
[createAttachmentAssetUrl, environmentId, routeThreadKey],
);
const serverAttachmentIds = useMemo(() => {
const attachmentIds = new Set<string>();
Expand Down Expand Up @@ -4389,6 +4446,8 @@ function ChatViewContent(props: ChatViewProps) {
return [];
});
resetLocalDispatch();
videoPreviewRequestIdRef.current += 1;
setOpeningVideoAttachmentId(null);
setExpandedImage(null);
}, [draftId, resetLocalDispatch, threadId]);

Expand Down Expand Up @@ -6844,6 +6903,8 @@ function ChatViewContent(props: ChatViewProps) {
};

const onExpandTimelineImage = useCallback((preview: ExpandedImagePreview) => {
videoPreviewRequestIdRef.current += 1;
setOpeningVideoAttachmentId(null);
setExpandedImage(preview);
}, []);
const onOpenTurnDiff = useCallback(
Expand Down Expand Up @@ -7139,7 +7200,8 @@ function ChatViewContent(props: ChatViewProps) {
onRevertUserMessage={onRevertUserMessage}
isRevertingCheckpoint={isRevertingCheckpoint}
onImageExpand={onExpandTimelineImage}
onFileDownload={downloadFileAttachment}
onFileOpen={openFileAttachment}
openingVideoAttachmentId={openingVideoAttachmentId}
markdownCwd={gitCwd ?? undefined}
resolvedTheme={resolvedTheme}
timestampFormat={timestampFormat}
Expand Down
Loading
Loading