commit 00874549edc7f8fa2793aa4c34417881f07889cf Author: github-actions[bot] Date: Tue Nov 18 18:49:50 2025 +0000 Deploy minified webusb build diff --git a/assets/icon_16.png b/assets/icon_16.png new file mode 100644 index 0000000..1531c5c Binary files /dev/null and b/assets/icon_16.png differ diff --git a/assets/icon_180.png b/assets/icon_180.png new file mode 100644 index 0000000..07d97af Binary files /dev/null and b/assets/icon_180.png differ diff --git a/assets/icon_32.png b/assets/icon_32.png new file mode 100644 index 0000000..43e7123 Binary files /dev/null and b/assets/icon_32.png differ diff --git a/assets/icon_48.png b/assets/icon_48.png new file mode 100644 index 0000000..eae524e Binary files /dev/null and b/assets/icon_48.png differ diff --git a/index.css b/index.css new file mode 100644 index 0000000..044f890 --- /dev/null +++ b/index.css @@ -0,0 +1 @@ +@keyframes spin{0%{transform:rotate(0deg)}to{transform:rotate(360deg)}}@keyframes pulse{0%,to{transform:scale(1)}50%{transform:scale(1.1)}}.download-spinner-text-wrap{display:flex;align-items:center;flex:1;min-width:0}#downloadSpinnerText{overflow-x:auto;white-space:nowrap;display:block;min-width:0;flex:1;scrollbar-width:none}#downloadSpinnerText::-webkit-scrollbar{display:none}#downloadSpinnerSpeed{flex-shrink:0;margin-left:12px;color:#32ffcf}.spinner{width:32px;height:32px;border:4px solid #163951;border-top:4px solid #32ffcf;border-radius:50%;animation:spin 1s linear infinite;margin-right:15px;display:inline-block;vertical-align:middle}.custom-select{background:linear-gradient(45deg,#32ffcf,#5cbeff);color:#111f28;border:0;padding:14px 26px;border-radius:8px;cursor:pointer;font-size:16px;font-weight:600;transition:transform .2s,box-shadow .2s;margin:5px;min-height:48px;appearance:none;-webkit-appearance:none;-moz-appearance:none;box-shadow:0 5px 15px rgba(50,255,207,.08)}.custom-select:focus{outline:0;box-shadow:0 0 0 2px #32ffcf}.custom-select option{background:#143144;color:#32ffcf;font-weight:600;font-size:16px}body{font-family:"Segoe UI",Tahoma,Geneva,Verdana,sans-serif;max-width:800px;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,.3);border:1px solid #335e77}@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,button{background:linear-gradient(45deg,#32ffcf,#5cbeff);color:#111f28;border-radius:8px;font-weight:600;transition:transform .2s,box-shadow .2s}.browser-link{display:inline-block;padding:12px 24px;text-decoration:none;margin-top:15px}.browser-link:hover,button:hover{transform:translateY(-2px);box-shadow:0 5px 15px rgba(50,255,207,.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{border:0;padding:14px 26px;cursor:pointer;font-size:16px;margin:5px;min-height:48px}@media (max-width:768px){button{width:100%;margin:8px 0;padding:16px}}button:disabled{background:#335e77;color:#bed0d6;cursor:not-allowed;transform:none}.device-info,.status{padding:10px;border-radius:5px;margin:10px 0;font-size:.95em}.status.success{background-color:rgba(105,255,143,.2);color:#69ff8f;border:1px solid #69ff8f}.status.error{background-color:rgba(250,58,93,.2);color:#fa3a5d;border:1px solid #fa3a5d}.status.info{background-color:rgba(92,190,255,.2);color:#5cbeff;border:1px solid #5cbeff}.device-info{background:#0b1519;padding:12px;border-radius:8px;border:1px solid #163951;color:#bed0d6;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:.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:.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:.9em}.btn-remove{background:#ff3232;color:#fff;border:0;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-id,.device-name{overflow:hidden;text-overflow:ellipsis}.device-name{flex:1;color:#bed0d6;min-width:0;margin-right:8px}.device-id{color:#5cbeff;font-size:.85em;margin-right:10px;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,.3);font-weight:600;font-size:14px;z-index:1000;transform:translateX(400px);transition:transform .3s ease-in-out,opacity .3s ease-in-out;opacity:0;display:flex;align-items:center;gap:10px;border:2px solid rgba(50,255,207,.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:#fff;box-shadow:0 10px 30px rgba(255,50,50,.3);border:2px solid rgba(255,50,50,.5)}.connection-toast.info{background:linear-gradient(45deg,#5cbeff,#32ffcf);color:#111f28;box-shadow:0 10px 30px rgba(92,190,255,.3);border:2px solid rgba(92,190,255,.5)}.connection-toast.success{background:linear-gradient(45deg,#69ff8f,#32ffcf);color:#111f28;box-shadow:0 10px 30px rgba(105,255,143,.3);border:2px solid rgba(105,255,143,.5)}.connection-toast.error{background:linear-gradient(45deg,#ff3232,#ff5c5c);color:#fff;box-shadow:0 10px 30px rgba(255,50,50,.3);border:2px solid rgba(255,50,50,.5)}.toast-icon{font-size:18px;animation:pulse 2s infinite}.toast-close{background:0 0;border:0;color:inherit;font-size:18px;cursor:pointer;padding:0;margin:0 0 0 10px;opacity:.7;transition:opacity .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 .3s ease}.time-info{display:grid;grid-template-columns:1fr 1fr;gap:8px;font-size:.85em;margin-top:8px}.time-info div{display:flex;justify-content:space-between}@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){.container{padding:12px}h1{font-size:1.6em}.section,body{padding:10px}} \ No newline at end of file diff --git a/index.html b/index.html new file mode 100644 index 0000000..ae28997 --- /dev/null +++ b/index.html @@ -0,0 +1 @@ +Sphaira WebUSB File Transfer

