diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..b758826 --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,145 @@ +cmake_minimum_required(VERSION 3.25) +project(OmniMTP VERSION 1.0.0 + DESCRIPTION "Nintendo Switch MTP Client for macOS" + LANGUAGES CXX OBJCXX OBJC C +) + +# ─── Language standards ─────────────────────────────────────────────────────── +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_OBJCXX_STANDARD 20) +set(CMAKE_C_STANDARD 11) +set(CMAKE_C_STANDARD_REQUIRED ON) + +# ─── macOS targeting ────────────────────────────────────────────────────────── +set(CMAKE_OSX_DEPLOYMENT_TARGET "12.0" CACHE STRING "Minimum macOS version") + +if(NOT CMAKE_OSX_ARCHITECTURES) + set(CMAKE_OSX_ARCHITECTURES "arm64;x86_64" CACHE STRING "Target CPU architectures" FORCE) +endif() + +if(NOT CMAKE_BUILD_TYPE) + set(CMAKE_BUILD_TYPE "Release" CACHE STRING "Build type" FORCE) +endif() + +include(FetchContent) +set(FETCHCONTENT_QUIET FALSE) + +# ─── libusb (built from source, fully static) ───────────────────────────────── +cmake_policy(PUSH) +cmake_policy(SET CMP0169 OLD) # allow FetchContent_Populate (deprecated in 3.30 but fine) +FetchContent_Declare(libusb_src + GIT_REPOSITORY https://github.com/libusb/libusb.git + GIT_TAG v1.0.27 + GIT_SHALLOW TRUE +) +FetchContent_GetProperties(libusb_src) +if(NOT libusb_src_POPULATED) + FetchContent_Populate(libusb_src) +endif() +cmake_policy(POP) + +# Provide a pre-configured config.h for macOS (avoids autoconf requirement) +configure_file( + "${CMAKE_SOURCE_DIR}/cmake/libusb_config.h" + "${libusb_src_SOURCE_DIR}/config.h" + COPYONLY +) + +add_library(usb_static STATIC + ${libusb_src_SOURCE_DIR}/libusb/core.c + ${libusb_src_SOURCE_DIR}/libusb/descriptor.c + ${libusb_src_SOURCE_DIR}/libusb/hotplug.c + ${libusb_src_SOURCE_DIR}/libusb/io.c + ${libusb_src_SOURCE_DIR}/libusb/sync.c + ${libusb_src_SOURCE_DIR}/libusb/strerror.c + ${libusb_src_SOURCE_DIR}/libusb/os/darwin_usb.c + ${libusb_src_SOURCE_DIR}/libusb/os/events_posix.c + ${libusb_src_SOURCE_DIR}/libusb/os/threads_posix.c +) +target_include_directories(usb_static PUBLIC ${libusb_src_SOURCE_DIR}/libusb) +target_include_directories(usb_static PRIVATE + ${libusb_src_SOURCE_DIR} # config.h lives here + ${libusb_src_SOURCE_DIR}/libusb/os +) +target_compile_definitions(usb_static PRIVATE HAVE_CONFIG_H) +target_compile_options(usb_static PRIVATE -Wno-deprecated-declarations -Wno-error) + +find_library(FW_IOKIT IOKit REQUIRED) +find_library(FW_COREFOUNDATION CoreFoundation REQUIRED) +find_library(FW_SECURITY Security REQUIRED) +target_link_libraries(usb_static PUBLIC + ${FW_IOKIT} ${FW_COREFOUNDATION} ${FW_SECURITY} +) + +# ─── System frameworks ──────────────────────────────────────────────────────── +find_library(FW_METAL Metal REQUIRED) +find_library(FW_APPKIT AppKit REQUIRED) +find_library(FW_FOUNDATION Foundation REQUIRED) +find_library(FW_WEBKIT WebKit REQUIRED) +find_library(FW_CORETEXT CoreText REQUIRED) + +# ─── Application ───────────────────────────────────────────────────────────── +file(GLOB_RECURSE APP_SOURCES CONFIGURE_DEPENDS + "${CMAKE_SOURCE_DIR}/src/*.cpp" + "${CMAKE_SOURCE_DIR}/src/*.mm" +) + +# webroot resource files — copied into the bundle at Resources/webroot/ +file(GLOB WEBROOT_FILES "src/ui/webroot/*") +foreach(WF ${WEBROOT_FILES}) + set_source_files_properties(${WF} PROPERTIES MACOSX_PACKAGE_LOCATION Resources/webroot) +endforeach() + +# App icon — generated from assets/AppIcon.png at build time +set(APP_ICON_SOURCE "${CMAKE_SOURCE_DIR}/assets/AppIcon.png") +set(APP_ICON_ICNS "${CMAKE_CURRENT_BINARY_DIR}/AppIcon.icns") +add_custom_command( + OUTPUT ${APP_ICON_ICNS} + COMMAND "${CMAKE_SOURCE_DIR}/cmake/generate_icns.sh" + "${APP_ICON_SOURCE}" "${APP_ICON_ICNS}" + DEPENDS "${APP_ICON_SOURCE}" "${CMAKE_SOURCE_DIR}/cmake/generate_icns.sh" + COMMENT "Generating AppIcon.icns" + VERBATIM +) + +add_executable(OmniMTP MACOSX_BUNDLE ${APP_SOURCES} ${WEBROOT_FILES} ${APP_ICON_ICNS}) + +target_include_directories(OmniMTP PRIVATE + ${CMAKE_SOURCE_DIR}/src + ${libusb_src_SOURCE_DIR}/libusb +) +target_link_libraries(OmniMTP PRIVATE + usb_static + ${FW_APPKIT} ${FW_FOUNDATION} ${FW_METAL} ${FW_WEBKIT} ${FW_CORETEXT} +) + +target_compile_options(OmniMTP PRIVATE + -Wall -Wextra + $<$:-Wpedantic> + $<$:-fobjc-arc -Wpedantic> +) + +set_target_properties(OmniMTP PROPERTIES + MACOSX_BUNDLE_GUI_IDENTIFIER "com.omni.OmniMTP" + MACOSX_BUNDLE_BUNDLE_NAME "OmniMTP" + MACOSX_BUNDLE_BUNDLE_VERSION "${PROJECT_VERSION}" + MACOSX_BUNDLE_SHORT_VERSION_STRING "${PROJECT_VERSION}" + MACOSX_BUNDLE_COPYRIGHT "Copyright © 2025 OmniMTP" + MACOSX_BUNDLE_INFO_PLIST "${CMAKE_SOURCE_DIR}/cmake/Info.plist.in" + MACOSX_BUNDLE_ICON_FILE AppIcon +) + +set_source_files_properties(${APP_ICON_ICNS} PROPERTIES + MACOSX_PACKAGE_LOCATION Resources + GENERATED TRUE +) + +# ─── Status ─────────────────────────────────────────────────────────────────── +message(STATUS "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") +message(STATUS " OmniMTP — Nintendo Switch MTP Client") +message(STATUS " macOS target : ${CMAKE_OSX_DEPLOYMENT_TARGET}+") +message(STATUS " Architectures: ${CMAKE_OSX_ARCHITECTURES}") +message(STATUS " Build type : ${CMAKE_BUILD_TYPE}") +message(STATUS "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") +message(STATUS "Build: cmake -B build && cmake --build build -j$(sysctl -n hw.logicalcpu)") diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..c283c9f --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 helzeiah + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..fa91679 --- /dev/null +++ b/README.md @@ -0,0 +1,163 @@ +
+ +

