Files
sphaira/index.js
2025-08-31 06:20:25 +00:00

1 line
20 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,i=0,n=0,o=0){this.magic=e,this.arg2=t,this.arg3=s,this.arg4=i,this.arg5=n,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 i=new SendPacket(MAGIC,e,t,s);return i.generateCrc32c(),i}getCmd(){return this.arg2}}class ResultPacket extends UsbPacket{static build(e,t=0,s=0){const i=new ResultPacket(MAGIC,e,t,s);return i.generateCrc32c(),i}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 i=Number(BigInt(e)>>32n&0xFFFFFFFFn),n=Number(0xFFFFFFFFn&BigInt(e)),o=new SendDataPacket(MAGIC,i,n,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 i=0;const n=s.length;for(;i<n-3;i+=4)t=e[255&(t^s[i])]^t>>>8,t=e[255&(t^s[i+1])]^t>>>8,t=e[255&(t^s[i+2])]^t>>>8,t=e[255&(t^s[i+3])]^t>>>8;for(;i<n;i++)t=e[255&(t^s[i])]^t>>>8;return(4294967295^t)>>>0}})();class WebUSBFileTransfer{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 i=e.productName||"Unknown Device",n=`${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">${i} ${e.serialNumber}</div>\n <div class="device-id">${n}</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())}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 i of t){const t=i.name.toLowerCase();s.some(e=>t.endsWith(e))?this.fileQueue.some(e=>e.name===i.name&&e.size===i.size)||(this.fileQueue.push(i),e++):this.log(`Skipping unsupported file type: ${i.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 i=this.fileQueue[e];s+=i.size,t+=`\n <div class="file-item">\n <div class="file-name" title="${i.name}">${i.name}</div>\n <div class="file-size">${this.formatFileSize(i.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")}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 i=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,i.length),await this.write(i);;)try{const[t,s,i]=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 i=e.length,n=s+1;this.progressContext={current:n,total:i},this.log(`Opening file [${n}/${i}]: ${t.name} (${this.formatFileSize(t.size)})`),this.showToast(`📤 Transferring file ${n} of ${i}: ${t.name}`,"info",3e3),this.updateTransferProgress(this.completedCount,i,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 ${n} of ${i} completed`,"success",2e3),this.updateTransferProgress(this.completedCount,i,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 i=ResultPacket.build(e,t,s);await this.write(i.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,i]=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 n=e.slice(t,t+s),o=new Uint8Array(await n.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 i=65536;let n=this.coverage.get(e.name);if(n||(n=new Set,this.coverage.set(e.name,n)),s>0){const e=Math.floor(t/i),o=Math.floor((t+s-1)/i);for(let t=e;t<=o;t++)n.add(t)}const o=Math.min(n.size*i,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)),i=parseFloat((e/Math.pow(1024,s)).toFixed(2));return s>=2&&i<10?i.toFixed(1)+" "+t[s]:i+" "+t[s]}showConnectionToast(e,t="connect"){const s=document.getElementById("connectionToast"),i=document.getElementById("toastMessage"),n=s.querySelector(".toast-icon");this.toastTimeout&&clearTimeout(this.toastTimeout),i.textContent=e,"connect"===t?(n.textContent="🔗",s.className="connection-toast"):(n.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 i=document.getElementById("connectionToast"),n=document.getElementById("toastMessage"),o=i.querySelector(".toast-icon");this.toastTimeout&&clearTimeout(this.toastTimeout),n.textContent=e;o.textContent={info:"",success:"✅",error:"❌",connect:"🔗",disconnect:"🔌"}[t]||"",i.className=`connection-toast ${t}`,i.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,i,n){this.updateProgressStats(s,i),this.updateProgressUI(e,t,s,i,n)}updateProgressStats(e,t){const s=Date.now();let i=0;if(t&&(i=t.size),this.lastUpdateTime){const t=(s-this.lastUpdateTime)/1e3;if(t>.1){const i=e-this.lastBytesTransferred;this.currentSpeed=i/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,i,n){document.getElementById("progressCounter").textContent=`${e} / ${t}`,this.updateProgressTitle(e,t,i),this.updateTimeAndSpeedInfo(s,i),this.updateProgressBar(n),document.getElementById("progressPercentage").textContent=`${Math.round(n)}%`}updateProgressTitle(e,t,s){const i=document.getElementById("progressTitle");if(s){const e=s.name.length>100?s.name.slice(0,97)+"...":s.name;i.textContent=`📄 ${e}`,document.getElementById("transferProgress").style.display="block"}else e===t&&t>0?(i.textContent="✅ All files completed!",setTimeout(()=>{document.getElementById("transferProgress").style.display="none"},3e3)):i.textContent="Waiting for next file..."}updateTimeAndSpeedInfo(e,t){const s=Date.now();let i=0;t&&(i=t.size);const n=(s-this.transferStartTime)/1e3;let o=0;if(this.averageSpeed>0){o=(i-e)/this.averageSpeed}document.getElementById("timeSpent").textContent=this.formatTime(n),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())});