From c2e8734e85eca1bac891d05fb483af1bf9ea55b9 Mon Sep 17 00:00:00 2001 From: ITotalJustice <47043333+ITotalJustice@users.noreply.github.com> Date: Sun, 31 Aug 2025 07:15:53 +0100 Subject: [PATCH] webusb: add webUSB page and workflow to auto build it. --- .github/workflows/webusb-build.yml | 55 ++ tools/webusb/assets/icon_16.png | Bin 0 -> 502 bytes tools/webusb/assets/icon_180.png | Bin 0 -> 20260 bytes tools/webusb/assets/icon_32.png | Bin 0 -> 1304 bytes tools/webusb/assets/icon_48.png | Bin 0 -> 2421 bytes tools/webusb/index.css | 537 +++++++++++++++++ tools/webusb/index.html | 106 ++++ tools/webusb/index.js | 889 +++++++++++++++++++++++++++++ 8 files changed, 1587 insertions(+) create mode 100644 .github/workflows/webusb-build.yml create mode 100644 tools/webusb/assets/icon_16.png create mode 100644 tools/webusb/assets/icon_180.png create mode 100644 tools/webusb/assets/icon_32.png create mode 100644 tools/webusb/assets/icon_48.png create mode 100644 tools/webusb/index.css create mode 100644 tools/webusb/index.html create mode 100644 tools/webusb/index.js 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 0000000000000000000000000000000000000000..1531c5ccd9b19fc7eefe4cc5fde080e19611c814 GIT binary patch literal 502 zcmVls7XXYR49?nk3DV_K@f#sb=Umu%&=oK{DZI%AV5g4a)KoS(58rx zoKtWOE&wrt42KpG5mx|-wbllMEU=B)o!Rc`DuUSyIHQuP_o<$q9@V2UPBP|1-~YEk zRdu-{=AK}Q04#3++@t$$mUME?<~TzumiiW7ag@ce_9KKToCr zs2+_~@#OJFb*czKEgR=To@a+gN2}HF?3vRaK7Lxea;eM*uikA^p8&$wo$pzaJla@) zv$+)%1i5o95RauvQHen3FaJVl_n60i97{NCc#y6qFO?FatFM*1E;aU~V;7 sbQT?HFc2n*pfM-`WiUBx2`<6KKX2~Jc;M_TN&o-=07*qoM6N<$f&+NiV*mgE literal 0 HcmV?d00001 diff --git a/tools/webusb/assets/icon_180.png b/tools/webusb/assets/icon_180.png new file mode 100644 index 0000000000000000000000000000000000000000..07d97af8c01fa9babd0af7097087cf2cb8b11f82 GIT binary patch literal 20260 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 literal 0 HcmV?d00001 diff --git a/tools/webusb/assets/icon_32.png b/tools/webusb/assets/icon_32.png new file mode 100644 index 0000000000000000000000000000000000000000..43e7123d20a8764b0ca57e9c9c0fa34f0beb8305 GIT binary patch literal 1304 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 literal 0 HcmV?d00001 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(); + } +});