OmniMTP

+ +

Ein schneller (3–4× schneller als direkter microSD-Zugriff), nativer macOS-MTP-Client für die Nintendo Switch.
+Übertrage NSP-, XCI- und NRO-Dateien per USB-C, ohne die microSD-Karte auszubauen.

+ +[![macOS](https://img.shields.io/badge/macOS-12%2B-blue?style=flat-square&logo=apple&logoColor=white)](https://www.apple.com/macos/) +[![License](https://img.shields.io/badge/license-MIT-brightgreen?style=flat-square)](LICENSE) +[![C++20](https://img.shields.io/badge/C%2B%2B-20-orange?style=flat-square&logo=cplusplus&logoColor=white)]() +[![Universal](https://img.shields.io/badge/arch-Apple%20Silicon%20%2B%20Intel-lightgrey?style=flat-square)]() + +
+ +OmniMTP Screenshot + +
+ +--- + +> **Herkunft:** OmniMTP ist eine Weiterentwicklung von [heziMTP](https://github.com/helzeiah/heziMTP), dem ursprünglichen Projekt-Repository. + +## Funktionen + +- **Direkter USB-C-Transfer** — kommuniziert mit DBI oder Sphaira im MTP-Modus über USB, ohne SD-Kartenwechsel +- **Schnell** — USB-3.0-Unterstützung mit adaptiver Chunk-Größe; realistisch 60–150+ MB/s, abhängig von Switch und Kabel +- **Zwei-Panel-Dateibrowser** — Mac-Dateisystem links, Switch rechts; Dateien per Drag & Drop zwischen beiden Seiten verschieben +- **Mehrfachübertragung** — mehrere Dateien mit Cmd+Klick oder Shift+Klick auswählen und gemeinsam ziehen +- **Große Dateien** — Dateien >4 GB werden korrekt unterstützt +- **Automatische Geräteerkennung** — einstecken und loslegen, kein manuelles Scannen nötig +- **Modernes macOS-UI** — natives Dark Mode, SF Pro, macOS-12+-Fensterdesign +- **Keine Abhängigkeiten** — libusb ist statisch eingebunden; nichts extra installieren + +## Voraussetzungen + +- **Mac:** macOS 12 Monterey oder neuer (Universal Binary — Apple Silicon + Intel) +- **Switch:** Custom Firmware mit [DBI](https://github.com/rashevskyv/dbi) oder [Sphaira](https://github.com/ITotalJustice/sphaira) +- **Kabel:** ein Kabel mit Datenübertragung (kein reines Ladekabel) + +## Erste Schritte + +### 1. Switch vorbereiten + +In DBI: **Tools → MTP Responder** + +In Sphaira: **Tools → MTP** + +USB-C-Kabel anschließen und den Bildschirm offen lassen + +### 2. Aus dem Quellcode bauen + +```bash +git clone https://github.com/helzeiah/OmniMTP.git +cd OmniMTP +./build.sh # Release-Build +./build.sh run # bauen + starten +``` + +Oder manuell: + +```bash +cmake -B build -DCMAKE_BUILD_TYPE=Release +cmake --build build -j$(sysctl -n hw.logicalcpu) +open build/OmniMTP.app +``` + +**Build-Abhängigkeiten** (werden von CMake automatisch geladen): + +- libusb 1.0.27 +- keine weiteren externen Abhängigkeiten + +### 3. Dateien übertragen + +- **Links** den Mac durchsuchen, **rechts** die Switch +- **Ziehen:** links → rechts = hochladen, rechts → links = herunterladen +- **Doppelklick** auf eine lokale Datei lädt sie bei aktiver Verbindung direkt hoch +- **Rechtsklick** für Kontextmenü (Auf Switch hochladen / Auf Mac laden / Löschen) +- **Add Files** öffnet einen nativen Datei-Dialog für Mehrfach-Uploads + +## Wie schnell ist es? + +| Verbindung | Typische Geschwindigkeit | +| ---------------------------------------- | ------------------------ | +| USB 2.0 | 25–45 MB/s | +| USB 3.0 (Switch OLED / direktes USB-C 3.x) | 60–150+ MB/s | + +Die Geschwindigkeit hängt von der microSD-Schreibgeschwindigkeit (Uploads) und der USB-Verbindung ab. Eine schnelle UHS-I-Karte plus USB-3.0-Kabel bringt das meiste raus. + +## Hinweis zur Installation (macOS Gatekeeper) + +Da OmniMTP nicht mit einem Apple-Developer-Zertifikat notarisiert ist, zeigt macOS beim ersten Start eine Warnung **„Unbekannter Entwickler“**. Das ist bei Open-Source-Apps außerhalb des App Store normal. + +**So öffnest du die App:** + +**Option A — Rechtsklick (am einfachsten)** +Rechtsklick (oder Control-Klick) auf `OmniMTP.app` → **Öffnen** → im Dialog erneut **Öffnen**. Das musst du nur einmal machen. + +**Option B — Terminal** +```bash +xattr -r -d com.apple.quarantine /Applications/OmniMTP.app +# oder wo auch immer du die App abgelegt hast: +xattr -r -d com.apple.quarantine ~/Downloads/OmniMTP.app +``` + +**Option C — Systemeinstellungen** +Systemeinstellungen → Datenschutz & Sicherheit → Bereich Sicherheit → **Trotzdem öffnen**. + +--- + +## Fehlerbehebung + +**Die App erkennt meine Switch nicht** + +- Prüfe, ob DBI oder Sphaira im MTP-Modus läuft (nicht nur im Home-Menü) +- Probiere ein anderes USB-C-Kabel — viele Ladekabel haben keine Datenleitungen +- Klicke in der App auf **Scan** + +**Fehler „Operation not supported“** + +- DBI/Sphaira auf eine aktuelle Version aktualisieren +- Sphaira: sicherstellen, dass du im Menü **MTP Install** bist + +## Geplante Erweiterungen + +- **Breitere MTP-Geräteunterstützung** — die MTP/USB-Schicht ist geräteunabhängig. Android-Handys, Kameras und andere MTP-Geräte sollten mit kleinen Anpassungen an Erkennung und Protokoll funktionieren +- Ordner-Upload (rekursiv) +- Unterbrochene Übertragungen fortsetzen +- Warteschlange umsortieren +- Dateien auf dem Gerät umbenennen + +## Bauen + +``` +./build.sh # Release (Standard) +./build.sh debug # Debug-Build +./build.sh run # Release + starten +./build.sh clean # Build-Verzeichnisse löschen +``` + +Das Build-System lädt libusb aus dem Quellcode und kompiliert es statisch — kein Homebrew oder Systempakete nötig. + +## Architektur + +``` +src/ +├── main.mm Einstiegspunkt (NSApp + WKWebView) +├── ui/ +│ ├── App.hpp/.mm Backend: Geräteüberwachung, lokale/remote Dateien, Transfers +│ ├── WebUI.hpp/.mm Bridge: WKWebView-Setup + JS↔C++ Message Handler +│ └── webroot/ Frontend: HTML/CSS/JS (ohne Build-Schritt, ohne Frameworks) +├── mtp/ +│ ├── MTPProtocol.hpp MTP-Konstanten, Container-Format, Datenstrukturen +│ ├── MTPSession.* USB/libusb-Transport, Chunked Transfers +│ └── MTPOperations.* High-Level-MTP-Operationen (GetObject, SendObject, …) +└── transfer/ + └── TransferEngine.* Hintergrund-Warteschlange mit Fortschrittsanzeige +``` + +Die UI ist eine WKWebView-basierte HTML/CSS/JS-App, die über native Message Passing mit einem C++20-MTP-Backend spricht. Kein Electron, kein Node, keine externe Runtime — nur AppKit + WebKit + libusb. + +## Lizenz + +MIT — siehe [LICENSE](LICENSE). diff --git a/assets/AppIcon.png b/assets/AppIcon.png new file mode 100644 index 0000000..5d99e82 Binary files /dev/null and b/assets/AppIcon.png differ diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..2ae70a5 --- /dev/null +++ b/build.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +set -euo pipefail + +# ── OmniMTP build script ────────────────────────────────────────────────────── +# Usage: +# ./build.sh → release build (default) +# ./build.sh debug → debug build +# ./build.sh release → release build +# ./build.sh clean → wipe all build dirs +# ./build.sh run → release build + open the app +# ./build.sh run debug → debug build + open the app + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +JOBS=$(sysctl -n hw.logicalcpu) + +# ── Parse args ──────────────────────────────────────────────────────────────── +MODE="release" +RUN=false + +for arg in "$@"; do + case "$arg" in + debug) MODE="debug" ;; + release) MODE="release" ;; + run) RUN=true ;; + clean) + echo "Cleaning build directories..." + rm -rf "$SCRIPT_DIR/build" "$SCRIPT_DIR/build_debug" "$SCRIPT_DIR/build_release" + echo "Done." + exit 0 + ;; + *) + echo "Unknown argument: $arg" + echo "Usage: $0 [debug|release|clean|run]" + exit 1 + ;; + esac +done + +# ── Resolve build dir and CMake config ─────────────────────────────────────── +if [[ "$MODE" == "debug" ]]; then + BUILD_DIR="$SCRIPT_DIR/build_debug" + CMAKE_TYPE="Debug" + LABEL="Debug" +else + BUILD_DIR="$SCRIPT_DIR/build_release" + CMAKE_TYPE="Release" + LABEL="Release" +fi + +APP_PATH="$BUILD_DIR/OmniMTP.app" + +# ── Configure (only if CMakeCache.txt is missing) ──────────────────────────── +if [[ ! -f "$BUILD_DIR/CMakeCache.txt" ]]; then + echo "── Configuring ($LABEL) ─────────────────────────────────────────" + cmake -B "$BUILD_DIR" \ + -DCMAKE_BUILD_TYPE="$CMAKE_TYPE" \ + -S "$SCRIPT_DIR" +fi + +# ── Build ──────────────────────────────────────────────────────────────────── +echo "── Building ($LABEL, $JOBS threads) ────────────────────────────────" +cmake --build "$BUILD_DIR" -j"$JOBS" + +# ── Result ─────────────────────────────────────────────────────────────────── +BINARY="$APP_PATH/Contents/MacOS/OmniMTP" +SIZE=$(du -sh "$BINARY" 2>/dev/null | cut -f1) +ARCH=$(file "$BINARY" | grep -o "arm64\|x86_64" | tr '\n' '+' | sed 's/+$//') +echo "" +echo "── Built: $APP_PATH" +echo " Binary: $SIZE ($ARCH universal)" + +if $RUN; then + echo "── Launching..." + open "$APP_PATH" +fi diff --git a/cmake/Info.plist.in b/cmake/Info.plist.in new file mode 100644 index 0000000..7ac0349 --- /dev/null +++ b/cmake/Info.plist.in @@ -0,0 +1,36 @@ + + + + + CFBundleExecutable + ${MACOSX_BUNDLE_EXECUTABLE_NAME} + CFBundleIdentifier + ${MACOSX_BUNDLE_GUI_IDENTIFIER} + CFBundleName + ${MACOSX_BUNDLE_BUNDLE_NAME} + CFBundleDisplayName + OmniMTP + CFBundleIconFile + AppIcon + CFBundleVersion + ${MACOSX_BUNDLE_BUNDLE_VERSION} + CFBundleShortVersionString + ${MACOSX_BUNDLE_SHORT_VERSION_STRING} + CFBundlePackageType + APPL + CFBundleSignature + ???? + LSMinimumSystemVersion + ${CMAKE_OSX_DEPLOYMENT_TARGET} + NSHighResolutionCapable + + NSSupportsAutomaticGraphicsSwitching + + + com.apple.security.app-sandbox + + NSPrincipalClass + NSApplication + + diff --git a/cmake/generate_icns.sh b/cmake/generate_icns.sh new file mode 100755 index 0000000..b9c17a2 --- /dev/null +++ b/cmake/generate_icns.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ $# -ne 2 ]]; then + echo "Usage: $0 " >&2 + exit 1 +fi + +SRC="$1" +OUT="$2" +WORKDIR="$(mktemp -d)" +trap 'rm -rf "$WORKDIR"' EXIT + +ICONSET="$WORKDIR/AppIcon.iconset" +mkdir -p "$ICONSET" + +make_icon() { + local name="$1" + local size="$2" + sips -z "$size" "$size" "$SRC" --out "$ICONSET/$name" >/dev/null +} + +make_icon icon_16x16.png 16 +make_icon icon_16x16@2x.png 32 +make_icon icon_32x32.png 32 +make_icon icon_32x32@2x.png 64 +make_icon icon_128x128.png 128 +make_icon icon_128x128@2x.png 256 +make_icon icon_256x256.png 256 +make_icon icon_256x256@2x.png 512 +make_icon icon_512x512.png 512 +make_icon icon_512x512@2x.png 1024 + +iconutil -c icns "$ICONSET" -o "$OUT" diff --git a/cmake/libusb_config.h b/cmake/libusb_config.h new file mode 100644 index 0000000..09f5099 --- /dev/null +++ b/cmake/libusb_config.h @@ -0,0 +1,39 @@ +/* libusb config.h for macOS (Darwin) — arm64 + x86_64, macOS 12+ */ +#pragma once + +/* clock_gettime available since macOS 10.12 */ +#define HAVE_CLOCK_GETTIME 1 + +/* pthread_condattr_setclock(CLOCK_MONOTONIC) is NOT available on macOS. + Must be undefined (not 0) so #ifdef checks are false. */ +#undef HAVE_PTHREAD_CONDATTR_SETCLOCK + +/* timerfd is Linux-only — must be undefined, not 0 */ +#undef HAVE_TIMERFD + +/* Standard POSIX headers present on macOS */ +#define HAVE_SYS_TIME_H 1 +#define HAVE_DLFCN_H 1 +#define HAVE_INTTYPES_H 1 +#define HAVE_MEMORY_H 1 +#define HAVE_STDINT_H 1 +#define HAVE_STDLIB_H 1 +#define HAVE_STRING_H 1 +#define HAVE_STRINGS_H 1 +#define HAVE_SYS_STAT_H 1 +#define HAVE_SYS_TYPES_H 1 +#define HAVE_UNISTD_H 1 +#define STDC_HEADERS 1 + +#define PLATFORM_POSIX 1 + +/* Visibility / printf format attributes */ +#define DEFAULT_VISIBILITY __attribute__((visibility("default"))) +#define PRINTF_FORMAT(a, b) __attribute__((__format__(__printf__, a, b))) + +/* 64-bit */ +#define SIZEOF_VOID_P 8 + +/* Logging enabled (not debug) */ +#define ENABLE_LOGGING 1 +#undef ENABLE_DEBUG_LOGGING diff --git a/heziMTP.png b/heziMTP.png new file mode 100644 index 0000000..edc1a1c Binary files /dev/null and b/heziMTP.png differ diff --git a/src/main.mm b/src/main.mm new file mode 100644 index 0000000..db1d6e6 --- /dev/null +++ b/src/main.mm @@ -0,0 +1,14 @@ +#include "ui/App.hpp" +#include "ui/WebUI.hpp" +#import + +int main(int, char**) { + @autoreleasepool { + omniMTP::App app; + app.init(); + omniMTP::WebUI ui(app); + ui.run(); + app.shutdown(); + } + return 0; +} diff --git a/src/mtp/MTPOperations.cpp b/src/mtp/MTPOperations.cpp new file mode 100644 index 0000000..87bf38a --- /dev/null +++ b/src/mtp/MTPOperations.cpp @@ -0,0 +1,333 @@ +#include "MTPOperations.hpp" +#include +#include +#include + +namespace mtp { + +// ─── Encoding helpers ───────────────────────────────────────────────────────── +void MTPOperations::put_le16(std::vector& b, uint16_t v) { + b.push_back(v & 0xFF); + b.push_back(v >> 8); +} +void MTPOperations::put_le32(std::vector& 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& b, uint64_t v) { + put_le32(b, static_cast(v)); + put_le32(b, static_cast(v >> 32)); +} + +// UTF-8 → MTP UTF-16LE string: [1-byte len][len × 2-byte UTF-16LE chars] +void MTPOperations::encode_mtp_string(std::vector& 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(len)); + for (size_t i = 0; i < len - 1; ++i) { + uint8_t c = static_cast(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(p[0]) | (static_cast(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(p[0]) | (static_cast(p[1])<<8); + p += 2; + if (!c) break; + if (c < 0x80) s += static_cast(c); + else if (c < 0x800) { s += static_cast(0xC0|(c>>6)); s += static_cast(0x80|(c&0x3F)); } + else { s += static_cast(0xE0|(c>>12)); s += static_cast(0x80|((c>>6)&0x3F)); s += static_cast(0x80|(c&0x3F)); } + } + return s; +} + +std::vector MTPOperations::parse_u32_array(const std::vector& 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 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& 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 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 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 MTPOperations::get_object_handles(uint32_t storage_id, + uint32_t parent_handle) { + std::lock_guard lock(session_.mutex()); + std::array 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 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& cancel) { + std::lock_guard lock(session_.mutex()); + std::array 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 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 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(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 build_prop_list(const std::string& filename) { + std::vector 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(PROP_OBJECT_FILENAME & 0xFF)); + b.push_back(static_cast(PROP_OBJECT_FILENAME >> 8)); + // data type = String (0xFFFF) + b.push_back(static_cast(DTYPE_STRING & 0xFF)); + b.push_back(static_cast(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(len)); + for (size_t i = 0; i < len - 1; ++i) { + b.push_back(static_cast(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& 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(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(file_size >> 32); + uint32_t size_lo = static_cast(file_size & 0xFFFFFFFFu); + std::array params{ + storage_id, parent_handle, + static_cast(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 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 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 params{ storage_id, parent_handle }; + session_.send_command(OpCode::SendObjectInfo, params); + + auto info = build_object_info(storage_id, parent_handle, name, 0, + static_cast(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 diff --git a/src/mtp/MTPOperations.hpp b/src/mtp/MTPOperations.hpp new file mode 100644 index 0000000..7b2928f --- /dev/null +++ b/src/mtp/MTPOperations.hpp @@ -0,0 +1,70 @@ +#pragma once +#include "MTPSession.hpp" +#include "MTPProtocol.hpp" +#include +#include +#include + +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 get_storage_ids(); + StorageInfo get_storage_info(uint32_t storage_id); + + // ── Object enumeration ──────────────────────────────────────────────────── + // parent_handle = ROOT_PARENT for root of a storage + std::vector 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& 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& 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 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& buf, const std::string& utf8); + static void put_le16(std::vector& b, uint16_t v); + static void put_le32(std::vector& b, uint32_t v); + static void put_le64(std::vector& b, uint64_t v); + + static std::vector parse_u32_array(const std::vector& data); + static ObjectInfo parse_object_info(const std::vector& data); +}; + +} // namespace mtp diff --git a/src/mtp/MTPProtocol.hpp b/src/mtp/MTPProtocol.hpp new file mode 100644 index 0000000..7267c8b --- /dev/null +++ b/src/mtp/MTPProtocol.hpp @@ -0,0 +1,226 @@ +#pragma once +#include +#include +#include + +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(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 operations_supported; + std::vector events_supported; + std::vector device_props_supported; + std::vector capture_formats; + std::vector 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 diff --git a/src/mtp/MTPSession.cpp b/src/mtp/MTPSession.cpp new file mode 100644 index 0000000..c130549 --- /dev/null +++ b/src/mtp/MTPSession.cpp @@ -0,0 +1,603 @@ +#include "MTPSession.hpp" +#include +#include +#include +#include +#include + +#include + +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(p[0]) | (static_cast(p[1]) << 8); + p += 2; + if (c == 0) break; + if (c < 0x80) { + out += static_cast(c); + } else if (c < 0x800) { + out += static_cast(0xC0 | (c >> 6)); + out += static_cast(0x80 | (c & 0x3F)); + } else { + out += static_cast(0xE0 | (c >> 12)); + out += static_cast(0x80 | ((c >> 6) & 0x3F)); + out += static_cast(0x80 | (c & 0x3F)); + } + } + return out; +} + +// ─── Read a uint16 array: [4-byte count][count × uint16] ───────────────────── +static std::vector 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 v(n); + for (auto& x : v) { + if (p + 2 > end) break; + x = static_cast(p[0]) | (static_cast(p[1]) << 8); + p += 2; + } + return v; +} + +// ─── Read a uint32 array ────────────────────────────────────────────────────── +static std::vector 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 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(p[0]) | (static_cast(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(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{&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 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(data.data()), + static_cast(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(data.size()))); + throw MTPException(ResponseCode::GeneralError, + std::format("USB write error: {}", libusb_error_name(r))); + } + if (transferred != static_cast(data.size())) + throw MTPException(ResponseCode::IncompleteTransfer, + std::format("USB write: sent {} of {} bytes", transferred, data.size())); +} + +std::vector MTPSession::bulk_read(size_t max_bytes, int timeout_ms) { + std::vector buf(max_bytes); + int transferred = 0; + int r = libusb_bulk_transfer( + handle_, ep_.bulk_in, + buf.data(), static_cast(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(transferred)); + return buf; +} + +// ─── MTP container I/O ──────────────────────────────────────────────────────── +void MTPSession::send_command(OpCode op, std::span params, uint32_t* out_tid) { + last_op_code_ = static_cast(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 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(total)); + buf[4] = static_cast(static_cast(ContainerType::Command) & 0xFF); + buf[5] = static_cast(static_cast(ContainerType::Command) >> 8); + buf[6] = static_cast(static_cast(op) & 0xFF); + buf[7] = static_cast(static_cast(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 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 buf(total); + put_le32(buf.data(), static_cast(total)); + buf[4] = static_cast(static_cast(ContainerType::Data) & 0xFF); + buf[5] = static_cast(static_cast(ContainerType::Data) >> 8); + buf[6] = static_cast(last_op_code_ & 0xFF); + buf[7] = static_cast(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 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(type) == ContainerType::Response) { + // Device sent a response instead of data — parse and throw + ResponseCode rc = static_cast(get_le16(first.data() + 6)); + if (rc != ResponseCode::OK) + throw MTPException(rc, MTPException::code_name(rc)); + return first; + } + + std::vector 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(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 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& 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(header_chunk.data() + CONTAINER_HEADER_SIZE), + static_cast(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(CHUNK_SIZE, payload_size - written); + auto chunk = bulk_read(want, TIMEOUT_READ_MS); + if (chunk.empty()) break; + ofs.write(reinterpret_cast(chunk.data()), + static_cast(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& 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(total); + + // Build 12-byte container header + std::array hdr{}; + put_le32(hdr.data(), wire_len); + hdr[4] = static_cast(static_cast(ContainerType::Data) & 0xFF); + hdr[5] = static_cast(static_cast(ContainerType::Data) >> 8); + hdr[6] = static_cast(static_cast(op) & 0xFF); + hdr[7] = static_cast(static_cast(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(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( + std::min(first_data_want, file_size)); + + std::vector 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(first_pkt.data() + CONTAINER_HEADER_SIZE), + static_cast(first_data_want)); + size_t first_got = static_cast(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 buf(xfer_chunk); + while (sent < file_size) { + if (cancel.load()) + throw MTPException(ResponseCode::TransactionCancelled, "Transfer cancelled"); + + size_t want = static_cast(std::min(xfer_chunk, file_size - sent)); + ifs.read(reinterpret_cast(buf.data()), static_cast(want)); + auto got = static_cast(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(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 diff --git a/src/mtp/MTPSession.hpp b/src/mtp/MTPSession.hpp new file mode 100644 index 0000000..c1ab9f5 --- /dev/null +++ b/src/mtp/MTPSession.hpp @@ -0,0 +1,125 @@ +#pragma once +#include "MTPProtocol.hpp" +#include +#include +#include +#include +#include +#include + +namespace mtp { + +struct CommandResponse { + ResponseCode code; + std::vector 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; + +// 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 params = {}, + uint32_t* out_tid = nullptr); + + // Send a data container following a command. + void send_data(std::span data); + + // Receive a data container into a heap buffer. + std::vector 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& 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& 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 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(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 bulk_read(size_t max_bytes, int timeout_ms = TIMEOUT_MS); + void bulk_write(std::span 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 connected_{false}; + mutable std::mutex mtx_; +}; + +} // namespace mtp diff --git a/src/transfer/TransferEngine.cpp b/src/transfer/TransferEngine.cpp new file mode 100644 index 0000000..2bab5e2 --- /dev/null +++ b/src/transfer/TransferEngine.cpp @@ -0,0 +1,180 @@ +#include "TransferEngine.hpp" +#include +#include +#include +#include +#include + +namespace fs = std::filesystem; + +namespace transfer { + +std::string TransferEngine::make_id() { + static std::mt19937_64 rng{std::random_device{}()}; + static std::uniform_int_distribution 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 +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(); + 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 +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(); + 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> 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 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 diff --git a/src/transfer/TransferEngine.hpp b/src/transfer/TransferEngine.hpp new file mode 100644 index 0000000..26df72b --- /dev/null +++ b/src/transfer/TransferEngine.hpp @@ -0,0 +1,115 @@ +#pragma once +#include "mtp/MTPSession.hpp" +#include "mtp/MTPOperations.hpp" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +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 bytes_done{0}; + std::atomic state{State::Queued}; + std::atomic 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(bytes_done.load()) / static_cast(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( + std::chrono::steady_clock::now() - started_at).count(); + return elapsed > 0.0 ? static_cast(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(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 + 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 + 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> all_transfers() const; + bool has_active() const; + + using CompletionFn = std::function; + 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> pending_; + std::vector> history_; // all items ever queued + + CompletionFn completion_; + + std::jthread worker_; + std::atomic shutdown_{false}; + static std::string make_id(); +}; + +} // namespace transfer diff --git a/src/ui/App.hpp b/src/ui/App.hpp new file mode 100644 index 0000000..89c73c0 --- /dev/null +++ b/src/ui/App.hpp @@ -0,0 +1,158 @@ +#pragma once +#include "mtp/MTPSession.hpp" +#include "mtp/MTPOperations.hpp" +#include "mtp/MTPProtocol.hpp" +#include "transfer/TransferEngine.hpp" +#include +#include +#include +#include +#include +#include +#include +#include + +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 session_; + std::unique_ptr ops_; + std::unique_ptr engine_; + + std::atomic connected_{false}; + std::jthread monitor_thread_; + mutable std::mutex device_mtx_; + mtp::DeviceInfo device_info_; + + std::vector> storages_; + uint32_t active_storage_ = 0; + + // ── State: local panel ──────────────────────────────────────────────────── + fs::path local_path_; + std::vector local_entries_; + std::vector local_drives_; + mutable std::atomic local_needs_refresh_{false}; + + // ── State: remote panel ─────────────────────────────────────────────────── + std::vector remote_nav_stack_; + std::vector 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 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 diff --git a/src/ui/App.mm b/src/ui/App.mm new file mode 100644 index 0000000..8772fdd --- /dev/null +++ b/src/ui/App.mm @@ -0,0 +1,537 @@ +#include "App.hpp" +#import +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +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>; +static CrumbList build_crumbs(const fs::path& path) { + std::vector> 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(); + auto di = sess->connect(); + { + std::lock_guard lock(device_mtx_); + session_ = std::move(sess); + ops_ = std::make_unique(*session_); + engine_ = std::make_unique(*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* 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(this)->process_finder_drops(); + } + if (local_needs_refresh_.load()) { + local_needs_refresh_.store(false); + const_cast(this)->refresh_local(local_path_); + } + if (remote_needs_refresh_ && connected_.load()) { + remote_needs_refresh_ = false; + const_cast(this)->refresh_remote(); + } + const_cast(this)->refresh_local_drives(); + const std::string active_drive = active_local_drive_path(); + + bool conn = connected_.load(); + std::string dn; + std::vector> stor; + uint32_t astor; + std::vector 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;iall_transfers(); + for (size_t i=0;istate.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 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 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 diff --git a/src/ui/WebUI.hpp b/src/ui/WebUI.hpp new file mode 100644 index 0000000..609fbc4 --- /dev/null +++ b/src/ui/WebUI.hpp @@ -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 diff --git a/src/ui/WebUI.mm b/src/ui/WebUI.mm new file mode 100644 index 0000000..b106d15 --- /dev/null +++ b/src/ui/WebUI.mm @@ -0,0 +1,741 @@ +#include "WebUI.hpp" +#import +#import +#include +#include + +// ─── Forward declarations ───────────────────────────────────────────────────── +@class OmniBridge; +@class OmniAppDelegate; + +// ─── OmniBridge: JS → C++ message handler ───────────────────────────────────── +@interface OmniBridge : NSObject +- (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 +@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 +- (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* _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)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*)fileURLsFromDrag:(id)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* 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)sender { + NSArray* 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)sender { + return [self draggingUpdated:sender]; +} + +- (void)draggingExited:(id)sender { + (void)sender; + [self setRemoteDragOver:NO]; + [self setLocalDragOver:NO]; +} + +- (BOOL)performDragOperation:(id)sender { + [self setRemoteDragOver:NO]; + [self setLocalDragOver:NO]; + if (!_app) return NO; + + NSString* target = [self dropTargetAtWebPoint:[self webPointFromDrag:sender]]; + NSArray* 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 +- (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* 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 = @"

webroot/index.html not found

"; + [_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 diff --git a/src/ui/webroot/app.css b/src/ui/webroot/app.css new file mode 100644 index 0000000..6049b6f --- /dev/null +++ b/src/ui/webroot/app.css @@ -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; } diff --git a/src/ui/webroot/app.js b/src/ui/webroot/app.js new file mode 100644 index 0000000..4c36590 --- /dev/null +++ b/src/ui/webroot/app.js @@ -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 = ` + NameSizeModified +`; + +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 ` +
+ + ${esc(f.name)} +
+ ${f.isDir ? '' : esc(f.sizeStr)} + ${esc(f.date)} + `; + }).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 = `
+ + + +
No files in this folder
`; + return; + } + el.innerHTML = `${FILE_TABLE_HEADER} + ${buildFileRows(S.localFiles, S.selLocal, 'path', true)}
`; + 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 = '
Loading…
'; + _remoteFingerprint = '__loading__'; + return; + } + + const fp = fingerprintFiles(S.remoteFiles); + if (fp !== _remoteFingerprint) { + _remoteFingerprint = fp; + if (!S.remoteFiles.length) { + el.innerHTML = `
+ + + +
Empty folder
`; + } else { + el.innerHTML = `${FILE_TABLE_HEADER} + ${buildFileRows(S.remoteFiles, S.selRemote, 'handle', false)}
`; + } + 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 => + `` + ).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 ? '' : ''; + show.forEach((p, i) => { + const fullIdx = parts.length - show.length + i; + const path = '/' + parts.slice(0, fullIdx + 1).join('/'); + html += i === show.length - 1 + ? `${esc(p)}` + : `${esc(p)}`; + }); + 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 => + `` + ).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 + ? `${esc(e.label)}` + : `${esc(e.label)}`; + }).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 `
+
+ ${dir} + ${esc(t.filename)} + ${statText} + ${showBar ? `` : ''} +
+ ${showBar ? `
+
${pct}%${t.etaStr ? ' · ' + t.etaStr : ''}
` : ''} +
`; + }).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); +}); diff --git a/src/ui/webroot/index.html b/src/ui/webroot/index.html new file mode 100644 index 0000000..a9a92d5 --- /dev/null +++ b/src/ui/webroot/index.html @@ -0,0 +1,106 @@ + + + + + + OmniMTP + + + +
+
+ OmniMTP +
No Device
+
+ +
+
+ +
+
Waiting for Nintendo Switch…
+
+ +
+
+ +
+ +
+
+ + + + +
+
+
Loading…
+
+
+ + +
+ + +
+
+ + + + +
+
+
+ + + + + +
No Switch Connected
+
Connect via USB-C and open DBI or Sphaira MTP
+
+
+
+
+ + +
+
+ Transfers + +
+
+
+ + +
+ + + +
+ + + + + + +