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;
|
struct ProgressBox;
|
||||||
using ProgressBoxCallback = std::function<Result(ProgressBox*)>;
|
using ProgressBoxCallback = std::function<Result(ProgressBox*)>;
|
||||||
using ProgressBoxDoneCallback = std::function<void(Result rc)>;
|
using ProgressBoxDoneCallback = std::function<void(Result rc)>;
|
||||||
|
// using CancelCallback = std::function<void()>;
|
||||||
|
|
||||||
struct ProgressBox final : Widget {
|
struct ProgressBox final : Widget {
|
||||||
ProgressBox(
|
ProgressBox(
|
||||||
@@ -39,6 +40,9 @@ struct ProgressBox final : Widget {
|
|||||||
auto ShouldExit() -> bool;
|
auto ShouldExit() -> bool;
|
||||||
auto ShouldExitResult() -> Result;
|
auto ShouldExitResult() -> Result;
|
||||||
|
|
||||||
|
void AddCancelEvent(UEvent* event);
|
||||||
|
void RemoveCancelEvent(const UEvent* event);
|
||||||
|
|
||||||
// helper functions
|
// 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_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;
|
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{};
|
Thread m_thread{};
|
||||||
ThreadData m_thread_data{};
|
ThreadData m_thread_data{};
|
||||||
ProgressBoxDoneCallback m_done{};
|
ProgressBoxDoneCallback m_done{};
|
||||||
|
std::vector<UEvent*> m_cancel_events{};
|
||||||
|
|
||||||
// shared data start.
|
// shared data start.
|
||||||
std::string m_action{};
|
std::string m_action{};
|
||||||
|
|||||||
@@ -24,6 +24,14 @@ struct Usb {
|
|||||||
// Result OpenFile(u32 index, s64& file_size);
|
// Result OpenFile(u32 index, s64& file_size);
|
||||||
Result CloseFile();
|
Result CloseFile();
|
||||||
|
|
||||||
|
auto GetOpenResult() const {
|
||||||
|
return m_open_result;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto GetCancelEvent() {
|
||||||
|
return m_usb->GetCancelEvent();
|
||||||
|
}
|
||||||
|
|
||||||
private:
|
private:
|
||||||
Result SendAndVerify(const void* data, u32 size, u64 timeout, api::ResultPacket* out = nullptr);
|
Result SendAndVerify(const void* data, u32 size, u64 timeout, api::ResultPacket* out = nullptr);
|
||||||
Result SendAndVerify(const void* data, u32 size, 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 OpenFile(u32 index, s64& file_size);
|
||||||
Result CloseFile();
|
Result CloseFile();
|
||||||
|
|
||||||
|
auto GetOpenResult() const {
|
||||||
|
return m_open_result;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto GetCancelEvent() {
|
||||||
|
return m_usb->GetCancelEvent();
|
||||||
|
}
|
||||||
|
|
||||||
private:
|
private:
|
||||||
Result SendAndVerify(const void* data, u32 size, u64 timeout, api::ResultPacket* out = nullptr);
|
Result SendAndVerify(const void* data, u32 size, u64 timeout, api::ResultPacket* out = nullptr);
|
||||||
Result SendAndVerify(const void* data, u32 size, 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();
|
Result file_transfer_loop();
|
||||||
|
|
||||||
|
auto GetOpenResult() const {
|
||||||
|
return m_open_result;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto GetCancelEvent() {
|
||||||
|
return m_usb->GetCancelEvent();
|
||||||
|
}
|
||||||
|
|
||||||
private:
|
private:
|
||||||
Result SendResult(u32 result, u32 arg3 = 0, u32 arg4 = 0);
|
Result SendResult(u32 result, u32 arg3 = 0, u32 arg4 = 0);
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,14 @@ struct Usb final : Base {
|
|||||||
return m_usb->CloseFile();
|
return m_usb->CloseFile();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
auto GetOpenResult() const {
|
||||||
|
return m_usb->GetOpenResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
auto GetCancelEvent() {
|
||||||
|
return m_usb->GetCancelEvent();
|
||||||
|
}
|
||||||
|
|
||||||
private:
|
private:
|
||||||
std::unique_ptr<usb::install::Usb> m_usb{};
|
std::unique_ptr<usb::install::Usb> m_usb{};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -165,6 +165,14 @@ struct WriteUsbSource final : WriteSource {
|
|||||||
R_SUCCEED();
|
R_SUCCEED();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
auto GetOpenResult() const {
|
||||||
|
return m_usb->GetOpenResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
auto GetCancelEvent() {
|
||||||
|
return m_usb->GetCancelEvent();
|
||||||
|
}
|
||||||
|
|
||||||
private:
|
private:
|
||||||
std::unique_ptr<usb::dump::Usb> m_usb{};
|
std::unique_ptr<usb::dump::Usb> m_usb{};
|
||||||
bool m_was_mtp_enabled{};
|
bool m_was_mtp_enabled{};
|
||||||
@@ -178,8 +186,8 @@ constexpr DumpLocationEntry DUMP_LOCATIONS[]{
|
|||||||
};
|
};
|
||||||
|
|
||||||
struct UsbTest final : usb::upload::Usb, yati::source::Stream {
|
struct UsbTest final : usb::upload::Usb, yati::source::Stream {
|
||||||
UsbTest(ui::ProgressBox* pbox, BaseSource* source, std::span<const fs::FsPath> paths)
|
UsbTest(ui::ProgressBox* pbox, BaseSource* source, std::span<const fs::FsPath> paths, u64 timeout)
|
||||||
: Usb{UINT64_MAX}
|
: Usb{timeout}
|
||||||
, m_pbox{pbox}
|
, m_pbox{pbox}
|
||||||
, m_source{source}
|
, m_source{source}
|
||||||
, m_paths{paths} {
|
, m_paths{paths} {
|
||||||
@@ -248,6 +256,10 @@ struct UsbTest final : usb::upload::Usb, yati::source::Stream {
|
|||||||
return m_pull_offset;
|
return m_pull_offset;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
auto GetOpenResult() const {
|
||||||
|
return Usb::GetOpenResult();
|
||||||
|
}
|
||||||
|
|
||||||
private:
|
private:
|
||||||
ui::ProgressBox* m_pbox{};
|
ui::ProgressBox* m_pbox{};
|
||||||
BaseSource* m_source{};
|
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) {
|
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) {
|
for (const auto& path : paths) {
|
||||||
const auto file_size = source->GetSize(path);
|
const auto file_size = source->GetSize(path);
|
||||||
@@ -273,7 +292,7 @@ Result DumpToUsb(ui::ProgressBox* pbox, BaseSource* source, std::span<const fs::
|
|||||||
while (true) {
|
while (true) {
|
||||||
R_TRY(pbox->ShouldExitResult());
|
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)) {
|
if (R_SUCCEEDED(rc)) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -398,8 +417,14 @@ Result DumpToUsbS2S(ui::ProgressBox* pbox, BaseSource* source, std::span<const f
|
|||||||
file_list.emplace_back(path);
|
file_list.emplace_back(path);
|
||||||
}
|
}
|
||||||
|
|
||||||
auto usb = std::make_unique<UsbTest>(pbox, source, paths);
|
// create usb test instance and verify that it opened.
|
||||||
constexpr u64 timeout = 3e+9;
|
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()) {
|
while (!pbox->ShouldExit()) {
|
||||||
if (R_SUCCEEDED(usb->IsUsbConnected(timeout))) {
|
if (R_SUCCEEDED(usb->IsUsbConnected(timeout))) {
|
||||||
|
|||||||
@@ -97,6 +97,9 @@ void Menu::Update(Controller* controller, TouchInfo* touch) {
|
|||||||
m_state = State::Progress;
|
m_state = State::Progress;
|
||||||
log_write("got connection\n");
|
log_write("got connection\n");
|
||||||
App::Push<ui::ProgressBox>(0, "Installing "_i18n, "", [this](auto pbox) -> Result {
|
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");
|
log_write("inside progress box\n");
|
||||||
for (u32 i = 0; i < std::size(m_names); i++) {
|
for (u32 i = 0; i < std::size(m_names); i++) {
|
||||||
const auto& file_name = 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& {
|
auto ProgressBox::SetActionName(const std::string& action) -> ProgressBox& {
|
||||||
mutexLock(&m_mutex);
|
SCOPED_MUTEX(&m_mutex);
|
||||||
m_action = action;
|
m_action = action;
|
||||||
mutexUnlock(&m_mutex);
|
|
||||||
Yield();
|
|
||||||
return *this;
|
return *this;
|
||||||
}
|
}
|
||||||
|
|
||||||
auto ProgressBox::SetTitle(const std::string& title) -> ProgressBox& {
|
auto ProgressBox::SetTitle(const std::string& title) -> ProgressBox& {
|
||||||
mutexLock(&m_mutex);
|
SCOPED_MUTEX(&m_mutex);
|
||||||
m_title = title;
|
m_title = title;
|
||||||
mutexUnlock(&m_mutex);
|
|
||||||
Yield();
|
|
||||||
return *this;
|
return *this;
|
||||||
}
|
}
|
||||||
|
|
||||||
auto ProgressBox::NewTransfer(const std::string& transfer) -> ProgressBox& {
|
auto ProgressBox::NewTransfer(const std::string& transfer) -> ProgressBox& {
|
||||||
mutexLock(&m_mutex);
|
SCOPED_MUTEX(&m_mutex);
|
||||||
m_transfer = transfer;
|
m_transfer = transfer;
|
||||||
m_size = 0;
|
m_size = 0;
|
||||||
m_offset = 0;
|
m_offset = 0;
|
||||||
m_last_offset = 0;
|
m_last_offset = 0;
|
||||||
m_timestamp.Update();
|
m_timestamp.Update();
|
||||||
mutexUnlock(&m_mutex);
|
|
||||||
Yield();
|
|
||||||
return *this;
|
return *this;
|
||||||
}
|
}
|
||||||
|
|
||||||
auto ProgressBox::ResetTranfser() -> ProgressBox& {
|
auto ProgressBox::ResetTranfser() -> ProgressBox& {
|
||||||
mutexLock(&m_mutex);
|
SCOPED_MUTEX(&m_mutex);
|
||||||
m_size = 0;
|
m_size = 0;
|
||||||
m_offset = 0;
|
m_offset = 0;
|
||||||
m_last_offset = 0;
|
m_last_offset = 0;
|
||||||
m_timestamp.Update();
|
m_timestamp.Update();
|
||||||
mutexUnlock(&m_mutex);
|
|
||||||
Yield();
|
|
||||||
return *this;
|
return *this;
|
||||||
}
|
}
|
||||||
|
|
||||||
auto ProgressBox::UpdateTransfer(s64 offset, s64 size) -> ProgressBox& {
|
auto ProgressBox::UpdateTransfer(s64 offset, s64 size) -> ProgressBox& {
|
||||||
mutexLock(&m_mutex);
|
SCOPED_MUTEX(&m_mutex);
|
||||||
m_size = size;
|
m_size = size;
|
||||||
m_offset = offset;
|
m_offset = offset;
|
||||||
mutexUnlock(&m_mutex);
|
|
||||||
Yield();
|
|
||||||
return *this;
|
return *this;
|
||||||
}
|
}
|
||||||
|
|
||||||
auto ProgressBox::SetImage(int image) -> ProgressBox& {
|
auto ProgressBox::SetImage(int image) -> ProgressBox& {
|
||||||
mutexLock(&m_mutex);
|
SCOPED_MUTEX(&m_mutex);
|
||||||
m_image_pending = image;
|
m_image_pending = image;
|
||||||
m_is_image_pending = true;
|
m_is_image_pending = true;
|
||||||
mutexUnlock(&m_mutex);
|
|
||||||
return *this;
|
return *this;
|
||||||
}
|
}
|
||||||
|
|
||||||
auto ProgressBox::SetImageData(std::vector<u8>& data) -> ProgressBox& {
|
auto ProgressBox::SetImageData(std::vector<u8>& data) -> ProgressBox& {
|
||||||
mutexLock(&m_mutex);
|
SCOPED_MUTEX(&m_mutex);
|
||||||
std::swap(m_image_data, data);
|
std::swap(m_image_data, data);
|
||||||
mutexUnlock(&m_mutex);
|
|
||||||
return *this;
|
return *this;
|
||||||
}
|
}
|
||||||
|
|
||||||
auto ProgressBox::SetImageDataConst(std::span<const u8> data) -> ProgressBox& {
|
auto ProgressBox::SetImageDataConst(std::span<const u8> data) -> ProgressBox& {
|
||||||
mutexLock(&m_mutex);
|
SCOPED_MUTEX(&m_mutex);
|
||||||
m_image_data.resize(data.size());
|
m_image_data.resize(data.size());
|
||||||
std::memcpy(m_image_data.data(), data.data(), m_image_data.size());
|
std::memcpy(m_image_data.data(), data.data(), m_image_data.size());
|
||||||
mutexUnlock(&m_mutex);
|
|
||||||
return *this;
|
return *this;
|
||||||
}
|
}
|
||||||
|
|
||||||
void ProgressBox::RequestExit() {
|
void ProgressBox::RequestExit() {
|
||||||
|
SCOPED_MUTEX(&m_mutex);
|
||||||
m_stop_source.request_stop();
|
m_stop_source.request_stop();
|
||||||
ueventSignal(GetCancelEvent());
|
ueventSignal(GetCancelEvent());
|
||||||
|
|
||||||
|
// cancel any registered events.
|
||||||
|
for (auto& e : m_cancel_events) {
|
||||||
|
ueventSignal(e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
auto ProgressBox::ShouldExit() -> bool {
|
auto ProgressBox::ShouldExit() -> bool {
|
||||||
@@ -318,6 +311,26 @@ auto ProgressBox::ShouldExitResult() -> Result {
|
|||||||
R_SUCCEED();
|
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 {
|
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_file_based_emummc = App::IsFileBaseEmummc();
|
||||||
const auto is_both_native = fs_src->IsNative() && fs_dst->IsNative();
|
const auto is_both_native = fs_src->IsNative() && fs_dst->IsNative();
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ Base::Base(u64 transfer_timeout) {
|
|||||||
App::SetAutoSleepDisabled(true);
|
App::SetAutoSleepDisabled(true);
|
||||||
|
|
||||||
m_transfer_timeout = transfer_timeout;
|
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]);
|
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() {
|
Usb::~Usb() {
|
||||||
if (m_was_connected && R_SUCCEEDED(m_usb->IsUsbConnected(0))) {
|
if (m_was_connected && R_SUCCEEDED(m_usb->IsUsbConnected(0))) {
|
||||||
const auto send_header = SendPacket::Build(CMD_QUIT);
|
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() {
|
Usb::~Usb() {
|
||||||
if (m_was_connected && R_SUCCEEDED(m_usb->IsUsbConnected(0))) {
|
if (m_was_connected && R_SUCCEEDED(m_usb->IsUsbConnected(0))) {
|
||||||
const auto send_header = SendPacket::Build(CMD_QUIT);
|
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 {
|
body {
|
||||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||||
max-width: 800px;
|
max-width: 800px;
|
||||||
|
|||||||
@@ -32,8 +32,20 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- Step 2: Mode Selection -->
|
||||||
<div class="section">
|
<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">
|
<input type="file" id="fileInput" accept=".nsp, .xci, .nsz, .xcz" multiple class="hidden">
|
||||||
|
|
||||||
<div class="file-controls">
|
<div class="file-controls">
|
||||||
@@ -50,8 +62,16 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="section">
|
<!-- Step 3: Download - Folder Picker (hidden by default) -->
|
||||||
<h3>Step 3: Transfer Files</h3>
|
<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>
|
<button id="sendBtn" disabled>Send Files</button>
|
||||||
<div id="transferProgress" class="device-info" style="display: none;">
|
<div id="transferProgress" class="device-info" style="display: none;">
|
||||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;">
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;">
|
||||||
@@ -91,6 +111,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="section">
|
||||||
<h3>Logs</h3>
|
<h3>Logs</h3>
|
||||||
<div id="logDiv" class="log"></div>
|
<div id="logDiv" class="log"></div>
|
||||||
|
|||||||
@@ -25,17 +25,20 @@ class UsbPacket {
|
|||||||
toBuffer() {
|
toBuffer() {
|
||||||
const buf = new ArrayBuffer(PACKET_SIZE);
|
const buf = new ArrayBuffer(PACKET_SIZE);
|
||||||
const view = new DataView(buf);
|
const view = new DataView(buf);
|
||||||
|
|
||||||
view.setUint32(0, this.magic, true);
|
view.setUint32(0, this.magic, true);
|
||||||
view.setUint32(4, this.arg2, true);
|
view.setUint32(4, this.arg2, true);
|
||||||
view.setUint32(8, this.arg3, true);
|
view.setUint32(8, this.arg3, true);
|
||||||
view.setUint32(12, this.arg4, true);
|
view.setUint32(12, this.arg4, true);
|
||||||
view.setUint32(16, this.arg5, true);
|
view.setUint32(16, this.arg5, true);
|
||||||
view.setUint32(20, this.crc32c, true);
|
view.setUint32(20, this.crc32c, true);
|
||||||
|
|
||||||
return buf;
|
return buf;
|
||||||
}
|
}
|
||||||
|
|
||||||
static fromBuffer(buf) {
|
static fromBuffer(buf) {
|
||||||
const view = new DataView(buf);
|
const view = new DataView(buf);
|
||||||
|
|
||||||
return new this(
|
return new this(
|
||||||
view.getUint32(0, true),
|
view.getUint32(0, true),
|
||||||
view.getUint32(4, true),
|
view.getUint32(4, true),
|
||||||
@@ -70,6 +73,7 @@ class SendPacket extends UsbPacket {
|
|||||||
packet.generateCrc32c();
|
packet.generateCrc32c();
|
||||||
return packet;
|
return packet;
|
||||||
}
|
}
|
||||||
|
|
||||||
getCmd() {
|
getCmd() {
|
||||||
return this.arg2;
|
return this.arg2;
|
||||||
}
|
}
|
||||||
@@ -81,6 +85,7 @@ class ResultPacket extends UsbPacket {
|
|||||||
packet.generateCrc32c();
|
packet.generateCrc32c();
|
||||||
return packet;
|
return packet;
|
||||||
}
|
}
|
||||||
|
|
||||||
verify() {
|
verify() {
|
||||||
super.verify();
|
super.verify();
|
||||||
if (this.arg2 !== RESULT_OK) throw new Error("Result not OK");
|
if (this.arg2 !== RESULT_OK) throw new Error("Result not OK");
|
||||||
@@ -96,12 +101,15 @@ class SendDataPacket extends UsbPacket {
|
|||||||
packet.generateCrc32c();
|
packet.generateCrc32c();
|
||||||
return packet;
|
return packet;
|
||||||
}
|
}
|
||||||
|
|
||||||
getOffset() {
|
getOffset() {
|
||||||
return Number((BigInt(this.arg2) << 32n) | BigInt(this.arg3));
|
return Number((BigInt(this.arg2) << 32n) | BigInt(this.arg3));
|
||||||
}
|
}
|
||||||
|
|
||||||
getSize() {
|
getSize() {
|
||||||
return this.arg4;
|
return this.arg4;
|
||||||
}
|
}
|
||||||
|
|
||||||
getCrc32c() {
|
getCrc32c() {
|
||||||
return this.arg5;
|
return this.arg5;
|
||||||
}
|
}
|
||||||
@@ -111,6 +119,7 @@ class SendDataPacket extends UsbPacket {
|
|||||||
const crc32c = (() => {
|
const crc32c = (() => {
|
||||||
const POLY = 0x82f63b78;
|
const POLY = 0x82f63b78;
|
||||||
const table = new Uint32Array(256);
|
const table = new Uint32Array(256);
|
||||||
|
|
||||||
for (let i = 0; i < 256; i++) {
|
for (let i = 0; i < 256; i++) {
|
||||||
let crc = i;
|
let crc = i;
|
||||||
for (let j = 0; j < 8; j++) {
|
for (let j = 0; j < 8; j++) {
|
||||||
@@ -118,25 +127,229 @@ const crc32c = (() => {
|
|||||||
}
|
}
|
||||||
table[i] = crc >>> 0;
|
table[i] = crc >>> 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
return function(crc, bytes) {
|
return function(crc, bytes) {
|
||||||
crc ^= 0xffffffff;
|
crc ^= 0xffffffff;
|
||||||
let i = 0;
|
let i = 0;
|
||||||
const len = bytes.length;
|
const len = bytes.length;
|
||||||
|
|
||||||
for (; i < len - 3; i += 4) {
|
for (; i < len - 3; i += 4) {
|
||||||
crc = table[(crc ^ bytes[i]) & 0xff] ^ (crc >>> 8);
|
crc = table[(crc ^ bytes[i]) & 0xff] ^ (crc >>> 8);
|
||||||
crc = table[(crc ^ bytes[i + 1]) & 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 + 2]) & 0xff] ^ (crc >>> 8);
|
||||||
crc = table[(crc ^ bytes[i + 3]) & 0xff] ^ (crc >>> 8);
|
crc = table[(crc ^ bytes[i + 3]) & 0xff] ^ (crc >>> 8);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (; i < len; i++) {
|
for (; i < len; i++) {
|
||||||
crc = table[(crc ^ bytes[i]) & 0xff] ^ (crc >>> 8);
|
crc = table[(crc ^ bytes[i]) & 0xff] ^ (crc >>> 8);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (crc ^ 0xffffffff) >>> 0;
|
return (crc ^ 0xffffffff) >>> 0;
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|
||||||
// --- Main Class ---
|
// --- Main Class ---
|
||||||
class WebUSBFileTransfer {
|
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() {
|
constructor() {
|
||||||
this.device = null;
|
this.device = null;
|
||||||
this.isConnected = false;
|
this.isConnected = false;
|
||||||
@@ -212,6 +425,7 @@ class WebUSBFileTransfer {
|
|||||||
container.style.display = 'none';
|
container.style.display = 'none';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
container.style.display = 'block';
|
container.style.display = 'block';
|
||||||
this.updateAuthorizedDevicesUI();
|
this.updateAuthorizedDevicesUI();
|
||||||
}
|
}
|
||||||
@@ -233,6 +447,7 @@ class WebUSBFileTransfer {
|
|||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
});
|
});
|
||||||
|
|
||||||
listContainer.innerHTML = html;
|
listContainer.innerHTML = html;
|
||||||
// Add event listeners to connect buttons
|
// Add event listeners to connect buttons
|
||||||
const connectButtons = listContainer.querySelectorAll('button[data-device-index]:not([disabled])');
|
const connectButtons = listContainer.querySelectorAll('button[data-device-index]:not([disabled])');
|
||||||
@@ -249,8 +464,10 @@ class WebUSBFileTransfer {
|
|||||||
this.showStatus('Invalid device index', 'error');
|
this.showStatus('Invalid device index', 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const device = this.authorizedDevices[deviceIndex];
|
const device = this.authorizedDevices[deviceIndex];
|
||||||
this.log(`Connecting to authorized device: ${device.productName || 'Unknown Device'}`);
|
this.log(`Connecting to authorized device: ${device.productName || 'Unknown Device'}`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.connectToDevice(device);
|
await this.connectToDevice(device);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -290,6 +507,34 @@ class WebUSBFileTransfer {
|
|||||||
document.getElementById('addFilesBtn').addEventListener('click', () => this.triggerFileInput());
|
document.getElementById('addFilesBtn').addEventListener('click', () => this.triggerFileInput());
|
||||||
document.getElementById('clearQueueBtn').addEventListener('click', () => this.clearFileQueue());
|
document.getElementById('clearQueueBtn').addEventListener('click', () => this.clearFileQueue());
|
||||||
document.getElementById('toastClose').addEventListener('click', () => this.hideConnectionToast());
|
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 ---
|
// --- File Queue Management ---
|
||||||
@@ -308,6 +553,7 @@ class WebUSBFileTransfer {
|
|||||||
handleFileSelect(event) {
|
handleFileSelect(event) {
|
||||||
const newFiles = Array.from(event.target.files);
|
const newFiles = Array.from(event.target.files);
|
||||||
const allowedExt = ['.nsp', '.xci', '.nsz', '.xcz'];
|
const allowedExt = ['.nsp', '.xci', '.nsz', '.xcz'];
|
||||||
|
|
||||||
if (newFiles.length > 0) {
|
if (newFiles.length > 0) {
|
||||||
let added = 0;
|
let added = 0;
|
||||||
for (const file of newFiles) {
|
for (const file of newFiles) {
|
||||||
@@ -316,11 +562,13 @@ class WebUSBFileTransfer {
|
|||||||
this.log(`Skipping unsupported file type: ${file.name}`);
|
this.log(`Skipping unsupported file type: ${file.name}`);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!this.fileQueue.some(f => f.name === file.name && f.size === file.size)) {
|
if (!this.fileQueue.some(f => f.name === file.name && f.size === file.size)) {
|
||||||
this.fileQueue.push(file);
|
this.fileQueue.push(file);
|
||||||
added++;
|
added++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (added > 0) {
|
if (added > 0) {
|
||||||
this.updateFileQueueUI();
|
this.updateFileQueueUI();
|
||||||
this.log(`Added ${added} file(s) to queue. Total: ${this.fileQueue.length}`);
|
this.log(`Added ${added} file(s) to queue. Total: ${this.fileQueue.length}`);
|
||||||
@@ -344,10 +592,12 @@ class WebUSBFileTransfer {
|
|||||||
document.getElementById('sendBtn').disabled = true;
|
document.getElementById('sendBtn').disabled = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById('clearQueueBtn').disabled = false;
|
document.getElementById('clearQueueBtn').disabled = false;
|
||||||
document.getElementById('sendBtn').disabled = !this.isConnected;
|
document.getElementById('sendBtn').disabled = !this.isConnected;
|
||||||
let html = '';
|
let html = '';
|
||||||
let totalSize = 0;
|
let totalSize = 0;
|
||||||
|
|
||||||
for (let i = 0; i < this.fileQueue.length; i++) {
|
for (let i = 0; i < this.fileQueue.length; i++) {
|
||||||
const file = this.fileQueue[i];
|
const file = this.fileQueue[i];
|
||||||
totalSize += file.size;
|
totalSize += file.size;
|
||||||
@@ -361,6 +611,7 @@ class WebUSBFileTransfer {
|
|||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
html += `
|
html += `
|
||||||
<div class="file-item" style="border-top: 1px solid #163951; font-weight: 600;">
|
<div class="file-item" style="border-top: 1px solid #163951; font-weight: 600;">
|
||||||
<div class="file-name">Total</div>
|
<div class="file-name">Total</div>
|
||||||
@@ -397,6 +648,7 @@ class WebUSBFileTransfer {
|
|||||||
this.device = await navigator.usb.requestDevice({
|
this.device = await navigator.usb.requestDevice({
|
||||||
filters: [{ vendorId: 0x057e, productId: 0x3000 }]
|
filters: [{ vendorId: 0x057e, productId: 0x3000 }]
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.connectToDevice(this.device);
|
await this.connectToDevice(this.device);
|
||||||
await this.loadAuthorizedDevices();
|
await this.loadAuthorizedDevices();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -408,11 +660,13 @@ class WebUSBFileTransfer {
|
|||||||
async connectToDevice(device) {
|
async connectToDevice(device) {
|
||||||
this.device = device;
|
this.device = device;
|
||||||
this.log(`Selected device: ${this.device.productName || 'Unknown'}`);
|
this.log(`Selected device: ${this.device.productName || 'Unknown'}`);
|
||||||
|
|
||||||
await this.device.open();
|
await this.device.open();
|
||||||
if (this.device.configuration === null) {
|
if (this.device.configuration === null) {
|
||||||
await this.device.selectConfiguration(1);
|
await this.device.selectConfiguration(1);
|
||||||
this.log('Configuration selected');
|
this.log('Configuration selected');
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.device.claimInterface(0);
|
await this.device.claimInterface(0);
|
||||||
this.log('Interface claimed');
|
this.log('Interface claimed');
|
||||||
const iface = this.device.configuration.interfaces[0].alternates[0];
|
const iface = this.device.configuration.interfaces[0].alternates[0];
|
||||||
@@ -421,10 +675,12 @@ class WebUSBFileTransfer {
|
|||||||
if (this.endpointIn === undefined || this.endpointOut === undefined) {
|
if (this.endpointIn === undefined || this.endpointOut === undefined) {
|
||||||
throw new Error("Bulk IN/OUT endpoints not found");
|
throw new Error("Bulk IN/OUT endpoints not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
this.isConnected = true;
|
this.isConnected = true;
|
||||||
this.updateUI();
|
this.updateUI();
|
||||||
this.showToast(`Device connected successfully!`, 'success', 3000);
|
this.showToast(`Device connected successfully!`, 'success', 3000);
|
||||||
this.showConnectionToast(`Connected: ${this.device.productName || 'USB Device'}`, 'connect');
|
this.showConnectionToast(`Connected: ${this.device.productName || 'USB Device'}`, 'connect');
|
||||||
|
this.maybeStartDownloadLoop();
|
||||||
}
|
}
|
||||||
|
|
||||||
async disconnectDevice() {
|
async disconnectDevice() {
|
||||||
@@ -493,6 +749,7 @@ class WebUSBFileTransfer {
|
|||||||
this.log(`❌ Transfer stopped: invalid file index ${arg3} (out of ${files.length})`);
|
this.log(`❌ Transfer stopped: invalid file index ${arg3} (out of ${files.length})`);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
const total = files.length;
|
const total = files.length;
|
||||||
const current = arg3 + 1;
|
const current = arg3 + 1;
|
||||||
this.progressContext = {current, total};
|
this.progressContext = {current, total};
|
||||||
@@ -540,16 +797,20 @@ class WebUSBFileTransfer {
|
|||||||
if (result.status && result.status !== 'ok') {
|
if (result.status && result.status !== 'ok') {
|
||||||
throw new Error(`USB transferIn failed: ${result.status}`);
|
throw new Error(`USB transferIn failed: ${result.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!result.data) {
|
if (!result.data) {
|
||||||
throw new Error('transferIn returned no data');
|
throw new Error('transferIn returned no data');
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
async write(buffer) {
|
async write(buffer) {
|
||||||
const result = await this.device.transferOut(this.endpointOut, buffer);
|
const result = await this.device.transferOut(this.endpointOut, buffer);
|
||||||
if (result.status && result.status !== 'ok') {
|
if (result.status && result.status !== 'ok') {
|
||||||
throw new Error(`USB transferOut failed: ${result.status}`);
|
throw new Error(`USB transferOut failed: ${result.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -657,6 +918,7 @@ class WebUSBFileTransfer {
|
|||||||
document.getElementById('disconnectBtn').disabled = !this.isConnected;
|
document.getElementById('disconnectBtn').disabled = !this.isConnected;
|
||||||
document.getElementById('addFilesBtn').disabled = !this.isConnected;
|
document.getElementById('addFilesBtn').disabled = !this.isConnected;
|
||||||
document.getElementById('sendBtn').disabled = !this.isConnected || this.fileQueue.length === 0;
|
document.getElementById('sendBtn').disabled = !this.isConnected || this.fileQueue.length === 0;
|
||||||
|
|
||||||
if (this.authorizedDevices.length > 0) {
|
if (this.authorizedDevices.length > 0) {
|
||||||
this.updateAuthorizedDevicesUI();
|
this.updateAuthorizedDevicesUI();
|
||||||
}
|
}
|
||||||
@@ -666,17 +928,20 @@ class WebUSBFileTransfer {
|
|||||||
showStatus(message, type) {
|
showStatus(message, type) {
|
||||||
this.log(`[${type.toUpperCase()}] ${message}`);
|
this.log(`[${type.toUpperCase()}] ${message}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
log(message) {
|
log(message) {
|
||||||
const logDiv = document.getElementById('logDiv');
|
const logDiv = document.getElementById('logDiv');
|
||||||
const timestamp = new Date().toLocaleTimeString();
|
const timestamp = new Date().toLocaleTimeString();
|
||||||
logDiv.textContent += `[${timestamp}] ${message}\n`;
|
logDiv.textContent += `[${timestamp}] ${message}\n`;
|
||||||
logDiv.scrollTop = logDiv.scrollHeight;
|
logDiv.scrollTop = logDiv.scrollHeight;
|
||||||
}
|
}
|
||||||
|
|
||||||
clearLog() {
|
clearLog() {
|
||||||
const logDiv = document.getElementById('logDiv');
|
const logDiv = document.getElementById('logDiv');
|
||||||
logDiv.textContent = '';
|
logDiv.textContent = '';
|
||||||
this.log('Log cleared');
|
this.log('Log cleared');
|
||||||
}
|
}
|
||||||
|
|
||||||
copyLog() {
|
copyLog() {
|
||||||
const logDiv = document.getElementById('logDiv');
|
const logDiv = document.getElementById('logDiv');
|
||||||
navigator.clipboard.writeText(logDiv.textContent)
|
navigator.clipboard.writeText(logDiv.textContent)
|
||||||
@@ -717,6 +982,7 @@ class WebUSBFileTransfer {
|
|||||||
toast.classList.add('show');
|
toast.classList.add('show');
|
||||||
this.toastTimeout = setTimeout(() => { this.hideConnectionToast(); }, 4000);
|
this.toastTimeout = setTimeout(() => { this.hideConnectionToast(); }, 4000);
|
||||||
}
|
}
|
||||||
|
|
||||||
hideConnectionToast() {
|
hideConnectionToast() {
|
||||||
const toast = document.getElementById('connectionToast');
|
const toast = document.getElementById('connectionToast');
|
||||||
toast.classList.remove('show');
|
toast.classList.remove('show');
|
||||||
@@ -725,6 +991,7 @@ class WebUSBFileTransfer {
|
|||||||
this.toastTimeout = null;
|
this.toastTimeout = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
showToast(message, type = 'info', duration = 4000) {
|
showToast(message, type = 'info', duration = 4000) {
|
||||||
const toast = document.getElementById('connectionToast');
|
const toast = document.getElementById('connectionToast');
|
||||||
const toastMessage = document.getElementById('toastMessage');
|
const toastMessage = document.getElementById('toastMessage');
|
||||||
@@ -880,6 +1147,7 @@ navigator.usb?.addEventListener('disconnect', async (event) => {
|
|||||||
await app.tryAutoConnect();
|
await app.tryAutoConnect();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
navigator.usb?.addEventListener('connect', async (event) => {
|
navigator.usb?.addEventListener('connect', async (event) => {
|
||||||
console.log('USB device connected:', event.device);
|
console.log('USB device connected:', event.device);
|
||||||
if (app) {
|
if (app) {
|
||||||
|
|||||||
Reference in New Issue
Block a user