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
32 changes: 32 additions & 0 deletions apps/desktop/src/main/__tests__/lightboxMediaActions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ describe('mime helpers', () => {

function makeDeps(overrides: Partial<LightboxMediaDeps> = {}): LightboxMediaDeps {
return {
realpath: vi.fn(async (filePath: string) => filePath),
isPathAllowed: () => true,
resolveImageCacheUrl: (url) => ({
absPath: `/cache/${url.replace('xdt-image://', '')}`,
Expand Down Expand Up @@ -217,6 +218,37 @@ describe('openWithDefaultApp', () => {
});

describe('saveAs', () => {
// Unified previews: PDF/video originals save with their names, while image-only
// reading stays closed to binary files and protected symlink targets stay blocked.
it.each(['report.pdf', 'clip.mp4', 'archive.zip'])(
'saves the original %s without image decoding',
async (name) => {
const file = path.resolve('/tmp', name);
const deps = makeDeps();
const handlers = createLightboxMediaHandlers(deps);
await expect(handlers.saveAs({ url: localFileUrl(file) })).resolves.toMatchObject({
canceled: false,
});
expect(deps.showSaveDialog).toHaveBeenCalledWith({
defaultPath: path.join('/downloads', name),
});
expect(deps.copyFile).toHaveBeenCalledWith(file, '/downloads/out.png');
await expect(handlers.readImageBytes({ url: localFileUrl(file) })).rejects.toThrow();
},
);
it('rejects an allowed-looking file that resolves to a blocked path before offering save', async () => {
const blocked = path.resolve('/private/key');
const deps = makeDeps({
realpath: vi.fn(async () => blocked),
isPathAllowed: (value) => value !== blocked,
});
await expect(
createLightboxMediaHandlers(deps).saveAs({ url: localFileUrl('/tmp/document.pdf') }),
).rejects.toThrow();
expect(deps.showSaveDialog).not.toHaveBeenCalled();
expect(deps.copyFile).not.toHaveBeenCalled();
});

it('copies a local source to the chosen path', async () => {
const deps = makeDeps();
const result = await createLightboxMediaHandlers(deps).saveAs({
Expand Down
5 changes: 4 additions & 1 deletion apps/desktop/src/main/bootstrap-electron.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8464,10 +8464,13 @@ const registerIpcHandlers = () => {

const lightboxMedia = createLightboxMediaHandlers({
isPathAllowed,
realpath: (absPath) => fs.promises.realpath(absPath),
resolveImageCacheUrl: (url) =>
url.startsWith('cindy-media://')
? cindyMediaBlobStore.resolveSafe(url)
: imageCacheStore.resolveSafe(url),
: url.startsWith('xdt-video://')
? videoCacheStore.resolveSafe(url)
: imageCacheStore.resolveSafe(url),
// 规则 25:lightbox「发送到对话」的缓存写同样进媒体总仓(草稿语义,
// 发送时统一由 register.ts 挂引用),与 IPC from-path/from-buffer 一致。
cacheImageFromPath: (params) =>
Expand Down
51 changes: 42 additions & 9 deletions apps/desktop/src/main/lightboxMediaActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@
import { createHash } from 'node:crypto';
import * as path from 'node:path';

import { isDangerousAttachmentName } from '../shared/attachmentSafety';

import { isIpcErrorCode } from '../shared/ipc-errors';
import { throwIpcError } from './utils/ipcValidate';

Expand Down Expand Up @@ -69,8 +71,9 @@ const EXT_BY_MIME: Record<string, string> = {
* - `http(s)://` / `data:image/...;base64,` 标记为需取字节的远程/内联源;
* - 其余(cindy-remote-media:// / blob: / 相对路径等)一律 unsupported。
*/
export function classifyLightboxMediaUrl(url: string): LightboxMediaSource {
export function classifyLightboxMediaUrl(url: string, forSave = false): LightboxMediaSource {
if (typeof url !== 'string' || url.length === 0) return { kind: 'unsupported' };
if (forSave && url.startsWith('xdt-video://')) return { kind: 'image-cache', url };
if (url.startsWith('xdt-image://')) return { kind: 'image-cache', url };
// cindy-media(媒体总仓 blob)与 xdt-image 同语义:本机缓存文件,可解析绝对
// 路径;resolveImageCacheUrl 依赖按 scheme 分派到对应 store(bootstrap 接线)。
Expand All @@ -91,7 +94,9 @@ export function classifyLightboxMediaUrl(url: string): LightboxMediaSource {
? resolved.toLowerCase() === normalizedInput.toLowerCase()
: resolved === normalizedInput;
if (!eq) return { kind: 'unsupported' };
if (!IMAGE_EXTS.has(path.extname(resolved).toLowerCase())) {
if (
(!forSave && !IMAGE_EXTS.has(path.extname(resolved).toLowerCase())) ||
(forSave && isDangerousAttachmentName(resolved))) {
return { kind: 'unsupported' };
}
return { kind: 'local-file', absPath: resolved };
Expand Down Expand Up @@ -241,6 +246,7 @@ export async function collectStreamWithLimit(
export interface LightboxMediaDeps {
/** bootstrap-electron 的路径白名单校验。 */
isPathAllowed(absPath: string): boolean;
realpath(absPath: string): Promise<string>;
/** imageCacheStore.resolveSafe:xdt-image:// URL → 缓存文件绝对路径(含安全校验,失败抛错)。 */
resolveImageCacheUrl(url: string): { absPath: string; mimeType: string };
/** imageCacheStore.copyFromPath(lifecycle 由调用处指定)。 */
Expand All @@ -260,8 +266,7 @@ export interface LightboxMediaDeps {
}): Promise<{ url: string; filename: string }>;
/** dialog.showSaveDialog(挂到聚焦窗口)。 */
showSaveDialog(opts: {
defaultPath: string;
}): Promise<{ canceled: boolean; filePath?: string }>;
defaultPath: string }): Promise<{ canceled: boolean; filePath?: string }>;
/** shell.openPath:成功返回 '',失败返回错误串。 */
openPath(absPath: string): Promise<string>;
/** fs.promises.readFile:readImageBytes 的 local-file 分支读字节。 */
Expand Down Expand Up @@ -293,8 +298,7 @@ export interface LightboxMediaHandlers {
saveAs(params: { url: string }): Promise<{ canceled: boolean; savedPath?: string }>;
cacheForSession(params: {
url: string;
sessionId: string;
}): Promise<CacheForSessionResult>;
sessionId: string }): Promise<CacheForSessionResult>;
/** renderer 字节层(标注烧录/位图复制)对 http / remote-media 源的取字节入口。 */
readImageBytes(params: { url: string }): Promise<{ base64: string; mimeType: string }>;
}
Expand Down Expand Up @@ -441,14 +445,23 @@ export function createLightboxMediaHandlers(deps: LightboxMediaDeps): LightboxMe
// 另存为。先弹保存对话框(只需要建议文件名),确认后才取字节/复制,
// 用户取消不浪费下载。
async saveAs(params) {
const source = classifyLightboxMediaUrl(params?.url ?? '');
// Save supports files; image decoding / annotation keep their image-only classifier.
const source = classifyLightboxMediaUrl(params?.url ?? '', true);
if (source.kind === 'unsupported') {
throwIpcError('INVALID_PARAMS', 'unsupported media source');
}

let localAbsPath: string | null = null;
if (source.kind === 'image-cache' || source.kind === 'local-file') {
localAbsPath = resolveLocalSourceOrThrow(source, deps);
try {
localAbsPath = await deps.realpath(localAbsPath);
if (!deps.isPathAllowed(localAbsPath) || isDangerousAttachmentName(localAbsPath))
throwIpcError('PERMISSION_DENIED', '不允许访问该路径');
} catch (err) {
rethrowIfIpcError(err);
throwIpcError('NOT_FOUND', '文件不存在');
}
}

// remote-media 先取字节:正在 lightbox 显示的图协议缓存大概率命中(快),
Expand All @@ -462,10 +475,30 @@ export function createLightboxMediaHandlers(deps: LightboxMediaDeps): LightboxMe
rethrowIfIpcError(err);
throwIpcError('INTERNAL', err instanceof Error ? err.message : String(err));
}
const mime = sniffImageMime(remoteBytes.buffer) ?? remoteBytes.mimeType ?? 'image/png';
suggested = `image-${deps.now()}${extForMimeType(mime)}`;
const mime = sniffImageMime(remoteBytes.buffer) ?? remoteBytes.mimeType ?? 'application/octet-stream';
const mediaExt: Record<string, string> = {
'video/mp4': '.mp4',
'video/quicktime': '.mov',
'video/webm': '.webm',
'audio/mpeg': '.mp3',
'audio/mp4': '.m4a',
'audio/wav': '.wav',
'audio/ogg': '.ogg',
'application/pdf': '.pdf',
'model/gltf-binary': '.glb',
};
suggested = `${mime.startsWith('image/') ? 'image' : 'media'}-${deps.now()}${mediaExt[mime] ?? (mime.startsWith('image/') ? extForMimeType(mime) : '.bin')}`;
} else {
suggested = suggestSaveFileName(source, localAbsPath, deps.now());
if (source.kind === 'http') {
try {
const base = decodeURIComponent(new URL(source.url).pathname.split('/').pop() ?? '');
if (base && !/[\\/\x00-\x1f]/.test(base) && !isDangerousAttachmentName(base))
suggested = base;
} catch {
/* retain the safe fallback */
}
}
}

const { canceled, filePath } = await deps.showSaveDialog({
Expand Down
6 changes: 3 additions & 3 deletions apps/desktop/src/renderer/__tests__/chatImageView.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ vi.mock('@/hooks/useRemoteMediaUrl', () => ({
}));

vi.mock('../components/chat/ImageLightbox', () => ({
ImageLightbox: ({ onClose }: { onClose: () => void }) => React.createElement(
'button', { 'data-testid': 'image-lightbox', onClick: onClose }, 'close preview',
ImageLightbox: ({ onClose, enableGallery }: { onClose: () => void; enableGallery?: boolean }) => React.createElement(
'button', { 'data-testid': 'image-lightbox', 'data-gallery-enabled': String(enableGallery), onClick: onClose }, 'close preview',
),
}));

Expand Down Expand Up @@ -368,7 +368,7 @@ describe('ChatImageView', () => {
trigger.focus();
fireEvent.keyDown(trigger, { key });
expect(screen.getByTestId('image-lightbox')).toBeTruthy();
expect(trigger.dataset.galleryActive).toBe('1');
expect(screen.getByTestId('image-lightbox').dataset.galleryEnabled).toBe('true');
const close = screen.getByTestId('image-lightbox');
close.focus();
fireEvent.click(close);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
* 与 chatVideoView.test.ts 对位,覆盖 review 指出的"image/model 路径同样
* 没有把焦点移进 lightbox"的一半:不 mock ImageLightbox / ModelLightbox,
* 用真实组件断言键盘打开后焦点进入弹窗(FocusScope 挂载焦点)、Esc 延迟
* 关闭后回到预览触发器。图片路径同时保留 gallery 定位标记的既有断言。
* 关闭后回到预览触发器。图片路径同时保留集合入口的键盘断言。
*/
import React from 'react';
import { afterEach, describe, expect, it, vi } from 'vitest';
Expand All @@ -28,8 +28,8 @@ afterEach(() => { cleanup(); vi.useRealTimers(); vi.clearAllMocks(); });

describe('ChatImageView 键盘交互(真实 ImageLightbox / ModelLightbox)', () => {
// ModelLightbox's 2026-06 scheme regression: the custom element reports a
// parse/load error, but its poster must stay usable and the user gets a
// toast. Existing focus tests never dispatch that event. Reuse the real
// parse/load error, but its poster must stay usable and the user gets an
// inline error with retry. Existing focus tests never dispatch that event. Reuse the real
// lightbox unit harness, with fake time to detect delayed unwanted closure.
it('reports a model load error and keeps the preview open', async () => {
vi.useFakeTimers();
Expand All @@ -42,7 +42,9 @@ describe('ChatImageView 键盘交互(真实 ImageLightbox / ModelLightbox)', ()
fireEvent.error(viewer);
await vi.advanceTimersByTimeAsync(300);
});
expect(toast.error).toHaveBeenCalledExactlyOnceWith('Failed to load 3D model');
expect(screen.getByText('chat.media.modelLoadFailed')).toBeTruthy();
expect(screen.getByRole('button', { name: 'attachmentPreview.retry' })).toBeTruthy();
expect(toast.error).not.toHaveBeenCalled();
expect(onClose).not.toHaveBeenCalled();
expect(viewer.isConnected).toBe(true);
expect(viewer.getAttribute('src')).toBe(url);
Expand All @@ -61,9 +63,6 @@ describe('ChatImageView 键盘交互(真实 ImageLightbox / ModelLightbox)', ()
trigger.focus();

fireEvent.keyDown(trigger, { key: 'Enter' });
// 注:gallery 定位标记(data-gallery-active)是瞬时信号——真实
// ImageLightbox 挂载后立即清掉(见其 mount effect),这里断不到;它的
// 设置路径由 chatImageView.test.ts 的 mock 用例覆盖。
// 真实 ImageLightbox:portal 内出现 alt="" 的大图(触发器缩略图 alt 是
// 文件名,不会撞)。
const lightboxImg = document.body.querySelector('img[alt=""]');
Expand Down
5 changes: 5 additions & 0 deletions apps/desktop/src/renderer/__tests__/chatVideoView.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,14 @@ describe('ChatVideoView inline keyboard controls', () => {
expect(screen.queryByRole('dialog')).toBeNull();
expect(screen.queryByRole('button', { name: 'chat.lightbox.close' })).toBeNull();

await user.tab();
const preview = screen.getByRole('button', { name: 'attachmentPreview.open' });
expect(document.activeElement).toBe(preview);
await user.tab();
expect(document.activeElement).toBe(screen.getByRole('button', { name: 'After' }));
await user.tab({ shift: true });
expect(document.activeElement).toBe(preview);
await user.tab({ shift: true });
expect(document.activeElement).toBe(video);
});
});
Expand Down
18 changes: 7 additions & 11 deletions apps/desktop/src/renderer/__tests__/openPathFeedback.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,6 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { invokeOpenPath } from '../../preload/openPath';
import { shouldOpenTextLightbox } from '../lib/filePreview';
import { toast } from '../lib/toast';

vi.mock('../lib/toast', () => ({ toast: { error: vi.fn() } }));
vi.mock('@/i18n', () => ({ i18n: { t: (key: string) => key } }));
vi.mock('../lib/remoteFileOpen', () => ({ openRemoteChatFile: vi.fn() }));

afterEach(() => {
vi.unstubAllGlobals();
Expand All @@ -25,16 +20,17 @@ describe('opening a non-text file from chat', () => {
window.electronAPI = {
openPath: (target: string) => invokeOpenPath(invoke, target),
} as typeof window.electronAPI;
expect(await shouldOpenTextLightbox('report.pdf')).toBe(false);
expect(toast.error).toHaveBeenCalledTimes(visible ? 1 : 0);
if (visible) expect(toast.error).toHaveBeenCalledWith(message);
const result = await window.electronAPI.openPath('report.pdf');
expect(result).toEqual({ success: false, error: message, ...(!visible ? { failureKind: 'ipc_lifecycle' } : {}) });
});

it('still shows an ordinary system error returned by main', async () => {
it('routes PDF to the in-app preview without invoking the system app', async () => {
vi.stubGlobal('electronAPI', {
openPath: vi.fn().mockResolvedValue({ success: false, error: 'No application associated' }),
});
await shouldOpenTextLightbox('report.pdf');
expect(toast.error).toHaveBeenCalledWith('No application associated');
expect(await shouldOpenTextLightbox('report.pdf')).toBe(true);
expect(window.electronAPI.openPath).not.toHaveBeenCalled();
const systemError = { success: false, error: 'No application associated' };
expect(await invokeOpenPath(vi.fn().mockResolvedValue(systemError), 'report.pdf')).toEqual(systemError);
});
});
58 changes: 3 additions & 55 deletions apps/desktop/src/renderer/__tests__/textLightbox.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ import '../themes/colors';
* moved exclusively to the Oversize CTA after the file-modal-and-toast
* polish on 2026-04-19.
* F4 Toolbar Close (X) button wires to handleClose.
* F4 Doc Card sized at 80vw/80vh capped 1600/1200 (no longer 1024×640).
* Private-card geometry and tooltip source checks retired with PreviewChrome;
* real closing and rendering are covered in runtime acceptance.
* F5 Oversize body shows triangle-alert + i18n 阈值文案 + Open-in-system CTA
* (Oversize is the SOLE caller of window.electronAPI.openPath now).
* F6 Esc closes; Doc Card clicks stop propagation; Overlay onClick=handleClose
Expand Down Expand Up @@ -58,19 +59,6 @@ describe('TextLightbox — F6 close behaviour', () => {
expect(source).toMatch(/handleClose\(\)/);
});

it('backdrop button wires onClick to handleClose (backdrop click closes)', () => {
expect(source).toMatch(/<button[\s\S]*type="button"[\s\S]*aria-label=\{t\('chat\.lightbox\.close'\)\}/);
expect(source).toMatch(/onClick=\{handleClose\}/);
});

it('Doc Card is layered above the backdrop button so inner clicks DO NOT close', () => {
// The backdrop close target is a separate background button behind the Doc
// Card. Toolbar buttons and text selection stay on the higher card layer.
expect(source).toMatch(/data-text-lightbox-card/);
expect(source).toContain('backdrop is a separate background button behind the Doc Card');
expect(source).toMatch(/zIndex:\s*1/);
});

it('handleClose guards against double-fire via isClosingRef', () => {
expect(source).toContain('isClosingRef.current');
expect(source).toMatch(/if\s*\(isClosingRef\.current\)\s*return;/);
Expand All @@ -85,23 +73,6 @@ describe('TextLightbox — F3 overlay style', () => {
expect(source).toContain("background: 'var(--overlay-lightbox)'");
for (const mode of ['light', 'dark'] as const) expect(colorRegistry.resolveDefault('overlay-lightbox', mode)).toBe('rgba(0, 0, 0, 0.85)');
});

it('overlay is rendered via createPortal to document.body', () => {
expect(source).toMatch(/createPortal\(overlay,\s*document\.body\)/);
});

it('keeps every toolbar tooltip above the lightbox overlay', () => {
expect(source).toMatch(/TEXT_LIGHTBOX_OVERLAY_Z_INDEX\s*=\s*9999/);
expect(source).toMatch(
/TEXT_LIGHTBOX_TOOLTIP_STYLE\s*=\s*\{[\s\S]*zIndex:\s*TEXT_LIGHTBOX_OVERLAY_Z_INDEX\s*\+\s*1/,
);

const tooltipContents = source.match(/<Tooltip\.Content\b/g) ?? [];
const layeredTooltipContents =
source.match(/<Tooltip\.Content\s+style=\{TEXT_LIGHTBOX_TOOLTIP_STYLE\}/g) ?? [];
expect(tooltipContents.length).toBeGreaterThan(0);
expect(layeredTooltipContents).toHaveLength(tooltipContents.length);
});
});

// ── F4: toolbar wiring ──────────────────────────────────────────────────────
Expand Down Expand Up @@ -145,27 +116,6 @@ describe('TextLightbox — F4 toolbar wiring', () => {
const openInSystemMatches = source.match(/onClick=\{openInSystem\}/g) ?? [];
expect(openInSystemMatches.length).toBe(1);
});

it('Toolbar adds an X close button wired to handleClose', () => {
// The new affordance — a visible × button on the Toolbar's right side
// gives users a third escape hatch (besides Esc and backdrop click).
expect(source).toMatch(/import\s+\{[^}]*\bX\b[^}]*\}\s+from\s+'lucide-react'/);
// Two `onClick={handleClose}` are now expected: overlay backdrop + Toolbar X.
const handleCloseClicks = source.match(/onClick=\{handleClose\}/g) ?? [];
expect(handleCloseClicks.length).toBeGreaterThanOrEqual(2);
});

it('Doc Card sizes to 80vw/80vh with 1600/1200 caps (post-polish)', () => {
// Old hard-coded `min(1024px, …)` caused the modal to feel small + offset.
// Polish picks the design-spec 80%/80% with sane upper bounds for 4K/5K.
expect(source).toMatch(/width:\s*'80vw'/);
expect(source).toMatch(/height:\s*'80vh'/);
expect(source).toMatch(/maxWidth:\s*'1600px'/);
expect(source).toMatch(/maxHeight:\s*'1200px'/);
// And explicitly NOT the old hard-coded 1024 / 640 values.
expect(source).not.toMatch(/min\(1024px/);
expect(source).not.toMatch(/min\(640px/);
});
});

// ── F5: Oversize body ───────────────────────────────────────────────────────
Expand All @@ -178,9 +128,7 @@ describe('TextLightbox — F5 Oversize body', () => {
it('uses i18n copy with dynamic file size and the preview cap', () => {
// 文案必须通过 i18n 注入动态 size/limit,组件内不再保留英文裸文案。
expect(source).toContain("t('chat.textLightbox.oversizeTitle')");
expect(source).toContain(
"t('chat.textLightbox.oversizeBody', { size: oversizeSizeText, limit: oversizeLimitMb })",
);
expect(source.replace(/\s+/g, "")).toContain("t('chat.textLightbox.oversizeBody',{size:oversizeSizeText,limit:oversizeLimitMb");
expect(source).not.toMatch(
/This file is \{oversizeSizeText\}, exceeding the \{oversizeLimitMb\} MB preview limit/,
);
Expand Down
Loading
Loading