From 826f10edb82e9b126bffa694562c3841a93e3e6d Mon Sep 17 00:00:00 2001 From: Mahin Bharathwaj Date: Wed, 30 Sep 2026 12:41:53 -0700 Subject: [PATCH] Add LAN Download Together preview with helper budgets and chunk recovery --- README.md | 2 + docs/download-together.md | 45 ++ e2e/together.spec.ts | 245 ++++++++ src/main/index.ts | 3 +- src/main/ipc/handlers.ts | 18 +- src/main/together/session.ts | 547 ++++++++++++++++++ src/main/together/transport.ts | 177 ++++++ src/preload/index.ts | 8 + src/renderer/src/App.tsx | 31 +- src/renderer/src/components/TogetherPanel.tsx | 402 +++++++++++++ src/shared/ipc-channels.ts | 6 + src/shared/ipc-contract.ts | 12 + src/shared/together.ts | 55 ++ 13 files changed, 1545 insertions(+), 6 deletions(-) create mode 100644 docs/download-together.md create mode 100644 e2e/together.spec.ts create mode 100644 src/main/together/session.ts create mode 100644 src/main/together/transport.ts create mode 100644 src/renderer/src/components/TogetherPanel.tsx create mode 100644 src/shared/together.ts diff --git a/README.md b/README.md index 0b0880e..08e985a 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,8 @@ File ──→ Split ─────┤ ├─ ## Features +_New preview:_ [Download Together](docs/download-together.md) lets trusted computers on a local network contribute file pieces using separate internet connections, with join codes, helper data limits, and per-person progress. + - 🚀 **Multi-interface, multi-connection downloads** — splits files into chunks of up to 8 MB and fans them out across worker connections bound to specific network interfaces, each kept open from one chunk to the next. Each interface starts with 8 connections and doubles once they are all receiving, up to 32. If the server refuses some (503, 429, 403, or leaves them unanswered), that interface drops to the ones it accepted, then gets one more back each minute without a refusal; a server refusing everything (busy, or an expired link) doesn't lower it. You can also pick a fixed 4, 8, 16 or 32 per interface on the start screen instead of Auto. - 🔌 **Hardware interface detection** — queries Windows adapters via PowerShell `Get-NetAdapter` and macOS hardware ports via `networksetup` so Wi-Fi, Ethernet, tethered iPhones, and Thunderbolt bridges are labeled by real device names instead of bare BSD names (`en0`, `en6`). - ⚖️ **Dynamic work-stealing queue** — chunks are leased from a shared pending queue; faster networks pull more chunks instead of waiting for slower connections to finish. diff --git a/docs/download-together.md b/docs/download-together.md new file mode 100644 index 0000000..cbaef71 --- /dev/null +++ b/docs/download-together.md @@ -0,0 +1,45 @@ +# Download Together (preview) + +Two or more Plexo computers can fetch different pieces of one file through their own internet connections. Helpers send their pieces over a shared local network; the host saves the complete file. + +## Try it + +1. Run the same version of Plexo on both computers (`npm ci`, then `npm run dev`). +2. Connect both computers to a trusted local network. For a speed benefit, give the helper a separate internet connection, such as a USB-tethered phone. Both computers on one home broadband connection usually share the same bottleneck. +3. On the host, open **Download Together → Host**. Choose the internet connection to fetch pieces and the local IPv4 address reachable by your friend. These can be different connections. Enter a public file URL and destination folder. If the publisher supplies a SHA-256 checksum, paste it. +4. Click **Create session** and share the full `plexo://…` join code privately. Allow incoming Plexo connections if your OS firewall prompts you. Guest Wi-Fi/client isolation may prevent computers from reaching each other; no port forwarding is needed. +5. On the helper, open **Download Together → Join**, paste the code and click **Review file**. Check the source URL, choose the internet connection, enter a name and a maximum source-data allowance in MiB, then click **Join and help**. +6. On the host, click **Start together**. The progress card shows each computer's contribution. The helper may leave at any time. The host reclaims incomplete pieces immediately on a clean leave, or within 45 seconds after an abrupt disconnect. +7. The host checks and publishes the completed file. Click **Show file** to open its folder. Helpers do not retain a complete copy. + +The data allowance counts received file payload, including unsuccessful attempts. TCP/TLS and other protocol overhead are extra, and sending the pieces to the host also uses the local network. Plexo stops assigning full pieces when the remaining allowance is insufficient; this is not an OS-level cellular billing cap. + +## Requirements and boundaries + +- The link must support HTTP byte ranges, report a size, and supply a strong ETag. Together validates the exact ETag, Content-Range and byte count for every response. A changed source, missing validator or mid-transfer redirect is rejected. The regular download mode supports a broader set of servers. +- Public HTTP(S) files only: no browser cookies, account login, URL credentials or DRM support. Do not use sensitive/signed links: everyone with the join code sees the source URL. +- IPv4 local pairing, one Together session per app, up to four helper registrations per session, and one in-flight 1 MiB piece per participant. Source files are limited to 100,000 pieces (about 97.7 GiB). +- Use trusted helpers on a trusted network. The random join token restricts access, but LAN pairing and payloads are **not encrypted**. A helper-generated chunk checksum detects transfer corruption, not deliberate tampering by that helper. A trusted publisher's SHA-256 lets the host verify the complete file before publication. Without it, the displayed SHA-256 is only a fingerprint. +- Stop or quit cancels the Together session and removes its incomplete file. Together does not yet support pause, crash recovery, or persistence. A force-kill/power loss can leave a `.plexo` partial file beside the destination; it cannot be resumed in this mode. +- No cloud relay, NAT traversal, QR-camera flow, or cross-internet pairing. Join codes contain the LAN address, port and a random token. Codes expire when the session stops, or 30 seconds after completion/failure. +- Real-world speed depends on independent upstream capacity, local transfer speed, server limits and OS routing. Two-instance automated tests verify cooperation and file integrity; they do not establish a speed increase on physical devices. + +## Implementation + +`src/main/together/session.ts` owns the host coordinator and helper lifecycle. It uses bounded HTTP messages, expiring chunk leases, a separate peer credential after joining, one outstanding lease per helper, and rejects browser-origin requests. A lease is checked again after upload before any file write, so late/duplicate uploads cannot overwrite a reassigned or completed chunk. Writes use fixed offsets in an exclusively reserved destination-side staging file. Publication reuses the existing collision-aware `DownloadFile` implementation. + +`transport.ts` uses Plexo's `StreamConnection` to bind source requests to the participant's selected interface. Transfers to the host follow the OS route to the selected local address. Chunk bodies are bounded to 1 MiB, and network operations have deadlines. The host keeps working when helpers leave; returned pieces are attributed only after successful writes. + +The UI and typed IPC live alongside the solo downloader. Together session shutdown participates in the app's existing quit cleanup. No dependencies were added. + +## Verification + +```bash +npm run typecheck +npm run lint +npm run format:check +npx playwright test e2e/together.spec.ts +npm run test:e2e:smoke +``` + +Tests cover two real Electron windows (host/review/join/download), exact output hashes, contributions, chosen helper source address where available, budget exhaustion, graceful departure, abandoned leases, wrong publisher hashes, invalid tokens/browser origins, malformed/duplicate chunks, changed sources, stop/restart, quit cleanup and unsupported sources. diff --git a/e2e/together.spec.ts b/e2e/together.spec.ts new file mode 100644 index 0000000..337e459 --- /dev/null +++ b/e2e/together.spec.ts @@ -0,0 +1,245 @@ +import { readFile, readdir } from 'node:fs/promises' +import { test, expect, makeDirs, PlexoApp, LAN_ADDRESS } from './fixtures' +import { sha256 } from './origin' +import { callPeer, hash } from '../src/main/together/transport' +import type { TogetherLease } from '../src/shared/together' + +const MiB = 1024 * 1024 +async function host(plexo: PlexoApp, url: string, expectedSha256?: string): Promise { + const state = await plexo.api.hostTogether({ + url, + destinationDir: plexo.dirs.dest, + internetInterfaceId: 'a', + lanAddress: '127.0.0.1', + expectedSha256 + }) + return state.code! +} + +async function finalFile(plexo: PlexoApp, expected: string): Promise { + await expect + .poll(async () => (await plexo.api.getTogether())?.status, { timeout: 65_000 }) + .toBe('completed') + const state = (await plexo.api.getTogether())! + expect(sha256(await readFile(state.destinationPath!))).toBe(expected) + expect(state.sha256).toBe(expected) + expect(state.bytes).toBe(state.file.totalBytes) + expect((await readdir(plexo.dirs.dest)).filter((name) => name.endsWith('.plexo'))).toEqual([]) +} + +test('Together: two Electron windows host, review, join and produce the exact file @smoke', async ({ + plexo, + serve +}, testInfo) => { + const origin = await serve({ size: 6 * MiB + 321, bytesPerSecond: 2 * MiB }) + const other = await makeDirs() + const helper = new PlexoApp(other.dirs) + try { + await helper.launch() + await plexo.page.getByRole('button', { name: 'Download Together', exact: true }).click() + await plexo.page.getByLabel('Public download link').fill(origin.url()) + await plexo.page.getByLabel('Local address for friends to join').selectOption('127.0.0.1') + await plexo.page.getByLabel('Publisher SHA-256 (optional)').fill(sha256(origin.content)) + // The UI destination defaults to Downloads; use the real folder picker bridge to set the test folder. + await plexo.evaluateMain(({ dialog }, dest) => { + dialog.showOpenDialog = async () => ({ canceled: false, filePaths: [dest] }) + }, plexo.dirs.dest) + await plexo.page.getByRole('button', { name: 'Browse…' }).click() + await plexo.page.getByRole('button', { name: 'Create session' }).click() + await expect(plexo.page.getByTestId('together-status')).toHaveText('waiting') + const code = await plexo.page.getByLabel('Host join code').inputValue() + await helper.page.getByRole('button', { name: 'Download Together', exact: true }).click() + await helper.page.getByRole('button', { name: 'Join', exact: true }).click() + await helper.page.getByLabel('Join code', { exact: true }).fill(code) + await helper.page.getByRole('button', { name: 'Review file' }).click() + await expect(helper.page.getByText(`Source: ${origin.url()}`)).toBeVisible() + if (LAN_ADDRESS) await helper.page.getByLabel('Internet connection').selectOption('b') + await helper.page.getByLabel('Your name').fill('Test friend') + await helper.page.getByRole('button', { name: 'Join and help' }).click() + await expect(helper.page.getByTestId('together-status')).toHaveText('waiting') + await plexo.page.getByRole('button', { name: 'Start together' }).click() + await finalFile(plexo, sha256(origin.content)) + await expect.poll(async () => (await helper.api.getTogether())?.status).toBe('completed') + const state = (await plexo.api.getTogether())! + expect(state.contributedBytes).toBeGreaterThan(0) + expect(state.peers[0].bytes).toBeGreaterThan(0) + expect(state.contributedBytes + state.peers[0].bytes).toBe(state.file.totalBytes) + expect((await helper.api.getTogether())!.contributedBytes).toBe(state.peers[0].bytes) + if (LAN_ADDRESS) + expect(origin.log.some((entry) => entry.from === LAN_ADDRESS && entry.bytesSent > 1)).toBe( + true + ) + await plexo.page.screenshot({ path: testInfo.outputPath('together-completed.png') }) + } finally { + if (helper.alive) await helper.quit() + await other.dispose() + } +}) + +test('Together: helper payload budget stops work and host finishes', async ({ plexo, serve }) => { + const origin = await serve({ size: 5 * MiB, bytesPerSecond: MiB }) + const code = await host(plexo, origin.url()) + const other = await makeDirs() + const helper = new PlexoApp(other.dirs) + try { + await helper.launch() + const file = await helper.api.previewTogether(code) + await helper.api.joinTogether({ + code, + file, + name: 'Budget helper', + internetInterfaceId: 'a', + budgetBytes: MiB + }) + await plexo.api.startTogether() + await expect + .poll(async () => (await helper.api.getTogether())?.status, { timeout: 15_000 }) + .toBe('stopped') + const contribution = (await helper.api.getTogether())! + expect(contribution.usedBytes).toBe(MiB) + expect(contribution.contributedBytes).toBe(MiB) + await finalFile(plexo, sha256(origin.content)) + } finally { + if (helper.alive) await helper.quit() + await other.dispose() + } +}) + +test('Together: leaving during a chunk reassigns it to the host', async ({ plexo, serve }) => { + const origin = await serve({ size: 4 * MiB, bytesPerSecond: MiB }) + const code = await host(plexo, origin.url()) + const other = await makeDirs() + const helper = new PlexoApp(other.dirs) + try { + await helper.launch() + await helper.api.joinTogether({ + code, + file: await helper.api.previewTogether(code), + name: 'Leaving', + internetInterfaceId: 'a', + budgetBytes: 10 * MiB + }) + await plexo.api.startTogether() + await expect + .poll(async () => (await helper.api.getTogether())?.usedBytes ?? 0) + .toBeGreaterThan(0) + await helper.api.stopTogether() + await finalFile(plexo, sha256(origin.content)) + expect((await plexo.api.getTogether())!.peers[0].status).toBe('left') + expect((await helper.api.getTogether())!.status).toBe('stopped') + } finally { + if (helper.alive) await helper.quit() + await other.dispose() + } +}) + +test('Together: an abandoned lease expires and the host finishes', async ({ plexo, serve }) => { + test.setTimeout(80_000) + const origin = await serve({ size: 3 * MiB, bytesPerSecond: MiB }) + const code = await host(plexo, origin.url()) + const { peer } = await callPeer<{ peer: string }>(code, '/join', { name: 'Disconnected' }) + await plexo.api.startTogether() + const assignment = await callPeer<{ lease: TogetherLease }>( + code, + '/lease', + { remaining: 10 * MiB }, + undefined, + { 'X-Plexo-Peer': peer } + ) + expect(assignment.lease).toBeTruthy() + // Simulate a killed helper: no upload, no leave request, no more traffic. + await finalFile(plexo, sha256(origin.content)) + expect((await plexo.api.getTogether())!.peers[0].bytes).toBe(0) +}) + +test('Together: wrong publisher checksum refuses publication and removes staging file', async ({ + plexo, + serve +}) => { + const origin = await serve({ size: MiB }) + await host(plexo, origin.url(), '0'.repeat(64)) + await plexo.api.startTogether() + await expect.poll(async () => (await plexo.api.getTogether())?.status).toBe('error') + await expect.poll(() => readdir(plexo.dirs.dest)).toEqual([]) + expect((await plexo.api.getTogether())!.error).toContain('SHA-256') +}) + +test('Together: reject strangers, browser origins, malformed chunks and duplicate uploads', async ({ + plexo, + serve +}) => { + const origin = await serve({ size: 4 * MiB, bytesPerSecond: MiB }) + const code = await host(plexo, origin.url()) + await expect( + callPeer(code.slice(0, -1) + (code.endsWith('0') ? '1' : '0'), '/session') + ).rejects.toThrow('Invalid session code') + await expect( + callPeer(code, '/session', undefined, undefined, { Origin: 'https://untrusted.example' }) + ).rejects.toThrow('Invalid session code') + const { peer } = await callPeer<{ peer: string }>(code, '/join', { name: 'Helper' }) + await plexo.api.startTogether() + const headers = { 'X-Plexo-Peer': peer } + const { lease } = await callPeer<{ lease: TogetherLease }>( + code, + '/lease', + { remaining: 10 * MiB }, + undefined, + headers + ) + const bytes = origin.content.subarray(lease.start, lease.end + 1) + const upload = { ...headers, 'X-Plexo-Lease': lease.id, 'X-Plexo-Sha256': hash(bytes) } + await expect(callPeer(code, '/chunk', Buffer.alloc(1), undefined, upload)).rejects.toThrow( + 'Incorrect chunk size' + ) + await expect( + callPeer(code, '/chunk', bytes, undefined, { ...upload, 'X-Plexo-Sha256': '0'.repeat(64) }) + ).rejects.toThrow('checksum mismatch') + await callPeer(code, '/chunk', bytes, undefined, upload) + await expect(callPeer(code, '/chunk', bytes, undefined, upload)).rejects.toThrow( + 'no longer needed' + ) + await finalFile(plexo, sha256(origin.content)) +}) + +test('Together: stop cleans staging data, revokes code and allows a fresh session', async ({ + plexo, + serve +}) => { + const origin = await serve({ size: 3 * MiB, bytesPerSecond: MiB }) + const code = await host(plexo, origin.url()) + await plexo.api.startTogether() + await plexo.api.stopTogether() + expect((await plexo.api.getTogether())!.status).toBe('stopped') + expect(await readdir(plexo.dirs.dest)).toEqual([]) + await expect(callPeer(code, '/session')).rejects.toThrow() + await host(plexo, origin.url()) + await plexo.api.startTogether() + await finalFile(plexo, sha256(origin.content)) +}) + +test('Together: unsupported sources do not create files', async ({ plexo, serve }) => { + for (const options of [{ ranges: false }, { etag: null }, { etag: 'W/"weak"' }]) { + const origin = await serve({ size: MiB, ...options }) + await expect(host(plexo, origin.url())).rejects.toThrow('Together needs') + expect(await readdir(plexo.dirs.dest)).toEqual([]) + } +}) + +test('Together: a changed source cannot be mixed into the saved file', async ({ plexo, serve }) => { + const origin = await serve({ size: 2 * MiB }) + await host(plexo, origin.url()) + origin.etag = '"changed-version"' + await plexo.api.startTogether() + await expect.poll(async () => (await plexo.api.getTogether())?.status).toBe('error') + expect((await plexo.api.getTogether())!.error).toContain('source changed') + await expect.poll(() => readdir(plexo.dirs.dest)).toEqual([]) +}) + +test('Together: quitting an active host removes its unfinished file', async ({ plexo, serve }) => { + const origin = await serve({ size: 4 * MiB, bytesPerSecond: MiB }) + const code = await host(plexo, origin.url()) + await plexo.api.startTogether() + await plexo.quit() + expect(await readdir(plexo.dirs.dest)).toEqual([]) + await expect(callPeer(code, '/session')).rejects.toThrow() +}) diff --git a/src/main/index.ts b/src/main/index.ts index 0d1fd8e..36fe7ab 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -5,7 +5,6 @@ import icon from '../../resources/icon-dark.png?asset' import { registerIpcHandlers } from './ipc/handlers' import { loadThemeSource, migrateLegacyNetworkPreferences } from './settings' import { testKnobs } from './testKnobs' -import type { DownloadManager } from './download/downloadManager' // In dev mode the app runs as the raw `electron` binary, which otherwise shows "Electron" in // the Dock tooltip/menu bar — must be set before the app is ready. Packaged builds already get @@ -16,7 +15,7 @@ app.setName('Plexo') if (testKnobs.userDataDir) app.setPath('userData', testKnobs.userDataDir) let mainWindow: BrowserWindow | null = null -let downloadManager: DownloadManager | null = null +let downloadManager: ReturnType | null = null let quitAfterSuspending = false function createWindow(): void { diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index d6ce336..5db7511 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -21,6 +21,7 @@ import { measureLatencies } from '../network/latency' import { NetworkMonitor } from '../network/interfaces' import { loadSettings, saveSettings } from '../settings' import { testKnobs } from '../testKnobs' +import { TogetherSession } from '../together/session' import { checkForUpdate, UPDATE_PAGE_URL } from '../updateCheck' async function openNetworkSettings(): Promise { @@ -56,7 +57,9 @@ function handle( const DESTINATION_CHECK_MS = 300 -export function registerIpcHandlers(getWindow: () => BrowserWindow | null): DownloadManager { +export function registerIpcHandlers(getWindow: () => BrowserWindow | null): { + suspendAll(): Promise +} { // The main process keeps the network list, for downloads and the window alike. const networks = new NetworkMonitor((list) => { manager.networksChanged() @@ -64,6 +67,13 @@ export function registerIpcHandlers(getWindow: () => BrowserWindow | null): Down if (window && !window.isDestroyed()) window.webContents.send(IpcChannels.networksChanged, list) }) const manager = new DownloadManager(getWindow, networks) + const together = new TogetherSession(networks) + handle('hostTogether', (_event, request) => together.host(request)) + handle('previewTogether', (_event, code) => together.preview(code)) + handle('joinTogether', (_event, request) => together.join(request)) + handle('startTogether', () => together.start()) + handle('stopTogether', () => together.stopSession()) + handle('getTogether', () => together.snapshot()) // Waking from sleep, the networks may have changed without a poll in between to see it. powerMonitor.on('resume', () => { manager.systemResumed() @@ -190,5 +200,9 @@ export function registerIpcHandlers(getWindow: () => BrowserWindow | null): Down return { ...info, dismissed: info.version === dismissedUpdateVersion } }) - return manager + return { + suspendAll: async () => { + await Promise.allSettled([manager.suspendAll(), together.stopSession()]) + } + } } diff --git a/src/main/together/session.ts b/src/main/together/session.ts new file mode 100644 index 0000000..5e0c24e --- /dev/null +++ b/src/main/together/session.ts @@ -0,0 +1,547 @@ +import { createHash, randomBytes, randomUUID } from 'node:crypto' +import { createReadStream } from 'node:fs' +import { open, statfs } from 'node:fs/promises' +import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http' +import type { AddressInfo } from 'node:net' +import { setTimeout as delay } from 'node:timers/promises' +import type { + HostTogetherRequest, + JoinTogetherRequest, + TogetherFile, + TogetherLease, + TogetherPeer, + TogetherState +} from '../../shared/together' +import type { NetworkInterfaceInfo } from '../../shared/types' +import { DownloadFile } from '../download/downloadFile' +import { reserveDestinationPath } from '../download/paths' +import { probeUrl } from '../download/probe' +import { + callPeer, + CHUNK_BYTES, + fetchPiece, + hash, + lanAddress, + LEASE_MS, + readBody, + validateFile +} from './transport' + +type Networks = { + refresh(): Promise + find(id: string): NetworkInterfaceInfo | undefined +} +type Block = { + start: number + end: number + done: boolean + failures: number + lease?: TogetherLease & { owner: string; expires: number; writing: boolean } +} +type Peer = TogetherPeer & { lease?: string; lastSeen: number } +type Assignment = { status: TogetherState['status']; lease: TogetherLease | null; bytes: number } + +const message = (error: unknown): string => (error instanceof Error ? error.message : String(error)) +const json = (res: ServerResponse, value: unknown, status = 200): void => { + res.writeHead(status, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }) + res.end(JSON.stringify(value)) +} + +/** Session-local leases never survive a restart. Existing solo downloads keep their own lifecycle. */ +export class TogetherSession { + private state: TogetherState | null = null + private stop = new AbortController() + private server?: Server + private file?: DownloadFile + private blocks: Block[] = [] + private peers = new Map() + private jobs = new Set>() + private run?: Promise + private busy = false + private expiry?: NodeJS.Timeout + private helper?: { code: string; peer: string } + + constructor(private readonly networks: Networks) {} + + snapshot(): TogetherState | null { + if (this.state?.role === 'host') { + this.state.peers = [...this.peers.values()].map(({ name, bytes, status, lastSeen }) => ({ + name, + bytes, + status: Date.now() - lastSeen > LEASE_MS ? 'left' : status + })) + } + return this.state && structuredClone(this.state) + } + + private available(): void { + if ( + this.busy || + (this.state && ['waiting', 'downloading', 'verifying'].includes(this.state.status)) + ) + throw new Error('Stop the current Together session first') + } + + async host(request: HostTogetherRequest): Promise { + this.available() + this.busy = true + try { + await this.close() + this.stop = new AbortController() + const networks = await this.networks.refresh() + if (!this.networks.find(request.internetInterfaceId)) + throw new Error('Select an internet connection') + if ( + !lanAddress(request.lanAddress) || + !networks.some((network) => + network.addresses.some((address) => address.address === request.lanAddress) + ) + ) + throw new Error('Select a local IPv4 address for joining') + const probe = await probeUrl(request.url) + if ( + !probe.supportsRanges || + !probe.totalBytes || + !probe.etag || + !/^"[^\r\n]*"$/.test(probe.etag) + ) + throw new Error( + 'Together needs a file with byte-range support, a known size, and a strong ETag. Use a normal download for this link.' + ) + const file = validateFile({ + protocol: 1, + id: randomUUID(), + url: probe.finalUrl, + fileName: probe.suggestedFileName, + totalBytes: probe.totalBytes, + etag: probe.etag, + ...(request.expectedSha256?.trim() + ? { expectedSha256: request.expectedSha256.trim().toLowerCase() } + : {}) + }) + const disk = await statfs(request.destinationDir) + if (disk.bavail * disk.bsize < file.totalBytes) throw new Error('Not enough free disk space') + const destinationPath = await reserveDestinationPath(request.destinationDir, file.fileName) + this.file = new DownloadFile(`${destinationPath}.plexo`) + this.blocks = Array.from( + { length: Math.ceil(file.totalBytes / CHUNK_BYTES) }, + (_, index) => ({ + start: index * CHUNK_BYTES, + end: Math.min((index + 1) * CHUNK_BYTES, file.totalBytes) - 1, + done: false, + failures: 0 + }) + ) + this.peers.clear() + const token = randomBytes(16).toString('hex') + this.state = { + role: 'host', + status: 'waiting', + file, + bytes: 0, + contributedBytes: 0, + usedBytes: 0, + peers: [], + destinationPath + } + this.server = createServer((req, res) => { + const job = this.serve(req, res, token).catch((error) => { + if (!res.headersSent && !res.destroyed) json(res, { error: message(error) }, 400) + else res.destroy() + }) + this.jobs.add(job) + void job.finally(() => this.jobs.delete(job)) + }) + this.server.requestTimeout = 30_000 + this.server.headersTimeout = 10_000 + this.server.maxConnections = 12 + this.server.on('connection', (socket) => socket.setTimeout(30_000, () => socket.destroy())) + await new Promise((resolve, reject) => { + this.server!.once('error', reject) + this.server!.listen(0, request.lanAddress, () => { + this.server!.off('error', reject) + resolve() + }) + }) + this.server.on('error', (error) => { + void this.fail(error) + }) + this.state.code = `plexo://${request.lanAddress}:${(this.server.address() as AddressInfo).port}/${token}` + this.hostNetwork = request.internetInterfaceId + return this.snapshot()! + } catch (error) { + await this.close() + this.state = null + throw error + } finally { + this.busy = false + } + } + + private hostNetwork = '' + + start(): void { + if (this.busy || this.state?.role !== 'host' || this.state.status !== 'waiting') + throw new Error('Create a host session first') + this.state.status = 'downloading' + this.run = this.hostWorker().catch((error) => this.fail(error)) + } + + async preview(code: string): Promise { + return validateFile(await callPeer(code, '/session')) + } + + async join(request: JoinTogetherRequest): Promise { + this.available() + this.busy = true + try { + await this.close() + this.stop = new AbortController() + await this.networks.refresh() + if (!this.networks.find(request.internetInterfaceId)) + throw new Error('Select an internet connection') + if (!Number.isSafeInteger(request.budgetBytes) || request.budgetBytes < CHUNK_BYTES) + throw new Error('Choose a data limit of at least 1 MiB') + const file = await this.preview(request.code) + if (JSON.stringify(file) !== JSON.stringify(validateFile(request.file))) + throw new Error('The session changed. Review the file and join again.') + const { peer } = await callPeer<{ peer: string }>(request.code, '/join', { + name: request.name + }) + this.helper = { code: request.code, peer } + this.state = { + role: 'helper', + status: 'waiting', + file, + bytes: 0, + contributedBytes: 0, + usedBytes: 0, + budgetBytes: request.budgetBytes, + peers: [] + } + this.run = this.helperWorker(request).catch((error) => this.fail(error)) + return this.snapshot()! + } finally { + this.busy = false + } + } + + private claim(owner: string, remaining: number): TogetherLease | null { + const now = Date.now() + for (const block of this.blocks) { + if (block.lease && !block.lease.writing && block.lease.expires <= now) block.lease = undefined + } + // One outstanding chunk per helper; repeated polls return that same assignment. + const existing = this.blocks.find((block) => block.lease?.owner === owner) + if (existing?.lease) return existing.lease + const index = this.blocks.findIndex( + (block) => !block.done && !block.lease && block.end - block.start + 1 <= remaining + ) + if (index < 0) return null + const block = this.blocks[index] + const lease = { + id: randomUUID(), + index, + start: block.start, + end: block.end, + owner, + expires: now + LEASE_MS, + writing: false + } + block.lease = lease + return lease + } + + private async accept(owner: string, lease: TogetherLease, bytes: Buffer): Promise { + const block = this.blocks[lease.index] + if ( + this.stop.signal.aborted || + this.state?.status !== 'downloading' || + !block || + block.done || + block.lease?.id !== lease.id || + block.lease.owner !== owner || + block.lease.expires <= Date.now() || + block.lease.writing || + bytes.length !== block.end - block.start + 1 + ) + throw new Error('The chunk assignment expired or was already completed') + block.lease.writing = true + try { + const handle = await open(this.file!.path, 'r+') + try { + let offset = 0 + while (offset < bytes.length) { + const { bytesWritten } = await handle.write( + bytes, + offset, + bytes.length - offset, + block.start + offset + ) + if (!bytesWritten) throw new Error('Could not write the downloaded chunk') + offset += bytesWritten + } + } finally { + await handle.close() + } + block.done = true + block.lease = undefined + this.state.bytes += bytes.length + if (owner === 'host') this.state.contributedBytes += bytes.length + else { + const peer = this.peers.get(owner)! + peer.bytes += bytes.length + peer.status = 'connected' + } + } catch (error) { + block.lease = undefined + // Disk errors must stop the whole session, not repeatedly assign the same write. + this.stop.abort() + this.state.status = 'error' + this.state.error = message(error) + throw error + } + } + + private async serve(req: IncomingMessage, res: ServerResponse, token: string): Promise { + if (req.headers.origin || req.headers.authorization !== `Bearer ${token}`) { + json(res, { error: 'Invalid session code' }, 403) + return + } + const state = this.state! + if (req.method === 'GET' && req.url === '/session') { + json(res, state.file) + return + } + if (req.method !== 'POST') { + json(res, { error: 'Unknown operation' }, 404) + return + } + if (req.url === '/join') { + if (!['waiting', 'downloading'].includes(state.status)) + throw new Error('The session is no longer accepting helpers') + if (this.peers.size >= 4) + throw new Error( + 'This session already has four helpers; create a new session to reset invitations' + ) + const body = JSON.parse((await readBody(req, 1024)).toString()) + if (typeof body.name !== 'string' || !body.name.trim() || body.name.length > 48) + throw new Error('Use a name between 1 and 48 characters') + const peer = randomBytes(16).toString('hex') + this.peers.set(peer, { + name: body.name.trim(), + bytes: 0, + status: 'connected', + lastSeen: Date.now() + }) + json(res, { peer }) + return + } + const peerId = String(req.headers['x-plexo-peer'] ?? '') + const peer = this.peers.get(peerId) + if (!peer) throw new Error('Join the session first') + peer.lastSeen = Date.now() + if (req.url === '/leave') { + peer.status = 'left' + for (const block of this.blocks) + if (block.lease?.owner === peerId && !block.lease.writing) block.lease = undefined + json(res, { ok: true }) + return + } + if (peer.status === 'left') throw new Error('This helper has left the session') + if (req.url === '/lease') { + const body = JSON.parse((await readBody(req, 1024)).toString()) + if (!Number.isSafeInteger(body.remaining) || body.remaining < 0) + throw new Error('Invalid remaining budget') + const lease = state.status === 'downloading' ? this.claim(peerId, body.remaining) : null + peer.status = lease ? 'working' : 'connected' + json(res, { + status: state.status, + lease: lease && { id: lease.id, index: lease.index, start: lease.start, end: lease.end }, + bytes: state.bytes + } satisfies Assignment) + return + } + if (req.url === '/chunk') { + const block = this.blocks.find( + (item) => + item.lease && + item.lease.id === req.headers['x-plexo-lease'] && + item.lease.owner === peerId + ) + if (!block?.lease || state.status !== 'downloading') + throw new Error('This chunk is no longer needed') + const lease = block.lease + const expected = block.end - block.start + 1 + if (Number(req.headers['content-length']) !== expected) + throw new Error('Incorrect chunk size') + const bytes = await readBody(req, expected) + if (hash(bytes) !== req.headers['x-plexo-sha256']) throw new Error('Chunk checksum mismatch') + await this.accept(peerId, lease, bytes) + json(res, { ok: true }) + return + } + throw new Error('Unknown operation') + } + + private async hostWorker(): Promise { + const signal = this.stop.signal + while (!signal.aborted && !this.blocks.every((block) => block.done)) { + const lease = this.claim('host', CHUNK_BYTES) + if (!lease) { + await delay(200, undefined, { signal }) + continue + } + try { + const bytes = await fetchPiece( + this.state!.file, + lease, + () => this.networks.find(this.hostNetwork), + signal, + (count) => { + this.state!.usedBytes += count + } + ) + await this.accept('host', lease, bytes) + } catch (error) { + const block = this.blocks[lease.index] + if (block.lease?.id === lease.id) block.lease = undefined + if (signal.aborted || ++block.failures >= 3) throw error + await delay(500, undefined, { signal }) + } + } + signal.throwIfAborted() + this.state!.status = 'verifying' + const digest = createHash('sha256') + for await (const bytes of createReadStream(this.file!.path, { signal })) digest.update(bytes) + const sha256 = digest.digest('hex') + if (this.state!.file.expectedSha256 && sha256 !== this.state!.file.expectedSha256) + throw new Error( + 'Final SHA-256 does not match the supplied checksum. The file was not published.' + ) + this.state!.destinationPath = await this.file!.publish( + this.state!.destinationPath!, + this.state!.file.totalBytes, + async () => { + signal.throwIfAborted() + } + ) + this.file = undefined + this.state!.sha256 = sha256 + this.state!.status = 'completed' + this.expireServer() + } + + private async helperWorker(request: JoinTogetherRequest): Promise { + const signal = this.stop.signal + const peer = this.helper!.peer + const headers = { 'X-Plexo-Peer': peer } + try { + while (!signal.aborted) { + const remaining = request.budgetBytes - this.state!.usedBytes + const assignment = await callPeer( + request.code, + '/lease', + { remaining }, + signal, + headers + ) + this.state!.bytes = assignment.bytes + if (assignment.status === 'completed') { + this.state!.status = 'completed' + return + } + if (['stopped', 'error'].includes(assignment.status)) + throw new Error('The host stopped this session') + this.state!.status = assignment.status + if (remaining < Math.min(CHUNK_BYTES, this.state!.file.totalBytes)) { + this.state!.status = 'stopped' + return + } + if (!assignment.lease) { + await delay(350, undefined, { signal }) + continue + } + const lease = assignment.lease + if (lease.end - lease.start + 1 > remaining) + throw new Error('Assignment exceeds your remaining data budget') + const bytes = await fetchPiece( + this.state!.file, + lease, + () => this.networks.find(request.internetInterfaceId), + signal, + (count) => { + this.state!.usedBytes += count + } + ) + await callPeer(request.code, '/chunk', bytes, signal, { + ...headers, + 'X-Plexo-Lease': lease.id, + 'X-Plexo-Sha256': hash(bytes) + }) + this.state!.contributedBytes += bytes.length + } + } finally { + await callPeer(request.code, '/leave', {}, AbortSignal.timeout(1500), headers).catch(() => {}) + } + } + + private expireServer(): void { + clearTimeout(this.expiry) + // Let helpers observe completion/failure, then revoke the code and close the listener. + this.expiry = setTimeout(() => { + this.server?.closeAllConnections() + this.server?.close() + this.server = undefined + }, 30_000) + this.expiry.unref() + } + + private async fail(error: unknown): Promise { + if (this.state && this.state.status !== 'stopped' && this.state.status !== 'completed') { + this.state.status = 'error' + this.state.error = message(error) + } + this.stop.abort() + if (this.server) this.expireServer() + // No new leases after failure. Wait for any upload write before deleting staging data. + await Promise.allSettled([...this.jobs]) + try { + await this.file?.discard() + this.file = undefined + } catch (cleanupError) { + if (this.state) + this.state.error = `${this.state.error ?? message(error)}. Could not remove the partial file: ${message(cleanupError)}` + } + } + + private async close(): Promise { + clearTimeout(this.expiry) + this.stop.abort() + if (this.state && !['completed', 'error'].includes(this.state.status)) + this.state.status = 'stopped' + const server = this.server + this.server = undefined + if (server) { + server.closeAllConnections() + await new Promise((resolve) => server.close(() => resolve())) + } + await this.run + this.run = undefined + await Promise.allSettled([...this.jobs]) + await this.file?.discard() + this.file = undefined + this.helper = undefined + clearTimeout(this.expiry) + } + + async stopSession(): Promise { + if (this.busy) throw new Error('Wait for session setup to finish') + this.busy = true + try { + await this.close() + } finally { + this.busy = false + } + } +} diff --git a/src/main/together/transport.ts b/src/main/together/transport.ts new file mode 100644 index 0000000..57aaf6e --- /dev/null +++ b/src/main/together/transport.ts @@ -0,0 +1,177 @@ +import { createHash } from 'node:crypto' +import { request, type IncomingMessage } from 'node:http' +import { isIPv4 } from 'node:net' +import type { TogetherFile, TogetherLease } from '../../shared/together' +import type { NetworkInterfaceInfo } from '../../shared/types' +import { StreamConnection } from '../network/routes' + +export const CHUNK_BYTES = 1024 * 1024 +export const LEASE_MS = 45_000 +export const TRANSFER_MS = 30_000 +export const hash = (bytes: Buffer): string => createHash('sha256').update(bytes).digest('hex') + +export function lanAddress(address: string): boolean { + if (!isIPv4(address)) return false + const [a, b] = address.split('.').map(Number) + return ( + a === 10 || + a === 127 || + (a === 192 && b === 168) || + (a === 172 && b >= 16 && b <= 31) || + (a === 169 && b === 254) + ) +} + +export function parseCode(code: string): { host: string; port: number; token: string } { + if (typeof code !== 'string' || code.length > 256) throw new Error('Invalid join code') + const match = /^plexo:\/\/([\d.]+):(\d+)\/([a-f0-9]{32})$/.exec(code.trim()) + if (!match || !lanAddress(match[1]) || +match[2] < 1 || +match[2] > 65535) { + throw new Error('Paste the complete Plexo join code from the host on your local network') + } + return { host: match[1], port: +match[2], token: match[3] } +} + +export async function readBody(message: IncomingMessage, limit: number): Promise { + const parts: Buffer[] = [] + let length = 0 + for await (const raw of message) { + const part = Buffer.from(raw) + length += part.length + if (length > limit) throw new Error('Request exceeds the allowed size') + parts.push(part) + } + return Buffer.concat(parts, length) +} + +/** No redirects, cookies, proxy or browser credentials are forwarded to a peer. */ +export function callPeer( + code: string, + path: string, + data?: object | Buffer, + signal?: AbortSignal, + extra: Record = {} +): Promise { + const { host, port, token } = parseCode(code) + const body = + data === undefined + ? undefined + : Buffer.isBuffer(data) + ? data + : Buffer.from(JSON.stringify(data)) + return new Promise((resolve, reject) => { + const req = request( + { + host, + port, + path, + method: body ? 'POST' : 'GET', + signal: AbortSignal.any([AbortSignal.timeout(TRANSFER_MS), ...(signal ? [signal] : [])]), + headers: { + Authorization: `Bearer ${token}`, + 'Content-Length': String(body?.length ?? 0), + ...extra + } + }, + (res) => { + void readBody(res, 32 * 1024) + .then((bytes) => { + const value = JSON.parse(bytes.toString()) + if (res.statusCode !== 200) + throw new Error( + typeof value.error === 'string' ? value.error : 'The host refused the request' + ) + resolve(value as T) + }) + .catch(reject) + } + ) + req.on('error', reject) + req.end(body) + }) +} + +export function validateFile(value: TogetherFile): TogetherFile { + if ( + !value || + value.protocol !== 1 || + typeof value.id !== 'string' || + !/^[a-f0-9-]{36}$/.test(value.id) || + typeof value.url !== 'string' || + value.url.length > 8192 || + typeof value.fileName !== 'string' || + value.fileName.length > 1024 || + !Number.isSafeInteger(value.totalBytes) || + value.totalBytes < 1 || + value.totalBytes > CHUNK_BYTES * 100_000 || + typeof value.etag !== 'string' || + !/^"[^\r\n]*"$/.test(value.etag) + ) { + throw new Error('This session has unsupported file metadata') + } + const url = new URL(value.url) + if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) + throw new Error('Only HTTP(S) links without embedded credentials are supported') + if (value.expectedSha256 !== undefined && !/^[a-f0-9]{64}$/i.test(value.expectedSha256)) + throw new Error('Invalid SHA-256 checksum') + return value +} + +/** One bounded range; exact ETag/range/length checks. A redirect requires a fresh session. */ +export async function fetchPiece( + file: TogetherFile, + lease: TogetherLease, + network: () => NetworkInterfaceInfo | undefined, + signal: AbortSignal, + progress: (bytes: number) => void +): Promise { + const length = lease.end - lease.start + 1 + if ( + !Number.isSafeInteger(lease.start) || + !Number.isSafeInteger(lease.end) || + lease.start < 0 || + lease.end >= file.totalBytes || + length < 1 || + length > CHUNK_BYTES + ) + throw new Error('Invalid chunk assignment') + const connection = new StreamConnection(network, { timeoutMs: TRANSFER_MS }) + const deadline = AbortSignal.any([signal, AbortSignal.timeout(TRANSFER_MS)]) + const abort = (): void => connection.close() + deadline.addEventListener('abort', abort, { once: true }) + try { + const { res: response } = await connection.request( + new URL(file.url), + { + Range: `bytes=${lease.start}-${lease.end}`, + 'If-Match': file.etag, + 'Accept-Encoding': 'identity', + 'User-Agent': 'Plexo-Together/1' + }, + deadline + ) + if ( + response.statusCode !== 206 || + response.headers['content-range'] !== + `bytes ${lease.start}-${lease.end}/${file.totalBytes}` || + response.headers.etag !== file.etag || + (response.headers['content-encoding'] && response.headers['content-encoding'] !== 'identity') + ) { + response.destroy() + throw new Error('The source changed or did not return the requested file range') + } + const parts: Buffer[] = [] + let received = 0 + for await (const raw of response) { + const part = Buffer.from(raw) + received += part.length + progress(part.length) + if (received > length) throw new Error('Source sent too many bytes') + parts.push(part) + } + if (received !== length) throw new Error('Source returned an incomplete chunk') + return Buffer.concat(parts, received) + } finally { + deadline.removeEventListener('abort', abort) + connection.close() + } +} diff --git a/src/preload/index.ts b/src/preload/index.ts index 583652c..88e5f0c 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -19,6 +19,14 @@ function invoke( } const plexoApi = { + hostTogether: (request: IpcContract['hostTogether']['args'][0]) => + invoke('hostTogether', request), + previewTogether: (code: string) => invoke('previewTogether', code), + joinTogether: (request: IpcContract['joinTogether']['args'][0]) => + invoke('joinTogether', request), + startTogether: () => invoke('startTogether'), + stopTogether: () => invoke('stopTogether'), + getTogether: () => invoke('getTogether'), platform: process.platform, // Sync on purpose — see InitialState. One small read, once, before the renderer's first paint. initialState: ipcRenderer.sendSync(IpcChannels.getInitialState) as InitialState, diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 27f3011..6f55a4a 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -1,5 +1,6 @@ import type { DownloadState } from '@shared/types' -import { useEffect } from 'react' +import { useEffect, useState } from 'react' +import { TogetherPanel } from './components/TogetherPanel' import { TitleBar, type TitleBarStatus } from './components/TitleBar' import { NetworkBindingDialog } from './components/NetworkBindingDialog' import { UpdateDialog } from './components/UpdateDialog' @@ -64,6 +65,8 @@ function renderDownload( } function App(): React.JSX.Element { + const [togetherMode, setTogetherMode] = useState(false) + const [togetherRunning, setTogetherRunning] = useState(false) useDownloadEvents() useNetworkEvents() @@ -112,7 +115,31 @@ function App(): React.JSX.Element {
-
{screen}
+ + +
diff --git a/src/renderer/src/components/TogetherPanel.tsx b/src/renderer/src/components/TogetherPanel.tsx new file mode 100644 index 0000000..18de24e --- /dev/null +++ b/src/renderer/src/components/TogetherPanel.tsx @@ -0,0 +1,402 @@ +import { useEffect, useState } from 'react' +import type { TogetherFile, TogetherState } from '@shared/together' +import { useAppStore } from '../store/useAppStore' +import { formatBytes } from '../utils/format' +import { Button } from './ui/button' + +const inputClass = 'w-full rounded-md border border-border bg-background px-3 py-2 text-sm' +const busyStatuses = ['waiting', 'downloading', 'verifying'] + +/** Kept mounted while hidden so mode changes cannot hide an ongoing session from its status tab. */ +export function TogetherPanel({ + active, + onStatus +}: { + active: boolean + onStatus: (running: boolean) => void +}): React.JSX.Element { + const networks = useAppStore((store) => store.interfaces) + const destination = useAppStore((store) => store.destinationDir) + const setDestination = useAppStore((store) => store.setDestinationDir) + const [mode, setMode] = useState<'host' | 'join'>('host') + const [url, setUrl] = useState('') + const [checksum, setChecksum] = useState('') + const [network, setNetwork] = useState('') + const [lan, setLan] = useState('') + const [code, setCode] = useState('') + const [name, setName] = useState('Helper') + const [budget, setBudget] = useState('500') + const [preview, setPreview] = useState(null) + const [session, setSession] = useState(null) + const [error, setError] = useState('') + const [pending, setPending] = useState(false) + const selectedNetwork = network || networks[0]?.id || '' + const addresses = networks.flatMap((iface) => + iface.addresses + .filter((address) => address.family === 4) + .map((address) => ({ address: address.address, name: iface.displayName })) + ) + const selectedLan = lan || addresses[0]?.address || '' + const running = !!session && busyStatuses.includes(session.status) + + useEffect(() => { + onStatus(running) + }, [running, onStatus]) + useEffect(() => { + let disposed = false + let timer: ReturnType + const poll = async (): Promise => { + try { + const next = await window.plexo.getTogether() + if (!disposed) setSession(next) + } catch (cause) { + if (!disposed) setError(String(cause)) + } + if (!disposed) timer = setTimeout(() => void poll(), 500) + } + void poll() + return () => { + disposed = true + clearTimeout(timer) + } + }, []) + + const perform = async (action: () => Promise): Promise => { + setPending(true) + setError('') + try { + await action() + setSession(await window.plexo.getTogether()) + } catch (cause) { + setError(cause instanceof Error ? cause.message : String(cause)) + } finally { + setPending(false) + } + } + + if (!active) return <> + + return ( + + ) +} diff --git a/src/shared/ipc-channels.ts b/src/shared/ipc-channels.ts index e0ba3df..f34fd4b 100644 --- a/src/shared/ipc-channels.ts +++ b/src/shared/ipc-channels.ts @@ -1,4 +1,10 @@ export const IpcChannels = { + hostTogether: 'together:host', + previewTogether: 'together:preview', + joinTogether: 'together:join', + startTogether: 'together:start', + stopTogether: 'together:stop', + getTogether: 'together:get', listInterfaces: 'network:list-interfaces', pingInterfaces: 'network:ping-interfaces', deviceBindingSupported: 'network:device-binding-supported', diff --git a/src/shared/ipc-contract.ts b/src/shared/ipc-contract.ts index c24a0f9..e9b4676 100644 --- a/src/shared/ipc-contract.ts +++ b/src/shared/ipc-contract.ts @@ -1,3 +1,9 @@ +import type { + HostTogetherRequest, + JoinTogetherRequest, + TogetherFile, + TogetherState +} from './together' import type { AppSettings, DownloadUpdate, @@ -12,6 +18,12 @@ import type { * both plexoApi (preload) and registerIpcHandlers (main), so a signature drift between the two * is a compile error instead of a runtime one. */ export interface IpcContract { + hostTogether: { args: [request: HostTogetherRequest]; result: TogetherState } + previewTogether: { args: [code: string]; result: TogetherFile } + joinTogether: { args: [request: JoinTogetherRequest]; result: TogetherState } + startTogether: { args: []; result: void } + stopTogether: { args: []; result: void } + getTogether: { args: []; result: TogetherState | null } listInterfaces: { args: []; result: NetworkInterfaceInfo[] } pingInterfaces: { args: []; result: Record } deviceBindingSupported: { args: []; result: boolean } diff --git a/src/shared/together.ts b/src/shared/together.ts new file mode 100644 index 0000000..4b719d9 --- /dev/null +++ b/src/shared/together.ts @@ -0,0 +1,55 @@ +/** Versioned LAN protocol. A join code grants access; share it only with trusted people. */ +export interface TogetherFile { + protocol: 1 + id: string + url: string + fileName: string + totalBytes: number + etag: string + expectedSha256?: string +} + +export interface TogetherPeer { + name: string + bytes: number + status: 'connected' | 'working' | 'left' +} + +export interface TogetherState { + role: 'host' | 'helper' + status: 'waiting' | 'downloading' | 'verifying' | 'completed' | 'stopped' | 'error' + file: TogetherFile + code?: string + bytes: number + contributedBytes: number + /** Source response payload received, including failed attempts; excludes network overhead. */ + usedBytes: number + budgetBytes?: number + peers: TogetherPeer[] + destinationPath?: string + sha256?: string + error?: string +} + +export interface HostTogetherRequest { + url: string + destinationDir: string + internetInterfaceId: string + lanAddress: string + expectedSha256?: string +} + +export interface JoinTogetherRequest { + code: string + file: TogetherFile + name: string + internetInterfaceId: string + budgetBytes: number +} + +export interface TogetherLease { + id: string + index: number + start: number + end: number +}