From 9a36333fc4519d211dbccdc4b631fde05ceca00f Mon Sep 17 00:00:00 2001 From: Si Hyeong Lee Date: Thu, 22 Oct 2026 18:25:13 -0700 Subject: [PATCH 1/2] feat(attach): send any file to a session on another PC, up to 4 GB, in pieces A remote session took images and 21 document kinds up to 5 MB. Now any kind travels: cockpit:uploadStart / uploadChunk (2 MB pieces, inside the link's 8 MB frame) / uploadFinish / uploadAbort, all needing `control` like receiveFile. The kind is no longer checked: a device that can type into a session can already have its agent run anything. What stays: written only into temp under a DevDeck-chosen name the sweeper removes; never opened; Mark of the Web on Windows; refused if it would leave under 2 GB free; at most 4 open; dropped after 5 idle minutes. The sender reads one piece at a time, shows progress past 8 MB, sends an image too large to redraw as PNG as it is, and falls back to receiveFile for a host older than this. qa:images-remote sends a 9 MB binary between two real paired apps and compares bytes. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016NLGwiBY5uEivJqERQzcoY --- docs/quality.md | 2 +- qa/images-remote.mjs | 20 ++++- src/main/api/deckApi.surface.test.ts | 18 +++++ src/main/api/deckApi.ts | 10 +++ src/main/fileUploads.test.ts | 86 +++++++++++++++++++++ src/main/fileUploads.ts | 111 +++++++++++++++++++++++++++ src/main/link/clientLink.ts | 2 + src/preload/preload.ts | 8 ++ src/renderer/attachments.test.ts | 78 +++++++++++++++++-- src/renderer/attachments.ts | 99 ++++++++++++++++++------ src/renderer/cockpitView.ts | 26 +++++-- src/renderer/global.d.ts | 8 ++ src/renderer/loadError.ts | 8 ++ src/renderer/locales/en.json | 11 ++- src/renderer/locales/ja.json | 11 ++- src/renderer/locales/ko.json | 11 ++- src/renderer/locales/zh.json | 11 ++- src/renderer/machineDeck.ts | 9 +++ src/renderer/whatsNew.test.ts | 4 +- src/renderer/whatsNew.ts | 1 + src/shared/sentFiles.ts | 31 ++++++-- 21 files changed, 509 insertions(+), 56 deletions(-) create mode 100644 src/main/fileUploads.test.ts create mode 100644 src/main/fileUploads.ts diff --git a/docs/quality.md b/docs/quality.md index dcadd7c..f254c88 100644 --- a/docs/quality.md +++ b/docs/quality.md @@ -32,7 +32,7 @@ Results and screenshots are written to `qa/shots/maintenance/` and `qa/shots/res The CI performance gate is `qa:perf-gate` (Windows, every push): a fixture agent instead of a real one, and units that do not depend on the runner's speed — running CSS animations and DOM mutation records on an idle deck (both must be ~0), DOM mutations outside the terminals while two tiles repaint at 10fps (a per-chunk sidebar rebuild is ~8,000 against a limit of 400), and the main process's longest event-loop stall while working and while 20,000 lines stream. Each limit was checked by injecting the regression it guards against: an infinite CSS animation, a sidebar render per PTY chunk, and a 700ms synchronous block in the main process all fail it. Results go to `qa/shots/perf-gate/results.json`. -`qa:images` (Windows) drives the cockpit image strip, full-size viewer and 📎 attach against an isolated home holding a transcript with a tool screenshot, a pasted image and a written image file; its fixture `claude` echoes what it is sent, so an attached path is read back off the terminal. `qa:update` (Windows) checks "Update all": a fixture `claude` prints Claude Code's failed-update footer, and the harness asserts that `claude update` ran with no session process alive and that every conversation was resumed by id. `qa:images-remote` (Windows) pairs a host and a viewer over real TLS and checks the same from the viewer: thumbnails and the full view cross the link, an attached JPEG lands on the host as PNG with the host path typed, and a non-image named `.png` or an unindexed id is refused over the link. `qa:restore-pacing` (Windows) checks that restored agents start one at a time: a fixture that draws for 2s while booting must not see the next one launched before it finished (measured before the fix: 26–49ms apart). `qa:image-rows` (Windows) ties images to the terminal: a fixture `claude` draws a screen the way Claude Code does (`● server - tool (MCP)` calls, `⎿ [Image]` results, shaded `❯` prompts, a composer between rules) over a transcript holding the same screenshots, and the harness checks that each placeholder is marked and opens its own image, that the viewer shows what led to an image and scrolls the terminal to its row, that copying puts an image on the clipboard (the person's clipboard is restored afterwards), that a new screenshot is previewed while the strip is closed, and that Ctrl+Up/Down steps between sent prompts without entering the composer. `qa:cockpit-tools` (Windows) uses a fixture `claude` that records every byte typed into it: a quick reply from the header menu and from Ctrl+Shift+ arrives as the phrase followed by Enter, phrases edited in Settings are saved and offered, the timeline lists each prompt with its time, reply and image count and scrolls the terminal to it without resizing it, and a screenshot marked up in the viewer reaches the session as a PNG that has the red marks in it. It also starts as an install updated from 1.41.0 with the Claude status signal on: the What's new window lists only what is newer and Show me rings the setting, NEW dots go once a button is used, and the fixture asks for status the way Claude Code does (OSC 7501) — the host answers on the bundled ConPTY and the header shows working, needs approval and done. It also starts a separate workspace from the header, checks its branch and that the project stays clean, sees a change counted, and cleans it up through the warning. Its fixture also answers `claude agents --json`, so the harness checks the Claude background sessions group, the waiting mark, and that a click opens the session with `claude attach `. The release workflow runs it against the packaged app too. `qa:images-remote` also sends a log file from the viewer and checks it lands on the host under its own name. None of these starts a real agent. +`qa:images` (Windows) drives the cockpit image strip, full-size viewer and 📎 attach against an isolated home holding a transcript with a tool screenshot, a pasted image and a written image file; its fixture `claude` echoes what it is sent, so an attached path is read back off the terminal. `qa:update` (Windows) checks "Update all": a fixture `claude` prints Claude Code's failed-update footer, and the harness asserts that `claude update` ran with no session process alive and that every conversation was resumed by id. `qa:images-remote` (Windows) pairs a host and a viewer over real TLS and checks the same from the viewer: thumbnails and the full view cross the link, an attached JPEG lands on the host as PNG with the host path typed, and a non-image named `.png` or an unindexed id is refused over the link. `qa:restore-pacing` (Windows) checks that restored agents start one at a time: a fixture that draws for 2s while booting must not see the next one launched before it finished (measured before the fix: 26–49ms apart). `qa:image-rows` (Windows) ties images to the terminal: a fixture `claude` draws a screen the way Claude Code does (`● server - tool (MCP)` calls, `⎿ [Image]` results, shaded `❯` prompts, a composer between rules) over a transcript holding the same screenshots, and the harness checks that each placeholder is marked and opens its own image, that the viewer shows what led to an image and scrolls the terminal to its row, that copying puts an image on the clipboard (the person's clipboard is restored afterwards), that a new screenshot is previewed while the strip is closed, and that Ctrl+Up/Down steps between sent prompts without entering the composer. `qa:cockpit-tools` (Windows) uses a fixture `claude` that records every byte typed into it: a quick reply from the header menu and from Ctrl+Shift+ arrives as the phrase followed by Enter, phrases edited in Settings are saved and offered, the timeline lists each prompt with its time, reply and image count and scrolls the terminal to it without resizing it, and a screenshot marked up in the viewer reaches the session as a PNG that has the red marks in it. It also starts as an install updated from 1.41.0 with the Claude status signal on: the What's new window lists only what is newer and Show me rings the setting, NEW dots go once a button is used, and the fixture asks for status the way Claude Code does (OSC 7501) — the host answers on the bundled ConPTY and the header shows working, needs approval and done. It also starts a separate workspace from the header, checks its branch and that the project stays clean, sees a change counted, and cleans it up through the warning. Its fixture also answers `claude agents --json`, so the harness checks the Claude background sessions group, the waiting mark, and that a click opens the session with `claude attach `. The release workflow runs it against the packaged app too. `qa:images-remote` also sends a log file from the viewer and checks it lands on the host under its own name. It also sends a 9 MB binary, which is past the old limit and of a kind the old list refused, and checks that it arrives in pieces, byte for byte, with its progress shown. None of these starts a real agent. Do not run these against a live development conversation. Do not equate a short loopback test with sleep/resume, VPN changes or multi-day field validation. diff --git a/qa/images-remote.mjs b/qa/images-remote.mjs index 9d10f70..9d33dd3 100644 --- a/qa/images-remote.mjs +++ b/qa/images-remote.mjs @@ -109,7 +109,7 @@ try { }, [join(root, 'build', 'icon.png'), jpg]); }); const chooser = viewer.win.waitForEvent('filechooser'); - await viewer.win.locator('#ck-header .ck-act').filter({ has: viewer.win.locator('svg') }).and(viewer.win.locator('[title*="이미지·문서 첨부"]')).click(); + await viewer.win.locator('#ck-header .ck-act').filter({ has: viewer.win.locator('svg') }).and(viewer.win.locator('[title*="파일 첨부"]')).click(); await (await chooser).setFiles(jpg); await rowsInclude(viewer.win, 'devdeck-paste-'); const typed = await host.win.evaluate(() => [...document.querySelectorAll('.ck-term.show .xterm-rows')].map((r) => r.textContent).join('\n')); @@ -124,7 +124,7 @@ try { const log = join(sandbox, 'build.log'); writeFileSync(log, 'step 1 ok\nstep 2 failed\n'); const chooser = viewer.win.waitForEvent('filechooser'); - await viewer.win.locator('#ck-header .ck-act[title*="이미지·문서 첨부"]').click(); + await viewer.win.locator('#ck-header .ck-act[title*="파일 첨부"]').click(); await (await chooser).setFiles(log); await rowsInclude(viewer.win, 'build.log'); const typed = await host.win.evaluate(() => [...document.querySelectorAll('.ck-term.show .xterm-rows')].map((r) => r.textContent).join('\n')); @@ -133,6 +133,22 @@ try { assert.equal(readFileSync(m[1], 'utf8'), 'step 1 ok\nstep 2 failed\n'); }); + await check('any kind of file past the old 5MB limit crosses in pieces, byte for byte, with its progress shown', async () => { + const big = join(sandbox, 'dataset.bin'); + const bytes = Buffer.alloc(9 * 1024 * 1024 + 17); for (let i = 0; i < bytes.length; i++) bytes[i] = (i * 31) % 256; + writeFileSync(big, bytes); + const chooser = viewer.win.waitForEvent('filechooser'); + await viewer.win.locator('#ck-header .ck-act[title*="파일 첨부"]').click(); + await (await chooser).setFiles(big); + await viewer.win.waitForFunction(() => [...document.querySelectorAll('#toast-host .toast')].some((t) => /dataset\.bin.*%/.test(t.textContent)), null, { timeout: 15000 }); + await rowsInclude(viewer.win, 'dataset.bin'); + const typed = await host.win.evaluate(() => [...document.querySelectorAll('.ck-term.show .xterm-rows')].map((r) => r.textContent).join('\n')); + const m = /GOT:(\S*devdeck-file-[0-9a-f-]{36}-dataset\.bin)/.exec(typed.replace(/\n/g, '')); + assert.ok(m, 'host typed the sent file path:\n' + typed); + assert.ok(readFileSync(m[1]).equals(bytes), 'the file on the host is the same bytes'); + rmSync(m[1], { force: true }); + }); + await check('over the link, a real image is served and a fake .png or unindexed id is refused', async () => { const r = await viewer.win.evaluate(async ([id, path]) => { const m = window.devdeck.machine(id).cockpit; diff --git a/src/main/api/deckApi.surface.test.ts b/src/main/api/deckApi.surface.test.ts index 44dbdca..da96848 100644 --- a/src/main/api/deckApi.surface.test.ts +++ b/src/main/api/deckApi.surface.test.ts @@ -333,6 +333,24 @@ describe('receiving a document from another machine', () => { }); }); +describe('receiving a file of any kind in pieces', () => { + it('needs control at every step, like a document', () => { + for (const m of ['cockpit:uploadStart', 'cockpit:uploadChunk', 'cockpit:uploadFinish', 'cockpit:uploadAbort']) { + expect(mayCallRemotely(api[m], ['observe']), m).toBe(false); + expect(mayCallRemotely(api[m], ['control']), m).toBe(true); + } + }); + + it('lands a kind receiveFile refuses in the temp directory, under its own name', async () => { + const start = await api['cockpit:uploadStart'].handler('tool.exe', 2) as { id: string }; + expect(await api['cockpit:uploadChunk'].handler(start.id, 0, Buffer.from('MZ').toString('base64'))).toBe(2); + const written = await api['cockpit:uploadFinish'].handler(start.id) as string; + expect(dirname(written)).toBe(tmpdir()); + expect(basename(written)).toMatch(/^devdeck-file-[0-9a-f-]{36}-tool\.exe$/); + rmSync(written); + }); +}); + it('reads a session timeline only for an allowed project, as an observer may', async () => { expect(mayCallRemotely(api['cockpit:sessionTurns'], ['observe'])).toBe(true); await expect(api['cockpit:sessionTurns'].handler(join(process.cwd(), 'outside'), '00000000-0000-0000-0000-000000000000', 'claude')).resolves.toEqual([]); diff --git a/src/main/api/deckApi.ts b/src/main/api/deckApi.ts index 9fc95ce..cb709e4 100644 --- a/src/main/api/deckApi.ts +++ b/src/main/api/deckApi.ts @@ -13,6 +13,7 @@ import { scanFolders, isRepo } from '../scanner'; import { getGitInfo, getRepoUrl, getGitBranchDirty, getRecentCommits, createGitRunner, type GitRunner } from '../gitInfo'; import { createWorktree, removeWorktree, worktreeChanges, type Worktree } from '../worktrees'; import { listBackgroundAgents, pauseBackgroundAgents } from '../backgroundAgents'; +import { createUploads } from '../fileUploads'; import { isBackgroundAgentId } from '../../shared/backgroundAgents'; import { getProvider, availableAgents, resolveOpenSession, resolveProjectOpenCommand } from '../agents'; import { toAgentId, type AgentId, type CloseResult, type Folder, type OpenMode, type ProjectOpenIntent, type SessionMeta } from '../../shared/types'; @@ -1243,6 +1244,15 @@ export function createDeckApi(cfg: DeckApiConfig): DeckApiBundle { try { writeFileSync(file, bytes); } catch { return null; } return file; }); + /** + * A file of any kind and size sent here in pieces (receiveFile above stays for senders older than + * this): start, chunks in order, finish → its path. See main/fileUploads for what is and is not checked. + */ + const uploads = createUploads({ dir: tmpdir(), prefix: SENT_FILE_PREFIX }); + invoke('cockpit:uploadStart', allow('control'), (name: unknown, size: unknown) => uploads.start(name, size)); + invoke('cockpit:uploadChunk', allow('control'), (id: unknown, offset: unknown, base64: unknown) => uploads.chunk(id, offset, base64)); + invoke('cockpit:uploadFinish', allow('control'), (id: unknown) => uploads.finish(id)); + invoke('cockpit:uploadAbort', allow('control'), (id: unknown) => uploads.abort(id)); // Open a clicked terminal link. Terminal output is arbitrary, so (unlike shell:openExternal, which is // locked to DevDeck's own repo) any host is allowed — but only the http/https scheme, never file:/etc. diff --git a/src/main/fileUploads.test.ts b/src/main/fileUploads.test.ts new file mode 100644 index 0000000..b9d5bb7 --- /dev/null +++ b/src/main/fileUploads.test.ts @@ -0,0 +1,86 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { basename, dirname, join } from 'node:path'; +import { createUploads } from './fileUploads'; +import { MAX_UPLOAD_BYTES, UPLOAD_CHUNK_BYTES } from '../shared/sentFiles'; + +let dir: string; +beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'devdeck-uploads-')); }); +afterEach(() => { rmSync(dir, { recursive: true, force: true }); }); + +const b64 = (b: Buffer | string) => Buffer.from(b).toString('base64'); +const make = (free = 100 * 1024 ** 3) => createUploads({ dir, prefix: 'devdeck-file-', freeBytes: async () => free }); +const idOf = (r: Awaited['start']>>) => { if (!('id' in r)) throw new Error(r.error); return r.id; }; + +describe('receiving a file of any kind in pieces', () => { + it('writes the pieces in order and lands the file under its own name in the given folder', async () => { + const up = make(); + const id = idOf(await up.start('..\\..\\Windows\\tool.exe', 6)); + expect(await up.chunk(id, 0, b64(Buffer.from([0x4d, 0x5a, 0])))).toBe(3); + expect(await up.chunk(id, 3, b64(Buffer.from([1, 2, 3])))).toBe(6); + const path = await up.finish(id); + expect(dirname(path!)).toBe(dir); + expect(basename(path!)).toMatch(/^devdeck-file-[0-9a-f-]{36}-tool\.exe$/); + expect([...readFileSync(path!)]).toEqual([0x4d, 0x5a, 0, 1, 2, 3]); + if (process.platform === 'win32') expect(readFileSync(path! + ':Zone.Identifier', 'utf8')).toContain('ZoneId=3'); + }); + + it('takes an empty file', async () => { + const up = make(); + const path = await up.finish(idOf(await up.start('empty.txt', 0))); + expect(readFileSync(path!).length).toBe(0); + }); + + it('refuses a piece out of order, too large, or past the announced size, and drops the upload for the last two', async () => { + const up = make(); + const id = idOf(await up.start('a.bin', 4)); + expect(await up.chunk(id, 2, b64('ab'))).toBeNull(); // not where the last one ended + expect(await up.chunk(id, 0, b64('abcdef'))).toBeNull(); // more than announced + expect(await up.chunk(id, 0, b64('ab'))).toBeNull(); // the upload is gone + expect(readdirSync(dir)).toEqual([]); + const id2 = idOf(await up.start('b.bin', MAX_UPLOAD_BYTES)); + expect(await up.chunk(id2, 0, 'A'.repeat(Math.ceil(UPLOAD_CHUNK_BYTES / 3) * 4 + 4))).toBeNull(); + expect(readdirSync(dir)).toEqual([]); + }); + + it('lands nothing when bytes are missing, and abort removes the part file', async () => { + const up = make(); + const id = idOf(await up.start('a.bin', 10)); + await up.chunk(id, 0, b64('abc')); + expect(await up.finish(id)).toBeNull(); + expect(readdirSync(dir)).toEqual([]); + const id2 = idOf(await up.start('b.bin', 10)); + expect(readdirSync(dir)).toHaveLength(1); + await up.abort(id2); + expect(readdirSync(dir)).toEqual([]); + }); + + it('refuses a name with nothing left, a size over 4GB, and a file that would leave under 2GB free', async () => { + expect(await make().start('...', 1)).toEqual({ error: 'bad-name' }); + expect(await make().start(42, 1)).toEqual({ error: 'bad-name' }); + expect(await make().start('a.bin', MAX_UPLOAD_BYTES + 1)).toEqual({ error: 'too-large' }); + expect(await make().start('a.bin', -1)).toEqual({ error: 'too-large' }); + expect(await make(3 * 1024 ** 3).start('a.bin', 1.5 * 1024 ** 3)).toEqual({ error: 'no-space' }); + // What open uploads still have to write counts too. + const up = make(5 * 1024 ** 3); + idOf(await up.start('a.bin', 2 * 1024 ** 3)); + expect(await up.start('b.bin', 2 * 1024 ** 3)).toEqual({ error: 'no-space' }); + await up.dispose(); + }); + + it('holds at most four uploads open at once', async () => { + const up = make(); + for (let i = 0; i < 4; i++) idOf(await up.start(`f${i}.bin`, 1)); + expect(await up.start('f4.bin', 1)).toEqual({ error: 'busy' }); + await up.dispose(); + expect(readdirSync(dir)).toEqual([]); + }); + + it('answers nothing for an id it never gave', async () => { + const up = make(); + expect(await up.chunk('nope', 0, b64('a'))).toBeNull(); + expect(await up.finish('nope')).toBeNull(); + expect(existsSync(dir)).toBe(true); + }); +}); diff --git a/src/main/fileUploads.ts b/src/main/fileUploads.ts new file mode 100644 index 0000000..8b05a2c --- /dev/null +++ b/src/main/fileUploads.ts @@ -0,0 +1,111 @@ +// src/main/fileUploads.ts +// A file of any kind and size sent to a session on this machine from another one, in pieces. +// +// The link carries at most 8MB per message, so a file travels as a series of chunks appended to a +// `.part` file in the temp directory; when the last byte is in it is renamed to its own name and +// that path is what gets typed into the session. +// +// Any kind is accepted. The device sending it holds "type in sessions", so it can already ask the +// agent here to do anything a file could; refusing an .exe in temp did not take anything away from +// it, and kept people from handing an agent a zip, a spreadsheet or a source file. What stays fixed: +// the file goes only into the temp directory, under a name DevDeck chooses and its sweeper removes +// after a day; DevDeck never opens or runs it; and on Windows it is marked as downloaded from the +// internet, so double-clicking it shows the usual warning. +import { randomUUID } from 'node:crypto'; +import { open, rename, rm, statfs, writeFile, type FileHandle } from 'node:fs/promises'; +import { join } from 'node:path'; +import { MAX_UPLOAD_BYTES, UPLOAD_CHUNK_BYTES, safeUploadName, type UploadStart } from '../shared/sentFiles'; + +/** An upload nobody has added to for this long is abandoned and its part file removed. */ +const IDLE_MS = 5 * 60_000; +/** Uploads open at once; a sender sends one file at a time, so this is only a ceiling. */ +const MAX_OPEN = 4; +/** Disk space an upload must leave free, so a large file never fills the disk the agent works on. */ +const FREE_RESERVE_BYTES = 2 * 1024 ** 3; + +interface Upload { file: string; handle: FileHandle; size: number; written: number; timer: ReturnType } + +export interface UploadDeps { + dir: string; + /** File name prefix the temp sweeper owns. */ + prefix: string; + /** Free bytes on the disk holding `dir`. */ + freeBytes?: (dir: string) => Promise; + platform?: NodeJS.Platform; +} + +export function createUploads(deps: UploadDeps) { + const uploads = new Map(); + const freeBytes = deps.freeBytes ?? (async (dir: string) => { const s = await statfs(dir); return s.bavail * s.bsize; }); + const platform = deps.platform ?? process.platform; + + const drop = async (id: string): Promise => { + const u = uploads.get(id); + if (!u) return; + uploads.delete(id); + clearTimeout(u.timer); + await u.handle.close().catch(() => undefined); + await rm(u.file, { force: true }).catch(() => undefined); + }; + const touch = (id: string, u: Upload): void => { + clearTimeout(u.timer); + u.timer = setTimeout(() => { void drop(id); }, IDLE_MS); + u.timer.unref?.(); + }; + + return { + async start(name: unknown, size: unknown): Promise { + const safe = typeof name === 'string' ? safeUploadName(name) : null; + if (!safe) return { error: 'bad-name' }; + if (typeof size !== 'number' || !Number.isInteger(size) || size < 0 || size > MAX_UPLOAD_BYTES) return { error: 'too-large' }; + if (uploads.size >= MAX_OPEN) return { error: 'busy' }; + // What the uploads already open still have to write counts against the space too. + const pending = [...uploads.values()].reduce((sum, u) => sum + (u.size - u.written), 0); + let free: number; + try { free = await freeBytes(deps.dir); } catch { free = Infinity; } + if (free - pending - size < FREE_RESERVE_BYTES) return { error: 'no-space' }; + const id = randomUUID(); + const file = join(deps.dir, `${deps.prefix}${randomUUID()}-${safe}.part`); + const handle = await open(file, 'wx'); + const u: Upload = { file, handle, size, written: 0, timer: setTimeout(() => {}, 0) }; + uploads.set(id, u); + touch(id, u); + return { id }; + }, + + /** Append the next piece. `offset` must be where the last one ended. Answers the bytes written so far, or null. */ + async chunk(id: unknown, offset: unknown, base64: unknown): Promise { + const u = typeof id === 'string' ? uploads.get(id) : undefined; + if (!u || typeof base64 !== 'string' || offset !== u.written) return null; + if (base64.length > Math.ceil(UPLOAD_CHUNK_BYTES / 3) * 4) { await drop(id as string); return null; } + const bytes = Buffer.from(base64, 'base64'); + if (bytes.length === 0 || u.written + bytes.length > u.size) { await drop(id as string); return null; } + try { await u.handle.write(bytes, 0, bytes.length, u.written); } catch { await drop(id as string); return null; } + u.written += bytes.length; + touch(id as string, u); + return u.written; + }, + + /** Close the file under its own name and answer its path; null unless every byte arrived. */ + async finish(id: unknown): Promise { + const u = typeof id === 'string' ? uploads.get(id) : undefined; + if (!u) return null; + if (u.written !== u.size) { await drop(id as string); return null; } + uploads.delete(id as string); + clearTimeout(u.timer); + const final = u.file.slice(0, -'.part'.length); + try { + await u.handle.close(); + await rename(u.file, final); + } catch { await rm(u.file, { force: true }).catch(() => undefined); return null; } + // Mark of the Web: Windows treats it as downloaded, so opening it from Explorer warns first. + if (platform === 'win32') await writeFile(`${final}:Zone.Identifier`, '[ZoneTransfer]\r\nZoneId=3\r\n').catch(() => undefined); + return final; + }, + + async abort(id: unknown): Promise { if (typeof id === 'string') await drop(id); }, + + /** Remove every unfinished part file; on quit. */ + async dispose(): Promise { await Promise.all([...uploads.keys()].map(drop)); }, + }; +} diff --git a/src/main/link/clientLink.ts b/src/main/link/clientLink.ts index 738098c..89bed09 100644 --- a/src/main/link/clientLink.ts +++ b/src/main/link/clientLink.ts @@ -109,6 +109,8 @@ const HEAVY_REQUEST_TIMEOUT_MS: Record = { 'projects:list': 120_000, 'usage:report': 120_000, 'project:memory': 120_000, + // A 2MB piece of a file being sent; on a slow connection that is well past the default. + 'cockpit:uploadChunk': 120_000, }; export function requestTimeoutFor(method: string, fallback = DEFAULT_REQUEST_TIMEOUT_MS): number { diff --git a/src/preload/preload.ts b/src/preload/preload.ts index 3057bd3..dfd664a 100644 --- a/src/preload/preload.ts +++ b/src/preload/preload.ts @@ -112,6 +112,10 @@ contextBridge.exposeInMainWorld('devdeck', { openFile: (projectPath: string, filePath: string) => ipcRenderer.invoke('cockpit:openFile', projectPath, filePath), receiveImage: (base64: string) => ipcRenderer.invoke('cockpit:receiveImage', base64), receiveFile: (name: string, base64: string) => ipcRenderer.invoke('cockpit:receiveFile', name, base64), + uploadStart: (name: string, size: number) => ipcRenderer.invoke('cockpit:uploadStart', name, size), + uploadChunk: (id: string, offset: number, base64: string) => ipcRenderer.invoke('cockpit:uploadChunk', id, offset, base64), + uploadFinish: (id: string) => ipcRenderer.invoke('cockpit:uploadFinish', id), + uploadAbort: (id: string) => ipcRenderer.invoke('cockpit:uploadAbort', id), createWorktree: (repoPath: string) => ipcRenderer.invoke('cockpit:createWorktree', repoPath), backgroundAgents: () => ipcRenderer.invoke('agents:background'), worktreeInfo: (path: string) => ipcRenderer.invoke('cockpit:worktreeInfo', path), @@ -197,6 +201,10 @@ contextBridge.exposeInMainWorld('devdeck', { gitInfo: (projectPath: string) => ipcRenderer.invoke('link:call', machineId, 'cockpit:gitInfo', [projectPath]), receiveImage: (base64: string) => ipcRenderer.invoke('link:call', machineId, 'cockpit:receiveImage', [base64]), receiveFile: (name: string, base64: string) => ipcRenderer.invoke('link:call', machineId, 'cockpit:receiveFile', [name, base64]), + uploadStart: (name: string, size: number) => ipcRenderer.invoke('link:call', machineId, 'cockpit:uploadStart', [name, size]), + uploadChunk: (id: string, offset: number, base64: string) => ipcRenderer.invoke('link:call', machineId, 'cockpit:uploadChunk', [id, offset, base64]), + uploadFinish: (id: string) => ipcRenderer.invoke('link:call', machineId, 'cockpit:uploadFinish', [id]), + uploadAbort: (id: string) => ipcRenderer.invoke('link:call', machineId, 'cockpit:uploadAbort', [id]), createWorktree: (repoPath: string) => ipcRenderer.invoke('link:call', machineId, 'cockpit:createWorktree', [repoPath]), backgroundAgents: () => ipcRenderer.invoke('link:call', machineId, 'agents:background', []), worktreeInfo: (path: string) => ipcRenderer.invoke('link:call', machineId, 'cockpit:worktreeInfo', [path]), diff --git a/src/renderer/attachments.test.ts b/src/renderer/attachments.test.ts index 8b23fab..45df3ea 100644 --- a/src/renderer/attachments.test.ts +++ b/src/renderer/attachments.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; -import { attachFiles, type AttachTarget } from './attachments'; -import { MAX_SENT_FILE_BYTES } from '../shared/sentFiles'; +import { attachFiles, sendInPieces, type AttachTarget, type UploadApi } from './attachments'; +import { MAX_SENT_FILE_BYTES, MAX_UPLOAD_BYTES, UPLOAD_CHUNK_BYTES } from '../shared/sentFiles'; const remote = (over: Partial = {}): AttachTarget => ({ local: false, @@ -10,7 +10,7 @@ const remote = (over: Partial = {}): AttachTarget => ({ ...over, }); -describe('attaching documents to a session on another PC', () => { +describe('attaching documents to a session on a PC older than uploads', () => { it('sends a document the host can take and types the path it landed at', async () => { const target = remote(); const out = await attachFiles([new File(['step 1 ok\n'], 'build.log')], target); @@ -23,10 +23,78 @@ describe('attaching documents to a session on another PC', () => { expect([out.paths.length, out.hostTooOld, out.failed]).toEqual([0, 1, 0]); }); - it('never sends a kind that could run, or one over the limit', async () => { + it('skips what such a PC cannot take: other kinds, and documents past 5MB', async () => { const target = remote(); const out = await attachFiles([new File(['MZ'], 'setup.exe'), new File([new Uint8Array(MAX_SENT_FILE_BYTES + 1)], 'huge.log')], target); - expect([out.skippedNonImage, out.tooLarge]).toEqual([1, 1]); + expect(out.skippedNonImage).toBe(2); expect(target.receiveFile).not.toHaveBeenCalled(); }); + + it('falls back to the old way when the PC answers that it has no uploads', async () => { + const target = remote({ upload: vi.fn().mockResolvedValue({ error: 'host-too-old' }) }); + const out = await attachFiles([new File(['ok\n'], 'build.log'), new File(['PK'], 'src.zip')], target); + expect(out.paths).toEqual(['C:\\Temp\\devdeck-file-x-build.log']); + expect(out.skippedNonImage).toBe(1); + }); +}); + +/** A host that keeps what it is sent, as the real one writes it to disk. */ +function fakeHost() { + const files = new Map(); + const api: UploadApi = { + uploadStart: vi.fn(async (name: string, size: number) => { + if (size > MAX_UPLOAD_BYTES) return { error: 'too-large' as const }; + const id = 'u' + files.size; files.set(id, { name, size, data: [] }); return { id }; + }), + uploadChunk: vi.fn(async (id: string, offset: number, b64: string) => { + const f = files.get(id)!; if (offset !== f.data.length) return null; + const bin = atob(b64); for (let i = 0; i < bin.length; i++) f.data.push(bin.charCodeAt(i)); return f.data.length; + }), + uploadFinish: vi.fn(async (id: string) => { const f = files.get(id)!; return f.data.length === f.size ? 'C:\\Temp\\devdeck-file-' + id + '-' + f.name : null; }), + uploadAbort: vi.fn(async () => {}), + }; + return { api, files }; +} + +describe('sending any file to a session on another PC', () => { + it('sends a kind the old way refused, as it is, and types where it landed', async () => { + const host = fakeHost(); + const target = remote({ upload: (file, onProgress) => sendInPieces(file, host.api, onProgress) }); + const out = await attachFiles([new File([new Uint8Array([0x4d, 0x5a, 0, 1])], 'tool.exe'), new File(['PK\x03\x04'], 'src.zip')], target); + expect(out.paths).toEqual(['C:\\Temp\\devdeck-file-u0-tool.exe', 'C:\\Temp\\devdeck-file-u1-src.zip']); + expect(host.files.get('u0')!.data).toEqual([0x4d, 0x5a, 0, 1]); + expect(target.receiveFile).not.toHaveBeenCalled(); + }); + + it('splits a large file into pieces in order and reports progress', async () => { + const host = fakeHost(); + const size = UPLOAD_CHUNK_BYTES * 2 + 123; + const bytes = new Uint8Array(size); for (let i = 0; i < size; i++) bytes[i] = i % 251; + const seen: number[] = []; + const r = await sendInPieces(new File([bytes], 'big.bin'), host.api, (n) => seen.push(n)); + expect(r).toEqual({ path: 'C:\\Temp\\devdeck-file-u0-big.bin' }); + expect(host.api.uploadChunk).toHaveBeenCalledTimes(3); + expect(seen).toEqual([UPLOAD_CHUNK_BYTES, UPLOAD_CHUNK_BYTES * 2, size]); + expect(host.files.get('u0')!.data.length).toBe(size); + expect(host.files.get('u0')!.data[size - 1]).toBe((size - 1) % 251); + }); + + it('sends an empty file', async () => { + const host = fakeHost(); + expect(await sendInPieces(new File([], 'empty.txt'), host.api)).toEqual({ path: 'C:\\Temp\\devdeck-file-u0-empty.txt' }); + }); + + it('abandons the upload when a piece is refused, and says so', async () => { + const host = fakeHost(); + host.api.uploadChunk = vi.fn().mockResolvedValue(null); + expect(await sendInPieces(new File(['abc'], 'a.bin'), host.api)).toEqual({ error: 'failed' }); + expect(host.api.uploadAbort).toHaveBeenCalledWith('u0'); + }); + + it('tells a PC without uploads, a full disk and a refused size apart', async () => { + const api = (start: UploadApi['uploadStart']): UploadApi => ({ ...fakeHost().api, uploadStart: start }); + expect(await sendInPieces(new File(['x'], 'a'), api(vi.fn().mockRejectedValue(new Error('no such method: cockpit:uploadStart'))))).toEqual({ error: 'host-too-old' }); + expect(await sendInPieces(new File(['x'], 'a'), api(vi.fn().mockResolvedValue({ error: 'no-space' })))).toEqual({ error: 'no-space' }); + expect(await sendInPieces(new File(['x'], 'a'), api(vi.fn().mockResolvedValue({ error: 'too-large' })))).toEqual({ error: 'too-large' }); + }); }); diff --git a/src/renderer/attachments.ts b/src/renderer/attachments.ts index 26ecdf0..7f5d99e 100644 --- a/src/renderer/attachments.ts +++ b/src/renderer/attachments.ts @@ -4,8 +4,9 @@ // An agent takes a file by its PATH, typed into its prompt — the same trick the Ctrl+V image paste // uses. A local session gets the file's own path. A session on another machine cannot read this // disk, so the file travels instead and the path it was written to over there is what gets typed: -// an image as PNG, a document (text, data, log, PDF — see shared/sentFiles) as it is. -import { MAX_SENT_FILE_BYTES, SENDABLE_FILE_RE } from '../shared/sentFiles'; +// an image as PNG, anything else as it is, in pieces (see main/fileUploads). A machine older than +// that takes only the documents shared/sentFiles lists, up to 5MB. +import { MAX_SENT_FILE_BYTES, MAX_UPLOAD_BYTES, SENDABLE_FILE_RE, UPLOAD_CHUNK_BYTES, type UploadStart } from '../shared/sentFiles'; /** What the host accepts per image (see cockpit:receiveImage); kept a little under it. */ const MAX_IMAGE_BYTES = 5 * 1024 * 1024 - 64 * 1024; @@ -18,14 +19,28 @@ export interface AttachTarget { receiveImage(base64: string): Promise; /** Writes a document where the session runs and returns that path; absent where it cannot. */ receiveFile?(name: string, base64: string): Promise; + /** Sends a file of any kind in pieces (see sendInPieces); absent where it cannot. */ + upload?(file: File, onProgress: (sentBytes: number) => void): Promise; pathForFile(file: File): string; + /** Told how far each file being sent in pieces has got. */ + onProgress?(file: File, sentBytes: number): void; +} + +export type UploadResult = { path: string } | { error: 'too-large' | 'no-space' | 'failed' | 'host-too-old' }; + +/** The calls one upload takes; a machine's cockpit API has them. */ +export interface UploadApi { + uploadStart(name: string, size: number): Promise; + uploadChunk(id: string, offset: number, base64: string): Promise; + uploadFinish(id: string): Promise; + uploadAbort(id: string): Promise; } /** - * `skippedNonImage`: kinds that cannot travel at all. `hostTooOld`: documents the other machine - * could not take because its DevDeck predates receiving them. + * `skippedNonImage`: files the other machine's older DevDeck cannot take (any kind but its short + * list, or past 5MB). `hostTooOld`: documents it cannot take at all. `noSpace`: its disk is too full. */ -export interface AttachOutcome { paths: string[]; skippedNonImage: number; failed: number; tooLarge: number; hostTooOld: number } +export interface AttachOutcome { paths: string[]; skippedNonImage: number; failed: number; tooLarge: number; hostTooOld: number; noSpace: number } export function isImageFile(file: File): boolean { return IMAGE_TYPE_RE.test(file.type) || IMAGE_NAME_RE.test(file.name); @@ -35,38 +50,74 @@ export function isSendableFile(file: File): boolean { return SENDABLE_FILE_RE.test(file.name); } -/** What the 📎 picker offers for a session on another machine. */ -export const REMOTE_ACCEPT = 'image/png,image/jpeg,image/gif,image/webp,image/bmp,' + - '.txt,.log,.md,.markdown,.json,.jsonl,.ndjson,.csv,.tsv,.yaml,.yml,.xml,.toml,.ini,.cfg,.conf,.diff,.patch,.sql,.pdf'; - export async function attachFiles(files: File[], target: AttachTarget): Promise { - const out: AttachOutcome = { paths: [], skippedNonImage: 0, failed: 0, tooLarge: 0, hostTooOld: 0 }; + const out: AttachOutcome = { paths: [], skippedNonImage: 0, failed: 0, tooLarge: 0, hostTooOld: 0, noSpace: 0 }; for (const file of files) { const own = target.local ? target.pathForFile(file) : ''; if (own) { out.paths.push(own); continue; } // Remote, or dragged out of a browser with no file behind it: the file itself has to travel. - if (!isImageFile(file)) { - if (!target.receiveFile || !isSendableFile(file)) { out.skippedNonImage++; continue; } - if (file.size > MAX_SENT_FILE_BYTES) { out.tooLarge++; continue; } - let landed: string | null = null; - try { landed = await target.receiveFile(file.name, bytesToBase64(new Uint8Array(await file.arrayBuffer()))); } - catch (error) { - if (/no such method/.test(error instanceof Error ? error.message : String(error))) { out.hostTooOld++; continue; } - landed = null; + // An image goes as a PNG the agent can look at; one too large for that goes as it is. + if (isImageFile(file)) { + let png: string | null; + try { png = await toPngBase64(file); } catch { png = null; } + if (png !== null) { + let landed: string | null = null; + try { landed = await target.receiveImage(png); } catch { landed = null; } + if (landed) out.paths.push(landed); else out.failed++; + continue; } - if (landed) out.paths.push(landed); else out.failed++; - continue; + if (!target.upload) { out.tooLarge++; continue; } } - let png: string | null; - try { png = await toPngBase64(file); } catch { png = null; } - if (png === null) { out.tooLarge++; continue; } + if (target.upload) { + if (file.size > MAX_UPLOAD_BYTES) { out.tooLarge++; continue; } + const sent = await target.upload(file, (n) => target.onProgress?.(file, n)); + if ('path' in sent) { out.paths.push(sent.path); continue; } + if (sent.error === 'too-large') { out.tooLarge++; continue; } + if (sent.error === 'no-space') { out.noSpace++; continue; } + if (sent.error === 'failed') { out.failed++; continue; } + // An older machine: what it can take still goes the old way. + } + if (!target.receiveFile || !isSendableFile(file) || file.size > MAX_SENT_FILE_BYTES) { out.skippedNonImage++; continue; } let landed: string | null = null; - try { landed = await target.receiveImage(png); } catch { landed = null; } + try { landed = await target.receiveFile(file.name, bytesToBase64(new Uint8Array(await file.arrayBuffer()))); } + catch (error) { + if (/no such method/.test(error instanceof Error ? error.message : String(error))) { out.hostTooOld++; continue; } + landed = null; + } if (landed) out.paths.push(landed); else out.failed++; } return out; } +/** + * A file sent to another machine in pieces of UPLOAD_CHUNK_BYTES, read off disk one piece at a + * time (a 4GB file is never in memory at once). `host-too-old` when that machine predates uploads. + */ +export async function sendInPieces(file: File, api: UploadApi, onProgress?: (sentBytes: number) => void): Promise { + let started: UploadStart; + try { started = await api.uploadStart(file.name, file.size); } + catch (error) { + return { error: /no such method/.test(error instanceof Error ? error.message : String(error)) ? 'host-too-old' : 'failed' }; + } + if ('error' in started) return { error: started.error === 'too-large' ? 'too-large' : started.error === 'no-space' ? 'no-space' : 'failed' }; + const { id } = started; + try { + let offset = 0; + while (offset < file.size) { + const piece = new Uint8Array(await file.slice(offset, offset + UPLOAD_CHUNK_BYTES).arrayBuffer()); + const next = await api.uploadChunk(id, offset, bytesToBase64(piece)); + if (next !== offset + piece.length) throw new Error('piece refused'); + offset = next; + onProgress?.(offset); + } + const path = await api.uploadFinish(id); + return path ? { path } : { error: 'failed' }; + } catch { + void api.uploadAbort(id).catch(() => undefined); + return { error: 'failed' }; + } +} + /** Paths as one prompt fragment; a path with a space is quoted so it stays one argument. */ export function pathsAsPrompt(paths: string[]): string { return paths.map((p) => (/\s/.test(p) ? `"${p}"` : p)).join(' ') + ' '; diff --git a/src/renderer/cockpitView.ts b/src/renderer/cockpitView.ts index 00ad22f..da9153f 100644 --- a/src/renderer/cockpitView.ts +++ b/src/renderer/cockpitView.ts @@ -17,7 +17,7 @@ import { cockpitNavigationId, cockpitNavigationIdForRuntime, createCockpitTileId import { toAgentId, type AgentId, type OpenMode, type StaleLevel } from '../shared/types'; import { createProviderLogo, providerName } from './providerLogo'; import { tr, currentLang } from './i18n-runtime'; -import { toast, reportSaveError } from './loadError'; +import { toast, reportSaveError, progressToast } from './loadError'; import { setActiveUsageProvider } from './usageBar'; import { reportShutdownActivity } from './shutdown'; import { createIcon, type IconName } from './icons'; @@ -29,7 +29,7 @@ import { basename } from '../shared/paths'; import type { PtySessionInfo as RunningSession } from '../main/ptyHost'; import { currentImageRefs, currentImageSourceKey, imageStripState, imageThumbUrl, initImageStrip, openImageById, openSingleImage, refreshImages, showImagesFor, toggleImageStrip, type ImageSource } from './imageStrip'; import { jumpToPrompt, marksFor, promptRows, revealRow } from './termMarks'; -import { attachFiles, pathsAsPrompt, REMOTE_ACCEPT } from './attachments'; +import { attachFiles, pathsAsPrompt, sendInPieces } from './attachments'; import { initTimeline, isTimelineOpen, refreshTimeline, showTimelineFor, toggleTimeline, type TimelineSource } from './timeline'; import { pairTurnsWithRows } from '../shared/timeline'; import { isQuickReplyMenuOpen, quickReplies, setQuickReplies, toggleQuickReplyMenu } from './quickReplyMenu'; @@ -791,12 +791,22 @@ function pasteClipboardText(term: Terminal): void { /** Give files to a session: by their own path locally, as uploaded copies on another machine. */ async function attachToTerminal(files: File[], machineId: string, term: Terminal): Promise { + // A file that takes more than a moment to send says how far it has got, so a large one does not + // look like nothing happened. + let progress = null as ReturnType | null; const out = await attachFiles(files, { local: machineId === LOCAL_MACHINE_ID, receiveImage: (b64) => deckFor(machineId).cockpit.receiveImage(b64), receiveFile: (name, b64) => deckFor(machineId).cockpit.receiveFile(name, b64), + upload: (file, onProgress) => sendInPieces(file, deckFor(machineId).cockpit, onProgress), pathForFile: (f) => window.devdeck.cockpit.pathForFile(f), + onProgress: (file, sent) => { + if (file.size < 8 * 1024 * 1024) return; + progress ??= progressToast(); + progress.set(tr('cockpit.attach_sending', { name: file.name, percent: String(Math.floor((sent / file.size) * 100)), size: formatBytes(file.size) })); + }, }); + progress?.close(); if (out.paths.length) { term.paste(pathsAsPrompt(out.paths)); term.focus(); } // Every file that did not make it is said out loud: an attachment that silently went missing is // one the person goes on talking about while the agent never saw it. @@ -804,16 +814,20 @@ async function attachToTerminal(files: File[], machineId: string, term: Terminal if (out.paths.length) notes.push(tr('cockpit.attached', { count: String(out.paths.length) })); if (out.skippedNonImage) notes.push(tr('cockpit.attach_kinds_only', { count: String(out.skippedNonImage), machine: machineName(machineId) })); if (out.hostTooOld) notes.push(tr('cockpit.attach_host_too_old', { count: String(out.hostTooOld), machine: machineName(machineId) })); - if (out.tooLarge) notes.push(tr('cockpit.remote_image_too_large')); - if (out.failed) notes.push(tr('cockpit.remote_image_failed')); + if (out.tooLarge) notes.push(tr('cockpit.attach_too_large', { count: String(out.tooLarge) })); + if (out.noSpace) notes.push(tr('cockpit.attach_no_space', { count: String(out.noSpace), machine: machineName(machineId) })); + if (out.failed) notes.push(tr('cockpit.attach_failed', { count: String(out.failed) })); if (notes.length) toast(notes.join(' · ')); } +function formatBytes(n: number): string { + if (n >= 1024 ** 3) return `${(n / 1024 ** 3).toFixed(1)} GB`; + return `${Math.max(1, Math.round(n / 1024 ** 2))} MB`; +} + /** 📎: the same as a drop, through the system file picker. */ function pickAttachments(machineId: string, term: Terminal): void { const input = document.createElement('input'); input.type = 'file'; input.multiple = true; - // A remote session takes images and documents (see shared/sentFiles), so the picker offers those there. - if (machineId !== LOCAL_MACHINE_ID) input.accept = REMOTE_ACCEPT; input.addEventListener('change', () => { const files = Array.from(input.files ?? []); if (files.length) void attachToTerminal(files, machineId, term); }); input.click(); } diff --git a/src/renderer/global.d.ts b/src/renderer/global.d.ts index ee88893..c1858f6 100644 --- a/src/renderer/global.d.ts +++ b/src/renderer/global.d.ts @@ -66,6 +66,10 @@ declare global { gitInfo(projectPath: string): Promise<{ branch: string | null; dirty: number } | null>; receiveImage(base64: string): Promise; receiveFile(name: string, base64: string): Promise; + uploadStart(name: string, size: number): Promise; + uploadChunk(id: string, offset: number, base64: string): Promise; + uploadFinish(id: string): Promise; + uploadAbort(id: string): Promise; createWorktree(repoPath: string): Promise<{ worktree?: import('../main/worktrees').Worktree; error?: string }>; backgroundAgents(): Promise; worktreeInfo(path: string): Promise<{ worktree: import('../main/worktrees').Worktree; changes: import('../main/worktrees').WorktreeChanges } | null>; @@ -166,6 +170,10 @@ declare global { openFile(projectPath: string, filePath: string): Promise; receiveImage(base64: string): Promise; receiveFile(name: string, base64: string): Promise; + uploadStart(name: string, size: number): Promise; + uploadChunk(id: string, offset: number, base64: string): Promise; + uploadFinish(id: string): Promise; + uploadAbort(id: string): Promise; createWorktree(repoPath: string): Promise<{ worktree?: import('../main/worktrees').Worktree; error?: string }>; backgroundAgents(): Promise; worktreeInfo(path: string): Promise<{ worktree: import('../main/worktrees').Worktree; changes: import('../main/worktrees').WorktreeChanges } | null>; diff --git a/src/renderer/loadError.ts b/src/renderer/loadError.ts index c456906..9a53594 100644 --- a/src/renderer/loadError.ts +++ b/src/renderer/loadError.ts @@ -24,6 +24,14 @@ export function toast(message: string): void { setTimeout(() => el.remove(), 6000); } +/** A toast that stays and can be rewritten while something long runs (a large file being sent). */ +export function progressToast(): { set(message: string): void; close(): void } { + const host = document.getElementById('toast-host'); + const el = document.createElement('div'); el.className = 'toast'; el.setAttribute('role', 'status'); + host?.appendChild(el); + return { set: (message) => { el.textContent = message; }, close: () => el.remove() }; +} + /** Handled write failures still belong in diagnostics; never include the user's draft text. */ export function reportSaveError(error: unknown): void { toast(tr('common.save_failed')); diff --git a/src/renderer/locales/en.json b/src/renderer/locales/en.json index 186f494..6bdefc6 100644 --- a/src/renderer/locales/en.json +++ b/src/renderer/locales/en.json @@ -469,7 +469,7 @@ "cockpit.remote_file_unavailable": "That file is on {machine}, not on this computer.", "cockpit.redrawn": "Redrew {count} terminal(s) from their sessions.", "cockpit.attach": "Attach files", - "cockpit.attach_remote": "Attach images or documents (sent to the machine running this session)", + "cockpit.attach_remote": "Attach files — any kind, up to 4 GB (sent to the machine running this session)", "cockpit.attached": "📎 Attached {count}", "cockpit.images_show": "Show images from this session", "cockpit.images_hide": "Hide images", @@ -534,8 +534,12 @@ "cockpit.rename_hint": "Enter to save · Escape to cancel", "cockpit.rename_save": "Save title", "cockpit.rename_cancel": "Cancel", - "cockpit.attach_kinds_only": "{count} skipped — only images and text, data, log or PDF files can be sent to {machine}", + "cockpit.attach_kinds_only": "{count} skipped — DevDeck on {machine} is too old for this file (from 1.45.0 any kind of file up to 4 GB can be sent)", "cockpit.attach_host_too_old": "{count} skipped — DevDeck on {machine} is too old to receive documents (update it to 1.41.0)", + "cockpit.attach_failed": "Could not send {count} — nothing was pasted for it", + "cockpit.attach_no_space": "{count} skipped — not enough free disk space on {machine}", + "cockpit.attach_too_large": "{count} skipped — too large (4 GB limit)", + "cockpit.attach_sending": "📎 Sending {name}… {percent}% ({size})", "cockpit.quick_replies": "Quick replies", "cockpit.quick_replies_hint": "Edit these in Settings → Quick replies", "cockpit.quick_default_1": "Yes, go ahead", @@ -640,6 +644,9 @@ "cockpit.bg_state_working": "working", "cockpit.bg_state_idle": "idle", "cockpit.bg_state_failed": "failed", + "whatsnew.f.send-any-file.title": "Send any file to a session on another PC", + "whatsnew.f.send-any-file.body": "Zip, spreadsheet, source file, video: any kind, up to 4 GB (it was 21 document kinds, 5 MB). Large files go in pieces with their progress shown.", + "whatsnew.f.send-any-file.where": "The 📎 button in a remote session's header, or drop onto its terminal", "whatsnew.f.background-agents.title": "Claude's background sessions in the sidebar", "whatsnew.f.background-agents.body": "Sessions started with claude --bg or from Claude's agent view now show up, with the ones waiting for you marked. Click one to open it in a tile; it keeps running as it was.", "whatsnew.f.background-agents.where": "The \"Claude background sessions\" group in the sidebar" diff --git a/src/renderer/locales/ja.json b/src/renderer/locales/ja.json index 9441f6a..b65e9f2 100644 --- a/src/renderer/locales/ja.json +++ b/src/renderer/locales/ja.json @@ -469,7 +469,7 @@ "cockpit.remote_file_unavailable": "そのファイルはこの PC ではなく {machine} にあります。", "cockpit.redrawn": "ターミナル {count} 個をセッションから描き直しました。", "cockpit.attach": "ファイルを添付", - "cockpit.attach_remote": "画像・ドキュメントを添付 (このセッションを実行中のマシンへ送信)", + "cockpit.attach_remote": "ファイルを添付 — 種類を問わず 4GB まで (このセッションを実行中のマシンへ送信)", "cockpit.attached": "📎 {count} 件を添付", "cockpit.images_show": "このセッションの画像を表示", "cockpit.images_hide": "画像を隠す", @@ -534,8 +534,12 @@ "cockpit.rename_hint": "Enterで保存 · Escapeでキャンセル", "cockpit.rename_save": "名前を保存", "cockpit.rename_cancel": "キャンセル", - "cockpit.attach_kinds_only": "{count} 件をスキップ — {machine} には画像とテキスト・データ・ログ・PDF ファイルのみ送信できます", + "cockpit.attach_kinds_only": "{count} 件をスキップ — {machine} の DevDeck は古いバージョンのため、このファイルを受け取れません (1.45.0 以降はあらゆる種類のファイルを 4GB まで送信できます)", "cockpit.attach_host_too_old": "{count} 件をスキップ — {machine} の DevDeck はドキュメントを受け取れないバージョンです (1.41.0 に更新してください)", + "cockpit.attach_failed": "{count} 件を送信できませんでした — 貼り付けていません", + "cockpit.attach_no_space": "{count} 件をスキップ — {machine} のディスクの空き容量が不足しています", + "cockpit.attach_too_large": "{count} 件をスキップ — ファイルが大きすぎます (上限 4GB)", + "cockpit.attach_sending": "📎 {name} を送信中… {percent}% ({size})", "cockpit.quick_replies": "クイック返信", "cockpit.quick_replies_hint": "設定 → クイック返信 で変更できます", "cockpit.quick_default_1": "はい、続けてください", @@ -640,6 +644,9 @@ "cockpit.bg_state_working": "作業中", "cockpit.bg_state_idle": "待機", "cockpit.bg_state_failed": "失敗", + "whatsnew.f.send-any-file.title": "別の PC のセッションにどんなファイルでも", + "whatsnew.f.send-any-file.body": "zip、Excel、ソースファイル、動画まで、種類を問わず 4GB まで送れます (以前はドキュメント 21 種、5MB)。大きなファイルは分割して送り、進行状況を表示します。", + "whatsnew.f.send-any-file.where": "リモートセッションのヘッダーの 📎 ボタン、またはターミナルへのドラッグ&ドロップ", "whatsnew.f.background-agents.title": "Claude のバックグラウンドセッションをサイドバーに", "whatsnew.f.background-agents.body": "claude --bg や Claude のエージェント画面から始めたセッションが表示され、返答を待っているものは区別して示されます。クリックするとタイルで開き、セッションはそのまま続きます。", "whatsnew.f.background-agents.where": "サイドバーの「Claude バックグラウンドセッション」グループ" diff --git a/src/renderer/locales/ko.json b/src/renderer/locales/ko.json index 2657c0c..e3d30d8 100644 --- a/src/renderer/locales/ko.json +++ b/src/renderer/locales/ko.json @@ -469,7 +469,7 @@ "cockpit.remote_file_unavailable": "그 파일은 이 컴퓨터가 아니라 {machine}에 있습니다.", "cockpit.redrawn": "터미널 {count}개를 세션에서 다시 그렸습니다.", "cockpit.attach": "파일 첨부", - "cockpit.attach_remote": "이미지·문서 첨부 (이 세션을 실행 중인 기기로 전송)", + "cockpit.attach_remote": "파일 첨부 — 모든 종류, 4GB까지 (이 세션을 실행 중인 기기로 전송)", "cockpit.attached": "📎 {count}개 첨부함", "cockpit.images_show": "이 세션의 이미지 보기", "cockpit.images_hide": "이미지 숨기기", @@ -534,8 +534,12 @@ "cockpit.rename_hint": "Enter로 저장 · Escape로 취소", "cockpit.rename_save": "제목 저장", "cockpit.rename_cancel": "취소", - "cockpit.attach_kinds_only": "{count}개 건너뜀 — {machine}에는 이미지와 텍스트·데이터·로그·PDF 파일만 보낼 수 있습니다", + "cockpit.attach_kinds_only": "{count}개 건너뜀 — {machine}의 DevDeck은 이 파일을 받을 수 없는 이전 버전입니다 (1.45.0 이상으로 업데이트하면 모든 종류를 4GB까지 보낼 수 있습니다)", "cockpit.attach_host_too_old": "{count}개 건너뜀 — {machine}의 DevDeck이 문서를 받을 수 없는 버전입니다 (1.41.0으로 업데이트하세요)", + "cockpit.attach_failed": "{count}개를 보내지 못했습니다 — 붙여넣지 않았습니다", + "cockpit.attach_no_space": "{count}개 건너뜀 — {machine}의 디스크 여유 공간이 부족합니다", + "cockpit.attach_too_large": "{count}개 건너뜀 — 파일이 너무 큽니다 (4GB 제한)", + "cockpit.attach_sending": "📎 {name} 보내는 중… {percent}% ({size})", "cockpit.quick_replies": "빠른 답장", "cockpit.quick_replies_hint": "설정 → 빠른 답장에서 바꿀 수 있습니다", "cockpit.quick_default_1": "네 계속 진행해줘", @@ -640,6 +644,9 @@ "cockpit.bg_state_working": "작업 중", "cockpit.bg_state_idle": "대기", "cockpit.bg_state_failed": "실패", + "whatsnew.f.send-any-file.title": "다른 PC 세션에 어떤 파일이든", + "whatsnew.f.send-any-file.body": "zip, 엑셀, 소스 파일, 영상까지 종류를 가리지 않고 4GB까지 보낼 수 있습니다(이전에는 문서 21종, 5MB). 큰 파일은 나눠서 보내며 진행률이 표시됩니다.", + "whatsnew.f.send-any-file.where": "다른 PC 세션 헤더의 📎 버튼, 또는 터미널에 끌어다 놓기", "whatsnew.f.background-agents.title": "Claude 백그라운드 세션을 사이드바에", "whatsnew.f.background-agents.body": "claude --bg나 Claude 에이전트 화면에서 띄운 세션이 이제 보이고, 내 응답을 기다리는 세션은 따로 표시됩니다. 클릭하면 타일로 열리며 세션은 그대로 계속됩니다.", "whatsnew.f.background-agents.where": "사이드바의 \"Claude 백그라운드 세션\" 그룹" diff --git a/src/renderer/locales/zh.json b/src/renderer/locales/zh.json index 1c6ed80..ac69ff8 100644 --- a/src/renderer/locales/zh.json +++ b/src/renderer/locales/zh.json @@ -469,7 +469,7 @@ "cockpit.remote_file_unavailable": "该文件位于 {machine},而不是这台电脑上。", "cockpit.redrawn": "已从会话重绘 {count} 个终端。", "cockpit.attach": "附加文件", - "cockpit.attach_remote": "附加图片或文档 (发送到运行此会话的设备)", + "cockpit.attach_remote": "附加文件 — 任何类型,最大 4GB (发送到运行此会话的设备)", "cockpit.attached": "📎 已附加 {count} 个", "cockpit.images_show": "显示此会话的图片", "cockpit.images_hide": "隐藏图片", @@ -534,8 +534,12 @@ "cockpit.rename_hint": "Enter保存 · Escape取消", "cockpit.rename_save": "保存名称", "cockpit.rename_cancel": "取消", - "cockpit.attach_kinds_only": "跳过 {count} 个 — 只能向 {machine} 发送图片以及文本、数据、日志或 PDF 文件", + "cockpit.attach_kinds_only": "跳过 {count} 个 — {machine} 上的 DevDeck 版本过旧,无法接收此文件 (1.45.0 起可发送任何类型的文件,最大 4GB)", "cockpit.attach_host_too_old": "跳过 {count} 个 — {machine} 上的 DevDeck 版本过旧,无法接收文档 (请更新到 1.41.0)", + "cockpit.attach_failed": "{count} 个未能发送 — 未粘贴", + "cockpit.attach_no_space": "跳过 {count} 个 — {machine} 的磁盘可用空间不足", + "cockpit.attach_too_large": "跳过 {count} 个 — 文件过大 (上限 4GB)", + "cockpit.attach_sending": "📎 正在发送 {name}… {percent}% ({size})", "cockpit.quick_replies": "快捷回复", "cockpit.quick_replies_hint": "可在 设置 → 快捷回复 中修改", "cockpit.quick_default_1": "好的,继续", @@ -640,6 +644,9 @@ "cockpit.bg_state_working": "工作中", "cockpit.bg_state_idle": "空闲", "cockpit.bg_state_failed": "失败", + "whatsnew.f.send-any-file.title": "向另一台 PC 上的会话发送任何文件", + "whatsnew.f.send-any-file.body": "zip、表格、源文件、视频,任何类型,最大 4GB (以前为 21 种文档、5MB)。大文件分块发送并显示进度。", + "whatsnew.f.send-any-file.where": "远程会话标题栏的 📎 按钮,或拖放到其终端", "whatsnew.f.background-agents.title": "侧边栏显示 Claude 后台会话", "whatsnew.f.background-agents.body": "用 claude --bg 或在 Claude 智能体视图中启动的会话现在会显示出来,等待你回应的会单独标出。点击即可在面板中打开,会话照常继续。", "whatsnew.f.background-agents.where": "侧边栏中的“Claude 后台会话”分组" diff --git a/src/renderer/machineDeck.ts b/src/renderer/machineDeck.ts index a757c38..38a6d90 100644 --- a/src/renderer/machineDeck.ts +++ b/src/renderer/machineDeck.ts @@ -45,6 +45,10 @@ export interface DeckFacade { gitInfo: (projectPath: string) => Promise<{ branch: string | null; dirty: number } | null>; receiveImage: (base64: string) => Promise; receiveFile: (name: string, base64: string) => Promise; + uploadStart: (name: string, size: number) => Promise; + uploadChunk: (id: string, offset: number, base64: string) => Promise; + uploadFinish: (id: string) => Promise; + uploadAbort: (id: string) => Promise; createWorktree: (repoPath: string) => Promise<{ worktree?: import('../main/worktrees').Worktree; error?: string }>; backgroundAgents: () => Promise; worktreeInfo: (path: string) => Promise<{ worktree: import('../main/worktrees').Worktree; changes: import('../main/worktrees').WorktreeChanges } | null>; @@ -113,6 +117,11 @@ export function deckFor(machineId: string): DeckFacade { receiveImage: (base64) => remote.cockpit.receiveImage(base64), // An older host has no receiveFile: its 'no such method' is passed on, so the sender can say why. receiveFile: (name, base64) => remote.cockpit.receiveFile(name, base64), + // An older host has no uploads either; the sender falls back to receiveFile on 'no such method'. + uploadStart: (name, size) => remote.cockpit.uploadStart(name, size), + uploadChunk: (id, offset, base64) => remote.cockpit.uploadChunk(id, offset, base64), + uploadFinish: (id) => remote.cockpit.uploadFinish(id), + uploadAbort: (id) => remote.cockpit.uploadAbort(id).catch(() => undefined), // An older host has no workspaces: creating one says so, reading one finds none. createWorktree: (repoPath) => remote.cockpit.createWorktree(repoPath).catch((error: unknown) => ({ error: /no such method/.test(String(error)) ? HOST_TOO_OLD : String(error) })), worktreeInfo: (path) => remote.cockpit.worktreeInfo(path).catch(() => null), diff --git a/src/renderer/whatsNew.test.ts b/src/renderer/whatsNew.test.ts index 86e97ec..daa31ab 100644 --- a/src/renderer/whatsNew.test.ts +++ b/src/renderer/whatsNew.test.ts @@ -13,9 +13,9 @@ describe('when the What\'s new window opens', () => { }); it('lists only what is newer than what was last shown, newest first', () => { - expect(featuresAfter('1.41.0').map((f) => f.version)).toEqual(['1.44.0', '1.43.0', '1.42.0']); + expect(featuresAfter('1.41.0').map((f) => f.version)).toEqual(['1.45.0', '1.44.0', '1.43.0', '1.42.0']); const all = featuresAfter(null).map((f) => f.version); - expect(all[0]).toBe('1.44.0'); + expect(all[0]).toBe('1.45.0'); expect([...all].sort((a, b) => compareVersions(b, a))).toEqual(all); }); diff --git a/src/renderer/whatsNew.ts b/src/renderer/whatsNew.ts index 3c78fd5..24744e8 100644 --- a/src/renderer/whatsNew.ts +++ b/src/renderer/whatsNew.ts @@ -25,6 +25,7 @@ export interface Feature { /** Newest first. A feature's NEW dot is shown on its `target` until the person uses it. */ export const FEATURES: readonly Feature[] = [ // No Show me: the group exists only while Claude has background sessions running. + { id: 'send-any-file', version: '1.45.0', icon: 'paperclip', target: { view: 'cockpit', selector: '#ck-header .ck-attach-btn' } }, { id: 'background-agents', version: '1.44.0', icon: 'sessions' }, { id: 'worktree-sessions', version: '1.43.0', icon: 'branch', target: { view: 'cockpit', selector: '#ck-header .ck-wt-new' } }, { id: 'program-status', version: '1.42.0', icon: 'terminal', target: { view: 'settings', selector: '#set-program-status' } }, diff --git a/src/shared/sentFiles.ts b/src/shared/sentFiles.ts index c269f75..9a628f2 100644 --- a/src/shared/sentFiles.ts +++ b/src/shared/sentFiles.ts @@ -1,11 +1,10 @@ // src/shared/sentFiles.ts -// Which files besides images may be sent to a session on another machine, and how one is checked -// before it is written there. +// Files sent to a session on another machine. // -// The host writes what it is sent into its temp directory for the agent to read off a path, on -// behalf of a device that merely holds "type in sessions". So the kinds are documents an agent reads -// — text, data, logs, PDF — and never anything a double-click would run: no scripts, no -// executables, no HTML. +// Since 1.45.0 any kind travels, in pieces, up to MAX_UPLOAD_BYTES (see main/fileUploads for why +// the kind is no longer checked). The list below is what a host older than that takes through +// receiveFile, one message of at most 5MB: documents an agent reads (text, data, logs, PDF) whose +// bytes must match the name. A sender talking to such a host still uses it. /** File kinds that travel; checked on the sender (to say so up front) and again on the host. */ export const SENDABLE_FILE_RE = /\.(?:txt|log|md|markdown|json|jsonl|ndjson|csv|tsv|ya?ml|xml|toml|ini|cfg|conf|diff|patch|sql|pdf)$/i; @@ -13,17 +12,33 @@ export const SENDABLE_FILE_RE = /\.(?:txt|log|md|markdown|json|jsonl|ndjson|csv| export const MAX_SENT_FILE_BYTES = 5 * 1024 * 1024; const NAME_MAX = 80; +/** Largest file one upload (main/fileUploads) may be: any kind, sent in pieces. */ +export const MAX_UPLOAD_BYTES = 4 * 1024 ** 3; +/** Largest piece per call, before base64: well inside the link's 8MB frame once encoded. */ +export const UPLOAD_CHUNK_BYTES = 2 * 1024 * 1024; +/** How the machine receiving a file answers the start of an upload. */ +export type UploadStart = { id: string } | { error: 'bad-name' | 'too-large' | 'no-space' | 'busy' }; + /** * The sender's file name, made safe to write as the tail of a temp file name: no directories, no * characters Windows refuses, at most NAME_MAX characters with the extension kept. Null for a kind * that does not travel. */ export function safeSentFileName(name: string): string | null { + const clean = cleanFileName(name); + return clean && SENDABLE_FILE_RE.test(clean) ? clean : null; +} + +/** The same, for a file of any kind (see main/fileUploads): only the name is made safe. Null when nothing usable is left. */ +export function safeUploadName(name: string): string | null { + return cleanFileName(name) || null; +} + +function cleanFileName(name: string): string { const base = String(name).split(/[\\/]/).pop() ?? ''; let clean = base.replace(/[<>:"|?*\u0000-\u001f]/g, '_').replace(/^[\s.]+|[\s.]+$/g, ''); - if (!SENDABLE_FILE_RE.test(clean)) return null; if (clean.length > NAME_MAX) { - const ext = /\.[^.]+$/.exec(clean)?.[0] ?? ''; + const ext = /\.[^.]{1,16}$/.exec(clean)?.[0] ?? ''; clean = clean.slice(0, NAME_MAX - ext.length) + ext; } return clean; From 686bea6b6ab44e3f536b03592d5eafb3a36bd35e Mon Sep 17 00:00:00 2001 From: Si Hyeong Lee Date: Thu, 22 Oct 2026 18:25:13 -0700 Subject: [PATCH 2/2] chore: 1.45.0 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016NLGwiBY5uEivJqERQzcoY --- README.md | 2 +- docs/releases/1.45.0.md | 29 +++++++++++++++++++++++++++++ package-lock.json | 4 ++-- package.json | 2 +- 4 files changed, 33 insertions(+), 4 deletions(-) create mode 100644 docs/releases/1.45.0.md diff --git a/README.md b/README.md index e2683cd..3c64841 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ See what needs attention, resume the right conversation, and work on a paired PC -> **1.44.1** fixes the "Assertion failed: remove_pty_baton" dialog (and the occasional hang or crash) when quitting or running Update all with several sessions open ([release notes](docs/releases/1.44.1.md)). 1.44.0 lists Claude Code's own background sessions (`claude --bg`, the agent view) in the sidebar, marks the ones waiting for you, and opens one in a tile by attaching to it ([release notes](docs/releases/1.44.0.md)). 1.43.0 runs parallel sessions in separate workspaces — each in its own git worktree and branch, so two agents never edit the same files — with its changes in the header and a clean-up that warns before unmerged work is lost ([release notes](docs/releases/1.43.0.md)). 1.42.0 shows what changed — a What's new window after each update, with Show me, and NEW dots on new buttons until you try them — and, as an experimental setting on Windows, lets Claude Code report its own state (working, needs approval, asking, done) instead of DevDeck reading the screen ([release notes](docs/releases/1.42.0.md)). 1.41.0 adds quick replies (one click or Ctrl+Shift+1…9 for the phrases you keep typing), a session timeline of your prompts with their times, replies and images, red boxes and arrows drawn on a screenshot and sent straight back to the session, and text, log and PDF attachments for sessions on another PC ([release notes](docs/releases/1.41.0.md)). 1.40.0 ties each screenshot to the terminal row it appeared on — hover a highlighted `[Image]` for a thumbnail, click for the full view, and jump from the viewer back to the row — says what led to each image, previews new screenshots, copies images, and adds Ctrl+↑/↓ between your prompts ([release notes](docs/releases/1.40.0.md)). 1.39.0 shows the screenshots and images a Claude session worked with — Claude Desktop shows them inline; DevDeck puts them in a strip beside the terminal, also for sessions on another PC — adds 📎 / drag-and-drop attachments (images travel to a remote PC), **Update all** for Claude Code on Windows, staggered session restores that no longer stall a PC, and ends the daily false "Login expired" ([release notes](docs/releases/1.39.0.md)). 1.38.0 made closing a session reliable across paired PCs ([notes](docs/releases/1.38.0.md)). See [the maintenance review](docs/quality-review-2026-09-08.md) and [session synchronization review](docs/session-sync-review-2026-09-08.md). +> **1.45.0** sends any file to a session on another PC — any kind, up to 4 GB, in pieces with its progress shown ([release notes](docs/releases/1.45.0.md)). 1.44.1 fixes the "Assertion failed: remove_pty_baton" dialog (and the occasional hang or crash) when quitting or running Update all with several sessions open ([release notes](docs/releases/1.44.1.md)). 1.44.0 lists Claude Code's own background sessions (`claude --bg`, the agent view) in the sidebar, marks the ones waiting for you, and opens one in a tile by attaching to it ([release notes](docs/releases/1.44.0.md)). 1.43.0 runs parallel sessions in separate workspaces — each in its own git worktree and branch, so two agents never edit the same files — with its changes in the header and a clean-up that warns before unmerged work is lost ([release notes](docs/releases/1.43.0.md)). 1.42.0 shows what changed — a What's new window after each update, with Show me, and NEW dots on new buttons until you try them — and, as an experimental setting on Windows, lets Claude Code report its own state (working, needs approval, asking, done) instead of DevDeck reading the screen ([release notes](docs/releases/1.42.0.md)). 1.41.0 adds quick replies (one click or Ctrl+Shift+1…9 for the phrases you keep typing), a session timeline of your prompts with their times, replies and images, red boxes and arrows drawn on a screenshot and sent straight back to the session, and text, log and PDF attachments for sessions on another PC ([release notes](docs/releases/1.41.0.md)). 1.40.0 ties each screenshot to the terminal row it appeared on — hover a highlighted `[Image]` for a thumbnail, click for the full view, and jump from the viewer back to the row — says what led to each image, previews new screenshots, copies images, and adds Ctrl+↑/↓ between your prompts ([release notes](docs/releases/1.40.0.md)). 1.39.0 shows the screenshots and images a Claude session worked with — Claude Desktop shows them inline; DevDeck puts them in a strip beside the terminal, also for sessions on another PC — adds 📎 / drag-and-drop attachments (images travel to a remote PC), **Update all** for Claude Code on Windows, staggered session restores that no longer stall a PC, and ends the daily false "Login expired" ([release notes](docs/releases/1.39.0.md)). 1.38.0 made closing a session reliable across paired PCs ([notes](docs/releases/1.38.0.md)). See [the maintenance review](docs/quality-review-2026-09-08.md) and [session synchronization review](docs/session-sync-review-2026-09-08.md).
DevDeck project overview with repository and session navigation diff --git a/docs/releases/1.45.0.md b/docs/releases/1.45.0.md new file mode 100644 index 0000000..cd4cfd7 --- /dev/null +++ b/docs/releases/1.45.0.md @@ -0,0 +1,29 @@ +# DevDeck 1.45.0 + +## Send any file to a session on another PC + +Before, a session on a paired PC took images and 21 kinds of documents (text, data, logs, PDF) up to 5 MB each. A zip, a spreadsheet, a source file or a video was refused. + +- **Any kind:** zip, xlsx, docx, source files, audio, video, binaries. +- **Up to 4 GB per file:** large files are sent in 2 MB pieces. A file over 8 MB shows its progress while it goes. +- **Drop it or use 📎:** both work on a remote session. The 📎 picker no longer filters by kind. The path where the file landed on the other PC is typed into the session, as before. +- **Images:** they still go as PNG, so the agent can look at them. An image too large to redraw as PNG within 5 MB now goes as it is, instead of being refused. +- **The other PC needs 1.45.0.** An older DevDeck there still takes what it did before (the 21 document kinds, up to 5 MB), and DevDeck says when something was skipped for that reason. + +## What is still checked, on the PC receiving the file + +Sending a file needs the same permission as typing into its sessions. A device that can type into a session can already have the agent there run anything, so checking the file's kind only got in the way. What is kept: + +- **Where it goes:** the file is written only into the temp folder, under a name DevDeck chooses. It is never written to a path the sender picks. DevDeck removes it after a day. +- **Never opened:** DevDeck does not open or run a received file. +- **Marked as downloaded:** on Windows the file is marked as coming from the internet (Mark of the Web), so opening it from Explorer shows the usual warning first. +- **Disk space:** a file is refused if writing it would leave less than 2 GB free on that disk. +- **Limits:** at most 4 uploads are open at once. An upload with nothing new for 5 minutes is dropped and its partial file is deleted. + +## Under the hood + +- **New methods:** `cockpit:uploadStart`, `uploadChunk`, `uploadFinish` and `uploadAbort`, with the `control` permission. `cockpit:receiveFile` is kept for senders older than 1.45.0. +- **Tests:** + - On the receiving side, pieces are written in order. A piece that is out of order, too large or past the announced size is refused. A file with bytes missing never lands under its name. The size, disk-space and open-upload limits hold, and the Windows Mark of the Web is written. + - On the sending side, a file is split and reported in order. An empty file is sent. A refused piece abandons the upload. An older PC is told apart from a full disk. + - `qa:images-remote` sends a 9 MB binary between two real paired DevDecks and checks that it arrives byte for byte, with its progress shown. diff --git a/package-lock.json b/package-lock.json index 591306b..558bd22 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "devdeck", - "version": "1.44.1", + "version": "1.45.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "devdeck", - "version": "1.44.1", + "version": "1.45.0", "license": "MIT", "dependencies": { "@fontsource-variable/geist": "5.3.0", diff --git a/package.json b/package.json index 552abf1..23c8e34 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "devdeck", - "version": "1.44.1", + "version": "1.45.0", "description": "Project command deck — at-a-glance state + Claude Code, Codex, and Antigravity resume", "main": "dist/main/main.js", "type": "commonjs",