Sphaira WebUSB File Transfer

🔗 Device Connected

Step 1: Connect USB Device

Step 2: Select Mode

Step 3: Select Files to Transfer

File Queue 0 files

Step 4: Transfer Files

Logs

\ No newline at end of file diff --git a/index.js b/index.js new file mode 100644 index 0000000..9bb21de --- /dev/null +++ b/index.js @@ -0,0 +1 @@ +const MAGIC=1397770288,PACKET_SIZE=24,CMD_QUIT=0,CMD_OPEN=1,CMD_EXPORT=1,RESULT_OK=0,RESULT_ERROR=1,FLAG_NONE=0,FLAG_STREAM=1;class UsbPacket{constructor(e=MAGIC,t=0,s=0,n=0,i=0,o=0){this.magic=e,this.arg2=t,this.arg3=s,this.arg4=n,this.arg5=i,this.crc32c=o}toBuffer(){const e=new ArrayBuffer(24),t=new DataView(e);return t.setUint32(0,this.magic,!0),t.setUint32(4,this.arg2,!0),t.setUint32(8,this.arg3,!0),t.setUint32(12,this.arg4,!0),t.setUint32(16,this.arg5,!0),t.setUint32(20,this.crc32c,!0),e}static fromBuffer(e){const t=new DataView(e);return new this(t.getUint32(0,!0),t.getUint32(4,!0),t.getUint32(8,!0),t.getUint32(12,!0),t.getUint32(16,!0),t.getUint32(20,!0))}calculateCrc32c(){const e=this.toBuffer(),t=new Uint8Array(e,0,20);return crc32c(0,t)}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!0}}class SendPacket extends UsbPacket{static build(e,t=0,s=0){const n=new SendPacket(MAGIC,e,t,s);return n.generateCrc32c(),n}getCmd(){return this.arg2}}class ResultPacket extends UsbPacket{static build(e,t=0,s=0){const n=new ResultPacket(MAGIC,e,t,s);return n.generateCrc32c(),n}verify(){if(super.verify(),0!==this.arg2)throw new Error("Result not OK");return!0}}class SendDataPacket extends UsbPacket{static build(e,t,s){const n=Number(BigInt(e)>>32n&0xFFFFFFFFn),i=Number(0xFFFFFFFFn&BigInt(e)),o=new SendDataPacket(MAGIC,n,i,t,s);return o.generateCrc32c(),o}getOffset(){return Number(BigInt(this.arg2)<<32n|BigInt(this.arg3))}getSize(){return this.arg4}getCrc32c(){return this.arg5}}const crc32c=(()=>{const e=new Uint32Array(256);for(let t=0;t<256;t++){let s=t;for(let e=0;e<8;e++)s=1&s?s>>>1^2197175160:s>>>1;e[t]=s>>>0}return function(t,s){t^=4294967295;let n=0;const i=s.length;for(;n>>8,t=e[255&(t^s[n+1])]^t>>>8,t=e[255&(t^s[n+2])]^t>>>8,t=e[255&(t^s[n+3])]^t>>>8;for(;n>>8;return(4294967295^t)>>>0}})();class WebUSBFileTransfer{maybeStartDownloadLoop(){"download"===document.getElementById("modeSelect").value&&this.isConnected&&this.selectedDownloadDirHandle&&!this.downloadLoopActive&&this.startDownloadLoop()}async startDownloadLoop(){if(!this.downloadLoopActive){if(this.downloadLoopActive=!0,!this.selectedDownloadDirHandle)return this.showToast("No download folder selected.","error",4e3),void(this.downloadLoopActive=!1);if(!this.isConnected)return this.showToast("Device not connected.","error",4e3),void(this.downloadLoopActive=!1);this.log("Starting download command loop...");try{for(;;){const[e,t,s]=await this.get_send_header();if(0===e){await this.send_result(0),this.log("Received CMD_QUIT, exiting download loop.");break}if(1!==e){await this.send_result(1),this.log(`Unknown command (${e}), exiting.`);break}{await this.send_result(0);const e=new Uint8Array(await this.read(t).then(e=>e.data.buffer)),s=new TextDecoder("utf-8").decode(e);this.log(`Receiving file: ${s}`);const n=await this.createFileInDir(this.selectedDownloadDirHandle,s);console.log(`Created file handle for: ${s}`),await this.send_result(0),console.log("Acknowledged file creation, starting data transfer..."),await this.downloadFileData(n)}}}catch(e){this.log("Download loop error: "+e.message),this.showToast("Download failed: "+e.message,"error",5e3)}finally{this.downloadLoopActive=!1}}}sanitizePathSegment(e){return e&&"."!==e&&".."!==e?e.replace(/[\\/:*?"<>|]/g,"_"):null}async createFileInDir(e,t){this.log(`Creating file in directory: ${t}`);const s=t.split("/");let n=e;for(let e=0;e>>0;if(c!==u){this.log(`CRC32C mismatch at offset ${e}: want ${c}, got ${u}`),await this.send_result(1);continue}e===s?(await t.write(h),s+=h.length):await t.write({type:"write",position:e,data:h}),n=Math.max(n,e+h.length);const g=Date.now();if(g-o>200){const e=(g-i)/1e3,t=e>0?n/e:0;this.updateDownloadSpinner(r,t),o=g,a=n}await this.send_result(0)}this.updateDownloadSpinner(r,0,!0),await t.close(),this.hideDownloadSpinner(),this.log("File written successfully.")}showDownloadSpinner(e,t){const s=document.getElementById("downloadSpinner");s&&(this.updateDownloadSpinner(e,t),s.style.display="flex")}updateDownloadSpinner(e,t,s=!1){const n=document.getElementById("downloadSpinnerText"),i=document.getElementById("downloadSpinnerSpeed");n&&(s?(n.textContent=`Finishing write for "${e}"`,n.scrollLeft=0,i&&(i.textContent="")):(n.textContent=`Receiving "${e}"`,i&&(i.textContent=t>0?`${this.formatFileSize(t)}/s`:"")))}hideDownloadSpinner(){const e=document.getElementById("downloadSpinner");e&&(e.style.display="none")}async handleDirectoryPicker(){if(window.showDirectoryPicker)try{const e=await window.showDirectoryPicker();this.selectedDownloadDirHandle=e,document.getElementById("selectedFolderName").textContent=`Selected: ${e.name}`,this.log(`Selected download folder: ${e.name}`),this.maybeStartDownloadLoop()}catch(e){"AbortError"!==e.name&&this.showToast("Failed to select folder: "+e.message,"error",5e3)}else this.showToast("Your browser does not support the File System Access API.","error",5e3)}constructor(){this.device=null,this.isConnected=!1,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()}async checkWebUSBSupport(){navigator.usb?await this.loadAuthorizedDevices():this.showUnsupportedSplash()}async loadAuthorizedDevices(){try{const e=await navigator.usb.getDevices();this.authorizedDevices=e.filter(e=>1406===e.vendorId&&12288===e.productId),this.showAuthorizedDevices(),this.authorizedDevices.length>0?(this.log(`Found ${this.authorizedDevices.length} previously authorized device(s)`),await this.tryAutoConnect()):this.log("No previously authorized devices found")}catch(e){this.log(`Error loading authorized devices: ${e.message}`),this.authorizedDevices=[],this.showAuthorizedDevices()}}async tryAutoConnect(){if(0!==this.authorizedDevices.length)try{const e=this.authorizedDevices[0];this.log(`Attempting to auto-connect to: ${e.productName||"Unknown Device"}`),await this.connectToDevice(e)}catch(e){this.log(`Auto-connect failed: ${e.message}`),this.showToast("Auto-connect failed. Device may be unplugged.","info",4e3)}}formatTime(e){const t=Math.floor(e/60),s=Math.floor(e%60);return`${t.toString().padStart(2,"0")}:${s.toString().padStart(2,"0")}`}showAuthorizedDevices(){const e=document.getElementById("authorizedDevices");0!==this.authorizedDevices.length?(e.style.display="block",this.updateAuthorizedDevicesUI()):e.style.display="none"}updateAuthorizedDevicesUI(){const e=document.getElementById("deviceListContainer");let t="";this.authorizedDevices.forEach((e,s)=>{const n=e.productName||"Unknown Device",i=`${e.vendorId.toString(16).padStart(4,"0")}:${e.productId.toString(16).padStart(4,"0")}`,o=this.device&&this.device===e&&this.isConnected;t+=`\n
\n
${n} ${e.serialNumber}
\n
${i}
\n \n
\n `}),e.innerHTML=t;e.querySelectorAll("button[data-device-index]:not([disabled])").forEach(e=>{e.addEventListener("click",async e=>{const t=parseInt(e.target.getAttribute("data-device-index"));await this.connectToAuthorizedDevice(t)})})}async connectToAuthorizedDevice(e){if(e<0||e>=this.authorizedDevices.length)return void this.showStatus("Invalid device index","error");const t=this.authorizedDevices[e];this.log(`Connecting to authorized device: ${t.productName||"Unknown Device"}`);try{await this.connectToDevice(t)}catch(e){this.log(`Failed to connect to authorized device: ${e.message}`),this.showStatus(`Failed to connect: ${e.message}`,"error")}}showUnsupportedSplash(){document.querySelector(".container").innerHTML='\n
\n

WebUSB File Transfer

\n

âš ī¸ Browser Not Supported

\n

Your browser does not support WebUSB API.

\n

To use this application, please switch to a supported browser:

\n

â€ĸ Google Chrome (version 61+)
\n â€ĸ Microsoft Edge (version 79+)
\n â€ĸ Opera (version 48+)

\n

Firefox and Safari do not currently support WebUSB.

\n \n View Browser Compatibility Chart\n \n
\n '}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()),document.getElementById("modeSelect").addEventListener("change",e=>this.handleModeChange(e)),document.getElementById("pickFolderBtn").addEventListener("click",async()=>{await this.handleDirectoryPicker()})}handleModeChange(e){const t=e.target.value,s=document.getElementById("uploadStep3Section"),n=document.getElementById("uploadStep4Section"),i=document.getElementById("downloadStep3Section"),o=document.getElementById("downloadStep4Section");"upload"===t?(s.style.display="",n.style.display="",i.style.display="none",o.style.display="none"):(s.style.display="none",n.style.display="none",i.style.display="",o.style.display="")}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",2e3)}handleFileSelect(e){const t=Array.from(e.target.files),s=[".nsp",".xci",".nsz",".xcz"];if(t.length>0){let e=0;for(const n of t){const t=n.name.toLowerCase();s.some(e=>t.endsWith(e))?this.fileQueue.some(e=>e.name===n.name&&e.size===n.size)||(this.fileQueue.push(n),e++):this.log(`Skipping unsupported file type: ${n.name}`)}e>0?(this.updateFileQueueUI(),this.log(`Added ${e} file(s) to queue. Total: ${this.fileQueue.length}`),this.showToast(`Added ${e} file(s) to queue`,"success",2e3)):this.showToast("No supported files were added","info",2e3)}e.target.value=""}updateFileQueueUI(){const e=document.getElementById("fileQueueList");if(document.getElementById("fileCount").textContent=`${this.fileQueue.length} file${1!==this.fileQueue.length?"s":""}`,0===this.fileQueue.length)return e.innerHTML='
No files in queue. Click "Add Files" to select files.
',document.getElementById("clearQueueBtn").disabled=!0,void(document.getElementById("sendBtn").disabled=!0);document.getElementById("clearQueueBtn").disabled=!1,document.getElementById("sendBtn").disabled=!this.isConnected;let t="",s=0;for(let e=0;e\n
${n.name}
\n
${this.formatFileSize(n.size)}
\n
\n \n
\n \n `}t+=`\n
\n
Total
\n
${this.formatFileSize(s)}
\n
\n
\n `,e.innerHTML=t;e.querySelectorAll(".btn-remove").forEach(e=>{e.addEventListener("click",e=>{const t=parseInt(e.target.getAttribute("data-index"));this.removeFileFromQueue(t)})})}removeFileFromQueue(e){if(e>=0&&e"in"===e.direction&&"bulk"===e.type)?.endpointNumber,this.endpointOut=t.endpoints.find(e=>"out"===e.direction&&"bulk"===e.type)?.endpointNumber,void 0===this.endpointIn||void 0===this.endpointOut)throw new Error("Bulk IN/OUT endpoints not found");this.isConnected=!0,this.updateUI(),this.showToast("Device connected successfully!","success",3e3),this.showConnectionToast(`Connected: ${this.device.productName||"USB Device"}`,"connect"),this.maybeStartDownloadLoop()}async disconnectDevice(){try{if(this.device)try{this.isConnected&&await this.device.close()}catch(e){this.log(`Close skipped: ${e.message}`)}finally{this.device=null,this.isConnected=!1,this.updateUI(),this.log("Device state reset after disconnect"),this.showConnectionToast("Device Disconnected","disconnect")}}catch(e){this.log(`Disconnect error: ${e.message}`),this.showToast(`Disconnect error: ${e.message}`,"error",4e3)}}async sendFile(){const e=this.fileQueue;let t=new TextEncoder;if(!e.length||!this.isConnected)return void this.showToast("Please select files and ensure device is connected","error",4e3);let s=e.map(e=>e.name).join("\n")+"\n";const n=t.encode(s);this.completedCount=0,this.showTransferProgress(e.length);try{for(this.log("Waiting for Sphaira to begin transfer"),document.getElementById("sendBtn").disabled=!0,await this.get_send_header(),await this.send_result(0,n.length),await this.write(n);;)try{const[t,s,n]=await this.get_send_header();if(0==t){await this.send_result(0),e.length>0&&(this.log(`All ${e.length} files transferred successfully`),this.showToast(`✅ All ${e.length} files transferred successfully!`,"success",5e3),this.updateTransferProgress(e.length,e.length,0,null,100));break}if(1!=t){await this.send_result(1),this.log(`❌ Unknown command (${t}) from device`),this.showToast(`❌ Transfer stopped after ${this.completedCount} of ${e.length} files (unknown command)`,"error",5e3);break}{const t=e[s];if(!t){await this.send_result(1),this.showToast(`Device requested invalid file index: ${s}`,"error",5e3),this.log(`❌ Transfer stopped: invalid file index ${s} (out of ${e.length})`);break}const n=e.length,i=s+1;this.progressContext={current:i,total:n},this.log(`Opening file [${i}/${n}]: ${t.name} (${this.formatFileSize(t.size)})`),this.showToast(`📤 Transferring file ${i} of ${n}: ${t.name}`,"info",3e3),this.updateTransferProgress(this.completedCount,n,0,t,0),this.coverage.delete(t.name),await this.send_result(0),await this.file_transfer_loop(t),this.completedCount+=1,this.showToast(`✅ File ${i} of ${n} completed`,"success",2e3),this.updateTransferProgress(this.completedCount,n,0,null,100)}}catch(t){this.log(`❌ Loop error: ${t.message}`),this.showToast(`❌ Transfer stopped after ${this.completedCount} of ${e.length} files`,"error",5e3);break}}catch(e){this.log(`Transfer error: ${e.message}`),this.showToast(`Transfer failed: ${e.message}`,"error",5e3)}finally{document.getElementById("sendBtn").disabled=!1,setTimeout(()=>{this.hideTransferProgress()},3e3)}}async read(e){const t=await this.device.transferIn(this.endpointIn,e);if(t.status&&"ok"!==t.status)throw new Error(`USB transferIn failed: ${t.status}`);if(!t.data)throw new Error("transferIn returned no data");return t}async write(e){const t=await this.device.transferOut(this.endpointOut,e);if(t.status&&"ok"!==t.status)throw new Error(`USB transferOut failed: ${t.status}`);return t}async get_send_header(){const e=await this.read(24),t=e.data.buffer.slice(e.data.byteOffset,e.data.byteOffset+24),s=SendPacket.fromBuffer(t);return s.verify(),[s.getCmd(),s.arg3,s.arg4]}async get_send_data_header(){const e=await this.read(24),t=e.data.buffer.slice(e.data.byteOffset,e.data.byteOffset+24),s=SendDataPacket.fromBuffer(t);return s.verify(),[s.getOffset(),s.getSize(),s.getCrc32c()]}async send_result(e,t=0,s=0){const n=ResultPacket.build(e,t,s);await this.write(n.toBuffer())}async file_transfer_loop(e){this.disableFileControls(!0),this.transferStartTime=Date.now(),this.lastUpdateTime=null,this.lastBytesTransferred=0,this.currentSpeed=0,this.speedSamples=[],this.averageSpeed=0;try{for(;;){const[t,s,n]=await this.get_send_data_header();if(0===t&&0===s){await this.send_result(0),this.log("Transfer complete"),this.markCoverage(e,Math.max(0,e.size-1),1);break}const i=e.slice(t,t+s),o=new Uint8Array(await i.arrayBuffer()),a=crc32c(0,o)>>>0;await this.send_result(0,o.length,a),await this.write(o),this.markCoverage(e,t,s)}}catch(e){this.log(`File loop error: ${e.message}`),this.showToast(`File transfer aborted: ${e.message}`,"error",4e3)}finally{this.disableFileControls(!1)}}disableFileControls(e){document.getElementById("addFilesBtn").disabled=e||!this.isConnected,document.getElementById("clearQueueBtn").disabled=e||0===this.fileQueue.length,document.querySelectorAll(".btn-remove").forEach(t=>t.disabled=e)}markCoverage(e,t,s){const n=65536;let i=this.coverage.get(e.name);if(i||(i=new Set,this.coverage.set(e.name,i)),s>0){const e=Math.floor(t/n),o=Math.floor((t+s-1)/n);for(let t=e;t<=o;t++)i.add(t)}const o=Math.min(i.size*n,e.size),a=e.size>0?Math.min(100,Math.floor(o/e.size*100)):100;return this.progressContext.total>0&&this.updateTransferProgress(this.completedCount,this.progressContext.total,o,e,a),a}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||0===this.fileQueue.length,this.authorizedDevices.length>0&&this.updateAuthorizedDevicesUI()}showStatus(e,t){this.log(`[${t.toUpperCase()}] ${e}`)}log(e){const t=document.getElementById("logDiv"),s=(new Date).toLocaleTimeString();t.textContent+=`[${s}] ${e}\n`,t.scrollTop=t.scrollHeight}clearLog(){document.getElementById("logDiv").textContent="",this.log("Log cleared")}copyLog(){const e=document.getElementById("logDiv");navigator.clipboard.writeText(e.textContent).then(()=>{this.log("Log copied to clipboard")}).catch(e=>{this.log(`Failed to copy log: ${e}`)})}formatFileSize(e){if(0===e)return"0 Bytes";const t=["Bytes","KB","MB","GB"],s=Math.floor(Math.log(e)/Math.log(1024)),n=parseFloat((e/Math.pow(1024,s)).toFixed(2));return s>=2&&n<10?n.toFixed(1)+" "+t[s]:n+" "+t[s]}showConnectionToast(e,t="connect"){const s=document.getElementById("connectionToast"),n=document.getElementById("toastMessage"),i=s.querySelector(".toast-icon");this.toastTimeout&&clearTimeout(this.toastTimeout),n.textContent=e,"connect"===t?(i.textContent="🔗",s.className="connection-toast"):(i.textContent="🔌",s.className="connection-toast disconnect"),s.classList.add("show"),this.toastTimeout=setTimeout(()=>{this.hideConnectionToast()},4e3)}hideConnectionToast(){document.getElementById("connectionToast").classList.remove("show"),this.toastTimeout&&(clearTimeout(this.toastTimeout),this.toastTimeout=null)}showToast(e,t="info",s=4e3){const n=document.getElementById("connectionToast"),i=document.getElementById("toastMessage"),o=n.querySelector(".toast-icon");this.toastTimeout&&clearTimeout(this.toastTimeout),i.textContent=e;o.textContent={info:"â„šī¸",success:"✅",error:"❌",connect:"🔗",disconnect:"🔌"}[t]||"â„šī¸",n.className=`connection-toast ${t}`,n.classList.add("show"),this.toastTimeout=setTimeout(()=>{this.hideConnectionToast()},s)}showTransferProgress(e){document.getElementById("transferProgress").style.display="block",this.updateTransferProgress(0,e,0,null,0)}updateTransferProgress(e,t,s,n,i){this.updateProgressStats(s,n),this.updateProgressUI(e,t,s,n,i)}updateProgressStats(e,t){const s=Date.now();let n=0;if(t&&(n=t.size),this.lastUpdateTime){const t=(s-this.lastUpdateTime)/1e3;if(t>.1){const n=e-this.lastBytesTransferred;this.currentSpeed=n/t,this.speedSamples.push(this.currentSpeed),this.speedSamples.length>10&&this.speedSamples.shift(),this.averageSpeed=this.speedSamples.reduce((e,t)=>e+t,0)/this.speedSamples.length,this.lastUpdateTime=s,this.lastBytesTransferred=e}}else this.lastUpdateTime=s,this.lastBytesTransferred=e}updateProgressUI(e,t,s,n,i){document.getElementById("progressCounter").textContent=`${e} / ${t}`,this.updateProgressTitle(e,t,n),this.updateTimeAndSpeedInfo(s,n),this.updateProgressBar(i),document.getElementById("progressPercentage").textContent=`${Math.round(i)}%`}updateProgressTitle(e,t,s){const n=document.getElementById("progressTitle");if(s){const e=s.name.length>100?s.name.slice(0,97)+"...":s.name;n.textContent=`📄 ${e}`,document.getElementById("transferProgress").style.display="block"}else e===t&&t>0?(n.textContent="✅ All files completed!",setTimeout(()=>{document.getElementById("transferProgress").style.display="none"},3e3)):n.textContent="Waiting for next file..."}updateTimeAndSpeedInfo(e,t){const s=Date.now();let n=0;t&&(n=t.size);const i=(s-this.transferStartTime)/1e3;let o=0;if(this.averageSpeed>0){o=(n-e)/this.averageSpeed}document.getElementById("timeSpent").textContent=this.formatTime(i),document.getElementById("timeRemaining").textContent=o>0?this.formatTime(o):"--:--",document.getElementById("dataTransferred").textContent=this.formatFileSize(e),document.getElementById("currentSpeed").textContent=`${this.formatFileSize(this.averageSpeed)}/s`,document.getElementById("transferSpeed").textContent=`${this.formatFileSize(this.averageSpeed)}/s`}updateProgressBar(e){const t=document.getElementById("fileProgressBar");t&&(t.style.width=`${e}%`)}hideTransferProgress(){document.getElementById("transferProgress").style.display="none"}}let app;window.addEventListener("load",async()=>{app=new WebUSBFileTransfer}),navigator.usb?.addEventListener("disconnect",async e=>{console.log("USB device disconnected:",e.device),app?.device&&e.device===app.device&&(app.disconnectDevice(),await app.loadAuthorizedDevices(),await app.tryAutoConnect())}),navigator.usb?.addEventListener("connect",async e=>{console.log("USB device connected:",e.device),app&&(await app.loadAuthorizedDevices(),await app.tryAutoConnect())}); \ No newline at end of file