From c5f449f29926b1027d50d74175e4ac9256750538 Mon Sep 17 00:00:00 2001 From: dibin Date: Sun, 11 Oct 2026 16:13:03 +0800 Subject: [PATCH] Upload any file from the key bar beside the image button Agents can read more than images: logs, PDFs, archives and source files are just as useful in a prompt, but the key bar could only send pictures. A new paperclip button next to the image button sends any file up to 3 MB as is, and the host saves it under its UUID plus a sanitized name and pastes the path back, exactly like an image. Files travel as paste_file with MIME application/octet-stream and a name. The relay and the host both sanitize the name so it can never act as a path and needs no shell quoting. Hosts announce paste_any_file; the relay refuses files for older hosts up front instead of letting them fail after upload. --- README.md | 2 +- README.zh-CN.md | 2 +- docs/protocol.md | 31 ++- packages/cli/src/connector/host-connector.ts | 11 +- packages/cli/src/connector/host-hello.ts | 1 + packages/cli/src/pasted-files.ts | 23 ++- packages/cli/tests/pasted-files.test.js | 56 +++++- packages/relay/src/protocol/messages.ts | 6 + packages/relay/src/protocol/paste.ts | 39 +++- packages/relay/src/server/client-channel.ts | 64 +++---- packages/relay/src/server/host-channel.ts | 1 + packages/relay/src/server/types.ts | 2 + packages/relay/tests/paste-file.test.js | 88 +++++++++ .../relay/web/src/connection/clientAdapter.ts | 3 +- .../relay/web/src/context/TerminalContext.tsx | 30 +-- .../relay/web/src/context/useImageUpload.ts | 173 +++++++++++------ .../relay/web/src/context/useTerminalInput.ts | 4 +- packages/relay/web/src/context/values.ts | 4 +- .../web/src/features/keyboard/KeyLabel.tsx | 1 + .../web/src/features/keyboard/KeyToolbar.tsx | 21 +- .../src/features/keyboard/UploadControls.tsx | 62 ++++-- .../keyboard/keyToolbarFileUpload.test.tsx | 179 ++++++++++++++++++ .../keyboard/keyToolbarImageUpload.test.tsx | 6 +- .../web/src/features/paste/imagePaste.ts | 4 + packages/relay/web/src/shared/i18n/en.ts | 10 +- packages/relay/web/src/shared/i18n/zh.ts | 9 +- 26 files changed, 662 insertions(+), 170 deletions(-) create mode 100644 packages/relay/web/src/features/keyboard/keyToolbarFileUpload.test.tsx diff --git a/README.md b/README.md index f603d5c..74d1c23 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ The first run launches a setup wizard, after which the services run in the backg - **Context-Aware Agent Shortcuts**: Key bar dynamically adapts to the active agent (supporting 24 agents including Claude Code, Codex, and Gemini CLI), with customizable order and keybindings. - **Native Terminal Styling**: Preserves workstation terminal color schemes, fonts, and sizes, with dynamic font streaming and predictive echo on high-latency links. The interface ships with Maple Mono NF CN, so Chinese and Japanese text stays exactly two cells wide on any device. - **Windows Shell Support**: Key bar recognizes CMD, PowerShell, and Git Bash, with quick creation of elevated terminal tabs in Herdr. -- **Mobile Image Uploads**: Send photos or screenshots from your phone directly to the workstation, inserting local file paths straight into the agent prompt. +- **Mobile Image & File Uploads**: Send photos, screenshots or any file (up to 3 MB) from your phone directly to the workstation, inserting local file paths straight into the agent prompt. - **Flexible Connection Modes**: Supports local-only, LAN / Tailscale, official public relay, or self-hosted relays. - **Multi-Workstation Management**: Manage and switch between multiple Herdr workstations from a single browser session, with an operator dashboard for relay administrators. - **Cross-Platform**: Runs on Linux, macOS, and Windows 10/11 with a bilingual TUI and background service supervisors. diff --git a/README.zh-CN.md b/README.zh-CN.md index 082ac4b..f7b9118 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -55,7 +55,7 @@ herdr-remote - **智能体专属快捷键**:按键栏自动跟随当前窗格中的智能体(支持 Claude Code、Codex、Gemini CLI 等 24 种),支持调整顺序与重新绑定。 - **还原工作站终端体验**:自动沿用工作站终端的配色、字体与字号,支持字体动态流式分发与预测回显;界面内置 Maple Mono NF CN,任何设备上中日文都严格占两格。 - **Windows Shell 支持**:按键栏自动识别 CMD、PowerShell 和 Git Bash,支持在 Herdr 中快速新建提权终端标签页。 -- **移动端图片直传**:手机上的照片或截图可直接保存到工作站,并将文件路径自动填入当前智能体的输入框。 +- **移动端图片与文件直传**:手机上的照片、截图或任意文件(不超过 3 MB)可直接保存到工作站,并将文件路径自动填入当前智能体的输入框。 - **灵活连接模式**:支持本机访问、局域网 / Tailscale、官方公共 Relay 或自建私有 Relay。 - **多工作站管理**:单浏览器统一管理多台工作站实例,提供 Relay 运维管理面板。 - **跨平台支持**:支持 Linux、macOS 与 Windows 10/11,提供中英双语 TUI 与后台守护服务。 diff --git a/docs/protocol.md b/docs/protocol.md index 0805e0f..0c9c1f4 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -409,9 +409,9 @@ persisted by the relay. ## Pasted image files -Browsers can transfer clipboard images to the workstation. Because PTY streams only consume text, -the image payload is saved to host state storage and the resulting local file path is pasted -into the active terminal. +Browsers can transfer clipboard images, and files of any type picked from the key bar, to the +workstation. Because PTY streams only consume text, the payload is saved to host state storage +and the resulting local file path is pasted into the active terminal. ### Client to relay (`paste_file`) @@ -425,16 +425,22 @@ Sent as a JSON message over `/ws/client`: } ``` -- Allowed MIME types: `image/png`, `image/jpeg`, `image/webp`, `image/gif`. -- Size limit: 3 MB decoded raw binary (base64 payload must not exceed ~4 MB). +- Allowed MIME types: `image/png`, `image/jpeg`, `image/webp`, `image/gif`, and + `application/octet-stream` for a file of any type. Only a file carries `name`, its own file + name, which is never used as a path (see below). +- Size limit: 3 MB decoded raw binary (base64 payload must not exceed ~4 MB). The browser + compresses an image to fit; it refuses a file over the limit without sending it. - Error codes returned on client error: - `paste_file_unsupported`: MIME type is not in the whitelist or payload is malformed. - `paste_file_too_large`: Decoded payload exceeds 3 MB. The connection remains open. + - `paste_file_host_outdated`: A file was sent to a host that does not advertise the + `paste_any_file` capability, i.e. one from before file uploads. ### Relay to host -The relay validates the MIME whitelist and 3 MB payload cap, stamps the message with the -originating client's `streamId`, and forwards it over `/ws/host`: +The relay validates the MIME whitelist, the 3 MB payload cap and an image's magic bytes, +replaces a file's `name` with its sanitized form (`sanitizePasteFileName`), stamps the message +with the originating client's `streamId`, and forwards it over `/ws/host`: ```json { @@ -450,15 +456,20 @@ originating client's `streamId`, and forwards it over `/ws/host`: The host connector performs strict independent security checks before writing: 1. **Pinned directory**: Files are written strictly to `/pasted` (directory mode `0o700`). -2. **Unpredictable filename**: Generated via `crypto.randomUUID()`; client-supplied names are never used. -3. **Whitelisted extension**: Derived directly from the validated MIME (`.png`, `.jpg`, `.webp`, `.gif`). +2. **Unpredictable filename**: Generated via `crypto.randomUUID()`. An image ignores any client-supplied + name; a file is saved as `-`, its name sanitized again on the host: no directories, no + leading dots, only letters, digits, `.`, `_` and `-`, at most 120 characters — so the pasted path + needs no shell quoting. +3. **Whitelisted extension**: An image's is derived directly from the validated MIME (`.png`, `.jpg`, + `.webp`, `.gif`); a file keeps the extension of its sanitized name. 4. **Host size re-validation**: The host independently enforces the 3 MB ceiling. 5. **Magic bytes sniffing**: Raw binary headers are validated against declared MIME: - PNG: `89 50 4E 47` - JPEG: `FF D8 FF` - GIF: `47 49 46 38` - WebP: `RIFF` (bytes 0..3) and `WEBP` (bytes 8..11) - Mismatches are rejected to prevent file-type spoofing. + Mismatches are rejected to prevent file-type spoofing. Files of any type are not sniffed: + they claim no type to spoof. 6. **File mode**: Created with mode `0o600`. 7. **Retention and pruning**: Caps at 20 files and 50 MB (oldest deleted first). Residual files older than 24 hours are deleted upon startup. diff --git a/packages/cli/src/connector/host-connector.ts b/packages/cli/src/connector/host-connector.ts index 4bbc261..9a10a5e 100644 --- a/packages/cli/src/connector/host-connector.ts +++ b/packages/cli/src/connector/host-connector.ts @@ -391,9 +391,16 @@ export class HostConnector { return this.sessions.start(message); } - private savePaste(streamId: string | null, message: { mime: string; dataBase64: string }): void { + private savePaste( + streamId: string | null, + message: { mime: string; dataBase64: string; name?: string }, + ): void { try { - const savedPath = savePastedFile({ mime: message.mime, dataBase64: message.dataBase64 }); + const savedPath = savePastedFile({ + mime: message.mime, + dataBase64: message.dataBase64, + name: message.name, + }); sendJson(this.ws, { type: 'paste_file_ready', clientId: streamId, path: savedPath }); } catch (error) { sendJson(this.ws, { diff --git a/packages/cli/src/connector/host-hello.ts b/packages/cli/src/connector/host-hello.ts index b4df5fe..2df2cbe 100644 --- a/packages/cli/src/connector/host-hello.ts +++ b/packages/cli/src/connector/host-hello.ts @@ -37,6 +37,7 @@ export function hostHello(identity: HostHelloIdentity) { CAPABILITY.hostHandoff, CAPABILITY.idleHeartbeat, CAPABILITY.binaryFrameV2, + CAPABILITY.pasteAnyFile, ...(process.platform === 'win32' ? [CAPABILITY.adminTabs] : []), ], }; diff --git a/packages/cli/src/pasted-files.ts b/packages/cli/src/pasted-files.ts index 85c42ed..4a7205e 100644 --- a/packages/cli/src/pasted-files.ts +++ b/packages/cli/src/pasted-files.ts @@ -1,12 +1,14 @@ /** - * Secure file storage and validation for pasted clipboard images. + * Secure file storage and validation for pasted clipboard images and uploaded files. * * Security requirements: * 1. Pinned directory: writes strictly to path.join(stateDir(), 'pasted') (directory mode 0o700). - * 2. Unpredictable filename: generated via crypto.randomUUID(), never using client-provided strings. - * 3. Whitelisted extension: derived exclusively from verified MIME type. + * 2. Unpredictable filename: starts with crypto.randomUUID(); a client-provided name is only + * appended to a file's UUID after sanitizePasteFileName, never used as a path. + * 3. Whitelisted extension: an image's is derived exclusively from its verified MIME type. * 4. Host-side size check: re-validated to enforce <= 3 MB independently of relay checks. - * 5. Magic bytes sniffing: inspects raw binary headers (PNG, JPEG, GIF, WebP) to prevent file-type spoofing. + * 5. Magic bytes sniffing: inspects raw binary headers (PNG, JPEG, GIF, WebP) to prevent image + * type spoofing. Files of any type are not sniffed: nothing claims what they contain. * 6. File mode: files are created with mode 0o600. * 7. Automatic pruning: caps at 20 files and 50 MB (oldest deleted first), removes >24h stale files at startup. */ @@ -17,10 +19,12 @@ import path from 'node:path'; import { stateDir } from './paths.js'; import { ensureDir } from 'herdr-remote-relay/state'; import { + PASTE_FILE_MIME, PASTE_MAX_BYTES, PASTE_IMAGE_EXTENSIONS, isPasteImageMime, hasImageSignature, + sanitizePasteFileName, } from 'herdr-remote-relay/protocol'; const MAX_SAVED_FILES = 20; @@ -98,13 +102,16 @@ function cleanPastedDir({ function savePastedFile({ mime, dataBase64, + name, dir = getPastedDir(), }: { mime: unknown; dataBase64: unknown; + name?: unknown; dir?: string; }): string { - if (!isPasteImageMime(mime)) { + const imageMime = isPasteImageMime(mime) ? mime : null; + if (!imageMime && mime !== PASTE_FILE_MIME) { throw new Error(`unsupported MIME type: ${mime}`); } if (typeof dataBase64 !== 'string') { @@ -119,14 +126,16 @@ function savePastedFile({ throw new Error('file payload is empty'); } - if (!hasImageSignature(mime, buf)) { + if (imageMime && !hasImageSignature(imageMime, buf)) { throw new Error(`file magic bytes do not match declared MIME type ${mime}`); } // Prune before writing new file cleanPastedDir({ dir }); - const fileName = `${crypto.randomUUID()}${PASTE_IMAGE_EXTENSIONS[mime]}`; + const fileName = imageMime + ? `${crypto.randomUUID()}${PASTE_IMAGE_EXTENSIONS[imageMime]}` + : `${crypto.randomUUID()}-${sanitizePasteFileName(name)}`; const filePath = path.join(dir, fileName); fs.writeFileSync(filePath, buf, { mode: 0o600, flag: 'wx' }); diff --git a/packages/cli/tests/pasted-files.test.js b/packages/cli/tests/pasted-files.test.js index 143fa6d..74f22d7 100644 --- a/packages/cli/tests/pasted-files.test.js +++ b/packages/cli/tests/pasted-files.test.js @@ -4,7 +4,12 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { savePastedFile, cleanPastedDir } from '../src/pasted-files.js'; -import { PASTE_MAX_BYTES, PASTE_IMAGE_EXTENSIONS } from 'herdr-remote-relay/protocol'; +import { + PASTE_FILE_NAME_MAX_CHARS, + PASTE_IMAGE_EXTENSIONS, + PASTE_MAX_BYTES, + sanitizePasteFileName, +} from 'herdr-remote-relay/protocol'; // Valid magic byte fixtures for each supported image type const FIXTURES = { @@ -199,3 +204,52 @@ test('cleanPastedDir purges stale files older than 24 hours', (t) => { assert.equal(fs.existsSync(freshPath), true, 'fresh file must remain'); assert.equal(fs.existsSync(stalePath), false, 'stale file >24h must be deleted'); }); + +test('savePastedFile saves any file under its UUID and sanitized name, without sniffing', (t) => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'host-paste-test-')); + t.onTestFinished(() => fs.rmSync(tmpDir, { recursive: true, force: true })); + + const content = Buffer.from('#!/bin/sh\necho not an image\n'); + const savedPath = savePastedFile({ + mime: 'application/octet-stream', + dataBase64: content.toString('base64'), + name: '..\\..\\Windows/.bashrc copy.sh', + dir: tmpDir, + }); + + assert.equal(path.dirname(savedPath), tmpDir); + assert.match( + path.basename(savedPath), + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}-bashrc_copy\.sh$/i, + ); + assert.deepEqual(fs.readFileSync(savedPath), content); + if (process.platform !== 'win32') { + assert.equal(fs.statSync(savedPath).mode & 0o777, 0o600); + } +}); + +test('sanitizePasteFileName keeps a pasteable name and its extension', () => { + assert.equal(sanitizePasteFileName('报告 2026.pdf'), '报告_2026.pdf'); + assert.equal(sanitizePasteFileName('a;rm -rf ~;$(x).txt'), 'a_rm_-rf_x_.txt'); + assert.equal(sanitizePasteFileName('...'), 'file'); + assert.equal(sanitizePasteFileName(undefined), 'file'); + const long = sanitizePasteFileName(`${'x'.repeat(300)}.tar.gz`); + assert.equal(long.length, PASTE_FILE_NAME_MAX_CHARS); + assert.ok(long.endsWith('.gz')); +}); + +test('savePastedFile still caps any file at the paste size limit', (t) => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'host-paste-test-')); + t.onTestFinished(() => fs.rmSync(tmpDir, { recursive: true, force: true })); + assert.throws( + () => + savePastedFile({ + mime: 'application/octet-stream', + dataBase64: Buffer.alloc(PASTE_MAX_BYTES + 1).toString('base64'), + name: 'big.bin', + dir: tmpDir, + }), + /exceeds maximum limit/, + ); + assert.deepEqual(fs.readdirSync(tmpDir), []); +}); diff --git a/packages/relay/src/protocol/messages.ts b/packages/relay/src/protocol/messages.ts index bec248e..742d0ac 100644 --- a/packages/relay/src/protocol/messages.ts +++ b/packages/relay/src/protocol/messages.ts @@ -21,6 +21,8 @@ export const CAPABILITY = { binaryFrameV2: 'binary_frame_v2', /** The host can open an elevated PowerShell as a tab of its Windows Herdr. */ adminTabs: 'admin_tabs', + /** The host saves pasted files of any type, not just images. */ + pasteAnyFile: 'paste_any_file', } as const; export type ClientRole = 'controller' | 'viewer'; @@ -64,6 +66,8 @@ export interface ClientPasteFileMessage { type: 'paste_file'; mime: string; dataBase64: string; + /** The file's own name; only read when `mime` is `PASTE_FILE_MIME`. */ + name?: string; } /** @@ -470,6 +474,8 @@ export interface RelayPasteFileMessage extends StreamAddressed { type: 'paste_file'; mime: string; dataBase64: string; + /** Sanitized file name, present only when `mime` is `PASTE_FILE_MIME`. */ + name?: string; } /** Sent instead of `host_ready` when the handshake is refused. */ diff --git a/packages/relay/src/protocol/paste.ts b/packages/relay/src/protocol/paste.ts index 200dfbb..96020d7 100644 --- a/packages/relay/src/protocol/paste.ts +++ b/packages/relay/src/protocol/paste.ts @@ -1,10 +1,41 @@ -// Rules for an image pasted in a browser: the browser compresses to fit them, -// the relay refuses what breaks them, and the host checks again before it -// writes a file. Browser-safe: no Node APIs. +// Rules for an image or file pasted in a browser: the browser compresses or +// refuses to fit them, the relay refuses what breaks them, and the host checks +// again before it writes a file. Browser-safe: no Node APIs. -/** Largest decoded image a browser may paste. */ +/** Largest decoded image or file a browser may paste. */ export const PASTE_MAX_BYTES = 3 * 1024 * 1024; +/** + * The MIME a paste carries when it is any file rather than an image. Its bytes + * are never sniffed, so it is saved under its (sanitized) name, not a type. + */ +export const PASTE_FILE_MIME = 'application/octet-stream'; + +/** Longest file name, in characters, a pasted file keeps on the host. */ +export const PASTE_FILE_NAME_MAX_CHARS = 120; + +/** + * A pasted file's name made safe to save and to paste into a shell unquoted: + * no directories, no hidden files, nothing but letters, digits, `.`, `_` and + * `-`. A name with nothing left becomes `file`. + */ +export function sanitizePasteFileName(name: unknown): string { + if (typeof name !== 'string') return 'file'; + const base = name.normalize('NFC').split(/[\\/]/).pop() ?? ''; + let safe = base + .replace(/[^\p{L}\p{N}._-]+/gu, '_') + // Leading dots would hide the file; trailing ones Windows drops silently. + .replace(/^[._]+|[._]+$/g, ''); + if ([...safe].length > PASTE_FILE_NAME_MAX_CHARS) { + const dot = safe.lastIndexOf('.'); + // Keep a short extension: it is what tells an agent what the file is. + const ext = dot > 0 && safe.length - dot <= 16 ? safe.slice(dot) : ''; + const stem = [...safe.slice(0, safe.length - ext.length)]; + safe = stem.slice(0, PASTE_FILE_NAME_MAX_CHARS - ext.length).join('') + ext; + } + return safe || 'file'; +} + /** Image types a paste may carry, with the extension the host saves them under. */ export const PASTE_IMAGE_EXTENSIONS = { 'image/png': '.png', diff --git a/packages/relay/src/server/client-channel.ts b/packages/relay/src/server/client-channel.ts index 2913924..afb8052 100644 --- a/packages/relay/src/server/client-channel.ts +++ b/packages/relay/src/server/client-channel.ts @@ -8,12 +8,14 @@ import { type ClientHelloMessage, type ClientJsonMessage, FRAME_TYPE_INPUT, + PASTE_FILE_MIME, PASTE_MAX_BYTES, PROTOCOL_VERSION, hasImageSignature, isPasteImageMime, packStreamFrame, packStreamFrameV2, + sanitizePasteFileName, } from '../protocol/index.js'; import { requestFontChunk, requestFontSubset } from './font-proxy.js'; import { allowClientHandshake, allowPairAttempt, clientAddress } from './requests.js'; @@ -350,69 +352,46 @@ function handleClientMessage( // so beats a silence an older client would wait on. jsonSend(client.ws, { type: 'control_state', role: 'controller', controllerId: null }); } else if (message.type === 'paste_file') { + const reject = (code: string, text: string) => + jsonSend(client.ws, { type: 'error', code, message: text }); if (client.role === 'viewer') { - jsonSend(client.ws, { - type: 'error', - code: 'viewer_mode', - message: 'Viewer mode cannot paste to terminal', - }); + reject('viewer_mode', 'Viewer mode cannot paste to terminal'); return; } - if (!isPasteImageMime(message.mime)) { - jsonSend(client.ws, { - type: 'error', - code: 'paste_file_unsupported', - message: 'Unsupported paste image format', - }); + const isFile = message.mime === PASTE_FILE_MIME; + if (!isFile && !isPasteImageMime(message.mime)) { + reject('paste_file_unsupported', 'Unsupported paste image format'); return; } if (typeof message.dataBase64 !== 'string') { - jsonSend(client.ws, { - type: 'error', - code: 'paste_file_unsupported', - message: 'Invalid paste image payload', - }); + reject('paste_file_unsupported', 'Invalid paste payload'); return; } const rawLength = Buffer.byteLength(message.dataBase64, 'base64'); if (rawLength === 0) { - jsonSend(client.ws, { - type: 'error', - code: 'paste_file_empty', - message: 'Pasted image payload is empty', - }); + reject('paste_file_empty', 'Pasted payload is empty'); return; } if (rawLength > PASTE_MAX_BYTES) { - jsonSend(client.ws, { - type: 'error', - code: 'paste_file_too_large', - message: 'Pasted image exceeds 3 MB limit', - }); + reject('paste_file_too_large', 'Pasted payload exceeds 3 MB limit'); return; } - if (!verifyImageMagicBytes(message.mime, message.dataBase64)) { - jsonSend(client.ws, { - type: 'error', - code: 'paste_file_unsupported', - message: 'Pasted image content does not match declared MIME type', - }); + if (!isFile && !verifyImageMagicBytes(message.mime, message.dataBase64)) { + reject('paste_file_unsupported', 'Pasted image content does not match declared MIME type'); return; } if (!isOpen(host?.ws)) { - jsonSend(client.ws, { - type: 'error', - code: 'host_offline', - message: 'Host is offline or disconnected', - }); + reject('host_offline', 'Host is offline or disconnected'); + return; + } + // A host from before any-file pastes would refuse the type after the + // whole payload had crossed; saying so here is quicker and clearer. + if (isFile && !host.pasteAnyFile) { + reject('paste_file_host_outdated', 'Host is too old to receive files; update herdr-remote'); return; } if (!client.session) { - jsonSend(client.ws, { - type: 'error', - code: 'no_session', - message: 'Terminal session is not ready', - }); + reject('no_session', 'Terminal session is not ready'); return; } jsonSend(host.ws, { @@ -421,6 +400,7 @@ function handleClientMessage( streamId: client.session.streamId, mime: message.mime, dataBase64: message.dataBase64, + ...(isFile ? { name: sanitizePasteFileName(message.name) } : {}), }); } } diff --git a/packages/relay/src/server/host-channel.ts b/packages/relay/src/server/host-channel.ts index 61adfe7..ef8ae6c 100644 --- a/packages/relay/src/server/host-channel.ts +++ b/packages/relay/src/server/host-channel.ts @@ -86,6 +86,7 @@ function createHostRecord( connectionGeneration: randomId('host-connection'), handoffCapable: capabilities.includes(CAPABILITY.hostHandoff), binaryFrameV2: capabilities.includes(CAPABILITY.binaryFrameV2), + pasteAnyFile: capabilities.includes(CAPABILITY.pasteAnyFile), streamIndices: new Map(), nextStreamIndex: 0, shutdownRequested: false, diff --git a/packages/relay/src/server/types.ts b/packages/relay/src/server/types.ts index a876506..b9ba7e2 100644 --- a/packages/relay/src/server/types.ts +++ b/packages/relay/src/server/types.ts @@ -36,6 +36,8 @@ export interface RelayHost { shellProfile?: HostShellProfile; /** The host can open an administrator terminal as a Herdr tab. */ adminTabsAvailable: boolean; + /** The host saves pasted files of any type, not just images. */ + pasteAnyFile: boolean; arch: string; connectedAt: string; connectedAtMs: number; diff --git a/packages/relay/tests/paste-file.test.js b/packages/relay/tests/paste-file.test.js index c15391e..87df464 100644 --- a/packages/relay/tests/paste-file.test.js +++ b/packages/relay/tests/paste-file.test.js @@ -430,3 +430,91 @@ test('paste_file rejects request when client is viewer mode with viewer_mode err clientWs.close(); hostWs.close(); }); + +async function relayWithHostAndClient(t, capabilities) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'relay-paste-test-')); + const server = new RelayServer(relayConfig({ maxPayloadBytes: 5 * 1024 * 1024 }), { + stateFile: path.join(dir, 'auth.json'), + }); + const address = await server.listen(0, '127.0.0.1'); + const base = `http://127.0.0.1:${address.port}`; + const wsBase = `ws://127.0.0.1:${address.port}`; + t.onTestFinished(async () => { + await server.close(); + fs.rmSync(dir, { recursive: true, force: true }); + }); + + const hostWs = await openWebSocket(`${wsBase}/ws/host`); + const hostReady = nextMessage(hostWs, (msg) => msg.type === 'host_ready'); + hostWs.send( + JSON.stringify({ + type: 'host_hello', + protocol: 1, + hostId: 'host-test', + token: HOST_AUTH['X-Herdr-Host-Token'], + capabilities, + }), + ); + await hostReady; + + const pairing = await postJson(`${base}/api/pair/start`, HOST_AUTH); + const client = await openWebSocket(`${wsBase}/ws/client`); + const ready = nextMessage(client, (msg) => msg.type === 'ready'); + client.send( + JSON.stringify({ + type: 'hello', + protocol: 1, + pairCode: pairing.code, + clientId: 'browser-a', + cols: 80, + rows: 24, + }), + ); + await ready; + t.onTestFinished(() => { + client.close(); + hostWs.close(); + }); + return { hostWs, client }; +} + +test('paste_file forwards any file with a sanitized name to a host that accepts files', async (t) => { + const { hostWs, client } = await relayWithHostAndClient(t, ['paste_any_file']); + const hostReceivedPaste = nextMessage(hostWs, (msg) => msg.type === 'paste_file'); + const payload = Buffer.from('%PDF-1.7 not an image').toString('base64'); + client.send( + JSON.stringify({ + type: 'paste_file', + mime: 'application/octet-stream', + dataBase64: payload, + name: '../../etc/my report (final).pdf', + }), + ); + + const hostMsg = (await hostReceivedPaste).value; + assert.equal(hostMsg.mime, 'application/octet-stream'); + assert.equal(hostMsg.dataBase64, payload); + assert.equal(hostMsg.name, 'my_report_final_.pdf'); + assert.ok(hostMsg.streamId, 'must carry streamId'); +}); + +test('paste_file refuses any file for a host that predates file uploads', async (t) => { + const { hostWs, client } = await relayWithHostAndClient(t, ['host_handoff']); + let hostReceivedPaste = false; + hostWs.on('message', (data) => { + if (JSON.parse(data.toString()).type === 'paste_file') hostReceivedPaste = true; + }); + const res = nextMessage(client, (msg) => msg.type === 'error'); + client.send( + JSON.stringify({ + type: 'paste_file', + mime: 'application/octet-stream', + dataBase64: Buffer.from('hello').toString('base64'), + name: 'notes.txt', + }), + ); + + assert.equal((await res).value.code, 'paste_file_host_outdated'); + await new Promise((r) => setTimeout(r, 50)); + assert.equal(hostReceivedPaste, false); +}); diff --git a/packages/relay/web/src/connection/clientAdapter.ts b/packages/relay/web/src/connection/clientAdapter.ts index 97f4654..a30079b 100644 --- a/packages/relay/web/src/connection/clientAdapter.ts +++ b/packages/relay/web/src/connection/clientAdapter.ts @@ -232,11 +232,12 @@ export class HerdrClientAdapter extends RelaySocket { this.sendJson(msg); } - public sendPasteFile(mime: string, dataBase64: string): void { + public sendPasteFile(mime: string, dataBase64: string, name?: string): void { this.sendJson({ type: 'paste_file', mime, dataBase64, + ...(name === undefined ? {} : { name }), }); } diff --git a/packages/relay/web/src/context/TerminalContext.tsx b/packages/relay/web/src/context/TerminalContext.tsx index ded17e1..23df716 100644 --- a/packages/relay/web/src/context/TerminalContext.tsx +++ b/packages/relay/web/src/context/TerminalContext.tsx @@ -107,16 +107,22 @@ export const TerminalProvider: React.FC<{ children: ReactNode }> = ({ children } // input, and holding it back here is what makes the window only watch. const role: ClientRole = settings.observerMode ? 'viewer' : session.role; const input = useTerminalInput({ adapterRef, role, addToast, tRef }); - const { uploadProgress, uploadImage, resetUploadProgress, abortUpload, completeUpload } = - useImageUpload({ - adapterRef, - role, - connectionState: session.connectionState, - warnViewerMode: input.warnViewerMode, - addToast, - t, - sendPasteFile: input.sendPasteFile, - }); + const { + uploadProgress, + uploadImage, + uploadFile, + resetUploadProgress, + abortUpload, + completeUpload, + } = useImageUpload({ + adapterRef, + role, + connectionState: session.connectionState, + warnViewerMode: input.warnViewerMode, + addToast, + t, + sendPasteFile: input.sendPasteFile, + }); const adapter = useHerdrAdapter(adapterRef, () => buildConnectionConfig(settings), { stateChange: (state, detail, code) => { @@ -443,8 +449,8 @@ export const TerminalProvider: React.FC<{ children: ReactNode }> = ({ children } ); const uploadValue = useMemo( - () => ({ uploadProgress, uploadImage, resetUploadProgress }), - [uploadProgress, uploadImage, resetUploadProgress], + () => ({ uploadProgress, uploadImage, uploadFile, resetUploadProgress }), + [uploadProgress, uploadImage, uploadFile, resetUploadProgress], ); return ( diff --git a/packages/relay/web/src/context/useImageUpload.ts b/packages/relay/web/src/context/useImageUpload.ts index 5da0df6..55a7d7a 100644 --- a/packages/relay/web/src/context/useImageUpload.ts +++ b/packages/relay/web/src/context/useImageUpload.ts @@ -1,7 +1,9 @@ -// Sending a pasted image to the workstation, with progress for the key bar. +// Sending a pasted image, or any file, to the workstation, with progress for +// the key bar. import type { ClientRole } from '@protocol/messages'; -import { type RefObject, useCallback, useEffect, useRef, useState } from 'react'; +import { PASTE_FILE_MIME, PASTE_MAX_BYTES } from '@protocol/paste'; +import { type RefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { Translate } from '@/shared/i18n'; import type { HerdrClientAdapter } from '@/connection/clientAdapter'; import type { ConnectionState } from '@/connection/types'; @@ -10,6 +12,7 @@ import { IDLE_IMAGE_UPLOAD_PROGRESS, type ImageUploadProgress, type PreparedImagePaste, + type UploadKind, } from '@/features/paste/imagePaste'; import type { ToastItem } from './useToastQueue'; @@ -33,11 +36,11 @@ export function useImageUpload({ warnViewerMode: () => void; addToast: (type: ToastItem['type'], message: string) => void; t: Translate; - sendPasteFile: (mime: string, dataBase64: string) => boolean; + sendPasteFile: (mime: string, dataBase64: string, name?: string) => boolean; }) { - const [uploadProgress, setUploadProgress] = useState( - IDLE_IMAGE_UPLOAD_PROGRESS, - ); + const [progress, setUploadProgress] = useState(IDLE_IMAGE_UPLOAD_PROGRESS); + const [uploadKind, setUploadKind] = useState('image'); + const uploadProgress = useMemo(() => ({ ...progress, kind: uploadKind }), [progress, uploadKind]); const activeUploadTaskIdRef = useRef(null); const uploadTaskIdCounterRef = useRef(0); const uploadTimeoutTimerRef = useRef(null); @@ -86,32 +89,74 @@ export function useImageUpload({ useEffect(() => clearUploadTimer, [clearUploadTimer]); - const uploadImage = useCallback( - async (input: Blob | File | PreparedImagePaste): Promise => { + /** Claims the one upload slot this window has, or says why it cannot. */ + const beginUpload = useCallback( + (kind: UploadKind): number | null => { if (role !== 'controller') { warnViewerMode(); - return false; + return null; } - if (!adapterRef.current || connectionState !== 'connected') { addToast('error', t('serverErrors.host_offline')); - return false; + return null; } - - if (activeUploadTaskIdRef.current !== null) { - return false; - } - + if (activeUploadTaskIdRef.current !== null) return null; const taskId = ++uploadTaskIdCounterRef.current; activeUploadTaskIdRef.current = taskId; + setUploadKind(kind); clearUploadTimer(); + return taskId; + }, + [adapterRef, role, connectionState, warnViewerMode, addToast, t, clearUploadTimer], + ); - const giveUp = (message?: string) => { - if (message) addToast('error', message); - activeUploadTaskIdRef.current = null; - setUploadProgress(IDLE_IMAGE_UPLOAD_PROGRESS); - return false; - }; + const giveUp = useCallback( + (message?: string) => { + if (message) addToast('error', message); + activeUploadTaskIdRef.current = null; + setUploadProgress(IDLE_IMAGE_UPLOAD_PROGRESS); + return false; + }, + [addToast], + ); + + /** Sends the payload, then waits for the host to say where it saved it. */ + const sendAndAwaitHost = useCallback( + (taskId: number, mime: string, dataBase64: string, name?: string): boolean => { + setUploadProgress({ + active: true, + phase: 'sending', + ratio: 0.75, + percent: 75, + statusText: t('virtualKeyboard.uploadProgressSending'), + }); + + const sent = sendPasteFile(mime, dataBase64, name); + if (activeUploadTaskIdRef.current !== taskId) return false; + if (!sent) return giveUp(); + + setUploadProgress({ + active: true, + phase: 'waitingHost', + ratio: 0.88, + percent: 88, + statusText: t('virtualKeyboard.uploadProgressWaitingHost'), + }); + + uploadTimeoutTimerRef.current = setTimeout(() => { + if (activeUploadTaskIdRef.current === taskId) { + giveUp(t('serverErrors.paste_file_write_failed')); + } + }, UPLOAD_CONFIRM_TIMEOUT_MS); + return true; + }, + [t, sendPasteFile, giveUp], + ); + + const uploadImage = useCallback( + async (input: Blob | File | PreparedImagePaste): Promise => { + const taskId = beginUpload('image'); + if (taskId === null) return false; const isPrepared = 'dataBase64' in input && typeof (input as PreparedImagePaste).dataBase64 === 'string'; @@ -159,52 +204,56 @@ export function useImageUpload({ }); } - setUploadProgress({ - active: true, - phase: 'sending', - ratio: 0.75, - percent: 75, - statusText: t('virtualKeyboard.uploadProgressSending'), - }); - - const sent = sendPasteFile(prepared.mime, prepared.dataBase64); - if (activeUploadTaskIdRef.current !== taskId) { - return false; - } - if (!sent) return giveUp(); - - setUploadProgress({ - active: true, - phase: 'waitingHost', - ratio: 0.88, - percent: 88, - statusText: t('virtualKeyboard.uploadProgressWaitingHost'), - }); - - uploadTimeoutTimerRef.current = setTimeout(() => { - if (activeUploadTaskIdRef.current === taskId) { - giveUp(t('serverErrors.paste_file_write_failed')); - } - }, UPLOAD_CONFIRM_TIMEOUT_MS); - - return true; + return sendAndAwaitHost(taskId, prepared.mime, prepared.dataBase64); } catch (err) { console.error('Failed to process/upload image:', err); if (activeUploadTaskIdRef.current === taskId) giveUp(t('clipboard.imageTooLarge')); return false; } }, - [ - adapterRef, - role, - connectionState, - warnViewerMode, - addToast, - t, - sendPasteFile, - clearUploadTimer, - ], + [beginUpload, giveUp, sendAndAwaitHost, t], + ); + + const uploadFile = useCallback( + async (file: File): Promise => { + const taskId = beginUpload('file'); + if (taskId === null) return false; + // Checked before reading: a file over the cap is refused, never shrunk. + if (file.size === 0) return giveUp(t('clipboard.fileEmpty')); + if (file.size > PASTE_MAX_BYTES) return giveUp(t('clipboard.fileTooLarge')); + + setUploadProgress({ + active: true, + phase: 'reading', + ratio: 0.3, + percent: 30, + statusText: t('virtualKeyboard.uploadProgressReading'), + }); + try { + const dataUrl = await new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(String(reader.result ?? '')); + reader.onerror = () => reject(reader.error ?? new Error('Failed to read file')); + reader.readAsDataURL(file); + }); + if (activeUploadTaskIdRef.current !== taskId) return false; + const dataBase64 = dataUrl.slice(dataUrl.indexOf(',') + 1); + return sendAndAwaitHost(taskId, PASTE_FILE_MIME, dataBase64, file.name); + } catch (err) { + console.error('Failed to read file for upload:', err); + if (activeUploadTaskIdRef.current === taskId) giveUp(t('clipboard.fileReadFailed')); + return false; + } + }, + [beginUpload, giveUp, sendAndAwaitHost, t], ); - return { uploadProgress, uploadImage, resetUploadProgress, abortUpload, completeUpload }; + return { + uploadProgress, + uploadImage, + uploadFile, + resetUploadProgress, + abortUpload, + completeUpload, + }; } diff --git a/packages/relay/web/src/context/useTerminalInput.ts b/packages/relay/web/src/context/useTerminalInput.ts index 4a449b8..259652e 100644 --- a/packages/relay/web/src/context/useTerminalInput.ts +++ b/packages/relay/web/src/context/useTerminalInput.ts @@ -143,13 +143,13 @@ export function useTerminalInput({ ); const sendPasteFile = useCallback( - (mime: string, dataBase64: string) => { + (mime: string, dataBase64: string, name?: string) => { if (role !== 'controller') { warnViewerMode(); return false; } if (adapterRef.current) { - adapterRef.current.sendPasteFile(mime, dataBase64); + adapterRef.current.sendPasteFile(mime, dataBase64, name); return true; } return false; diff --git a/packages/relay/web/src/context/values.ts b/packages/relay/web/src/context/values.ts index 56df43c..85789cf 100644 --- a/packages/relay/web/src/context/values.ts +++ b/packages/relay/web/src/context/values.ts @@ -125,7 +125,7 @@ export interface TerminalIOContextValue { sendResize: (cols: number, rows: number) => void; /** Say, once per stretch of viewer role, that this window cannot type. */ warnViewerMode: () => void; - sendPasteFile: (mime: string, dataBase64: string) => boolean; + sendPasteFile: (mime: string, dataBase64: string, name?: string) => boolean; subscribeToPasteFileReady: (handler: (path: string) => void) => () => void; /** Attach the terminal as the output sink; buffered output is replayed first. */ subscribeToOutput: (sink: TerminalOutputSink) => () => void; @@ -136,6 +136,8 @@ export interface TerminalIOContextValue { export interface UploadContextValue { uploadProgress: ImageUploadProgress; uploadImage: (file: Blob | File | PreparedImagePaste) => Promise; + /** Send any file as it is, under its own name; the host pastes back its path. */ + uploadFile: (file: File) => Promise; resetUploadProgress: () => void; } diff --git a/packages/relay/web/src/features/keyboard/KeyLabel.tsx b/packages/relay/web/src/features/keyboard/KeyLabel.tsx index 0ea8438..54c6460 100644 --- a/packages/relay/web/src/features/keyboard/KeyLabel.tsx +++ b/packages/relay/web/src/features/keyboard/KeyLabel.tsx @@ -23,6 +23,7 @@ const KEY_ICONS = { open: { glyph: '\u{F0360}', box: [91, 509, 256, 464] }, // nf-md-menu_up close: { glyph: '\u{F035D}', box: [91, 509, 256, 464] }, // nf-md-menu_down image: { glyph: '\u{F02E9}', box: [0, 750, -15, 735] }, // nf-md-image + attach: { glyph: '\u{F03E2}', box: [70, 530, -99, 819] }, // nf-md-paperclip customize: { glyph: '\u{F066A}', box: [0, 750, -15, 735] }, // nf-md-tune_vertical admin: { glyph: '\u{F088F}', box: [0, 750, -99, 819] }, // nf-md-shield_account } as const; diff --git a/packages/relay/web/src/features/keyboard/KeyToolbar.tsx b/packages/relay/web/src/features/keyboard/KeyToolbar.tsx index 3bc0f13..76b8c55 100644 --- a/packages/relay/web/src/features/keyboard/KeyToolbar.tsx +++ b/packages/relay/web/src/features/keyboard/KeyToolbar.tsx @@ -7,7 +7,7 @@ import { DEFAULT_TOOLBAR_KEYS, getLocalizedKeyTitle, type ToolbarKeyDef } from ' import { AgentKeyActions } from './AgentKeyActions'; import { CAP_ACTIVE, CAP_BASE, CAP_COMMIT, CAP_IDLE, capSizes } from './caps'; import { ChordsDrawer, FnKeysDrawer, SymbolsDrawer } from './KeyDrawers'; -import { ImageUploadButton, UploadFileInputs, UploadProgressBar } from './UploadControls'; +import { UploadButton, UploadFileInputs, UploadProgressBar } from './UploadControls'; import { useKeySender } from './useKeySender'; import { KeyIcon, KeyLabel } from './KeyLabel'; import { AdminTerminalButton } from './AdminTerminalButton'; @@ -34,6 +34,7 @@ export const KeyToolbar: React.FC = ({ compact = false, onCusto const { capHeight, keyClass, squareKeyClass, drawerToggleClass } = capSizes(compact); const fileInputRef = useRef(null); + const anyFileInputRef = useRef(null); const modifierPressTimerRef = useRef | null>(null); const modifierPressRef = useRef<{ @@ -152,7 +153,7 @@ export const KeyToolbar: React.FC = ({ compact = false, onCusto data-testid="key-toolbar-collapsed" className="z-20 flex shrink-0 flex-col border-t border-tui-border bg-tui-mantle pb-[max(env(safe-area-inset-bottom,0px),0.125rem)]" > - +
); } diff --git a/packages/relay/web/src/features/keyboard/keyToolbarFileUpload.test.tsx b/packages/relay/web/src/features/keyboard/keyToolbarFileUpload.test.tsx new file mode 100644 index 0000000..65dda1d --- /dev/null +++ b/packages/relay/web/src/features/keyboard/keyToolbarFileUpload.test.tsx @@ -0,0 +1,179 @@ +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { PASTE_MAX_BYTES } from '@protocol/paste'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { ToastContainer } from '@/app/ToastContainer'; +import { TerminalProvider, useTerminal } from '@/context/TerminalContext'; +import * as imagePasteModule from '@/features/paste/imagePaste'; +import { saveSettings } from '@/features/settings/storage'; +import { TerminalView } from '@/features/terminal/TerminalView'; +import type { MockTerminalInstance, MockWebSocket } from '@/test/setup'; +import { KeyToolbar } from './KeyToolbar'; + +const xtermInstances = (globalThis as unknown as { __xtermInstances: MockTerminalInstance[] }) + .__xtermInstances; +const webSocketInstances = (globalThis as unknown as { __webSocketInstances: MockWebSocket[] }) + .__webSocketInstances; + +function emitAdapterEvent( + context: ReturnType | null, + event: string, + ...args: unknown[] +): void { + ( + context?.adapter as unknown as { emit: (name: string, ...values: unknown[]) => void } | null + )?.emit(event, ...args); +} + +function sentPastes(ws: MockWebSocket) { + return ws.sent + .map((s) => (typeof s === 'string' ? JSON.parse(s) : null)) + .filter((m) => m?.type === 'paste_file'); +} + +describe('KeyToolbar file upload', () => { + const originalMatchMedia = window.matchMedia; + + beforeEach(() => { + localStorage.clear(); + sessionStorage.clear(); + xtermInstances.length = 0; + webSocketInstances.length = 0; + vi.restoreAllMocks(); + window.matchMedia = vi.fn().mockImplementation((query) => ({ + matches: false, + media: query, + onchange: null, + addListener: () => {}, + removeListener: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => false, + })) as unknown as typeof window.matchMedia; + }); + + afterEach(() => { + window.matchMedia = originalMatchMedia; + }); + + const setupTestSession = (role: 'controller' | 'viewer' = 'controller') => { + saveSettings({ toolbarVisible: true, language: 'zh' }); + let capturedCtx: ReturnType | null = null; + const ContextCollector = () => { + capturedCtx = useTerminal(); + return null; + }; + render( + + + + + + , + ); + const ws = webSocketInstances[webSocketInstances.length - 1]; + act(() => { + ws.simulateOpen(); + emitAdapterEvent(capturedCtx, 'sessionReady', {}); + if (role === 'controller') emitAdapterEvent(capturedCtx, 'controlGranted'); + else emitAdapterEvent(capturedCtx, 'roleChange', 'viewer', 'other-controller'); + }); + return { getCtx: () => capturedCtx, ws }; + }; + + it('sits beside the image button with a paperclip icon and its own labels', () => { + saveSettings({ toolbarVisible: true, language: 'en' }); + render( + + + , + ); + const btn = screen.getByTestId('file-upload-btn'); + expect(btn).toHaveAttribute('aria-label', 'Upload File'); + expect(btn).toHaveAttribute('title', expect.stringContaining('any file')); + expect(btn.textContent).toBe('\u{F03E2}'); + expect(btn.nextElementSibling).toBe(screen.getByTestId('image-upload-btn')); + }); + + it('opens a picker that accepts any file type', () => { + setupTestSession(); + const input = screen.getByTestId('key-toolbar-any-file-input') as HTMLInputElement; + expect(input.hasAttribute('accept')).toBe(false); + const clickSpy = vi.spyOn(input, 'click'); + fireEvent.click(screen.getByTestId('file-upload-btn')); + expect(clickSpy).toHaveBeenCalledTimes(1); + }); + + it('does not open the picker in viewer mode', () => { + setupTestSession('viewer'); + const input = screen.getByTestId('key-toolbar-any-file-input') as HTMLInputElement; + const clickSpy = vi.spyOn(input, 'click'); + fireEvent.click(screen.getByTestId('file-upload-btn')); + expect(clickSpy).not.toHaveBeenCalled(); + expect(screen.getByText(/观察者模式/i)).toBeInTheDocument(); + }); + + it('sends the file untouched under its name and pastes back the saved path', async () => { + const compressSpy = vi.spyOn(imagePasteModule, 'compressAndPrepareImage'); + const { getCtx, ws } = setupTestSession(); + await waitFor(() => expect(xtermInstances.length).toBe(1)); + const term = xtermInstances[0]; + term.paste = vi.fn(); + + const file = new File(['hello'], 'notes.txt', { type: 'text/plain' }); + await act(async () => { + fireEvent.change(screen.getByTestId('key-toolbar-any-file-input'), { + target: { files: [file] }, + }); + }); + + await waitFor(() => expect(sentPastes(ws)).toHaveLength(1)); + const [msg] = sentPastes(ws); + expect(msg.mime).toBe('application/octet-stream'); + expect(msg.name).toBe('notes.txt'); + expect(msg.dataBase64).toBe('aGVsbG8='); + expect(compressSpy).not.toHaveBeenCalled(); + + // Only the button that started the upload shows its percentage. + expect(screen.getByTestId('file-upload-btn').textContent).toBe('88%'); + expect(screen.getByTestId('image-upload-btn')).toBeDisabled(); + expect(screen.getByTestId('image-upload-btn').textContent).toBe('\u{F02E9}'); + + await act(async () => { + emitAdapterEvent(getCtx(), 'pasteFileReady', '/state/pasted/uuid-notes.txt'); + }); + expect(term.paste).toHaveBeenCalledWith('/state/pasted/uuid-notes.txt'); + expect(screen.getByText('完成')).toBeInTheDocument(); + }); + + it('refuses a file over the size limit without sending it', async () => { + const { ws } = setupTestSession(); + const file = new File(['x'], 'big.bin'); + Object.defineProperty(file, 'size', { value: PASTE_MAX_BYTES + 1 }); + await act(async () => { + fireEvent.change(screen.getByTestId('key-toolbar-any-file-input'), { + target: { files: [file] }, + }); + }); + expect(await screen.findByText('文件超过 3 MB 上传上限。')).toBeInTheDocument(); + expect(sentPastes(ws)).toHaveLength(0); + expect(screen.queryByTestId('key-toolbar-progress')).toBeNull(); + }); + + it('explains when the workstation is too old to receive files', async () => { + const { getCtx } = setupTestSession(); + await act(async () => { + fireEvent.change(screen.getByTestId('key-toolbar-any-file-input'), { + target: { files: [new File(['hi'], 'a.txt')] }, + }); + }); + await waitFor(() => expect(screen.getByText(/等待工作站保存/)).toBeInTheDocument()); + await act(async () => { + emitAdapterEvent(getCtx(), 'error', { + code: 'paste_file_host_outdated', + message: 'Host is too old to receive files; update herdr-remote', + }); + }); + expect(await screen.findByText(/版本过旧,无法接收文件/)).toBeInTheDocument(); + expect(screen.queryByTestId('key-toolbar-progress')).toBeNull(); + }); +}); diff --git a/packages/relay/web/src/features/keyboard/keyToolbarImageUpload.test.tsx b/packages/relay/web/src/features/keyboard/keyToolbarImageUpload.test.tsx index 00664dc..da74ab4 100644 --- a/packages/relay/web/src/features/keyboard/keyToolbarImageUpload.test.tsx +++ b/packages/relay/web/src/features/keyboard/keyToolbarImageUpload.test.tsx @@ -371,7 +371,7 @@ describe('KeyToolbar Image Upload & Progress Integration', () => { expect(screen.queryByTestId('key-toolbar-progress')).toBeNull(); const btn = screen.getByTestId('image-upload-btn'); expect(btn).not.toBeDisabled(); - expect(await screen.findByText('粘贴图片超过 3 MB 上限。')).toBeInTheDocument(); + expect(await screen.findByText('粘贴的图片或文件超过 3 MB 上限。')).toBeInTheDocument(); }); it('per-window task isolation: paste_file_ready does NOT activate completion progress when this window did not initiate upload', async () => { @@ -453,6 +453,10 @@ describe('KeyToolbar Image Upload & Progress Integration', () => { expect(imageBtn.textContent).not.toContain('🖼️'); expect(imageBtn.className).toContain('h-7'); expect(imageBtn.className).not.toContain('min-h-[44px]'); + // The any-file button sits just left of it, the same size + const fileBtn = buttons[buttons.length - 3]; + expect(fileBtn).toHaveAttribute('data-testid', 'file-upload-btn'); + expect(fileBtn.className).toContain('h-7'); // Enter button also shares the desktop cap height expect(last.className).toContain('h-7'); diff --git a/packages/relay/web/src/features/paste/imagePaste.ts b/packages/relay/web/src/features/paste/imagePaste.ts index d4fb461..b83193e 100644 --- a/packages/relay/web/src/features/paste/imagePaste.ts +++ b/packages/relay/web/src/features/paste/imagePaste.ts @@ -57,8 +57,12 @@ export type ImageUploadPhase = | 'completed' | 'error'; +/** Which key bar button started an upload, so only that one shows its progress. */ +export type UploadKind = 'image' | 'file'; + export interface ImageUploadProgress { active: boolean; + kind?: UploadKind; phase: ImageUploadPhase; ratio: number; // 0..1 percent: number; // 0..100 diff --git a/packages/relay/web/src/shared/i18n/en.ts b/packages/relay/web/src/shared/i18n/en.ts index 30d2898..0256a59 100644 --- a/packages/relay/web/src/shared/i18n/en.ts +++ b/packages/relay/web/src/shared/i18n/en.ts @@ -392,6 +392,8 @@ export const en = { badgeKill: 'KILL', uploadImage: 'Upload Image', uploadImageTitle: 'Select and upload image to terminal', + uploadFile: 'Upload File', + uploadFileTitle: 'Select any file (up to 3 MB) and paste its path on the host', uploadProgressReading: 'Reading...', uploadProgressProcessing: 'Compressing...', uploadProgressSending: 'Uploading...', @@ -650,8 +652,10 @@ export const en = { client_send_failed: 'The relay could not send data to this browser.', too_many_hosts: 'This relay has reached its host limit.', paste_file_unsupported: 'Unsupported image format for paste.', - paste_file_too_large: 'Pasted image exceeds 3 MB limit.', - paste_file_write_failed: 'Failed to save pasted image on host.', + paste_file_too_large: 'Pasted image or file exceeds 3 MB limit.', + paste_file_write_failed: 'Failed to save pasted image or file on host.', + paste_file_host_outdated: + 'The workstation is too old to receive files. Update herdr-remote there.', paste_file_empty: 'Pasted image payload is empty.', paste_file_corrupt: 'Pasted image data is corrupt or does not match declared MIME.', viewer_mode: 'Viewer mode cannot paste to terminal.', @@ -722,6 +726,8 @@ export const en = { imageTooLarge: 'Image exceeds 3 MB limit and cannot be compressed further.', imageCompressedSize: 'Compressed: {size}', fileEmpty: 'File is empty.', + fileTooLarge: 'File exceeds the 3 MB upload limit.', + fileReadFailed: 'Failed to read the file.', decodeFailed: 'Failed to decode image file.', unsupportedType: 'Unsupported format. Only PNG, JPEG, WebP, and GIF are supported.', imageSectionTitle: 'Image', diff --git a/packages/relay/web/src/shared/i18n/zh.ts b/packages/relay/web/src/shared/i18n/zh.ts index 76c444f..f6fddfe 100644 --- a/packages/relay/web/src/shared/i18n/zh.ts +++ b/packages/relay/web/src/shared/i18n/zh.ts @@ -386,6 +386,8 @@ export const zh: TranslationSchema = { badgeKill: '剪切', uploadImage: '上传图片', uploadImageTitle: '选择图片并上传至终端', + uploadFile: '上传文件', + uploadFileTitle: '选择任意文件(不超过 3 MB),上传后把工作站上的路径填入终端', uploadProgressReading: '读取中...', uploadProgressProcessing: '处理压缩中...', uploadProgressSending: '上传中...', @@ -629,8 +631,9 @@ export const zh: TranslationSchema = { client_send_failed: 'Relay 无法向此浏览器发送数据。', too_many_hosts: '此 relay 已达到主机数量上限。', paste_file_unsupported: '不支持的粘贴图片格式。', - paste_file_too_large: '粘贴图片超过 3 MB 上限。', - paste_file_write_failed: '工作站保存图片失败。', + paste_file_too_large: '粘贴的图片或文件超过 3 MB 上限。', + paste_file_write_failed: '工作站保存图片或文件失败。', + paste_file_host_outdated: '工作站上的 herdr-remote 版本过旧,无法接收文件,请先更新。', paste_file_empty: '粘贴图片内容为空。', paste_file_corrupt: '粘贴图片数据损坏或与声明格式不符。', viewer_mode: '观察者模式无法粘贴到终端。', @@ -696,6 +699,8 @@ export const zh: TranslationSchema = { imageTooLarge: '图片超过 3 MB 限制且无法进一步压缩。', imageCompressedSize: '压缩后大小:{size}', fileEmpty: '文件内容为空。', + fileTooLarge: '文件超过 3 MB 上传上限。', + fileReadFailed: '读取文件失败。', decodeFailed: '无法解析图片文件。', unsupportedType: '不支持的图片格式,仅支持 PNG、JPEG、WebP 和 GIF。', imageSectionTitle: '图片',