// --- 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 += `
${deviceName} ${device.serialNumber}
${deviceId}
`;
});
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 = `
WebUSB File Transfer
⚠️ Browser Not Supported
Your browser does not support WebUSB API.
To use this application, please switch to a supported browser:
• Google Chrome (version 61+)
• Microsoft Edge (version 79+)
• Opera (version 48+)
Firefox and Safari do not currently support WebUSB.
View Browser Compatibility Chart
`;
}
// --- 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 = 'No files in queue. Click "Add Files" to select files.
';
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 += `
${file.name}
${this.formatFileSize(file.size)}
`;
}
html += `
Total
${this.formatFileSize(totalSize)}
`;
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();
}
});