diff --git a/.github/workflows/webusb-build.yml b/.github/workflows/webusb-build.yml new file mode 100644 index 0000000..8cd47dc --- /dev/null +++ b/.github/workflows/webusb-build.yml @@ -0,0 +1,55 @@ +name: Build and Deploy WebUSB Site + +on: + push: + paths: + - 'tools/webusb/**' + +jobs: + build-and-deploy: + runs-on: ubuntu-latest + steps: + - name: Checkout source + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Install minifiers + run: | + npm install -g html-minifier-terser terser csso-cli + + - name: Minify HTML + run: | + html-minifier-terser --collapse-whitespace --remove-comments --minify-css true --minify-js true -o tools/webusb/index.html.min tools/webusb/index.html + + - name: Minify JS + run: | + terser tools/webusb/index.js -c -m -o tools/webusb/index.js.min + + - name: Minify CSS + run: | + csso tools/webusb/index.css --output tools/webusb/index.css.min + + - name: Prepare deploy branch + run: | + rm -rf webusb + mkdir webusb + cp tools/webusb/index.html.min webusb/index.html + cp tools/webusb/index.js.min webusb/index.js + cp tools/webusb/index.css.min webusb/index.css + cp -r tools/webusb/assets webusb/assets + + - name: Commit and force-push to webusb branch + run: | + cd webusb + git init + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add . + git commit -m "Deploy minified webusb build" + git branch -M webusb + git remote add origin "https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/${{ github.repository }}.git" + git push --force origin webusb diff --git a/tools/webusb/assets/icon_16.png b/tools/webusb/assets/icon_16.png new file mode 100644 index 0000000..1531c5c Binary files /dev/null and b/tools/webusb/assets/icon_16.png differ diff --git a/tools/webusb/assets/icon_180.png b/tools/webusb/assets/icon_180.png new file mode 100644 index 0000000..07d97af Binary files /dev/null and b/tools/webusb/assets/icon_180.png differ diff --git a/tools/webusb/assets/icon_32.png b/tools/webusb/assets/icon_32.png new file mode 100644 index 0000000..43e7123 Binary files /dev/null and b/tools/webusb/assets/icon_32.png differ diff --git a/tools/webusb/assets/icon_48.png b/tools/webusb/assets/icon_48.png new file mode 100644 index 0000000..eae524e Binary files /dev/null and b/tools/webusb/assets/icon_48.png differ diff --git a/tools/webusb/index.css b/tools/webusb/index.css new file mode 100644 index 0000000..4f5d75b --- /dev/null +++ b/tools/webusb/index.css @@ -0,0 +1,537 @@ +body { + font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; + max-width: 800px; + margin: 0 auto; + padding: 20px; + background: linear-gradient(135deg, #111f28 0%, #0B1519 100%); + min-height: 100dvh; + min-height: -webkit-fill-available; + color: #fbfbfb; +} + +.container { + background: #143144; + border-radius: 15px; + padding: 20px; + box-shadow: 0 20px 40px rgba(0,0,0,0.3); + border: 1px solid #335e77; +} + +/* Improved responsive layout */ +@media (max-width: 768px) { + body { + padding: 12px; + } + + .container { + padding: 15px; + border-radius: 12px; + } +} + +.unsupported-splash { + text-align: center; + padding: 30px 15px; + background: #122430; + border-radius: 15px; + border: 2px solid #fa3a5d; +} + +.unsupported-splash h2 { + color: #fa3a5d; + font-size: 1.8em; + margin-bottom: 15px; +} + +.unsupported-splash p { + font-size: 1em; + line-height: 1.5; + margin-bottom: 12px; + color: #bed0d6; +} + +.browser-link { + display: inline-block; + background: linear-gradient(45deg, #32ffcf, #5cbeff); + color: #111f28; + padding: 12px 24px; + border-radius: 8px; + text-decoration: none; + font-weight: 600; + margin-top: 15px; + transition: transform 0.2s, box-shadow 0.2s; +} + +.browser-link:hover { + transform: translateY(-2px); + box-shadow: 0 5px 15px rgba(50, 255, 207, 0.4); +} + +h1 { + text-align: center; + margin-bottom: 25px; + background: linear-gradient(45deg, #32ffcf, #69ff8f); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + font-size: 2.2em; +} + +@media (max-width: 768px) { + h1 { + font-size: 1.8em; + margin-bottom: 20px; + } + + .unsupported-splash { + padding: 20px 12px; + } + + .unsupported-splash h2 { + font-size: 1.5em; + } +} + +.section { + margin-bottom: 20px; + padding: 15px; + border-radius: 10px; + background: #122430; + border-left: 4px solid #32ffcf; +} + +@media (max-width: 768px) { + .section { + padding: 12px; + margin-bottom: 15px; + } +} + +button { + 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; /* Better touch target for mobile */ +} + +@media (max-width: 768px) { + button { + width: 100%; + margin: 8px 0; + padding: 16px; + } +} + +button:hover { + transform: translateY(-2px); + box-shadow: 0 5px 15px rgba(50, 255, 207, 0.4); +} + +button:disabled { + background: #335e77; + color: #bed0d6; + cursor: not-allowed; + transform: none; +} + +.status { + padding: 10px; + border-radius: 5px; + margin: 10px 0; + font-size: 0.95em; +} + +.status.success { + background-color: rgba(105, 255, 143, 0.2); + color: #69ff8f; + border: 1px solid #69ff8f; +} + +.status.error { + background-color: rgba(250, 58, 93, 0.2); + color: #fa3a5d; + border: 1px solid #fa3a5d; +} + +.status.info { + background-color: rgba(92, 190, 255, 0.2); + color: #5cbeff; + border: 1px solid #5cbeff; +} + +.device-info { + background: #0B1519; + padding: 12px; + border-radius: 8px; + margin: 10px 0; + border: 1px solid #163951; + color: #bed0d6; + font-size: 0.95em; + overflow-wrap: break-word; +} + +.transfer-text { + color: #32ffcf; + font-weight: 600; +} + +.log { + background: #071013; + color: #bed0d6; + padding: 12px; + border-radius: 8px; + font-family: 'Courier New', monospace; + max-height: 200px; + overflow-y: auto; + margin-top: 12px; + border: 1px solid #163951; + white-space: pre-wrap; + font-size: 0.9em; +} + +h3 { + color: #32ffcf; + margin-top: 0; + font-size: 1.3em; +} + +@media (max-width: 768px) { + h3 { + font-size: 1.2em; + } +} + +.log-controls { + display: flex; + justify-content: flex-end; + margin-top: 10px; + flex-wrap: wrap; +} + +.log-btn { + background: #163951; + color: #bed0d6; + padding: 8px 12px; + font-size: 14px; + margin: 4px; + min-height: 36px; +} + +.file-queue { + margin-top: 15px; + background: #0B1519; + border-radius: 8px; + padding: 12px; + border: 1px solid #163951; +} + +.file-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 10px; + border-bottom: 1px solid #163951; + flex-wrap: wrap; +} + +.file-item:last-child { + border-bottom: none; +} + +.file-name { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; + margin-right: 8px; +} + +.file-size { + color: #bed0d6; + margin: 0 8px; + font-size: 0.9em; + white-space: nowrap; +} + +.file-actions { + display: flex; + gap: 8px; + flex-shrink: 0; +} + +.queue-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 10px; + flex-wrap: wrap; +} + +.queue-title { + font-weight: 600; + color: #32ffcf; + margin-right: 10px; +} + +.queue-count { + background: #163951; + color: #bed0d6; + padding: 4px 10px; + border-radius: 10px; + font-size: 0.9em; +} + +.btn-remove { + background: #ff3232; + color: white; + border: none; + border-radius: 4px; + padding: 6px 10px; + cursor: pointer; + font-size: 13px; + min-height: 32px; +} + +.file-controls { + display: flex; + gap: 10px; + margin-top: 10px; + flex-wrap: wrap; +} + +@media (max-width: 768px) { + .file-controls { + flex-direction: column; + } + + .file-controls button { + width: 100%; + } +} + +.btn-add { + background: linear-gradient(45deg, #69ff8f, #32ffcf); +} + +.btn-clear { + background: linear-gradient(45deg, #ff3232, #ff5c5c); +} + +.device-list { + background: #0B1519; + border-radius: 8px; + padding: 12px; + margin: 10px 0; + border: 1px solid #163951; +} + +.device-list-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 10px; + border-bottom: 1px solid #163951; + margin-bottom: 8px; + flex-wrap: wrap; +} + +.device-list-item:last-child { + border-bottom: none; + margin-bottom: 0; +} + +.device-name { + flex: 1; + color: #bed0d6; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + margin-right: 8px; +} + +.device-id { + color: #5cbeff; + font-size: 0.85em; + margin-right: 10px; + overflow: hidden; + text-overflow: ellipsis; + max-width: 40%; +} + +.hidden { + display: none; +} + +.connection-toast { + position: fixed; + top: 20px; + right: 20px; + background: linear-gradient(45deg, #32ffcf, #69ff8f); + color: #111f28; + padding: 15px 20px; + border-radius: 10px; + box-shadow: 0 10px 30px rgba(50, 255, 207, 0.3); + font-weight: 600; + font-size: 14px; + z-index: 1000; + transform: translateX(400px); + transition: transform 0.3s ease-in-out, opacity 0.3s ease-in-out; + opacity: 0; + display: flex; + align-items: center; + gap: 10px; + border: 2px solid rgba(50, 255, 207, 0.5); + max-width: 350px; +} + +@media (max-width: 768px) { + .connection-toast { + top: 10px; + right: 10px; + left: 10px; + max-width: none; + padding: 12px 16px; + } +} + +.connection-toast.show { + transform: translateX(0); + opacity: 1; +} + +.connection-toast.disconnect { + background: linear-gradient(45deg, #ff3232, #ff5c5c); + color: white; + box-shadow: 0 10px 30px rgba(255, 50, 50, 0.3); + border: 2px solid rgba(255, 50, 50, 0.5); +} + +.connection-toast.info { + background: linear-gradient(45deg, #5cbeff, #32ffcf); + color: #111f28; + box-shadow: 0 10px 30px rgba(92, 190, 255, 0.3); + border: 2px solid rgba(92, 190, 255, 0.5); +} + +.connection-toast.success { + background: linear-gradient(45deg, #69ff8f, #32ffcf); + color: #111f28; + box-shadow: 0 10px 30px rgba(105, 255, 143, 0.3); + border: 2px solid rgba(105, 255, 143, 0.5); +} + +.connection-toast.error { + background: linear-gradient(45deg, #ff3232, #ff5c5c); + color: white; + box-shadow: 0 10px 30px rgba(255, 50, 50, 0.3); + border: 2px solid rgba(255, 50, 50, 0.5); +} + +.toast-icon { + font-size: 18px; + animation: pulse 2s infinite; +} + +@keyframes pulse { + 0%, 100% { transform: scale(1); } + 50% { transform: scale(1.1); } +} + +.toast-close { + background: none; + border: none; + color: inherit; + font-size: 18px; + cursor: pointer; + padding: 0; + margin: 0; + margin-left: 10px; + opacity: 0.7; + transition: opacity 0.2s; + flex-shrink: 0; +} + +.toast-close:hover { + opacity: 1; +} + +.progress-bar-container { + margin: 10px 0; +} + +.progress-bar { + height: 8px; + background: #163951; + border-radius: 4px; + overflow: hidden; + margin-bottom: 4px; +} + +.progress-bar-fill { + height: 100%; + background: linear-gradient(90deg, #32ffcf, #5cbeff); + transition: width 0.3s ease; +} + +.time-info { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; + font-size: 0.85em; + margin-top: 8px; +} + +.time-info div { + display: flex; + justify-content: space-between; +} + +/* Orientation-specific adjustments */ +@media (max-width: 768px) and (orientation: portrait) { + .device-list-item { + flex-direction: column; + align-items: flex-start; + } + + .device-id { + margin: 5px 0; + max-width: 100%; + } + + .file-item { + flex-direction: column; + align-items: flex-start; + } + + .file-actions { + margin-top: 8px; + width: 100%; + justify-content: flex-end; + } +} + +@media (max-width: 768px) and (orientation: landscape) { + body { + padding: 10px; + } + + .container { + padding: 12px; + } + + h1 { + font-size: 1.6em; + } + + .section { + padding: 10px; + } +} diff --git a/tools/webusb/index.html b/tools/webusb/index.html new file mode 100644 index 0000000..d7b6c88 --- /dev/null +++ b/tools/webusb/index.html @@ -0,0 +1,106 @@ + + + + + + Sphaira WebUSB File Transfer + + + + + + + +
+

Sphaira WebUSB File Transfer

+ + +
+ 🔗 + Device Connected + +
+ +
+

Step 1: Connect USB Device

+ + + + +
+ +
+

Step 2: Select Files to Transfer

+ + +
+ + +
+ +
+
+ File Queue + 0 files +
+
+
+
+ +
+

Step 3: Transfer Files

+ + +
+ +
+

Logs

+
+
+ + +
+
+
+ + + + diff --git a/tools/webusb/index.js b/tools/webusb/index.js new file mode 100644 index 0000000..e07e212 --- /dev/null +++ b/tools/webusb/index.js @@ -0,0 +1,889 @@ +// --- 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(); + } +});