Initial commit

This commit is contained in:
niklascfw committed 2026-06-07 00:35:25 +02:00
1 parent ea13609f7c
commit b8eca61e20
24 files changed
+4991

No files matched your search

+14
View File
@@ -0,0 +1,14 @@
#include "ui/App.hpp"
#include "ui/WebUI.hpp"
#import <Cocoa/Cocoa.h>
int main(int, char**) {
@autoreleasepool {
omniMTP::App app;
app.init();
omniMTP::WebUI ui(app);
ui.run();
app.shutdown();
}
return 0;
}
+333
View File
@@ -0,0 +1,333 @@
#include "MTPOperations.hpp"
#include <cstring>
#include <stdexcept>
#include <format>
namespace mtp {
// ─── Encoding helpers ─────────────────────────────────────────────────────────
void MTPOperations::put_le16(std::vector<uint8_t>& b, uint16_t v) {
b.push_back(v & 0xFF);
b.push_back(v >> 8);
}
void MTPOperations::put_le32(std::vector<uint8_t>& b, uint32_t v) {
b.push_back(v & 0xFF); b.push_back((v>>8)&0xFF);
b.push_back((v>>16)&0xFF); b.push_back(v>>24);
}
void MTPOperations::put_le64(std::vector<uint8_t>& b, uint64_t v) {
put_le32(b, static_cast<uint32_t>(v));
put_le32(b, static_cast<uint32_t>(v >> 32));
}
// UTF-8 → MTP UTF-16LE string: [1-byte len][len × 2-byte UTF-16LE chars]
void MTPOperations::encode_mtp_string(std::vector<uint8_t>& buf, const std::string& utf8) {
if (utf8.empty()) { buf.push_back(0); return; }
// Simple ASCII-only encoding (sufficient for filenames in practice)
size_t len = std::min(utf8.size(), size_t(255)) + 1; // +1 for null terminator
buf.push_back(static_cast<uint8_t>(len));
for (size_t i = 0; i < len - 1; ++i) {
uint8_t c = static_cast<uint8_t>(utf8[i]);
buf.push_back(c);
buf.push_back(0);
}
// Null terminator
buf.push_back(0);
buf.push_back(0);
}
// ─── Parsing helpers ──────────────────────────────────────────────────────────
static uint16_t u16(const uint8_t* p) {
return static_cast<uint16_t>(p[0]) | (static_cast<uint16_t>(p[1]) << 8);
}
static uint32_t u32(const uint8_t* p) {
return p[0] | (p[1]<<8) | (p[2]<<16) | (p[3]<<24);
}
static uint64_t u64(const uint8_t* p) {
uint64_t lo = u32(p), hi = u32(p+4);
return lo | (hi << 32);
}
static std::string mtp_str(const uint8_t*& p, const uint8_t* end) {
if (p >= end) return {};
uint8_t n = *p++;
if (!n) return {};
std::string s; s.reserve(n);
for (uint8_t i = 0; i < n && p+1 < end; ++i) {
uint16_t c = static_cast<uint16_t>(p[0]) | (static_cast<uint16_t>(p[1])<<8);
p += 2;
if (!c) break;
if (c < 0x80) s += static_cast<char>(c);
else if (c < 0x800) { s += static_cast<char>(0xC0|(c>>6)); s += static_cast<char>(0x80|(c&0x3F)); }
else { s += static_cast<char>(0xE0|(c>>12)); s += static_cast<char>(0x80|((c>>6)&0x3F)); s += static_cast<char>(0x80|(c&0x3F)); }
}
return s;
}
std::vector<uint32_t> MTPOperations::parse_u32_array(const std::vector<uint8_t>& data) {
const uint8_t* p = data.data() + CONTAINER_HEADER_SIZE;
const uint8_t* end = data.data() + data.size();
if (p + 4 > end) return {};
uint32_t n = u32(p); p += 4;
std::vector<uint32_t> v; v.reserve(n);
for (uint32_t i = 0; i < n && p + 4 <= end; ++i) {
v.push_back(u32(p)); p += 4;
}
return v;
}
ObjectInfo MTPOperations::parse_object_info(const std::vector<uint8_t>& data) {
const uint8_t* p = data.data() + CONTAINER_HEADER_SIZE;
const uint8_t* end = data.data() + data.size();
ObjectInfo oi{};
if (p + 4 <= end) { oi.storage_id = u32(p); p += 4; }
if (p + 2 <= end) { oi.object_format = u16(p); p += 2; }
if (p + 2 <= end) { oi.protection_status = u16(p); p += 2; }
// MTP spec defines compressed_size as uint32_t in the ObjectInfo struct,
// but DBI/Sphaira follow the standard. For files > 4 GiB use GetObjectPropValue.
uint32_t wire_size = 0;
if (p + 4 <= end) { wire_size = u32(p); p += 4; }
oi.compressed_size = wire_size; // extended size handled via GetObjectPropValue
if (p + 2 <= end) { /* thumb format */ p += 2; }
if (p + 4 <= end) { /* thumb size */ p += 4; }
if (p + 4 <= end) { /* thumb w */ p += 4; }
if (p + 4 <= end) { /* thumb h */ p += 4; }
if (p + 4 <= end) { /* img w */ p += 4; }
if (p + 4 <= end) { /* img h */ p += 4; }
if (p + 4 <= end) { /* img depth */ p += 4; }
if (p + 4 <= end) { oi.parent_object = u32(p); p += 4; }
if (p + 2 <= end) { oi.association_type = u16(p); p += 2; }
if (p + 4 <= end) { oi.association_desc = u32(p); p += 4; }
if (p + 4 <= end) { oi.sequence_number = u32(p); p += 4; }
oi.filename = mtp_str(p, end);
oi.date_created = mtp_str(p, end);
oi.date_modified = mtp_str(p, end);
/* keywords = */ mtp_str(p, end);
return oi;
}
// ─── MTPOperations implementation ─────────────────────────────────────────────
MTPOperations::MTPOperations(MTPSession& session) : session_(session) {}
std::vector<uint32_t> MTPOperations::get_storage_ids() {
std::lock_guard lock(session_.mutex());
session_.send_command(OpCode::GetStorageIDs);
auto data = session_.receive_data();
auto resp = session_.receive_response();
if (resp.code != ResponseCode::OK)
throw MTPException(resp.code, MTPException::code_name(resp.code));
return parse_u32_array(data);
}
StorageInfo MTPOperations::get_storage_info(uint32_t storage_id) {
std::lock_guard lock(session_.mutex());
std::array<uint32_t,1> p{storage_id};
session_.send_command(OpCode::GetStorageInfo, p);
auto data = session_.receive_data();
auto resp = session_.receive_response();
if (resp.code != ResponseCode::OK)
throw MTPException(resp.code, MTPException::code_name(resp.code));
const uint8_t* ptr = data.data() + CONTAINER_HEADER_SIZE;
const uint8_t* end = data.data() + data.size();
StorageInfo si{};
if (ptr+2<=end){si.storage_type = u16(ptr); ptr+=2;}
if (ptr+2<=end){si.filesystem_type = u16(ptr); ptr+=2;}
if (ptr+2<=end){si.access_capability = u16(ptr); ptr+=2;}
if (ptr+8<=end){si.max_capacity = u64(ptr); ptr+=8;}
if (ptr+8<=end){si.free_space_bytes = u64(ptr); ptr+=8;}
if (ptr+4<=end){si.free_space_objects= u32(ptr); ptr+=4;}
si.description = mtp_str(ptr, end);
si.volume_id = mtp_str(ptr, end);
return si;
}
std::vector<uint32_t> MTPOperations::get_object_handles(uint32_t storage_id,
uint32_t parent_handle) {
std::lock_guard lock(session_.mutex());
std::array<uint32_t,3> params{ storage_id, ALL_FORMATS, parent_handle };
session_.send_command(OpCode::GetObjectHandles, params);
auto data = session_.receive_data();
auto resp = session_.receive_response();
if (resp.code != ResponseCode::OK)
throw MTPException(resp.code, MTPException::code_name(resp.code));
return parse_u32_array(data);
}
ObjectInfo MTPOperations::get_object_info(uint32_t handle) {
std::lock_guard lock(session_.mutex());
std::array<uint32_t,1> p{handle};
session_.send_command(OpCode::GetObjectInfo, p);
auto data = session_.receive_data();
auto resp = session_.receive_response();
if (resp.code != ResponseCode::OK)
throw MTPException(resp.code, MTPException::code_name(resp.code));
return parse_object_info(data);
}
void MTPOperations::get_object(uint32_t handle,
const std::string& dest_path,
uint64_t expected_size,
const ProgressFn& progress,
std::atomic<bool>& cancel) {
std::lock_guard lock(session_.mutex());
std::array<uint32_t,1> p{handle};
session_.send_command(OpCode::GetObject, p);
session_.receive_data_to_file(dest_path, expected_size, progress, cancel);
auto resp = session_.receive_response();
if (resp.code != ResponseCode::OK && resp.code != ResponseCode::TransactionCancelled)
throw MTPException(resp.code, MTPException::code_name(resp.code));
}
std::vector<uint8_t> MTPOperations::build_object_info(uint32_t storage_id,
uint32_t parent_handle,
const std::string& filename,
uint64_t file_size,
uint16_t format) {
std::vector<uint8_t> b;
b.reserve(128);
// Container header placeholder (will be filled in by session)
// Actual wire bytes are handled by send_data(), so we just build the payload here.
put_le32(b, storage_id);
put_le16(b, format);
put_le16(b, 0); // protection_status = none
// compressed_size as uint32 (0 = unknown, or truncated for large files)
put_le32(b, file_size > 0xFFFFFFFFu ? 0xFFFFFFFFu : static_cast<uint32_t>(file_size));
put_le16(b, 0); // thumb_format
put_le32(b, 0); // thumb_compressed_size
put_le32(b, 0); // thumb_pix_w
put_le32(b, 0); // thumb_pix_h
put_le32(b, 0); // image_pix_w
put_le32(b, 0); // image_pix_h
put_le32(b, 0); // image_bit_depth
put_le32(b, parent_handle);
put_le16(b, 0); // association_type
put_le32(b, 0); // association_desc
put_le32(b, 0); // sequence_number
encode_mtp_string(b, filename);
b.push_back(0); // date_created (empty string)
b.push_back(0); // date_modified (empty string)
b.push_back(0); // keywords (empty string)
return b;
}
// Build the SendObjectPropList data payload — just the filename property.
// The actual 64-bit file size is passed in the command parameters, not here.
static std::vector<uint8_t> build_prop_list(const std::string& filename) {
std::vector<uint8_t> b;
// number of properties = 1
b.push_back(1); b.push_back(0); b.push_back(0); b.push_back(0);
// object handle = 0 (new object)
b.push_back(0); b.push_back(0); b.push_back(0); b.push_back(0);
// property code = ObjectFilename (0xDC07)
b.push_back(static_cast<uint8_t>(PROP_OBJECT_FILENAME & 0xFF));
b.push_back(static_cast<uint8_t>(PROP_OBJECT_FILENAME >> 8));
// data type = String (0xFFFF)
b.push_back(static_cast<uint8_t>(DTYPE_STRING & 0xFF));
b.push_back(static_cast<uint8_t>(DTYPE_STRING >> 8));
// MTP string value
if (filename.empty()) {
b.push_back(0);
} else {
size_t len = std::min(filename.size(), size_t(255)) + 1;
b.push_back(static_cast<uint8_t>(len));
for (size_t i = 0; i < len - 1; ++i) {
b.push_back(static_cast<uint8_t>(filename[i]));
b.push_back(0);
}
b.push_back(0); b.push_back(0); // null terminator
}
return b;
}
uint32_t MTPOperations::send_object(uint32_t storage_id,
uint32_t parent_handle,
const std::string& src_path,
const std::string& filename,
uint64_t file_size,
const ProgressFn& progress,
std::atomic<bool>& cancel) {
// Always use Undefined (0x3000) for game files.
// DBI/Sphaira reject vendor-specific format codes like 0xB005 for NSP/XCI
// and return OperationNotSupported. Undefined is universally accepted.
uint16_t fmt = static_cast<uint16_t>(ObjectFormat::Undefined);
// All of SendObjectInfo/SendObjectPropList + SendObject must be atomic.
std::lock_guard lock(session_.mutex());
// Step 1: announce the object to the device.
//
// Prefer SendObjectPropList (0x9808) when supported — it passes the actual
// 64-bit file size in the command parameters (size_hi, size_lo), so the
// device knows the real byte count for files >4 GiB instead of receiving
// 0xFFFFFFFF and stopping at ~4.29 GB.
//
// Fall back to SendObjectInfo (0x100C) for devices that don't support it.
if (session_.supports_op(OpCode::SendObjectPropList)) {
// params: [storage_id, parent_handle, format, size_hi, size_lo]
uint32_t size_hi = static_cast<uint32_t>(file_size >> 32);
uint32_t size_lo = static_cast<uint32_t>(file_size & 0xFFFFFFFFu);
std::array<uint32_t,5> params{
storage_id, parent_handle,
static_cast<uint32_t>(fmt),
size_hi, size_lo
};
session_.send_command(OpCode::SendObjectPropList, params);
auto prop_list = build_prop_list(filename);
session_.send_data(prop_list);
} else {
std::array<uint32_t,2> params{ storage_id, parent_handle };
session_.send_command(OpCode::SendObjectInfo, params);
auto obj_info = build_object_info(storage_id, parent_handle, filename, file_size, fmt);
session_.send_data(obj_info);
}
{
auto resp = session_.receive_response();
if (resp.code != ResponseCode::OK)
throw MTPException(resp.code, MTPException::code_name(resp.code));
}
// Step 2: SendObject — immediately follows, no gap for foreign commands
session_.send_command(OpCode::SendObject);
session_.send_data_from_file(src_path, file_size, OpCode::SendObject, progress, cancel);
// Use TIMEOUT_INSTALL_MS: after the last byte DBI/Sphaira verify NCAs and
// update the content database, which can take well over 5 s on slow microSD.
{
auto resp = session_.receive_response(TIMEOUT_INSTALL_MS);
if (resp.code != ResponseCode::OK && resp.code != ResponseCode::TransactionCancelled)
throw MTPException(resp.code, MTPException::code_name(resp.code));
}
return 0;
}
void MTPOperations::delete_object(uint32_t handle) {
std::lock_guard lock(session_.mutex());
std::array<uint32_t,2> params{ handle, ALL_FORMATS };
session_.send_command(OpCode::DeleteObject, params);
auto resp = session_.receive_response();
if (resp.code != ResponseCode::OK)
throw MTPException(resp.code, MTPException::code_name(resp.code));
}
uint32_t MTPOperations::create_directory(uint32_t storage_id,
uint32_t parent_handle,
const std::string& name) {
std::lock_guard lock(session_.mutex());
std::array<uint32_t,2> params{ storage_id, parent_handle };
session_.send_command(OpCode::SendObjectInfo, params);
auto info = build_object_info(storage_id, parent_handle, name, 0,
static_cast<uint16_t>(ObjectFormat::Association));
session_.send_data(info);
auto resp = session_.receive_response();
if (resp.code != ResponseCode::OK)
throw MTPException(resp.code, MTPException::code_name(resp.code));
return resp.params.size() >= 3 ? resp.params[2] : 0;
}
} // namespace mtp
+70
View File
@@ -0,0 +1,70 @@
#pragma once
#include "MTPSession.hpp"
#include "MTPProtocol.hpp"
#include <cstdint>
#include <string>
#include <vector>
namespace mtp {
// Stateless MTP operations that run on top of an MTPSession.
// Every public function acquires the session mutex.
class MTPOperations {
public:
explicit MTPOperations(MTPSession& session);
// ── Storage ───────────────────────────────────────────────────────────────
std::vector<uint32_t> get_storage_ids();
StorageInfo get_storage_info(uint32_t storage_id);
// ── Object enumeration ────────────────────────────────────────────────────
// parent_handle = ROOT_PARENT for root of a storage
std::vector<uint32_t> get_object_handles(uint32_t storage_id,
uint32_t parent_handle = ROOT_PARENT);
ObjectInfo get_object_info(uint32_t handle);
// ── Transfer ──────────────────────────────────────────────────────────────
// Download object to a local file path.
void get_object(uint32_t handle,
const std::string& dest_path,
uint64_t expected_size,
const ProgressFn& progress,
std::atomic<bool>& cancel);
// Upload a local file, returns new object handle on the device.
uint32_t send_object(uint32_t storage_id,
uint32_t parent_handle,
const std::string& src_path,
const std::string& filename,
uint64_t file_size,
const ProgressFn& progress,
std::atomic<bool>& cancel);
// ── Management ────────────────────────────────────────────────────────────
void delete_object(uint32_t handle);
// Create a directory (association object).
uint32_t create_directory(uint32_t storage_id,
uint32_t parent_handle,
const std::string& name);
private:
MTPSession& session_;
// Build a packed ObjectInfo blob for SendObjectInfo
static std::vector<uint8_t> build_object_info(uint32_t storage_id,
uint32_t parent_handle,
const std::string& filename,
uint64_t file_size,
uint16_t format);
static void encode_mtp_string(std::vector<uint8_t>& buf, const std::string& utf8);
static void put_le16(std::vector<uint8_t>& b, uint16_t v);
static void put_le32(std::vector<uint8_t>& b, uint32_t v);
static void put_le64(std::vector<uint8_t>& b, uint64_t v);
static std::vector<uint32_t> parse_u32_array(const std::vector<uint8_t>& data);
static ObjectInfo parse_object_info(const std::vector<uint8_t>& data);
};
} // namespace mtp
+226
View File
@@ -0,0 +1,226 @@
#pragma once
#include <cstdint>
#include <string>
#include <vector>
namespace mtp {
// ─── Container types ──────────────────────────────────────────────────────────
enum class ContainerType : uint16_t {
Command = 0x0001,
Data = 0x0002,
Response = 0x0003,
Event = 0x0004,
};
// ─── MTP Operation codes ──────────────────────────────────────────────────────
enum class OpCode : uint16_t {
GetDeviceInfo = 0x1001,
OpenSession = 0x1002,
CloseSession = 0x1003,
GetStorageIDs = 0x1004,
GetStorageInfo = 0x1005,
GetNumObjects = 0x1006,
GetObjectHandles = 0x1007,
GetObjectInfo = 0x1008,
GetObject = 0x1009,
GetThumb = 0x100A,
DeleteObject = 0x100B,
SendObjectInfo = 0x100C,
SendObject = 0x100D,
GetDevicePropDesc = 0x1014,
GetDevicePropValue = 0x1015,
SetDevicePropValue = 0x1016,
MoveObject = 0x1019,
CopyObject = 0x101A,
GetPartialObject = 0x101B,
GetObjectPropsSupported = 0x9801,
GetObjectPropDesc = 0x9802,
GetObjectPropValue = 0x9803,
SetObjectPropValue = 0x9804,
// Modern object creation — passes full 64-bit size in command params
// so devices know the real size even for files > 4 GiB.
SendObjectPropList = 0x9808,
};
// ─── Response codes ───────────────────────────────────────────────────────────
enum class ResponseCode : uint16_t {
OK = 0x2001,
GeneralError = 0x2002,
SessionNotOpen = 0x2003,
InvalidTransactionID = 0x2004,
OperationNotSupported = 0x2005,
ParameterNotSupported = 0x2006,
IncompleteTransfer = 0x2007,
InvalidStorageID = 0x2008,
InvalidObjectHandle = 0x2009,
DevicePropNotSupported = 0x200A,
StoreFull = 0x200C,
ObjectWriteProtected = 0x200D,
StoreReadOnly = 0x200E,
AccessDenied = 0x200F,
DeviceBusy = 0x2019,
InvalidParentObject = 0x201A,
InvalidParameter = 0x201D,
SessionAlreadyOpen = 0x201E,
TransactionCancelled = 0x201F,
};
// ─── Object format codes ──────────────────────────────────────────────────────
enum class ObjectFormat : uint16_t {
Undefined = 0x3000,
Association = 0x3001, // Directory/folder
Script = 0x3002,
Text = 0x3004,
HTML = 0x3005,
MP3 = 0x3009,
AVI = 0x300A,
MPEG = 0x300B,
JPEG = 0x3801,
BMP = 0x3804,
GIF = 0x3807,
PNG = 0x380B,
TIFF = 0x380D,
NSP = 0xB005, // Nintendo Switch Package
XCI = 0xB006, // Nintendo Switch Cartridge Image
};
// ─── Wire-format container header (little-endian, packed) ────────────────────
#pragma pack(push, 1)
struct ContainerHeader {
uint32_t length; // total bytes including this header
uint16_t type;
uint16_t code;
uint32_t transaction_id;
};
static_assert(sizeof(ContainerHeader) == 12);
#pragma pack(pop)
// ─── Object property codes (for SendObjectPropList payload) ──────────────────
static constexpr uint16_t PROP_OBJECT_FILENAME = 0xDC07;
static constexpr uint16_t PROP_OBJECT_SIZE = 0xDC04;
static constexpr uint16_t DTYPE_STRING = 0xFFFF;
static constexpr uint16_t DTYPE_UINT64 = 0x0008;
// ─── Constants ────────────────────────────────────────────────────────────────
static constexpr uint32_t CONTAINER_HEADER_SIZE = 12;
static constexpr uint32_t ROOT_PARENT = 0xFFFFFFFF;
static constexpr uint32_t ALL_STORAGE = 0xFFFFFFFF;
static constexpr uint32_t ALL_FORMATS = 0x00000000;
static constexpr uint32_t SESSION_ID = 1;
// Nintendo Switch USB identifiers
static constexpr uint16_t NINTENDO_VID = 0x057e;
static constexpr uint16_t SWITCH_PID_V1 = 0x2000;
static constexpr uint16_t SWITCH_PID_V2 = 0x3000; // Switch V2/Lite/OLED
// USB MTP class/subclass/protocol
static constexpr uint8_t MTP_CLASS = 6;
static constexpr uint8_t MTP_SUBCLASS = 1;
static constexpr uint8_t MTP_PROTOCOL = 1;
// Transfer sizing
// 512 KiB per bulk transfer — safe across all macOS USB controllers and Switch firmware.
// 1 MiB can hit IOUSBLib scatter-gather limits on some hosts.
// USB 2.0 HS: max_pkt = 512 → use go-mtpfs's rwBufSize = 16 KiB
// USB 3.0 SS: max_pkt = 1024 → scale up; each syscall has ~100 µs overhead,
// so 16 KiB chunks cap throughput at ~164 MB/s even with USB 3.0 bandwidth.
// 512 KiB chunks drop syscall count 32× and saturate USB 3.0 properly.
static constexpr size_t CHUNK_SIZE = 0x4000; // 16 KiB (USB 2.0 HS baseline)
static constexpr size_t CHUNK_SIZE_USB3 = 512u << 10; // 512 KiB (USB 3.0 SS)
static constexpr size_t SMALL_CHUNK_SIZE = 0x1000; // 4 KiB
// Timeouts
static constexpr int TIMEOUT_CMD_MS = 5000; // command / response containers
static constexpr int TIMEOUT_READ_MS = 10000; // bulk read per chunk
// go-mtpfs uses d.Timeout = 2000 ms for every bulk transfer.
// 2 s at 1 MB/s = 2 GB/s worth of leeway; generous but not infinitely blocking.
static constexpr int TIMEOUT_WRITE_MS = 2000; // bulk write per chunk
// After the last byte of a file is sent, DBI/Sphaira verify NCAs, write to
// the content database, update storage info, etc. On a slow microSD this can
// take well over 10 seconds, so we give it 3 minutes before calling it dead.
static constexpr int TIMEOUT_INSTALL_MS = 180000;
static constexpr int CONNECT_TIMEOUT_MS = 10000;
static constexpr int TIMEOUT_MS = TIMEOUT_CMD_MS;
// ─── High-level data structures ───────────────────────────────────────────────
struct StorageInfo {
uint16_t storage_type;
uint16_t filesystem_type;
uint16_t access_capability;
uint64_t max_capacity;
uint64_t free_space_bytes;
uint32_t free_space_objects;
std::string description;
std::string volume_id;
};
struct ObjectInfo {
uint32_t storage_id;
uint16_t object_format;
uint16_t protection_status;
uint64_t compressed_size; // 64-bit to handle files > 4 GiB
uint32_t parent_object;
uint16_t association_type;
uint32_t association_desc;
uint32_t sequence_number;
std::string filename;
std::string date_created;
std::string date_modified;
bool is_directory() const {
return object_format == static_cast<uint16_t>(ObjectFormat::Association);
}
};
struct DeviceInfo {
uint16_t standard_version;
uint32_t vendor_extension_id;
uint16_t vendor_extension_version;
std::string vendor_extension_desc;
uint16_t functional_mode;
std::vector<uint16_t> operations_supported;
std::vector<uint16_t> events_supported;
std::vector<uint16_t> device_props_supported;
std::vector<uint16_t> capture_formats;
std::vector<uint16_t> playback_formats;
std::string manufacturer;
std::string model;
std::string device_version;
std::string serial_number;
};
// ─── Exception ────────────────────────────────────────────────────────────────
class MTPException : public std::runtime_error {
public:
MTPException(ResponseCode code, std::string msg)
: std::runtime_error(std::move(msg)), code_(code) {}
ResponseCode code() const { return code_; }
static const char* code_name(ResponseCode c) {
switch (c) {
case ResponseCode::OK: return "OK";
case ResponseCode::GeneralError: return "General Error";
case ResponseCode::SessionNotOpen: return "Session Not Open";
case ResponseCode::OperationNotSupported: return "Operation Not Supported";
case ResponseCode::ParameterNotSupported: return "Parameter Not Supported";
case ResponseCode::IncompleteTransfer: return "Incomplete Transfer";
case ResponseCode::InvalidStorageID: return "Invalid Storage ID";
case ResponseCode::InvalidObjectHandle: return "Invalid Object Handle";
case ResponseCode::StoreFull: return "Store Full";
case ResponseCode::AccessDenied: return "Access Denied";
case ResponseCode::DeviceBusy: return "Device Busy";
case ResponseCode::InvalidParentObject: return "Invalid Parent Object";
case ResponseCode::InvalidParameter: return "Invalid Parameter";
case ResponseCode::SessionAlreadyOpen: return "Session Already Open";
case ResponseCode::TransactionCancelled: return "Transaction Cancelled";
default: return "Unknown Error";
}
}
private:
ResponseCode code_;
};
} // namespace mtp
+603
View File
@@ -0,0 +1,603 @@
#include "MTPSession.hpp"
#include <cassert>
#include <cstring>
#include <fstream>
#include <stdexcept>
#include <format>
#include <array>
namespace mtp {
// ─── UTF-16LE MTP string → UTF-8 ─────────────────────────────────────────────
// MTP strings: 1 byte length (chars including null), then length×2 bytes UTF-16LE.
static std::string decode_mtp_string(const uint8_t*& p, const uint8_t* end) {
if (p >= end) return {};
uint8_t nchars = *p++;
if (nchars == 0) return {};
std::string out;
out.reserve(nchars);
for (uint8_t i = 0; i < nchars && p + 1 < end; ++i) {
uint16_t c = static_cast<uint16_t>(p[0]) | (static_cast<uint16_t>(p[1]) << 8);
p += 2;
if (c == 0) break;
if (c < 0x80) {
out += static_cast<char>(c);
} else if (c < 0x800) {
out += static_cast<char>(0xC0 | (c >> 6));
out += static_cast<char>(0x80 | (c & 0x3F));
} else {
out += static_cast<char>(0xE0 | (c >> 12));
out += static_cast<char>(0x80 | ((c >> 6) & 0x3F));
out += static_cast<char>(0x80 | (c & 0x3F));
}
}
return out;
}
// ─── Read a uint16 array: [4-byte count][count × uint16] ─────────────────────
static std::vector<uint16_t> decode_u16_array(const uint8_t*& p, const uint8_t* end) {
if (p + 4 > end) return {};
uint32_t n = p[0] | (p[1]<<8) | (p[2]<<16) | (p[3]<<24);
p += 4;
std::vector<uint16_t> v(n);
for (auto& x : v) {
if (p + 2 > end) break;
x = static_cast<uint16_t>(p[0]) | (static_cast<uint16_t>(p[1]) << 8);
p += 2;
}
return v;
}
// ─── Read a uint32 array ──────────────────────────────────────────────────────
static std::vector<uint32_t> decode_u32_array(const uint8_t*& p, const uint8_t* end) {
if (p + 4 > end) return {};
uint32_t n = p[0] | (p[1]<<8) | (p[2]<<16) | (p[3]<<24);
p += 4;
std::vector<uint32_t> v(n);
for (auto& x : v) {
if (p + 4 > end) break;
x = p[0] | (p[1]<<8) | (p[2]<<16) | (p[3]<<24);
p += 4;
}
return v;
}
// ─── Little-endian helpers ────────────────────────────────────────────────────
static void put_le32(uint8_t* dst, uint32_t v) {
dst[0] = v; dst[1] = v>>8; dst[2] = v>>16; dst[3] = v>>24;
}
static uint32_t get_le32(const uint8_t* p) {
return p[0] | (p[1]<<8) | (p[2]<<16) | (p[3]<<24);
}
static uint16_t get_le16(const uint8_t* p) {
return static_cast<uint16_t>(p[0]) | (static_cast<uint16_t>(p[1])<<8);
}
static uint64_t get_le64(const uint8_t* p) {
uint64_t lo = get_le32(p), hi = get_le32(p+4);
return lo | (hi << 32);
}
// ─── MTPSession ───────────────────────────────────────────────────────────────
MTPSession::MTPSession() {
if (libusb_init(&ctx_) != LIBUSB_SUCCESS)
throw std::runtime_error("libusb_init failed");
#ifdef NDEBUG
libusb_set_option(ctx_, LIBUSB_OPTION_LOG_LEVEL, LIBUSB_LOG_LEVEL_WARNING);
#else
libusb_set_option(ctx_, LIBUSB_OPTION_LOG_LEVEL, LIBUSB_LOG_LEVEL_INFO);
#endif
}
MTPSession::~MTPSession() {
if (connected_) disconnect();
if (ctx_) libusb_exit(ctx_);
}
// ─── Device detection ─────────────────────────────────────────────────────────
bool MTPSession::find_device() {
libusb_device** list = nullptr;
ssize_t cnt = libusb_get_device_list(ctx_, &list);
if (cnt < 0) return false;
libusb_device* found = nullptr;
for (ssize_t i = 0; i < cnt; ++i) {
libusb_device* dev = list[i];
libusb_device_descriptor desc{};
if (libusb_get_device_descriptor(dev, &desc) != LIBUSB_SUCCESS) continue;
// Match by Nintendo VID + known Switch PIDs first
bool is_switch = (desc.idVendor == NINTENDO_VID)
&& (desc.idProduct == SWITCH_PID_V1
|| desc.idProduct == SWITCH_PID_V2);
// Also accept any device advertising MTP class/subclass/protocol
bool is_mtp_class = false;
if (!is_switch) {
for (uint8_t c = 0; c < desc.bNumConfigurations; ++c) {
libusb_config_descriptor* cfg = nullptr;
if (libusb_get_config_descriptor(dev, c, &cfg) != LIBUSB_SUCCESS) continue;
for (uint8_t i2 = 0; i2 < cfg->bNumInterfaces; ++i2) {
for (int a = 0; a < cfg->interface[i2].num_altsetting; ++a) {
const auto& alt = cfg->interface[i2].altsetting[a];
if (alt.bInterfaceClass == MTP_CLASS
&& alt.bInterfaceSubClass == MTP_SUBCLASS
&& alt.bInterfaceProtocol == MTP_PROTOCOL) {
is_mtp_class = true;
}
}
}
libusb_free_config_descriptor(cfg);
if (is_mtp_class) break;
}
}
if (is_switch || is_mtp_class) {
found = dev;
libusb_ref_device(found);
vendor_id_ = desc.idVendor;
product_id_ = desc.idProduct;
break;
}
}
libusb_free_device_list(list, 1);
if (!found) return false;
// Try to get a string descriptor for a friendly name
libusb_device_handle* h = nullptr;
if (libusb_open(found, &h) == LIBUSB_SUCCESS) {
char buf[256]{};
libusb_device_descriptor desc{};
libusb_get_device_descriptor(found, &desc);
if (desc.iProduct)
libusb_get_string_descriptor_ascii(h, desc.iProduct,
reinterpret_cast<unsigned char*>(buf), sizeof(buf));
device_name_ = buf[0] ? buf : "Nintendo Switch";
libusb_close(h);
}
libusb_unref_device(found);
return true;
}
USBEndpoints MTPSession::find_endpoints(libusb_device* dev) {
libusb_config_descriptor* cfg = nullptr;
if (libusb_get_active_config_descriptor(dev, &cfg) != LIBUSB_SUCCESS)
throw MTPException(ResponseCode::GeneralError, "Cannot read USB config descriptor");
USBEndpoints ep{};
bool ok = false;
for (uint8_t i = 0; i < cfg->bNumInterfaces && !ok; ++i) {
for (int a = 0; a < cfg->interface[i].num_altsetting && !ok; ++a) {
const auto& alt = cfg->interface[i].altsetting[a];
if (alt.bInterfaceClass != MTP_CLASS ||
alt.bInterfaceSubClass != MTP_SUBCLASS ||
alt.bInterfaceProtocol != MTP_PROTOCOL) continue;
ep.interface_num = alt.bInterfaceNumber;
for (uint8_t e = 0; e < alt.bNumEndpoints; ++e) {
const auto& ed = alt.endpoint[e];
uint8_t type = ed.bmAttributes & LIBUSB_TRANSFER_TYPE_MASK;
bool in = (ed.bEndpointAddress & LIBUSB_ENDPOINT_DIR_MASK) == LIBUSB_ENDPOINT_IN;
if (type == LIBUSB_TRANSFER_TYPE_BULK && in && !ep.bulk_in)
ep.bulk_in = ed.bEndpointAddress;
if (type == LIBUSB_TRANSFER_TYPE_BULK && !in && !ep.bulk_out) {
ep.bulk_out = ed.bEndpointAddress;
ep.bulk_out_max_pkt = ed.wMaxPacketSize;
}
if (type == LIBUSB_TRANSFER_TYPE_INTERRUPT && in)
ep.interrupt_in = ed.bEndpointAddress;
}
ok = (ep.bulk_in && ep.bulk_out);
}
}
libusb_free_config_descriptor(cfg);
if (!ok)
throw MTPException(ResponseCode::GeneralError, "Could not locate MTP bulk endpoints");
return ep;
}
// ─── Connect ──────────────────────────────────────────────────────────────────
DeviceInfo MTPSession::connect() {
if (!find_device())
throw MTPException(ResponseCode::GeneralError, "Nintendo Switch not found on USB bus");
libusb_device** list = nullptr;
ssize_t cnt = libusb_get_device_list(ctx_, &list);
if (cnt < 0)
throw MTPException(ResponseCode::GeneralError, "libusb_get_device_list failed");
libusb_device* dev = nullptr;
for (ssize_t i = 0; i < cnt; ++i) {
libusb_device_descriptor desc{};
libusb_get_device_descriptor(list[i], &desc);
if (desc.idVendor == vendor_id_ && desc.idProduct == product_id_) {
dev = list[i];
break;
}
}
if (!dev) {
libusb_free_device_list(list, 1);
throw MTPException(ResponseCode::GeneralError, "Device disappeared after detection");
}
ep_ = find_endpoints(dev);
if (libusb_open(dev, &handle_) != LIBUSB_SUCCESS) {
libusb_free_device_list(list, 1);
throw MTPException(ResponseCode::GeneralError, "Cannot open USB device");
}
libusb_free_device_list(list, 1);
// Detach kernel driver if attached (macOS shouldn't need this but be defensive)
if (libusb_kernel_driver_active(handle_, ep_.interface_num) == 1)
libusb_detach_kernel_driver(handle_, ep_.interface_num);
int r = libusb_claim_interface(handle_, ep_.interface_num);
if (r != LIBUSB_SUCCESS)
throw MTPException(ResponseCode::GeneralError,
std::format("Cannot claim USB interface: {}", libusb_error_name(r)));
// Clear any stale halt/stall state left over from a previous session or
// failed transfer — android-file-transfer-linux always does this before use.
libusb_clear_halt(handle_, ep_.bulk_in);
libusb_clear_halt(handle_, ep_.bulk_out);
connected_.store(true);
// ── MTP Session open ──────────────────────────────────────────────────────
// GetDeviceInfo doesn't require an open session
send_command(OpCode::GetDeviceInfo);
auto raw_info = receive_data();
auto cmd_resp = receive_response();
if (cmd_resp.code != ResponseCode::OK)
throw MTPException(cmd_resp.code, "GetDeviceInfo failed");
// OpenSession
uint32_t sid = SESSION_ID;
send_command(OpCode::OpenSession, std::span<const uint32_t>{&sid, 1});
auto open_resp = receive_response();
if (open_resp.code != ResponseCode::OK
&& open_resp.code != ResponseCode::SessionAlreadyOpen)
throw MTPException(open_resp.code, "OpenSession failed");
// Parse DeviceInfo blob
const uint8_t* p = raw_info.data();
const uint8_t* end = p + raw_info.size();
// Skip the 12-byte container header that receive_data includes
if (raw_info.size() > CONTAINER_HEADER_SIZE) p += CONTAINER_HEADER_SIZE;
DeviceInfo di{};
if (p + 2 <= end) { di.standard_version = get_le16(p); p += 2; }
if (p + 4 <= end) { di.vendor_extension_id = get_le32(p); p += 4; }
if (p + 2 <= end) { di.vendor_extension_version = get_le16(p); p += 2; }
di.vendor_extension_desc = decode_mtp_string(p, end);
if (p + 2 <= end) { di.functional_mode = get_le16(p); p += 2; }
di.operations_supported = decode_u16_array(p, end);
di.events_supported = decode_u16_array(p, end);
di.device_props_supported = decode_u16_array(p, end);
di.capture_formats = decode_u16_array(p, end);
di.playback_formats = decode_u16_array(p, end);
di.manufacturer = decode_mtp_string(p, end);
di.model = decode_mtp_string(p, end);
di.device_version = decode_mtp_string(p, end);
di.serial_number = decode_mtp_string(p, end);
device_info_ = di; // cache so MTPOperations can query supported ops
return di;
}
// ─── Disconnect ───────────────────────────────────────────────────────────────
void MTPSession::disconnect() {
if (!connected_.load()) return;
connected_.store(false);
try {
send_command(OpCode::CloseSession);
receive_response(); // best-effort; ignore result
} catch (...) {}
if (handle_) {
libusb_release_interface(handle_, ep_.interface_num);
libusb_close(handle_);
handle_ = nullptr;
}
}
// ─── Raw USB I/O ──────────────────────────────────────────────────────────────
// Returns true if the libusb error code means the device is physically gone
static bool is_disconnect_error(int r) {
return r == LIBUSB_ERROR_NO_DEVICE
|| r == LIBUSB_ERROR_IO
|| r == LIBUSB_ERROR_PIPE
|| r == LIBUSB_ERROR_OTHER;
}
void MTPSession::bulk_write(std::span<const uint8_t> data, int timeout_ms) {
// One libusb_bulk_transfer per call — never split a transfer across multiple
// calls. If a call times out mid-transfer, calling bulk_transfer again on the
// remaining bytes starts a new USB bulk transaction; the device sees a broken
// packet boundary and the endpoint enters an inconsistent state.
// With 64 KiB chunks each call completes in ~1 ms at USB 2.0 speeds, so
// the timeout is a genuine error signal, not a flow-control retry trigger.
int transferred = 0;
int r = libusb_bulk_transfer(
handle_, ep_.bulk_out,
const_cast<uint8_t*>(data.data()),
static_cast<int>(data.size()),
&transferred, timeout_ms);
if (r != LIBUSB_SUCCESS) {
if (is_disconnect_error(r)) {
connected_.store(false);
throw MTPException(ResponseCode::GeneralError,
"Switch disconnected during transfer — the app may have cancelled "
"the install (e.g. wrong file format) or the USB cable was unplugged");
}
if (r == LIBUSB_ERROR_TIMEOUT)
throw MTPException(ResponseCode::GeneralError,
std::format("USB write timeout ({}ms, {} bytes) — "
"Switch may be busy writing to storage", timeout_ms,
static_cast<int>(data.size())));
throw MTPException(ResponseCode::GeneralError,
std::format("USB write error: {}", libusb_error_name(r)));
}
if (transferred != static_cast<int>(data.size()))
throw MTPException(ResponseCode::IncompleteTransfer,
std::format("USB write: sent {} of {} bytes", transferred, data.size()));
}
std::vector<uint8_t> MTPSession::bulk_read(size_t max_bytes, int timeout_ms) {
std::vector<uint8_t> buf(max_bytes);
int transferred = 0;
int r = libusb_bulk_transfer(
handle_, ep_.bulk_in,
buf.data(), static_cast<int>(max_bytes),
&transferred, timeout_ms);
if (r != LIBUSB_SUCCESS && r != LIBUSB_ERROR_OVERFLOW) {
if (is_disconnect_error(r)) {
connected_.store(false);
throw MTPException(ResponseCode::GeneralError,
"Switch disconnected during read");
}
if (r == LIBUSB_ERROR_TIMEOUT)
throw MTPException(ResponseCode::GeneralError,
std::format("USB read timeout after {}ms", timeout_ms));
throw MTPException(ResponseCode::GeneralError,
std::format("USB read error: {}", libusb_error_name(r)));
}
buf.resize(static_cast<size_t>(transferred));
return buf;
}
// ─── MTP container I/O ────────────────────────────────────────────────────────
void MTPSession::send_command(OpCode op, std::span<const uint32_t> params, uint32_t* out_tid) {
last_op_code_ = static_cast<uint16_t>(op); // echo in subsequent data container
uint32_t tid = next_transaction_id();
if (out_tid) *out_tid = tid;
size_t total = CONTAINER_HEADER_SIZE + params.size() * 4;
std::vector<uint8_t> buf(total, 0);
// Container layout: [0..3]=length [4..5]=type [6..7]=code [8..11]=tid [12+]=params
put_le32(buf.data() + 0, static_cast<uint32_t>(total));
buf[4] = static_cast<uint8_t>(static_cast<uint16_t>(ContainerType::Command) & 0xFF);
buf[5] = static_cast<uint8_t>(static_cast<uint16_t>(ContainerType::Command) >> 8);
buf[6] = static_cast<uint8_t>(static_cast<uint16_t>(op) & 0xFF);
buf[7] = static_cast<uint8_t>(static_cast<uint16_t>(op) >> 8);
put_le32(buf.data() + 8, tid);
for (size_t i = 0; i < params.size(); ++i)
put_le32(buf.data() + CONTAINER_HEADER_SIZE + i * 4, params[i]);
bulk_write(buf, TIMEOUT_CMD_MS);
}
void MTPSession::send_data(std::span<const uint8_t> payload) {
// MTP spec §6.4: data container code field must echo the initiating command's opcode.
// DBI and some other implementations validate this and return OperationNotSupported
// if it's wrong (they use it to route the payload to the right handler).
uint32_t tid = transaction_id_;
size_t total = CONTAINER_HEADER_SIZE + payload.size();
std::vector<uint8_t> buf(total);
put_le32(buf.data(), static_cast<uint32_t>(total));
buf[4] = static_cast<uint8_t>(static_cast<uint16_t>(ContainerType::Data) & 0xFF);
buf[5] = static_cast<uint8_t>(static_cast<uint16_t>(ContainerType::Data) >> 8);
buf[6] = static_cast<uint8_t>(last_op_code_ & 0xFF);
buf[7] = static_cast<uint8_t>(last_op_code_ >> 8);
put_le32(buf.data() + 8, tid);
std::memcpy(buf.data() + CONTAINER_HEADER_SIZE, payload.data(), payload.size());
bulk_write(buf, TIMEOUT_WRITE_MS);
}
std::vector<uint8_t> MTPSession::receive_data() {
// First read: get container header + however much data fits in one transfer
auto first = bulk_read(CHUNK_SIZE, TIMEOUT_READ_MS);
if (first.size() < CONTAINER_HEADER_SIZE)
throw MTPException(ResponseCode::GeneralError, "Truncated MTP container header");
uint32_t total_length = get_le32(first.data());
uint16_t type = get_le16(first.data() + 4);
if (static_cast<ContainerType>(type) == ContainerType::Response) {
// Device sent a response instead of data — parse and throw
ResponseCode rc = static_cast<ResponseCode>(get_le16(first.data() + 6));
if (rc != ResponseCode::OK)
throw MTPException(rc, MTPException::code_name(rc));
return first;
}
std::vector<uint8_t> data = std::move(first);
// Read remaining chunks if total_length > what we received
while (data.size() < total_length) {
size_t remaining = total_length - data.size();
auto chunk = bulk_read(std::min(remaining, CHUNK_SIZE), TIMEOUT_READ_MS);
if (chunk.empty()) break;
data.insert(data.end(), chunk.begin(), chunk.end());
}
return data;
}
CommandResponse MTPSession::receive_response(int timeout_ms) {
auto buf = bulk_read(SMALL_CHUNK_SIZE, timeout_ms);
if (buf.size() < CONTAINER_HEADER_SIZE)
throw MTPException(ResponseCode::GeneralError, "Truncated MTP response");
CommandResponse resp{};
resp.code = static_cast<ResponseCode>(get_le16(buf.data() + 6));
uint32_t length = get_le32(buf.data());
size_t n_params = (length > CONTAINER_HEADER_SIZE)
? (length - CONTAINER_HEADER_SIZE) / 4 : 0;
for (size_t i = 0; i < n_params && CONTAINER_HEADER_SIZE + i*4+4 <= buf.size(); ++i)
resp.params.push_back(get_le32(buf.data() + CONTAINER_HEADER_SIZE + i*4));
return resp;
}
CommandResponse MTPSession::command(OpCode op, std::span<const uint32_t> params) {
send_command(op, params);
return receive_response();
}
// ─── Chunked file receive (GetObject) ─────────────────────────────────────────
void MTPSession::receive_data_to_file(const std::string& dest_path,
uint64_t expected_size,
const ProgressFn& progress,
std::atomic<bool>& cancel) {
std::ofstream ofs(dest_path, std::ios::binary | std::ios::trunc);
if (!ofs) throw MTPException(ResponseCode::GeneralError,
std::format("Cannot open destination file: {}", dest_path));
// Read the header chunk
auto header_chunk = bulk_read(CHUNK_SIZE, TIMEOUT_READ_MS);
if (header_chunk.size() < CONTAINER_HEADER_SIZE)
throw MTPException(ResponseCode::GeneralError, "Truncated data container header");
uint32_t total_length = get_le32(header_chunk.data());
uint64_t payload_size = (total_length > CONTAINER_HEADER_SIZE)
? total_length - CONTAINER_HEADER_SIZE : 0;
// Write payload portion of first chunk
size_t first_payload = header_chunk.size() > CONTAINER_HEADER_SIZE
? header_chunk.size() - CONTAINER_HEADER_SIZE : 0;
if (first_payload)
ofs.write(reinterpret_cast<const char*>(header_chunk.data() + CONTAINER_HEADER_SIZE),
static_cast<std::streamsize>(first_payload));
uint64_t written = first_payload;
if (progress) progress(written, expected_size ? expected_size : payload_size);
while (written < payload_size) {
if (cancel.load()) {
ofs.close();
std::remove(dest_path.c_str());
throw MTPException(ResponseCode::TransactionCancelled, "Transfer cancelled");
}
size_t want = std::min<uint64_t>(CHUNK_SIZE, payload_size - written);
auto chunk = bulk_read(want, TIMEOUT_READ_MS);
if (chunk.empty()) break;
ofs.write(reinterpret_cast<const char*>(chunk.data()),
static_cast<std::streamsize>(chunk.size()));
written += chunk.size();
if (progress) progress(written, expected_size ? expected_size : payload_size);
}
ofs.close();
}
// ─── Chunked file send (SendObject) ───────────────────────────────────────────
void MTPSession::send_data_from_file(const std::string& src_path,
uint64_t file_size,
OpCode op,
const ProgressFn& progress,
std::atomic<bool>& cancel) {
std::ifstream ifs(src_path, std::ios::binary);
if (!ifs) throw MTPException(ResponseCode::GeneralError,
std::format("Cannot open source file: {}", src_path));
uint32_t tid = transaction_id_;
uint64_t total = CONTAINER_HEADER_SIZE + file_size;
uint32_t wire_len = (total > 0xFFFFFFFFu) ? 0xFFFFFFFFu
: static_cast<uint32_t>(total);
// Build 12-byte container header
std::array<uint8_t, CONTAINER_HEADER_SIZE> hdr{};
put_le32(hdr.data(), wire_len);
hdr[4] = static_cast<uint8_t>(static_cast<uint16_t>(ContainerType::Data) & 0xFF);
hdr[5] = static_cast<uint8_t>(static_cast<uint16_t>(ContainerType::Data) >> 8);
hdr[6] = static_cast<uint8_t>(static_cast<uint16_t>(op) & 0xFF);
hdr[7] = static_cast<uint8_t>(static_cast<uint16_t>(op) >> 8);
put_le32(hdr.data() + 8, tid);
// ── First USB packet ────────────────────────────────────────────────────
// go-mtpfs sends exactly ONE USB max-packet as the first write:
// header (12 bytes) + first (max_packet_size - 12) bytes of file data.
// This gives the device a clean full USB packet containing the MTP header
// followed immediately by data, matching how every real MTP client behaves.
// Sending a 12-byte-only short packet (previous approach) causes DBI/Sphaira
// to think the data phase ended before any file bytes were delivered.
const size_t max_pkt = static_cast<size_t>(ep_.bulk_out_max_pkt); // 512 HS / 1024 SS
size_t first_data_want = max_pkt > CONTAINER_HEADER_SIZE
? max_pkt - CONTAINER_HEADER_SIZE : 0;
first_data_want = static_cast<size_t>(
std::min<uint64_t>(first_data_want, file_size));
std::vector<uint8_t> first_pkt(CONTAINER_HEADER_SIZE + first_data_want);
std::memcpy(first_pkt.data(), hdr.data(), CONTAINER_HEADER_SIZE);
if (first_data_want > 0)
ifs.read(reinterpret_cast<char*>(first_pkt.data() + CONTAINER_HEADER_SIZE),
static_cast<std::streamsize>(first_data_want));
size_t first_got = static_cast<size_t>(ifs.gcount());
first_pkt.resize(CONTAINER_HEADER_SIZE + first_got);
bulk_write(first_pkt, TIMEOUT_WRITE_MS);
uint64_t sent = first_got;
if (progress) progress(sent, file_size);
// ── Subsequent chunks ────────────────────────────────────────────────────
// USB 3.0 SuperSpeed (max_pkt = 1024): use 512 KiB chunks — 32× larger than
// the go-mtpfs 16 KiB baseline. Each libusb_bulk_transfer call costs ~100 µs
// of host-side overhead; fewer, larger calls are needed to saturate USB 3.0.
// USB 2.0 High Speed (max_pkt = 512): keep 16 KiB per go-mtpfs.
const size_t xfer_chunk = (max_pkt >= 1024) ? CHUNK_SIZE_USB3 : CHUNK_SIZE;
size_t last_write_size = first_pkt.size();
std::vector<uint8_t> buf(xfer_chunk);
while (sent < file_size) {
if (cancel.load())
throw MTPException(ResponseCode::TransactionCancelled, "Transfer cancelled");
size_t want = static_cast<size_t>(std::min<uint64_t>(xfer_chunk, file_size - sent));
ifs.read(reinterpret_cast<char*>(buf.data()), static_cast<std::streamsize>(want));
auto got = static_cast<size_t>(ifs.gcount());
if (got == 0) break;
// Use a longer per-chunk timeout for USB 3.0 large chunks:
// 512 KiB at even 1 MB/s = 512 ms; 10 s gives plenty of margin.
const int chunk_timeout = (max_pkt >= 1024) ? 10000 : TIMEOUT_WRITE_MS;
bulk_write(std::span<const uint8_t>(buf.data(), got), chunk_timeout);
last_write_size = got;
sent += got;
if (progress) progress(sent, file_size);
if (cancel.load())
throw MTPException(ResponseCode::TransactionCancelled, "Transfer cancelled");
}
// ── Zero-length packet ─────────────────────────────────────────────────
// go-mtpfs: "if lastTransfer % packetSize == 0, write a short packet just
// to be sure." Without it, a packet-size-aligned last chunk has no natural
// short-packet terminator and the device may hang waiting for more data.
if (last_write_size % max_pkt == 0) {
int dummy_tr = 0;
libusb_bulk_transfer(handle_, ep_.bulk_out, nullptr, 0, &dummy_tr, 250);
}
}
} // namespace mtp
+125
View File
@@ -0,0 +1,125 @@
#pragma once
#include "MTPProtocol.hpp"
#include <libusb.h>
#include <atomic>
#include <functional>
#include <mutex>
#include <span>
#include <vector>
namespace mtp {
struct CommandResponse {
ResponseCode code;
std::vector<uint32_t> params;
};
struct USBEndpoints {
uint8_t bulk_in = 0;
uint8_t bulk_out = 0;
uint8_t interrupt_in = 0;
int interface_num = 0;
int bulk_out_max_pkt = 512; // wMaxPacketSize: 512 for USB 2.0 HS, 1024 for SS
};
using ProgressFn = std::function<void(uint64_t done, uint64_t total)>;
// MTPSession owns the libusb handle, USB endpoints, and MTP session state.
// All operations are serialized through the session mutex — MTP is strictly
// single-threaded from the device's perspective.
class MTPSession {
public:
MTPSession();
~MTPSession();
MTPSession(const MTPSession&) = delete;
MTPSession& operator=(const MTPSession&) = delete;
// Scan USB bus, open the Switch, negotiate MTP session.
// Returns DeviceInfo on success; throws MTPException on failure.
DeviceInfo connect();
// Close MTP session and release USB resources.
void disconnect();
bool is_connected() const { return connected_.load(); }
// ── Low-level MTP API (called by MTPOperations) ──────────────────────────
// Send a command container with up to 5 parameters.
// Returns the assigned transaction_id via the optional out-param.
void send_command(OpCode op,
std::span<const uint32_t> params = {},
uint32_t* out_tid = nullptr);
// Send a data container following a command.
void send_data(std::span<const uint8_t> data);
// Receive a data container into a heap buffer.
std::vector<uint8_t> receive_data();
// Chunked receive directly into a file — avoids buffering whole file in RAM.
void receive_data_to_file(const std::string& dest_path,
uint64_t expected_size,
const ProgressFn& progress,
std::atomic<bool>& cancel);
// Chunked send of a file as a data container.
// op must be the OpCode from the preceding send_command() call so the
// data container echoes the correct operation code (MTP spec §6.4).
void send_data_from_file(const std::string& src_path,
uint64_t file_size,
OpCode op,
const ProgressFn& progress,
std::atomic<bool>& cancel);
// Receive the response container after a command (+optional data exchange).
// Pass TIMEOUT_INSTALL_MS after large file transfers so post-install DB work
// on the Switch side has time to complete before we declare a timeout error.
CommandResponse receive_response(int timeout_ms = TIMEOUT_CMD_MS);
// Convenience: send command then immediately receive response (no data phase).
CommandResponse command(OpCode op, std::span<const uint32_t> params = {});
uint32_t next_transaction_id() { return ++transaction_id_; }
// Accessors for device metadata
uint16_t vendor_id() const { return vendor_id_; }
uint16_t product_id() const { return product_id_; }
std::string device_name() const { return device_name_; }
const DeviceInfo& device_info() const { return device_info_; }
bool supports_op(OpCode op) const {
auto code = static_cast<uint16_t>(op);
for (auto c : device_info_.operations_supported)
if (c == code) return true;
return false;
}
// Serialize all MTP access through this mutex (TransferEngine uses it)
std::mutex& mutex() { return mtx_; }
private:
bool find_device();
USBEndpoints find_endpoints(libusb_device* dev);
// Raw USB bulk I/O
std::vector<uint8_t> bulk_read(size_t max_bytes, int timeout_ms = TIMEOUT_MS);
void bulk_write(std::span<const uint8_t> data, int timeout_ms = TIMEOUT_MS);
libusb_context* ctx_ = nullptr;
libusb_device_handle* handle_ = nullptr;
USBEndpoints ep_;
uint32_t transaction_id_ = 0;
uint16_t last_op_code_ = 0;
uint16_t vendor_id_ = 0;
uint16_t product_id_ = 0;
DeviceInfo device_info_;
std::string device_name_;
std::atomic<bool> connected_{false};
mutable std::mutex mtx_;
};
} // namespace mtp
+180
View File
@@ -0,0 +1,180 @@
#include "TransferEngine.hpp"
#include <filesystem>
#include <format>
#include <random>
#include <sstream>
#include <iomanip>
namespace fs = std::filesystem;
namespace transfer {
std::string TransferEngine::make_id() {
static std::mt19937_64 rng{std::random_device{}()};
static std::uniform_int_distribution<uint64_t> dist;
std::ostringstream ss;
ss << std::hex << std::setw(16) << std::setfill('0') << dist(rng);
return ss.str();
}
TransferEngine::TransferEngine(mtp::MTPSession& session)
: session_(session), ops_(session)
{
worker_ = std::jthread([this](std::stop_token) { worker_loop(); });
}
TransferEngine::~TransferEngine() {
shutdown_.store(true);
cv_.notify_all();
// std::jthread destructor requests stop and joins automatically
}
std::shared_ptr<TransferItem>
TransferEngine::enqueue_download(uint32_t handle, uint32_t storage_id,
const std::string& dest_path,
const std::string& filename,
uint64_t expected_size) {
auto item = std::make_shared<TransferItem>();
item->id = make_id();
item->direction = Direction::Download;
item->filename = filename;
item->total_bytes = expected_size;
item->object_handle = handle;
item->storage_id = storage_id;
item->dest_path = dest_path;
std::lock_guard lock(mtx_);
history_.push_back(item);
pending_.push(item);
cv_.notify_one();
return item;
}
std::shared_ptr<TransferItem>
TransferEngine::enqueue_upload(const std::string& src_path,
uint32_t parent_handle, uint32_t storage_id,
const std::string& filename,
uint64_t file_size) {
auto item = std::make_shared<TransferItem>();
item->id = make_id();
item->direction = Direction::Upload;
item->filename = filename;
item->total_bytes = file_size;
item->src_path = src_path;
item->parent_handle = parent_handle;
item->storage_id = storage_id;
std::lock_guard lock(mtx_);
history_.push_back(item);
pending_.push(item);
cv_.notify_one();
return item;
}
void TransferEngine::cancel(const std::string& transfer_id) {
std::lock_guard lock(mtx_);
for (auto& item : history_) {
if (item->id == transfer_id) {
item->cancel.store(true);
break;
}
}
}
void TransferEngine::cancel_all() {
std::lock_guard lock(mtx_);
for (auto& item : history_)
item->cancel.store(true);
}
std::vector<std::shared_ptr<TransferItem>> TransferEngine::all_transfers() const {
std::lock_guard lock(mtx_);
return history_;
}
bool TransferEngine::has_active() const {
std::lock_guard lock(mtx_);
for (auto& item : history_)
if (item->state.load() == State::Active) return true;
return false;
}
void TransferEngine::set_completion_handler(CompletionFn fn) {
std::lock_guard lock(mtx_);
completion_ = std::move(fn);
}
void TransferEngine::worker_loop() {
while (!shutdown_.load()) {
std::shared_ptr<TransferItem> item;
{
std::unique_lock lock(mtx_);
cv_.wait(lock, [&]{ return shutdown_.load() || !pending_.empty(); });
if (shutdown_.load()) break;
if (pending_.empty()) continue;
item = pending_.front();
pending_.pop();
}
if (item->cancel.load()) {
item->state.store(State::Cancelled);
continue;
}
item->state.store(State::Active);
item->started_at = std::chrono::steady_clock::now();
try {
if (item->direction == Direction::Download)
run_download(*item);
else
run_upload(*item);
if (item->cancel.load())
item->state.store(State::Cancelled);
else
item->state.store(State::Done);
} catch (const mtp::MTPException& e) {
item->error_msg = e.what();
item->state.store(State::Failed);
} catch (const std::exception& e) {
item->error_msg = e.what();
item->state.store(State::Failed);
}
item->finished_at = std::chrono::steady_clock::now();
CompletionFn cb;
{
std::lock_guard lock(mtx_);
cb = completion_;
}
if (cb) cb(*item);
}
}
void TransferEngine::run_download(TransferItem& item) {
// Ensure destination directory exists
if (auto parent = fs::path(item.dest_path).parent_path(); !parent.empty())
fs::create_directories(parent);
mtp::ProgressFn progress = [&](uint64_t done, uint64_t total) {
item.bytes_done.store(done);
if (total && !item.total_bytes) item.total_bytes = total;
};
ops_.get_object(item.object_handle, item.dest_path, item.total_bytes,
progress, item.cancel);
}
void TransferEngine::run_upload(TransferItem& item) {
mtp::ProgressFn progress = [&](uint64_t done, uint64_t /*total*/) {
item.bytes_done.store(done);
};
ops_.send_object(item.storage_id, item.parent_handle,
item.src_path, item.filename, item.total_bytes,
progress, item.cancel);
}
} // namespace transfer
+115
View File
@@ -0,0 +1,115 @@
#pragma once
#include "mtp/MTPSession.hpp"
#include "mtp/MTPOperations.hpp"
#include <atomic>
#include <chrono>
#include <condition_variable>
#include <functional>
#include <memory>
#include <mutex>
#include <queue>
#include <string>
#include <thread>
#include <vector>
namespace transfer {
enum class Direction { Download, Upload };
enum class State { Queued, Active, Done, Failed, Cancelled };
struct TransferItem {
std::string id;
std::string filename;
Direction direction;
uint64_t total_bytes = 0;
std::string error_msg;
std::atomic<uint64_t> bytes_done{0};
std::atomic<State> state{State::Queued};
std::atomic<bool> cancel{false};
std::chrono::steady_clock::time_point started_at;
std::chrono::steady_clock::time_point finished_at;
float progress() const {
if (total_bytes == 0) return 0.f;
return static_cast<float>(bytes_done.load()) / static_cast<float>(total_bytes);
}
// Bytes per second since transfer started
double speed_bps() const {
if (state.load() != State::Active) return 0.0;
auto elapsed = std::chrono::duration<double>(
std::chrono::steady_clock::now() - started_at).count();
return elapsed > 0.0 ? static_cast<double>(bytes_done.load()) / elapsed : 0.0;
}
double eta_seconds() const {
double sp = speed_bps();
if (sp <= 0) return -1.0;
uint64_t remaining = total_bytes > bytes_done.load()
? total_bytes - bytes_done.load() : 0;
return static_cast<double>(remaining) / sp;
}
// Download-specific fields
uint32_t object_handle = 0;
uint32_t storage_id = 0;
std::string dest_path;
// Upload-specific fields
std::string src_path;
uint32_t parent_handle = 0;
};
// Single-worker transfer engine. MTP is inherently serial so one worker is
// correct; the worker uses dedicated operations to keep the mutex clean.
class TransferEngine {
public:
explicit TransferEngine(mtp::MTPSession& session);
~TransferEngine();
// Returns a shared_ptr to the queued item (so the UI can observe progress).
std::shared_ptr<TransferItem>
enqueue_download(uint32_t handle, uint32_t storage_id,
const std::string& dest_path,
const std::string& filename,
uint64_t expected_size);
std::shared_ptr<TransferItem>
enqueue_upload(const std::string& src_path,
uint32_t parent_handle, uint32_t storage_id,
const std::string& filename,
uint64_t file_size);
void cancel(const std::string& transfer_id);
void cancel_all();
// Thread-safe snapshot for the UI to display
std::vector<std::shared_ptr<TransferItem>> all_transfers() const;
bool has_active() const;
using CompletionFn = std::function<void(const TransferItem&)>;
void set_completion_handler(CompletionFn fn);
private:
void worker_loop();
void run_download(TransferItem& item);
void run_upload(TransferItem& item);
mtp::MTPSession& session_;
mtp::MTPOperations ops_;
mutable std::mutex mtx_;
std::condition_variable cv_;
std::queue<std::shared_ptr<TransferItem>> pending_;
std::vector<std::shared_ptr<TransferItem>> history_; // all items ever queued
CompletionFn completion_;
std::jthread worker_;
std::atomic<bool> shutdown_{false};
static std::string make_id();
};
} // namespace transfer
+158
View File
@@ -0,0 +1,158 @@
#pragma once
#include "mtp/MTPSession.hpp"
#include "mtp/MTPOperations.hpp"
#include "mtp/MTPProtocol.hpp"
#include "transfer/TransferEngine.hpp"
#include <atomic>
#include <filesystem>
#include <memory>
#include <mutex>
#include <set>
#include <string>
#include <thread>
#include <vector>
namespace omniMTP {
namespace fs = std::filesystem;
// ── Entry in a file browser panel ────────────────────────────────────────────
struct LocalEntry {
fs::path path;
std::string name;
bool is_dir = false;
uint64_t size = 0;
std::string size_str;
std::string date_str;
};
struct LocalDrive {
std::string path;
std::string name;
bool removable = false;
};
struct RemoteEntry {
uint32_t handle = 0;
uint32_t storage_id = 0;
std::string name;
bool is_dir = false;
uint64_t size = 0;
std::string size_str;
std::string date_str;
};
// Breadcrumb stack item
struct NavEntry {
uint32_t handle = 0; // mtp::ROOT_PARENT = root of storage
uint32_t storage_id = 0;
std::string label;
};
// ── Main application ──────────────────────────────────────────────────────────
class App {
public:
App();
~App();
void init();
void shutdown();
// ── Web bridge API ────────────────────────────────────────────────────────
std::string state_json() const;
std::string current_local_path() const;
void navigate_local(const std::string& path);
void navigate_local_up();
void navigate_remote(uint32_t handle, uint32_t storage_id, const std::string& name);
void navigate_remote_up();
void navigate_remote_to(size_t idx);
void set_active_storage(uint32_t storage_id);
void request_refresh_remote();
void do_upload(const std::string& src_path);
void do_download(uint32_t handle, uint32_t storage_id, const std::string& filename, uint64_t size);
// Blocking download used by Finder drag-export (NSFilePromiseProvider).
void download_remote_to_path(uint32_t handle, uint32_t storage_id,
const std::string& dest_path, uint64_t size);
void do_cancel(const std::string& id);
void do_cancel_all();
void do_delete(uint32_t handle);
void do_create_folder(const std::string& name);
void reconnect();
bool connected() const { return connected_.load(); }
// Drop-zone rects (CSS pixels, origin top-left) updated by the web UI.
void update_drop_zones(double rx, double ry, double rw, double rh,
double lx, double ly, double lw, double lh);
// Returns "remote", "local", or "none".
std::string drop_target_at(double x, double y) const;
// Called from the OS drag-and-drop callback (may be on a different thread).
void enqueue_finder_drop(const std::string& path);
private:
// ── Local filesystem ──────────────────────────────────────────────────────
void refresh_local(const fs::path& path);
void refresh_local_drives();
std::string active_local_drive_path() const;
// ── Remote filesystem ─────────────────────────────────────────────────────
void refresh_remote();
// ── Transfer helpers ──────────────────────────────────────────────────────
void start_download(uint32_t handle, uint32_t storage_id,
const std::string& filename, uint64_t size);
void start_upload(const fs::path& src_path, uint64_t file_size);
// ── Device connection loop (runs on background thread) ────────────────────
void device_monitor_loop(std::stop_token st);
void on_device_connected(mtp::DeviceInfo di);
void on_device_disconnected();
// ── Status bar ────────────────────────────────────────────────────────────
void set_status(const std::string& msg, float duration_sec = 5.f);
// ── Finder drop processing (called from state_json) ───────────────────────
void process_finder_drops();
// ── State: connection ─────────────────────────────────────────────────────
std::unique_ptr<mtp::MTPSession> session_;
std::unique_ptr<mtp::MTPOperations> ops_;
std::unique_ptr<transfer::TransferEngine> engine_;
std::atomic<bool> connected_{false};
std::jthread monitor_thread_;
mutable std::mutex device_mtx_;
mtp::DeviceInfo device_info_;
std::vector<std::pair<uint32_t, mtp::StorageInfo>> storages_;
uint32_t active_storage_ = 0;
// ── State: local panel ────────────────────────────────────────────────────
fs::path local_path_;
std::vector<LocalEntry> local_entries_;
std::vector<LocalDrive> local_drives_;
mutable std::atomic<bool> local_needs_refresh_{false};
// ── State: remote panel ───────────────────────────────────────────────────
std::vector<NavEntry> remote_nav_stack_;
std::vector<RemoteEntry> remote_entries_;
mutable bool remote_needs_refresh_ = true;
bool remote_refreshing_ = false;
// Status bar message with timeout
std::string status_msg_;
float status_msg_timer_ = 0.f;
// Files/folders dropped from macOS Finder.
// Written from the drop callback thread, read + cleared elsewhere.
std::vector<std::string> finder_drops_;
std::mutex finder_drops_mtx_;
struct DropZone { double x = 0, y = 0, w = 0, h = 0; };
DropZone remote_drop_zone_;
DropZone local_drop_zone_;
bool drop_zones_valid_ = false;
};
} // namespace omniMTP
+537
View File
@@ -0,0 +1,537 @@
#include "App.hpp"
#import <Foundation/Foundation.h>
#include <stdexcept>
#include <cstdio>
#include <filesystem>
#include <format>
#include <algorithm>
#include <chrono>
#include <thread>
#include <sys/stat.h>
#include <cstdlib>
#include <cstring>
namespace fs = std::filesystem;
namespace omniMTP {
// ─── Static helpers ───────────────────────────────────────────────────────────
static std::string date_str_from_fs(const fs::directory_entry& e) {
struct ::stat st{};
if (::stat(e.path().c_str(), &st) == 0) {
char buf[32];
std::strftime(buf, sizeof(buf), "%b %e, %Y", std::localtime(&st.st_mtime));
return buf;
}
return "";
}
using CrumbList = std::vector<std::pair<std::string, fs::path>>;
static CrumbList build_crumbs(const fs::path& path) {
std::vector<std::pair<std::string, fs::path>> segs;
fs::path p = path;
while (true) {
auto par = p.parent_path();
if (par == p) break; // reached root — skip it
segs.insert(segs.begin(), {p.filename().string(), p});
p = par;
}
return segs;
}
static std::string jstr(const std::string& s) {
std::string r; r.reserve(s.size()+4);
for (char c : s) {
if (c=='"') r+="\\\"";
else if (c=='\\') r+="\\\\";
else if (c=='\n') r+="\\n";
else if (c=='\r') r+="\\r";
else if (c=='\t') r+="\\t";
else r+=c;
}
return r;
}
static std::string fmt_size(uint64_t b) {
if (!b) return "0 B";
if (b<1024) return std::to_string(b)+" B";
if (b<(1u<<20)) return std::to_string(b/1024)+" KB";
if (b<(1ull<<30)) { char x[24]; snprintf(x,sizeof(x),"%.1f MB",b/1048576.0); return x; }
char x[24]; snprintf(x,sizeof(x),"%.2f GB",b/1073741824.0); return x;
}
static std::string fmt_speed(double bps) {
char b[32];
if (bps>1048576) snprintf(b,sizeof(b),"%.0f MB/s",bps/1048576.0);
else snprintf(b,sizeof(b),"%.0f KB/s",bps/1024.0);
return b;
}
static std::string fmt_eta(double s) {
if (s<0) return "--:--";
int si = (int)s;
if (si<60) { char b[16]; snprintf(b,sizeof(b),"0:%02d",si); return b; }
if (si<3600) { char b[16]; snprintf(b,sizeof(b),"%d:%02d",si/60,si%60); return b; }
char b[16]; snprintf(b,sizeof(b),"%dh%02dm",si/3600,(si%3600)/60); return b;
}
// ─── App lifecycle ────────────────────────────────────────────────────────────
App::App() : local_path_(fs::path(std::getenv("HOME") ? std::getenv("HOME") : "/")) {}
App::~App() { shutdown(); }
void App::init() {
local_path_ = fs::path(std::getenv("HOME") ? std::getenv("HOME") : "/");
refresh_local(local_path_);
monitor_thread_ = std::jthread([this](std::stop_token st) { device_monitor_loop(st); });
}
void App::shutdown() {
monitor_thread_.request_stop();
engine_.reset();
if (connected_.load())
try { if (session_) session_->disconnect(); } catch (...) {}
}
// ─── Device monitor ───────────────────────────────────────────────────────────
void App::device_monitor_loop(std::stop_token st) {
while (!st.stop_requested()) {
if (!connected_.load()) {
try {
auto sess = std::make_unique<mtp::MTPSession>();
auto di = sess->connect();
{
std::lock_guard lock(device_mtx_);
session_ = std::move(sess);
ops_ = std::make_unique<mtp::MTPOperations>(*session_);
engine_ = std::make_unique<transfer::TransferEngine>(*session_);
engine_->set_completion_handler([this](const transfer::TransferItem& item) {
if (item.state.load() != transfer::State::Done) return;
if (item.direction == transfer::Direction::Download)
local_needs_refresh_.store(true);
else
remote_needs_refresh_ = true;
});
}
on_device_connected(std::move(di));
} catch (...) {}
} else {
bool transfer_active = false;
{
std::lock_guard lock(device_mtx_);
transfer_active = engine_ && engine_->has_active();
}
if (!transfer_active) {
mtp::MTPOperations* ops_raw = nullptr;
{
std::lock_guard lock(device_mtx_);
ops_raw = ops_.get();
}
if (ops_raw) {
try { ops_raw->get_storage_ids(); }
catch (...) { on_device_disconnected(); }
}
}
}
for (int i = 0; i < 8 && !st.stop_requested(); ++i)
std::this_thread::sleep_for(std::chrono::milliseconds(250));
}
}
void App::on_device_connected(mtp::DeviceInfo di) {
{
std::lock_guard lock(device_mtx_);
device_info_ = std::move(di);
storages_.clear();
remote_nav_stack_.clear();
remote_entries_.clear();
try {
auto ids = ops_->get_storage_ids();
for (auto id : ids) {
auto si = ops_->get_storage_info(id);
storages_.emplace_back(id, std::move(si));
}
if (!storages_.empty()) {
active_storage_ = storages_.front().first;
const auto& si = storages_.front().second;
std::string label = si.description.empty()
? std::format("Storage {:04X}", active_storage_)
: si.description;
remote_nav_stack_.push_back({mtp::ROOT_PARENT, active_storage_, label});
}
} catch (...) {}
}
connected_.store(true);
remote_needs_refresh_ = true;
set_status(std::format("Connected {}", device_info_.model));
refresh_remote();
}
void App::on_device_disconnected() {
{
std::lock_guard lock(device_mtx_);
if (engine_) { engine_->cancel_all(); engine_.reset(); }
ops_.reset();
if (session_) { try { session_->disconnect(); } catch (...) {} session_.reset(); }
storages_.clear();
remote_entries_.clear();
remote_nav_stack_.clear();
}
connected_.store(false);
remote_needs_refresh_ = false;
set_status("Switch disconnected");
}
// ─── Local filesystem ─────────────────────────────────────────────────────────
void App::refresh_local(const fs::path& path) {
local_entries_.clear();
try {
for (auto& e : fs::directory_iterator(path,
fs::directory_options::skip_permission_denied)) {
LocalEntry le{};
le.path = e.path();
le.name = e.path().filename().string();
le.is_dir = e.is_directory();
if (!le.is_dir) { le.size = e.file_size(); le.size_str = fmt_size(le.size); }
le.date_str = date_str_from_fs(e);
local_entries_.push_back(std::move(le));
}
std::sort(local_entries_.begin(), local_entries_.end(), [](auto& a, auto& b) {
if (a.is_dir != b.is_dir) return a.is_dir > b.is_dir;
return a.name < b.name;
});
} catch (...) {}
local_path_ = path;
}
void App::refresh_local_drives() {
local_drives_.clear();
@autoreleasepool {
NSFileManager* fm = NSFileManager.defaultManager;
NSArray* keys = @[
NSURLVolumeNameKey,
NSURLVolumeIsRemovableKey,
];
NSArray<NSURL*>* urls =
[fm mountedVolumeURLsIncludingResourceValuesForKeys:keys
options:NSVolumeEnumerationSkipHiddenVolumes];
bool has_data_volume = false;
for (NSURL* url in urls) {
if ([url.path isEqualToString:@"/System/Volumes/Data"])
has_data_volume = true;
}
for (NSURL* url in urls) {
NSString* path = url.path;
if (!path || path.length == 0) continue;
std::string mount = path.UTF8String;
if (mount == "/dev" || mount == "/home" || mount == "/net") continue;
if (mount.rfind("/private/", 0) == 0) continue;
if (mount.rfind("/System/Volumes/", 0) == 0 && mount != "/System/Volumes/Data") continue;
if (mount == "/" && has_data_volume) continue;
NSString* vol_name = nil;
NSNumber* removable = nil;
[url getResourceValue:&vol_name forKey:NSURLVolumeNameKey error:nil];
[url getResourceValue:&removable forKey:NSURLVolumeIsRemovableKey error:nil];
LocalDrive drive{};
drive.path = mount;
drive.name = vol_name.length ? vol_name.UTF8String : fs::path(mount).filename().string();
if (drive.name.empty()) drive.name = mount;
drive.removable = removable.boolValue;
local_drives_.push_back(std::move(drive));
}
}
std::sort(local_drives_.begin(), local_drives_.end(),
[](const LocalDrive& a, const LocalDrive& b) {
if (a.removable != b.removable) return a.removable < b.removable;
return a.name < b.name;
});
}
std::string App::active_local_drive_path() const {
std::error_code ec;
fs::path current = fs::weakly_canonical(local_path_, ec);
if (ec) current = local_path_;
const std::string current_str = current.string();
std::string best;
size_t best_len = 0;
for (const auto& drive : local_drives_) {
fs::path root = fs::path(drive.path);
fs::path root_canonical = fs::weakly_canonical(root, ec);
if (ec) root_canonical = root;
const std::string root_str = root_canonical.string();
const bool under =
current == root_canonical ||
current_str.rfind(root_str + "/", 0) == 0;
if (under && root_str.size() >= best_len) {
best = drive.path;
best_len = root_str.size();
}
}
if (!best.empty()) return best;
return local_drives_.empty() ? "" : local_drives_.front().path;
}
// ─── Remote filesystem ────────────────────────────────────────────────────────
void App::refresh_remote() {
if (!connected_.load() || remote_refreshing_) return;
remote_refreshing_ = true;
remote_entries_.clear();
try {
std::lock_guard lock(device_mtx_);
if (!ops_ || remote_nav_stack_.empty()) { remote_refreshing_ = false; return; }
const auto& top = remote_nav_stack_.back();
auto handles = ops_->get_object_handles(top.storage_id, top.handle);
for (auto h : handles) {
auto oi = ops_->get_object_info(h);
RemoteEntry re{};
re.handle = h;
re.storage_id = oi.storage_id;
re.name = oi.filename;
re.is_dir = oi.is_directory();
re.size = oi.compressed_size;
re.size_str = re.is_dir ? "" : fmt_size(re.size);
re.date_str = oi.date_modified.empty() ? oi.date_created : oi.date_modified;
remote_entries_.push_back(std::move(re));
}
std::sort(remote_entries_.begin(), remote_entries_.end(), [](auto& a, auto& b) {
if (a.is_dir != b.is_dir) return a.is_dir > b.is_dir;
return a.name < b.name;
});
} catch (const mtp::MTPException& e) {
set_status(std::format("Error listing: {}", e.what()));
}
remote_refreshing_ = false;
remote_needs_refresh_ = false;
}
// ─── Transfers / misc ─────────────────────────────────────────────────────────
void App::start_download(uint32_t handle, uint32_t storage_id,
const std::string& filename, uint64_t size) {
if (!engine_) return;
engine_->enqueue_download(handle, storage_id,
(local_path_ / filename).string(), filename, size);
set_status(std::format("Downloading {}", filename));
}
void App::start_upload(const fs::path& src, uint64_t sz) {
if (!engine_ || remote_nav_stack_.empty()) return;
const auto& top = remote_nav_stack_.back();
engine_->enqueue_upload(src.string(), top.handle, top.storage_id,
src.filename().string(), sz);
set_status(std::format("Uploading {}", src.filename().string()));
}
void App::set_status(const std::string& msg, float dur) {
status_msg_ = msg; status_msg_timer_ = dur;
}
void App::enqueue_finder_drop(const std::string& path) {
std::lock_guard lock(finder_drops_mtx_);
finder_drops_.push_back(path);
}
void App::update_drop_zones(double rx, double ry, double rw, double rh,
double lx, double ly, double lw, double lh) {
remote_drop_zone_ = {rx, ry, rw, rh};
local_drop_zone_ = {lx, ly, lw, lh};
drop_zones_valid_ = rw > 0 && rh > 0 && lw > 0 && lh > 0;
}
std::string App::drop_target_at(double x, double y) const {
if (!drop_zones_valid_) return "none";
auto inside = [](const DropZone& z, double px, double py) {
return px >= z.x && px <= z.x + z.w && py >= z.y && py <= z.y + z.h;
};
if (inside(remote_drop_zone_, x, y)) return "remote";
if (inside(local_drop_zone_, x, y)) return "local";
return "none";
}
// ─── Web bridge API ───────────────────────────────────────────────────────────
std::string App::current_local_path() const { return local_path_.string(); }
std::string App::state_json() const {
// Process any pending finder drops
{
const_cast<App*>(this)->process_finder_drops();
}
if (local_needs_refresh_.load()) {
local_needs_refresh_.store(false);
const_cast<App*>(this)->refresh_local(local_path_);
}
if (remote_needs_refresh_ && connected_.load()) {
remote_needs_refresh_ = false;
const_cast<App*>(this)->refresh_remote();
}
const_cast<App*>(this)->refresh_local_drives();
const std::string active_drive = active_local_drive_path();
bool conn = connected_.load();
std::string dn;
std::vector<std::pair<uint32_t,mtp::StorageInfo>> stor;
uint32_t astor;
std::vector<NavEntry> nav;
bool refreshing;
{
std::lock_guard lock(device_mtx_);
dn = device_info_.model;
stor = storages_;
astor = active_storage_;
nav = remote_nav_stack_;
refreshing = remote_refreshing_;
}
std::string j = "{";
j += "\"connected\":" + std::string(conn?"true":"false") + ",";
j += "\"deviceName\":\"" + jstr(dn) + "\",";
j += "\"storages\":[";
for (size_t i=0;i<stor.size();++i) {
auto& [id,si]=stor[i];
j+="{\"id\":"+std::to_string(id)+",\"name\":\""+jstr(si.description)+"\",";
j+="\"freeBytes\":"+std::to_string(si.free_space_bytes)+",\"freeStr\":\""+fmt_size(si.free_space_bytes)+"\",";
j+="\"totalBytes\":"+std::to_string(si.max_capacity)+",\"totalStr\":\""+fmt_size(si.max_capacity)+"\"}";
if (i+1<stor.size()) j+=",";
}
j+="],\"activeStorageId\":"+std::to_string(astor)+",";
j+="\"remoteNavStack\":[";
for (size_t i=0;i<nav.size();++i) {
j+="{\"handle\":"+std::to_string(nav[i].handle)+",\"storageId\":"+std::to_string(nav[i].storage_id)+",\"label\":\""+jstr(nav[i].label)+"\"}";
if (i+1<nav.size()) j+=",";
}
j+="],\"localDrives\":[";
for (size_t i = 0; i < local_drives_.size(); ++i) {
auto& d = local_drives_[i];
j += "{\"path\":\"" + jstr(d.path) + "\",\"name\":\"" + jstr(d.name) + "\",";
j += "\"removable\":" + std::string(d.removable ? "true" : "false") + "}";
if (i + 1 < local_drives_.size()) j += ",";
}
j += "],\"activeLocalDrive\":\"" + jstr(active_drive) + "\",";
j+="\"localPath\":\""+jstr(local_path_.string())+"\",\"localFiles\":[";
for (size_t i=0;i<local_entries_.size();++i) {
auto& e=local_entries_[i];
j+="{\"name\":\""+jstr(e.name)+"\",\"isDir\":"+std::string(e.is_dir?"true":"false")+",";
j+="\"size\":"+std::to_string(e.size)+",\"sizeStr\":\""+jstr(e.size_str)+"\",";
j+="\"date\":\""+jstr(e.date_str)+"\",\"path\":\""+jstr(e.path.string())+"\"}";
if (i+1<local_entries_.size()) j+=",";
}
j+="],\"remoteRefreshing\":"+std::string(refreshing?"true":"false")+",\"remoteFiles\":[";
for (size_t i=0;i<remote_entries_.size();++i) {
auto& e=remote_entries_[i];
j+="{\"handle\":"+std::to_string(e.handle)+",\"storageId\":"+std::to_string(e.storage_id)+",";
j+="\"name\":\""+jstr(e.name)+"\",\"isDir\":"+std::string(e.is_dir?"true":"false")+",";
j+="\"size\":"+std::to_string(e.size)+",\"sizeStr\":\""+jstr(e.size_str)+"\",\"date\":\""+jstr(e.date_str)+"\"}";
if (i+1<remote_entries_.size()) j+=",";
}
j+="],\"transfers\":[";
if (engine_) {
auto items=engine_->all_transfers();
for (size_t i=0;i<items.size();++i) {
auto& t=items[i];
auto st=t->state.load();
char pr[16]; snprintf(pr,sizeof(pr),"%.4f",t->progress());
j+="{\"id\":\""+jstr(t->id)+"\",\"filename\":\""+jstr(t->filename)+"\",";
j+="\"direction\":"+std::to_string((int)t->direction)+",\"state\":"+std::to_string((int)st)+",";
j+="\"bytesDone\":"+std::to_string(t->bytes_done.load())+",\"totalBytes\":"+std::to_string(t->total_bytes)+",";
j+="\"progress\":"+std::string(pr)+",\"speedStr\":\""+fmt_speed(t->speed_bps())+"\",";
j+="\"speedBps\":"+std::to_string((uint64_t)t->speed_bps())+",";
j+="\"etaStr\":\""+fmt_eta(t->eta_seconds())+"\",\"error\":\""+jstr(t->error_msg)+"\"}";
if (i+1<items.size()) j+=",";
}
}
j+="],\"statusMessage\":\""+jstr(status_msg_)+"\",";
char tm[16]; snprintf(tm,sizeof(tm),"%.2f",(double)status_msg_timer_);
j+="\"statusTimer\":"+std::string(tm)+"}";
return j;
}
void App::process_finder_drops() {
std::vector<std::string> drops;
{
std::lock_guard lock(finder_drops_mtx_);
drops.swap(finder_drops_);
}
for (auto& dropped : drops) {
fs::path p(dropped);
std::error_code ec;
if (fs::is_directory(p, ec)) {
refresh_local(p);
} else if (!ec && connected_.load()) {
auto sz = fs::file_size(p, ec);
if (!ec) start_upload(p, sz);
}
}
}
void App::navigate_local(const std::string& p) {
refresh_local(fs::path(p == "~" ? (std::getenv("HOME") ? std::getenv("HOME") : "/") : p));
}
void App::navigate_local_up() {
if (local_path_.has_parent_path() && local_path_ != local_path_.parent_path())
refresh_local(local_path_.parent_path());
}
void App::navigate_remote(uint32_t h, uint32_t s, const std::string& name) {
remote_nav_stack_.push_back({h, s, name});
refresh_remote();
}
void App::navigate_remote_up() {
if (remote_nav_stack_.size() > 1) {
remote_nav_stack_.pop_back();
refresh_remote();
}
}
void App::navigate_remote_to(size_t idx) {
if (idx < remote_nav_stack_.size()) {
remote_nav_stack_.resize(idx+1);
refresh_remote();
}
}
void App::set_active_storage(uint32_t id) {
std::string label;
{
std::lock_guard lock(device_mtx_);
for (auto& [sid,si]: storages_) if (sid==id) { label=si.description; break; }
active_storage_ = id;
}
remote_nav_stack_ = {{mtp::ROOT_PARENT, id, label.empty()?std::format("{:04X}",id):label}};
refresh_remote();
}
void App::request_refresh_remote() { refresh_remote(); }
void App::do_upload(const std::string& src) {
fs::path p(src);
std::error_code ec;
auto sz = fs::file_size(p, ec);
if (!ec) start_upload(p, sz);
}
void App::do_download(uint32_t h, uint32_t s, const std::string& fn, uint64_t sz) {
start_download(h, s, fn, sz);
}
void App::download_remote_to_path(uint32_t handle, uint32_t storage_id,
const std::string& dest_path, uint64_t size) {
std::lock_guard lock(device_mtx_);
if (!ops_) throw std::runtime_error("Not connected");
std::atomic<bool> cancel{false};
mtp::ProgressFn noop = [](uint64_t, uint64_t) {};
ops_->get_object(handle, dest_path, size, noop, cancel);
}
void App::do_cancel(const std::string& id) { if (engine_) engine_->cancel(id); }
void App::do_cancel_all() { if (engine_) engine_->cancel_all(); }
void App::do_delete(uint32_t h) {
try { std::lock_guard lock(device_mtx_); if (ops_) ops_->delete_object(h); }
catch (...) {}
refresh_remote();
}
void App::do_create_folder(const std::string& name) {
if (remote_nav_stack_.empty()) return;
const auto& top = remote_nav_stack_.back();
try { std::lock_guard lock(device_mtx_); if (ops_) ops_->create_directory(top.storage_id, top.handle, name); }
catch (...) {}
refresh_remote();
}
void App::reconnect() { on_device_disconnected(); }
} // namespace omniMTP
+15
View File
@@ -0,0 +1,15 @@
#pragma once
#include "App.hpp"
namespace omniMTP {
class WebUI {
public:
explicit WebUI(App& app);
~WebUI();
void run();
private:
App& app_;
};
} // namespace omniMTP
+741
View File
@@ -0,0 +1,741 @@
#include "WebUI.hpp"
#import <Cocoa/Cocoa.h>
#import <WebKit/WebKit.h>
#include <string>
#include <cmath>
// ─── Forward declarations ─────────────────────────────────────────────────────
@class OmniBridge;
@class OmniAppDelegate;
// ─── OmniBridge: JS → C++ message handler ─────────────────────────────────────
@interface OmniBridge : NSObject <WKScriptMessageHandler, WKNavigationDelegate, WKUIDelegate>
- (instancetype)initWithApp:(omniMTP::App*)app webView:(WKWebView*)webView;
@end
@implementation OmniBridge {
omniMTP::App* _app;
WKWebView* _webView;
}
- (instancetype)initWithApp:(omniMTP::App*)app webView:(WKWebView*)webView {
if ((self = [super init])) {
_app = app;
_webView = webView;
}
return self;
}
- (void)userContentController:(WKUserContentController*)ucc
didReceiveScriptMessage:(WKScriptMessage*)message {
(void)ucc;
if (![message.name isEqualToString:@"bridge"]) return;
NSDictionary* body = message.body;
if (![body isKindOfClass:[NSDictionary class]]) return;
NSNumber* msgId = body[@"id"];
NSString* action = body[@"action"];
if (!msgId || !action) return;
long long rid = msgId.longLongValue;
// All dispatched on main thread already (WKWebView guarantee), but we
// use dispatch_async to avoid blocking the JS engine during heavy ops.
dispatch_async(dispatch_get_main_queue(), ^{
[self handleAction:action body:body responseId:rid];
});
}
- (void)handleAction:(NSString*)action body:(NSDictionary*)body responseId:(long long)rid {
NSString* result = nil;
if ([action isEqualToString:@"get_state"]) {
std::string json = _app->state_json();
result = [NSString stringWithUTF8String:json.c_str()];
// result is raw JSON object, send directly
[self sendRawResult:result responseId:rid];
return;
} else if ([action isEqualToString:@"navigate_local"]) {
NSString* path = body[@"path"];
if (path) _app->navigate_local(path.UTF8String);
} else if ([action isEqualToString:@"navigate_local_up"]) {
_app->navigate_local_up();
} else if ([action isEqualToString:@"refresh_local"]) {
_app->navigate_local(_app->current_local_path());
} else if ([action isEqualToString:@"navigate_remote"]) {
NSNumber* handle = body[@"handle"];
NSNumber* storageId = body[@"storageId"];
NSString* name = body[@"name"];
if (handle && storageId && name)
_app->navigate_remote(handle.unsignedIntValue,
storageId.unsignedIntValue,
name.UTF8String);
} else if ([action isEqualToString:@"navigate_remote_up"]) {
_app->navigate_remote_up();
} else if ([action isEqualToString:@"navigate_remote_to"]) {
NSNumber* index = body[@"index"];
if (index) _app->navigate_remote_to((size_t)index.unsignedIntegerValue);
} else if ([action isEqualToString:@"refresh_remote"]) {
_app->request_refresh_remote();
} else if ([action isEqualToString:@"set_storage"]) {
NSNumber* storageId = body[@"storageId"];
if (storageId) _app->set_active_storage(storageId.unsignedIntValue);
} else if ([action isEqualToString:@"start_upload"]) {
NSString* srcPath = body[@"srcPath"];
if (srcPath && srcPath.length > 0) _app->do_upload(srcPath.UTF8String);
} else if ([action isEqualToString:@"start_download"]) {
NSNumber* handle = body[@"handle"];
NSNumber* storageId = body[@"storageId"];
NSString* filename = body[@"filename"];
NSNumber* size = body[@"size"];
if (handle && storageId && filename && size)
_app->do_download(handle.unsignedIntValue,
storageId.unsignedIntValue,
filename.UTF8String,
size.unsignedLongLongValue);
} else if ([action isEqualToString:@"cancel_transfer"]) {
NSString* tid = body[@"id"];
if (tid) _app->do_cancel(tid.UTF8String);
} else if ([action isEqualToString:@"cancel_all"]) {
_app->do_cancel_all();
} else if ([action isEqualToString:@"delete_remote"]) {
NSNumber* handle = body[@"handle"];
if (handle) _app->do_delete(handle.unsignedIntValue);
} else if ([action isEqualToString:@"create_folder"]) {
NSString* name = body[@"name"];
if (name && name.length > 0) _app->do_create_folder(name.UTF8String);
} else if ([action isEqualToString:@"reconnect"]) {
_app->reconnect();
} else if ([action isEqualToString:@"open_file_picker"]) {
// Open NSOpenPanel to pick files for upload
dispatch_async(dispatch_get_main_queue(), ^{
NSOpenPanel* panel = [NSOpenPanel openPanel];
panel.canChooseFiles = YES;
panel.canChooseDirectories = NO;
panel.allowsMultipleSelection = YES;
[panel beginWithCompletionHandler:^(NSModalResponse r) {
if (r == NSModalResponseOK) {
for (NSURL* url in panel.URLs) {
NSString* path = url.path;
if (path) self->_app->do_upload(path.UTF8String);
}
}
}];
});
} else if ([action isEqualToString:@"update_drop_zones"]) {
NSDictionary* remote = body[@"remote"];
NSDictionary* local = body[@"local"];
if ([remote isKindOfClass:[NSDictionary class]] && [local isKindOfClass:[NSDictionary class]]) {
_app->update_drop_zones(
[remote[@"x"] doubleValue], [remote[@"y"] doubleValue],
[remote[@"w"] doubleValue], [remote[@"h"] doubleValue],
[local[@"x"] doubleValue], [local[@"y"] doubleValue],
[local[@"w"] doubleValue], [local[@"h"] doubleValue]);
}
}
// Send null result for void actions
[self sendNullResult:rid];
}
- (void)sendRawResult:(NSString*)json responseId:(long long)rid {
NSString* js = [NSString stringWithFormat:@"window.bridgeResponse(%lld,%@)", rid, json];
[_webView evaluateJavaScript:js completionHandler:nil];
}
- (void)sendNullResult:(long long)rid {
NSString* js = [NSString stringWithFormat:@"window.bridgeResponse(%lld,null)", rid];
[_webView evaluateJavaScript:js completionHandler:nil];
}
// WKNavigationDelegate — log errors
- (void)webView:(WKWebView*)wv didFailNavigation:(WKNavigation*)nav withError:(NSError*)err {
(void)wv; (void)nav;
NSLog(@"[OmniMTP] WebView navigation failed: %@", err.localizedDescription);
}
- (void)webView:(WKWebView*)wv didFailProvisionalNavigation:(WKNavigation*)nav withError:(NSError*)err {
(void)wv; (void)nav;
NSLog(@"[OmniMTP] WebView provisional navigation failed: %@", err.localizedDescription);
}
// WKUIDelegate — required for window.alert/confirm/prompt in WKWebView
- (void)webView:(WKWebView*)webView
runJavaScriptAlertPanelWithMessage:(NSString*)message
initiatedByFrame:(WKFrameInfo*)frame
completionHandler:(void (^)(void))completionHandler {
(void)webView; (void)frame;
NSAlert* alert = [[NSAlert alloc] init];
alert.messageText = message;
[alert addButtonWithTitle:@"OK"];
[alert runModal];
completionHandler();
}
- (void)webView:(WKWebView*)webView
runJavaScriptConfirmPanelWithMessage:(NSString*)message
initiatedByFrame:(WKFrameInfo*)frame
completionHandler:(void (^)(BOOL result))completionHandler {
(void)webView; (void)frame;
NSAlert* alert = [[NSAlert alloc] init];
alert.messageText = message;
[alert addButtonWithTitle:@"Delete"];
[alert addButtonWithTitle:@"Cancel"];
NSModalResponse response = [alert runModal];
completionHandler(response == NSAlertFirstButtonReturn);
}
@end
// ─── Titlebar drag strip (WKWebView ignores -webkit-app-region) ───────────────
static const CGFloat kTitlebarDragHeight = 38.0;
static const CGFloat kTrafficLightInset = 72.0;
@interface OmniTitlebarDragView : NSView
@end
@implementation OmniTitlebarDragView {
NSPoint _dragStartScreen;
NSPoint _windowStartOrigin;
}
- (BOOL)mouseDownCanMoveWindow {
return YES;
}
- (void)mouseDown:(NSEvent*)event {
(void)event;
_dragStartScreen = NSEvent.mouseLocation;
_windowStartOrigin = self.window.frame.origin;
while (YES) {
NSEvent* next = [self.window nextEventMatchingMask:
NSEventMaskLeftMouseDragged | NSEventMaskLeftMouseUp
untilDate:[NSDate distantFuture]
inMode:NSEventTrackingRunLoopMode
dequeue:YES];
if (!next || next.type == NSEventTypeLeftMouseUp) break;
NSPoint current = NSEvent.mouseLocation;
[self.window setFrameOrigin:NSMakePoint(
_windowStartOrigin.x + (current.x - _dragStartScreen.x),
_windowStartOrigin.y + (current.y - _dragStartScreen.y))];
}
}
- (BOOL)acceptsFirstMouse:(NSEvent*)event {
(void)event;
return YES;
}
@end
// ─── Finder drag-export for remote MTP files ───────────────────────────────────
@interface OmniExportPromiseDelegate : NSObject <NSFilePromiseProviderDelegate>
@property (nonatomic, assign) omniMTP::App* app;
@property (nonatomic, assign) uint32_t handle;
@property (nonatomic, assign) uint32_t storageId;
@property (nonatomic, assign) uint64_t size;
@property (nonatomic, copy) NSString* filename;
@end
@implementation OmniExportPromiseDelegate
- (void)filePromiseProvider:(NSFilePromiseProvider*)provider
writePromiseToURL:(NSURL*)url
completionHandler:(void (^)(NSError* _Nullable))completionHandler {
(void)provider;
@try {
if (_app) _app->download_remote_to_path(_handle, _storageId, url.path.UTF8String, _size);
completionHandler(nil);
} @catch (NSException* ex) {
completionHandler([NSError errorWithDomain:@"OmniMTP" code:1
userInfo:@{NSLocalizedDescriptionKey: ex.reason ?: @"Download failed"}]);
}
}
- (NSString*)filePromiseProvider:(NSFilePromiseProvider*)provider
fileNameForType:(NSString*)fileType {
(void)provider; (void)fileType;
return _filename;
}
- (void)filePromiseProvider:(NSFilePromiseProvider*)provider
didFinishWritingToURL:(NSURL*)url {
(void)provider; (void)url;
}
@end
// ─── OmniWebView: native drag-and-drop ────────────────────────────────────────
@interface OmniWebView : WKWebView <NSDraggingSource>
- (instancetype)initWithFrame:(NSRect)frame
configuration:(WKWebViewConfiguration*)config
app:(omniMTP::App*)app;
@end
@implementation OmniWebView {
omniMTP::App* _app;
BOOL _remoteDragHighlighted;
BOOL _localDragHighlighted;
NSPoint _mouseDownWebPoint;
NSString* _pendingDragJSON;
BOOL _nativeDragStarted;
NSArray<NSDictionary*>* _exportDragEntries;
}
- (instancetype)initWithFrame:(NSRect)frame
configuration:(WKWebViewConfiguration*)config
app:(omniMTP::App*)app {
if ((self = [super initWithFrame:frame configuration:config])) {
_app = app;
_remoteDragHighlighted = NO;
_localDragHighlighted = NO;
[self registerForDraggedTypes:@[NSPasteboardTypeFileURL]];
}
return self;
}
- (NSPoint)webPointFromEvent:(NSEvent*)event {
NSPoint loc = [self convertPoint:event.locationInWindow fromView:nil];
return NSMakePoint(loc.x, self.bounds.size.height - loc.y);
}
- (NSPoint)webPointFromDrag:(id<NSDraggingInfo>)sender {
NSPoint loc = [self convertPoint:sender.draggingLocation fromView:nil];
return NSMakePoint(loc.x, self.bounds.size.height - loc.y);
}
- (NSString*)dropTargetAtWebPoint:(NSPoint)webPoint {
if (!_app) return @"none";
std::string target = _app->drop_target_at(webPoint.x, webPoint.y);
return [NSString stringWithUTF8String:target.c_str()];
}
- (NSArray<NSURL*>*)fileURLsFromDrag:(id<NSDraggingInfo>)sender {
NSPasteboard* pb = sender.draggingPasteboard;
NSDictionary* opts = @{NSPasteboardURLReadingFileURLsOnlyKey: @YES};
if ([pb canReadObjectForClasses:@[[NSURL class]] options:opts]) {
NSArray* urls = [pb readObjectsForClasses:@[[NSURL class]] options:opts];
return urls ?: @[];
}
return @[];
}
- (void)setRemoteDragOver:(BOOL)over {
if (_remoteDragHighlighted == over) return;
_remoteDragHighlighted = over;
NSString* js = over
? @"document.getElementById('remote-files')?.classList.add('drag-over')"
: @"document.getElementById('remote-files')?.classList.remove('drag-over')";
[self evaluateJavaScript:js completionHandler:nil];
}
- (void)setLocalDragOver:(BOOL)over {
if (_localDragHighlighted == over) return;
_localDragHighlighted = over;
NSString* js = over
? @"document.getElementById('local-files')?.classList.add('drag-over')"
: @"document.getElementById('local-files')?.classList.remove('drag-over')";
[self evaluateJavaScript:js completionHandler:nil];
}
- (void)mouseDown:(NSEvent*)event {
_mouseDownWebPoint = [self webPointFromEvent:event];
_pendingDragJSON = nil;
_nativeDragStarted = NO;
[self evaluateJavaScript:@"window.omniClearSelection&&window.omniClearSelection()"
completionHandler:nil];
NSString* js = [NSString stringWithFormat:
@"window.omniRemoteDragFilesAt(%f,%f)", _mouseDownWebPoint.x, _mouseDownWebPoint.y];
[self evaluateJavaScript:js completionHandler:^(id value, NSError* err) {
(void)err;
if ([value isKindOfClass:[NSString class]] && [(NSString*)value length] > 0)
self->_pendingDragJSON = value;
}];
[super mouseDown:event];
}
- (void)mouseDragged:(NSEvent*)event {
if (!_nativeDragStarted && _pendingDragJSON.length > 0 && _app && _app->connected()) {
NSPoint cur = [self webPointFromEvent:event];
CGFloat dx = cur.x - _mouseDownWebPoint.x;
CGFloat dy = cur.y - _mouseDownWebPoint.y;
if (std::sqrt(dx * dx + dy * dy) >= 4.0) {
_nativeDragStarted = YES;
[self evaluateJavaScript:@"window.omniClearSelection&&window.omniClearSelection()"
completionHandler:nil];
if ([self beginRemoteExportDrag:event json:_pendingDragJSON]) return;
}
}
if (!_nativeDragStarted) [super mouseDragged:event];
}
- (BOOL)beginRemoteExportDrag:(NSEvent*)event json:(NSString*)json {
NSData* data = [json dataUsingEncoding:NSUTF8StringEncoding];
id parsed = [NSJSONSerialization JSONObjectWithData:data options:0 error:nil];
if (![parsed isKindOfClass:[NSArray class]] || [(NSArray*)parsed count] == 0) return NO;
_exportDragEntries = [(NSArray*)parsed copy];
NSMutableArray<NSDraggingItem*>* dragItems = [NSMutableArray array];
CGFloat viewY = self.bounds.size.height - _mouseDownWebPoint.y;
NSRect frame = NSMakeRect(_mouseDownWebPoint.x - 1, viewY - 1, 2, 2);
for (NSDictionary* entry in (NSArray*)parsed) {
NSNumber* handle = entry[@"handle"];
NSNumber* storageId = entry[@"storageId"];
NSString* name = entry[@"name"];
NSNumber* size = entry[@"size"];
if (!handle || !storageId || !name || !size) continue;
OmniExportPromiseDelegate* delegate = [OmniExportPromiseDelegate new];
delegate.app = _app;
delegate.handle = handle.unsignedIntValue;
delegate.storageId = storageId.unsignedIntValue;
delegate.size = size.unsignedLongLongValue;
delegate.filename = name;
NSFilePromiseProvider* provider =
[[NSFilePromiseProvider alloc] initWithFileType:@"public.data" delegate:delegate];
NSDraggingItem* dragItem = [[NSDraggingItem alloc] initWithPasteboardWriter:provider];
[dragItem setDraggingFrame:frame contents:nil];
[dragItems addObject:dragItem];
}
if (dragItems.count == 0) {
_exportDragEntries = nil;
return NO;
}
[self evaluateJavaScript:@"window.omniClearSelection&&window.omniClearSelection()"
completionHandler:nil];
[self beginDraggingSessionWithItems:dragItems event:event source:self];
return YES;
}
- (NSDragOperation)draggingSession:(NSDraggingSession*)session
sourceOperationMaskForDraggingContext:(NSDraggingContext)context {
(void)session; (void)context;
return NSDragOperationCopy;
}
- (void)draggingSession:(NSDraggingSession*)session
endedAtPoint:(NSPoint)screenPoint
operation:(NSDragOperation)operation {
(void)session; (void)screenPoint; (void)operation;
_exportDragEntries = nil;
}
- (NSDragOperation)draggingUpdated:(id<NSDraggingInfo>)sender {
NSArray<NSURL*>* urls = [self fileURLsFromDrag:sender];
const BOOL exportDrag = _exportDragEntries.count > 0;
if (!exportDrag && urls.count == 0) {
[self setRemoteDragOver:NO];
[self setLocalDragOver:NO];
return NSDragOperationNone;
}
NSString* target = [self dropTargetAtWebPoint:[self webPointFromDrag:sender]];
if (exportDrag && [target isEqualToString:@"local"]) {
[self setLocalDragOver:YES];
[self setRemoteDragOver:NO];
return NSDragOperationCopy;
}
if (urls.count > 0 && [target isEqualToString:@"remote"] && _app && _app->connected()) {
[self setRemoteDragOver:YES];
[self setLocalDragOver:NO];
return NSDragOperationCopy;
}
if (urls.count > 0 && [target isEqualToString:@"local"]) {
[self setRemoteDragOver:NO];
[self setLocalDragOver:NO];
return NSDragOperationLink;
}
[self setRemoteDragOver:NO];
[self setLocalDragOver:NO];
return exportDrag ? NSDragOperationCopy : NSDragOperationNone;
}
- (NSDragOperation)draggingEntered:(id<NSDraggingInfo>)sender {
return [self draggingUpdated:sender];
}
- (void)draggingExited:(id<NSDraggingInfo>)sender {
(void)sender;
[self setRemoteDragOver:NO];
[self setLocalDragOver:NO];
}
- (BOOL)performDragOperation:(id<NSDraggingInfo>)sender {
[self setRemoteDragOver:NO];
[self setLocalDragOver:NO];
if (!_app) return NO;
NSString* target = [self dropTargetAtWebPoint:[self webPointFromDrag:sender]];
NSArray<NSURL*>* urls = [self fileURLsFromDrag:sender];
if ([target isEqualToString:@"local"] && _exportDragEntries.count > 0) {
for (NSDictionary* entry in _exportDragEntries) {
NSNumber* handle = entry[@"handle"];
NSNumber* storageId = entry[@"storageId"];
NSString* name = entry[@"name"];
NSNumber* size = entry[@"size"];
if (handle && storageId && name && size)
_app->do_download(handle.unsignedIntValue, storageId.unsignedIntValue,
name.UTF8String, size.unsignedLongLongValue);
}
_exportDragEntries = nil;
return YES;
}
NSFileManager* fm = NSFileManager.defaultManager;
BOOL handled = NO;
if ([target isEqualToString:@"remote"]) {
for (NSURL* url in urls) {
if (!url.isFileURL) continue;
NSString* path = url.path;
if (!path) continue;
BOOL isDir = NO;
if ([fm fileExistsAtPath:path isDirectory:&isDir] && !isDir)
_app->do_upload(path.UTF8String);
handled = YES;
}
return handled;
}
if ([target isEqualToString:@"local"]) {
for (NSURL* url in urls) {
if (!url.isFileURL) continue;
NSString* path = url.path;
if (!path) continue;
BOOL isDir = NO;
if ([fm fileExistsAtPath:path isDirectory:&isDir] && isDir)
_app->navigate_local(path.UTF8String);
handled = YES;
}
return handled;
}
return NO;
}
@end
// ─── OmniAppDelegate ──────────────────────────────────────────────────────────
@interface OmniAppDelegate : NSObject <NSApplicationDelegate>
- (instancetype)initWithApp:(omniMTP::App*)app;
@end
@implementation OmniAppDelegate {
omniMTP::App* _app;
NSWindow* _window;
WKWebView* _webView;
OmniBridge* _bridge;
}
- (instancetype)initWithApp:(omniMTP::App*)app {
if ((self = [super init])) {
_app = app;
}
return self;
}
- (void)applicationDidFinishLaunching:(NSNotification*)note {
(void)note;
// ── WKWebView configuration ───────────────────────────────────────────────
WKWebViewConfiguration* config = [[WKWebViewConfiguration alloc] init];
// Enable developer tools in debug builds
#if DEBUG
if (@available(macOS 13.3, *)) {
config.preferences.elementFullscreenEnabled = YES;
[config.preferences setValue:@YES forKey:@"developerExtrasEnabled"];
}
#endif
// Disable text selection in file lists; file drag only.
WKUserScript* noTextDrag = [[WKUserScript alloc]
initWithSource:@"(function(){"
"function blockText(e){if(e.target.closest('.file-list'))e.preventDefault();}"
"document.addEventListener('selectstart',blockText,true);"
"document.addEventListener('mousedown',function(e){"
"if(e.button===0&&e.target.closest('.file-list .frow'))e.preventDefault();"
"},true);"
"document.addEventListener('dragstart',function(e){"
"var row=e.target.closest('.file-list .frow');"
"if(!row)return;"
"if(row.closest('#remote-files')||row.getAttribute('draggable')!=='true')"
"e.preventDefault();"
"},true);"
"})();"
injectionTime:WKUserScriptInjectionTimeAtDocumentStart
forMainFrameOnly:YES];
[config.userContentController addUserScript:noTextDrag];
// Register the "bridge" message handler
_webView = [[OmniWebView alloc] initWithFrame:NSMakeRect(0, 0, 1280, 800)
configuration:config
app:_app];
_bridge = [[OmniBridge alloc] initWithApp:_app webView:_webView];
[config.userContentController addScriptMessageHandler:_bridge name:@"bridge"];
_webView.navigationDelegate = _bridge;
_webView.UIDelegate = _bridge;
// Allow file:// access for local resource loading
_webView.autoresizingMask = NSViewWidthSizable | NSViewHeightSizable;
// ── NSWindow ──────────────────────────────────────────────────────────────
NSRect frame = NSMakeRect(0, 0, 1280, 800);
NSWindowStyleMask style =
NSWindowStyleMaskTitled |
NSWindowStyleMaskClosable |
NSWindowStyleMaskMiniaturizable |
NSWindowStyleMaskResizable |
NSWindowStyleMaskFullSizeContentView;
_window = [[NSWindow alloc] initWithContentRect:frame
styleMask:style
backing:NSBackingStoreBuffered
defer:NO];
_window.title = @"OmniMTP";
_window.titleVisibility = NSWindowTitleHidden;
_window.titlebarAppearsTransparent = YES;
_window.minSize = NSMakeSize(900, 600);
_window.movableByWindowBackground = YES;
// Force dark appearance
if (@available(macOS 10.14, *)) {
_window.appearance = [NSAppearance appearanceNamed:NSAppearanceNameDarkAqua];
}
// Container keeps a native drag strip above the WKWebView (web content
// would otherwise swallow titlebar mouse events).
NSView* container = [[NSView alloc] initWithFrame:frame];
_webView.frame = NSMakeRect(0, 0, frame.size.width, frame.size.height);
[container addSubview:_webView];
OmniTitlebarDragView* dragBar = [[OmniTitlebarDragView alloc]
initWithFrame:NSMakeRect(kTrafficLightInset, frame.size.height - kTitlebarDragHeight,
frame.size.width - kTrafficLightInset, kTitlebarDragHeight)];
dragBar.autoresizingMask = NSViewWidthSizable | NSViewMinYMargin;
[container addSubview:dragBar];
[_window setContentView:container];
[_window center];
[_window makeKeyAndOrderFront:nil];
// ── Load index.html from bundle (dev fallback: source tree) ───────────────
NSURL* indexURL = nil;
// 1. Try the bundle's webroot resource directory
NSBundle* bundle = [NSBundle mainBundle];
indexURL = [bundle URLForResource:@"index" withExtension:@"html" subdirectory:@"webroot"];
// 2. Dev fallback: look next to the binary in a webroot/ sibling
if (!indexURL) {
NSString* execDir = bundle.executableURL.URLByDeletingLastPathComponent.path;
NSString* devPath = [execDir stringByAppendingPathComponent:@"webroot/index.html"];
if ([[NSFileManager defaultManager] fileExistsAtPath:devPath])
indexURL = [NSURL fileURLWithPath:devPath];
}
// 3. Source tree fallback for direct cmake build without bundle
if (!indexURL) {
// Walk up from executable looking for src/ui/webroot
NSString* base = bundle.executableURL.URLByDeletingLastPathComponent.path;
NSArray<NSString*>* candidates = @[
[base stringByAppendingPathComponent:@"../src/ui/webroot/index.html"],
[base stringByAppendingPathComponent:@"../../src/ui/webroot/index.html"],
[base stringByAppendingPathComponent:@"../../../src/ui/webroot/index.html"],
];
for (NSString* c in candidates) {
NSString* resolved = c.stringByResolvingSymlinksInPath;
if ([[NSFileManager defaultManager] fileExistsAtPath:resolved]) {
indexURL = [NSURL fileURLWithPath:resolved];
break;
}
}
}
if (indexURL) {
NSURL* baseURL = indexURL.URLByDeletingLastPathComponent;
NSString* html = [NSString stringWithContentsOfURL:indexURL
encoding:NSUTF8StringEncoding
error:nil];
if (html) {
[_webView loadHTMLString:html baseURL:baseURL];
} else {
[_webView loadFileURL:indexURL allowingReadAccessToURL:baseURL];
}
} else {
// Inline fallback so the app at least opens
NSString* fallback = @"<!DOCTYPE html><html><body style='background:#1a1a1a;color:#fff;font-family:system-ui;display:flex;align-items:center;justify-content:center;height:100vh'><h2>webroot/index.html not found</h2></body></html>";
[_webView loadHTMLString:fallback baseURL:nil];
NSLog(@"[OmniMTP] ERROR: webroot/index.html not found in bundle or source tree");
}
}
- (BOOL)applicationShouldTerminateAfterLastWindowClosed:(NSApplication*)app {
(void)app;
return YES;
}
- (void)applicationWillTerminate:(NSNotification*)note {
(void)note;
}
@end
// ─── WebUI ────────────────────────────────────────────────────────────────────
namespace omniMTP {
WebUI::WebUI(App& a) : app_(a) {}
WebUI::~WebUI() = default;
void WebUI::run() {
@autoreleasepool {
[NSApplication sharedApplication];
[NSApp setActivationPolicy:NSApplicationActivationPolicyRegular];
OmniAppDelegate* delegate = [[OmniAppDelegate alloc] initWithApp:&app_];
[NSApp setDelegate:delegate];
// App menu (Cmd+Q to quit)
NSMenu* menuBar = [[NSMenu alloc] init];
NSMenuItem* appItem = [[NSMenuItem alloc] init];
[menuBar addItem:appItem];
NSMenu* appMenu = [[NSMenu alloc] init];
NSMenuItem* quitItem = [[NSMenuItem alloc]
initWithTitle:@"Quit OmniMTP"
action:@selector(terminate:)
keyEquivalent:@"q"];
[appMenu addItem:quitItem];
appItem.submenu = appMenu;
[NSApp setMainMenu:menuBar];
[NSApp activateIgnoringOtherApps:YES];
[NSApp run];
}
}
} // namespace omniMTP
+573
View File
@@ -0,0 +1,573 @@
/* ── Reset & base ─────────────────────────────────────────────────────────── */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg-primary: #1a1a1a;
--bg-secondary: #1e1e1e;
--bg-tertiary: #252525;
--bg-hover: rgba(255,255,255,0.055);
--bg-active: rgba(255,255,255,0.10);
--bg-selected: rgba(10,132,255,0.18);
--bg-sel-border: rgba(10,132,255,0.45);
--accent: #0a84ff;
--accent-dim: rgba(10,132,255,0.55);
--green: #30d158;
--yellow: #ffd60a;
--red: #ff453a;
--red-dim: rgba(255,69,58,0.80);
--label: rgba(255,255,255,0.92);
--label-2: rgba(235,235,245,0.55);
--label-3: rgba(235,235,245,0.28);
--label-disabled:rgba(235,235,245,0.18);
--sep: rgba(235,235,245,0.09);
--sep-strong: rgba(235,235,245,0.13);
--border: rgba(235,235,245,0.10);
--row-h: 26px;
--toolbar-h: 40px;
--titlebar-h: 38px;
--statusbar-h: 22px;
--divider-w: 1px;
--font: -apple-system, BlinkMacSystemFont, "SF Pro Text", system-ui, sans-serif;
--font-mono: "SF Mono", ui-monospace, monospace;
--radius: 6px;
--radius-sm: 4px;
}
html, body {
height: 100%;
overflow: hidden;
background: var(--bg-primary);
color: var(--label);
font-family: var(--font);
font-size: 13px;
line-height: 1.4;
-webkit-font-smoothing: antialiased;
user-select: none;
}
/* ── Layout skeleton ─────────────────────────────────────────────────────────
Titlebar → Device-bar → Main(flex:1) → Transfers(auto) → Statusbar
*/
body {
display: flex;
flex-direction: column;
}
/* ── Titlebar ─────────────────────────────────────────────────────────────── */
#titlebar {
height: var(--titlebar-h);
display: flex;
align-items: center;
padding: 0 16px;
background: var(--bg-secondary);
border-bottom: 1px solid var(--sep);
-webkit-app-region: drag;
flex-shrink: 0;
}
.tl-spacer {
width: 72px; /* space for traffic lights */
flex-shrink: 0;
-webkit-app-region: drag;
user-select: none;
-webkit-user-select: none;
}
#app-title {
flex: 1;
text-align: center;
font-size: 13px;
font-weight: 600;
letter-spacing: 0.01em;
color: var(--label-2);
-webkit-app-region: drag;
user-select: none;
-webkit-user-select: none;
cursor: default;
}
#conn-badge {
-webkit-app-region: no-drag;
font-size: 11px;
font-weight: 500;
padding: 2px 10px;
border-radius: 20px;
white-space: nowrap;
user-select: none;
-webkit-user-select: none;
cursor: default;
}
.badge.connected { background: rgba(48,209,88,0.18); color: var(--green); }
.badge.disconnected { background: rgba(235,235,245,0.08); color: var(--label-3); }
/* ── Device bar ───────────────────────────────────────────────────────────── */
#device-bar {
height: var(--toolbar-h);
display: flex;
align-items: center;
padding: 0 12px;
background: var(--bg-secondary);
border-bottom: 1px solid var(--sep);
flex-shrink: 0;
gap: 8px;
}
.device-left, .device-right { display: flex; align-items: center; gap: 6px; flex-shrink: 0; }
.device-center {
flex: 1;
text-align: center;
font-size: 12px;
color: var(--label-2);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* ── Main two-panel area ──────────────────────────────────────────────────── */
#main {
flex: 1;
display: flex;
overflow: hidden;
min-height: 0;
}
/* ── Panel ────────────────────────────────────────────────────────────────── */
.panel {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
overflow: hidden;
background: var(--bg-primary);
}
/* ── Panel divider ───────────────────────────────────────────────────────── */
#panel-divider {
width: var(--divider-w);
background: var(--sep-strong);
flex-shrink: 0;
}
/* ── Panel toolbar ────────────────────────────────────────────────────────── */
.panel-toolbar {
height: var(--toolbar-h);
display: flex;
align-items: center;
padding: 0 8px;
gap: 6px;
background: var(--bg-tertiary);
border-bottom: 1px solid var(--sep);
flex-shrink: 0;
}
/* ── Nav button group (pill shape) ─────────────────────────────────────────── */
.nav-group {
display: flex;
align-items: center;
background: rgba(255,255,255,0.05);
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
flex-shrink: 0;
}
.nav-group .nav-btn {
border-radius: 0;
border: none;
border-right: 1px solid var(--border);
}
.nav-group .nav-btn:last-child { border-right: none; }
.nav-btn {
-webkit-app-region: no-drag;
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 26px;
background: transparent;
border: none;
border-radius: var(--radius-sm);
color: var(--label-2);
cursor: pointer;
transition: background 80ms, color 80ms;
flex-shrink: 0;
}
.nav-btn svg { width: 14px; height: 14px; }
.nav-btn:hover:not(:disabled) { background: var(--bg-hover); color: var(--label); }
.nav-btn:active:not(:disabled){ background: var(--bg-active); }
.nav-btn:disabled { color: var(--label-disabled); cursor: default; }
.nav-btn.icon-only { width: 28px; }
/* ── Breadcrumb ──────────────────────────────────────────────────────────── */
.breadcrumb {
flex: 1;
display: flex;
align-items: center;
overflow: hidden;
gap: 0;
min-width: 0;
font-size: 12px;
}
.crumb-link {
color: var(--accent);
cursor: pointer;
white-space: nowrap;
padding: 2px 3px;
border-radius: 3px;
transition: background 80ms;
overflow: hidden;
text-overflow: ellipsis;
max-width: 140px;
}
.crumb-link:hover { background: var(--bg-hover); }
.crumb-cur {
color: var(--label);
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 160px;
}
.crumb-sep { color: var(--label-3); padding: 0 2px; flex-shrink: 0; }
.crumb-ellipsis { color: var(--label-3); padding: 0 4px; flex-shrink: 0; }
/* ── Storage select ──────────────────────────────────────────────────────── */
.storage-select {
-webkit-app-region: no-drag;
background: rgba(255,255,255,0.05);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--label);
font-family: var(--font);
font-size: 12px;
padding: 3px 6px;
cursor: pointer;
max-width: 160px;
flex-shrink: 0;
}
.storage-select:focus { outline: none; border-color: var(--accent-dim); }
/* ── File list container ──────────────────────────────────────────────────── */
.file-list {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
min-height: 0;
position: relative;
user-select: none;
-webkit-user-select: none;
-webkit-user-drag: none;
}
.file-list * {
user-select: none;
-webkit-user-select: none;
}
.file-list .frow[draggable="true"] {
-webkit-user-drag: element;
}
#remote-files .frow {
-webkit-user-drag: none;
}
/* Custom scrollbar */
.file-list::-webkit-scrollbar { width: 5px; }
.file-list::-webkit-scrollbar-track { background: transparent; }
.file-list::-webkit-scrollbar-thumb {
background: rgba(235,235,245,0.15);
border-radius: 3px;
}
.file-list::-webkit-scrollbar-thumb:hover { background: rgba(235,235,245,0.25); }
.file-list.drag-over { background: rgba(10,132,255,0.06); outline: 2px solid var(--accent-dim); outline-offset: -2px; }
/* ── File table ──────────────────────────────────────────────────────────── */
.ftable {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}
.ftable thead tr {
position: sticky;
top: 0;
z-index: 1;
background: var(--bg-tertiary);
}
.ftable th {
font-size: 11px;
font-weight: 500;
color: var(--label-2);
text-align: left;
padding: 4px 8px;
border-bottom: 1px solid var(--sep);
white-space: nowrap;
overflow: hidden;
}
.ftable th:first-child { padding-left: 12px; }
.col-size { width: 72px; text-align: right; }
.col-date { width: 110px; }
.frow {
height: var(--row-h);
cursor: default;
will-change: background-color;
transition: background-color 120ms ease;
user-select: none;
-webkit-user-select: none;
}
.frow:hover { background-color: var(--bg-hover); }
.frow.selected {
background: var(--bg-selected);
box-shadow: inset 3px 0 0 var(--bg-sel-border);
}
.ftable td {
font-size: 13px;
padding: 0 8px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
vertical-align: middle;
}
.ftable td:first-child { padding-left: 12px; }
/* Name cell: dot + name inline */
.fname-cell {
display: flex;
align-items: center;
gap: 7px;
overflow: hidden;
}
.dot {
width: 7px;
height: 7px;
border-radius: 50%;
flex-shrink: 0;
opacity: 0.85;
}
.color-dir { color: var(--accent); } .dot.color-dir { background: var(--accent); }
.color-game { color: var(--yellow); } .dot.color-game { background: var(--yellow); }
.color-hb { color: var(--green); } .dot.color-hb { background: var(--green); }
.color-file { color: rgba(255,255,255,0.75); } .dot.color-file { background: rgba(255,255,255,0.45); }
/* Size / date columns */
.col-size { color: var(--label-2); font-size: 12px; text-align: right; }
.col-date { color: var(--label-3); font-size: 11px; font-variant-numeric: tabular-nums; }
/* ── Empty states ─────────────────────────────────────────────────────────── */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
min-height: 180px;
gap: 10px;
padding: 32px 16px;
color: var(--label-3);
}
.empty-icon {
width: 48px;
height: 48px;
color: var(--label-disabled);
stroke: currentColor;
}
.empty-title {
font-size: 14px;
font-weight: 500;
color: var(--label-2);
}
.empty-sub {
font-size: 12px;
color: var(--label-3);
text-align: center;
max-width: 240px;
}
.loading {
padding: 20px 16px;
color: var(--label-3);
font-size: 13px;
}
/* ── Transfers panel ─────────────────────────────────────────────────────── */
#transfers-panel {
flex-shrink: 0;
max-height: 160px;
overflow: hidden;
border-top: 1px solid var(--sep-strong);
background: var(--bg-secondary);
display: flex;
flex-direction: column;
}
#transfers-header {
display: flex;
align-items: center;
padding: 5px 12px;
gap: 8px;
border-bottom: 1px solid var(--sep);
flex-shrink: 0;
}
#transfers-label {
font-size: 11px;
font-weight: 600;
color: var(--label-2);
letter-spacing: 0.04em;
text-transform: uppercase;
flex: 1;
}
#transfers-list {
overflow-y: auto;
flex: 1;
padding: 4px 8px;
}
#transfers-list::-webkit-scrollbar { width: 4px; }
#transfers-list::-webkit-scrollbar-thumb {
background: rgba(235,235,245,0.12);
border-radius: 2px;
}
/* Transfer item */
.txfer-item {
padding: 4px 4px;
margin-bottom: 3px;
border-radius: var(--radius-sm);
background: rgba(255,255,255,0.03);
}
.txfer-row {
display: flex;
align-items: center;
gap: 6px;
height: 20px;
}
.txfer-dir {
font-size: 10px;
font-weight: 700;
padding: 1px 5px;
border-radius: 3px;
flex-shrink: 0;
letter-spacing: 0.03em;
}
.dir-dl { background: rgba(10,132,255,0.18); color: var(--accent); }
.dir-ul { background: rgba(48,209,88,0.18); color: var(--green); }
.txfer-name {
flex: 1;
font-size: 12px;
color: var(--label);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.txfer-stat {
font-size: 11px;
color: var(--label-2);
white-space: nowrap;
flex-shrink: 0;
}
.txfer-cancel {
-webkit-app-region: no-drag;
background: transparent;
border: none;
color: var(--label-3);
cursor: pointer;
font-size: 14px;
padding: 0 4px;
border-radius: 3px;
transition: color 80ms, background 80ms;
flex-shrink: 0;
}
.txfer-cancel:hover { color: var(--red); background: rgba(255,69,58,0.12); }
.pbar {
height: 3px;
background: rgba(235,235,245,0.07);
border-radius: 2px;
margin: 3px 0 2px;
overflow: hidden;
}
.pbar-fill {
height: 100%;
background: var(--accent);
border-radius: 2px;
transition: width 200ms linear;
}
.pbar-fill.done { background: var(--green); }
.txfer-meta {
font-size: 10px;
color: var(--label-3);
font-variant-numeric: tabular-nums;
}
/* ── Status bar ──────────────────────────────────────────────────────────── */
#statusbar {
height: var(--statusbar-h);
display: flex;
align-items: center;
padding: 0 12px;
background: var(--bg-tertiary);
border-top: 1px solid var(--sep);
flex-shrink: 0;
font-size: 11px;
color: var(--label-3);
gap: 8px;
}
#sb-left { flex: 0 0 auto; }
#sb-center{ flex: 1; text-align: center; color: var(--label-2); }
#sb-right { flex: 0 0 auto; }
/* ── Context menu ────────────────────────────────────────────────────────── */
.ctx-menu {
position: fixed;
z-index: 9999;
background: #2a2a2e;
border: 1px solid rgba(255,255,255,0.12);
border-radius: 8px;
padding: 4px 0;
min-width: 160px;
box-shadow: 0 8px 32px rgba(0,0,0,0.55);
}
.ctx-menu.hidden { display: none; }
.ctx-item {
padding: 6px 14px;
font-size: 13px;
cursor: pointer;
color: var(--label);
transition: background 60ms;
}
.ctx-item:hover { background: var(--bg-hover); }
.ctx-item.danger { color: var(--red); }
.ctx-item.ctx-sep {
height: 1px;
background: var(--sep);
padding: 0;
margin: 3px 0;
cursor: default;
}
.ctx-item.ctx-sep:hover { background: var(--sep); }
/* ── Marquee selection ───────────────────────────────────────────────────── */
.marquee-box {
position: fixed;
z-index: 9998;
border: 1px solid var(--accent);
background: rgba(10,132,255,0.12);
pointer-events: none;
}
/* ── Buttons ─────────────────────────────────────────────────────────────── */
.action-btn {
-webkit-app-region: no-drag;
background: rgba(255,255,255,0.07);
border: 1px solid rgba(255,255,255,0.10);
border-radius: var(--radius-sm);
color: var(--label);
font-family: var(--font);
font-size: 12px;
padding: 4px 12px;
cursor: pointer;
transition: background 80ms, border-color 80ms;
white-space: nowrap;
}
.action-btn:hover { background: rgba(255,255,255,0.11); border-color: rgba(255,255,255,0.16); }
.action-btn:active{ background: rgba(255,255,255,0.15); }
.action-btn.primary { background: rgba(10,132,255,0.22); border-color: rgba(10,132,255,0.35); color: var(--accent); }
.action-btn.primary:hover { background: rgba(10,132,255,0.30); border-color: rgba(10,132,255,0.50); }
.action-btn.danger { background: rgba(255,69,58,0.15); border-color: rgba(255,69,58,0.25); color: var(--red); }
.action-btn.danger:hover { background: rgba(255,69,58,0.25); }
.action-btn.small { font-size: 11px; padding: 2px 8px; }
+682
View File
@@ -0,0 +1,682 @@
'use strict';
// ── Bridge ────────────────────────────────────────────────────────────────────
let _reqId = 0;
const _pending = {};
function call(action, params = {}) {
return new Promise(resolve => {
const id = ++_reqId;
_pending[id] = resolve;
try {
window.webkit.messageHandlers.bridge.postMessage({id, action, ...params});
} catch(e) {
// Dev fallback
setTimeout(() => { if (_pending[id]) { _pending[id](null); delete _pending[id]; } }, 100);
}
});
}
window.bridgeResponse = function(id, data) {
if (_pending[id]) { _pending[id](data); delete _pending[id]; }
};
// ── State ─────────────────────────────────────────────────────────────────────
const S = {
connected: false, deviceName: '',
storages: [], activeStorageId: 0,
remoteNavStack: [],
localPath: '', localFiles: [],
localDrives: [], activeLocalDrive: '',
remoteFiles: [], remoteRefreshing: false,
transfers: [],
statusMessage: '', statusTimer: 0,
selLocal: new Set(), // keys = file.path strings
selRemote: new Set(), // keys = String(file.handle)
lastLocalIdx: -1, // for shift+click range
lastRemoteIdx: -1,
};
// ── Smart-render fingerprints ──────────────────────────────────────────────────
// The file lists are only fully rebuilt when the files themselves change.
// Selection and toolbar updates run every poll without touching the table DOM,
// which eliminates the 400 ms hover-flash from innerHTML replacement.
let _localFingerprint = '';
let _remoteFingerprint = '';
function fingerprintFiles(files) {
// Fast hash: join paths/handles — changes on any navigation or refresh
return files.map(f => f.path || f.handle || f.name).join('\0');
}
// ── Polling ───────────────────────────────────────────────────────────────────
async function poll() {
try {
const data = await call('get_state');
if (data) {
const sl = S.selLocal, sr = S.selRemote;
const lli = S.lastLocalIdx, lri = S.lastRemoteIdx;
Object.assign(S, data);
S.selLocal = sl; S.selRemote = sr;
S.lastLocalIdx = lli; S.lastRemoteIdx = lri;
render();
}
} catch(e) {}
setTimeout(poll, 400);
}
// ── Rendering ─────────────────────────────────────────────────────────────────
function render() {
renderDeviceBar();
renderLocalToolbar();
renderLocalFiles();
renderRemoteToolbar();
renderRemoteFiles();
renderTransfers();
renderStatusBar();
updateDropZones();
}
function updateDropZones() {
const rem = document.getElementById('panel-remote')?.getBoundingClientRect();
const loc = document.getElementById('panel-local')?.getBoundingClientRect();
if (!rem || !loc) return;
call('update_drop_zones', {
remote: {x: rem.left, y: rem.top, w: rem.width, h: rem.height},
local: {x: loc.left, y: loc.top, w: loc.width, h: loc.height},
});
}
function esc(s) {
if (s == null) return '';
const d = document.createElement('div');
d.textContent = String(s);
return d.innerHTML;
}
function fileColor(f) {
if (f.isDir) return 'color-dir';
const ext = (f.name.split('.').pop() || '').toLowerCase();
if (['nsp','xci','nsz','xcz'].includes(ext)) return 'color-game';
if (ext === 'nro') return 'color-hb';
return 'color-file';
}
// ── File table builder ────────────────────────────────────────────────────────
const FILE_TABLE_HEADER = `<thead><tr>
<th>Name</th><th class="col-size">Size</th><th class="col-date">Modified</th>
</tr></thead>`;
function buildFileRows(files, selSet, idKey, allowDrag = false) {
return files.map((f, i) => {
const key = String(f[idKey] != null ? f[idKey] : (f.path || f.name));
const sel = selSet.has(key) ? ' selected' : '';
const cc = fileColor(f);
const drag = allowDrag && !f.isDir ? ' draggable="true"' : '';
return `<tr class="frow${sel}" data-idx="${i}" data-key="${esc(key)}" data-is-dir="${f.isDir ? '1' : '0'}"${drag}>
<td class="col-name"><div class="fname-cell">
<span class="dot ${cc}"></span>
<span class="${cc}">${esc(f.name)}</span>
</div></td>
<td class="col-size">${f.isDir ? '' : esc(f.sizeStr)}</td>
<td class="col-date">${esc(f.date)}</td>
</tr>`;
}).join('');
}
// ── Selection helpers ─────────────────────────────────────────────────────────
function applySelectionToDOM(container, selSet) {
container.querySelectorAll('.frow').forEach(r => {
r.classList.toggle('selected', selSet.has(r.dataset.key));
});
}
function selectionKeys(files, idKey) {
return files.map(f => String(f[idKey] != null ? f[idKey] : (f.path || f.name)));
}
// ── Local panel ───────────────────────────────────────────────────────────────
function renderLocalFiles() {
const el = document.getElementById('local-files');
const fp = fingerprintFiles(S.localFiles);
if (fp !== _localFingerprint) {
_localFingerprint = fp;
// Full rebuild only when file list actually changed
if (!S.localFiles.length) {
el.innerHTML = `<div class="empty-state">
<svg viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-width="1.5" class="empty-icon">
<path d="M6 38V14a2 2 0 012-2h12l4 4h16a2 2 0 012 2v20a2 2 0 01-2 2H8a2 2 0 01-2-2z"/>
</svg>
<div class="empty-title">No files in this folder</div></div>`;
return;
}
el.innerHTML = `<table class="ftable">${FILE_TABLE_HEADER}
<tbody>${buildFileRows(S.localFiles, S.selLocal, 'path', true)}</tbody></table>`;
attachLocalEvents(el);
} else {
// Cheap path: just sync selection highlight
applySelectionToDOM(el, S.selLocal);
}
}
function attachMarqueeSelect(el, selSet) {
let active = false, startX = 0, startY = 0, box = null, additive = false;
const finish = () => {
active = false;
if (box) { box.remove(); box = null; }
window.removeEventListener('mousemove', onMove);
window.removeEventListener('mouseup', finish);
};
const onMove = e => {
if (!active) return;
window.omniClearSelection();
const x1 = Math.min(startX, e.clientX), y1 = Math.min(startY, e.clientY);
const x2 = Math.max(startX, e.clientX), y2 = Math.max(startY, e.clientY);
Object.assign(box.style, {
left: x1 + 'px', top: y1 + 'px',
width: (x2 - x1) + 'px', height: (y2 - y1) + 'px',
});
if (!additive) selSet.clear();
el.querySelectorAll('.frow').forEach(row => {
const r = row.getBoundingClientRect();
const hit = r.right >= x1 && r.left <= x2 && r.bottom >= y1 && r.top <= y2;
if (hit) selSet.add(row.dataset.key);
});
applySelectionToDOM(el, selSet);
};
el.addEventListener('mousedown', e => {
if (e.button !== 0 || e.target.closest('.frow')) return;
e.preventDefault();
active = true;
additive = e.metaKey || e.shiftKey;
startX = e.clientX;
startY = e.clientY;
box = document.createElement('div');
box.className = 'marquee-box';
document.body.appendChild(box);
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', finish);
});
}
function attachLocalEvents(el) {
const files = S.localFiles;
const keys = selectionKeys(files, 'path');
el.querySelectorAll('.frow').forEach((row, i) => {
const f = files[i];
const key = keys[i];
// Click → select
row.addEventListener('click', e => {
handleRowClick(e, i, key, S.selLocal, keys, 'lastLocalIdx');
applySelectionToDOM(el, S.selLocal);
});
// Double-click → navigate dir OR upload file to Switch
row.addEventListener('dblclick', () => {
if (f.isDir) { call('navigate_local', {path: f.path}); }
else if (S.connected) { call('start_upload', {srcPath: f.path}); }
});
// Right-click → local context menu
row.addEventListener('contextmenu', e => {
e.preventDefault();
showLocalCtxMenu(f, e);
});
// Drag source: drag selected local file(s) to the remote panel
if (!f.isDir) {
row.addEventListener('dragstart', e => {
const dragFiles = (S.selLocal.has(key)
? files.filter((_, j) => S.selLocal.has(keys[j]))
: [f]).filter(df => !df.isDir);
if (!dragFiles.length) { e.preventDefault(); return; }
window.omniClearSelection();
e.dataTransfer.clearData();
e.dataTransfer.setData('application/x-omnimtp-local',
JSON.stringify(dragFiles.map(df => ({path: df.path, name: df.name}))));
e.dataTransfer.effectAllowed = 'copy';
e.dataTransfer.setDragImage(dragBadge(dragFiles.length, dragFiles[0].name), 0, 0);
});
}
});
attachMarqueeSelect(el, S.selLocal);
// Drop target: receive remote files dropped here → download
el.addEventListener('dragover', e => { e.preventDefault(); el.classList.add('drag-over'); });
el.addEventListener('dragleave', e => { if (!el.contains(e.relatedTarget)) el.classList.remove('drag-over'); });
el.addEventListener('drop', e => {
e.preventDefault();
el.classList.remove('drag-over');
const d = e.dataTransfer.getData('application/x-omnimtp-remote');
if (d) {
const items = JSON.parse(d);
(Array.isArray(items) ? items : [items]).forEach(rf => {
call('start_download', {handle: rf.handle, storageId: rf.storageId, filename: rf.name, size: rf.size});
});
}
});
}
// ── Remote panel ──────────────────────────────────────────────────────────────
function renderRemoteFiles() {
const el = document.getElementById('remote-files');
const empty = document.getElementById('remote-empty-state');
if (!S.connected) {
if (empty) empty.style.display = '';
el.querySelectorAll('table,.loading,.empty-state:not(#remote-empty-state)')
.forEach(n => n.remove());
_remoteFingerprint = '';
return;
}
if (empty) empty.style.display = 'none';
if (S.remoteRefreshing) {
el.innerHTML = '<div class="loading">Loading…</div>';
_remoteFingerprint = '__loading__';
return;
}
const fp = fingerprintFiles(S.remoteFiles);
if (fp !== _remoteFingerprint) {
_remoteFingerprint = fp;
if (!S.remoteFiles.length) {
el.innerHTML = `<div class="empty-state">
<svg viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-width="1.5" class="empty-icon">
<path d="M6 38V14a2 2 0 012-2h12l4 4h16a2 2 0 012 2v20a2 2 0 01-2 2H8a2 2 0 01-2-2z"/>
</svg>
<div class="empty-title">Empty folder</div></div>`;
} else {
el.innerHTML = `<table class="ftable">${FILE_TABLE_HEADER}
<tbody>${buildFileRows(S.remoteFiles, S.selRemote, 'handle', false)}</tbody></table>`;
}
attachRemoteEvents(el);
} else {
applySelectionToDOM(el, S.selRemote);
}
}
function attachRemoteEvents(el) {
const files = S.remoteFiles;
const keys = selectionKeys(files, 'handle');
el.querySelectorAll('.frow').forEach((row, i) => {
const f = files[i];
const key = keys[i];
row.addEventListener('click', e => {
handleRowClick(e, i, key, S.selRemote, keys, 'lastRemoteIdx');
applySelectionToDOM(el, S.selRemote);
});
row.addEventListener('dblclick', () => {
if (f.isDir) call('navigate_remote', {handle: f.handle, storageId: f.storageId, name: f.name});
});
row.addEventListener('contextmenu', e => { e.preventDefault(); showRemoteCtxMenu(f, e); });
});
attachMarqueeSelect(el, S.selRemote);
// Drop target: receive local files → upload
el.addEventListener('dragover', e => { e.preventDefault(); el.classList.add('drag-over'); });
el.addEventListener('dragleave', e => { if (!el.contains(e.relatedTarget)) el.classList.remove('drag-over'); });
el.addEventListener('drop', e => {
e.preventDefault();
el.classList.remove('drag-over');
// Internal drag (local app files)
const d = e.dataTransfer.getData('application/x-omnimtp-local');
if (d) {
const items = JSON.parse(d);
(Array.isArray(items) ? items : [items]).forEach(lf => {
call('start_upload', {srcPath: lf.path});
});
return;
}
// Finder drop
for (const file of (e.dataTransfer.files || [])) {
const p = file.path || '';
if (p) call('start_upload', {srcPath: p});
}
});
}
// ── Shared row click with shift-range support ─────────────────────────────────
function handleRowClick(e, idx, key, selSet, allKeys, lastIdxProp) {
if (e.shiftKey && S[lastIdxProp] >= 0) {
const lo = Math.min(idx, S[lastIdxProp]);
const hi = Math.max(idx, S[lastIdxProp]);
if (!e.metaKey) selSet.clear();
allKeys.slice(lo, hi + 1).forEach(k => selSet.add(k));
} else if (e.metaKey) {
if (selSet.has(key)) selSet.delete(key); else selSet.add(key);
S[lastIdxProp] = idx;
} else {
selSet.clear();
selSet.add(key);
S[lastIdxProp] = idx;
}
}
// ── Drag badge ghost image ─────────────────────────────────────────────────────
function dragBadge(count, firstName) {
const el = document.createElement('div');
el.style.cssText = [
'position:fixed', 'top:-200px', 'left:-200px',
'background:rgba(10,132,255,0.85)', 'color:#fff',
'font:600 12px -apple-system', 'padding:4px 10px',
'border-radius:10px', 'white-space:nowrap',
].join(';');
el.textContent = count > 1 ? `${count} files` : firstName;
document.body.appendChild(el);
setTimeout(() => el.remove(), 0);
return el;
}
// ── Toolbars ──────────────────────────────────────────────────────────────────
function renderLocalToolbar() {
const sel = document.getElementById('local-drive-sel');
if (S.localDrives && S.localDrives.length) {
const prev = sel.value;
sel.innerHTML = S.localDrives.map(d =>
`<option value="${esc(d.path)}">${esc(d.name)}</option>`
).join('');
const active = S.activeLocalDrive || prev;
if ([...sel.options].some(o => o.value === active)) sel.value = active;
sel.style.display = '';
} else {
sel.style.display = 'none';
}
const crumb = document.getElementById('local-breadcrumb');
const parts = (S.localPath || '').split('/').filter(Boolean);
const show = parts.slice(-4);
let html = show.length < parts.length ? '<span class="crumb-ellipsis">…</span><span class="crumb-sep">›</span>' : '';
show.forEach((p, i) => {
const fullIdx = parts.length - show.length + i;
const path = '/' + parts.slice(0, fullIdx + 1).join('/');
html += i === show.length - 1
? `<span class="crumb-cur">${esc(p)}</span>`
: `<span class="crumb-link" data-path="${esc(path)}">${esc(p)}</span><span class="crumb-sep">›</span>`;
});
crumb.innerHTML = html;
crumb.querySelectorAll('.crumb-link').forEach(el =>
el.addEventListener('click', () => call('navigate_local', {path: el.dataset.path})));
document.getElementById('btn-local-up').disabled = parts.length === 0;
}
function renderRemoteToolbar() {
const sel = document.getElementById('storage-sel');
if (S.storages && S.storages.length) {
const prev = sel.value;
sel.innerHTML = S.storages.map(s =>
`<option value="${s.id}">${esc(s.name)}${s.freeStr ? ' (' + s.freeStr + ')' : ''}</option>`
).join('');
sel.value = String(S.activeStorageId || prev);
sel.style.display = '';
} else {
sel.style.display = 'none';
}
const stack = S.remoteNavStack || [];
const crumb = document.getElementById('remote-breadcrumb');
crumb.innerHTML = stack.slice(1).map((e, i) => {
const absIdx = i + 1;
return absIdx === stack.length - 1
? `<span class="crumb-cur">${esc(e.label)}</span>`
: `<span class="crumb-link" data-idx="${absIdx}">${esc(e.label)}</span><span class="crumb-sep">›</span>`;
}).join('');
crumb.querySelectorAll('.crumb-link').forEach(el =>
el.addEventListener('click', () => call('navigate_remote_to', {index: +el.dataset.idx})));
document.getElementById('btn-remote-up').disabled = stack.length <= 1;
}
// ── Device bar ────────────────────────────────────────────────────────────────
function renderDeviceBar() {
const badge = document.getElementById('conn-badge');
const status = document.getElementById('device-status-text');
if (S.connected) {
badge.className = 'badge connected';
badge.textContent = S.deviceName || 'Nintendo Switch';
status.textContent = S.deviceName || 'Nintendo Switch connected';
} else {
badge.className = 'badge disconnected';
badge.textContent = 'No Device';
status.textContent = 'Waiting for Nintendo Switch…';
}
}
// ── Transfers panel ───────────────────────────────────────────────────────────
function renderTransfers() {
const list = document.getElementById('transfers-list');
const label = document.getElementById('transfers-label');
const cancelAll= document.getElementById('btn-cancel-all');
const panel = document.getElementById('transfers-panel');
const active = (S.transfers || []).filter(t => t.state <= 1);
label.textContent = active.length ? `${active.length} Transfer${active.length > 1 ? 's' : ''}` : 'Transfers';
cancelAll.style.display = active.length ? '' : 'none';
if (!S.transfers || !S.transfers.length) { list.innerHTML = ''; panel.style.display = 'none'; return; }
panel.style.display = '';
const stateNames = ['Queued','','Done','Failed','Cancelled'];
list.innerHTML = S.transfers.map(t => {
const dir = t.direction === 0 ? 'DL' : 'UP';
const dc = t.direction === 0 ? 'dir-dl' : 'dir-ul';
const pct = ((t.progress || 0) * 100).toFixed(0);
const showBar = t.state <= 1;
const statText = t.state === 1 ? esc(t.speedStr)
: t.state === 3 ? '⚠ ' + esc(t.error || 'Failed')
: stateNames[t.state] || '';
return `<div class="txfer-item">
<div class="txfer-row">
<span class="txfer-dir ${dc}">${dir}</span>
<span class="txfer-name" title="${esc(t.filename)}">${esc(t.filename)}</span>
<span class="txfer-stat">${statText}</span>
${showBar ? `<button class="txfer-cancel" data-id="${esc(t.id)}">×</button>` : ''}
</div>
${showBar ? `<div class="pbar"><div class="pbar-fill" style="width:${pct}%"></div></div>
<div class="txfer-meta">${pct}%${t.etaStr ? ' · ' + t.etaStr : ''}</div>` : ''}
</div>`;
}).join('');
list.querySelectorAll('.txfer-cancel').forEach(btn =>
btn.addEventListener('click', () => call('cancel_transfer', {id: btn.dataset.id})));
}
// ── Status bar ────────────────────────────────────────────────────────────────
function renderStatusBar() {
const lf = (S.localFiles || []).length;
const rf = (S.remoteFiles || []).length;
document.getElementById('sb-left').textContent = `${lf} item${lf !== 1 ? 's' : ''}`;
document.getElementById('sb-right').textContent = S.connected ? `${rf} item${rf !== 1 ? 's' : ''}` : '';
let center = '';
if (S.statusTimer > 0 && S.statusMessage) {
center = S.statusMessage;
} else if (S.connected) {
const act = (S.transfers || []).filter(t => t.state === 1);
if (act.length) {
const spd = act.reduce((s, t) => s + (t.speedBps || 0), 0);
center = `${act.length} active · ` + (spd > 1048576
? (spd/1048576).toFixed(0) + ' MB/s'
: (spd/1024).toFixed(0) + ' KB/s');
}
}
document.getElementById('sb-center').textContent = center;
}
// ── Context menus ─────────────────────────────────────────────────────────────
let _ctxFile = null, _ctxIsLocal = false;
function showLocalCtxMenu(file, e) {
_ctxFile = file;
_ctxIsLocal = true;
const m = document.getElementById('ctx-menu');
document.getElementById('ctx-upload').style.display = (!file.isDir && S.connected) ? '' : 'none';
document.getElementById('ctx-download').style.display = 'none';
document.getElementById('ctx-delete').style.display = 'none';
document.getElementById('ctx-sep-del').style.display = 'none';
m.style.left = e.clientX + 'px';
m.style.top = e.clientY + 'px';
m.classList.remove('hidden');
}
function showRemoteCtxMenu(file, e) {
_ctxFile = file;
_ctxIsLocal = false;
if (!S.selRemote.has(String(file.handle))) {
S.selRemote.clear();
S.selRemote.add(String(file.handle));
applySelectionToDOM(document.getElementById('remote-files'), S.selRemote);
}
const m = document.getElementById('ctx-menu');
const showDownload = !file.isDir;
document.getElementById('ctx-upload').style.display = 'none';
document.getElementById('ctx-download').style.display = showDownload ? '' : 'none';
document.getElementById('ctx-delete').style.display = '';
document.getElementById('ctx-sep-del').style.display = showDownload ? '' : 'none';
m.style.left = e.clientX + 'px';
m.style.top = e.clientY + 'px';
m.classList.remove('hidden');
}
document.addEventListener('click', e => {
if (e.target.closest('#ctx-menu')) return;
document.getElementById('ctx-menu').classList.add('hidden');
});
document.addEventListener('contextmenu', e => { if (!e.target.closest('.frow')) { e.preventDefault(); document.getElementById('ctx-menu').classList.add('hidden'); } });
// ── Init ──────────────────────────────────────────────────────────────────────
window.omniClearSelection = function() {
const sel = window.getSelection();
if (sel && sel.rangeCount) sel.removeAllRanges();
};
window.omniRemoteDragFilesAt = function(x, y) {
if (!S.connected) return '';
const hit = document.elementFromPoint(x, y);
const row = hit?.closest('#remote-files .frow');
if (!row || row.dataset.isDir === '1') return '';
const f = S.remoteFiles[+row.dataset.idx];
if (!f || f.isDir) return '';
const key = String(f.handle);
const dragFiles = S.selRemote.has(key)
? S.remoteFiles.filter(rf => S.selRemote.has(String(rf.handle)) && !rf.isDir)
: [f];
if (!dragFiles.length) return '';
return JSON.stringify(dragFiles.map(df => ({
handle: df.handle, storageId: df.storageId, name: df.name, size: df.size,
})));
};
// Block text selection and text-drag; only explicit file drags allowed.
document.addEventListener('selectstart', e => {
if (e.target.closest('.file-list')) e.preventDefault();
}, true);
document.addEventListener('mousedown', e => {
if (e.button !== 0) return;
if (e.target.closest('.file-list .frow')) e.preventDefault();
}, true);
document.addEventListener('dragstart', e => {
const row = e.target.closest('.file-list .frow');
if (!row) return;
if (row.closest('#remote-files') || row.getAttribute('draggable') !== 'true') {
e.preventDefault();
return;
}
window.omniClearSelection();
if (e.dataTransfer) {
e.dataTransfer.effectAllowed = 'copy';
e.dataTransfer.setData('text/plain', '');
}
}, true);
document.addEventListener('DOMContentLoaded', () => {
document.getElementById('btn-local-up') .addEventListener('click', () => call('navigate_local_up'));
document.getElementById('btn-local-home') .addEventListener('click', () => call('navigate_local', {path: '~'}));
document.getElementById('btn-local-refresh').addEventListener('click', () => call('refresh_local'));
document.getElementById('local-drive-sel') .addEventListener('change', e => call('navigate_local', {path: e.target.value}));
document.getElementById('btn-remote-up') .addEventListener('click', () => call('navigate_remote_up'));
document.getElementById('btn-remote-refresh').addEventListener('click', () => call('refresh_remote'));
document.getElementById('btn-scan') .addEventListener('click', () => call('reconnect'));
document.getElementById('btn-open-picker') .addEventListener('click', () => call('open_file_picker'));
document.getElementById('btn-cancel-all') .addEventListener('click', () => call('cancel_all'));
document.getElementById('storage-sel') .addEventListener('change', e => call('set_storage', {storageId: +e.target.value}));
// Context menu actions
const bindCtx = (id, fn) => {
document.getElementById(id).addEventListener('click', e => {
e.stopPropagation();
fn();
});
};
bindCtx('ctx-upload', () => {
if (!_ctxFile) return;
const uploadFiles = S.selLocal.size > 1 && S.selLocal.has(_ctxFile.path)
? S.localFiles.filter(f => S.selLocal.has(f.path))
: [_ctxFile];
uploadFiles.forEach(f => call('start_upload', {srcPath: f.path}));
});
bindCtx('ctx-download', () => {
if (!_ctxFile) return;
const downloadFiles = S.selRemote.size > 1 && S.selRemote.has(String(_ctxFile.handle))
? S.remoteFiles.filter(f => S.selRemote.has(String(f.handle)))
: [_ctxFile];
downloadFiles.forEach(f => call('start_download', {handle: f.handle, storageId: f.storageId, filename: f.name, size: f.size}));
});
bindCtx('ctx-delete', () => {
if (!_ctxFile) return;
const label = S.selRemote.size > 1 && S.selRemote.has(String(_ctxFile.handle))
? `${S.selRemote.size} items`
: `"${_ctxFile.name}"`;
if (window.confirm(`Delete ${label}?`)) {
const handles = S.selRemote.size > 1 && S.selRemote.has(String(_ctxFile.handle))
? S.remoteFiles.filter(f => S.selRemote.has(String(f.handle))).map(f => f.handle)
: [_ctxFile.handle];
handles.forEach(h => call('delete_remote', {handle: h}));
}
document.getElementById('ctx-menu').classList.add('hidden');
});
// Keyboard
document.addEventListener('keydown', e => {
if (e.target.matches('input,textarea,select')) return;
if (e.key === 'Backspace' || e.key === 'Delete') {
S.selRemote.forEach(h => call('delete_remote', {handle: +h}));
}
if (e.metaKey && e.key === 'r') {
e.preventDefault();
call('refresh_remote');
call('refresh_local');
}
// Cmd+A = select all
if (e.metaKey && e.key === 'a') {
e.preventDefault();
const active = document.activeElement;
if (active && active.closest('#local-files')) {
S.localFiles.forEach(f => S.selLocal.add(f.path));
applySelectionToDOM(document.getElementById('local-files'), S.selLocal);
} else {
S.remoteFiles.forEach(f => S.selRemote.add(String(f.handle)));
applySelectionToDOM(document.getElementById('remote-files'), S.selRemote);
}
}
// Escape = deselect
if (e.key === 'Escape') {
S.selLocal.clear(); S.selRemote.clear();
applySelectionToDOM(document.getElementById('local-files'), S.selLocal);
applySelectionToDOM(document.getElementById('remote-files'), S.selRemote);
}
});
poll();
updateDropZones();
window.addEventListener('resize', updateDropZones);
});
+106
View File
@@ -0,0 +1,106 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>OmniMTP</title>
<link rel="stylesheet" href="app.css">
</head>
<body>
<div id="titlebar">
<div class="tl-spacer"></div>
<span id="app-title">OmniMTP</span>
<div id="conn-badge" class="badge disconnected">No Device</div>
</div>
<div id="device-bar">
<div class="device-left">
<button class="action-btn" id="btn-open-picker">Add Files</button>
</div>
<div class="device-center" id="device-status-text">Waiting for Nintendo Switch…</div>
<div class="device-right">
<button class="action-btn primary" id="btn-scan">Scan</button>
</div>
</div>
<div id="main">
<!-- Left: Local -->
<div class="panel" id="panel-local">
<div class="panel-toolbar">
<div class="nav-group">
<button class="nav-btn" id="btn-local-up" title="Go Up">
<svg viewBox="0 0 16 16"><path d="M8 3L3 8h3v5h4V8h3L8 3z" fill="currentColor"/></svg>
</button>
<button class="nav-btn" id="btn-local-home" title="Home">
<svg viewBox="0 0 16 16"><path d="M8 2L2 7h2v6h4v-4h4v4h2V7l2 0-6-5z" fill="currentColor"/></svg>
</button>
</div>
<select id="local-drive-sel" class="storage-select" title="Drive"></select>
<div id="local-breadcrumb" class="breadcrumb"></div>
<button class="nav-btn icon-only" id="btn-local-refresh" title="Refresh">
<svg viewBox="0 0 16 16"><path d="M13.5 8A5.5 5.5 0 1 1 8 2.5V1l3 2.5L8 6V4.5A3.5 3.5 0 1 0 11.5 8h2z" fill="currentColor"/></svg>
</button>
</div>
<div class="file-list" id="local-files">
<div class="loading">Loading…</div>
</div>
</div>
<!-- Divider -->
<div id="panel-divider"></div>
<!-- Right: Switch -->
<div class="panel" id="panel-remote">
<div class="panel-toolbar">
<div class="nav-group">
<button class="nav-btn" id="btn-remote-up" title="Go Up" disabled>
<svg viewBox="0 0 16 16"><path d="M8 3L3 8h3v5h4V8h3L8 3z" fill="currentColor"/></svg>
</button>
</div>
<select id="storage-sel" class="storage-select"></select>
<div id="remote-breadcrumb" class="breadcrumb"></div>
<button class="nav-btn icon-only" id="btn-remote-refresh" title="Refresh">
<svg viewBox="0 0 16 16"><path d="M13.5 8A5.5 5.5 0 1 1 8 2.5V1l3 2.5L8 6V4.5A3.5 3.5 0 1 0 11.5 8h2z" fill="currentColor"/></svg>
</button>
</div>
<div class="file-list" id="remote-files">
<div class="empty-state" id="remote-empty-state">
<svg viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-width="1.5" class="empty-icon">
<rect x="8" y="4" width="32" height="40" rx="4"/>
<line x1="24" y1="12" x2="24" y2="18"/>
<circle cx="24" cy="22" r="1.5" fill="currentColor"/>
</svg>
<div class="empty-title">No Switch Connected</div>
<div class="empty-sub">Connect via USB-C and open DBI or Sphaira MTP</div>
</div>
</div>
</div>
</div>
<!-- Transfers -->
<div id="transfers-panel">
<div id="transfers-header">
<span id="transfers-label">Transfers</span>
<button class="action-btn danger small" id="btn-cancel-all" style="display:none">Cancel All</button>
</div>
<div id="transfers-list"></div>
</div>
<!-- Status bar -->
<div id="statusbar">
<span id="sb-left"></span>
<span id="sb-center"></span>
<span id="sb-right"></span>
</div>
<!-- Context menu -->
<div id="ctx-menu" class="ctx-menu hidden">
<div class="ctx-item" id="ctx-upload">Upload to Switch</div>
<div class="ctx-item" id="ctx-download">Download to Mac</div>
<div class="ctx-item ctx-sep" id="ctx-sep-del"></div>
<div class="ctx-item danger" id="ctx-delete">Delete…</div>
</div>
<script src="app.js"></script>
</body>
</html>