diff --git a/apps/desktop/electron/main/ipc/workspace-ipc.ts b/apps/desktop/electron/main/ipc/workspace-ipc.ts index 9a990b0df..94015c57c 100644 --- a/apps/desktop/electron/main/ipc/workspace-ipc.ts +++ b/apps/desktop/electron/main/ipc/workspace-ipc.ts @@ -2,7 +2,7 @@ import { BrowserWindow, dialog, shell, type OpenDialogOptions } from "electron"; import { dirname } from "node:path"; import { homedir } from "node:os"; import { existsSync, statSync } from "node:fs"; -import { realpath } from "node:fs/promises"; +import { realpath, stat } from "node:fs/promises"; import { isAbsolute, join, resolve } from "node:path"; import { ErrorCodes, @@ -32,9 +32,9 @@ import { listDir, readOpenableFile, readOpenableImage, - resolveOpenablePath, resolveRealOpenablePath, } from "@pi-desktop/host-runtime"; +import { openableMp4Path } from "../open-attachment-video"; import { resolveChatFileRef } from "../chat-ref-resolve"; import { getWorkspaceFileIndex } from "../fs-index"; import { @@ -870,10 +870,11 @@ export function registerWorkspaceIpc({ return { ok: true }; }); - handle(IPC.invoke.fsOpen, async (input: { path?: string } = {}) => { + handle(IPC.invoke.fsOpen, async (input: { path?: string; mimeType?: string } = {}) => { const workspaceRoot = await optionalWorkspaceRoot(); - const target = resolveOpenablePath( - String(input.path ?? ""), + const requested = String(input.path ?? "").trim(); + const target = await resolveRealOpenablePath( + requested, workspaceRoot, await fsExtraRoots(workspaceRoot), ); @@ -882,7 +883,13 @@ export function registerWorkspaceIpc({ errorCode: ErrorCodes.INVALID_ARGUMENT, }); } - const openError = await shell.openPath(stripWinLongPrefix(target)); + if (!(await stat(target)).isFile()) { + throw Object.assign(new Error("not a file"), { + errorCode: ErrorCodes.INVALID_ARGUMENT, + }); + } + const openPath = await openableMp4Path(dataDir, target, input.mimeType); + const openError = await shell.openPath(stripWinLongPrefix(openPath)); if (openError) throw new Error(openError); return { ok: true }; }); diff --git a/apps/desktop/electron/main/open-attachment-video.ts b/apps/desktop/electron/main/open-attachment-video.ts new file mode 100644 index 000000000..3c3edddef --- /dev/null +++ b/apps/desktop/electron/main/open-attachment-video.ts @@ -0,0 +1,39 @@ +import { lstat, mkdir, realpath, symlink } from "node:fs/promises"; +import { basename, dirname, join } from "node:path"; + +const ATTACHMENT_HASH = /^[0-9a-f]{64}$/i; + +/** Give an extensionless attachment its media association without copying it. */ +export async function openableMp4Path( + dataDir: string, + target: string, + mimeType?: string, +): Promise { + const hash = basename(target); + if (!ATTACHMENT_HASH.test(hash) || mimeType?.toLowerCase() !== "video/mp4") { + return target; + } + let attachmentRoot: string; + try { + attachmentRoot = await realpath(join(dataDir, "attachments")); + } catch { + return target; + } + if (dirname(target) !== attachmentRoot) return target; + + const directory = join(dataDir, "openable-attachments"); + await mkdir(directory, { recursive: true, mode: 0o700 }); + if ((await lstat(directory)).isSymbolicLink()) { + throw new Error("attachment open directory is a symbolic link"); + } + const alias = join(directory, `${hash.toLowerCase()}.mp4`); + try { + await symlink(target, alias, "file"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; + if (!(await lstat(alias)).isSymbolicLink() || (await realpath(alias)) !== target) { + throw new Error("attachment open path is already occupied"); + } + } + return alias; +} diff --git a/apps/desktop/src/components/workpanel/FilesTab.tsx b/apps/desktop/src/components/workpanel/FilesTab.tsx index ad0c17493..b73418a9e 100644 --- a/apps/desktop/src/components/workpanel/FilesTab.tsx +++ b/apps/desktop/src/components/workpanel/FilesTab.tsx @@ -12,7 +12,7 @@ import { useAppStore } from "../../stores/app-store"; import { api } from "../../lib/api"; import { Markdown } from "../Markdown"; import { fileDirOf } from "../../lib/chat-links"; -import { cx } from "../ui"; +import { Button, cx } from "../ui"; import { TooltipButton } from "../ui"; import { ensureLang, @@ -74,6 +74,13 @@ function isMarkdownPath(path: string): boolean { return /\.(?:md|markdown)$/i.test(path); } +function isMp4(path: string, mimeType?: string): boolean { + return /\.mp4$/i.test(path) || ( + /(?:^|[\\/])attachments[\\/][0-9a-f]{64}$/i.test(path) && + mimeType?.toLowerCase() === "video/mp4" + ); +} + function formatSize(size: number): string { if (size < 1024) return `${size} B`; if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`; @@ -149,11 +156,13 @@ export function FilesTab() { const { t } = useTranslation(); const workspace = useAppStore((s) => s.workspace); const fileRequest = useAppStore((s) => s.workPanelFileRequest); + const showToast = useAppStore((s) => s.showToast); const root = workspace?.path ?? null; const [dirs, setDirs] = useState>({}); const [expanded, setExpanded] = useState>(new Set()); const [selected, setSelected] = useState(null); + const [selectedMimeType, setSelectedMimeType] = useState(); const [file, setFile] = useState(null); const [fileError, setFileError] = useState(false); @@ -168,6 +177,7 @@ export function FilesTab() { setDirs({}); setExpanded(new Set()); setSelected(null); + setSelectedMimeType(undefined); setFile(null); setFileError(false); }, [root]); @@ -207,6 +217,7 @@ export function FilesTab() { const openFile = useCallback(async (rel: string, mimeType?: string) => { setSelected(rel); + setSelectedMimeType(mimeType); setFile(null); setFileError(false); try { @@ -216,6 +227,15 @@ export function FilesTab() { } }, []); + const openMp4 = useCallback(async () => { + if (!selected) return; + try { + await api.fsOpen(selected, selectedMimeType); + } catch { + showToast(t("panel.files.openFailed"), { variant: "error" }); + } + }, [selected, selectedMimeType, showToast, t]); + // Chat-initiated previews: open the file and expand its ancestor folders // so "back" lands on a tree that reveals it. Attachment blobs and absolute // scratch paths live outside the workspace tree. @@ -316,6 +336,7 @@ export function FilesTab() { ariaLabel={t("panel.files.back")} onClick={() => { setSelected(null); + setSelectedMimeType(undefined); setFile(null); }} > @@ -358,7 +379,13 @@ export function FilesTab() { ? t("panel.files.tooLarge") : t("panel.files.binary") } - /> + > + {isMp4(selected, selectedMimeType) && ( + + )} + )} diff --git a/apps/desktop/src/features/chat/transcript/MessageRow.tsx b/apps/desktop/src/features/chat/transcript/MessageRow.tsx index c9078af31..7b69b7445 100644 --- a/apps/desktop/src/features/chat/transcript/MessageRow.tsx +++ b/apps/desktop/src/features/chat/transcript/MessageRow.tsx @@ -269,6 +269,7 @@ export const MessageRow = memo(function MessageRow({ name={attachment.name} path={attachment.ref} kind={attachment.kind} + mimeType={attachment.mimeType} onOpen={openFileRef} /> diff --git a/apps/desktop/src/features/chat/transcript/shared.tsx b/apps/desktop/src/features/chat/transcript/shared.tsx index 0d3effde7..91d82f7c8 100644 --- a/apps/desktop/src/features/chat/transcript/shared.tsx +++ b/apps/desktop/src/features/chat/transcript/shared.tsx @@ -416,13 +416,15 @@ export function FileRefChip({ name, path, kind, + mimeType, onOpen, ...position }: { name: string; path: string; kind?: "image" | "file"; - onOpen: (path: string) => void; + mimeType?: string; + onOpen: (path: string, baseDir?: string, mimeType?: string) => void; } & SourcePositionProps) { const { t } = useTranslation(); const Icon = fileChipIcon(name, kind); @@ -436,7 +438,7 @@ export function FileRefChip({ {...position} title={`${html ? t("chat.previewUrl") : t("chat.openFile")} — ${path}`} aria-label={`${name} — ${path}`} - onClick={() => onOpen(path)} + onClick={() => onOpen(path, undefined, mimeType)} onContextMenu={(event) => openFileMenu(event, { path })} > @@ -459,7 +461,7 @@ export function MessageAttachmentImage({ onOpenFile, }: { attachment: MessageAttachment; - onOpenFile: (path: string) => void; + onOpenFile: (path: string, baseDir?: string, mimeType?: string) => void; }) { const { fileMenu, openFileMenu, closeFileMenu } = useChatFileMenu(); const dataUrl = useReferencedImageDataUrl(attachment.ref, attachment.mimeType); @@ -469,6 +471,7 @@ export function MessageAttachmentImage({ name={attachment.name} path={attachment.ref} kind="image" + mimeType={attachment.mimeType} onOpen={onOpenFile} /> ); diff --git a/apps/desktop/src/lib/api.ts b/apps/desktop/src/lib/api.ts index f4c60c636..2c30bb6a9 100644 --- a/apps/desktop/src/lib/api.ts +++ b/apps/desktop/src/lib/api.ts @@ -1360,7 +1360,8 @@ export const api = { ...(mimeType ? { mimeType } : {}), }), fsReveal: (path: string) => invoke(IPC.invoke.fsReveal, { path }), - fsOpen: (path: string) => invoke(IPC.invoke.fsOpen, { path }), + fsOpen: (path: string, mimeType?: string) => + invoke(IPC.invoke.fsOpen, { path, mimeType }), fsIndex: () => invoke(IPC.invoke.fsIndex), /** * Complete a file reference from chat text to a real file (D320 follow-up). diff --git a/apps/desktop/test/open-attachment-video.test.mjs b/apps/desktop/test/open-attachment-video.test.mjs new file mode 100644 index 000000000..d80927508 --- /dev/null +++ b/apps/desktop/test/open-attachment-video.test.mjs @@ -0,0 +1,56 @@ +import assert from "node:assert/strict"; +import { mkdtemp, mkdir, readlink, realpath, rm, stat, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { openableMp4Path } from "../electron/main/open-attachment-video.ts"; + +test("an extensionless stored MP4 gets an OS-openable name without copying bytes", async (t) => { + const dataDir = await mkdtemp(join(tmpdir(), "pi-mp4-open-")); + t.after(() => rm(dataDir, { recursive: true, force: true })); + const attachments = join(dataDir, "attachments"); + await mkdir(attachments); + const hash = "a".repeat(64); + const stored = join(attachments, hash); + await writeFile(stored, Buffer.alloc(512 * 1024 + 1)); + const storedReal = await realpath(stored); + + const alias = await openableMp4Path(dataDir, storedReal, "video/mp4"); + assert.equal(alias, join(dataDir, "openable-attachments", `${hash}.mp4`)); + assert.equal(await realpath(alias), storedReal); + assert.equal(await readlink(alias), storedReal); + assert.equal((await stat(alias)).size, 512 * 1024 + 1); + assert.equal(await openableMp4Path(dataDir, storedReal, "video/mp4"), alias); +}); + +test("only a typed content-addressed MP4 is given an alias", async (t) => { + const dataDir = await mkdtemp(join(tmpdir(), "pi-mp4-open-")); + t.after(() => rm(dataDir, { recursive: true, force: true })); + const file = join(dataDir, "clip.mp4"); + await writeFile(file, "video"); + const workspace = join(dataDir, "workspace"); + await mkdir(workspace); + const hashNamedProjectFile = join(workspace, "b".repeat(64)); + await writeFile(hashNamedProjectFile, "project file"); + + assert.equal(await openableMp4Path(dataDir, file, "video/mp4"), file); + assert.equal(await openableMp4Path(dataDir, file, "text/plain"), file); + assert.equal(await openableMp4Path(dataDir, hashNamedProjectFile, "video/mp4"), hashNamedProjectFile); +}); + +test("an occupied alias cannot redirect the OS handoff", async (t) => { + const dataDir = await mkdtemp(join(tmpdir(), "pi-mp4-open-")); + t.after(() => rm(dataDir, { recursive: true, force: true })); + const attachments = join(dataDir, "attachments"); + const aliases = join(dataDir, "openable-attachments"); + await Promise.all([mkdir(attachments), mkdir(aliases)]); + const hash = "c".repeat(64); + const stored = join(attachments, hash); + await writeFile(stored, "video"); + await writeFile(join(aliases, `${hash}.mp4`), "different file"); + + await assert.rejects( + openableMp4Path(dataDir, await realpath(stored), "video/mp4"), + /already occupied/, + ); +}); diff --git a/apps/desktop/test/transcript-file-chips.test.mjs b/apps/desktop/test/transcript-file-chips.test.mjs index 4bfa6a0f6..cd37a0945 100644 --- a/apps/desktop/test/transcript-file-chips.test.mjs +++ b/apps/desktop/test/transcript-file-chips.test.mjs @@ -24,6 +24,8 @@ test("sent user-message file refs render as composer-like chips", () => { assert.match(transcript, /useOpenChatFileRef/); assert.match(transcript, /composer-chip-name/); assert.match(styles, /\.chat-file-chip[\s\S]*?appearance: none/); + assert.match(transcript, /mimeType=\{attachment\.mimeType\}/); + assert.match(transcript, /onOpen\(path, undefined, mimeType\)/); }); test("a file chip is routed by where the reference resolved, never optimistically", () => { @@ -53,7 +55,7 @@ test("a file chip is routed by where the reference resolved, never optimisticall // The OS handoff is no longer what a chat click does; the channel itself // stays part of the public IPC surface. assert.doesNotMatch(hook, /api\.fsOpen\(/); - assert.match(api, /fsOpen: \(path: string\) => invoke\(IPC\.invoke\.fsOpen, \{ path \}\)/); + assert.match(api, /fsOpen: \(path: string, mimeType\?: string\) =>\s*invoke\(IPC\.invoke\.fsOpen, \{ path, mimeType \}\)/); }); test("a tool row and a tool result row open a file where the message body does", () => { diff --git a/docs/spec/03-runtime/01-ipc-protocol.md b/docs/spec/03-runtime/01-ipc-protocol.md index eaf3b4aa3..4fe147193 100644 --- a/docs/spec/03-runtime/01-ipc-protocol.md +++ b/docs/spec/03-runtime/01-ipc-protocol.md @@ -1778,8 +1778,12 @@ Renderer IPC kept for the Plan-safe preview facade and URL fallback: containment as `fs/read`. Never returns non-image bytes. Renderer-only; not a plugin host API. - `fs/reveal({path})` → reveal in Finder. Same containment as `fs/read`. -- `fs/open({path})` → open with the OS default application. Same lexical - containment as `fs/read` (without the extra realpath step used by reads). +- `fs/open({path, mimeType?})` → open an existing regular file with the OS + default application. It uses the same realpath containment as `fs/read`, + including rejection of symlink escapes. For a content-addressed + `attachments/` blob declared as `video/mp4`, the host creates a + `.mp4` symlink inside its private app-data directory before the OS handoff, + so the extensionless blob has a media association without copying its bytes. - `fs/resolveRef({ref, sessionId?})` → `FsChatRefResolveResult` (`{ match: FsChatRefMatch | null }`, the match naming the answering `root` (`workspace` / `scratch` / `attachments`), the `relativePath` relative to that diff --git a/docs/spec/04-ux/09-interaction-patterns.md b/docs/spec/04-ux/09-interaction-patterns.md index 6d29ffbcf..1b187693c 100644 --- a/docs/spec/04-ux/09-interaction-patterns.md +++ b/docs/spec/04-ux/09-interaction-patterns.md @@ -1298,6 +1298,11 @@ Project drag/drop follows these patterns: outside the project has no relative path to copy and says so. The OS default application is no longer what this click does, though that action stays reachable from the file view's own context menu. +- In the host `file:` tab, a conversation MP4 that cannot be previewed because + it is binary or exceeds the text preview limit offers **Open with default + application**. This applies to named `.mp4` files and extensionless + attachment blobs carrying `video/mp4` metadata. A failed OS handoff shows an + error; the user can still reveal the contained file in the file manager. - The same destination rule governs every other surface of the transcript that names a file, because one opener serves them all: clicking the file path in a tool row's summary (Read, Write, Edit, fetch) and clicking a path in a tool diff --git a/docs/spec/06-delivery/04-e2e-test-plan.md b/docs/spec/06-delivery/04-e2e-test-plan.md index 78b0f169f..8b0ec55b7 100644 --- a/docs/spec/06-delivery/04-e2e-test-plan.md +++ b/docs/spec/06-delivery/04-e2e-test-plan.md @@ -8642,6 +8642,7 @@ must keep splitting are covered by `markdown-blocks.test.mjs`. | Quality (Independent session communication) | E2E-SESSION-independent-top-level-communication, E2E-SESSION-hover-card-model-and-links | | C — Conversation & stream (Hover card model and links) | E2E-SESSION-hover-card-model-and-links | | C — Conversation & stream (Chat file references) | E2E-CHAT-shorthand-file-ref-opens-the-matching-file, E2E-CHAT-file-ref-opens-the-surface-that-owns-it | +| C / Quality / Security (Conversation MP4 attachments) | E2E-CHAT-mp4-attachment-opens-in-system-player | | G — Plugins (Chat file references) | E2E-CHAT-file-ref-opens-the-surface-that-owns-it, E2E-PLUGIN-file-view-collapse-persists | | Quality (Chat file references) | E2E-CHAT-shorthand-file-ref-opens-the-matching-file, E2E-CHAT-file-ref-opens-the-surface-that-owns-it, E2E-PLUGIN-file-view-collapse-persists | | G — Plugins (project folder roots) | E2E-PLUGIN-file-view-switches-folder-per-project | @@ -8677,6 +8678,7 @@ must keep splitting are covered by `markdown-blocks.test.mjs`. | M6+ (Windows updater cache) | E2E-260 | | M6+ (Independent session communication) | E2E-SESSION-independent-top-level-communication, E2E-SESSION-hover-card-model-and-links | | M5 (Chat file references) | E2E-CHAT-shorthand-file-ref-opens-the-matching-file, E2E-CHAT-file-ref-opens-the-surface-that-owns-it | +| M5 (Conversation MP4 attachments) | E2E-CHAT-mp4-attachment-opens-in-system-player | | M6+ (Chat file references) | E2E-PLUGIN-file-view-collapse-persists | | M6+ (project folder roots) | E2E-PLUGIN-file-view-switches-folder-per-project | | Post-MVP | E2E-022A, E2E-022B, E2E-022C, E2E-024I, E2E-024J, E2E-024K, E2E-024L, E2E-024M (plugin roadmap R2/R3/R6) | @@ -11760,6 +11762,26 @@ are withdrawn with ADR 0165. shape of resolution produces); full UI journey Draft (run only in a capable environment when this surface changes) +#### E2E-CHAT-mp4-attachment-opens-in-system-player + +- **Preconditions**: A conversation has one pasted MP4 in session scratch and + two stored MP4 attachments whose content-addressed refs have no extension: + one larger than 512 KiB and one smaller binary file. All retain `video/mp4` + metadata. +- **Steps**: Click each attachment in the transcript, then choose **Open with + default application** in the host file tab. Repeat with a workspace `.mp4`. +- **Expected**: The large attachment reports that inline preview is unavailable + because of size and the small attachment reports binary content; both offer + the OS-open action. The host validates realpath containment and hands the OS + an `.mp4` alias of the same stored bytes. A file outside allowed roots or a + symlink escape is refused. Failed OS handoff is visible to the user. +- **Specs linked**: `03-runtime/01-ipc-protocol.md` § fs, + `04-ux/09-interaction-patterns.md` §8a.2. +- **Acceptance**: C (conversation & stream), Quality, Security +- **Milestone**: M5 +- **Status**: Electron fixture covered for pasted scratch MP4 and both blob sizes + (`test:e2e:composer-paste`); full installed-app/player journey Draft. + #### E2E-181: An imported skill is listed in the next session catalog - **Preconditions**: Settings > Agent > Skills is open. A conventional diff --git a/docs/zh-CN/spec/03-runtime/01-ipc-protocol.md b/docs/zh-CN/spec/03-runtime/01-ipc-protocol.md index 8aa9e0f16..2181b99e0 100644 --- a/docs/zh-CN/spec/03-runtime/01-ipc-protocol.md +++ b/docs/zh-CN/spec/03-runtime/01-ipc-protocol.md @@ -1393,7 +1393,7 @@ Chrome 和代理 CDP 位于随应用打包的 `pi.browser` 插件中,通过 `p - `fs/read({path, mimeType?})` → 文本 (≤512KB) / 图像数据 URL (≤5MB) / 二进制 / 太大。相对路径在工作区根内解析;`attachments/` 以及已位于工作区、`/scratch/` 或 `/attachments/` 下的绝对路径在 realpath 校验后也可读(D334 / ADR 0172);同一项目组中其他文件夹里的绝对路径同样可读(ADR 0249 §5、ADR 0263)。已知图片扩展名优先于 `mimeType`;无扩展名 blob 只接受图片 MIME 白名单。穿越、`~` 和其他逃逸被拒绝(`INVALID_ARGUMENT`)。 - `fs/readImageDataUrl({ref, mimeType?})` → `FsImageDataUrlResult`(`image` 带 `dataUrl`,或 `missing` / `notImage` / `tooLarge`)。包含范围与 `fs/read` 相同。从不返回非图片字节。仅渲染器使用,不是插件宿主 API。 - `fs/reveal({path})` → 在 Finder 中显示。包含范围与 `fs/read` 相同。 -- `fs/open({path})` → 用系统默认应用打开。词法包含范围与 `fs/read` 相同(读取额外做 realpath)。 +- `fs/open({path, mimeType?})` → 用系统默认应用打开已有的普通文件。与 `fs/read` 一样校验真实路径包含范围,拒绝通过符号链接逃逸。对于声明为 `video/mp4` 的无后缀 `attachments/` blob,宿主在私有应用数据目录建立 `.mp4` 符号链接后再交给系统,不复制视频字节。 - `fs/resolveRef({ref, sessionId?})` → `FsChatRefResolveResult`(`{ match: FsChatRefMatch | null }`,match 指出应答的 `root`(`workspace` / `scratch` / `attachments`)、相对该应答根的 `relativePath`、绝对路径 `absolutePath` 与 `matchedBy`(`exact-relative` / `exact-absolute` / `path-suffix` / `basename`),以及在 `workspace` 命中时给出的 `projectRoot`(`{ path, name, primary }`,指出是哪个文件夹应答的));`sessionId` 决定查哪个会话的临时目录。它补全智能体在聊天里打印的文件引用,因为渲染器看不到会话自己的临时目录:已经在某个已知根内指向真实文件的绝对引用直接胜出,`attachments/` blob 直接对附件库解析;否则按优先级顺序搜索各根——整个打开的项目、再会话自己的临时目录(`/scratch//`,ADR 0124)、最后附件库——第一个给出结果的根胜出。项目指的是打开的工作区背后的文件夹组(ADR 0249):主文件夹先应答,其余文件夹随后按项目组自身顺序搜索(ADR 0263),因此简写落在同级文件夹里和落在主文件夹里一样自然,命中结果也指出是哪个文件夹应答的。同一个根内精确路径优先于简写;简写之间最长匹配尾优先,其次路径更浅者。文件面板的忽略集合同样生效。什么都没匹配到时返回 `match: null`;解析本身不打开任何东西(ADR 0262)。 - `fs/list` 仍只限工作区;外面的遍历被拒绝(`INVALID_ARGUMENT`)。 diff --git a/docs/zh-CN/spec/04-ux/09-interaction-patterns.md b/docs/zh-CN/spec/04-ux/09-interaction-patterns.md index b8abd75dd..3362f4f2d 100644 --- a/docs/zh-CN/spec/04-ux/09-interaction-patterns.md +++ b/docs/zh-CN/spec/04-ux/09-interaction-patterns.md @@ -1025,6 +1025,7 @@ Mode/provider/model/permission/shell 配置和新提示仍然存在 从不解析序列化的 `@path` 文本。一旦回复内容开始,中止就会继续 部分抄本,不恢复草稿。 - 发送成功后,用户气泡仅把这些序列化的 `@path` 标记解析回与输入框一致的叶子名芯片用于展示。持久化消息和模型上下文仍是规范 `@path` 文本。点击芯片先经 `pi-desktop/fs/resolveRef` 补全引用——该通道搜索整个打开的项目,按项目组自身的文件夹顺序、主文件夹优先(ADR 0263)——再按解析结果打开:项目文件在随应用打包的 `pi.file-manager` 工作面板视图中打开(该视图不可用时退回宿主 `file:` 选项卡),会话临时目录或附件文件在宿主 `file:` 选项卡中打开,项目主文件夹中的 `.html`/`.htm` 页面仍在侧边浏览器中打开,因为侧边浏览器本就以该文件夹为根。交给工作面板的地址跟随应答的文件夹:主文件夹中的文件按项目内相对路径传递,同一项目的同级文件夹中的文件按绝对路径传递,与会话临时目录或附件文件一致。什么都没匹配到的芯片不打开任何东西,而是自己报告出来,右键这类芯片也一样;引用菜单里的那一条——发送的 `@path` 芯片、消息 Markdown 中的行内代码、本地链接与本地图片、工具行自己的文件路径、工具结果的文件列表或匹配列表中的路径、图片附件缩略图都提供同一条——经同一补全与同一寻址规则在系统文件管理器中显示该文件,并复制该文件的完整地址或项目相对地址(项目外的文件没有相对地址,菜单会直接说明)。系统默认应用不再由这次点击触发,该动作仍可从文件视图自己的右键菜单使用。 +- 在宿主 `file:` 选项卡中,对话 MP4 若因二进制内容或超过文本预览上限而无法预览,会提供“用系统默认应用打开”。带 `.mp4` 后缀的文件和附带 `video/mp4` 元数据的无后缀附件 blob 都适用。系统打开失败会显示错误;用户仍可在文件管理器中显示通过包含校验的文件。 ### 8a.3 打开时的键盘 diff --git a/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md b/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md index 5eb4f4533..4ace0192a 100644 --- a/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md +++ b/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md @@ -5424,6 +5424,7 @@ eleven-tool-round desktop paths are verified by | C — 对话和直播(导入可见性) | E2E-257 | | F——持久化(导入可见性) | E2E-257 | | C — 对话和直播(聊天文件引用) | E2E-CHAT-shorthand-file-ref-opens-the-matching-file、E2E-CHAT-file-ref-opens-the-surface-that-owns-it | +| C / 质量 / 安全(对话 MP4 附件) | E2E-CHAT-mp4-attachment-opens-in-system-player | | G——插件(聊天文件引用) | E2E-CHAT-file-ref-opens-the-surface-that-owns-it、E2E-PLUGIN-file-view-collapse-persists | | 品质(聊天文件引用) | E2E-CHAT-shorthand-file-ref-opens-the-matching-file、E2E-CHAT-file-ref-opens-the-surface-that-owns-it、E2E-PLUGIN-file-view-collapse-persists | | G——插件(项目文件夹根) | E2E-PLUGIN-file-view-switches-folder-per-project | @@ -5476,6 +5477,7 @@ eleven-tool-round desktop paths are verified by | M6+(Windows 更新缓存) | E2E-260 | | M6+(独立会话通信) | E2E-SESSION-independent-top-level-communication、E2E-SESSION-hover-card-model-and-links | | M5(聊天文件引用) | E2E-CHAT-shorthand-file-ref-opens-the-matching-file、E2E-CHAT-file-ref-opens-the-surface-that-owns-it | +| M5(对话 MP4 附件) | E2E-CHAT-mp4-attachment-opens-in-system-player | | M6+(聊天文件引用) | E2E-PLUGIN-file-view-collapse-persists | | M6+(项目文件夹根) | E2E-PLUGIN-file-view-switches-folder-per-project | | 后MVP | E2E-022A、E2E-022B、E2E-022C、E2E-024I、E2E-024J、E2E-024K、E2E-024L、E2E-024M(插件路线图 R2/R3/R6) | @@ -7070,6 +7072,16 @@ eleven-tool-round desktop paths are verified by - **里程碑**:M5 - **状态**:单元已覆盖(`apps/desktop/test/transcript-file-chips.test.mjs`);完整 UI 旅程仍为草稿(除非用户明确要求,否则不要在本地跑 E2E) +#### E2E-CHAT-mp4-attachment-opens-in-system-player + +- **前提条件**:对话中有一个粘贴到会话临时目录的 MP4,以及两个没有后缀、按内容哈希存储的 MP4 附件;后两者分别大于 512 KiB 和小于该上限,三者均保留 `video/mp4` 元数据。 +- **步骤**:点击每个对话附件,再在宿主文件选项卡选择“用系统默认应用打开”。对工作区内的 `.mp4` 文件重复操作。 +- **预期**:大附件提示体积超过内嵌预览上限,小附件提示二进制内容;二者均有系统打开操作。宿主校验真实路径包含范围,并为无后缀 blob 提供指向原始字节的 `.mp4` 别名。允许范围外的文件和符号链接逃逸均被拒绝;系统打开失败会提示用户。 +- **链接规格**:`03-runtime/01-ipc-protocol.md` § fs、`04-ux/09-interaction-patterns.md` §8a.2 +- **验收**:C(对话和直播)、质量、安全 +- **里程碑**:M5 +- **状态**:Electron 隔离测试已覆盖粘贴到临时目录的 MP4 及两种大小的 blob(`test:e2e:composer-paste`);安装版与系统播放器的完整旅程仍待验收。 + #### E2E-181:导入的技能会出现在下一个会话的目录里 - **前提条件**:设置 > 智能体 > 技能已打开。一份约定的 `/SKILL.md` 文档带有非 ASCII 的 frontmatter 名称和折叠 YAML 描述。同一项目上有一个空的 Agent 会话,技能将导入到该项目。 diff --git a/packages/i18n/src/locales/de/index.ts b/packages/i18n/src/locales/de/index.ts index 710ff14a3..1904836e1 100644 --- a/packages/i18n/src/locales/de/index.ts +++ b/packages/i18n/src/locales/de/index.ts @@ -1830,7 +1830,8 @@ sklm: { "back": "Zurück zu Dateien", "reveal": "Im Finder anzeigen", "binary": "Binärdatei – Vorschau nicht verfügbar", - "tooLarge": "Datei ist zu groß für die Vorschau" + "tooLarge": "Datei ist zu groß für die Vorschau", + "openFailed": "Die Datei konnte nicht mit der Standardanwendung geöffnet werden." } }, "palette": { diff --git a/packages/i18n/src/locales/en/index.ts b/packages/i18n/src/locales/en/index.ts index 8e1b7dc4b..dfefe1616 100644 --- a/packages/i18n/src/locales/en/index.ts +++ b/packages/i18n/src/locales/en/index.ts @@ -1849,6 +1849,7 @@ sklm: { reveal: "Reveal in Finder", binary: "Binary file — preview unavailable", tooLarge: "File is too large to preview", + openFailed: "Could not open the file with its default application.", }, }, palette: { diff --git a/packages/i18n/src/locales/es/index.ts b/packages/i18n/src/locales/es/index.ts index b1666c157..63cb48ed5 100644 --- a/packages/i18n/src/locales/es/index.ts +++ b/packages/i18n/src/locales/es/index.ts @@ -1830,7 +1830,8 @@ sklm: { "back": "Volver a los archivos", "reveal": "Mostrar en el Finder", "binary": "Archivo binario: vista previa no disponible", - "tooLarge": "El archivo es demasiado grande para obtener una vista previa" + "tooLarge": "El archivo es demasiado grande para obtener una vista previa", + "openFailed": "No se pudo abrir el archivo con la aplicación predeterminada." } }, "palette": { diff --git a/packages/i18n/src/locales/fr/index.ts b/packages/i18n/src/locales/fr/index.ts index 1bf9e2fd2..75e40f991 100644 --- a/packages/i18n/src/locales/fr/index.ts +++ b/packages/i18n/src/locales/fr/index.ts @@ -1830,7 +1830,8 @@ sklm: { "back": "Retour aux fichiers", "reveal": "Révéler dans le Finder", "binary": "Fichier binaire — aperçu indisponible", - "tooLarge": "Le fichier est trop volumineux pour être prévisualisé" + "tooLarge": "Le fichier est trop volumineux pour être prévisualisé", + "openFailed": "Impossible d’ouvrir le fichier avec l’application par défaut." } }, "palette": { diff --git a/packages/i18n/src/locales/ko/index.ts b/packages/i18n/src/locales/ko/index.ts index 66081e682..55067dc56 100644 --- a/packages/i18n/src/locales/ko/index.ts +++ b/packages/i18n/src/locales/ko/index.ts @@ -1846,6 +1846,7 @@ sklm: { reveal: "Finder에서 보기", binary: "바이너리 파일 — 미리 볼 수 없음", tooLarge: "파일이 너무 커서 미리 볼 수 없음", + openFailed: "기본 앱으로 파일을 열 수 없습니다.", }, }, palette: { diff --git a/packages/i18n/src/locales/pt-BR/index.ts b/packages/i18n/src/locales/pt-BR/index.ts index a57c86b0e..d713aa54b 100644 --- a/packages/i18n/src/locales/pt-BR/index.ts +++ b/packages/i18n/src/locales/pt-BR/index.ts @@ -1781,7 +1781,8 @@ export const ptBR = { back: "Voltar aos arquivos", reveal: "Revelar no Finder", binary: "Arquivo binário — pré-visualização indisponível", - tooLarge: "O arquivo é grande demais para pré-visualização" + tooLarge: "O arquivo é grande demais para pré-visualização", + openFailed: "Não foi possível abrir o arquivo com o aplicativo padrão." } }, palette: { diff --git a/packages/i18n/src/locales/tr/index.ts b/packages/i18n/src/locales/tr/index.ts index 6b73ca076..f422935fe 100644 --- a/packages/i18n/src/locales/tr/index.ts +++ b/packages/i18n/src/locales/tr/index.ts @@ -1836,6 +1836,7 @@ sklm: { reveal: "Finder’da göster", binary: "İkili dosya — önizleme yok", tooLarge: "Dosya önizlemek için çok büyük", + openFailed: "Dosya varsayılan uygulamayla açılamadı.", }, }, palette: { diff --git a/packages/i18n/src/locales/zh-CN/index.ts b/packages/i18n/src/locales/zh-CN/index.ts index 3dd81f3c6..f20603b53 100644 --- a/packages/i18n/src/locales/zh-CN/index.ts +++ b/packages/i18n/src/locales/zh-CN/index.ts @@ -1817,6 +1817,7 @@ sklm: { reveal: "在 Finder 中显示", binary: "二进制文件,无法预览", tooLarge: "文件过大,无法预览", + openFailed: "无法使用系统默认应用打开文件。", }, }, palette: { diff --git a/packages/i18n/src/locales/zh-TW/index.ts b/packages/i18n/src/locales/zh-TW/index.ts index 67bae8389..097e46c2a 100644 --- a/packages/i18n/src/locales/zh-TW/index.ts +++ b/packages/i18n/src/locales/zh-TW/index.ts @@ -1817,6 +1817,7 @@ sklm: { reveal: "在 Finder 中顯示", binary: "二進位制檔案,無法預覽", tooLarge: "檔案過大,無法預覽", + openFailed: "無法使用系統預設應用程式開啟檔案。", }, }, palette: { diff --git a/scripts/e2e-composer-paste.mjs b/scripts/e2e-composer-paste.mjs index 70e44a736..0ddd0e337 100644 --- a/scripts/e2e-composer-paste.mjs +++ b/scripts/e2e-composer-paste.mjs @@ -37,6 +37,7 @@ try { entryPoints: { writer: join(root, "apps/desktop/electron/main/composer-paste.ts"), reader: join(root, "packages/host-runtime/src/workspace-files.ts"), + video: join(root, "apps/desktop/electron/main/open-attachment-video.ts"), }, outdir: temp, outExtension: { ".js": ".cjs" }, @@ -69,10 +70,14 @@ const path = require("node:path"); const fs = require("node:fs/promises"); const assert = require("node:assert/strict"); const { saveComposerPasteFiles } = require("./writer.cjs"); -const { readOpenableFile, readOpenableImage } = require("./reader.cjs"); +const { readOpenableFile, readOpenableImage, resolveRealOpenablePath } = require("./reader.cjs"); +const { openableMp4Path } = require("./video.cjs"); app.setPath("userData", path.join(__dirname, "profile")); const saved = []; const history = []; +const opened = []; +let completeOpens; +const opensDone = new Promise(resolve => { completeOpens = resolve; }); ipcMain.handle("pi-desktop/composer/pasteFiles", async (_event, input) => { const files = await saveComposerPasteFiles(__dirname, input.sessionId, input.files); for (let i = 0; i < files.length; i++) { @@ -96,13 +101,41 @@ ipcMain.handle("pi-desktop/fs/readImageDataUrl", async (_event, input) => ({ })); ipcMain.handle("pi-desktop/fs/read", async (_event, input) => ({ ok: true, - data: await readOpenableFile(input.path, null, [path.join(__dirname, "scratch")], input.mimeType), + data: input.path === "untrusted.sh" + ? { kind: "tooLarge", size: 512 * 1024 + 1 } + : await readOpenableFile(input.path, null, [path.join(__dirname, "scratch"), path.join(__dirname, "attachments")], input.mimeType), })); +ipcMain.handle("pi-desktop/fs/resolveRef", async (_event, input) => { + const scratchRoot = path.join(__dirname, "scratch") + path.sep; + if (input.ref.startsWith(scratchRoot)) { + return { ok: true, data: { match: { + root: "scratch", relativePath: path.relative(scratchRoot, input.ref), + absolutePath: input.ref, matchedBy: "exact-absolute", + } } }; + } + const hash = input.ref.startsWith("attachments/") ? input.ref.slice("attachments/".length) : ""; + assert(/^[ab]{64}$/.test(hash), "unexpected MP4 attachment reference"); + return { ok: true, data: { match: { + root: "attachments", relativePath: hash, + absolutePath: path.join(__dirname, "attachments", hash), matchedBy: "exact-relative", + } } }; +}); +ipcMain.handle("pi-desktop/fs/open", async (_event, input) => { + const target = await resolveRealOpenablePath(input.path, null, [path.join(__dirname, "scratch"), path.join(__dirname, "attachments")]); + assert(target, "host rejected the MP4 attachment path"); + const openPath = await openableMp4Path(__dirname, target, input.mimeType); + opened.push(openPath); + if (opened.length === 3) completeOpens(); + return { ok: true, data: { ok: true } }; +}); ipcMain.handle("pi-desktop/clipboard/recordPaste", (_event, input) => { history.push(input.text); return { ok: true, data: null }; }); app.whenReady().then(async () => { + await fs.mkdir(path.join(__dirname, "attachments")); + await fs.writeFile(path.join(__dirname, "attachments", "a".repeat(64)), Buffer.alloc(512 * 1024 + 1)); + await fs.writeFile(path.join(__dirname, "attachments", "b".repeat(64)), Buffer.from([0, 1, 2, 0])); const window = new BrowserWindow({ show: false, webPreferences: { preload: ${JSON.stringify(join(root, "apps/desktop/out/preload/index.cjs"))}, sandbox: true, contextIsolation: true, nodeIntegration: false, backgroundThrottling: false, @@ -141,11 +174,16 @@ app.whenReady().then(async () => { }); await window.webContents.executeJavaScript('globalThis.composerPreviewPointer = input => new Promise(resolve => { globalThis.composerPreviewPointerDone = resolve; console.log("PI_PREVIEW_POINTER:" + JSON.stringify(input)); }); void 0'); const result = await window.webContents.executeJavaScript("globalThis.composerPasteProbe()"); + await opensDone; + assert.deepEqual(opened.map(path.extname), [".mp4", ".mp4", ".mp4"]); + assert.equal(await fs.realpath(opened[0]), await fs.realpath(path.join(__dirname, "attachments", "a".repeat(64)))); + assert.equal(await fs.realpath(opened[1]), await fs.realpath(path.join(__dirname, "attachments", "b".repeat(64)))); + assert.equal(opened[2], saved.find(entry => entry.mimeTypes.includes("video/mp4"))?.files[0].path); const mimeSets = saved.map((entry) => entry.mimeTypes.join("+")); assert.equal( saved.length, - 5, - "unexpected scratch writes (large text, image-only, native image, empty image-only, native files): " + JSON.stringify(mimeSets), + 6, + "unexpected scratch writes (large text, MP4, image-only, native image, empty image-only, native files): " + JSON.stringify(mimeSets), ); assert.deepEqual( mimeSets.filter((mimes) => mimes === "text/plain"), @@ -157,6 +195,7 @@ app.whenReady().then(async () => { 4, "image-only and native-file pastes must keep writing image bytes: " + JSON.stringify(mimeSets), ); + assert.equal(mimeSets.filter((mimes) => mimes === "video/mp4").length, 1); assert(history.some(text => text.includes("Word paragraph")), "short text missing from clipboard history"); console.log("COMPOSER_PASTE_PROBE " + JSON.stringify({ ...result, scratchBytesVerified: true })); app.quit(); diff --git a/scripts/e2e/composer-paste.tsx b/scripts/e2e/composer-paste.tsx index 1bcbc3bc6..240a9b1fa 100644 --- a/scripts/e2e/composer-paste.tsx +++ b/scripts/e2e/composer-paste.tsx @@ -26,6 +26,8 @@ import { } from "../../apps/desktop/src/features/chat/composer/editor"; import { api } from "../../apps/desktop/src/lib/api"; import { FilesTab } from "../../apps/desktop/src/components/workpanel/FilesTab"; +import { FileRefChip } from "../../apps/desktop/src/features/chat/transcript/shared"; +import { useOpenChatFileRef } from "../../apps/desktop/src/hooks/use-preview-target"; import { readComposerDraft, resetComposerDraftCache, @@ -108,6 +110,27 @@ function Fixture({ sessionId, t, workspacePath }: { sessionId: string; t: TFunct ); } +function Mp4AttachmentChips({ scratchPath }: { scratchPath: string }) { + const openFile = useOpenChatFileRef(); + return ( +
+ {[ + { name: "a.mp4", path: `attachments/${"a".repeat(64)}` }, + { name: "b.mp4", path: `attachments/${"b".repeat(64)}` }, + { name: "scratch.mp4", path: scratchPath }, + ].map((video) => ( + + ))} +
+ ); +} + globalThis.composerPasteProbe = async () => { const i18n = createInstance(); await i18n.init({ @@ -240,7 +263,11 @@ globalThis.composerPasteProbe = async () => { await pendingPaste; assert(controller.value === "", "pending paste changed the destination draft"); render("paste-a"); - await new Promise(requestAnimationFrame); + const restoreDeadline = performance.now() + 3000; + while (!controller.fileReferences.some((reference) => reference.name === "new.txt") && + performance.now() < restoreDeadline) { + await new Promise(requestAnimationFrame); + } const names = controller.fileReferences.map((r) => r.name); assert(names.includes("original.txt") && names.includes("new.txt"), "PENDING_PASTE_SESSION_SWITCH lost original attachment: " + JSON.stringify({ names, text: readEditorValue(controller.ref.current!), visible: controller.ref.current!.textContent })); @@ -389,20 +416,36 @@ globalThis.composerPasteProbe = async () => { `prefix ${controller.fileReferences[0].token} suffix`, "large text chip lost the selection boundary", ); + const longTextPath = controller.fileReferences[0].path; + await paste("", [new File([new Uint8Array(512 * 1024 + 1)], "clip.mp4", { + type: "video/mp4", + })]); + assert(controller.fileReferences.length === 1 && + controller.fileReferences[0].mimeType === "video/mp4" && + controller.fileReferences[0].path.endsWith(".mp4"), + "a pasted MP4 did not retain its playable scratch filename"); + const scratchVideoPath = controller.fileReferences[0].path; // Preview the persisted long-text attachment through the public work-panel // entry point, with no project open (the temporary-task user path). const previewHost = document.createElement("div"); document.body.append(previewHost); const previewRoot = createRoot(previewHost); + const originalFsOpen = api.fsOpen; + const openRequests: Array> = []; + api.fsOpen = (path: string, mimeType?: string) => { + const request = originalFsOpen(path, mimeType); + openRequests.push(request); + return request; + }; try { flushSync(() => previewRoot.render( - , + , )); assert(previewHost.textContent?.includes(i18n.t("panel.files.noWorkspace")), "file browsing without a project should show the empty state"); flushSync(() => useAppStore.getState().openFileInWorkPanel( - controller.fileReferences[0].path, "text/plain", + longTextPath, "text/plain", )); const deadline = performance.now() + 3000; while (!previewHost.querySelector(".file-viewer-code") && performance.now() < deadline) { @@ -410,6 +453,39 @@ globalThis.composerPasteProbe = async () => { } assert(previewHost.querySelector(".file-viewer-code")?.textContent === longText, "temporary-task attachment did not display its saved text in the file preview"); + for (const [index, [hash, expected]] of [ + ["a", i18n.t("panel.files.tooLarge")], + ["b", i18n.t("panel.files.binary")], + ["scratch", i18n.t("panel.files.tooLarge")], + ].entries()) { + const chip = previewHost.querySelector( + `.mp4-attachment-chips [aria-label^="${hash}.mp4"]`, + ); + assert(chip, `MP4 attachment chip is missing: ${hash}`); + flushSync(() => chip!.click()); + const deadline = performance.now() + 3000; + while (!previewHost.textContent?.includes(expected) && performance.now() < deadline) { + await new Promise(requestAnimationFrame); + } + assert(previewHost.textContent?.includes(expected), + `MP4 attachment did not reach its expected preview state: ${hash}`); + const open = Array.from(previewHost.querySelectorAll("button")) + .find((button) => button.textContent === i18n.t("chat.openFile")); + assert(open, `MP4 attachment has no system-player action: ${hash}`); + flushSync(() => open!.click()); + assert(openRequests.length === index + 1, `MP4 open did not reach IPC: ${hash}`); + await openRequests[index]; + } + flushSync(() => useAppStore.getState().openFileInWorkPanel("untrusted.sh", "video/mp4")); + const unsafeDeadline = performance.now() + 3000; + while ((previewHost.querySelector(".file-viewer-path")?.textContent !== "untrusted.sh" || + !previewHost.textContent?.includes(i18n.t("panel.files.tooLarge"))) && + performance.now() < unsafeDeadline) { + await new Promise(requestAnimationFrame); + } + assert(!Array.from(previewHost.querySelectorAll("button")) + .some((button) => button.textContent === i18n.t("chat.openFile")), + "a spoofed video MIME must not offer an OS-open action for a script"); const back = previewHost.querySelector( `[aria-label="${i18n.t("panel.files.back")}"]`, ); @@ -418,6 +494,7 @@ globalThis.composerPasteProbe = async () => { assert(previewHost.textContent?.includes(i18n.t("panel.files.noWorkspace")), "back from a temporary attachment should restore the no-project empty state"); } finally { + api.fsOpen = originalFsOpen; flushSync(() => previewRoot.unmount()); previewHost.remove(); } @@ -480,7 +557,7 @@ globalThis.composerPasteProbe = async () => { assert(dialog()!.contains(document.activeElement), "modal allowed background input focus"); button(i18n.t("chat.imagePreview.fit")).focus(); await globalThis.composerPreviewPressKey("Tab"); - assert(dialog()!.contains(document.activeElement), "Tab escaped the preview"); + await until(() => dialog()?.contains(document.activeElement), "Tab escaped the preview"); assert(preview.naturalWidth === 1 && preview.getBoundingClientRect().width === 1, "small image must not be stretched to fill the window"); assert(!useAppStore.getState().workPanelOpen, "preview opened the work panel"); @@ -808,6 +885,7 @@ globalThis.composerPasteProbe = async () => { crossBreakAndChipSelection: true, mixedLongText: true, temporaryTaskTextPreview: true, + mp4AttachmentOpen: true, imageOnly: true, nativeImageFile: true, imagePreviewAndKeyboard: true,