Skip to content
Merged
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
21 changes: 18 additions & 3 deletions cmd/cdx/progress.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"encoding/json"
"fmt"
"io"
"os"
"strings"
"time"
)
Expand All @@ -30,6 +31,7 @@ type progressBar struct {
sampled uint64
rate float64 // bytes per second
drawn bool
colored bool
// jsonLines writes one {"progress":…} object per line instead of a bar,
// for agents that read stderr from a pipe.
jsonLines bool
Expand All @@ -50,7 +52,9 @@ type progressLine struct {

func newProgressBar(out io.Writer, label string, total uint64) *progressBar {
now := time.Now()
return &progressBar{out: out, label: label, total: total, started: now, sampledAt: now}
file, ok := out.(*os.File)
colored := ok && isTerminal(file) && colorEnabled
return &progressBar{out: out, label: label, total: total, started: now, sampledAt: now, colored: colored}
}

// update redraws the line at most every progressInterval, and always for
Expand Down Expand Up @@ -100,8 +104,19 @@ func (p *progressBar) line(done uint64, now time.Time) string {
}
fraction := float64(done) / float64(p.total)
filled := int(fraction * progressBarWidth)
bar := strings.Repeat("█", filled) + strings.Repeat("░", progressBarWidth-filled)
line := fmt.Sprintf("%s [%s] %3d%% %s / %s", p.label, bar, int(fraction*100), formatShortBytes(done), formatShortBytes(p.total))
complete := strings.Repeat("█", filled)
remaining := strings.Repeat("░", progressBarWidth-filled)
percent := fmt.Sprintf("%3d%%", int(fraction*100))
label := p.label
counts := fmt.Sprintf("%s / %s", formatShortBytes(done), formatShortBytes(p.total))
if p.colored {
complete = accent(complete)
remaining = sgr("38;2;107;47;28", remaining)
percent = accent(percent)
label = sgr("38;2;228;212;182", label)
counts = sgr("38;2;179;163;140", counts)
}
line := fmt.Sprintf("%s [%s%s] %s %s", label, complete, remaining, percent, counts)
if done == p.total {
elapsed := now.Sub(p.started)
if seconds := elapsed.Seconds(); seconds > 0 {
Expand Down
2 changes: 1 addition & 1 deletion cmd/cdx/style.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ func sgr(code, text string) string {

func bold(text string) string { return sgr("1", text) }
func dim(text string) string { return sgr("2", text) }
func accent(text string) string { return sgr("38;5;141", text) }
func accent(text string) string { return sgr("38;2;229;138;87", text) }
func green(text string) string { return sgr("38;5;114", text) }
func cmdText(text string) string { return sgr("38;5;223", text) }

Expand Down
4 changes: 2 additions & 2 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@ <h2>Send files</h2>
<section id="receiver-view" class="view hidden" role="tabpanel" aria-labelledby="receive-mode-btn">
<div id="receiver-input-section" class="input-section">
<h2 class="section-title">Receive files</h2>
<p class="section-subtitle">Enter the 5-digit code or scan the QR.</p>
<p class="section-subtitle">Enter the code to start downloading, or scan the QR.</p>
<label for="code-input" class="visually-hidden">Share code</label>
<div class="code-input-wrapper">
<input
Expand All @@ -169,7 +169,7 @@ <h2 class="section-title">Receive files</h2>
autocorrect="off"
enterkeyhint="go"
spellcheck="false"
inputmode="text"
inputmode="numeric"
/>
<button id="connect-btn" class="primary-btn" type="button">Connect</button>
</div>
Expand Down
11 changes: 4 additions & 7 deletions scripts/verify-agent.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -141,15 +141,12 @@ async function verifyFolderBundle(context) {
const page = await context.newPage();
await page.goto(publicBase);
await page.locator('#receive-mode-btn').click();
await page.locator('#code-input').fill(bundleLink);
await page.locator('#receiver-relay [data-relay="offer"]:not([hidden])').waitFor();
assert.equal(await page.locator('[data-relay="file-name"]').textContent(), 'bundle folder-and-1-more.zip');
await page.locator('[data-relay="accept"]').click();
await page.locator('[data-relay="status"]').filter({ hasText: 'File verified and ready to download.' }).waitFor();
const downloadEvent = page.waitForEvent('download');
await page.locator('[data-relay="download"]').click();
await page.locator('#code-input').fill(bundleLink);
const download = await downloadEvent;
assert.equal(download.suggestedFilename(), 'bundle folder-and-1-more.zip');
const bundlePath = join(work, 'received-bundle.zip');
await (await downloadEvent).saveAs(bundlePath);
await download.saveAs(bundlePath);
const bundle = await readFile(bundlePath);
assert.deepEqual([...bundle.subarray(0, 4)], [0x50, 0x4b, 0x03, 0x04], 'bundle is not a zip');
assert.ok(bundle.includes(Buffer.from('bundle folder/nested/a.bin')), 'bundle is missing the nested file');
Expand Down
15 changes: 7 additions & 8 deletions scripts/verify-browser.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -67,24 +67,23 @@ try {
let senderStatus = async () => '';
async function receiveInBrowser(receiverBrowser, open) {
const context = await receiverBrowser.newContext({ acceptDownloads: true });
// Headless Chromium has no save dialog: use the in-memory download path.
await context.addInitScript(() => { delete window.showSaveFilePicker; });
await context.addInitScript(() => {
window.showSaveFilePicker = () => { throw new Error('Automatic receive must not open a save dialog'); };
});
const page = await context.newPage();
page.setDefaultTimeout(30_000);
await page.goto(publicBase);
await page.locator('.workbench:not([inert])').waitFor();
await page.locator('#receive-mode-btn').click();
assert.equal(await page.locator('#code-input').getAttribute('inputmode'), 'numeric');
const downloadEvent = page.waitForEvent('download', { timeout: 60_000 });
await open(page);
await page.locator('#receiver-relay [data-relay="offer"]:not([hidden])').waitFor();
await page.locator('[data-relay="accept"]').click();
let download;
try {
await page.locator('[data-relay="status"]').filter({ hasText: 'File verified and ready to download.' }).waitFor({ timeout: 60_000 });
download = await downloadEvent;
} catch (error) {
throw new Error(`browser receive stalled: receiver status "${await page.locator('[data-relay="status"]').textContent()}"; sender status "${await senderStatus()}"`, { cause: error });
}
const downloadEvent = page.waitForEvent('download');
await page.locator('[data-relay="download"]').click();
const download = await downloadEvent;
const path = join(work, `browser-${Date.now()}-${download.suggestedFilename()}`);
await download.saveAs(path);
assert.equal(new URL(page.url()).pathname, '/', 'receiving left the home page');
Expand Down
24 changes: 20 additions & 4 deletions src/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -589,7 +589,7 @@ const Receiver = (() => {
els.receiverConnecting.classList.add('hidden');
els.receiverRelay.classList.remove('hidden');
setState('idle');
stopRelay = mountRelayReceiver(els.receiverRelayPanel, url);
stopRelay = mountRelayReceiver(els.receiverRelayPanel, url, { autoReceive: true });
}

function closeRelay() {
Expand Down Expand Up @@ -1328,11 +1328,20 @@ els.senderRetryBtn.addEventListener('click', () => {
});
els.copyCmdBtn.addEventListener('click', () => void Sender.copyCommand());

els.connectBtn.addEventListener('click', () => Receiver.connect(els.codeInput.value));
let autoConnectTimer = null;

function connectEnteredCode() {
clearTimeout(autoConnectTimer);
els.codeInput.blur();
Receiver.connect(els.codeInput.value);
}

els.connectBtn.addEventListener('click', connectEnteredCode);
els.codeInput.addEventListener('keydown', (event) => {
if (event.key === 'Enter') Receiver.connect(els.codeInput.value);
if (event.key === 'Enter') connectEnteredCode();
});
els.codeInput.addEventListener('input', (event) => {
clearTimeout(autoConnectTimer);
const raw = event.target.value;
// Agent links (from `cdx send`) open the relay receiver instead of
// being cleaned into a P2P code. Check before mangling the pasted text.
Expand All @@ -1346,9 +1355,16 @@ els.codeInput.addEventListener('input', (event) => {
event.target.value = /[:\/#.]/.test(raw) ? codeFromUrl(raw, window.location.href) : cleanCode(raw);
els.codeInput.removeAttribute('aria-invalid');
// Five digits is the longest numeric code, so nothing else is coming.
if (/^\d{5}$/.test(event.target.value.trim())) Receiver.connect(event.target.value);
if (/^\d{5}$/.test(event.target.value.trim())) connectEnteredCode();
else if (/^\d{4}$/.test(event.target.value.trim())) {
autoConnectTimer = setTimeout(() => {
if (!els.receiverInputSection.classList.contains('hidden') &&
els.receiveModeBtn.getAttribute('aria-selected') === 'true') connectEnteredCode();
}, 800);
}
});
els.codeInput.addEventListener('paste', (event) => {
clearTimeout(autoConnectTimer);
event.preventDefault();
const text = (event.clipboardData || window.clipboardData).getData('text');
const agentUrl = agentShareUrlFromInput(text);
Expand Down
13 changes: 11 additions & 2 deletions src/share.js
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ function decodeCounts(value) {
// Renders the relay receiver into `container` and starts receiving the
// `/s/<id>#v1.<key>` link. Used by the standalone share page and inline by
// the main page's Receive tab. Returns a function that leaves the transfer.
export function mountRelayReceiver(container, shareUrl) {
export function mountRelayReceiver(container, shareUrl, { autoReceive = false } = {}) {
container.classList.add('share-panel');
container.innerHTML = PANEL_HTML;
const pick = (name) => container.querySelector(`[data-relay="${name}"]`);
Expand All @@ -102,13 +102,18 @@ export function mountRelayReceiver(container, shareUrl) {
let stop = () => { stopped = true; failureShown = true; };

async function createSink(offer) {
return createDownloadSink({ name: offer.name, size: offer.size, mediaType: offer.mediaType });
return createDownloadSink({ name: offer.name, size: offer.size, mediaType: offer.mediaType, preferDownload: autoReceive });
}

function waitForAcceptance(offer) {
elements.fileName.textContent = offer.name;
elements.fileSize.textContent = formatSize(offer.size);
elements.offer.hidden = false;
if (autoReceive) {
elements.accept.hidden = true;
elements.status.textContent = 'Preparing your download...';
return createSink(offer);
}
elements.status.textContent = 'Ready when you are. The file stays with the sender until you accept.';
return new Promise((resolve, reject) => {
elements.accept.addEventListener('click', async () => {
Expand Down Expand Up @@ -339,6 +344,10 @@ export function mountRelayReceiver(container, shareUrl) {
};
elements.download.addEventListener('click', () => setTimeout(revoke, 60_000), { once: true });
window.addEventListener('pagehide', revoke, { once: true });
if (autoReceive) {
elements.download.click();
elements.status.textContent = 'File verified. Download started; use the download link if your browser blocked it.';
}
} else elements.status.textContent = 'File verified and saved.';
socket.close(1000, 'complete');
return;
Expand Down
3 changes: 2 additions & 1 deletion src/sink.js
Original file line number Diff line number Diff line change
Expand Up @@ -211,8 +211,9 @@ function assertBlobSize(size) {

// Single-file receiver entry point (share.js): routes to the best tier
// and throws a readable error when the browser cannot take the size.
export async function createSink({ name, size, mediaType }) {
export async function createSink({ name, size, mediaType, preferDownload = false }) {
const capabilities = detectCapabilities();
if (preferDownload) capabilities.filePicker = false;
const tier = selectSinkTier(capabilities, size, navigator.userAgent);
if (tier === 'file-picker') {
try {
Expand Down
Loading