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:
@@ -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{};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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{};
|
||||
};
|
||||
|
||||
@@ -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))) {
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user