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
+
+
+
+
+
Previously Authorized Devices:
+
+
+
+
+
+
Step 2: Select Files to Transfer
+
+
+
+
+
+
+
+
+
+
+
+
Step 3: Transfer Files
+
+
+
+ Transfer Progress
+ 0 / 0
+
+
+
+
+
+
+
+ Time Spent:
+ 00:00
+
+
+ Time Remaining:
+ --:--
+
+
+ Data Transferred:
+ 0 MB
+
+
+ Speed:
+ 0 MB/s
+
+
+
+
+
+
+
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();
+ }
+});