Files
sphaira/index.js
2025-11-18 18:49:50 +00:00

1 line
24 KiB
JavaScript
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<i-3;n+=4)t=e[255&(t^s[n])]^t>>>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<i;n++)t=e[255&(t^s[n])]^t>>>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<s.length-1;e++){this.log(`Creating/entering directory: ${s[e]}`);const t=this.sanitizePathSegment(s[e]);t?(console.log(`Processing directory segment: ${t}`),n=await n.getDirectoryHandle(t,{create:!0}),console.log(`Entered directory: ${t}`)):console.log(`Skipping invalid directory segment: ${s[e]}`)}console.log(`Finalizing file creation for: ${s[s.length-1]}`);const i=this.sanitizePathSegment(s[s.length-1]);if(!i)throw new Error("Invalid file name");return console.log(`Creating file: ${i}`),await n.getFileHandle(i,{create:!0})}async downloadFileData(e){this.log("Starting file data transfer...");const t=await e.createWritable();let s=0,n=0,i=Date.now(),o=i,a=0,r=e.name||"file";for(this.showDownloadSpinner(r,0);;){const[e,d,c]=await this.get_send_data_header();if(await this.send_result(0),0===e&&0===d)break;const l=await this.read(d),h=new Uint8Array(l.data.buffer,l.data.byteOffset,l.data.byteLength),u=crc32c(0,h)>>>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 <div class="device-list-item">\n <div class="device-name">${n} ${e.serialNumber}</div>\n <div class="device-id">${i}</div>\n <button class="btnf-add" data-device-index="${s}" ${o?"disabled":""}>\n ${o?"Connected":"Connect"}\n </button>\n </div>\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 <div class="unsupported-splash">\n <h1>WebUSB File Transfer</h1>\n <h2>⚠️ Browser Not Supported</h2>\n <p>Your browser does not support WebUSB API.</p>\n <p><strong>To use this application, please switch to a supported browser:</strong></p>\n <p>• Google Chrome (version 61+)<br>\n • Microsoft Edge (version 79+)<br>\n • Opera (version 48+)</p>\n <p>Firefox and Safari do not currently support WebUSB.</p>\n <a href="https://developer.mozilla.org/en-US/docs/Web/API/WebUSB_API#browser_compatibility"\n class="browser-link" target="_blank" rel="noopener noreferrer">\n View Browser Compatibility Chart\n </a>\n </div>\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='<div class="file-item" style="color: #bed0d6; font-style: italic;">No files in queue. Click "Add Files" to select files.</div>',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<this.fileQueue.length;e++){const n=this.fileQueue[e];s+=n.size,t+=`\n <div class="file-item">\n <div class="file-name" title="${n.name}">${n.name}</div>\n <div class="file-size">${this.formatFileSize(n.size)}</div>\n <div class="file-actions">\n <button class="btn-remove" data-index="${e}">Remove</button>\n </div>\n </div>\n `}t+=`\n <div class="file-item" style="border-top: 1px solid #163951; font-weight: 600;">\n <div class="file-name">Total</div>\n <div class="file-size">${this.formatFileSize(s)}</div>\n <div class="file-actions"></div>\n </div>\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<this.fileQueue.length){const t=this.fileQueue[e];this.fileQueue.splice(e,1),this.updateFileQueueUI(),this.log(`Removed "${t.name}" from queue`),this.showStatus(`Removed "${t.name}" from queue`,"info")}}async connectDevice(){try{this.log("Requesting USB device..."),this.device=await navigator.usb.requestDevice({filters:[{vendorId:1406,productId:12288}]}),await this.connectToDevice(this.device),await this.loadAuthorizedDevices()}catch(e){this.log(`Connection error: ${e.message}`),this.showToast(`Failed to connect: ${e.message}`,"error",5e3)}}async connectToDevice(e){this.device=e,this.log(`Selected device: ${this.device.productName||"Unknown"}`),await this.device.open(),null===this.device.configuration&&(await this.device.selectConfiguration(1),this.log("Configuration selected")),await this.device.claimInterface(0),this.log("Interface claimed");const t=this.device.configuration.interfaces[0].alternates[0];if(this.endpointIn=t.endpoints.find(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())});