webusb: add support for exporting. usb: block requests with no timeout, using pbox to cancel if the user presses B.
This commit is contained in:
@@ -1,3 +1,74 @@
|
||||
.download-spinner-text-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
#downloadSpinnerText {
|
||||
overflow-x: auto;
|
||||
white-space: nowrap;
|
||||
display: block;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
scrollbar-width: none; /* Firefox */
|
||||
}
|
||||
#downloadSpinnerText::-webkit-scrollbar {
|
||||
display: none; /* Chrome, Safari */
|
||||
}
|
||||
|
||||
#downloadSpinnerSpeed {
|
||||
flex-shrink: 0;
|
||||
margin-left: 12px;
|
||||
color: #32ffcf;
|
||||
}
|
||||
/* Spinner styles */
|
||||
.spinner {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 4px solid #163951;
|
||||
border-top: 4px solid #32ffcf;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin-right: 15px;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
/* Custom styled select to match button look */
|
||||
.custom-select {
|
||||
background: linear-gradient(45deg, #32ffcf, #5cbeff);
|
||||
color: #111f28;
|
||||
border: none;
|
||||
padding: 14px 26px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
margin: 5px;
|
||||
min-height: 48px;
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
box-shadow: 0 5px 15px rgba(50, 255, 207, 0.08);
|
||||
}
|
||||
|
||||
.custom-select:focus {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px #32ffcf;
|
||||
}
|
||||
|
||||
.custom-select option {
|
||||
background: #143144;
|
||||
color: #32ffcf;
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
}
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
max-width: 800px;
|
||||
|
||||
@@ -32,8 +32,20 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Step 2: Mode Selection -->
|
||||
<div class="section">
|
||||
<h3>Step 2: Select Files to Transfer</h3>
|
||||
<h3>Step 2: Select Mode</h3>
|
||||
<label for="modeSelect" style="font-weight: 600; color: #bed0d6;">Choose transfer mode:</label>
|
||||
<select id="modeSelect" class="custom-select" style="margin-left: 10px;">
|
||||
<option value="upload">Upload (PC → Switch)</option>
|
||||
<option value="download">Download (Switch → PC)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Step 3: Upload (default) - File Selection -->
|
||||
<div class="section" id="uploadStep3Section">
|
||||
<h3>Step 3: Select Files to Transfer</h3>
|
||||
<input type="file" id="fileInput" accept=".nsp, .xci, .nsz, .xcz" multiple class="hidden">
|
||||
|
||||
<div class="file-controls">
|
||||
@@ -50,8 +62,16 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>Step 3: Transfer Files</h3>
|
||||
<!-- Step 3: Download - Folder Picker (hidden by default) -->
|
||||
<div class="section" id="downloadStep3Section" style="display: none;">
|
||||
<h3>Step 3: Select Download Destination</h3>
|
||||
<button id="pickFolderBtn" class="btn-add">Pick Folder</button>
|
||||
<span id="selectedFolderName" style="margin-left: 10px; color: #5cbeff;"></span>
|
||||
</div>
|
||||
|
||||
<!-- Step 4: Upload - Transfer Files -->
|
||||
<div class="section" id="uploadStep4Section">
|
||||
<h3>Step 4: Transfer Files</h3>
|
||||
<button id="sendBtn" disabled>Send Files</button>
|
||||
<div id="transferProgress" class="device-info" style="display: none;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;">
|
||||
@@ -91,6 +111,18 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 4: Download - Spinner and status -->
|
||||
<div class="section" id="downloadStep4Section" style="display: none;">
|
||||
<h3>Step 4: Download Files</h3>
|
||||
<div id="downloadSpinner" style="display:none;align-items:center;gap:10px;margin:18px 0 0 0;font-size:1.1em;">
|
||||
<span class="spinner"></span>
|
||||
<div id="downloadSpinnerTextWrap" style="display:flex;align-items:center;flex:1;min-width:0;">
|
||||
<span id="downloadSpinnerText" style="overflow-x:auto;white-space:nowrap;display:block;min-width:0;flex:1;"></span>
|
||||
<span id="downloadSpinnerSpeed" style="flex-shrink:0;margin-left:12px;color:#32ffcf;"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>Logs</h3>
|
||||
<div id="logDiv" class="log"></div>
|
||||
|
||||
@@ -25,17 +25,20 @@ class UsbPacket {
|
||||
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),
|
||||
@@ -70,6 +73,7 @@ class SendPacket extends UsbPacket {
|
||||
packet.generateCrc32c();
|
||||
return packet;
|
||||
}
|
||||
|
||||
getCmd() {
|
||||
return this.arg2;
|
||||
}
|
||||
@@ -81,6 +85,7 @@ class ResultPacket extends UsbPacket {
|
||||
packet.generateCrc32c();
|
||||
return packet;
|
||||
}
|
||||
|
||||
verify() {
|
||||
super.verify();
|
||||
if (this.arg2 !== RESULT_OK) throw new Error("Result not OK");
|
||||
@@ -96,12 +101,15 @@ class SendDataPacket extends UsbPacket {
|
||||
packet.generateCrc32c();
|
||||
return packet;
|
||||
}
|
||||
|
||||
getOffset() {
|
||||
return Number((BigInt(this.arg2) << 32n) | BigInt(this.arg3));
|
||||
}
|
||||
|
||||
getSize() {
|
||||
return this.arg4;
|
||||
}
|
||||
|
||||
getCrc32c() {
|
||||
return this.arg5;
|
||||
}
|
||||
@@ -111,6 +119,7 @@ class SendDataPacket extends UsbPacket {
|
||||
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++) {
|
||||
@@ -118,25 +127,229 @@ const crc32c = (() => {
|
||||
}
|
||||
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 {
|
||||
maybeStartDownloadLoop() {
|
||||
const mode = document.getElementById('modeSelect').value;
|
||||
if (mode === 'download' && this.isConnected && this.selectedDownloadDirHandle && !this.downloadLoopActive) {
|
||||
this.startDownloadLoop();
|
||||
}
|
||||
}
|
||||
|
||||
// --- Download Mode: Command Loop ---
|
||||
async startDownloadLoop() {
|
||||
if (this.downloadLoopActive) return;
|
||||
|
||||
this.downloadLoopActive = true;
|
||||
if (!this.selectedDownloadDirHandle) {
|
||||
this.showToast('No download folder selected.', 'error', 4000);
|
||||
this.downloadLoopActive = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.isConnected) {
|
||||
this.showToast('Device not connected.', 'error', 4000);
|
||||
this.downloadLoopActive = false;
|
||||
return;
|
||||
}
|
||||
|
||||
this.log('Starting download command loop...');
|
||||
try {
|
||||
while (true) {
|
||||
const [cmd, arg3, arg4] = await this.get_send_header();
|
||||
if (cmd === CMD_QUIT) {
|
||||
await this.send_result(RESULT_OK);
|
||||
this.log('Received CMD_QUIT, exiting download loop.');
|
||||
break;
|
||||
} else if (cmd === CMD_EXPORT) {
|
||||
await this.send_result(RESULT_OK);
|
||||
// Receive file name
|
||||
const fileNameBytes = new Uint8Array(await this.read(arg3).then(r => r.data.buffer));
|
||||
const fileName = new TextDecoder('utf-8').decode(fileNameBytes);
|
||||
this.log(`Receiving file: ${fileName}`);
|
||||
|
||||
// Create file in selected directory
|
||||
const fileHandle = await this.createFileInDir(this.selectedDownloadDirHandle, fileName);
|
||||
console.log(`Created file handle for: ${fileName}`);
|
||||
await this.send_result(RESULT_OK);
|
||||
console.log('Acknowledged file creation, starting data transfer...');
|
||||
await this.downloadFileData(fileHandle);
|
||||
} else {
|
||||
await this.send_result(RESULT_ERROR);
|
||||
this.log(`Unknown command (${cmd}), exiting.`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
this.log('Download loop error: ' + err.message);
|
||||
this.showToast('Download failed: ' + err.message, 'error', 5000);
|
||||
} finally {
|
||||
this.downloadLoopActive = false;
|
||||
}
|
||||
}
|
||||
|
||||
sanitizePathSegment(segment) {
|
||||
// Remove or replace invalid characters for directory/file names
|
||||
// Invalid: / ? < > \ : * | " . ..
|
||||
// We'll replace with _ and skip empty, ".", ".."
|
||||
if (!segment || segment === '.' || segment === '..') return null;
|
||||
return segment.replace(/[\\/:*?"<>|]/g, '_');
|
||||
}
|
||||
|
||||
async createFileInDir(dirHandle, filePath) {
|
||||
this.log(`Creating file in directory: ${filePath}`);
|
||||
|
||||
// filePath may include subfolders, so create them recursively
|
||||
const parts = filePath.split('/');
|
||||
let currentDir = dirHandle;
|
||||
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
this.log(`Creating/entering directory: ${parts[i]}`);
|
||||
const sanitized = this.sanitizePathSegment(parts[i]);
|
||||
if (!sanitized) {
|
||||
console.log(`Skipping invalid directory segment: ${parts[i]}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log(`Processing directory segment: ${sanitized}`);
|
||||
currentDir = await currentDir.getDirectoryHandle(sanitized, { create: true });
|
||||
console.log(`Entered directory: ${sanitized}`);
|
||||
}
|
||||
|
||||
console.log(`Finalizing file creation for: ${parts[parts.length - 1]}`);
|
||||
const fileName = this.sanitizePathSegment(parts[parts.length - 1]);
|
||||
if (!fileName) throw new Error('Invalid file name');
|
||||
|
||||
console.log(`Creating file: ${fileName}`);
|
||||
return await currentDir.getFileHandle(fileName, { create: true });
|
||||
}
|
||||
|
||||
async downloadFileData(fileHandle) {
|
||||
this.log('Starting file data transfer...');
|
||||
const writable = await fileHandle.createWritable();
|
||||
let expectedOffset = 0;
|
||||
let totalBytes = 0;
|
||||
let startTime = Date.now();
|
||||
let lastUpdate = startTime;
|
||||
let lastBytes = 0;
|
||||
let fileName = fileHandle.name || 'file';
|
||||
|
||||
// Show spinner and file info in step 4 UI
|
||||
this.showDownloadSpinner(fileName, 0);
|
||||
|
||||
while (true) {
|
||||
const [off, size, crc32cWant] = await this.get_send_data_header();
|
||||
await this.send_result(RESULT_OK); // acknowledge
|
||||
|
||||
if (off === 0 && size === 0) break;
|
||||
|
||||
const r = await this.read(size);
|
||||
const buf = new Uint8Array(r.data.buffer, r.data.byteOffset, r.data.byteLength);
|
||||
const crc32cGot = crc32c(0, buf) >>> 0;
|
||||
|
||||
if (crc32cWant !== crc32cGot) {
|
||||
this.log(`CRC32C mismatch at offset ${off}: want ${crc32cWant}, got ${crc32cGot}`);
|
||||
await this.send_result(RESULT_ERROR);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Hybrid: use fast streaming for sequential, random-access for others
|
||||
if (off === expectedOffset) {
|
||||
await writable.write(buf);
|
||||
expectedOffset += buf.length;
|
||||
} else {
|
||||
await writable.write({ type: 'write', position: off, data: buf });
|
||||
// expectedOffset does not change for random writes
|
||||
}
|
||||
|
||||
totalBytes = Math.max(totalBytes, off + buf.length);
|
||||
// Update spinner with speed
|
||||
const now = Date.now();
|
||||
if (now - lastUpdate > 200) {
|
||||
const elapsed = (now - startTime) / 1000;
|
||||
const speed = elapsed > 0 ? (totalBytes / elapsed) : 0;
|
||||
this.updateDownloadSpinner(fileName, speed);
|
||||
lastUpdate = now;
|
||||
lastBytes = totalBytes;
|
||||
}
|
||||
|
||||
await this.send_result(RESULT_OK);
|
||||
}
|
||||
|
||||
// Show spinner: finishing write
|
||||
this.updateDownloadSpinner(fileName, 0, true);
|
||||
await writable.close();
|
||||
this.hideDownloadSpinner();
|
||||
this.log('File written successfully.');
|
||||
}
|
||||
|
||||
// --- Download Spinner UI ---
|
||||
showDownloadSpinner(fileName, speed) {
|
||||
const spinner = document.getElementById('downloadSpinner');
|
||||
if (spinner) {
|
||||
this.updateDownloadSpinner(fileName, speed);
|
||||
spinner.style.display = 'flex';
|
||||
}
|
||||
}
|
||||
|
||||
updateDownloadSpinner(fileName, speed, finishing = false) {
|
||||
const text = document.getElementById('downloadSpinnerText');
|
||||
const speedEl = document.getElementById('downloadSpinnerSpeed');
|
||||
if (!text) return;
|
||||
if (finishing) {
|
||||
text.textContent = `Finishing write for "${fileName}"`;
|
||||
text.scrollLeft = 0;
|
||||
if (speedEl) speedEl.textContent = '';
|
||||
} else {
|
||||
text.textContent = `Receiving "${fileName}"`;
|
||||
if (speedEl) speedEl.textContent = speed > 0 ? `${this.formatFileSize(speed)}/s` : '';
|
||||
}
|
||||
}
|
||||
|
||||
hideDownloadSpinner() {
|
||||
const spinner = document.getElementById('downloadSpinner');
|
||||
if (spinner) spinner.style.display = 'none';
|
||||
}
|
||||
|
||||
// Optionally, trigger download loop after folder selection in download mode
|
||||
async handleDirectoryPicker() {
|
||||
if (!window.showDirectoryPicker) {
|
||||
this.showToast('Your browser does not support the File System Access API.', 'error', 5000);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const dirHandle = await window.showDirectoryPicker();
|
||||
this.selectedDownloadDirHandle = dirHandle;
|
||||
document.getElementById('selectedFolderName').textContent = `Selected: ${dirHandle.name}`;
|
||||
this.log(`Selected download folder: ${dirHandle.name}`);
|
||||
this.maybeStartDownloadLoop();
|
||||
} catch (err) {
|
||||
if (err.name !== 'AbortError') {
|
||||
this.showToast('Failed to select folder: ' + err.message, 'error', 5000);
|
||||
}
|
||||
}
|
||||
}
|
||||
constructor() {
|
||||
this.device = null;
|
||||
this.isConnected = false;
|
||||
@@ -212,6 +425,7 @@ class WebUSBFileTransfer {
|
||||
container.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
container.style.display = 'block';
|
||||
this.updateAuthorizedDevicesUI();
|
||||
}
|
||||
@@ -233,6 +447,7 @@ class WebUSBFileTransfer {
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
listContainer.innerHTML = html;
|
||||
// Add event listeners to connect buttons
|
||||
const connectButtons = listContainer.querySelectorAll('button[data-device-index]:not([disabled])');
|
||||
@@ -249,8 +464,10 @@ class WebUSBFileTransfer {
|
||||
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) {
|
||||
@@ -290,6 +507,34 @@ class WebUSBFileTransfer {
|
||||
document.getElementById('addFilesBtn').addEventListener('click', () => this.triggerFileInput());
|
||||
document.getElementById('clearQueueBtn').addEventListener('click', () => this.clearFileQueue());
|
||||
document.getElementById('toastClose').addEventListener('click', () => this.hideConnectionToast());
|
||||
|
||||
// Mode select dropdown
|
||||
document.getElementById('modeSelect').addEventListener('change', (e) => this.handleModeChange(e));
|
||||
|
||||
// Folder picker for download mode (File System Access API)
|
||||
document.getElementById('pickFolderBtn').addEventListener('click', async () => {
|
||||
await this.handleDirectoryPicker();
|
||||
});
|
||||
}
|
||||
|
||||
handleModeChange(e) {
|
||||
const mode = e.target.value;
|
||||
const uploadStep3 = document.getElementById('uploadStep3Section');
|
||||
const uploadStep4 = document.getElementById('uploadStep4Section');
|
||||
const downloadStep3 = document.getElementById('downloadStep3Section');
|
||||
const downloadStep4 = document.getElementById('downloadStep4Section');
|
||||
|
||||
if (mode === 'upload') {
|
||||
uploadStep3.style.display = '';
|
||||
uploadStep4.style.display = '';
|
||||
downloadStep3.style.display = 'none';
|
||||
downloadStep4.style.display = 'none';
|
||||
} else {
|
||||
uploadStep3.style.display = 'none';
|
||||
uploadStep4.style.display = 'none';
|
||||
downloadStep3.style.display = '';
|
||||
downloadStep4.style.display = '';
|
||||
}
|
||||
}
|
||||
|
||||
// --- File Queue Management ---
|
||||
@@ -308,6 +553,7 @@ class WebUSBFileTransfer {
|
||||
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) {
|
||||
@@ -316,11 +562,13 @@ class WebUSBFileTransfer {
|
||||
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}`);
|
||||
@@ -344,10 +592,12 @@ class WebUSBFileTransfer {
|
||||
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;
|
||||
@@ -361,6 +611,7 @@ class WebUSBFileTransfer {
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
html += `
|
||||
<div class="file-item" style="border-top: 1px solid #163951; font-weight: 600;">
|
||||
<div class="file-name">Total</div>
|
||||
@@ -397,6 +648,7 @@ class WebUSBFileTransfer {
|
||||
this.device = await navigator.usb.requestDevice({
|
||||
filters: [{ vendorId: 0x057e, productId: 0x3000 }]
|
||||
});
|
||||
|
||||
await this.connectToDevice(this.device);
|
||||
await this.loadAuthorizedDevices();
|
||||
} catch (error) {
|
||||
@@ -408,11 +660,13 @@ class WebUSBFileTransfer {
|
||||
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];
|
||||
@@ -421,10 +675,12 @@ class WebUSBFileTransfer {
|
||||
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');
|
||||
this.maybeStartDownloadLoop();
|
||||
}
|
||||
|
||||
async disconnectDevice() {
|
||||
@@ -493,6 +749,7 @@ class WebUSBFileTransfer {
|
||||
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};
|
||||
@@ -540,16 +797,20 @@ class WebUSBFileTransfer {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -657,6 +918,7 @@ class WebUSBFileTransfer {
|
||||
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();
|
||||
}
|
||||
@@ -666,17 +928,20 @@ class WebUSBFileTransfer {
|
||||
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)
|
||||
@@ -717,6 +982,7 @@ class WebUSBFileTransfer {
|
||||
toast.classList.add('show');
|
||||
this.toastTimeout = setTimeout(() => { this.hideConnectionToast(); }, 4000);
|
||||
}
|
||||
|
||||
hideConnectionToast() {
|
||||
const toast = document.getElementById('connectionToast');
|
||||
toast.classList.remove('show');
|
||||
@@ -725,6 +991,7 @@ class WebUSBFileTransfer {
|
||||
this.toastTimeout = null;
|
||||
}
|
||||
}
|
||||
|
||||
showToast(message, type = 'info', duration = 4000) {
|
||||
const toast = document.getElementById('connectionToast');
|
||||
const toastMessage = document.getElementById('toastMessage');
|
||||
@@ -880,6 +1147,7 @@ navigator.usb?.addEventListener('disconnect', async (event) => {
|
||||
await app.tryAutoConnect();
|
||||
}
|
||||
});
|
||||
|
||||
navigator.usb?.addEventListener('connect', async (event) => {
|
||||
console.log('USB device connected:', event.device);
|
||||
if (app) {
|
||||
|
||||
Reference in New Issue
Block a user