From fd67da0527349f8869a540fd16605699954fecb8 Mon Sep 17 00:00:00 2001 From: ITotalJustice <47043333+ITotalJustice@users.noreply.github.com> Date: Tue, 2 Sep 2025 04:24:45 +0100 Subject: [PATCH] webusb: add support for exporting. usb: block requests with no timeout, using pbox to cancel if the user presses B. --- sphaira/include/ui/progress_box.hpp | 5 + sphaira/include/usb/usb_dumper.hpp | 8 + sphaira/include/usb/usb_installer.hpp | 8 + sphaira/include/usb/usb_uploader.hpp | 8 + sphaira/include/yati/source/usb.hpp | 8 + sphaira/source/dumper.cpp | 37 +++- sphaira/source/ui/menus/usb_menu.cpp | 3 + sphaira/source/ui/progress_box.cpp | 55 ++++-- sphaira/source/usb/base.cpp | 2 +- sphaira/source/usb/usb_dumper.cpp | 2 +- sphaira/source/usb/usb_installer.cpp | 2 +- tools/webusb/index.css | 71 +++++++ tools/webusb/index.html | 38 +++- tools/webusb/index.js | 268 ++++++++++++++++++++++++++ 14 files changed, 482 insertions(+), 33 deletions(-) diff --git a/sphaira/include/ui/progress_box.hpp b/sphaira/include/ui/progress_box.hpp index 821aba5..4f04e30 100644 --- a/sphaira/include/ui/progress_box.hpp +++ b/sphaira/include/ui/progress_box.hpp @@ -11,6 +11,7 @@ namespace sphaira::ui { struct ProgressBox; using ProgressBoxCallback = std::function; using ProgressBoxDoneCallback = std::function; +// using CancelCallback = std::function; struct ProgressBox final : Widget { ProgressBox( @@ -39,6 +40,9 @@ struct ProgressBox final : Widget { auto ShouldExit() -> bool; auto ShouldExitResult() -> Result; + void AddCancelEvent(UEvent* event); + void RemoveCancelEvent(const UEvent* event); + // helper functions auto CopyFile(fs::Fs* fs_src, fs::Fs* fs_dst, const fs::FsPath& src, const fs::FsPath& dst, bool single_threaded = false) -> Result; auto CopyFile(fs::Fs* fs, const fs::FsPath& src, const fs::FsPath& dst, bool single_threaded = false) -> Result; @@ -82,6 +86,7 @@ private: Thread m_thread{}; ThreadData m_thread_data{}; ProgressBoxDoneCallback m_done{}; + std::vector m_cancel_events{}; // shared data start. std::string m_action{}; diff --git a/sphaira/include/usb/usb_dumper.hpp b/sphaira/include/usb/usb_dumper.hpp index 8ce02d2..af2077b 100644 --- a/sphaira/include/usb/usb_dumper.hpp +++ b/sphaira/include/usb/usb_dumper.hpp @@ -24,6 +24,14 @@ struct Usb { // Result OpenFile(u32 index, s64& file_size); Result CloseFile(); + auto GetOpenResult() const { + return m_open_result; + } + + auto GetCancelEvent() { + return m_usb->GetCancelEvent(); + } + private: Result SendAndVerify(const void* data, u32 size, u64 timeout, api::ResultPacket* out = nullptr); Result SendAndVerify(const void* data, u32 size, api::ResultPacket* out = nullptr); diff --git a/sphaira/include/usb/usb_installer.hpp b/sphaira/include/usb/usb_installer.hpp index 9419dac..b944a5d 100644 --- a/sphaira/include/usb/usb_installer.hpp +++ b/sphaira/include/usb/usb_installer.hpp @@ -25,6 +25,14 @@ struct Usb { Result OpenFile(u32 index, s64& file_size); Result CloseFile(); + auto GetOpenResult() const { + return m_open_result; + } + + auto GetCancelEvent() { + return m_usb->GetCancelEvent(); + } + private: Result SendAndVerify(const void* data, u32 size, u64 timeout, api::ResultPacket* out = nullptr); Result SendAndVerify(const void* data, u32 size, api::ResultPacket* out = nullptr); diff --git a/sphaira/include/usb/usb_uploader.hpp b/sphaira/include/usb/usb_uploader.hpp index 837ad65..bf78b22 100644 --- a/sphaira/include/usb/usb_uploader.hpp +++ b/sphaira/include/usb/usb_uploader.hpp @@ -29,6 +29,14 @@ struct Usb { Result file_transfer_loop(); + auto GetOpenResult() const { + return m_open_result; + } + + auto GetCancelEvent() { + return m_usb->GetCancelEvent(); + } + private: Result SendResult(u32 result, u32 arg3 = 0, u32 arg4 = 0); diff --git a/sphaira/include/yati/source/usb.hpp b/sphaira/include/yati/source/usb.hpp index 6ce1b95..e3fed46 100644 --- a/sphaira/include/yati/source/usb.hpp +++ b/sphaira/include/yati/source/usb.hpp @@ -43,6 +43,14 @@ struct Usb final : Base { return m_usb->CloseFile(); } + auto GetOpenResult() const { + return m_usb->GetOpenResult(); + } + + auto GetCancelEvent() { + return m_usb->GetCancelEvent(); + } + private: std::unique_ptr m_usb{}; }; diff --git a/sphaira/source/dumper.cpp b/sphaira/source/dumper.cpp index 13b7885..3d26a36 100644 --- a/sphaira/source/dumper.cpp +++ b/sphaira/source/dumper.cpp @@ -165,6 +165,14 @@ struct WriteUsbSource final : WriteSource { R_SUCCEED(); } + auto GetOpenResult() const { + return m_usb->GetOpenResult(); + } + + auto GetCancelEvent() { + return m_usb->GetCancelEvent(); + } + private: std::unique_ptr m_usb{}; bool m_was_mtp_enabled{}; @@ -178,8 +186,8 @@ constexpr DumpLocationEntry DUMP_LOCATIONS[]{ }; struct UsbTest final : usb::upload::Usb, yati::source::Stream { - UsbTest(ui::ProgressBox* pbox, BaseSource* source, std::span paths) - : Usb{UINT64_MAX} + UsbTest(ui::ProgressBox* pbox, BaseSource* source, std::span paths, u64 timeout) + : Usb{timeout} , m_pbox{pbox} , m_source{source} , m_paths{paths} { @@ -248,6 +256,10 @@ struct UsbTest final : usb::upload::Usb, yati::source::Stream { return m_pull_offset; } + auto GetOpenResult() const { + return Usb::GetOpenResult(); + } + private: ui::ProgressBox* m_pbox{}; BaseSource* m_source{}; @@ -261,7 +273,14 @@ private: }; Result DumpToUsb(ui::ProgressBox* pbox, BaseSource* source, std::span paths, const CustomTransfer& custom_transfer) { - auto write_source = std::make_unique(3e+9); + // create write source and verify that it opened. + constexpr u64 timeout = UINT64_MAX; + auto write_source = std::make_unique(timeout); + R_TRY(write_source->GetOpenResult()); + + // add cancel event. + pbox->AddCancelEvent(write_source->GetCancelEvent()); + ON_SCOPE_EXIT(pbox->RemoveCancelEvent(write_source->GetCancelEvent())); for (const auto& path : paths) { const auto file_size = source->GetSize(path); @@ -273,7 +292,7 @@ Result DumpToUsb(ui::ProgressBox* pbox, BaseSource* source, std::spanShouldExitResult()); - const auto rc = write_source->WaitForConnection(path, 3e+9); + const auto rc = write_source->WaitForConnection(path, timeout); if (R_SUCCEEDED(rc)) { break; } @@ -398,8 +417,14 @@ Result DumpToUsbS2S(ui::ProgressBox* pbox, BaseSource* source, std::span(pbox, source, paths); - constexpr u64 timeout = 3e+9; + // create usb test instance and verify that it opened. + constexpr u64 timeout = UINT64_MAX; + auto usb = std::make_unique(pbox, source, paths, timeout); + R_TRY(usb->GetOpenResult()); + + // add cancel event. + pbox->AddCancelEvent(usb->GetCancelEvent()); + ON_SCOPE_EXIT(pbox->RemoveCancelEvent(usb->GetCancelEvent())); while (!pbox->ShouldExit()) { if (R_SUCCEEDED(usb->IsUsbConnected(timeout))) { diff --git a/sphaira/source/ui/menus/usb_menu.cpp b/sphaira/source/ui/menus/usb_menu.cpp index 883135b..6ee9e54 100644 --- a/sphaira/source/ui/menus/usb_menu.cpp +++ b/sphaira/source/ui/menus/usb_menu.cpp @@ -97,6 +97,9 @@ void Menu::Update(Controller* controller, TouchInfo* touch) { m_state = State::Progress; log_write("got connection\n"); App::Push(0, "Installing "_i18n, "", [this](auto pbox) -> Result { + pbox->AddCancelEvent(m_usb_source->GetCancelEvent()); + ON_SCOPE_EXIT(pbox->RemoveCancelEvent(m_usb_source->GetCancelEvent())); + log_write("inside progress box\n"); for (u32 i = 0; i < std::size(m_names); i++) { const auto& file_name = m_names[i]; diff --git a/sphaira/source/ui/progress_box.cpp b/sphaira/source/ui/progress_box.cpp index 7ebf703..52b7226 100644 --- a/sphaira/source/ui/progress_box.cpp +++ b/sphaira/source/ui/progress_box.cpp @@ -232,79 +232,72 @@ auto ProgressBox::Draw(NVGcontext* vg, Theme* theme) -> void { } auto ProgressBox::SetActionName(const std::string& action) -> ProgressBox& { - mutexLock(&m_mutex); + SCOPED_MUTEX(&m_mutex); m_action = action; - mutexUnlock(&m_mutex); - Yield(); return *this; } auto ProgressBox::SetTitle(const std::string& title) -> ProgressBox& { - mutexLock(&m_mutex); + SCOPED_MUTEX(&m_mutex); m_title = title; - mutexUnlock(&m_mutex); - Yield(); return *this; } auto ProgressBox::NewTransfer(const std::string& transfer) -> ProgressBox& { - mutexLock(&m_mutex); + SCOPED_MUTEX(&m_mutex); m_transfer = transfer; m_size = 0; m_offset = 0; m_last_offset = 0; m_timestamp.Update(); - mutexUnlock(&m_mutex); - Yield(); return *this; } auto ProgressBox::ResetTranfser() -> ProgressBox& { - mutexLock(&m_mutex); + SCOPED_MUTEX(&m_mutex); m_size = 0; m_offset = 0; m_last_offset = 0; m_timestamp.Update(); - mutexUnlock(&m_mutex); - Yield(); return *this; } auto ProgressBox::UpdateTransfer(s64 offset, s64 size) -> ProgressBox& { - mutexLock(&m_mutex); + SCOPED_MUTEX(&m_mutex); m_size = size; m_offset = offset; - mutexUnlock(&m_mutex); - Yield(); return *this; } auto ProgressBox::SetImage(int image) -> ProgressBox& { - mutexLock(&m_mutex); + SCOPED_MUTEX(&m_mutex); m_image_pending = image; m_is_image_pending = true; - mutexUnlock(&m_mutex); return *this; } auto ProgressBox::SetImageData(std::vector& data) -> ProgressBox& { - mutexLock(&m_mutex); + SCOPED_MUTEX(&m_mutex); std::swap(m_image_data, data); - mutexUnlock(&m_mutex); return *this; } auto ProgressBox::SetImageDataConst(std::span data) -> ProgressBox& { - mutexLock(&m_mutex); + SCOPED_MUTEX(&m_mutex); m_image_data.resize(data.size()); std::memcpy(m_image_data.data(), data.data(), m_image_data.size()); - mutexUnlock(&m_mutex); return *this; } void ProgressBox::RequestExit() { + SCOPED_MUTEX(&m_mutex); m_stop_source.request_stop(); ueventSignal(GetCancelEvent()); + + // cancel any registered events. + for (auto& e : m_cancel_events) { + ueventSignal(e); + } } auto ProgressBox::ShouldExit() -> bool { @@ -318,6 +311,26 @@ auto ProgressBox::ShouldExitResult() -> Result { R_SUCCEED(); } +void ProgressBox::AddCancelEvent(UEvent* event) { + if (!event) { + return; + } + + SCOPED_MUTEX(&m_mutex); + if (std::ranges::find(m_cancel_events, event) == m_cancel_events.end()) { + m_cancel_events.emplace_back(event); + } +} + +void ProgressBox::RemoveCancelEvent(const UEvent* event) { + if (!event) { + return; + } + + SCOPED_MUTEX(&m_mutex); + m_cancel_events.erase(std::remove(m_cancel_events.begin(), m_cancel_events.end(), event), m_cancel_events.end()); +} + auto ProgressBox::CopyFile(fs::Fs* fs_src, fs::Fs* fs_dst, const fs::FsPath& src_path, const fs::FsPath& dst_path, bool single_threaded) -> Result { const auto is_file_based_emummc = App::IsFileBaseEmummc(); const auto is_both_native = fs_src->IsNative() && fs_dst->IsNative(); diff --git a/sphaira/source/usb/base.cpp b/sphaira/source/usb/base.cpp index 5f732ab..f8baa5c 100644 --- a/sphaira/source/usb/base.cpp +++ b/sphaira/source/usb/base.cpp @@ -36,7 +36,7 @@ Base::Base(u64 transfer_timeout) { App::SetAutoSleepDisabled(true); m_transfer_timeout = transfer_timeout; - ueventCreate(GetCancelEvent(), true); + ueventCreate(GetCancelEvent(), false); m_aligned = std::make_unique(new(std::align_val_t{TRANSFER_ALIGN}) u8[TRANSFER_MAX]); } diff --git a/sphaira/source/usb/usb_dumper.cpp b/sphaira/source/usb/usb_dumper.cpp index c1bc935..9b0f5bc 100644 --- a/sphaira/source/usb/usb_dumper.cpp +++ b/sphaira/source/usb/usb_dumper.cpp @@ -20,7 +20,7 @@ Usb::Usb(u64 transfer_timeout) { Usb::~Usb() { if (m_was_connected && R_SUCCEEDED(m_usb->IsUsbConnected(0))) { const auto send_header = SendPacket::Build(CMD_QUIT); - SendAndVerify(&send_header, sizeof(send_header)); + SendAndVerify(&send_header, sizeof(send_header), 1e+9); } } diff --git a/sphaira/source/usb/usb_installer.cpp b/sphaira/source/usb/usb_installer.cpp index fd20852..60477e6 100644 --- a/sphaira/source/usb/usb_installer.cpp +++ b/sphaira/source/usb/usb_installer.cpp @@ -20,7 +20,7 @@ Usb::Usb(u64 transfer_timeout) { Usb::~Usb() { if (m_was_connected && R_SUCCEEDED(m_usb->IsUsbConnected(0))) { const auto send_header = SendPacket::Build(CMD_QUIT); - SendAndVerify(&send_header, sizeof(send_header)); + SendAndVerify(&send_header, sizeof(send_header), 1e+9); } } diff --git a/tools/webusb/index.css b/tools/webusb/index.css index 4f5d75b..bb8d7ca 100644 --- a/tools/webusb/index.css +++ b/tools/webusb/index.css @@ -1,3 +1,74 @@ +.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; /* Firefox */ +} +#downloadSpinnerText::-webkit-scrollbar { + display: none; /* Chrome, Safari */ +} + +#downloadSpinnerSpeed { + flex-shrink: 0; + margin-left: 12px; + color: #32ffcf; +} +/* Spinner styles */ +.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; +} + +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} +/* Custom styled select to match button look */ +.custom-select { + 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; + appearance: none; + -webkit-appearance: none; + -moz-appearance: none; + box-shadow: 0 5px 15px rgba(50, 255, 207, 0.08); +} + +.custom-select:focus { + outline: none; + 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; diff --git a/tools/webusb/index.html b/tools/webusb/index.html index d7b6c88..7fecb9a 100644 --- a/tools/webusb/index.html +++ b/tools/webusb/index.html @@ -32,8 +32,20 @@ + +
-

Step 2: Select Files to Transfer

+

Step 2: Select Mode

+ + +
+ + +
+

Step 3: Select Files to Transfer

@@ -50,8 +62,16 @@
-
-

Step 3: Transfer Files

+ + + + +
+

Step 4: Transfer Files

+ + +

Logs

diff --git a/tools/webusb/index.js b/tools/webusb/index.js index e07e212..9fd6760 100644 --- a/tools/webusb/index.js +++ b/tools/webusb/index.js @@ -25,17 +25,20 @@ class UsbPacket { 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), @@ -70,6 +73,7 @@ class SendPacket extends UsbPacket { packet.generateCrc32c(); return packet; } + getCmd() { return this.arg2; } @@ -81,6 +85,7 @@ class ResultPacket extends UsbPacket { packet.generateCrc32c(); return packet; } + verify() { super.verify(); if (this.arg2 !== RESULT_OK) throw new Error("Result not OK"); @@ -96,12 +101,15 @@ class SendDataPacket extends UsbPacket { packet.generateCrc32c(); return packet; } + getOffset() { return Number((BigInt(this.arg2) << 32n) | BigInt(this.arg3)); } + getSize() { return this.arg4; } + getCrc32c() { return this.arg5; } @@ -111,6 +119,7 @@ class SendDataPacket extends UsbPacket { 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++) { @@ -118,25 +127,229 @@ const crc32c = (() => { } 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 { + maybeStartDownloadLoop() { + const mode = document.getElementById('modeSelect').value; + if (mode === 'download' && this.isConnected && this.selectedDownloadDirHandle && !this.downloadLoopActive) { + this.startDownloadLoop(); + } + } + + // --- Download Mode: Command Loop --- + async startDownloadLoop() { + if (this.downloadLoopActive) return; + + this.downloadLoopActive = true; + if (!this.selectedDownloadDirHandle) { + this.showToast('No download folder selected.', 'error', 4000); + this.downloadLoopActive = false; + return; + } + + if (!this.isConnected) { + this.showToast('Device not connected.', 'error', 4000); + this.downloadLoopActive = false; + return; + } + + this.log('Starting download command loop...'); + try { + while (true) { + const [cmd, arg3, arg4] = await this.get_send_header(); + if (cmd === CMD_QUIT) { + await this.send_result(RESULT_OK); + this.log('Received CMD_QUIT, exiting download loop.'); + break; + } else if (cmd === CMD_EXPORT) { + await this.send_result(RESULT_OK); + // Receive file name + const fileNameBytes = new Uint8Array(await this.read(arg3).then(r => r.data.buffer)); + const fileName = new TextDecoder('utf-8').decode(fileNameBytes); + this.log(`Receiving file: ${fileName}`); + + // Create file in selected directory + const fileHandle = await this.createFileInDir(this.selectedDownloadDirHandle, fileName); + console.log(`Created file handle for: ${fileName}`); + await this.send_result(RESULT_OK); + console.log('Acknowledged file creation, starting data transfer...'); + await this.downloadFileData(fileHandle); + } else { + await this.send_result(RESULT_ERROR); + this.log(`Unknown command (${cmd}), exiting.`); + break; + } + } + } catch (err) { + this.log('Download loop error: ' + err.message); + this.showToast('Download failed: ' + err.message, 'error', 5000); + } finally { + this.downloadLoopActive = false; + } + } + + sanitizePathSegment(segment) { + // Remove or replace invalid characters for directory/file names + // Invalid: / ? < > \ : * | " . .. + // We'll replace with _ and skip empty, ".", ".." + if (!segment || segment === '.' || segment === '..') return null; + return segment.replace(/[\\/:*?"<>|]/g, '_'); + } + + async createFileInDir(dirHandle, filePath) { + this.log(`Creating file in directory: ${filePath}`); + + // filePath may include subfolders, so create them recursively + const parts = filePath.split('/'); + let currentDir = dirHandle; + + for (let i = 0; i < parts.length - 1; i++) { + this.log(`Creating/entering directory: ${parts[i]}`); + const sanitized = this.sanitizePathSegment(parts[i]); + if (!sanitized) { + console.log(`Skipping invalid directory segment: ${parts[i]}`); + continue; + } + + console.log(`Processing directory segment: ${sanitized}`); + currentDir = await currentDir.getDirectoryHandle(sanitized, { create: true }); + console.log(`Entered directory: ${sanitized}`); + } + + console.log(`Finalizing file creation for: ${parts[parts.length - 1]}`); + const fileName = this.sanitizePathSegment(parts[parts.length - 1]); + if (!fileName) throw new Error('Invalid file name'); + + console.log(`Creating file: ${fileName}`); + return await currentDir.getFileHandle(fileName, { create: true }); + } + + async downloadFileData(fileHandle) { + this.log('Starting file data transfer...'); + const writable = await fileHandle.createWritable(); + let expectedOffset = 0; + let totalBytes = 0; + let startTime = Date.now(); + let lastUpdate = startTime; + let lastBytes = 0; + let fileName = fileHandle.name || 'file'; + + // Show spinner and file info in step 4 UI + this.showDownloadSpinner(fileName, 0); + + while (true) { + const [off, size, crc32cWant] = await this.get_send_data_header(); + await this.send_result(RESULT_OK); // acknowledge + + if (off === 0 && size === 0) break; + + const r = await this.read(size); + const buf = new Uint8Array(r.data.buffer, r.data.byteOffset, r.data.byteLength); + const crc32cGot = crc32c(0, buf) >>> 0; + + if (crc32cWant !== crc32cGot) { + this.log(`CRC32C mismatch at offset ${off}: want ${crc32cWant}, got ${crc32cGot}`); + await this.send_result(RESULT_ERROR); + continue; + } + + // Hybrid: use fast streaming for sequential, random-access for others + if (off === expectedOffset) { + await writable.write(buf); + expectedOffset += buf.length; + } else { + await writable.write({ type: 'write', position: off, data: buf }); + // expectedOffset does not change for random writes + } + + totalBytes = Math.max(totalBytes, off + buf.length); + // Update spinner with speed + const now = Date.now(); + if (now - lastUpdate > 200) { + const elapsed = (now - startTime) / 1000; + const speed = elapsed > 0 ? (totalBytes / elapsed) : 0; + this.updateDownloadSpinner(fileName, speed); + lastUpdate = now; + lastBytes = totalBytes; + } + + await this.send_result(RESULT_OK); + } + + // Show spinner: finishing write + this.updateDownloadSpinner(fileName, 0, true); + await writable.close(); + this.hideDownloadSpinner(); + this.log('File written successfully.'); + } + + // --- Download Spinner UI --- + showDownloadSpinner(fileName, speed) { + const spinner = document.getElementById('downloadSpinner'); + if (spinner) { + this.updateDownloadSpinner(fileName, speed); + spinner.style.display = 'flex'; + } + } + + updateDownloadSpinner(fileName, speed, finishing = false) { + const text = document.getElementById('downloadSpinnerText'); + const speedEl = document.getElementById('downloadSpinnerSpeed'); + if (!text) return; + if (finishing) { + text.textContent = `Finishing write for "${fileName}"`; + text.scrollLeft = 0; + if (speedEl) speedEl.textContent = ''; + } else { + text.textContent = `Receiving "${fileName}"`; + if (speedEl) speedEl.textContent = speed > 0 ? `${this.formatFileSize(speed)}/s` : ''; + } + } + + hideDownloadSpinner() { + const spinner = document.getElementById('downloadSpinner'); + if (spinner) spinner.style.display = 'none'; + } + + // Optionally, trigger download loop after folder selection in download mode + async handleDirectoryPicker() { + if (!window.showDirectoryPicker) { + this.showToast('Your browser does not support the File System Access API.', 'error', 5000); + return; + } + try { + const dirHandle = await window.showDirectoryPicker(); + this.selectedDownloadDirHandle = dirHandle; + document.getElementById('selectedFolderName').textContent = `Selected: ${dirHandle.name}`; + this.log(`Selected download folder: ${dirHandle.name}`); + this.maybeStartDownloadLoop(); + } catch (err) { + if (err.name !== 'AbortError') { + this.showToast('Failed to select folder: ' + err.message, 'error', 5000); + } + } + } constructor() { this.device = null; this.isConnected = false; @@ -212,6 +425,7 @@ class WebUSBFileTransfer { container.style.display = 'none'; return; } + container.style.display = 'block'; this.updateAuthorizedDevicesUI(); } @@ -233,6 +447,7 @@ class WebUSBFileTransfer {
`; }); + listContainer.innerHTML = html; // Add event listeners to connect buttons const connectButtons = listContainer.querySelectorAll('button[data-device-index]:not([disabled])'); @@ -249,8 +464,10 @@ class WebUSBFileTransfer { 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) { @@ -290,6 +507,34 @@ class WebUSBFileTransfer { document.getElementById('addFilesBtn').addEventListener('click', () => this.triggerFileInput()); document.getElementById('clearQueueBtn').addEventListener('click', () => this.clearFileQueue()); document.getElementById('toastClose').addEventListener('click', () => this.hideConnectionToast()); + + // Mode select dropdown + document.getElementById('modeSelect').addEventListener('change', (e) => this.handleModeChange(e)); + + // Folder picker for download mode (File System Access API) + document.getElementById('pickFolderBtn').addEventListener('click', async () => { + await this.handleDirectoryPicker(); + }); + } + + handleModeChange(e) { + const mode = e.target.value; + const uploadStep3 = document.getElementById('uploadStep3Section'); + const uploadStep4 = document.getElementById('uploadStep4Section'); + const downloadStep3 = document.getElementById('downloadStep3Section'); + const downloadStep4 = document.getElementById('downloadStep4Section'); + + if (mode === 'upload') { + uploadStep3.style.display = ''; + uploadStep4.style.display = ''; + downloadStep3.style.display = 'none'; + downloadStep4.style.display = 'none'; + } else { + uploadStep3.style.display = 'none'; + uploadStep4.style.display = 'none'; + downloadStep3.style.display = ''; + downloadStep4.style.display = ''; + } } // --- File Queue Management --- @@ -308,6 +553,7 @@ class WebUSBFileTransfer { 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) { @@ -316,11 +562,13 @@ class WebUSBFileTransfer { 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}`); @@ -344,10 +592,12 @@ class WebUSBFileTransfer { 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; @@ -361,6 +611,7 @@ class WebUSBFileTransfer {
`; } + html += `
Total
@@ -397,6 +648,7 @@ class WebUSBFileTransfer { this.device = await navigator.usb.requestDevice({ filters: [{ vendorId: 0x057e, productId: 0x3000 }] }); + await this.connectToDevice(this.device); await this.loadAuthorizedDevices(); } catch (error) { @@ -408,11 +660,13 @@ class WebUSBFileTransfer { 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]; @@ -421,10 +675,12 @@ class WebUSBFileTransfer { 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'); + this.maybeStartDownloadLoop(); } async disconnectDevice() { @@ -493,6 +749,7 @@ class WebUSBFileTransfer { 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}; @@ -540,16 +797,20 @@ class WebUSBFileTransfer { 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; } @@ -657,6 +918,7 @@ class WebUSBFileTransfer { 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(); } @@ -666,17 +928,20 @@ class WebUSBFileTransfer { 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) @@ -717,6 +982,7 @@ class WebUSBFileTransfer { toast.classList.add('show'); this.toastTimeout = setTimeout(() => { this.hideConnectionToast(); }, 4000); } + hideConnectionToast() { const toast = document.getElementById('connectionToast'); toast.classList.remove('show'); @@ -725,6 +991,7 @@ class WebUSBFileTransfer { this.toastTimeout = null; } } + showToast(message, type = 'info', duration = 4000) { const toast = document.getElementById('connectionToast'); const toastMessage = document.getElementById('toastMessage'); @@ -880,6 +1147,7 @@ navigator.usb?.addEventListener('disconnect', async (event) => { await app.tryAutoConnect(); } }); + navigator.usb?.addEventListener('connect', async (event) => { console.log('USB device connected:', event.device); if (app) {