mirror of
https://github.com/ITotalJustice/sphaira.git
synced 2025-11-03 01:16:08 +01:00
890 lines
34 KiB
JavaScript
890 lines
34 KiB
JavaScript
// --- 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();
|
||
}
|
||
});
|