webusb: add support for exporting. usb: block requests with no timeout, using pbox to cancel if the user presses B.

This commit is contained in:
ITotalJustice
2025-09-02 04:24:45 +01:00
parent 7bdec8457f
commit fd67da0527
14 changed files with 482 additions and 33 deletions

View File

@@ -11,6 +11,7 @@ namespace sphaira::ui {
struct ProgressBox;
using ProgressBoxCallback = std::function<Result(ProgressBox*)>;
using ProgressBoxDoneCallback = std::function<void(Result rc)>;
// using CancelCallback = std::function<void()>;
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<UEvent*> m_cancel_events{};
// shared data start.
std::string m_action{};

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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<usb::install::Usb> m_usb{};
};

View File

@@ -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<usb::dump::Usb> 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<const fs::FsPath> paths)
: Usb{UINT64_MAX}
UsbTest(ui::ProgressBox* pbox, BaseSource* source, std::span<const fs::FsPath> 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<const fs::FsPath> paths, const CustomTransfer& custom_transfer) {
auto write_source = std::make_unique<WriteUsbSource>(3e+9);
// create write source and verify that it opened.
constexpr u64 timeout = UINT64_MAX;
auto write_source = std::make_unique<WriteUsbSource>(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::span<const fs::
while (true) {
R_TRY(pbox->ShouldExitResult());
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<const f
file_list.emplace_back(path);
}
auto usb = std::make_unique<UsbTest>(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<UsbTest>(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))) {

View File

@@ -97,6 +97,9 @@ void Menu::Update(Controller* controller, TouchInfo* touch) {
m_state = State::Progress;
log_write("got connection\n");
App::Push<ui::ProgressBox>(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];

View File

@@ -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<u8>& 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<const u8> 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();

View File

@@ -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<u8*>(new(std::align_val_t{TRANSFER_ALIGN}) u8[TRANSFER_MAX]);
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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;

View File

@@ -32,8 +32,20 @@
</div>
</div>
<!-- Step 2: Mode Selection -->
<div class="section">
<h3>Step 2: Select Files to Transfer</h3>
<h3>Step 2: Select Mode</h3>
<label for="modeSelect" style="font-weight: 600; color: #bed0d6;">Choose transfer mode:</label>
<select id="modeSelect" class="custom-select" style="margin-left: 10px;">
<option value="upload">Upload (PC → Switch)</option>
<option value="download">Download (Switch → PC)</option>
</select>
</div>
<!-- Step 3: Upload (default) - File Selection -->
<div class="section" id="uploadStep3Section">
<h3>Step 3: Select Files to Transfer</h3>
<input type="file" id="fileInput" accept=".nsp, .xci, .nsz, .xcz" multiple class="hidden">
<div class="file-controls">
@@ -50,8 +62,16 @@
</div>
</div>
<div class="section">
<h3>Step 3: Transfer Files</h3>
<!-- Step 3: Download - Folder Picker (hidden by default) -->
<div class="section" id="downloadStep3Section" style="display: none;">
<h3>Step 3: Select Download Destination</h3>
<button id="pickFolderBtn" class="btn-add">Pick Folder</button>
<span id="selectedFolderName" style="margin-left: 10px; color: #5cbeff;"></span>
</div>
<!-- Step 4: Upload - Transfer Files -->
<div class="section" id="uploadStep4Section">
<h3>Step 4: Transfer Files</h3>
<button id="sendBtn" disabled>Send Files</button>
<div id="transferProgress" class="device-info" style="display: none;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;">
@@ -91,6 +111,18 @@
</div>
</div>
<!-- Step 4: Download - Spinner and status -->
<div class="section" id="downloadStep4Section" style="display: none;">
<h3>Step 4: Download Files</h3>
<div id="downloadSpinner" style="display:none;align-items:center;gap:10px;margin:18px 0 0 0;font-size:1.1em;">
<span class="spinner"></span>
<div id="downloadSpinnerTextWrap" style="display:flex;align-items:center;flex:1;min-width:0;">
<span id="downloadSpinnerText" style="overflow-x:auto;white-space:nowrap;display:block;min-width:0;flex:1;"></span>
<span id="downloadSpinnerSpeed" style="flex-shrink:0;margin-left:12px;color:#32ffcf;"></span>
</div>
</div>
</div>
<div class="section">
<h3>Logs</h3>
<div id="logDiv" class="log"></div>

View File

@@ -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 {
</div>
`;
});
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 {
</div>
`;
}
html += `
<div class="file-item" style="border-top: 1px solid #163951; font-weight: 600;">
<div class="file-name">Total</div>
@@ -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) {