Skip to content
Open
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 added docs/images/video-preview-desktop.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/images/video-preview-mobile.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion src/web/middleware/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -831,7 +831,7 @@ export function registerSecurityHeaders(app: FastifyInstance, https: boolean, ba
const workerSrc = "; worker-src 'self' blob:";
const csp =
`default-src 'self'; ${scriptSrc}; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; ` +
`img-src 'self' data: blob:; ${connectSrc}; font-src 'self' https://cdn.jsdelivr.net; frame-ancestors 'self'${workerSrc}`;
`img-src 'self' data: blob:; media-src 'self' blob: https: http:; ${connectSrc}; font-src 'self' https://cdn.jsdelivr.net; frame-ancestors 'self'${workerSrc}`;

app.addHook('onRequest', (req, reply, done) => {
reply.header('X-Content-Type-Options', 'nosniff');
Expand Down
6 changes: 6 additions & 0 deletions src/web/public/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -3645,6 +3645,12 @@ class CodemanApp {
return;
}

if (urlLink && !ev.ctrlKey && !ev.metaKey && !ev.shiftKey && !ev.altKey && this.openVideoUrlPreview?.(urlLink.href, body.dataset.rvSessionId || this.activeSessionId)) {
ev.preventDefault();
ev.stopPropagation();
return;
}

// One-click copy: lift the raw source from the sibling <pre><code>, or —
// COD-263 — the rendered text of the sibling <blockquote>.
const copyBtn = ev.target.closest('.rv-copy-btn');
Expand Down
65 changes: 65 additions & 0 deletions src/web/public/panels-ui.js
Original file line number Diff line number Diff line change
Expand Up @@ -5262,13 +5262,78 @@ Object.assign(CodemanApp.prototype, {
}
},

/** Direct media loading stays in the browser; external URLs never become server fetches. */
openVideoUrlPreview(value, sessionId = this.activeSessionId) {
if (typeof value !== 'string' || !/^https?:\/\//i.test(value)) return false;
let url;
try {
url = new URL(value);
if (url.username || url.password || !/\.(mp4|webm|mov|m4v|ogv)$/i.test(url.pathname)) return false;
} catch { return false; }
const overlay = this.$('filePreviewOverlay');
const body = this.$('filePreviewBody');
if (!overlay || !body) return false;
if (this.filePreviewEdit?.dirty && !confirm('Discard unsaved changes?')) return true;

this._stopFilePreviewPolling();
this._filePreviewLoad = (this._filePreviewLoad || 0) + 1;
this._stopFilePreviewMedia();
this._resetFilePreviewEdit?.();
this._resetFilePreviewExternalAction?.();
this._resetFilePreviewOriginalDownloadAction?.();
this._teardownOfficePreviewResources?.();
this._teardownOutlineScrollSpy?.();
this._closeAnnotationPopover?.();
this._closeAnnotationTray?.();
this._hideFilePreviewNavigation();
this._updateFolderPreviewNav?.(null);
if (this.folderGrid) this.folderGrid.index = null;
this.filePreviewText = null;
this.filePreviewContent = '';
this.filePreviewPage = null;
this.filePreviewDetachUrl = '';
this._annotations = [];
this._updateFilePreviewToolbar?.('none');
const detach = this.$('filePreviewDetachBtn');
if (detach) detach.hidden = true;
const folder = this.$('filePreviewFolderBtn');
if (folder) folder.hidden = true;
const state = this.filePreviewState = { sessionId, filePath: url.href, previewKind: 'video', externalPreviewUrl: url.href };
this._recordFilePreviewHistory(url.href, sessionId, null, {});
this._bindFilePreviewChrome();
this._restorePreviewSize();
this._applyFilePreviewExpanded();
overlay.classList.add('visible');
let name = url.pathname.split('/').pop();
try { name = decodeURIComponent(name); } catch { /* Keep an undecodable file name readable. */ }
this.$('filePreviewTitle').textContent = name;
const footer = this.$('filePreviewFooter');
footer.textContent = `Video • ${url.host}`;
const external = this.$('filePreviewOpenNewTab');
if (external) external.disabled = false;
const video = document.createElement('video');
video.controls = true;
video.autoplay = true;
video.playsInline = true;
video.preload = 'metadata';
video.style.width = '100%';
video.setAttribute('aria-label', name);
video.addEventListener('error', () => {
if (this.filePreviewState === state) footer.textContent = 'Video could not play. Try “Open in new tab” in the toolbar.';
});
video.src = url.href;
body.replaceChildren(video);
return true;
},

/**
* @param {object} [options]
* @param {{folderId: string, name: string}} [options.folderFile] A file of a
* granted folder (folder grid): rendered from the folder's by-name routes, so
* paging through 300 renders never touches the attachment registry.
*/
async openFilePreview(filePath, sessionId = this.activeSessionId, attachmentId = null, options = {}) {
if (!attachmentId && this.openVideoUrlPreview?.(filePath, sessionId)) return;
if (!sessionId || !filePath) return;
const folderFile = options.folderFile || null;
// Prev/next only while browsing a folder grid; a preview opened from anywhere
Expand Down
3 changes: 3 additions & 0 deletions src/web/public/sw.js
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,9 @@ self.addEventListener('fetch', (event) => {
if (request.method !== 'GET') return;
if (request.headers.get('upgrade') === 'websocket') return;
if (request.headers.get('accept') === 'text/event-stream') return;
// Let the browser stream and seek media directly. Cloning/caching a video
// duplicates large downloads, and Cache.put cannot store partial (206) responses.
if (request.destination === 'video' || request.destination === 'audio' || request.headers.get('range')) return;
if (request.url.includes('/api/')) return;
// A rendered HTML page is served `no-store` behind a capability that logout
// revokes; caching a popped-out page here would let the offline fallback
Expand Down
44 changes: 32 additions & 12 deletions src/web/public/terminal-ui.js
Original file line number Diff line number Diff line change
Expand Up @@ -613,7 +613,7 @@ function _debugEchoLogOuter() {
} catch {}
}

const TERMINAL_FILE_URI_PREVIEW_EXTENSIONS = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'pdf', 'docx', 'pptx', 'xlsx', 'md', 'txt']);
const TERMINAL_FILE_URI_PREVIEW_EXTENSIONS = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'pdf', 'docx', 'pptx', 'xlsx', 'md', 'txt', 'mp4', 'webm', 'mov', 'm4v', 'ogv']);

Object.assign(CodemanApp.prototype, {

Expand Down Expand Up @@ -2556,6 +2556,11 @@ Object.assign(CodemanApp.prototype, {
'docx',
'pptx',
'xlsx',
'mp4',
'webm',
'mov',
'm4v',
'ogv',
'json',
'yaml',
'yml',
Expand All @@ -2576,6 +2581,11 @@ Object.assign(CodemanApp.prototype, {
'docx',
'pptx',
'xlsx',
'mp4',
'webm',
'mov',
'm4v',
'ogv',
'md',
'txt',
]);
Expand Down Expand Up @@ -3662,6 +3672,8 @@ Object.assign(CodemanApp.prototype, {
return {
activate: (_event, text) => {
if (/^https?:\/\/[^\s\x00-\x1f]+$/i.test(text)) {
const proxied = window.CodemanWebviewLinks?.linkNeedsWebTabProxy?.(text, window.location?.hostname);
if (!proxied && this.openVideoUrlPreview?.(text, getSessionId())) return;
if (window.confirm(`Open this link?\n\n${text}`)) this._openTerminalUrl(text);
return;
}
Expand All @@ -3679,9 +3691,10 @@ Object.assign(CodemanApp.prototype, {
_openTerminalUrl(text) {
// A `localhost` link tapped from another device can only work through the
// server: route it into a proxied web tab (webview-tabs.js). Anything else
// opens as before. Lives here rather than in the link provider's activate()
// uses the video preview or opens normally. Lives here rather than in the link provider's activate()
// so every caller of this one opener gets the same routing.
if (this.openLinkThroughWebTabIfLoopback?.(text)) return;
if (this.openVideoUrlPreview?.(text)) return;
window.open(text, '_blank', 'noopener,noreferrer');
},

Expand Down Expand Up @@ -3792,18 +3805,25 @@ Object.assign(CodemanApp.prototype, {

const bufferLineNumber = (buffer.viewportY || 0) + cell.row;
let activated = false;
// xterm's native OSC 8 provider owns labelled links, whose visible text
// need not contain the URL. Its normal mouse path never runs on touch.
const nativeProvider = this.terminal?._core?._linkProviderService?.linkProviders?.[0];
const providers = nativeProvider && nativeProvider !== provider ? [nativeProvider, provider] : [provider];
try {
provider.provideLinks(bufferLineNumber, (links) => {
const link = links?.find((candidate) => {
const { start, end } = candidate.range;
const afterStart = bufferLineNumber > start.y || (bufferLineNumber === start.y && cell.col >= start.x);
const beforeEnd = bufferLineNumber < end.y || (bufferLineNumber === end.y && cell.col <= end.x);
return afterStart && beforeEnd;
for (const candidateProvider of providers) {
if (activated) break;
candidateProvider.provideLinks(bufferLineNumber, (links) => {
const link = links?.find((candidate) => {
const { start, end } = candidate.range;
const afterStart = bufferLineNumber > start.y || (bufferLineNumber === start.y && cell.col >= start.x);
const beforeEnd = bufferLineNumber < end.y || (bufferLineNumber === end.y && cell.col <= end.x);
return afterStart && beforeEnd;
});
if (!link || typeof link.activate !== 'function') return;
link.activate(event, link.text);
activated = true;
});
if (!link || typeof link.activate !== 'function') return;
link.activate(event, link.text);
activated = true;
});
}
} catch (err) {
console.warn('[LinkProvider] tap activation failed:', err);
return false;
Expand Down
Binary file added test/fixtures/media/preview.mp4
Binary file not shown.
17 changes: 17 additions & 0 deletions test/routes/file-uri-preview.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,23 @@ describe('POST /api/sessions/:id/file-uri-preview', () => {
});
}

it('registers a generated MP4 and streams seekable video bytes through its guarded attachment route', async () => {
const filePath = join(workspaceDir, 'preview.mp4');
await fs.writeFile(filePath, 'video-bytes');
const registered = await postUri(pathToFileURL(filePath).href);
expect(registered.statusCode).toBe(200);
const id = registered.json().data.attachmentId;
const response = await app.inject({
method: 'GET',
url: `/api/sessions/${ctx._sessionId}/attachments/${id}/raw`,
headers: { range: 'bytes=0-3' },
});
expect(response.statusCode).toBe(206);
expect(response.headers['content-type']).toContain('video/mp4');
expect(response.headers['content-range']).toBe('bytes 0-3/11');
expect(response.body).toBe('vide');
});

it('resolves a percent-encoded local artifact to sanitized opaque attachment metadata', async () => {
const filePath = join(workspaceDir, 'mockup one.png');
await fs.writeFile(filePath, 'image-bytes');
Expand Down
22 changes: 21 additions & 1 deletion test/sw-fetch-skip.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,11 @@ function loadFetchListener(): (event: unknown) => void {

function fetchEvent(path: string) {
return {
request: { method: 'GET', url: `http://localhost${path}`, headers: { get: () => null } },
request: {
method: 'GET',
url: `http://localhost${path}`,
headers: { get: (_name: string): string | null => null },
},
respondWith: vi.fn(),
};
}
Expand All @@ -48,6 +52,22 @@ describe('service worker fetch skip list', () => {
}
});

it.each(['video', 'audio'])('does not cache %s streams', (destination) => {
const onFetch = loadFetchListener();
const event = fetchEvent('/preview.mp4?token=private');
Object.assign(event.request, { destination });
onFetch(event);
expect(event.respondWith).not.toHaveBeenCalled();
});

it('leaves range requests to the native network path even without a media destination', () => {
const onFetch = loadFetchListener();
const event = fetchEvent('/download');
event.request.headers.get = (name: string) => (name === 'range' ? 'bytes=0-1023' : null);
onFetch(event);
expect(event.respondWith).not.toHaveBeenCalled();
});

it('still answers an app asset', () => {
const onFetch = loadFetchListener();
const event = fetchEvent('/app.js');
Expand Down
12 changes: 12 additions & 0 deletions test/terminal-document-link-provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1263,3 +1263,15 @@ it('routes native Codex file hyperlinks through the guarded preview using the ow
handler.activate(null, uri);
expect(preview).toHaveBeenCalledTimes(1);
});

it.each(['mp4', 'webm', 'mov', 'm4v', 'ogv'])(
'routes generated %s file URI labels to the guarded video preview',
(extension) => {
const { proto } = loadTerminalUiPrototype();
const host = { sessions: new Map([['video-pane', { mode: 'codex' }]]), openFileUriPreview: vi.fn() };
const handler = proto._terminalOsc8LinkHandler.call(host, { getSessionId: () => 'video-pane' });
const uri = `file:///Users/example/output/preview.${extension}`;
handler.activate(null, uri);
expect(host.openFileUriPreview).toHaveBeenCalledExactlyOnceWith(uri, 'video-pane');
}
);
Loading
Loading