Files
sphaira/tools/webusb/index.js
2025-08-31 07:15:53 +01:00

890 lines
34 KiB
JavaScript
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// --- Constants ---
const MAGIC = 0x53504830;
const PACKET_SIZE = 24;
const CMD_QUIT = 0;
const CMD_OPEN = 1;
const CMD_EXPORT = 1;
const RESULT_OK = 0;
const RESULT_ERROR = 1;
const FLAG_NONE = 0;
const FLAG_STREAM = 1 << 0;
class UsbPacket {
constructor(magic = MAGIC, arg2 = 0, arg3 = 0, arg4 = 0, arg5 = 0, crc32c = 0) {
this.magic = magic;
this.arg2 = arg2;
this.arg3 = arg3;
this.arg4 = arg4;
this.arg5 = arg5;
this.crc32c = crc32c;
}
toBuffer() {
const buf = new ArrayBuffer(PACKET_SIZE);
const view = new DataView(buf);
view.setUint32(0, this.magic, true);
view.setUint32(4, this.arg2, true);
view.setUint32(8, this.arg3, true);
view.setUint32(12, this.arg4, true);
view.setUint32(16, this.arg5, true);
view.setUint32(20, this.crc32c, true);
return buf;
}
static fromBuffer(buf) {
const view = new DataView(buf);
return new this(
view.getUint32(0, true),
view.getUint32(4, true),
view.getUint32(8, true),
view.getUint32(12, true),
view.getUint32(16, true),
view.getUint32(20, true)
);
}
calculateCrc32c() {
// Get the full buffer (24 bytes), but only use the first 20 bytes for CRC32C
const buf = this.toBuffer();
const bytes = new Uint8Array(buf, 0, 20);
return crc32c(0, bytes);
}
generateCrc32c() {
this.crc32c = this.calculateCrc32c();
}
verify() {
if (this.crc32c !== this.calculateCrc32c()) throw new Error("CRC32C mismatch");
if (this.magic !== MAGIC) throw new Error("Bad magic");
return true;
}
}
class SendPacket extends UsbPacket {
static build(cmd, arg3 = 0, arg4 = 0) {
const packet = new SendPacket(MAGIC, cmd, arg3, arg4);
packet.generateCrc32c();
return packet;
}
getCmd() {
return this.arg2;
}
}
class ResultPacket extends UsbPacket {
static build(result, arg3 = 0, arg4 = 0) {
const packet = new ResultPacket(MAGIC, result, arg3, arg4);
packet.generateCrc32c();
return packet;
}
verify() {
super.verify();
if (this.arg2 !== RESULT_OK) throw new Error("Result not OK");
return true;
}
}
class SendDataPacket extends UsbPacket {
static build(offset, size, crc32c) {
const arg2 = Number((BigInt(offset) >> 32n) & 0xFFFFFFFFn);
const arg3 = Number(BigInt(offset) & 0xFFFFFFFFn);
const packet = new SendDataPacket(MAGIC, arg2, arg3, size, crc32c);
packet.generateCrc32c();
return packet;
}
getOffset() {
return Number((BigInt(this.arg2) << 32n) | BigInt(this.arg3));
}
getSize() {
return this.arg4;
}
getCrc32c() {
return this.arg5;
}
}
// --- CRC32C Helper ---
const crc32c = (() => {
const POLY = 0x82f63b78;
const table = new Uint32Array(256);
for (let i = 0; i < 256; i++) {
let crc = i;
for (let j = 0; j < 8; j++) {
crc = crc & 1 ? (crc >>> 1) ^ POLY : crc >>> 1;
}
table[i] = crc >>> 0;
}
return function(crc, bytes) {
crc ^= 0xffffffff;
let i = 0;
const len = bytes.length;
for (; i < len - 3; i += 4) {
crc = table[(crc ^ bytes[i]) & 0xff] ^ (crc >>> 8);
crc = table[(crc ^ bytes[i + 1]) & 0xff] ^ (crc >>> 8);
crc = table[(crc ^ bytes[i + 2]) & 0xff] ^ (crc >>> 8);
crc = table[(crc ^ bytes[i + 3]) & 0xff] ^ (crc >>> 8);
}
for (; i < len; i++) {
crc = table[(crc ^ bytes[i]) & 0xff] ^ (crc >>> 8);
}
return (crc ^ 0xffffffff) >>> 0;
};
})();
// --- Main Class ---
class WebUSBFileTransfer {
constructor() {
this.device = null;
this.isConnected = false;
this.endpointIn = null;
this.endpointOut = null;
this.fileQueue = [];
this.authorizedDevices = [];
this.toastTimeout = null;
this.coverage = new Map();
this.progressContext = {current:0,total:0};
this.completedCount = 0;
this.transferStartTime = null;
this.lastUpdateTime = null;
this.lastBytesTransferred = 0;
this.currentSpeed = 0;
this.speedSamples = [];
this.averageSpeed = 0;
this.setupEventListeners();
this.checkWebUSBSupport();
}
// --- WebUSB Support & Device Management ---
async checkWebUSBSupport() {
if (!navigator.usb) {
this.showUnsupportedSplash();
return;
}
await this.loadAuthorizedDevices();
}
async loadAuthorizedDevices() {
try {
const devices = await navigator.usb.getDevices();
this.authorizedDevices = devices.filter(device =>
device.vendorId === 0x057e && device.productId === 0x3000
);
this.showAuthorizedDevices();
if (this.authorizedDevices.length > 0) {
this.log(`Found ${this.authorizedDevices.length} previously authorized device(s)`);
await this.tryAutoConnect();
} else {
this.log('No previously authorized devices found');
}
} catch (error) {
this.log(`Error loading authorized devices: ${error.message}`);
this.authorizedDevices = [];
this.showAuthorizedDevices();
}
}
async tryAutoConnect() {
if (this.authorizedDevices.length === 0) return;
try {
const device = this.authorizedDevices[0];
this.log(`Attempting to auto-connect to: ${device.productName || 'Unknown Device'}`);
await this.connectToDevice(device);
} catch (error) {
this.log(`Auto-connect failed: ${error.message}`);
this.showToast('Auto-connect failed. Device may be unplugged.', 'info', 4000);
}
}
// Add these methods to the class
formatTime(seconds) {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
}
showAuthorizedDevices() {
const container = document.getElementById('authorizedDevices');
if (this.authorizedDevices.length === 0) {
container.style.display = 'none';
return;
}
container.style.display = 'block';
this.updateAuthorizedDevicesUI();
}
updateAuthorizedDevicesUI() {
const listContainer = document.getElementById('deviceListContainer');
let html = '';
this.authorizedDevices.forEach((device, index) => {
const deviceName = device.productName || 'Unknown Device';
const deviceId = `${device.vendorId.toString(16).padStart(4, '0')}:${device.productId.toString(16).padStart(4, '0')}`;
const isCurrentDevice = this.device && this.device === device && this.isConnected;
html += `
<div class="device-list-item">
<div class="device-name">${deviceName} ${device.serialNumber}</div>
<div class="device-id">${deviceId}</div>
<button class="btnf-add" data-device-index="${index}" ${isCurrentDevice ? 'disabled' : ''}>
${isCurrentDevice ? 'Connected' : 'Connect'}
</button>
</div>
`;
});
listContainer.innerHTML = html;
// Add event listeners to connect buttons
const connectButtons = listContainer.querySelectorAll('button[data-device-index]:not([disabled])');
connectButtons.forEach(btn => {
btn.addEventListener('click', async (e) => {
const deviceIndex = parseInt(e.target.getAttribute('data-device-index'));
await this.connectToAuthorizedDevice(deviceIndex);
});
});
}
async connectToAuthorizedDevice(deviceIndex) {
if (deviceIndex < 0 || deviceIndex >= this.authorizedDevices.length) {
this.showStatus('Invalid device index', 'error');
return;
}
const device = this.authorizedDevices[deviceIndex];
this.log(`Connecting to authorized device: ${device.productName || 'Unknown Device'}`);
try {
await this.connectToDevice(device);
} catch (error) {
this.log(`Failed to connect to authorized device: ${error.message}`);
this.showStatus(`Failed to connect: ${error.message}`, 'error');
}
}
showUnsupportedSplash() {
const container = document.querySelector('.container');
container.innerHTML = `
<div class="unsupported-splash">
<h1>WebUSB File Transfer</h1>
<h2>⚠️ Browser Not Supported</h2>
<p>Your browser does not support WebUSB API.</p>
<p><strong>To use this application, please switch to a supported browser:</strong></p>
<p>• Google Chrome (version 61+)<br>
• Microsoft Edge (version 79+)<br>
• Opera (version 48+)</p>
<p>Firefox and Safari do not currently support WebUSB.</p>
<a href="https://developer.mozilla.org/en-US/docs/Web/API/WebUSB_API#browser_compatibility"
class="browser-link" target="_blank" rel="noopener noreferrer">
View Browser Compatibility Chart
</a>
</div>
`;
}
// --- UI Event Listeners ---
setupEventListeners() {
document.getElementById('connectBtn').addEventListener('click', () => this.connectDevice());
document.getElementById('disconnectBtn').addEventListener('click', () => this.disconnectDevice());
document.getElementById('fileInput').addEventListener('change', (e) => this.handleFileSelect(e));
document.getElementById('sendBtn').addEventListener('click', () => this.sendFile());
document.getElementById('clearLogBtn').addEventListener('click', () => this.clearLog());
document.getElementById('copyLogBtn').addEventListener('click', () => this.copyLog());
document.getElementById('addFilesBtn').addEventListener('click', () => this.triggerFileInput());
document.getElementById('clearQueueBtn').addEventListener('click', () => this.clearFileQueue());
document.getElementById('toastClose').addEventListener('click', () => this.hideConnectionToast());
}
// --- File Queue Management ---
triggerFileInput() {
document.getElementById('fileInput').click();
}
clearFileQueue() {
this.fileQueue = [];
document.getElementById('fileInput').value = '';
this.updateFileQueueUI();
this.log('File queue cleared');
this.showToast('File queue cleared', 'info', 2000);
}
handleFileSelect(event) {
const newFiles = Array.from(event.target.files);
const allowedExt = ['.nsp', '.xci', '.nsz', '.xcz'];
if (newFiles.length > 0) {
let added = 0;
for (const file of newFiles) {
const lower = file.name.toLowerCase();
if (!allowedExt.some(ext => lower.endsWith(ext))) {
this.log(`Skipping unsupported file type: ${file.name}`);
continue;
}
if (!this.fileQueue.some(f => f.name === file.name && f.size === file.size)) {
this.fileQueue.push(file);
added++;
}
}
if (added > 0) {
this.updateFileQueueUI();
this.log(`Added ${added} file(s) to queue. Total: ${this.fileQueue.length}`);
this.showToast(`Added ${added} file(s) to queue`, 'success', 2000);
} else {
this.showToast('No supported files were added', 'info', 2000);
}
}
// Reset input so same files can be picked again
event.target.value = '';
}
updateFileQueueUI() {
const queueList = document.getElementById('fileQueueList');
const fileCount = document.getElementById('fileCount');
fileCount.textContent = `${this.fileQueue.length} file${this.fileQueue.length !== 1 ? 's' : ''}`;
if (this.fileQueue.length === 0) {
queueList.innerHTML = '<div class="file-item" style="color: #bed0d6; font-style: italic;">No files in queue. Click "Add Files" to select files.</div>';
document.getElementById('clearQueueBtn').disabled = true;
document.getElementById('sendBtn').disabled = true;
return;
}
document.getElementById('clearQueueBtn').disabled = false;
document.getElementById('sendBtn').disabled = !this.isConnected;
let html = '';
let totalSize = 0;
for (let i = 0; i < this.fileQueue.length; i++) {
const file = this.fileQueue[i];
totalSize += file.size;
html += `
<div class="file-item">
<div class="file-name" title="${file.name}">${file.name}</div>
<div class="file-size">${this.formatFileSize(file.size)}</div>
<div class="file-actions">
<button class="btn-remove" data-index="${i}">Remove</button>
</div>
</div>
`;
}
html += `
<div class="file-item" style="border-top: 1px solid #163951; font-weight: 600;">
<div class="file-name">Total</div>
<div class="file-size">${this.formatFileSize(totalSize)}</div>
<div class="file-actions"></div>
</div>
`;
queueList.innerHTML = html;
// Add event listeners to remove buttons
const removeButtons = queueList.querySelectorAll('.btn-remove');
removeButtons.forEach(btn => {
btn.addEventListener('click', (e) => {
const index = parseInt(e.target.getAttribute('data-index'));
this.removeFileFromQueue(index);
});
});
}
removeFileFromQueue(index) {
if (index >= 0 && index < this.fileQueue.length) {
const removedFile = this.fileQueue[index];
this.fileQueue.splice(index, 1);
this.updateFileQueueUI();
this.log(`Removed "${removedFile.name}" from queue`);
this.showStatus(`Removed "${removedFile.name}" from queue`, 'info');
}
}
// --- Device Connection ---
async connectDevice() {
try {
this.log('Requesting USB device...');
this.device = await navigator.usb.requestDevice({
filters: [{ vendorId: 0x057e, productId: 0x3000 }]
});
await this.connectToDevice(this.device);
await this.loadAuthorizedDevices();
} catch (error) {
this.log(`Connection error: ${error.message}`);
this.showToast(`Failed to connect: ${error.message}`, 'error', 5000);
}
}
async connectToDevice(device) {
this.device = device;
this.log(`Selected device: ${this.device.productName || 'Unknown'}`);
await this.device.open();
if (this.device.configuration === null) {
await this.device.selectConfiguration(1);
this.log('Configuration selected');
}
await this.device.claimInterface(0);
this.log('Interface claimed');
const iface = this.device.configuration.interfaces[0].alternates[0];
this.endpointIn = iface.endpoints.find(e => e.direction === 'in' && e.type === 'bulk')?.endpointNumber;
this.endpointOut = iface.endpoints.find(e => e.direction === 'out' && e.type === 'bulk')?.endpointNumber;
if (this.endpointIn === undefined || this.endpointOut === undefined) {
throw new Error("Bulk IN/OUT endpoints not found");
}
this.isConnected = true;
this.updateUI();
this.showToast(`Device connected successfully!`, 'success', 3000);
this.showConnectionToast(`Connected: ${this.device.productName || 'USB Device'}`, 'connect');
}
async disconnectDevice() {
try {
if (this.device) {
try {
if (this.isConnected) {
await this.device.close();
}
} catch (closeErr) {
this.log(`Close skipped: ${closeErr.message}`);
} finally {
this.device = null;
this.isConnected = false;
this.updateUI();
this.log('Device state reset after disconnect');
this.showConnectionToast('Device Disconnected', 'disconnect');
}
}
} catch (error) {
this.log(`Disconnect error: ${error.message}`);
this.showToast(`Disconnect error: ${error.message}`, 'error', 4000);
}
}
// --- File Transfer ---
async sendFile() {
const files = this.fileQueue;
let utf8Encode = new TextEncoder();
if (!files.length || !this.isConnected) {
this.showToast('Please select files and ensure device is connected', 'error', 4000);
return;
}
let names = files.map(f => f.name).join("\n") + "\n";
const string_table = utf8Encode.encode(names);
this.completedCount = 0;
this.showTransferProgress(files.length);
try {
this.log(`Waiting for Sphaira to begin transfer`);
document.getElementById('sendBtn').disabled = true;
await this.get_send_header();
await this.send_result(RESULT_OK, string_table.length);
await this.write(string_table);
while (true) {
try {
const [cmd, arg3, arg4] = await this.get_send_header();
if (cmd == CMD_QUIT) {
await this.send_result(RESULT_OK);
if (files.length > 0) {
this.log(`All ${files.length} files transferred successfully`);
this.showToast(
`✅ All ${files.length} files transferred successfully!`,
'success', 5000
);
this.updateTransferProgress(files.length, files.length, 0, null, 100);
}
break;
} else if (cmd == CMD_OPEN) {
const file = files[arg3];
if (!file) {
await this.send_result(RESULT_ERROR);
this.showToast(`Device requested invalid file index: ${arg3}`, 'error', 5000);
this.log(`❌ Transfer stopped: invalid file index ${arg3} (out of ${files.length})`);
break;
}
const total = files.length;
const current = arg3 + 1;
this.progressContext = {current, total};
this.log(`Opening file [${current}/${total}]: ${file.name} (${this.formatFileSize(file.size)})`);
this.showToast(`📤 Transferring file ${current} of ${total}: ${file.name}`, 'info', 3000);
this.updateTransferProgress(this.completedCount, total, 0, file, 0);
this.coverage.delete(file.name);
await this.send_result(RESULT_OK);
await this.file_transfer_loop(file);
this.completedCount += 1;
this.showToast(`✅ File ${current} of ${total} completed`, 'success', 2000);
this.updateTransferProgress(this.completedCount, total, 0, null, 100);
} else {
await this.send_result(RESULT_ERROR);
this.log(`❌ Unknown command (${cmd}) from device`);
this.showToast(
`❌ Transfer stopped after ${this.completedCount} of ${files.length} files (unknown command)`,
'error', 5000
);
break;
}
} catch (loopError) {
this.log(`❌ Loop error: ${loopError.message}`);
this.showToast(
`❌ Transfer stopped after ${this.completedCount} of ${files.length} files`,
'error', 5000
);
break;
}
}
} catch (error) {
this.log(`Transfer error: ${error.message}`);
this.showToast(`Transfer failed: ${error.message}`, 'error', 5000);
} finally {
document.getElementById('sendBtn').disabled = false;
setTimeout(() => { this.hideTransferProgress(); }, 3000);
}
}
async read(size) {
const result = await this.device.transferIn(this.endpointIn, size);
if (result.status && result.status !== 'ok') {
throw new Error(`USB transferIn failed: ${result.status}`);
}
if (!result.data) {
throw new Error('transferIn returned no data');
}
return result;
}
async write(buffer) {
const result = await this.device.transferOut(this.endpointOut, buffer);
if (result.status && result.status !== 'ok') {
throw new Error(`USB transferOut failed: ${result.status}`);
}
return result;
}
// --- Protocol Helpers ---
async get_send_header() {
// Read a full SendPacket (24 bytes)
const result = await this.read(PACKET_SIZE);
const buf = result.data.buffer.slice(result.data.byteOffset, result.data.byteOffset + PACKET_SIZE);
const packet = SendPacket.fromBuffer(buf);
packet.verify();
return [packet.getCmd(), packet.arg3, packet.arg4];
}
async get_send_data_header() {
// Read a full SendDataPacket (24 bytes)
const result = await this.read(PACKET_SIZE);
const buf = result.data.buffer.slice(result.data.byteOffset, result.data.byteOffset + PACKET_SIZE);
const packet = SendDataPacket.fromBuffer(buf);
packet.verify();
return [packet.getOffset(), packet.getSize(), packet.getCrc32c()];
}
async send_result(result, arg3 = 0, arg4 = 0) {
// Build a ResultPacket and send it
const packet = ResultPacket.build(result, arg3, arg4);
await this.write(packet.toBuffer());
}
// --- File Transfer Loop ---
// Modify the file_transfer_loop method to track progress
async file_transfer_loop(file) {
this.disableFileControls(true);
// Reset progress tracking
this.transferStartTime = Date.now();
this.lastUpdateTime = null;
this.lastBytesTransferred = 0;
this.currentSpeed = 0;
this.speedSamples = [];
this.averageSpeed = 0;
try {
while (true) {
const [off, size, _] = await this.get_send_data_header();
if (off === 0 && size === 0) {
await this.send_result(RESULT_OK);
this.log("Transfer complete");
this.markCoverage(file, Math.max(0, file.size - 1), 1);
break;
}
const slice = file.slice(off, off + size);
const buf = new Uint8Array(await slice.arrayBuffer());
const crc32c_got = crc32c(0, buf) >>> 0;
// send result and data.
await this.send_result(RESULT_OK, buf.length, crc32c_got);
await this.write(buf);
// Update progress tracking
this.markCoverage(file, off, size);
}
} catch (err) {
this.log(`File loop error: ${err.message}`);
this.showToast(`File transfer aborted: ${err.message}`, 'error', 4000);
} finally {
this.disableFileControls(false);
}
}
disableFileControls(disable) {
document.getElementById('addFilesBtn').disabled = disable || !this.isConnected;
document.getElementById('clearQueueBtn').disabled = disable || this.fileQueue.length === 0;
document.querySelectorAll('.btn-remove').forEach(btn => btn.disabled = disable);
}
// --- Coverage Tracking ---
markCoverage(file, off, size) {
const BLOCK = 65536;
let set = this.coverage.get(file.name);
if (!set) {
set = new Set();
this.coverage.set(file.name, set);
}
if (size > 0) {
const start = Math.floor(off / BLOCK);
const end = Math.floor((off + size - 1) / BLOCK);
for (let b = start; b <= end; b++) set.add(b);
}
const coveredBytes = Math.min(set.size * BLOCK, file.size);
const pct = file.size > 0 ? Math.min(100, Math.floor((coveredBytes / file.size) * 100)) : 100;
if (this.progressContext.total > 0) {
this.updateTransferProgress(this.completedCount, this.progressContext.total, coveredBytes, file, pct);
}
return pct;
}
// --- UI State ---
updateUI() {
document.getElementById('connectBtn').disabled = this.isConnected;
document.getElementById('disconnectBtn').disabled = !this.isConnected;
document.getElementById('addFilesBtn').disabled = !this.isConnected;
document.getElementById('sendBtn').disabled = !this.isConnected || this.fileQueue.length === 0;
if (this.authorizedDevices.length > 0) {
this.updateAuthorizedDevicesUI();
}
}
// --- Status & Logging ---
showStatus(message, type) {
this.log(`[${type.toUpperCase()}] ${message}`);
}
log(message) {
const logDiv = document.getElementById('logDiv');
const timestamp = new Date().toLocaleTimeString();
logDiv.textContent += `[${timestamp}] ${message}\n`;
logDiv.scrollTop = logDiv.scrollHeight;
}
clearLog() {
const logDiv = document.getElementById('logDiv');
logDiv.textContent = '';
this.log('Log cleared');
}
copyLog() {
const logDiv = document.getElementById('logDiv');
navigator.clipboard.writeText(logDiv.textContent)
.then(() => { this.log('Log copied to clipboard'); })
.catch(err => { this.log(`Failed to copy log: ${err}`); });
}
// --- Formatting ---
formatFileSize(bytes) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
const value = parseFloat((bytes / Math.pow(k, i)).toFixed(2));
// For speeds, we want to show one decimal place for MB/s and GB/s
if (i >= 2 && value < 10) {
return value.toFixed(1) + ' ' + sizes[i];
}
return value + ' ' + sizes[i];
}
// --- Toasts & Progress UI ---
showConnectionToast(message, type = 'connect') {
const toast = document.getElementById('connectionToast');
const toastMessage = document.getElementById('toastMessage');
const toastIcon = toast.querySelector('.toast-icon');
if (this.toastTimeout) clearTimeout(this.toastTimeout);
toastMessage.textContent = message;
if (type === 'connect') {
toastIcon.textContent = '🔗';
toast.className = 'connection-toast';
} else {
toastIcon.textContent = '🔌';
toast.className = 'connection-toast disconnect';
}
toast.classList.add('show');
this.toastTimeout = setTimeout(() => { this.hideConnectionToast(); }, 4000);
}
hideConnectionToast() {
const toast = document.getElementById('connectionToast');
toast.classList.remove('show');
if (this.toastTimeout) {
clearTimeout(this.toastTimeout);
this.toastTimeout = null;
}
}
showToast(message, type = 'info', duration = 4000) {
const toast = document.getElementById('connectionToast');
const toastMessage = document.getElementById('toastMessage');
const toastIcon = toast.querySelector('.toast-icon');
if (this.toastTimeout) clearTimeout(this.toastTimeout);
toastMessage.textContent = message;
const icons = {
'info': '',
'success': '✅',
'error': '❌',
'connect': '🔗',
'disconnect': '🔌'
};
toastIcon.textContent = icons[type] || '';
toast.className = `connection-toast ${type}`;
toast.classList.add('show');
this.toastTimeout = setTimeout(() => { this.hideConnectionToast(); }, duration);
}
// --- Progress UI ---
showTransferProgress(totalFiles) {
const progressDiv = document.getElementById('transferProgress');
progressDiv.style.display = 'block';
this.updateTransferProgress(0, totalFiles, 0, null, 0);
}
updateTransferProgress(completed, total, offset, currentFile, fileProgress) {
this.updateProgressStats(offset, currentFile);
this.updateProgressUI(completed, total, offset, currentFile, fileProgress);
}
updateProgressStats(offset, currentFile) {
const now = Date.now();
let fileSize = 0;
if (currentFile) {
fileSize = currentFile.size;
}
// Calculate speed
if (this.lastUpdateTime) {
const timeDiff = (now - this.lastUpdateTime) / 1000; // in seconds
if (timeDiff > 0.1) { // Update at most every 100ms
const bytesDiff = offset - this.lastBytesTransferred;
this.currentSpeed = bytesDiff / timeDiff; // bytes per second
// Add to samples for averaging (keep last 10 samples)
this.speedSamples.push(this.currentSpeed);
if (this.speedSamples.length > 10) {
this.speedSamples.shift();
}
// Calculate average speed
this.averageSpeed = this.speedSamples.reduce((a, b) => a + b, 0) / this.speedSamples.length;
this.lastUpdateTime = now;
this.lastBytesTransferred = offset;
}
} else {
this.lastUpdateTime = now;
this.lastBytesTransferred = offset;
}
}
updateProgressUI(completed, total, offset, currentFile, fileProgress) {
// Update progress counter
document.getElementById('progressCounter').textContent = `${completed} / ${total}`;
// Update progress title based on state
this.updateProgressTitle(completed, total, currentFile);
// Update time and speed information
this.updateTimeAndSpeedInfo(offset, currentFile);
// Update progress bar
this.updateProgressBar(fileProgress);
// Update percentage display
document.getElementById('progressPercentage').textContent = `${Math.round(fileProgress)}%`;
}
updateProgressTitle(completed, total, currentFile) {
const progressTitle = document.getElementById('progressTitle');
if (currentFile) {
const truncatedName = currentFile.name.length > 100 ?
currentFile.name.slice(0, 97) + '...' : currentFile.name;
progressTitle.textContent = `📄 ${truncatedName}`;
// Show progress bar when a file is being transferred
document.getElementById('transferProgress').style.display = 'block';
} else if (completed === total && total > 0) {
progressTitle.textContent = '✅ All files completed!';
// Hide progress details when all files are done
setTimeout(() => {
document.getElementById('transferProgress').style.display = 'none';
}, 3000);
} else {
progressTitle.textContent = 'Waiting for next file...';
}
}
updateTimeAndSpeedInfo(offset, currentFile) {
const now = Date.now();
let fileSize = 0;
if (currentFile) {
fileSize = currentFile.size;
}
// Calculate time spent
const timeSpent = (now - this.transferStartTime) / 1000;
// Calculate time remaining
let timeRemaining = 0;
if (this.averageSpeed > 0) {
const remainingBytes = fileSize - offset;
timeRemaining = remainingBytes / this.averageSpeed;
}
// Update UI elements
document.getElementById('timeSpent').textContent = this.formatTime(timeSpent);
document.getElementById('timeRemaining').textContent = timeRemaining > 0 ? this.formatTime(timeRemaining) : '--:--';
document.getElementById('dataTransferred').textContent = this.formatFileSize(offset);
document.getElementById('currentSpeed').textContent = `${this.formatFileSize(this.averageSpeed)}/s`;
document.getElementById('transferSpeed').textContent = `${this.formatFileSize(this.averageSpeed)}/s`;
}
updateProgressBar(fileProgress) {
const progressBar = document.getElementById('fileProgressBar');
if (progressBar) {
progressBar.style.width = `${fileProgress}%`;
}
}
hideTransferProgress() {
const progressDiv = document.getElementById('transferProgress');
progressDiv.style.display = 'none';
}
}
// --- App Initialization ---
let app;
window.addEventListener('load', async () => {
app = new WebUSBFileTransfer();
});
// --- Global USB Event Handlers ---
navigator.usb?.addEventListener('disconnect', async (event) => {
console.log('USB device disconnected:', event.device);
if (app?.device && event.device === app.device) {
app.disconnectDevice();
await app.loadAuthorizedDevices();
await app.tryAutoConnect();
}
});
navigator.usb?.addEventListener('connect', async (event) => {
console.log('USB device connected:', event.device);
if (app) {
await app.loadAuthorizedDevices();
await app.tryAutoConnect();
}
});