Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
45 changes: 45 additions & 0 deletions docs/download-together.md
Original file line number Diff line number Diff line change
@@ -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.
245 changes: 245 additions & 0 deletions e2e/together.spec.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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<void> {
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()
})
3 changes: 1 addition & 2 deletions src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<typeof registerIpcHandlers> | null = null
let quitAfterSuspending = false

function createWindow(): void {
Expand Down
Loading
Loading