Initial commit

This commit is contained in:
2026-06-07 00:35:25 +02:00
parent ea13609f7c
commit b8eca61e20
24 changed files with 4991 additions and 0 deletions

145
CMakeLists.txt Normal file
View File

@@ -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
$<$<COMPILE_LANGUAGE:CXX>:-Wpedantic>
$<$<COMPILE_LANGUAGE:OBJCXX>:-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)")

21
LICENSE Normal file
View File

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

163
README.md Normal file
View File

@@ -0,0 +1,163 @@
<div align="center">
<h1>OmniMTP</h1>
<p>Ein schneller (<strong>34× schneller als direkter microSD-Zugriff</strong>), nativer macOS-MTP-Client für die Nintendo Switch.<br>
Übertrage NSP-, XCI- und NRO-Dateien per USB-C, ohne die microSD-Karte auszubauen.</p>
[![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)]()
<br>
<img src="OmniMTP.png" width="820" alt="OmniMTP Screenshot" />
</div>
---
> **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 60150+ 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 | 2545 MB/s |
| USB 3.0 (Switch OLED / direktes USB-C 3.x) | 60150+ 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).

BIN
assets/AppIcon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 452 KiB

75
build.sh Executable file
View File

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

36
cmake/Info.plist.in Normal file
View File

@@ -0,0 +1,36 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleExecutable</key>
<string>${MACOSX_BUNDLE_EXECUTABLE_NAME}</string>
<key>CFBundleIdentifier</key>
<string>${MACOSX_BUNDLE_GUI_IDENTIFIER}</string>
<key>CFBundleName</key>
<string>${MACOSX_BUNDLE_BUNDLE_NAME}</string>
<key>CFBundleDisplayName</key>
<string>OmniMTP</string>
<key>CFBundleIconFile</key>
<string>AppIcon</string>
<key>CFBundleVersion</key>
<string>${MACOSX_BUNDLE_BUNDLE_VERSION}</string>
<key>CFBundleShortVersionString</key>
<string>${MACOSX_BUNDLE_SHORT_VERSION_STRING}</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>LSMinimumSystemVersion</key>
<string>${CMAKE_OSX_DEPLOYMENT_TARGET}</string>
<key>NSHighResolutionCapable</key>
<true/>
<key>NSSupportsAutomaticGraphicsSwitching</key>
<true/>
<!-- USB access via IOKit — no App Store sandbox -->
<key>com.apple.security.app-sandbox</key>
<false/>
<key>NSPrincipalClass</key>
<string>NSApplication</string>
</dict>
</plist>

34
cmake/generate_icns.sh Executable file
View File

@@ -0,0 +1,34 @@
#!/usr/bin/env bash
set -euo pipefail
if [[ $# -ne 2 ]]; then
echo "Usage: $0 <source.png> <output.icns>" >&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"

39
cmake/libusb_config.h Normal file
View File

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

BIN
heziMTP.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 257 KiB

14
src/main.mm Normal file
View File

@@ -0,0 +1,14 @@
#include "ui/App.hpp"
#include "ui/WebUI.hpp"
#import <Cocoa/Cocoa.h>
int main(int, char**) {
@autoreleasepool {
omniMTP::App app;
app.init();
omniMTP::WebUI ui(app);
ui.run();
app.shutdown();
}
return 0;
}

333
src/mtp/MTPOperations.cpp Normal file
View File

@@ -0,0 +1,333 @@
#include "MTPOperations.hpp"
#include <cstring>
#include <stdexcept>
#include <format>
namespace mtp {
// ─── Encoding helpers ─────────────────────────────────────────────────────────
void MTPOperations::put_le16(std::vector<uint8_t>& b, uint16_t v) {
b.push_back(v & 0xFF);
b.push_back(v >> 8);
}
void MTPOperations::put_le32(std::vector<uint8_t>& b, uint32_t v) {
b.push_back(v & 0xFF); b.push_back((v>>8)&0xFF);
b.push_back((v>>16)&0xFF); b.push_back(v>>24);
}
void MTPOperations::put_le64(std::vector<uint8_t>& b, uint64_t v) {
put_le32(b, static_cast<uint32_t>(v));
put_le32(b, static_cast<uint32_t>(v >> 32));
}
// UTF-8 → MTP UTF-16LE string: [1-byte len][len × 2-byte UTF-16LE chars]
void MTPOperations::encode_mtp_string(std::vector<uint8_t>& buf, const std::string& utf8) {
if (utf8.empty()) { buf.push_back(0); return; }
// Simple ASCII-only encoding (sufficient for filenames in practice)
size_t len = std::min(utf8.size(), size_t(255)) + 1; // +1 for null terminator
buf.push_back(static_cast<uint8_t>(len));
for (size_t i = 0; i < len - 1; ++i) {
uint8_t c = static_cast<uint8_t>(utf8[i]);
buf.push_back(c);
buf.push_back(0);
}
// Null terminator
buf.push_back(0);
buf.push_back(0);
}
// ─── Parsing helpers ──────────────────────────────────────────────────────────
static uint16_t u16(const uint8_t* p) {
return static_cast<uint16_t>(p[0]) | (static_cast<uint16_t>(p[1]) << 8);
}
static uint32_t u32(const uint8_t* p) {
return p[0] | (p[1]<<8) | (p[2]<<16) | (p[3]<<24);
}
static uint64_t u64(const uint8_t* p) {
uint64_t lo = u32(p), hi = u32(p+4);
return lo | (hi << 32);
}
static std::string mtp_str(const uint8_t*& p, const uint8_t* end) {
if (p >= end) return {};
uint8_t n = *p++;
if (!n) return {};
std::string s; s.reserve(n);
for (uint8_t i = 0; i < n && p+1 < end; ++i) {
uint16_t c = static_cast<uint16_t>(p[0]) | (static_cast<uint16_t>(p[1])<<8);
p += 2;
if (!c) break;
if (c < 0x80) s += static_cast<char>(c);
else if (c < 0x800) { s += static_cast<char>(0xC0|(c>>6)); s += static_cast<char>(0x80|(c&0x3F)); }
else { s += static_cast<char>(0xE0|(c>>12)); s += static_cast<char>(0x80|((c>>6)&0x3F)); s += static_cast<char>(0x80|(c&0x3F)); }
}
return s;
}
std::vector<uint32_t> MTPOperations::parse_u32_array(const std::vector<uint8_t>& data) {
const uint8_t* p = data.data() + CONTAINER_HEADER_SIZE;
const uint8_t* end = data.data() + data.size();
if (p + 4 > end) return {};
uint32_t n = u32(p); p += 4;
std::vector<uint32_t> v; v.reserve(n);
for (uint32_t i = 0; i < n && p + 4 <= end; ++i) {
v.push_back(u32(p)); p += 4;
}
return v;
}
ObjectInfo MTPOperations::parse_object_info(const std::vector<uint8_t>& data) {
const uint8_t* p = data.data() + CONTAINER_HEADER_SIZE;
const uint8_t* end = data.data() + data.size();
ObjectInfo oi{};
if (p + 4 <= end) { oi.storage_id = u32(p); p += 4; }
if (p + 2 <= end) { oi.object_format = u16(p); p += 2; }
if (p + 2 <= end) { oi.protection_status = u16(p); p += 2; }
// MTP spec defines compressed_size as uint32_t in the ObjectInfo struct,
// but DBI/Sphaira follow the standard. For files > 4 GiB use GetObjectPropValue.
uint32_t wire_size = 0;
if (p + 4 <= end) { wire_size = u32(p); p += 4; }
oi.compressed_size = wire_size; // extended size handled via GetObjectPropValue
if (p + 2 <= end) { /* thumb format */ p += 2; }
if (p + 4 <= end) { /* thumb size */ p += 4; }
if (p + 4 <= end) { /* thumb w */ p += 4; }
if (p + 4 <= end) { /* thumb h */ p += 4; }
if (p + 4 <= end) { /* img w */ p += 4; }
if (p + 4 <= end) { /* img h */ p += 4; }
if (p + 4 <= end) { /* img depth */ p += 4; }
if (p + 4 <= end) { oi.parent_object = u32(p); p += 4; }
if (p + 2 <= end) { oi.association_type = u16(p); p += 2; }
if (p + 4 <= end) { oi.association_desc = u32(p); p += 4; }
if (p + 4 <= end) { oi.sequence_number = u32(p); p += 4; }
oi.filename = mtp_str(p, end);
oi.date_created = mtp_str(p, end);
oi.date_modified = mtp_str(p, end);
/* keywords = */ mtp_str(p, end);
return oi;
}
// ─── MTPOperations implementation ─────────────────────────────────────────────
MTPOperations::MTPOperations(MTPSession& session) : session_(session) {}
std::vector<uint32_t> MTPOperations::get_storage_ids() {
std::lock_guard lock(session_.mutex());
session_.send_command(OpCode::GetStorageIDs);
auto data = session_.receive_data();
auto resp = session_.receive_response();
if (resp.code != ResponseCode::OK)
throw MTPException(resp.code, MTPException::code_name(resp.code));
return parse_u32_array(data);
}
StorageInfo MTPOperations::get_storage_info(uint32_t storage_id) {
std::lock_guard lock(session_.mutex());
std::array<uint32_t,1> p{storage_id};
session_.send_command(OpCode::GetStorageInfo, p);
auto data = session_.receive_data();
auto resp = session_.receive_response();
if (resp.code != ResponseCode::OK)
throw MTPException(resp.code, MTPException::code_name(resp.code));
const uint8_t* ptr = data.data() + CONTAINER_HEADER_SIZE;
const uint8_t* end = data.data() + data.size();
StorageInfo si{};
if (ptr+2<=end){si.storage_type = u16(ptr); ptr+=2;}
if (ptr+2<=end){si.filesystem_type = u16(ptr); ptr+=2;}
if (ptr+2<=end){si.access_capability = u16(ptr); ptr+=2;}
if (ptr+8<=end){si.max_capacity = u64(ptr); ptr+=8;}
if (ptr+8<=end){si.free_space_bytes = u64(ptr); ptr+=8;}
if (ptr+4<=end){si.free_space_objects= u32(ptr); ptr+=4;}
si.description = mtp_str(ptr, end);
si.volume_id = mtp_str(ptr, end);
return si;
}
std::vector<uint32_t> MTPOperations::get_object_handles(uint32_t storage_id,
uint32_t parent_handle) {
std::lock_guard lock(session_.mutex());
std::array<uint32_t,3> params{ storage_id, ALL_FORMATS, parent_handle };
session_.send_command(OpCode::GetObjectHandles, params);
auto data = session_.receive_data();
auto resp = session_.receive_response();
if (resp.code != ResponseCode::OK)
throw MTPException(resp.code, MTPException::code_name(resp.code));
return parse_u32_array(data);
}
ObjectInfo MTPOperations::get_object_info(uint32_t handle) {
std::lock_guard lock(session_.mutex());
std::array<uint32_t,1> p{handle};
session_.send_command(OpCode::GetObjectInfo, p);
auto data = session_.receive_data();
auto resp = session_.receive_response();
if (resp.code != ResponseCode::OK)
throw MTPException(resp.code, MTPException::code_name(resp.code));
return parse_object_info(data);
}
void MTPOperations::get_object(uint32_t handle,
const std::string& dest_path,
uint64_t expected_size,
const ProgressFn& progress,
std::atomic<bool>& cancel) {
std::lock_guard lock(session_.mutex());
std::array<uint32_t,1> p{handle};
session_.send_command(OpCode::GetObject, p);
session_.receive_data_to_file(dest_path, expected_size, progress, cancel);
auto resp = session_.receive_response();
if (resp.code != ResponseCode::OK && resp.code != ResponseCode::TransactionCancelled)
throw MTPException(resp.code, MTPException::code_name(resp.code));
}
std::vector<uint8_t> MTPOperations::build_object_info(uint32_t storage_id,
uint32_t parent_handle,
const std::string& filename,
uint64_t file_size,
uint16_t format) {
std::vector<uint8_t> b;
b.reserve(128);
// Container header placeholder (will be filled in by session)
// Actual wire bytes are handled by send_data(), so we just build the payload here.
put_le32(b, storage_id);
put_le16(b, format);
put_le16(b, 0); // protection_status = none
// compressed_size as uint32 (0 = unknown, or truncated for large files)
put_le32(b, file_size > 0xFFFFFFFFu ? 0xFFFFFFFFu : static_cast<uint32_t>(file_size));
put_le16(b, 0); // thumb_format
put_le32(b, 0); // thumb_compressed_size
put_le32(b, 0); // thumb_pix_w
put_le32(b, 0); // thumb_pix_h
put_le32(b, 0); // image_pix_w
put_le32(b, 0); // image_pix_h
put_le32(b, 0); // image_bit_depth
put_le32(b, parent_handle);
put_le16(b, 0); // association_type
put_le32(b, 0); // association_desc
put_le32(b, 0); // sequence_number
encode_mtp_string(b, filename);
b.push_back(0); // date_created (empty string)
b.push_back(0); // date_modified (empty string)
b.push_back(0); // keywords (empty string)
return b;
}
// Build the SendObjectPropList data payload — just the filename property.
// The actual 64-bit file size is passed in the command parameters, not here.
static std::vector<uint8_t> build_prop_list(const std::string& filename) {
std::vector<uint8_t> b;
// number of properties = 1
b.push_back(1); b.push_back(0); b.push_back(0); b.push_back(0);
// object handle = 0 (new object)
b.push_back(0); b.push_back(0); b.push_back(0); b.push_back(0);
// property code = ObjectFilename (0xDC07)
b.push_back(static_cast<uint8_t>(PROP_OBJECT_FILENAME & 0xFF));
b.push_back(static_cast<uint8_t>(PROP_OBJECT_FILENAME >> 8));
// data type = String (0xFFFF)
b.push_back(static_cast<uint8_t>(DTYPE_STRING & 0xFF));
b.push_back(static_cast<uint8_t>(DTYPE_STRING >> 8));
// MTP string value
if (filename.empty()) {
b.push_back(0);
} else {
size_t len = std::min(filename.size(), size_t(255)) + 1;
b.push_back(static_cast<uint8_t>(len));
for (size_t i = 0; i < len - 1; ++i) {
b.push_back(static_cast<uint8_t>(filename[i]));
b.push_back(0);
}
b.push_back(0); b.push_back(0); // null terminator
}
return b;
}
uint32_t MTPOperations::send_object(uint32_t storage_id,
uint32_t parent_handle,
const std::string& src_path,
const std::string& filename,
uint64_t file_size,
const ProgressFn& progress,
std::atomic<bool>& cancel) {
// Always use Undefined (0x3000) for game files.
// DBI/Sphaira reject vendor-specific format codes like 0xB005 for NSP/XCI
// and return OperationNotSupported. Undefined is universally accepted.
uint16_t fmt = static_cast<uint16_t>(ObjectFormat::Undefined);
// All of SendObjectInfo/SendObjectPropList + SendObject must be atomic.
std::lock_guard lock(session_.mutex());
// Step 1: announce the object to the device.
//
// Prefer SendObjectPropList (0x9808) when supported — it passes the actual
// 64-bit file size in the command parameters (size_hi, size_lo), so the
// device knows the real byte count for files >4 GiB instead of receiving
// 0xFFFFFFFF and stopping at ~4.29 GB.
//
// Fall back to SendObjectInfo (0x100C) for devices that don't support it.
if (session_.supports_op(OpCode::SendObjectPropList)) {
// params: [storage_id, parent_handle, format, size_hi, size_lo]
uint32_t size_hi = static_cast<uint32_t>(file_size >> 32);
uint32_t size_lo = static_cast<uint32_t>(file_size & 0xFFFFFFFFu);
std::array<uint32_t,5> params{
storage_id, parent_handle,
static_cast<uint32_t>(fmt),
size_hi, size_lo
};
session_.send_command(OpCode::SendObjectPropList, params);
auto prop_list = build_prop_list(filename);
session_.send_data(prop_list);
} else {
std::array<uint32_t,2> params{ storage_id, parent_handle };
session_.send_command(OpCode::SendObjectInfo, params);
auto obj_info = build_object_info(storage_id, parent_handle, filename, file_size, fmt);
session_.send_data(obj_info);
}
{
auto resp = session_.receive_response();
if (resp.code != ResponseCode::OK)
throw MTPException(resp.code, MTPException::code_name(resp.code));
}
// Step 2: SendObject — immediately follows, no gap for foreign commands
session_.send_command(OpCode::SendObject);
session_.send_data_from_file(src_path, file_size, OpCode::SendObject, progress, cancel);
// Use TIMEOUT_INSTALL_MS: after the last byte DBI/Sphaira verify NCAs and
// update the content database, which can take well over 5 s on slow microSD.
{
auto resp = session_.receive_response(TIMEOUT_INSTALL_MS);
if (resp.code != ResponseCode::OK && resp.code != ResponseCode::TransactionCancelled)
throw MTPException(resp.code, MTPException::code_name(resp.code));
}
return 0;
}
void MTPOperations::delete_object(uint32_t handle) {
std::lock_guard lock(session_.mutex());
std::array<uint32_t,2> params{ handle, ALL_FORMATS };
session_.send_command(OpCode::DeleteObject, params);
auto resp = session_.receive_response();
if (resp.code != ResponseCode::OK)
throw MTPException(resp.code, MTPException::code_name(resp.code));
}
uint32_t MTPOperations::create_directory(uint32_t storage_id,
uint32_t parent_handle,
const std::string& name) {
std::lock_guard lock(session_.mutex());
std::array<uint32_t,2> params{ storage_id, parent_handle };
session_.send_command(OpCode::SendObjectInfo, params);
auto info = build_object_info(storage_id, parent_handle, name, 0,
static_cast<uint16_t>(ObjectFormat::Association));
session_.send_data(info);
auto resp = session_.receive_response();
if (resp.code != ResponseCode::OK)
throw MTPException(resp.code, MTPException::code_name(resp.code));
return resp.params.size() >= 3 ? resp.params[2] : 0;
}
} // namespace mtp

70
src/mtp/MTPOperations.hpp Normal file
View File

@@ -0,0 +1,70 @@
#pragma once
#include "MTPSession.hpp"
#include "MTPProtocol.hpp"
#include <cstdint>
#include <string>
#include <vector>
namespace mtp {
// Stateless MTP operations that run on top of an MTPSession.
// Every public function acquires the session mutex.
class MTPOperations {
public:
explicit MTPOperations(MTPSession& session);
// ── Storage ───────────────────────────────────────────────────────────────
std::vector<uint32_t> get_storage_ids();
StorageInfo get_storage_info(uint32_t storage_id);
// ── Object enumeration ────────────────────────────────────────────────────
// parent_handle = ROOT_PARENT for root of a storage
std::vector<uint32_t> get_object_handles(uint32_t storage_id,
uint32_t parent_handle = ROOT_PARENT);
ObjectInfo get_object_info(uint32_t handle);
// ── Transfer ──────────────────────────────────────────────────────────────
// Download object to a local file path.
void get_object(uint32_t handle,
const std::string& dest_path,
uint64_t expected_size,
const ProgressFn& progress,
std::atomic<bool>& cancel);
// Upload a local file, returns new object handle on the device.
uint32_t send_object(uint32_t storage_id,
uint32_t parent_handle,
const std::string& src_path,
const std::string& filename,
uint64_t file_size,
const ProgressFn& progress,
std::atomic<bool>& cancel);
// ── Management ────────────────────────────────────────────────────────────
void delete_object(uint32_t handle);
// Create a directory (association object).
uint32_t create_directory(uint32_t storage_id,
uint32_t parent_handle,
const std::string& name);
private:
MTPSession& session_;
// Build a packed ObjectInfo blob for SendObjectInfo
static std::vector<uint8_t> build_object_info(uint32_t storage_id,
uint32_t parent_handle,
const std::string& filename,
uint64_t file_size,
uint16_t format);
static void encode_mtp_string(std::vector<uint8_t>& buf, const std::string& utf8);
static void put_le16(std::vector<uint8_t>& b, uint16_t v);
static void put_le32(std::vector<uint8_t>& b, uint32_t v);
static void put_le64(std::vector<uint8_t>& b, uint64_t v);
static std::vector<uint32_t> parse_u32_array(const std::vector<uint8_t>& data);
static ObjectInfo parse_object_info(const std::vector<uint8_t>& data);
};
} // namespace mtp

226
src/mtp/MTPProtocol.hpp Normal file
View File

@@ -0,0 +1,226 @@
#pragma once
#include <cstdint>
#include <string>
#include <vector>
namespace mtp {
// ─── Container types ──────────────────────────────────────────────────────────
enum class ContainerType : uint16_t {
Command = 0x0001,
Data = 0x0002,
Response = 0x0003,
Event = 0x0004,
};
// ─── MTP Operation codes ──────────────────────────────────────────────────────
enum class OpCode : uint16_t {
GetDeviceInfo = 0x1001,
OpenSession = 0x1002,
CloseSession = 0x1003,
GetStorageIDs = 0x1004,
GetStorageInfo = 0x1005,
GetNumObjects = 0x1006,
GetObjectHandles = 0x1007,
GetObjectInfo = 0x1008,
GetObject = 0x1009,
GetThumb = 0x100A,
DeleteObject = 0x100B,
SendObjectInfo = 0x100C,
SendObject = 0x100D,
GetDevicePropDesc = 0x1014,
GetDevicePropValue = 0x1015,
SetDevicePropValue = 0x1016,
MoveObject = 0x1019,
CopyObject = 0x101A,
GetPartialObject = 0x101B,
GetObjectPropsSupported = 0x9801,
GetObjectPropDesc = 0x9802,
GetObjectPropValue = 0x9803,
SetObjectPropValue = 0x9804,
// Modern object creation — passes full 64-bit size in command params
// so devices know the real size even for files > 4 GiB.
SendObjectPropList = 0x9808,
};
// ─── Response codes ───────────────────────────────────────────────────────────
enum class ResponseCode : uint16_t {
OK = 0x2001,
GeneralError = 0x2002,
SessionNotOpen = 0x2003,
InvalidTransactionID = 0x2004,
OperationNotSupported = 0x2005,
ParameterNotSupported = 0x2006,
IncompleteTransfer = 0x2007,
InvalidStorageID = 0x2008,
InvalidObjectHandle = 0x2009,
DevicePropNotSupported = 0x200A,
StoreFull = 0x200C,
ObjectWriteProtected = 0x200D,
StoreReadOnly = 0x200E,
AccessDenied = 0x200F,
DeviceBusy = 0x2019,
InvalidParentObject = 0x201A,
InvalidParameter = 0x201D,
SessionAlreadyOpen = 0x201E,
TransactionCancelled = 0x201F,
};
// ─── Object format codes ──────────────────────────────────────────────────────
enum class ObjectFormat : uint16_t {
Undefined = 0x3000,
Association = 0x3001, // Directory/folder
Script = 0x3002,
Text = 0x3004,
HTML = 0x3005,
MP3 = 0x3009,
AVI = 0x300A,
MPEG = 0x300B,
JPEG = 0x3801,
BMP = 0x3804,
GIF = 0x3807,
PNG = 0x380B,
TIFF = 0x380D,
NSP = 0xB005, // Nintendo Switch Package
XCI = 0xB006, // Nintendo Switch Cartridge Image
};
// ─── Wire-format container header (little-endian, packed) ────────────────────
#pragma pack(push, 1)
struct ContainerHeader {
uint32_t length; // total bytes including this header
uint16_t type;
uint16_t code;
uint32_t transaction_id;
};
static_assert(sizeof(ContainerHeader) == 12);
#pragma pack(pop)
// ─── Object property codes (for SendObjectPropList payload) ──────────────────
static constexpr uint16_t PROP_OBJECT_FILENAME = 0xDC07;
static constexpr uint16_t PROP_OBJECT_SIZE = 0xDC04;
static constexpr uint16_t DTYPE_STRING = 0xFFFF;
static constexpr uint16_t DTYPE_UINT64 = 0x0008;
// ─── Constants ────────────────────────────────────────────────────────────────
static constexpr uint32_t CONTAINER_HEADER_SIZE = 12;
static constexpr uint32_t ROOT_PARENT = 0xFFFFFFFF;
static constexpr uint32_t ALL_STORAGE = 0xFFFFFFFF;
static constexpr uint32_t ALL_FORMATS = 0x00000000;
static constexpr uint32_t SESSION_ID = 1;
// Nintendo Switch USB identifiers
static constexpr uint16_t NINTENDO_VID = 0x057e;
static constexpr uint16_t SWITCH_PID_V1 = 0x2000;
static constexpr uint16_t SWITCH_PID_V2 = 0x3000; // Switch V2/Lite/OLED
// USB MTP class/subclass/protocol
static constexpr uint8_t MTP_CLASS = 6;
static constexpr uint8_t MTP_SUBCLASS = 1;
static constexpr uint8_t MTP_PROTOCOL = 1;
// Transfer sizing
// 512 KiB per bulk transfer — safe across all macOS USB controllers and Switch firmware.
// 1 MiB can hit IOUSBLib scatter-gather limits on some hosts.
// USB 2.0 HS: max_pkt = 512 → use go-mtpfs's rwBufSize = 16 KiB
// USB 3.0 SS: max_pkt = 1024 → scale up; each syscall has ~100 µs overhead,
// so 16 KiB chunks cap throughput at ~164 MB/s even with USB 3.0 bandwidth.
// 512 KiB chunks drop syscall count 32× and saturate USB 3.0 properly.
static constexpr size_t CHUNK_SIZE = 0x4000; // 16 KiB (USB 2.0 HS baseline)
static constexpr size_t CHUNK_SIZE_USB3 = 512u << 10; // 512 KiB (USB 3.0 SS)
static constexpr size_t SMALL_CHUNK_SIZE = 0x1000; // 4 KiB
// Timeouts
static constexpr int TIMEOUT_CMD_MS = 5000; // command / response containers
static constexpr int TIMEOUT_READ_MS = 10000; // bulk read per chunk
// go-mtpfs uses d.Timeout = 2000 ms for every bulk transfer.
// 2 s at 1 MB/s = 2 GB/s worth of leeway; generous but not infinitely blocking.
static constexpr int TIMEOUT_WRITE_MS = 2000; // bulk write per chunk
// After the last byte of a file is sent, DBI/Sphaira verify NCAs, write to
// the content database, update storage info, etc. On a slow microSD this can
// take well over 10 seconds, so we give it 3 minutes before calling it dead.
static constexpr int TIMEOUT_INSTALL_MS = 180000;
static constexpr int CONNECT_TIMEOUT_MS = 10000;
static constexpr int TIMEOUT_MS = TIMEOUT_CMD_MS;
// ─── High-level data structures ───────────────────────────────────────────────
struct StorageInfo {
uint16_t storage_type;
uint16_t filesystem_type;
uint16_t access_capability;
uint64_t max_capacity;
uint64_t free_space_bytes;
uint32_t free_space_objects;
std::string description;
std::string volume_id;
};
struct ObjectInfo {
uint32_t storage_id;
uint16_t object_format;
uint16_t protection_status;
uint64_t compressed_size; // 64-bit to handle files > 4 GiB
uint32_t parent_object;
uint16_t association_type;
uint32_t association_desc;
uint32_t sequence_number;
std::string filename;
std::string date_created;
std::string date_modified;
bool is_directory() const {
return object_format == static_cast<uint16_t>(ObjectFormat::Association);
}
};
struct DeviceInfo {
uint16_t standard_version;
uint32_t vendor_extension_id;
uint16_t vendor_extension_version;
std::string vendor_extension_desc;
uint16_t functional_mode;
std::vector<uint16_t> operations_supported;
std::vector<uint16_t> events_supported;
std::vector<uint16_t> device_props_supported;
std::vector<uint16_t> capture_formats;
std::vector<uint16_t> playback_formats;
std::string manufacturer;
std::string model;
std::string device_version;
std::string serial_number;
};
// ─── Exception ────────────────────────────────────────────────────────────────
class MTPException : public std::runtime_error {
public:
MTPException(ResponseCode code, std::string msg)
: std::runtime_error(std::move(msg)), code_(code) {}
ResponseCode code() const { return code_; }
static const char* code_name(ResponseCode c) {
switch (c) {
case ResponseCode::OK: return "OK";
case ResponseCode::GeneralError: return "General Error";
case ResponseCode::SessionNotOpen: return "Session Not Open";
case ResponseCode::OperationNotSupported: return "Operation Not Supported";
case ResponseCode::ParameterNotSupported: return "Parameter Not Supported";
case ResponseCode::IncompleteTransfer: return "Incomplete Transfer";
case ResponseCode::InvalidStorageID: return "Invalid Storage ID";
case ResponseCode::InvalidObjectHandle: return "Invalid Object Handle";
case ResponseCode::StoreFull: return "Store Full";
case ResponseCode::AccessDenied: return "Access Denied";
case ResponseCode::DeviceBusy: return "Device Busy";
case ResponseCode::InvalidParentObject: return "Invalid Parent Object";
case ResponseCode::InvalidParameter: return "Invalid Parameter";
case ResponseCode::SessionAlreadyOpen: return "Session Already Open";
case ResponseCode::TransactionCancelled: return "Transaction Cancelled";
default: return "Unknown Error";
}
}
private:
ResponseCode code_;
};
} // namespace mtp

603
src/mtp/MTPSession.cpp Normal file
View File

@@ -0,0 +1,603 @@
#include "MTPSession.hpp"
#include <cassert>
#include <cstring>
#include <fstream>
#include <stdexcept>
#include <format>
#include <array>
namespace mtp {
// ─── UTF-16LE MTP string → UTF-8 ─────────────────────────────────────────────
// MTP strings: 1 byte length (chars including null), then length×2 bytes UTF-16LE.
static std::string decode_mtp_string(const uint8_t*& p, const uint8_t* end) {
if (p >= end) return {};
uint8_t nchars = *p++;
if (nchars == 0) return {};
std::string out;
out.reserve(nchars);
for (uint8_t i = 0; i < nchars && p + 1 < end; ++i) {
uint16_t c = static_cast<uint16_t>(p[0]) | (static_cast<uint16_t>(p[1]) << 8);
p += 2;
if (c == 0) break;
if (c < 0x80) {
out += static_cast<char>(c);
} else if (c < 0x800) {
out += static_cast<char>(0xC0 | (c >> 6));
out += static_cast<char>(0x80 | (c & 0x3F));
} else {
out += static_cast<char>(0xE0 | (c >> 12));
out += static_cast<char>(0x80 | ((c >> 6) & 0x3F));
out += static_cast<char>(0x80 | (c & 0x3F));
}
}
return out;
}
// ─── Read a uint16 array: [4-byte count][count × uint16] ─────────────────────
static std::vector<uint16_t> decode_u16_array(const uint8_t*& p, const uint8_t* end) {
if (p + 4 > end) return {};
uint32_t n = p[0] | (p[1]<<8) | (p[2]<<16) | (p[3]<<24);
p += 4;
std::vector<uint16_t> v(n);
for (auto& x : v) {
if (p + 2 > end) break;
x = static_cast<uint16_t>(p[0]) | (static_cast<uint16_t>(p[1]) << 8);
p += 2;
}
return v;
}
// ─── Read a uint32 array ──────────────────────────────────────────────────────
static std::vector<uint32_t> decode_u32_array(const uint8_t*& p, const uint8_t* end) {
if (p + 4 > end) return {};
uint32_t n = p[0] | (p[1]<<8) | (p[2]<<16) | (p[3]<<24);
p += 4;
std::vector<uint32_t> v(n);
for (auto& x : v) {
if (p + 4 > end) break;
x = p[0] | (p[1]<<8) | (p[2]<<16) | (p[3]<<24);
p += 4;
}
return v;
}
// ─── Little-endian helpers ────────────────────────────────────────────────────
static void put_le32(uint8_t* dst, uint32_t v) {
dst[0] = v; dst[1] = v>>8; dst[2] = v>>16; dst[3] = v>>24;
}
static uint32_t get_le32(const uint8_t* p) {
return p[0] | (p[1]<<8) | (p[2]<<16) | (p[3]<<24);
}
static uint16_t get_le16(const uint8_t* p) {
return static_cast<uint16_t>(p[0]) | (static_cast<uint16_t>(p[1])<<8);
}
static uint64_t get_le64(const uint8_t* p) {
uint64_t lo = get_le32(p), hi = get_le32(p+4);
return lo | (hi << 32);
}
// ─── MTPSession ───────────────────────────────────────────────────────────────
MTPSession::MTPSession() {
if (libusb_init(&ctx_) != LIBUSB_SUCCESS)
throw std::runtime_error("libusb_init failed");
#ifdef NDEBUG
libusb_set_option(ctx_, LIBUSB_OPTION_LOG_LEVEL, LIBUSB_LOG_LEVEL_WARNING);
#else
libusb_set_option(ctx_, LIBUSB_OPTION_LOG_LEVEL, LIBUSB_LOG_LEVEL_INFO);
#endif
}
MTPSession::~MTPSession() {
if (connected_) disconnect();
if (ctx_) libusb_exit(ctx_);
}
// ─── Device detection ─────────────────────────────────────────────────────────
bool MTPSession::find_device() {
libusb_device** list = nullptr;
ssize_t cnt = libusb_get_device_list(ctx_, &list);
if (cnt < 0) return false;
libusb_device* found = nullptr;
for (ssize_t i = 0; i < cnt; ++i) {
libusb_device* dev = list[i];
libusb_device_descriptor desc{};
if (libusb_get_device_descriptor(dev, &desc) != LIBUSB_SUCCESS) continue;
// Match by Nintendo VID + known Switch PIDs first
bool is_switch = (desc.idVendor == NINTENDO_VID)
&& (desc.idProduct == SWITCH_PID_V1
|| desc.idProduct == SWITCH_PID_V2);
// Also accept any device advertising MTP class/subclass/protocol
bool is_mtp_class = false;
if (!is_switch) {
for (uint8_t c = 0; c < desc.bNumConfigurations; ++c) {
libusb_config_descriptor* cfg = nullptr;
if (libusb_get_config_descriptor(dev, c, &cfg) != LIBUSB_SUCCESS) continue;
for (uint8_t i2 = 0; i2 < cfg->bNumInterfaces; ++i2) {
for (int a = 0; a < cfg->interface[i2].num_altsetting; ++a) {
const auto& alt = cfg->interface[i2].altsetting[a];
if (alt.bInterfaceClass == MTP_CLASS
&& alt.bInterfaceSubClass == MTP_SUBCLASS
&& alt.bInterfaceProtocol == MTP_PROTOCOL) {
is_mtp_class = true;
}
}
}
libusb_free_config_descriptor(cfg);
if (is_mtp_class) break;
}
}
if (is_switch || is_mtp_class) {
found = dev;
libusb_ref_device(found);
vendor_id_ = desc.idVendor;
product_id_ = desc.idProduct;
break;
}
}
libusb_free_device_list(list, 1);
if (!found) return false;
// Try to get a string descriptor for a friendly name
libusb_device_handle* h = nullptr;
if (libusb_open(found, &h) == LIBUSB_SUCCESS) {
char buf[256]{};
libusb_device_descriptor desc{};
libusb_get_device_descriptor(found, &desc);
if (desc.iProduct)
libusb_get_string_descriptor_ascii(h, desc.iProduct,
reinterpret_cast<unsigned char*>(buf), sizeof(buf));
device_name_ = buf[0] ? buf : "Nintendo Switch";
libusb_close(h);
}
libusb_unref_device(found);
return true;
}
USBEndpoints MTPSession::find_endpoints(libusb_device* dev) {
libusb_config_descriptor* cfg = nullptr;
if (libusb_get_active_config_descriptor(dev, &cfg) != LIBUSB_SUCCESS)
throw MTPException(ResponseCode::GeneralError, "Cannot read USB config descriptor");
USBEndpoints ep{};
bool ok = false;
for (uint8_t i = 0; i < cfg->bNumInterfaces && !ok; ++i) {
for (int a = 0; a < cfg->interface[i].num_altsetting && !ok; ++a) {
const auto& alt = cfg->interface[i].altsetting[a];
if (alt.bInterfaceClass != MTP_CLASS ||
alt.bInterfaceSubClass != MTP_SUBCLASS ||
alt.bInterfaceProtocol != MTP_PROTOCOL) continue;
ep.interface_num = alt.bInterfaceNumber;
for (uint8_t e = 0; e < alt.bNumEndpoints; ++e) {
const auto& ed = alt.endpoint[e];
uint8_t type = ed.bmAttributes & LIBUSB_TRANSFER_TYPE_MASK;
bool in = (ed.bEndpointAddress & LIBUSB_ENDPOINT_DIR_MASK) == LIBUSB_ENDPOINT_IN;
if (type == LIBUSB_TRANSFER_TYPE_BULK && in && !ep.bulk_in)
ep.bulk_in = ed.bEndpointAddress;
if (type == LIBUSB_TRANSFER_TYPE_BULK && !in && !ep.bulk_out) {
ep.bulk_out = ed.bEndpointAddress;
ep.bulk_out_max_pkt = ed.wMaxPacketSize;
}
if (type == LIBUSB_TRANSFER_TYPE_INTERRUPT && in)
ep.interrupt_in = ed.bEndpointAddress;
}
ok = (ep.bulk_in && ep.bulk_out);
}
}
libusb_free_config_descriptor(cfg);
if (!ok)
throw MTPException(ResponseCode::GeneralError, "Could not locate MTP bulk endpoints");
return ep;
}
// ─── Connect ──────────────────────────────────────────────────────────────────
DeviceInfo MTPSession::connect() {
if (!find_device())
throw MTPException(ResponseCode::GeneralError, "Nintendo Switch not found on USB bus");
libusb_device** list = nullptr;
ssize_t cnt = libusb_get_device_list(ctx_, &list);
if (cnt < 0)
throw MTPException(ResponseCode::GeneralError, "libusb_get_device_list failed");
libusb_device* dev = nullptr;
for (ssize_t i = 0; i < cnt; ++i) {
libusb_device_descriptor desc{};
libusb_get_device_descriptor(list[i], &desc);
if (desc.idVendor == vendor_id_ && desc.idProduct == product_id_) {
dev = list[i];
break;
}
}
if (!dev) {
libusb_free_device_list(list, 1);
throw MTPException(ResponseCode::GeneralError, "Device disappeared after detection");
}
ep_ = find_endpoints(dev);
if (libusb_open(dev, &handle_) != LIBUSB_SUCCESS) {
libusb_free_device_list(list, 1);
throw MTPException(ResponseCode::GeneralError, "Cannot open USB device");
}
libusb_free_device_list(list, 1);
// Detach kernel driver if attached (macOS shouldn't need this but be defensive)
if (libusb_kernel_driver_active(handle_, ep_.interface_num) == 1)
libusb_detach_kernel_driver(handle_, ep_.interface_num);
int r = libusb_claim_interface(handle_, ep_.interface_num);
if (r != LIBUSB_SUCCESS)
throw MTPException(ResponseCode::GeneralError,
std::format("Cannot claim USB interface: {}", libusb_error_name(r)));
// Clear any stale halt/stall state left over from a previous session or
// failed transfer — android-file-transfer-linux always does this before use.
libusb_clear_halt(handle_, ep_.bulk_in);
libusb_clear_halt(handle_, ep_.bulk_out);
connected_.store(true);
// ── MTP Session open ──────────────────────────────────────────────────────
// GetDeviceInfo doesn't require an open session
send_command(OpCode::GetDeviceInfo);
auto raw_info = receive_data();
auto cmd_resp = receive_response();
if (cmd_resp.code != ResponseCode::OK)
throw MTPException(cmd_resp.code, "GetDeviceInfo failed");
// OpenSession
uint32_t sid = SESSION_ID;
send_command(OpCode::OpenSession, std::span<const uint32_t>{&sid, 1});
auto open_resp = receive_response();
if (open_resp.code != ResponseCode::OK
&& open_resp.code != ResponseCode::SessionAlreadyOpen)
throw MTPException(open_resp.code, "OpenSession failed");
// Parse DeviceInfo blob
const uint8_t* p = raw_info.data();
const uint8_t* end = p + raw_info.size();
// Skip the 12-byte container header that receive_data includes
if (raw_info.size() > CONTAINER_HEADER_SIZE) p += CONTAINER_HEADER_SIZE;
DeviceInfo di{};
if (p + 2 <= end) { di.standard_version = get_le16(p); p += 2; }
if (p + 4 <= end) { di.vendor_extension_id = get_le32(p); p += 4; }
if (p + 2 <= end) { di.vendor_extension_version = get_le16(p); p += 2; }
di.vendor_extension_desc = decode_mtp_string(p, end);
if (p + 2 <= end) { di.functional_mode = get_le16(p); p += 2; }
di.operations_supported = decode_u16_array(p, end);
di.events_supported = decode_u16_array(p, end);
di.device_props_supported = decode_u16_array(p, end);
di.capture_formats = decode_u16_array(p, end);
di.playback_formats = decode_u16_array(p, end);
di.manufacturer = decode_mtp_string(p, end);
di.model = decode_mtp_string(p, end);
di.device_version = decode_mtp_string(p, end);
di.serial_number = decode_mtp_string(p, end);
device_info_ = di; // cache so MTPOperations can query supported ops
return di;
}
// ─── Disconnect ───────────────────────────────────────────────────────────────
void MTPSession::disconnect() {
if (!connected_.load()) return;
connected_.store(false);
try {
send_command(OpCode::CloseSession);
receive_response(); // best-effort; ignore result
} catch (...) {}
if (handle_) {
libusb_release_interface(handle_, ep_.interface_num);
libusb_close(handle_);
handle_ = nullptr;
}
}
// ─── Raw USB I/O ──────────────────────────────────────────────────────────────
// Returns true if the libusb error code means the device is physically gone
static bool is_disconnect_error(int r) {
return r == LIBUSB_ERROR_NO_DEVICE
|| r == LIBUSB_ERROR_IO
|| r == LIBUSB_ERROR_PIPE
|| r == LIBUSB_ERROR_OTHER;
}
void MTPSession::bulk_write(std::span<const uint8_t> data, int timeout_ms) {
// One libusb_bulk_transfer per call — never split a transfer across multiple
// calls. If a call times out mid-transfer, calling bulk_transfer again on the
// remaining bytes starts a new USB bulk transaction; the device sees a broken
// packet boundary and the endpoint enters an inconsistent state.
// With 64 KiB chunks each call completes in ~1 ms at USB 2.0 speeds, so
// the timeout is a genuine error signal, not a flow-control retry trigger.
int transferred = 0;
int r = libusb_bulk_transfer(
handle_, ep_.bulk_out,
const_cast<uint8_t*>(data.data()),
static_cast<int>(data.size()),
&transferred, timeout_ms);
if (r != LIBUSB_SUCCESS) {
if (is_disconnect_error(r)) {
connected_.store(false);
throw MTPException(ResponseCode::GeneralError,
"Switch disconnected during transfer — the app may have cancelled "
"the install (e.g. wrong file format) or the USB cable was unplugged");
}
if (r == LIBUSB_ERROR_TIMEOUT)
throw MTPException(ResponseCode::GeneralError,
std::format("USB write timeout ({}ms, {} bytes) — "
"Switch may be busy writing to storage", timeout_ms,
static_cast<int>(data.size())));
throw MTPException(ResponseCode::GeneralError,
std::format("USB write error: {}", libusb_error_name(r)));
}
if (transferred != static_cast<int>(data.size()))
throw MTPException(ResponseCode::IncompleteTransfer,
std::format("USB write: sent {} of {} bytes", transferred, data.size()));
}
std::vector<uint8_t> MTPSession::bulk_read(size_t max_bytes, int timeout_ms) {
std::vector<uint8_t> buf(max_bytes);
int transferred = 0;
int r = libusb_bulk_transfer(
handle_, ep_.bulk_in,
buf.data(), static_cast<int>(max_bytes),
&transferred, timeout_ms);
if (r != LIBUSB_SUCCESS && r != LIBUSB_ERROR_OVERFLOW) {
if (is_disconnect_error(r)) {
connected_.store(false);
throw MTPException(ResponseCode::GeneralError,
"Switch disconnected during read");
}
if (r == LIBUSB_ERROR_TIMEOUT)
throw MTPException(ResponseCode::GeneralError,
std::format("USB read timeout after {}ms", timeout_ms));
throw MTPException(ResponseCode::GeneralError,
std::format("USB read error: {}", libusb_error_name(r)));
}
buf.resize(static_cast<size_t>(transferred));
return buf;
}
// ─── MTP container I/O ────────────────────────────────────────────────────────
void MTPSession::send_command(OpCode op, std::span<const uint32_t> params, uint32_t* out_tid) {
last_op_code_ = static_cast<uint16_t>(op); // echo in subsequent data container
uint32_t tid = next_transaction_id();
if (out_tid) *out_tid = tid;
size_t total = CONTAINER_HEADER_SIZE + params.size() * 4;
std::vector<uint8_t> buf(total, 0);
// Container layout: [0..3]=length [4..5]=type [6..7]=code [8..11]=tid [12+]=params
put_le32(buf.data() + 0, static_cast<uint32_t>(total));
buf[4] = static_cast<uint8_t>(static_cast<uint16_t>(ContainerType::Command) & 0xFF);
buf[5] = static_cast<uint8_t>(static_cast<uint16_t>(ContainerType::Command) >> 8);
buf[6] = static_cast<uint8_t>(static_cast<uint16_t>(op) & 0xFF);
buf[7] = static_cast<uint8_t>(static_cast<uint16_t>(op) >> 8);
put_le32(buf.data() + 8, tid);
for (size_t i = 0; i < params.size(); ++i)
put_le32(buf.data() + CONTAINER_HEADER_SIZE + i * 4, params[i]);
bulk_write(buf, TIMEOUT_CMD_MS);
}
void MTPSession::send_data(std::span<const uint8_t> payload) {
// MTP spec §6.4: data container code field must echo the initiating command's opcode.
// DBI and some other implementations validate this and return OperationNotSupported
// if it's wrong (they use it to route the payload to the right handler).
uint32_t tid = transaction_id_;
size_t total = CONTAINER_HEADER_SIZE + payload.size();
std::vector<uint8_t> buf(total);
put_le32(buf.data(), static_cast<uint32_t>(total));
buf[4] = static_cast<uint8_t>(static_cast<uint16_t>(ContainerType::Data) & 0xFF);
buf[5] = static_cast<uint8_t>(static_cast<uint16_t>(ContainerType::Data) >> 8);
buf[6] = static_cast<uint8_t>(last_op_code_ & 0xFF);
buf[7] = static_cast<uint8_t>(last_op_code_ >> 8);
put_le32(buf.data() + 8, tid);
std::memcpy(buf.data() + CONTAINER_HEADER_SIZE, payload.data(), payload.size());
bulk_write(buf, TIMEOUT_WRITE_MS);
}
std::vector<uint8_t> MTPSession::receive_data() {
// First read: get container header + however much data fits in one transfer
auto first = bulk_read(CHUNK_SIZE, TIMEOUT_READ_MS);
if (first.size() < CONTAINER_HEADER_SIZE)
throw MTPException(ResponseCode::GeneralError, "Truncated MTP container header");
uint32_t total_length = get_le32(first.data());
uint16_t type = get_le16(first.data() + 4);
if (static_cast<ContainerType>(type) == ContainerType::Response) {
// Device sent a response instead of data — parse and throw
ResponseCode rc = static_cast<ResponseCode>(get_le16(first.data() + 6));
if (rc != ResponseCode::OK)
throw MTPException(rc, MTPException::code_name(rc));
return first;
}
std::vector<uint8_t> data = std::move(first);
// Read remaining chunks if total_length > what we received
while (data.size() < total_length) {
size_t remaining = total_length - data.size();
auto chunk = bulk_read(std::min(remaining, CHUNK_SIZE), TIMEOUT_READ_MS);
if (chunk.empty()) break;
data.insert(data.end(), chunk.begin(), chunk.end());
}
return data;
}
CommandResponse MTPSession::receive_response(int timeout_ms) {
auto buf = bulk_read(SMALL_CHUNK_SIZE, timeout_ms);
if (buf.size() < CONTAINER_HEADER_SIZE)
throw MTPException(ResponseCode::GeneralError, "Truncated MTP response");
CommandResponse resp{};
resp.code = static_cast<ResponseCode>(get_le16(buf.data() + 6));
uint32_t length = get_le32(buf.data());
size_t n_params = (length > CONTAINER_HEADER_SIZE)
? (length - CONTAINER_HEADER_SIZE) / 4 : 0;
for (size_t i = 0; i < n_params && CONTAINER_HEADER_SIZE + i*4+4 <= buf.size(); ++i)
resp.params.push_back(get_le32(buf.data() + CONTAINER_HEADER_SIZE + i*4));
return resp;
}
CommandResponse MTPSession::command(OpCode op, std::span<const uint32_t> params) {
send_command(op, params);
return receive_response();
}
// ─── Chunked file receive (GetObject) ─────────────────────────────────────────
void MTPSession::receive_data_to_file(const std::string& dest_path,
uint64_t expected_size,
const ProgressFn& progress,
std::atomic<bool>& cancel) {
std::ofstream ofs(dest_path, std::ios::binary | std::ios::trunc);
if (!ofs) throw MTPException(ResponseCode::GeneralError,
std::format("Cannot open destination file: {}", dest_path));
// Read the header chunk
auto header_chunk = bulk_read(CHUNK_SIZE, TIMEOUT_READ_MS);
if (header_chunk.size() < CONTAINER_HEADER_SIZE)
throw MTPException(ResponseCode::GeneralError, "Truncated data container header");
uint32_t total_length = get_le32(header_chunk.data());
uint64_t payload_size = (total_length > CONTAINER_HEADER_SIZE)
? total_length - CONTAINER_HEADER_SIZE : 0;
// Write payload portion of first chunk
size_t first_payload = header_chunk.size() > CONTAINER_HEADER_SIZE
? header_chunk.size() - CONTAINER_HEADER_SIZE : 0;
if (first_payload)
ofs.write(reinterpret_cast<const char*>(header_chunk.data() + CONTAINER_HEADER_SIZE),
static_cast<std::streamsize>(first_payload));
uint64_t written = first_payload;
if (progress) progress(written, expected_size ? expected_size : payload_size);
while (written < payload_size) {
if (cancel.load()) {
ofs.close();
std::remove(dest_path.c_str());
throw MTPException(ResponseCode::TransactionCancelled, "Transfer cancelled");
}
size_t want = std::min<uint64_t>(CHUNK_SIZE, payload_size - written);
auto chunk = bulk_read(want, TIMEOUT_READ_MS);
if (chunk.empty()) break;
ofs.write(reinterpret_cast<const char*>(chunk.data()),
static_cast<std::streamsize>(chunk.size()));
written += chunk.size();
if (progress) progress(written, expected_size ? expected_size : payload_size);
}
ofs.close();
}
// ─── Chunked file send (SendObject) ───────────────────────────────────────────
void MTPSession::send_data_from_file(const std::string& src_path,
uint64_t file_size,
OpCode op,
const ProgressFn& progress,
std::atomic<bool>& cancel) {
std::ifstream ifs(src_path, std::ios::binary);
if (!ifs) throw MTPException(ResponseCode::GeneralError,
std::format("Cannot open source file: {}", src_path));
uint32_t tid = transaction_id_;
uint64_t total = CONTAINER_HEADER_SIZE + file_size;
uint32_t wire_len = (total > 0xFFFFFFFFu) ? 0xFFFFFFFFu
: static_cast<uint32_t>(total);
// Build 12-byte container header
std::array<uint8_t, CONTAINER_HEADER_SIZE> hdr{};
put_le32(hdr.data(), wire_len);
hdr[4] = static_cast<uint8_t>(static_cast<uint16_t>(ContainerType::Data) & 0xFF);
hdr[5] = static_cast<uint8_t>(static_cast<uint16_t>(ContainerType::Data) >> 8);
hdr[6] = static_cast<uint8_t>(static_cast<uint16_t>(op) & 0xFF);
hdr[7] = static_cast<uint8_t>(static_cast<uint16_t>(op) >> 8);
put_le32(hdr.data() + 8, tid);
// ── First USB packet ────────────────────────────────────────────────────
// go-mtpfs sends exactly ONE USB max-packet as the first write:
// header (12 bytes) + first (max_packet_size - 12) bytes of file data.
// This gives the device a clean full USB packet containing the MTP header
// followed immediately by data, matching how every real MTP client behaves.
// Sending a 12-byte-only short packet (previous approach) causes DBI/Sphaira
// to think the data phase ended before any file bytes were delivered.
const size_t max_pkt = static_cast<size_t>(ep_.bulk_out_max_pkt); // 512 HS / 1024 SS
size_t first_data_want = max_pkt > CONTAINER_HEADER_SIZE
? max_pkt - CONTAINER_HEADER_SIZE : 0;
first_data_want = static_cast<size_t>(
std::min<uint64_t>(first_data_want, file_size));
std::vector<uint8_t> first_pkt(CONTAINER_HEADER_SIZE + first_data_want);
std::memcpy(first_pkt.data(), hdr.data(), CONTAINER_HEADER_SIZE);
if (first_data_want > 0)
ifs.read(reinterpret_cast<char*>(first_pkt.data() + CONTAINER_HEADER_SIZE),
static_cast<std::streamsize>(first_data_want));
size_t first_got = static_cast<size_t>(ifs.gcount());
first_pkt.resize(CONTAINER_HEADER_SIZE + first_got);
bulk_write(first_pkt, TIMEOUT_WRITE_MS);
uint64_t sent = first_got;
if (progress) progress(sent, file_size);
// ── Subsequent chunks ────────────────────────────────────────────────────
// USB 3.0 SuperSpeed (max_pkt = 1024): use 512 KiB chunks — 32× larger than
// the go-mtpfs 16 KiB baseline. Each libusb_bulk_transfer call costs ~100 µs
// of host-side overhead; fewer, larger calls are needed to saturate USB 3.0.
// USB 2.0 High Speed (max_pkt = 512): keep 16 KiB per go-mtpfs.
const size_t xfer_chunk = (max_pkt >= 1024) ? CHUNK_SIZE_USB3 : CHUNK_SIZE;
size_t last_write_size = first_pkt.size();
std::vector<uint8_t> buf(xfer_chunk);
while (sent < file_size) {
if (cancel.load())
throw MTPException(ResponseCode::TransactionCancelled, "Transfer cancelled");
size_t want = static_cast<size_t>(std::min<uint64_t>(xfer_chunk, file_size - sent));
ifs.read(reinterpret_cast<char*>(buf.data()), static_cast<std::streamsize>(want));
auto got = static_cast<size_t>(ifs.gcount());
if (got == 0) break;
// Use a longer per-chunk timeout for USB 3.0 large chunks:
// 512 KiB at even 1 MB/s = 512 ms; 10 s gives plenty of margin.
const int chunk_timeout = (max_pkt >= 1024) ? 10000 : TIMEOUT_WRITE_MS;
bulk_write(std::span<const uint8_t>(buf.data(), got), chunk_timeout);
last_write_size = got;
sent += got;
if (progress) progress(sent, file_size);
if (cancel.load())
throw MTPException(ResponseCode::TransactionCancelled, "Transfer cancelled");
}
// ── Zero-length packet ─────────────────────────────────────────────────
// go-mtpfs: "if lastTransfer % packetSize == 0, write a short packet just
// to be sure." Without it, a packet-size-aligned last chunk has no natural
// short-packet terminator and the device may hang waiting for more data.
if (last_write_size % max_pkt == 0) {
int dummy_tr = 0;
libusb_bulk_transfer(handle_, ep_.bulk_out, nullptr, 0, &dummy_tr, 250);
}
}
} // namespace mtp

125
src/mtp/MTPSession.hpp Normal file
View File

@@ -0,0 +1,125 @@
#pragma once
#include "MTPProtocol.hpp"
#include <libusb.h>
#include <atomic>
#include <functional>
#include <mutex>
#include <span>
#include <vector>
namespace mtp {
struct CommandResponse {
ResponseCode code;
std::vector<uint32_t> params;
};
struct USBEndpoints {
uint8_t bulk_in = 0;
uint8_t bulk_out = 0;
uint8_t interrupt_in = 0;
int interface_num = 0;
int bulk_out_max_pkt = 512; // wMaxPacketSize: 512 for USB 2.0 HS, 1024 for SS
};
using ProgressFn = std::function<void(uint64_t done, uint64_t total)>;
// MTPSession owns the libusb handle, USB endpoints, and MTP session state.
// All operations are serialized through the session mutex — MTP is strictly
// single-threaded from the device's perspective.
class MTPSession {
public:
MTPSession();
~MTPSession();
MTPSession(const MTPSession&) = delete;
MTPSession& operator=(const MTPSession&) = delete;
// Scan USB bus, open the Switch, negotiate MTP session.
// Returns DeviceInfo on success; throws MTPException on failure.
DeviceInfo connect();
// Close MTP session and release USB resources.
void disconnect();
bool is_connected() const { return connected_.load(); }
// ── Low-level MTP API (called by MTPOperations) ──────────────────────────
// Send a command container with up to 5 parameters.
// Returns the assigned transaction_id via the optional out-param.
void send_command(OpCode op,
std::span<const uint32_t> params = {},
uint32_t* out_tid = nullptr);
// Send a data container following a command.
void send_data(std::span<const uint8_t> data);
// Receive a data container into a heap buffer.
std::vector<uint8_t> receive_data();
// Chunked receive directly into a file — avoids buffering whole file in RAM.
void receive_data_to_file(const std::string& dest_path,
uint64_t expected_size,
const ProgressFn& progress,
std::atomic<bool>& cancel);
// Chunked send of a file as a data container.
// op must be the OpCode from the preceding send_command() call so the
// data container echoes the correct operation code (MTP spec §6.4).
void send_data_from_file(const std::string& src_path,
uint64_t file_size,
OpCode op,
const ProgressFn& progress,
std::atomic<bool>& cancel);
// Receive the response container after a command (+optional data exchange).
// Pass TIMEOUT_INSTALL_MS after large file transfers so post-install DB work
// on the Switch side has time to complete before we declare a timeout error.
CommandResponse receive_response(int timeout_ms = TIMEOUT_CMD_MS);
// Convenience: send command then immediately receive response (no data phase).
CommandResponse command(OpCode op, std::span<const uint32_t> params = {});
uint32_t next_transaction_id() { return ++transaction_id_; }
// Accessors for device metadata
uint16_t vendor_id() const { return vendor_id_; }
uint16_t product_id() const { return product_id_; }
std::string device_name() const { return device_name_; }
const DeviceInfo& device_info() const { return device_info_; }
bool supports_op(OpCode op) const {
auto code = static_cast<uint16_t>(op);
for (auto c : device_info_.operations_supported)
if (c == code) return true;
return false;
}
// Serialize all MTP access through this mutex (TransferEngine uses it)
std::mutex& mutex() { return mtx_; }
private:
bool find_device();
USBEndpoints find_endpoints(libusb_device* dev);
// Raw USB bulk I/O
std::vector<uint8_t> bulk_read(size_t max_bytes, int timeout_ms = TIMEOUT_MS);
void bulk_write(std::span<const uint8_t> data, int timeout_ms = TIMEOUT_MS);
libusb_context* ctx_ = nullptr;
libusb_device_handle* handle_ = nullptr;
USBEndpoints ep_;
uint32_t transaction_id_ = 0;
uint16_t last_op_code_ = 0;
uint16_t vendor_id_ = 0;
uint16_t product_id_ = 0;
DeviceInfo device_info_;
std::string device_name_;
std::atomic<bool> connected_{false};
mutable std::mutex mtx_;
};
} // namespace mtp

View File

@@ -0,0 +1,180 @@
#include "TransferEngine.hpp"
#include <filesystem>
#include <format>
#include <random>
#include <sstream>
#include <iomanip>
namespace fs = std::filesystem;
namespace transfer {
std::string TransferEngine::make_id() {
static std::mt19937_64 rng{std::random_device{}()};
static std::uniform_int_distribution<uint64_t> dist;
std::ostringstream ss;
ss << std::hex << std::setw(16) << std::setfill('0') << dist(rng);
return ss.str();
}
TransferEngine::TransferEngine(mtp::MTPSession& session)
: session_(session), ops_(session)
{
worker_ = std::jthread([this](std::stop_token) { worker_loop(); });
}
TransferEngine::~TransferEngine() {
shutdown_.store(true);
cv_.notify_all();
// std::jthread destructor requests stop and joins automatically
}
std::shared_ptr<TransferItem>
TransferEngine::enqueue_download(uint32_t handle, uint32_t storage_id,
const std::string& dest_path,
const std::string& filename,
uint64_t expected_size) {
auto item = std::make_shared<TransferItem>();
item->id = make_id();
item->direction = Direction::Download;
item->filename = filename;
item->total_bytes = expected_size;
item->object_handle = handle;
item->storage_id = storage_id;
item->dest_path = dest_path;
std::lock_guard lock(mtx_);
history_.push_back(item);
pending_.push(item);
cv_.notify_one();
return item;
}
std::shared_ptr<TransferItem>
TransferEngine::enqueue_upload(const std::string& src_path,
uint32_t parent_handle, uint32_t storage_id,
const std::string& filename,
uint64_t file_size) {
auto item = std::make_shared<TransferItem>();
item->id = make_id();
item->direction = Direction::Upload;
item->filename = filename;
item->total_bytes = file_size;
item->src_path = src_path;
item->parent_handle = parent_handle;
item->storage_id = storage_id;
std::lock_guard lock(mtx_);
history_.push_back(item);
pending_.push(item);
cv_.notify_one();
return item;
}
void TransferEngine::cancel(const std::string& transfer_id) {
std::lock_guard lock(mtx_);
for (auto& item : history_) {
if (item->id == transfer_id) {
item->cancel.store(true);
break;
}
}
}
void TransferEngine::cancel_all() {
std::lock_guard lock(mtx_);
for (auto& item : history_)
item->cancel.store(true);
}
std::vector<std::shared_ptr<TransferItem>> TransferEngine::all_transfers() const {
std::lock_guard lock(mtx_);
return history_;
}
bool TransferEngine::has_active() const {
std::lock_guard lock(mtx_);
for (auto& item : history_)
if (item->state.load() == State::Active) return true;
return false;
}
void TransferEngine::set_completion_handler(CompletionFn fn) {
std::lock_guard lock(mtx_);
completion_ = std::move(fn);
}
void TransferEngine::worker_loop() {
while (!shutdown_.load()) {
std::shared_ptr<TransferItem> item;
{
std::unique_lock lock(mtx_);
cv_.wait(lock, [&]{ return shutdown_.load() || !pending_.empty(); });
if (shutdown_.load()) break;
if (pending_.empty()) continue;
item = pending_.front();
pending_.pop();
}
if (item->cancel.load()) {
item->state.store(State::Cancelled);
continue;
}
item->state.store(State::Active);
item->started_at = std::chrono::steady_clock::now();
try {
if (item->direction == Direction::Download)
run_download(*item);
else
run_upload(*item);
if (item->cancel.load())
item->state.store(State::Cancelled);
else
item->state.store(State::Done);
} catch (const mtp::MTPException& e) {
item->error_msg = e.what();
item->state.store(State::Failed);
} catch (const std::exception& e) {
item->error_msg = e.what();
item->state.store(State::Failed);
}
item->finished_at = std::chrono::steady_clock::now();
CompletionFn cb;
{
std::lock_guard lock(mtx_);
cb = completion_;
}
if (cb) cb(*item);
}
}
void TransferEngine::run_download(TransferItem& item) {
// Ensure destination directory exists
if (auto parent = fs::path(item.dest_path).parent_path(); !parent.empty())
fs::create_directories(parent);
mtp::ProgressFn progress = [&](uint64_t done, uint64_t total) {
item.bytes_done.store(done);
if (total && !item.total_bytes) item.total_bytes = total;
};
ops_.get_object(item.object_handle, item.dest_path, item.total_bytes,
progress, item.cancel);
}
void TransferEngine::run_upload(TransferItem& item) {
mtp::ProgressFn progress = [&](uint64_t done, uint64_t /*total*/) {
item.bytes_done.store(done);
};
ops_.send_object(item.storage_id, item.parent_handle,
item.src_path, item.filename, item.total_bytes,
progress, item.cancel);
}
} // namespace transfer

View File

@@ -0,0 +1,115 @@
#pragma once
#include "mtp/MTPSession.hpp"
#include "mtp/MTPOperations.hpp"
#include <atomic>
#include <chrono>
#include <condition_variable>
#include <functional>
#include <memory>
#include <mutex>
#include <queue>
#include <string>
#include <thread>
#include <vector>
namespace transfer {
enum class Direction { Download, Upload };
enum class State { Queued, Active, Done, Failed, Cancelled };
struct TransferItem {
std::string id;
std::string filename;
Direction direction;
uint64_t total_bytes = 0;
std::string error_msg;
std::atomic<uint64_t> bytes_done{0};
std::atomic<State> state{State::Queued};
std::atomic<bool> cancel{false};
std::chrono::steady_clock::time_point started_at;
std::chrono::steady_clock::time_point finished_at;
float progress() const {
if (total_bytes == 0) return 0.f;
return static_cast<float>(bytes_done.load()) / static_cast<float>(total_bytes);
}
// Bytes per second since transfer started
double speed_bps() const {
if (state.load() != State::Active) return 0.0;
auto elapsed = std::chrono::duration<double>(
std::chrono::steady_clock::now() - started_at).count();
return elapsed > 0.0 ? static_cast<double>(bytes_done.load()) / elapsed : 0.0;
}
double eta_seconds() const {
double sp = speed_bps();
if (sp <= 0) return -1.0;
uint64_t remaining = total_bytes > bytes_done.load()
? total_bytes - bytes_done.load() : 0;
return static_cast<double>(remaining) / sp;
}
// Download-specific fields
uint32_t object_handle = 0;
uint32_t storage_id = 0;
std::string dest_path;
// Upload-specific fields
std::string src_path;
uint32_t parent_handle = 0;
};
// Single-worker transfer engine. MTP is inherently serial so one worker is
// correct; the worker uses dedicated operations to keep the mutex clean.
class TransferEngine {
public:
explicit TransferEngine(mtp::MTPSession& session);
~TransferEngine();
// Returns a shared_ptr to the queued item (so the UI can observe progress).
std::shared_ptr<TransferItem>
enqueue_download(uint32_t handle, uint32_t storage_id,
const std::string& dest_path,
const std::string& filename,
uint64_t expected_size);
std::shared_ptr<TransferItem>
enqueue_upload(const std::string& src_path,
uint32_t parent_handle, uint32_t storage_id,
const std::string& filename,
uint64_t file_size);
void cancel(const std::string& transfer_id);
void cancel_all();
// Thread-safe snapshot for the UI to display
std::vector<std::shared_ptr<TransferItem>> all_transfers() const;
bool has_active() const;
using CompletionFn = std::function<void(const TransferItem&)>;
void set_completion_handler(CompletionFn fn);
private:
void worker_loop();
void run_download(TransferItem& item);
void run_upload(TransferItem& item);
mtp::MTPSession& session_;
mtp::MTPOperations ops_;
mutable std::mutex mtx_;
std::condition_variable cv_;
std::queue<std::shared_ptr<TransferItem>> pending_;
std::vector<std::shared_ptr<TransferItem>> history_; // all items ever queued
CompletionFn completion_;
std::jthread worker_;
std::atomic<bool> shutdown_{false};
static std::string make_id();
};
} // namespace transfer

158
src/ui/App.hpp Normal file
View File

@@ -0,0 +1,158 @@
#pragma once
#include "mtp/MTPSession.hpp"
#include "mtp/MTPOperations.hpp"
#include "mtp/MTPProtocol.hpp"
#include "transfer/TransferEngine.hpp"
#include <atomic>
#include <filesystem>
#include <memory>
#include <mutex>
#include <set>
#include <string>
#include <thread>
#include <vector>
namespace omniMTP {
namespace fs = std::filesystem;
// ── Entry in a file browser panel ────────────────────────────────────────────
struct LocalEntry {
fs::path path;
std::string name;
bool is_dir = false;
uint64_t size = 0;
std::string size_str;
std::string date_str;
};
struct LocalDrive {
std::string path;
std::string name;
bool removable = false;
};
struct RemoteEntry {
uint32_t handle = 0;
uint32_t storage_id = 0;
std::string name;
bool is_dir = false;
uint64_t size = 0;
std::string size_str;
std::string date_str;
};
// Breadcrumb stack item
struct NavEntry {
uint32_t handle = 0; // mtp::ROOT_PARENT = root of storage
uint32_t storage_id = 0;
std::string label;
};
// ── Main application ──────────────────────────────────────────────────────────
class App {
public:
App();
~App();
void init();
void shutdown();
// ── Web bridge API ────────────────────────────────────────────────────────
std::string state_json() const;
std::string current_local_path() const;
void navigate_local(const std::string& path);
void navigate_local_up();
void navigate_remote(uint32_t handle, uint32_t storage_id, const std::string& name);
void navigate_remote_up();
void navigate_remote_to(size_t idx);
void set_active_storage(uint32_t storage_id);
void request_refresh_remote();
void do_upload(const std::string& src_path);
void do_download(uint32_t handle, uint32_t storage_id, const std::string& filename, uint64_t size);
// Blocking download used by Finder drag-export (NSFilePromiseProvider).
void download_remote_to_path(uint32_t handle, uint32_t storage_id,
const std::string& dest_path, uint64_t size);
void do_cancel(const std::string& id);
void do_cancel_all();
void do_delete(uint32_t handle);
void do_create_folder(const std::string& name);
void reconnect();
bool connected() const { return connected_.load(); }
// Drop-zone rects (CSS pixels, origin top-left) updated by the web UI.
void update_drop_zones(double rx, double ry, double rw, double rh,
double lx, double ly, double lw, double lh);
// Returns "remote", "local", or "none".
std::string drop_target_at(double x, double y) const;
// Called from the OS drag-and-drop callback (may be on a different thread).
void enqueue_finder_drop(const std::string& path);
private:
// ── Local filesystem ──────────────────────────────────────────────────────
void refresh_local(const fs::path& path);
void refresh_local_drives();
std::string active_local_drive_path() const;
// ── Remote filesystem ─────────────────────────────────────────────────────
void refresh_remote();
// ── Transfer helpers ──────────────────────────────────────────────────────
void start_download(uint32_t handle, uint32_t storage_id,
const std::string& filename, uint64_t size);
void start_upload(const fs::path& src_path, uint64_t file_size);
// ── Device connection loop (runs on background thread) ────────────────────
void device_monitor_loop(std::stop_token st);
void on_device_connected(mtp::DeviceInfo di);
void on_device_disconnected();
// ── Status bar ────────────────────────────────────────────────────────────
void set_status(const std::string& msg, float duration_sec = 5.f);
// ── Finder drop processing (called from state_json) ───────────────────────
void process_finder_drops();
// ── State: connection ─────────────────────────────────────────────────────
std::unique_ptr<mtp::MTPSession> session_;
std::unique_ptr<mtp::MTPOperations> ops_;
std::unique_ptr<transfer::TransferEngine> engine_;
std::atomic<bool> connected_{false};
std::jthread monitor_thread_;
mutable std::mutex device_mtx_;
mtp::DeviceInfo device_info_;
std::vector<std::pair<uint32_t, mtp::StorageInfo>> storages_;
uint32_t active_storage_ = 0;
// ── State: local panel ────────────────────────────────────────────────────
fs::path local_path_;
std::vector<LocalEntry> local_entries_;
std::vector<LocalDrive> local_drives_;
mutable std::atomic<bool> local_needs_refresh_{false};
// ── State: remote panel ───────────────────────────────────────────────────
std::vector<NavEntry> remote_nav_stack_;
std::vector<RemoteEntry> remote_entries_;
mutable bool remote_needs_refresh_ = true;
bool remote_refreshing_ = false;
// Status bar message with timeout
std::string status_msg_;
float status_msg_timer_ = 0.f;
// Files/folders dropped from macOS Finder.
// Written from the drop callback thread, read + cleared elsewhere.
std::vector<std::string> finder_drops_;
std::mutex finder_drops_mtx_;
struct DropZone { double x = 0, y = 0, w = 0, h = 0; };
DropZone remote_drop_zone_;
DropZone local_drop_zone_;
bool drop_zones_valid_ = false;
};
} // namespace omniMTP

537
src/ui/App.mm Normal file
View File

@@ -0,0 +1,537 @@
#include "App.hpp"
#import <Foundation/Foundation.h>
#include <stdexcept>
#include <cstdio>
#include <filesystem>
#include <format>
#include <algorithm>
#include <chrono>
#include <thread>
#include <sys/stat.h>
#include <cstdlib>
#include <cstring>
namespace fs = std::filesystem;
namespace omniMTP {
// ─── Static helpers ───────────────────────────────────────────────────────────
static std::string date_str_from_fs(const fs::directory_entry& e) {
struct ::stat st{};
if (::stat(e.path().c_str(), &st) == 0) {
char buf[32];
std::strftime(buf, sizeof(buf), "%b %e, %Y", std::localtime(&st.st_mtime));
return buf;
}
return "";
}
using CrumbList = std::vector<std::pair<std::string, fs::path>>;
static CrumbList build_crumbs(const fs::path& path) {
std::vector<std::pair<std::string, fs::path>> segs;
fs::path p = path;
while (true) {
auto par = p.parent_path();
if (par == p) break; // reached root — skip it
segs.insert(segs.begin(), {p.filename().string(), p});
p = par;
}
return segs;
}
static std::string jstr(const std::string& s) {
std::string r; r.reserve(s.size()+4);
for (char c : s) {
if (c=='"') r+="\\\"";
else if (c=='\\') r+="\\\\";
else if (c=='\n') r+="\\n";
else if (c=='\r') r+="\\r";
else if (c=='\t') r+="\\t";
else r+=c;
}
return r;
}
static std::string fmt_size(uint64_t b) {
if (!b) return "0 B";
if (b<1024) return std::to_string(b)+" B";
if (b<(1u<<20)) return std::to_string(b/1024)+" KB";
if (b<(1ull<<30)) { char x[24]; snprintf(x,sizeof(x),"%.1f MB",b/1048576.0); return x; }
char x[24]; snprintf(x,sizeof(x),"%.2f GB",b/1073741824.0); return x;
}
static std::string fmt_speed(double bps) {
char b[32];
if (bps>1048576) snprintf(b,sizeof(b),"%.0f MB/s",bps/1048576.0);
else snprintf(b,sizeof(b),"%.0f KB/s",bps/1024.0);
return b;
}
static std::string fmt_eta(double s) {
if (s<0) return "--:--";
int si = (int)s;
if (si<60) { char b[16]; snprintf(b,sizeof(b),"0:%02d",si); return b; }
if (si<3600) { char b[16]; snprintf(b,sizeof(b),"%d:%02d",si/60,si%60); return b; }
char b[16]; snprintf(b,sizeof(b),"%dh%02dm",si/3600,(si%3600)/60); return b;
}
// ─── App lifecycle ────────────────────────────────────────────────────────────
App::App() : local_path_(fs::path(std::getenv("HOME") ? std::getenv("HOME") : "/")) {}
App::~App() { shutdown(); }
void App::init() {
local_path_ = fs::path(std::getenv("HOME") ? std::getenv("HOME") : "/");
refresh_local(local_path_);
monitor_thread_ = std::jthread([this](std::stop_token st) { device_monitor_loop(st); });
}
void App::shutdown() {
monitor_thread_.request_stop();
engine_.reset();
if (connected_.load())
try { if (session_) session_->disconnect(); } catch (...) {}
}
// ─── Device monitor ───────────────────────────────────────────────────────────
void App::device_monitor_loop(std::stop_token st) {
while (!st.stop_requested()) {
if (!connected_.load()) {
try {
auto sess = std::make_unique<mtp::MTPSession>();
auto di = sess->connect();
{
std::lock_guard lock(device_mtx_);
session_ = std::move(sess);
ops_ = std::make_unique<mtp::MTPOperations>(*session_);
engine_ = std::make_unique<transfer::TransferEngine>(*session_);
engine_->set_completion_handler([this](const transfer::TransferItem& item) {
if (item.state.load() != transfer::State::Done) return;
if (item.direction == transfer::Direction::Download)
local_needs_refresh_.store(true);
else
remote_needs_refresh_ = true;
});
}
on_device_connected(std::move(di));
} catch (...) {}
} else {
bool transfer_active = false;
{
std::lock_guard lock(device_mtx_);
transfer_active = engine_ && engine_->has_active();
}
if (!transfer_active) {
mtp::MTPOperations* ops_raw = nullptr;
{
std::lock_guard lock(device_mtx_);
ops_raw = ops_.get();
}
if (ops_raw) {
try { ops_raw->get_storage_ids(); }
catch (...) { on_device_disconnected(); }
}
}
}
for (int i = 0; i < 8 && !st.stop_requested(); ++i)
std::this_thread::sleep_for(std::chrono::milliseconds(250));
}
}
void App::on_device_connected(mtp::DeviceInfo di) {
{
std::lock_guard lock(device_mtx_);
device_info_ = std::move(di);
storages_.clear();
remote_nav_stack_.clear();
remote_entries_.clear();
try {
auto ids = ops_->get_storage_ids();
for (auto id : ids) {
auto si = ops_->get_storage_info(id);
storages_.emplace_back(id, std::move(si));
}
if (!storages_.empty()) {
active_storage_ = storages_.front().first;
const auto& si = storages_.front().second;
std::string label = si.description.empty()
? std::format("Storage {:04X}", active_storage_)
: si.description;
remote_nav_stack_.push_back({mtp::ROOT_PARENT, active_storage_, label});
}
} catch (...) {}
}
connected_.store(true);
remote_needs_refresh_ = true;
set_status(std::format("Connected {}", device_info_.model));
refresh_remote();
}
void App::on_device_disconnected() {
{
std::lock_guard lock(device_mtx_);
if (engine_) { engine_->cancel_all(); engine_.reset(); }
ops_.reset();
if (session_) { try { session_->disconnect(); } catch (...) {} session_.reset(); }
storages_.clear();
remote_entries_.clear();
remote_nav_stack_.clear();
}
connected_.store(false);
remote_needs_refresh_ = false;
set_status("Switch disconnected");
}
// ─── Local filesystem ─────────────────────────────────────────────────────────
void App::refresh_local(const fs::path& path) {
local_entries_.clear();
try {
for (auto& e : fs::directory_iterator(path,
fs::directory_options::skip_permission_denied)) {
LocalEntry le{};
le.path = e.path();
le.name = e.path().filename().string();
le.is_dir = e.is_directory();
if (!le.is_dir) { le.size = e.file_size(); le.size_str = fmt_size(le.size); }
le.date_str = date_str_from_fs(e);
local_entries_.push_back(std::move(le));
}
std::sort(local_entries_.begin(), local_entries_.end(), [](auto& a, auto& b) {
if (a.is_dir != b.is_dir) return a.is_dir > b.is_dir;
return a.name < b.name;
});
} catch (...) {}
local_path_ = path;
}
void App::refresh_local_drives() {
local_drives_.clear();
@autoreleasepool {
NSFileManager* fm = NSFileManager.defaultManager;
NSArray* keys = @[
NSURLVolumeNameKey,
NSURLVolumeIsRemovableKey,
];
NSArray<NSURL*>* urls =
[fm mountedVolumeURLsIncludingResourceValuesForKeys:keys
options:NSVolumeEnumerationSkipHiddenVolumes];
bool has_data_volume = false;
for (NSURL* url in urls) {
if ([url.path isEqualToString:@"/System/Volumes/Data"])
has_data_volume = true;
}
for (NSURL* url in urls) {
NSString* path = url.path;
if (!path || path.length == 0) continue;
std::string mount = path.UTF8String;
if (mount == "/dev" || mount == "/home" || mount == "/net") continue;
if (mount.rfind("/private/", 0) == 0) continue;
if (mount.rfind("/System/Volumes/", 0) == 0 && mount != "/System/Volumes/Data") continue;
if (mount == "/" && has_data_volume) continue;
NSString* vol_name = nil;
NSNumber* removable = nil;
[url getResourceValue:&vol_name forKey:NSURLVolumeNameKey error:nil];
[url getResourceValue:&removable forKey:NSURLVolumeIsRemovableKey error:nil];
LocalDrive drive{};
drive.path = mount;
drive.name = vol_name.length ? vol_name.UTF8String : fs::path(mount).filename().string();
if (drive.name.empty()) drive.name = mount;
drive.removable = removable.boolValue;
local_drives_.push_back(std::move(drive));
}
}
std::sort(local_drives_.begin(), local_drives_.end(),
[](const LocalDrive& a, const LocalDrive& b) {
if (a.removable != b.removable) return a.removable < b.removable;
return a.name < b.name;
});
}
std::string App::active_local_drive_path() const {
std::error_code ec;
fs::path current = fs::weakly_canonical(local_path_, ec);
if (ec) current = local_path_;
const std::string current_str = current.string();
std::string best;
size_t best_len = 0;
for (const auto& drive : local_drives_) {
fs::path root = fs::path(drive.path);
fs::path root_canonical = fs::weakly_canonical(root, ec);
if (ec) root_canonical = root;
const std::string root_str = root_canonical.string();
const bool under =
current == root_canonical ||
current_str.rfind(root_str + "/", 0) == 0;
if (under && root_str.size() >= best_len) {
best = drive.path;
best_len = root_str.size();
}
}
if (!best.empty()) return best;
return local_drives_.empty() ? "" : local_drives_.front().path;
}
// ─── Remote filesystem ────────────────────────────────────────────────────────
void App::refresh_remote() {
if (!connected_.load() || remote_refreshing_) return;
remote_refreshing_ = true;
remote_entries_.clear();
try {
std::lock_guard lock(device_mtx_);
if (!ops_ || remote_nav_stack_.empty()) { remote_refreshing_ = false; return; }
const auto& top = remote_nav_stack_.back();
auto handles = ops_->get_object_handles(top.storage_id, top.handle);
for (auto h : handles) {
auto oi = ops_->get_object_info(h);
RemoteEntry re{};
re.handle = h;
re.storage_id = oi.storage_id;
re.name = oi.filename;
re.is_dir = oi.is_directory();
re.size = oi.compressed_size;
re.size_str = re.is_dir ? "" : fmt_size(re.size);
re.date_str = oi.date_modified.empty() ? oi.date_created : oi.date_modified;
remote_entries_.push_back(std::move(re));
}
std::sort(remote_entries_.begin(), remote_entries_.end(), [](auto& a, auto& b) {
if (a.is_dir != b.is_dir) return a.is_dir > b.is_dir;
return a.name < b.name;
});
} catch (const mtp::MTPException& e) {
set_status(std::format("Error listing: {}", e.what()));
}
remote_refreshing_ = false;
remote_needs_refresh_ = false;
}
// ─── Transfers / misc ─────────────────────────────────────────────────────────
void App::start_download(uint32_t handle, uint32_t storage_id,
const std::string& filename, uint64_t size) {
if (!engine_) return;
engine_->enqueue_download(handle, storage_id,
(local_path_ / filename).string(), filename, size);
set_status(std::format("Downloading {}", filename));
}
void App::start_upload(const fs::path& src, uint64_t sz) {
if (!engine_ || remote_nav_stack_.empty()) return;
const auto& top = remote_nav_stack_.back();
engine_->enqueue_upload(src.string(), top.handle, top.storage_id,
src.filename().string(), sz);
set_status(std::format("Uploading {}", src.filename().string()));
}
void App::set_status(const std::string& msg, float dur) {
status_msg_ = msg; status_msg_timer_ = dur;
}
void App::enqueue_finder_drop(const std::string& path) {
std::lock_guard lock(finder_drops_mtx_);
finder_drops_.push_back(path);
}
void App::update_drop_zones(double rx, double ry, double rw, double rh,
double lx, double ly, double lw, double lh) {
remote_drop_zone_ = {rx, ry, rw, rh};
local_drop_zone_ = {lx, ly, lw, lh};
drop_zones_valid_ = rw > 0 && rh > 0 && lw > 0 && lh > 0;
}
std::string App::drop_target_at(double x, double y) const {
if (!drop_zones_valid_) return "none";
auto inside = [](const DropZone& z, double px, double py) {
return px >= z.x && px <= z.x + z.w && py >= z.y && py <= z.y + z.h;
};
if (inside(remote_drop_zone_, x, y)) return "remote";
if (inside(local_drop_zone_, x, y)) return "local";
return "none";
}
// ─── Web bridge API ───────────────────────────────────────────────────────────
std::string App::current_local_path() const { return local_path_.string(); }
std::string App::state_json() const {
// Process any pending finder drops
{
const_cast<App*>(this)->process_finder_drops();
}
if (local_needs_refresh_.load()) {
local_needs_refresh_.store(false);
const_cast<App*>(this)->refresh_local(local_path_);
}
if (remote_needs_refresh_ && connected_.load()) {
remote_needs_refresh_ = false;
const_cast<App*>(this)->refresh_remote();
}
const_cast<App*>(this)->refresh_local_drives();
const std::string active_drive = active_local_drive_path();
bool conn = connected_.load();
std::string dn;
std::vector<std::pair<uint32_t,mtp::StorageInfo>> stor;
uint32_t astor;
std::vector<NavEntry> nav;
bool refreshing;
{
std::lock_guard lock(device_mtx_);
dn = device_info_.model;
stor = storages_;
astor = active_storage_;
nav = remote_nav_stack_;
refreshing = remote_refreshing_;
}
std::string j = "{";
j += "\"connected\":" + std::string(conn?"true":"false") + ",";
j += "\"deviceName\":\"" + jstr(dn) + "\",";
j += "\"storages\":[";
for (size_t i=0;i<stor.size();++i) {
auto& [id,si]=stor[i];
j+="{\"id\":"+std::to_string(id)+",\"name\":\""+jstr(si.description)+"\",";
j+="\"freeBytes\":"+std::to_string(si.free_space_bytes)+",\"freeStr\":\""+fmt_size(si.free_space_bytes)+"\",";
j+="\"totalBytes\":"+std::to_string(si.max_capacity)+",\"totalStr\":\""+fmt_size(si.max_capacity)+"\"}";
if (i+1<stor.size()) j+=",";
}
j+="],\"activeStorageId\":"+std::to_string(astor)+",";
j+="\"remoteNavStack\":[";
for (size_t i=0;i<nav.size();++i) {
j+="{\"handle\":"+std::to_string(nav[i].handle)+",\"storageId\":"+std::to_string(nav[i].storage_id)+",\"label\":\""+jstr(nav[i].label)+"\"}";
if (i+1<nav.size()) j+=",";
}
j+="],\"localDrives\":[";
for (size_t i = 0; i < local_drives_.size(); ++i) {
auto& d = local_drives_[i];
j += "{\"path\":\"" + jstr(d.path) + "\",\"name\":\"" + jstr(d.name) + "\",";
j += "\"removable\":" + std::string(d.removable ? "true" : "false") + "}";
if (i + 1 < local_drives_.size()) j += ",";
}
j += "],\"activeLocalDrive\":\"" + jstr(active_drive) + "\",";
j+="\"localPath\":\""+jstr(local_path_.string())+"\",\"localFiles\":[";
for (size_t i=0;i<local_entries_.size();++i) {
auto& e=local_entries_[i];
j+="{\"name\":\""+jstr(e.name)+"\",\"isDir\":"+std::string(e.is_dir?"true":"false")+",";
j+="\"size\":"+std::to_string(e.size)+",\"sizeStr\":\""+jstr(e.size_str)+"\",";
j+="\"date\":\""+jstr(e.date_str)+"\",\"path\":\""+jstr(e.path.string())+"\"}";
if (i+1<local_entries_.size()) j+=",";
}
j+="],\"remoteRefreshing\":"+std::string(refreshing?"true":"false")+",\"remoteFiles\":[";
for (size_t i=0;i<remote_entries_.size();++i) {
auto& e=remote_entries_[i];
j+="{\"handle\":"+std::to_string(e.handle)+",\"storageId\":"+std::to_string(e.storage_id)+",";
j+="\"name\":\""+jstr(e.name)+"\",\"isDir\":"+std::string(e.is_dir?"true":"false")+",";
j+="\"size\":"+std::to_string(e.size)+",\"sizeStr\":\""+jstr(e.size_str)+"\",\"date\":\""+jstr(e.date_str)+"\"}";
if (i+1<remote_entries_.size()) j+=",";
}
j+="],\"transfers\":[";
if (engine_) {
auto items=engine_->all_transfers();
for (size_t i=0;i<items.size();++i) {
auto& t=items[i];
auto st=t->state.load();
char pr[16]; snprintf(pr,sizeof(pr),"%.4f",t->progress());
j+="{\"id\":\""+jstr(t->id)+"\",\"filename\":\""+jstr(t->filename)+"\",";
j+="\"direction\":"+std::to_string((int)t->direction)+",\"state\":"+std::to_string((int)st)+",";
j+="\"bytesDone\":"+std::to_string(t->bytes_done.load())+",\"totalBytes\":"+std::to_string(t->total_bytes)+",";
j+="\"progress\":"+std::string(pr)+",\"speedStr\":\""+fmt_speed(t->speed_bps())+"\",";
j+="\"speedBps\":"+std::to_string((uint64_t)t->speed_bps())+",";
j+="\"etaStr\":\""+fmt_eta(t->eta_seconds())+"\",\"error\":\""+jstr(t->error_msg)+"\"}";
if (i+1<items.size()) j+=",";
}
}
j+="],\"statusMessage\":\""+jstr(status_msg_)+"\",";
char tm[16]; snprintf(tm,sizeof(tm),"%.2f",(double)status_msg_timer_);
j+="\"statusTimer\":"+std::string(tm)+"}";
return j;
}
void App::process_finder_drops() {
std::vector<std::string> drops;
{
std::lock_guard lock(finder_drops_mtx_);
drops.swap(finder_drops_);
}
for (auto& dropped : drops) {
fs::path p(dropped);
std::error_code ec;
if (fs::is_directory(p, ec)) {
refresh_local(p);
} else if (!ec && connected_.load()) {
auto sz = fs::file_size(p, ec);
if (!ec) start_upload(p, sz);
}
}
}
void App::navigate_local(const std::string& p) {
refresh_local(fs::path(p == "~" ? (std::getenv("HOME") ? std::getenv("HOME") : "/") : p));
}
void App::navigate_local_up() {
if (local_path_.has_parent_path() && local_path_ != local_path_.parent_path())
refresh_local(local_path_.parent_path());
}
void App::navigate_remote(uint32_t h, uint32_t s, const std::string& name) {
remote_nav_stack_.push_back({h, s, name});
refresh_remote();
}
void App::navigate_remote_up() {
if (remote_nav_stack_.size() > 1) {
remote_nav_stack_.pop_back();
refresh_remote();
}
}
void App::navigate_remote_to(size_t idx) {
if (idx < remote_nav_stack_.size()) {
remote_nav_stack_.resize(idx+1);
refresh_remote();
}
}
void App::set_active_storage(uint32_t id) {
std::string label;
{
std::lock_guard lock(device_mtx_);
for (auto& [sid,si]: storages_) if (sid==id) { label=si.description; break; }
active_storage_ = id;
}
remote_nav_stack_ = {{mtp::ROOT_PARENT, id, label.empty()?std::format("{:04X}",id):label}};
refresh_remote();
}
void App::request_refresh_remote() { refresh_remote(); }
void App::do_upload(const std::string& src) {
fs::path p(src);
std::error_code ec;
auto sz = fs::file_size(p, ec);
if (!ec) start_upload(p, sz);
}
void App::do_download(uint32_t h, uint32_t s, const std::string& fn, uint64_t sz) {
start_download(h, s, fn, sz);
}
void App::download_remote_to_path(uint32_t handle, uint32_t storage_id,
const std::string& dest_path, uint64_t size) {
std::lock_guard lock(device_mtx_);
if (!ops_) throw std::runtime_error("Not connected");
std::atomic<bool> cancel{false};
mtp::ProgressFn noop = [](uint64_t, uint64_t) {};
ops_->get_object(handle, dest_path, size, noop, cancel);
}
void App::do_cancel(const std::string& id) { if (engine_) engine_->cancel(id); }
void App::do_cancel_all() { if (engine_) engine_->cancel_all(); }
void App::do_delete(uint32_t h) {
try { std::lock_guard lock(device_mtx_); if (ops_) ops_->delete_object(h); }
catch (...) {}
refresh_remote();
}
void App::do_create_folder(const std::string& name) {
if (remote_nav_stack_.empty()) return;
const auto& top = remote_nav_stack_.back();
try { std::lock_guard lock(device_mtx_); if (ops_) ops_->create_directory(top.storage_id, top.handle, name); }
catch (...) {}
refresh_remote();
}
void App::reconnect() { on_device_disconnected(); }
} // namespace omniMTP

15
src/ui/WebUI.hpp Normal file
View File

@@ -0,0 +1,15 @@
#pragma once
#include "App.hpp"
namespace omniMTP {
class WebUI {
public:
explicit WebUI(App& app);
~WebUI();
void run();
private:
App& app_;
};
} // namespace omniMTP

741
src/ui/WebUI.mm Normal file
View File

@@ -0,0 +1,741 @@
#include "WebUI.hpp"
#import <Cocoa/Cocoa.h>
#import <WebKit/WebKit.h>
#include <string>
#include <cmath>
// ─── Forward declarations ─────────────────────────────────────────────────────
@class OmniBridge;
@class OmniAppDelegate;
// ─── OmniBridge: JS → C++ message handler ─────────────────────────────────────
@interface OmniBridge : NSObject <WKScriptMessageHandler, WKNavigationDelegate, WKUIDelegate>
- (instancetype)initWithApp:(omniMTP::App*)app webView:(WKWebView*)webView;
@end
@implementation OmniBridge {
omniMTP::App* _app;
WKWebView* _webView;
}
- (instancetype)initWithApp:(omniMTP::App*)app webView:(WKWebView*)webView {
if ((self = [super init])) {
_app = app;
_webView = webView;
}
return self;
}
- (void)userContentController:(WKUserContentController*)ucc
didReceiveScriptMessage:(WKScriptMessage*)message {
(void)ucc;
if (![message.name isEqualToString:@"bridge"]) return;
NSDictionary* body = message.body;
if (![body isKindOfClass:[NSDictionary class]]) return;
NSNumber* msgId = body[@"id"];
NSString* action = body[@"action"];
if (!msgId || !action) return;
long long rid = msgId.longLongValue;
// All dispatched on main thread already (WKWebView guarantee), but we
// use dispatch_async to avoid blocking the JS engine during heavy ops.
dispatch_async(dispatch_get_main_queue(), ^{
[self handleAction:action body:body responseId:rid];
});
}
- (void)handleAction:(NSString*)action body:(NSDictionary*)body responseId:(long long)rid {
NSString* result = nil;
if ([action isEqualToString:@"get_state"]) {
std::string json = _app->state_json();
result = [NSString stringWithUTF8String:json.c_str()];
// result is raw JSON object, send directly
[self sendRawResult:result responseId:rid];
return;
} else if ([action isEqualToString:@"navigate_local"]) {
NSString* path = body[@"path"];
if (path) _app->navigate_local(path.UTF8String);
} else if ([action isEqualToString:@"navigate_local_up"]) {
_app->navigate_local_up();
} else if ([action isEqualToString:@"refresh_local"]) {
_app->navigate_local(_app->current_local_path());
} else if ([action isEqualToString:@"navigate_remote"]) {
NSNumber* handle = body[@"handle"];
NSNumber* storageId = body[@"storageId"];
NSString* name = body[@"name"];
if (handle && storageId && name)
_app->navigate_remote(handle.unsignedIntValue,
storageId.unsignedIntValue,
name.UTF8String);
} else if ([action isEqualToString:@"navigate_remote_up"]) {
_app->navigate_remote_up();
} else if ([action isEqualToString:@"navigate_remote_to"]) {
NSNumber* index = body[@"index"];
if (index) _app->navigate_remote_to((size_t)index.unsignedIntegerValue);
} else if ([action isEqualToString:@"refresh_remote"]) {
_app->request_refresh_remote();
} else if ([action isEqualToString:@"set_storage"]) {
NSNumber* storageId = body[@"storageId"];
if (storageId) _app->set_active_storage(storageId.unsignedIntValue);
} else if ([action isEqualToString:@"start_upload"]) {
NSString* srcPath = body[@"srcPath"];
if (srcPath && srcPath.length > 0) _app->do_upload(srcPath.UTF8String);
} else if ([action isEqualToString:@"start_download"]) {
NSNumber* handle = body[@"handle"];
NSNumber* storageId = body[@"storageId"];
NSString* filename = body[@"filename"];
NSNumber* size = body[@"size"];
if (handle && storageId && filename && size)
_app->do_download(handle.unsignedIntValue,
storageId.unsignedIntValue,
filename.UTF8String,
size.unsignedLongLongValue);
} else if ([action isEqualToString:@"cancel_transfer"]) {
NSString* tid = body[@"id"];
if (tid) _app->do_cancel(tid.UTF8String);
} else if ([action isEqualToString:@"cancel_all"]) {
_app->do_cancel_all();
} else if ([action isEqualToString:@"delete_remote"]) {
NSNumber* handle = body[@"handle"];
if (handle) _app->do_delete(handle.unsignedIntValue);
} else if ([action isEqualToString:@"create_folder"]) {
NSString* name = body[@"name"];
if (name && name.length > 0) _app->do_create_folder(name.UTF8String);
} else if ([action isEqualToString:@"reconnect"]) {
_app->reconnect();
} else if ([action isEqualToString:@"open_file_picker"]) {
// Open NSOpenPanel to pick files for upload
dispatch_async(dispatch_get_main_queue(), ^{
NSOpenPanel* panel = [NSOpenPanel openPanel];
panel.canChooseFiles = YES;
panel.canChooseDirectories = NO;
panel.allowsMultipleSelection = YES;
[panel beginWithCompletionHandler:^(NSModalResponse r) {
if (r == NSModalResponseOK) {
for (NSURL* url in panel.URLs) {
NSString* path = url.path;
if (path) self->_app->do_upload(path.UTF8String);
}
}
}];
});
} else if ([action isEqualToString:@"update_drop_zones"]) {
NSDictionary* remote = body[@"remote"];
NSDictionary* local = body[@"local"];
if ([remote isKindOfClass:[NSDictionary class]] && [local isKindOfClass:[NSDictionary class]]) {
_app->update_drop_zones(
[remote[@"x"] doubleValue], [remote[@"y"] doubleValue],
[remote[@"w"] doubleValue], [remote[@"h"] doubleValue],
[local[@"x"] doubleValue], [local[@"y"] doubleValue],
[local[@"w"] doubleValue], [local[@"h"] doubleValue]);
}
}
// Send null result for void actions
[self sendNullResult:rid];
}
- (void)sendRawResult:(NSString*)json responseId:(long long)rid {
NSString* js = [NSString stringWithFormat:@"window.bridgeResponse(%lld,%@)", rid, json];
[_webView evaluateJavaScript:js completionHandler:nil];
}
- (void)sendNullResult:(long long)rid {
NSString* js = [NSString stringWithFormat:@"window.bridgeResponse(%lld,null)", rid];
[_webView evaluateJavaScript:js completionHandler:nil];
}
// WKNavigationDelegate — log errors
- (void)webView:(WKWebView*)wv didFailNavigation:(WKNavigation*)nav withError:(NSError*)err {
(void)wv; (void)nav;
NSLog(@"[OmniMTP] WebView navigation failed: %@", err.localizedDescription);
}
- (void)webView:(WKWebView*)wv didFailProvisionalNavigation:(WKNavigation*)nav withError:(NSError*)err {
(void)wv; (void)nav;
NSLog(@"[OmniMTP] WebView provisional navigation failed: %@", err.localizedDescription);
}
// WKUIDelegate — required for window.alert/confirm/prompt in WKWebView
- (void)webView:(WKWebView*)webView
runJavaScriptAlertPanelWithMessage:(NSString*)message
initiatedByFrame:(WKFrameInfo*)frame
completionHandler:(void (^)(void))completionHandler {
(void)webView; (void)frame;
NSAlert* alert = [[NSAlert alloc] init];
alert.messageText = message;
[alert addButtonWithTitle:@"OK"];
[alert runModal];
completionHandler();
}
- (void)webView:(WKWebView*)webView
runJavaScriptConfirmPanelWithMessage:(NSString*)message
initiatedByFrame:(WKFrameInfo*)frame
completionHandler:(void (^)(BOOL result))completionHandler {
(void)webView; (void)frame;
NSAlert* alert = [[NSAlert alloc] init];
alert.messageText = message;
[alert addButtonWithTitle:@"Delete"];
[alert addButtonWithTitle:@"Cancel"];
NSModalResponse response = [alert runModal];
completionHandler(response == NSAlertFirstButtonReturn);
}
@end
// ─── Titlebar drag strip (WKWebView ignores -webkit-app-region) ───────────────
static const CGFloat kTitlebarDragHeight = 38.0;
static const CGFloat kTrafficLightInset = 72.0;
@interface OmniTitlebarDragView : NSView
@end
@implementation OmniTitlebarDragView {
NSPoint _dragStartScreen;
NSPoint _windowStartOrigin;
}
- (BOOL)mouseDownCanMoveWindow {
return YES;
}
- (void)mouseDown:(NSEvent*)event {
(void)event;
_dragStartScreen = NSEvent.mouseLocation;
_windowStartOrigin = self.window.frame.origin;
while (YES) {
NSEvent* next = [self.window nextEventMatchingMask:
NSEventMaskLeftMouseDragged | NSEventMaskLeftMouseUp
untilDate:[NSDate distantFuture]
inMode:NSEventTrackingRunLoopMode
dequeue:YES];
if (!next || next.type == NSEventTypeLeftMouseUp) break;
NSPoint current = NSEvent.mouseLocation;
[self.window setFrameOrigin:NSMakePoint(
_windowStartOrigin.x + (current.x - _dragStartScreen.x),
_windowStartOrigin.y + (current.y - _dragStartScreen.y))];
}
}
- (BOOL)acceptsFirstMouse:(NSEvent*)event {
(void)event;
return YES;
}
@end
// ─── Finder drag-export for remote MTP files ───────────────────────────────────
@interface OmniExportPromiseDelegate : NSObject <NSFilePromiseProviderDelegate>
@property (nonatomic, assign) omniMTP::App* app;
@property (nonatomic, assign) uint32_t handle;
@property (nonatomic, assign) uint32_t storageId;
@property (nonatomic, assign) uint64_t size;
@property (nonatomic, copy) NSString* filename;
@end
@implementation OmniExportPromiseDelegate
- (void)filePromiseProvider:(NSFilePromiseProvider*)provider
writePromiseToURL:(NSURL*)url
completionHandler:(void (^)(NSError* _Nullable))completionHandler {
(void)provider;
@try {
if (_app) _app->download_remote_to_path(_handle, _storageId, url.path.UTF8String, _size);
completionHandler(nil);
} @catch (NSException* ex) {
completionHandler([NSError errorWithDomain:@"OmniMTP" code:1
userInfo:@{NSLocalizedDescriptionKey: ex.reason ?: @"Download failed"}]);
}
}
- (NSString*)filePromiseProvider:(NSFilePromiseProvider*)provider
fileNameForType:(NSString*)fileType {
(void)provider; (void)fileType;
return _filename;
}
- (void)filePromiseProvider:(NSFilePromiseProvider*)provider
didFinishWritingToURL:(NSURL*)url {
(void)provider; (void)url;
}
@end
// ─── OmniWebView: native drag-and-drop ────────────────────────────────────────
@interface OmniWebView : WKWebView <NSDraggingSource>
- (instancetype)initWithFrame:(NSRect)frame
configuration:(WKWebViewConfiguration*)config
app:(omniMTP::App*)app;
@end
@implementation OmniWebView {
omniMTP::App* _app;
BOOL _remoteDragHighlighted;
BOOL _localDragHighlighted;
NSPoint _mouseDownWebPoint;
NSString* _pendingDragJSON;
BOOL _nativeDragStarted;
NSArray<NSDictionary*>* _exportDragEntries;
}
- (instancetype)initWithFrame:(NSRect)frame
configuration:(WKWebViewConfiguration*)config
app:(omniMTP::App*)app {
if ((self = [super initWithFrame:frame configuration:config])) {
_app = app;
_remoteDragHighlighted = NO;
_localDragHighlighted = NO;
[self registerForDraggedTypes:@[NSPasteboardTypeFileURL]];
}
return self;
}
- (NSPoint)webPointFromEvent:(NSEvent*)event {
NSPoint loc = [self convertPoint:event.locationInWindow fromView:nil];
return NSMakePoint(loc.x, self.bounds.size.height - loc.y);
}
- (NSPoint)webPointFromDrag:(id<NSDraggingInfo>)sender {
NSPoint loc = [self convertPoint:sender.draggingLocation fromView:nil];
return NSMakePoint(loc.x, self.bounds.size.height - loc.y);
}
- (NSString*)dropTargetAtWebPoint:(NSPoint)webPoint {
if (!_app) return @"none";
std::string target = _app->drop_target_at(webPoint.x, webPoint.y);
return [NSString stringWithUTF8String:target.c_str()];
}
- (NSArray<NSURL*>*)fileURLsFromDrag:(id<NSDraggingInfo>)sender {
NSPasteboard* pb = sender.draggingPasteboard;
NSDictionary* opts = @{NSPasteboardURLReadingFileURLsOnlyKey: @YES};
if ([pb canReadObjectForClasses:@[[NSURL class]] options:opts]) {
NSArray* urls = [pb readObjectsForClasses:@[[NSURL class]] options:opts];
return urls ?: @[];
}
return @[];
}
- (void)setRemoteDragOver:(BOOL)over {
if (_remoteDragHighlighted == over) return;
_remoteDragHighlighted = over;
NSString* js = over
? @"document.getElementById('remote-files')?.classList.add('drag-over')"
: @"document.getElementById('remote-files')?.classList.remove('drag-over')";
[self evaluateJavaScript:js completionHandler:nil];
}
- (void)setLocalDragOver:(BOOL)over {
if (_localDragHighlighted == over) return;
_localDragHighlighted = over;
NSString* js = over
? @"document.getElementById('local-files')?.classList.add('drag-over')"
: @"document.getElementById('local-files')?.classList.remove('drag-over')";
[self evaluateJavaScript:js completionHandler:nil];
}
- (void)mouseDown:(NSEvent*)event {
_mouseDownWebPoint = [self webPointFromEvent:event];
_pendingDragJSON = nil;
_nativeDragStarted = NO;
[self evaluateJavaScript:@"window.omniClearSelection&&window.omniClearSelection()"
completionHandler:nil];
NSString* js = [NSString stringWithFormat:
@"window.omniRemoteDragFilesAt(%f,%f)", _mouseDownWebPoint.x, _mouseDownWebPoint.y];
[self evaluateJavaScript:js completionHandler:^(id value, NSError* err) {
(void)err;
if ([value isKindOfClass:[NSString class]] && [(NSString*)value length] > 0)
self->_pendingDragJSON = value;
}];
[super mouseDown:event];
}
- (void)mouseDragged:(NSEvent*)event {
if (!_nativeDragStarted && _pendingDragJSON.length > 0 && _app && _app->connected()) {
NSPoint cur = [self webPointFromEvent:event];
CGFloat dx = cur.x - _mouseDownWebPoint.x;
CGFloat dy = cur.y - _mouseDownWebPoint.y;
if (std::sqrt(dx * dx + dy * dy) >= 4.0) {
_nativeDragStarted = YES;
[self evaluateJavaScript:@"window.omniClearSelection&&window.omniClearSelection()"
completionHandler:nil];
if ([self beginRemoteExportDrag:event json:_pendingDragJSON]) return;
}
}
if (!_nativeDragStarted) [super mouseDragged:event];
}
- (BOOL)beginRemoteExportDrag:(NSEvent*)event json:(NSString*)json {
NSData* data = [json dataUsingEncoding:NSUTF8StringEncoding];
id parsed = [NSJSONSerialization JSONObjectWithData:data options:0 error:nil];
if (![parsed isKindOfClass:[NSArray class]] || [(NSArray*)parsed count] == 0) return NO;
_exportDragEntries = [(NSArray*)parsed copy];
NSMutableArray<NSDraggingItem*>* dragItems = [NSMutableArray array];
CGFloat viewY = self.bounds.size.height - _mouseDownWebPoint.y;
NSRect frame = NSMakeRect(_mouseDownWebPoint.x - 1, viewY - 1, 2, 2);
for (NSDictionary* entry in (NSArray*)parsed) {
NSNumber* handle = entry[@"handle"];
NSNumber* storageId = entry[@"storageId"];
NSString* name = entry[@"name"];
NSNumber* size = entry[@"size"];
if (!handle || !storageId || !name || !size) continue;
OmniExportPromiseDelegate* delegate = [OmniExportPromiseDelegate new];
delegate.app = _app;
delegate.handle = handle.unsignedIntValue;
delegate.storageId = storageId.unsignedIntValue;
delegate.size = size.unsignedLongLongValue;
delegate.filename = name;
NSFilePromiseProvider* provider =
[[NSFilePromiseProvider alloc] initWithFileType:@"public.data" delegate:delegate];
NSDraggingItem* dragItem = [[NSDraggingItem alloc] initWithPasteboardWriter:provider];
[dragItem setDraggingFrame:frame contents:nil];
[dragItems addObject:dragItem];
}
if (dragItems.count == 0) {
_exportDragEntries = nil;
return NO;
}
[self evaluateJavaScript:@"window.omniClearSelection&&window.omniClearSelection()"
completionHandler:nil];
[self beginDraggingSessionWithItems:dragItems event:event source:self];
return YES;
}
- (NSDragOperation)draggingSession:(NSDraggingSession*)session
sourceOperationMaskForDraggingContext:(NSDraggingContext)context {
(void)session; (void)context;
return NSDragOperationCopy;
}
- (void)draggingSession:(NSDraggingSession*)session
endedAtPoint:(NSPoint)screenPoint
operation:(NSDragOperation)operation {
(void)session; (void)screenPoint; (void)operation;
_exportDragEntries = nil;
}
- (NSDragOperation)draggingUpdated:(id<NSDraggingInfo>)sender {
NSArray<NSURL*>* urls = [self fileURLsFromDrag:sender];
const BOOL exportDrag = _exportDragEntries.count > 0;
if (!exportDrag && urls.count == 0) {
[self setRemoteDragOver:NO];
[self setLocalDragOver:NO];
return NSDragOperationNone;
}
NSString* target = [self dropTargetAtWebPoint:[self webPointFromDrag:sender]];
if (exportDrag && [target isEqualToString:@"local"]) {
[self setLocalDragOver:YES];
[self setRemoteDragOver:NO];
return NSDragOperationCopy;
}
if (urls.count > 0 && [target isEqualToString:@"remote"] && _app && _app->connected()) {
[self setRemoteDragOver:YES];
[self setLocalDragOver:NO];
return NSDragOperationCopy;
}
if (urls.count > 0 && [target isEqualToString:@"local"]) {
[self setRemoteDragOver:NO];
[self setLocalDragOver:NO];
return NSDragOperationLink;
}
[self setRemoteDragOver:NO];
[self setLocalDragOver:NO];
return exportDrag ? NSDragOperationCopy : NSDragOperationNone;
}
- (NSDragOperation)draggingEntered:(id<NSDraggingInfo>)sender {
return [self draggingUpdated:sender];
}
- (void)draggingExited:(id<NSDraggingInfo>)sender {
(void)sender;
[self setRemoteDragOver:NO];
[self setLocalDragOver:NO];
}
- (BOOL)performDragOperation:(id<NSDraggingInfo>)sender {
[self setRemoteDragOver:NO];
[self setLocalDragOver:NO];
if (!_app) return NO;
NSString* target = [self dropTargetAtWebPoint:[self webPointFromDrag:sender]];
NSArray<NSURL*>* urls = [self fileURLsFromDrag:sender];
if ([target isEqualToString:@"local"] && _exportDragEntries.count > 0) {
for (NSDictionary* entry in _exportDragEntries) {
NSNumber* handle = entry[@"handle"];
NSNumber* storageId = entry[@"storageId"];
NSString* name = entry[@"name"];
NSNumber* size = entry[@"size"];
if (handle && storageId && name && size)
_app->do_download(handle.unsignedIntValue, storageId.unsignedIntValue,
name.UTF8String, size.unsignedLongLongValue);
}
_exportDragEntries = nil;
return YES;
}
NSFileManager* fm = NSFileManager.defaultManager;
BOOL handled = NO;
if ([target isEqualToString:@"remote"]) {
for (NSURL* url in urls) {
if (!url.isFileURL) continue;
NSString* path = url.path;
if (!path) continue;
BOOL isDir = NO;
if ([fm fileExistsAtPath:path isDirectory:&isDir] && !isDir)
_app->do_upload(path.UTF8String);
handled = YES;
}
return handled;
}
if ([target isEqualToString:@"local"]) {
for (NSURL* url in urls) {
if (!url.isFileURL) continue;
NSString* path = url.path;
if (!path) continue;
BOOL isDir = NO;
if ([fm fileExistsAtPath:path isDirectory:&isDir] && isDir)
_app->navigate_local(path.UTF8String);
handled = YES;
}
return handled;
}
return NO;
}
@end
// ─── OmniAppDelegate ──────────────────────────────────────────────────────────
@interface OmniAppDelegate : NSObject <NSApplicationDelegate>
- (instancetype)initWithApp:(omniMTP::App*)app;
@end
@implementation OmniAppDelegate {
omniMTP::App* _app;
NSWindow* _window;
WKWebView* _webView;
OmniBridge* _bridge;
}
- (instancetype)initWithApp:(omniMTP::App*)app {
if ((self = [super init])) {
_app = app;
}
return self;
}
- (void)applicationDidFinishLaunching:(NSNotification*)note {
(void)note;
// ── WKWebView configuration ───────────────────────────────────────────────
WKWebViewConfiguration* config = [[WKWebViewConfiguration alloc] init];
// Enable developer tools in debug builds
#if DEBUG
if (@available(macOS 13.3, *)) {
config.preferences.elementFullscreenEnabled = YES;
[config.preferences setValue:@YES forKey:@"developerExtrasEnabled"];
}
#endif
// Disable text selection in file lists; file drag only.
WKUserScript* noTextDrag = [[WKUserScript alloc]
initWithSource:@"(function(){"
"function blockText(e){if(e.target.closest('.file-list'))e.preventDefault();}"
"document.addEventListener('selectstart',blockText,true);"
"document.addEventListener('mousedown',function(e){"
"if(e.button===0&&e.target.closest('.file-list .frow'))e.preventDefault();"
"},true);"
"document.addEventListener('dragstart',function(e){"
"var row=e.target.closest('.file-list .frow');"
"if(!row)return;"
"if(row.closest('#remote-files')||row.getAttribute('draggable')!=='true')"
"e.preventDefault();"
"},true);"
"})();"
injectionTime:WKUserScriptInjectionTimeAtDocumentStart
forMainFrameOnly:YES];
[config.userContentController addUserScript:noTextDrag];
// Register the "bridge" message handler
_webView = [[OmniWebView alloc] initWithFrame:NSMakeRect(0, 0, 1280, 800)
configuration:config
app:_app];
_bridge = [[OmniBridge alloc] initWithApp:_app webView:_webView];
[config.userContentController addScriptMessageHandler:_bridge name:@"bridge"];
_webView.navigationDelegate = _bridge;
_webView.UIDelegate = _bridge;
// Allow file:// access for local resource loading
_webView.autoresizingMask = NSViewWidthSizable | NSViewHeightSizable;
// ── NSWindow ──────────────────────────────────────────────────────────────
NSRect frame = NSMakeRect(0, 0, 1280, 800);
NSWindowStyleMask style =
NSWindowStyleMaskTitled |
NSWindowStyleMaskClosable |
NSWindowStyleMaskMiniaturizable |
NSWindowStyleMaskResizable |
NSWindowStyleMaskFullSizeContentView;
_window = [[NSWindow alloc] initWithContentRect:frame
styleMask:style
backing:NSBackingStoreBuffered
defer:NO];
_window.title = @"OmniMTP";
_window.titleVisibility = NSWindowTitleHidden;
_window.titlebarAppearsTransparent = YES;
_window.minSize = NSMakeSize(900, 600);
_window.movableByWindowBackground = YES;
// Force dark appearance
if (@available(macOS 10.14, *)) {
_window.appearance = [NSAppearance appearanceNamed:NSAppearanceNameDarkAqua];
}
// Container keeps a native drag strip above the WKWebView (web content
// would otherwise swallow titlebar mouse events).
NSView* container = [[NSView alloc] initWithFrame:frame];
_webView.frame = NSMakeRect(0, 0, frame.size.width, frame.size.height);
[container addSubview:_webView];
OmniTitlebarDragView* dragBar = [[OmniTitlebarDragView alloc]
initWithFrame:NSMakeRect(kTrafficLightInset, frame.size.height - kTitlebarDragHeight,
frame.size.width - kTrafficLightInset, kTitlebarDragHeight)];
dragBar.autoresizingMask = NSViewWidthSizable | NSViewMinYMargin;
[container addSubview:dragBar];
[_window setContentView:container];
[_window center];
[_window makeKeyAndOrderFront:nil];
// ── Load index.html from bundle (dev fallback: source tree) ───────────────
NSURL* indexURL = nil;
// 1. Try the bundle's webroot resource directory
NSBundle* bundle = [NSBundle mainBundle];
indexURL = [bundle URLForResource:@"index" withExtension:@"html" subdirectory:@"webroot"];
// 2. Dev fallback: look next to the binary in a webroot/ sibling
if (!indexURL) {
NSString* execDir = bundle.executableURL.URLByDeletingLastPathComponent.path;
NSString* devPath = [execDir stringByAppendingPathComponent:@"webroot/index.html"];
if ([[NSFileManager defaultManager] fileExistsAtPath:devPath])
indexURL = [NSURL fileURLWithPath:devPath];
}
// 3. Source tree fallback for direct cmake build without bundle
if (!indexURL) {
// Walk up from executable looking for src/ui/webroot
NSString* base = bundle.executableURL.URLByDeletingLastPathComponent.path;
NSArray<NSString*>* candidates = @[
[base stringByAppendingPathComponent:@"../src/ui/webroot/index.html"],
[base stringByAppendingPathComponent:@"../../src/ui/webroot/index.html"],
[base stringByAppendingPathComponent:@"../../../src/ui/webroot/index.html"],
];
for (NSString* c in candidates) {
NSString* resolved = c.stringByResolvingSymlinksInPath;
if ([[NSFileManager defaultManager] fileExistsAtPath:resolved]) {
indexURL = [NSURL fileURLWithPath:resolved];
break;
}
}
}
if (indexURL) {
NSURL* baseURL = indexURL.URLByDeletingLastPathComponent;
NSString* html = [NSString stringWithContentsOfURL:indexURL
encoding:NSUTF8StringEncoding
error:nil];
if (html) {
[_webView loadHTMLString:html baseURL:baseURL];
} else {
[_webView loadFileURL:indexURL allowingReadAccessToURL:baseURL];
}
} else {
// Inline fallback so the app at least opens
NSString* fallback = @"<!DOCTYPE html><html><body style='background:#1a1a1a;color:#fff;font-family:system-ui;display:flex;align-items:center;justify-content:center;height:100vh'><h2>webroot/index.html not found</h2></body></html>";
[_webView loadHTMLString:fallback baseURL:nil];
NSLog(@"[OmniMTP] ERROR: webroot/index.html not found in bundle or source tree");
}
}
- (BOOL)applicationShouldTerminateAfterLastWindowClosed:(NSApplication*)app {
(void)app;
return YES;
}
- (void)applicationWillTerminate:(NSNotification*)note {
(void)note;
}
@end
// ─── WebUI ────────────────────────────────────────────────────────────────────
namespace omniMTP {
WebUI::WebUI(App& a) : app_(a) {}
WebUI::~WebUI() = default;
void WebUI::run() {
@autoreleasepool {
[NSApplication sharedApplication];
[NSApp setActivationPolicy:NSApplicationActivationPolicyRegular];
OmniAppDelegate* delegate = [[OmniAppDelegate alloc] initWithApp:&app_];
[NSApp setDelegate:delegate];
// App menu (Cmd+Q to quit)
NSMenu* menuBar = [[NSMenu alloc] init];
NSMenuItem* appItem = [[NSMenuItem alloc] init];
[menuBar addItem:appItem];
NSMenu* appMenu = [[NSMenu alloc] init];
NSMenuItem* quitItem = [[NSMenuItem alloc]
initWithTitle:@"Quit OmniMTP"
action:@selector(terminate:)
keyEquivalent:@"q"];
[appMenu addItem:quitItem];
appItem.submenu = appMenu;
[NSApp setMainMenu:menuBar];
[NSApp activateIgnoringOtherApps:YES];
[NSApp run];
}
}
} // namespace omniMTP

573
src/ui/webroot/app.css Normal file
View File

@@ -0,0 +1,573 @@
/* ── Reset & base ─────────────────────────────────────────────────────────── */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg-primary: #1a1a1a;
--bg-secondary: #1e1e1e;
--bg-tertiary: #252525;
--bg-hover: rgba(255,255,255,0.055);
--bg-active: rgba(255,255,255,0.10);
--bg-selected: rgba(10,132,255,0.18);
--bg-sel-border: rgba(10,132,255,0.45);
--accent: #0a84ff;
--accent-dim: rgba(10,132,255,0.55);
--green: #30d158;
--yellow: #ffd60a;
--red: #ff453a;
--red-dim: rgba(255,69,58,0.80);
--label: rgba(255,255,255,0.92);
--label-2: rgba(235,235,245,0.55);
--label-3: rgba(235,235,245,0.28);
--label-disabled:rgba(235,235,245,0.18);
--sep: rgba(235,235,245,0.09);
--sep-strong: rgba(235,235,245,0.13);
--border: rgba(235,235,245,0.10);
--row-h: 26px;
--toolbar-h: 40px;
--titlebar-h: 38px;
--statusbar-h: 22px;
--divider-w: 1px;
--font: -apple-system, BlinkMacSystemFont, "SF Pro Text", system-ui, sans-serif;
--font-mono: "SF Mono", ui-monospace, monospace;
--radius: 6px;
--radius-sm: 4px;
}
html, body {
height: 100%;
overflow: hidden;
background: var(--bg-primary);
color: var(--label);
font-family: var(--font);
font-size: 13px;
line-height: 1.4;
-webkit-font-smoothing: antialiased;
user-select: none;
}
/* ── Layout skeleton ─────────────────────────────────────────────────────────
Titlebar → Device-bar → Main(flex:1) → Transfers(auto) → Statusbar
*/
body {
display: flex;
flex-direction: column;
}
/* ── Titlebar ─────────────────────────────────────────────────────────────── */
#titlebar {
height: var(--titlebar-h);
display: flex;
align-items: center;
padding: 0 16px;
background: var(--bg-secondary);
border-bottom: 1px solid var(--sep);
-webkit-app-region: drag;
flex-shrink: 0;
}
.tl-spacer {
width: 72px; /* space for traffic lights */
flex-shrink: 0;
-webkit-app-region: drag;
user-select: none;
-webkit-user-select: none;
}
#app-title {
flex: 1;
text-align: center;
font-size: 13px;
font-weight: 600;
letter-spacing: 0.01em;
color: var(--label-2);
-webkit-app-region: drag;
user-select: none;
-webkit-user-select: none;
cursor: default;
}
#conn-badge {
-webkit-app-region: no-drag;
font-size: 11px;
font-weight: 500;
padding: 2px 10px;
border-radius: 20px;
white-space: nowrap;
user-select: none;
-webkit-user-select: none;
cursor: default;
}
.badge.connected { background: rgba(48,209,88,0.18); color: var(--green); }
.badge.disconnected { background: rgba(235,235,245,0.08); color: var(--label-3); }
/* ── Device bar ───────────────────────────────────────────────────────────── */
#device-bar {
height: var(--toolbar-h);
display: flex;
align-items: center;
padding: 0 12px;
background: var(--bg-secondary);
border-bottom: 1px solid var(--sep);
flex-shrink: 0;
gap: 8px;
}
.device-left, .device-right { display: flex; align-items: center; gap: 6px; flex-shrink: 0; }
.device-center {
flex: 1;
text-align: center;
font-size: 12px;
color: var(--label-2);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* ── Main two-panel area ──────────────────────────────────────────────────── */
#main {
flex: 1;
display: flex;
overflow: hidden;
min-height: 0;
}
/* ── Panel ────────────────────────────────────────────────────────────────── */
.panel {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
overflow: hidden;
background: var(--bg-primary);
}
/* ── Panel divider ───────────────────────────────────────────────────────── */
#panel-divider {
width: var(--divider-w);
background: var(--sep-strong);
flex-shrink: 0;
}
/* ── Panel toolbar ────────────────────────────────────────────────────────── */
.panel-toolbar {
height: var(--toolbar-h);
display: flex;
align-items: center;
padding: 0 8px;
gap: 6px;
background: var(--bg-tertiary);
border-bottom: 1px solid var(--sep);
flex-shrink: 0;
}
/* ── Nav button group (pill shape) ─────────────────────────────────────────── */
.nav-group {
display: flex;
align-items: center;
background: rgba(255,255,255,0.05);
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
flex-shrink: 0;
}
.nav-group .nav-btn {
border-radius: 0;
border: none;
border-right: 1px solid var(--border);
}
.nav-group .nav-btn:last-child { border-right: none; }
.nav-btn {
-webkit-app-region: no-drag;
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 26px;
background: transparent;
border: none;
border-radius: var(--radius-sm);
color: var(--label-2);
cursor: pointer;
transition: background 80ms, color 80ms;
flex-shrink: 0;
}
.nav-btn svg { width: 14px; height: 14px; }
.nav-btn:hover:not(:disabled) { background: var(--bg-hover); color: var(--label); }
.nav-btn:active:not(:disabled){ background: var(--bg-active); }
.nav-btn:disabled { color: var(--label-disabled); cursor: default; }
.nav-btn.icon-only { width: 28px; }
/* ── Breadcrumb ──────────────────────────────────────────────────────────── */
.breadcrumb {
flex: 1;
display: flex;
align-items: center;
overflow: hidden;
gap: 0;
min-width: 0;
font-size: 12px;
}
.crumb-link {
color: var(--accent);
cursor: pointer;
white-space: nowrap;
padding: 2px 3px;
border-radius: 3px;
transition: background 80ms;
overflow: hidden;
text-overflow: ellipsis;
max-width: 140px;
}
.crumb-link:hover { background: var(--bg-hover); }
.crumb-cur {
color: var(--label);
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 160px;
}
.crumb-sep { color: var(--label-3); padding: 0 2px; flex-shrink: 0; }
.crumb-ellipsis { color: var(--label-3); padding: 0 4px; flex-shrink: 0; }
/* ── Storage select ──────────────────────────────────────────────────────── */
.storage-select {
-webkit-app-region: no-drag;
background: rgba(255,255,255,0.05);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--label);
font-family: var(--font);
font-size: 12px;
padding: 3px 6px;
cursor: pointer;
max-width: 160px;
flex-shrink: 0;
}
.storage-select:focus { outline: none; border-color: var(--accent-dim); }
/* ── File list container ──────────────────────────────────────────────────── */
.file-list {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
min-height: 0;
position: relative;
user-select: none;
-webkit-user-select: none;
-webkit-user-drag: none;
}
.file-list * {
user-select: none;
-webkit-user-select: none;
}
.file-list .frow[draggable="true"] {
-webkit-user-drag: element;
}
#remote-files .frow {
-webkit-user-drag: none;
}
/* Custom scrollbar */
.file-list::-webkit-scrollbar { width: 5px; }
.file-list::-webkit-scrollbar-track { background: transparent; }
.file-list::-webkit-scrollbar-thumb {
background: rgba(235,235,245,0.15);
border-radius: 3px;
}
.file-list::-webkit-scrollbar-thumb:hover { background: rgba(235,235,245,0.25); }
.file-list.drag-over { background: rgba(10,132,255,0.06); outline: 2px solid var(--accent-dim); outline-offset: -2px; }
/* ── File table ──────────────────────────────────────────────────────────── */
.ftable {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}
.ftable thead tr {
position: sticky;
top: 0;
z-index: 1;
background: var(--bg-tertiary);
}
.ftable th {
font-size: 11px;
font-weight: 500;
color: var(--label-2);
text-align: left;
padding: 4px 8px;
border-bottom: 1px solid var(--sep);
white-space: nowrap;
overflow: hidden;
}
.ftable th:first-child { padding-left: 12px; }
.col-size { width: 72px; text-align: right; }
.col-date { width: 110px; }
.frow {
height: var(--row-h);
cursor: default;
will-change: background-color;
transition: background-color 120ms ease;
user-select: none;
-webkit-user-select: none;
}
.frow:hover { background-color: var(--bg-hover); }
.frow.selected {
background: var(--bg-selected);
box-shadow: inset 3px 0 0 var(--bg-sel-border);
}
.ftable td {
font-size: 13px;
padding: 0 8px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
vertical-align: middle;
}
.ftable td:first-child { padding-left: 12px; }
/* Name cell: dot + name inline */
.fname-cell {
display: flex;
align-items: center;
gap: 7px;
overflow: hidden;
}
.dot {
width: 7px;
height: 7px;
border-radius: 50%;
flex-shrink: 0;
opacity: 0.85;
}
.color-dir { color: var(--accent); } .dot.color-dir { background: var(--accent); }
.color-game { color: var(--yellow); } .dot.color-game { background: var(--yellow); }
.color-hb { color: var(--green); } .dot.color-hb { background: var(--green); }
.color-file { color: rgba(255,255,255,0.75); } .dot.color-file { background: rgba(255,255,255,0.45); }
/* Size / date columns */
.col-size { color: var(--label-2); font-size: 12px; text-align: right; }
.col-date { color: var(--label-3); font-size: 11px; font-variant-numeric: tabular-nums; }
/* ── Empty states ─────────────────────────────────────────────────────────── */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
min-height: 180px;
gap: 10px;
padding: 32px 16px;
color: var(--label-3);
}
.empty-icon {
width: 48px;
height: 48px;
color: var(--label-disabled);
stroke: currentColor;
}
.empty-title {
font-size: 14px;
font-weight: 500;
color: var(--label-2);
}
.empty-sub {
font-size: 12px;
color: var(--label-3);
text-align: center;
max-width: 240px;
}
.loading {
padding: 20px 16px;
color: var(--label-3);
font-size: 13px;
}
/* ── Transfers panel ─────────────────────────────────────────────────────── */
#transfers-panel {
flex-shrink: 0;
max-height: 160px;
overflow: hidden;
border-top: 1px solid var(--sep-strong);
background: var(--bg-secondary);
display: flex;
flex-direction: column;
}
#transfers-header {
display: flex;
align-items: center;
padding: 5px 12px;
gap: 8px;
border-bottom: 1px solid var(--sep);
flex-shrink: 0;
}
#transfers-label {
font-size: 11px;
font-weight: 600;
color: var(--label-2);
letter-spacing: 0.04em;
text-transform: uppercase;
flex: 1;
}
#transfers-list {
overflow-y: auto;
flex: 1;
padding: 4px 8px;
}
#transfers-list::-webkit-scrollbar { width: 4px; }
#transfers-list::-webkit-scrollbar-thumb {
background: rgba(235,235,245,0.12);
border-radius: 2px;
}
/* Transfer item */
.txfer-item {
padding: 4px 4px;
margin-bottom: 3px;
border-radius: var(--radius-sm);
background: rgba(255,255,255,0.03);
}
.txfer-row {
display: flex;
align-items: center;
gap: 6px;
height: 20px;
}
.txfer-dir {
font-size: 10px;
font-weight: 700;
padding: 1px 5px;
border-radius: 3px;
flex-shrink: 0;
letter-spacing: 0.03em;
}
.dir-dl { background: rgba(10,132,255,0.18); color: var(--accent); }
.dir-ul { background: rgba(48,209,88,0.18); color: var(--green); }
.txfer-name {
flex: 1;
font-size: 12px;
color: var(--label);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.txfer-stat {
font-size: 11px;
color: var(--label-2);
white-space: nowrap;
flex-shrink: 0;
}
.txfer-cancel {
-webkit-app-region: no-drag;
background: transparent;
border: none;
color: var(--label-3);
cursor: pointer;
font-size: 14px;
padding: 0 4px;
border-radius: 3px;
transition: color 80ms, background 80ms;
flex-shrink: 0;
}
.txfer-cancel:hover { color: var(--red); background: rgba(255,69,58,0.12); }
.pbar {
height: 3px;
background: rgba(235,235,245,0.07);
border-radius: 2px;
margin: 3px 0 2px;
overflow: hidden;
}
.pbar-fill {
height: 100%;
background: var(--accent);
border-radius: 2px;
transition: width 200ms linear;
}
.pbar-fill.done { background: var(--green); }
.txfer-meta {
font-size: 10px;
color: var(--label-3);
font-variant-numeric: tabular-nums;
}
/* ── Status bar ──────────────────────────────────────────────────────────── */
#statusbar {
height: var(--statusbar-h);
display: flex;
align-items: center;
padding: 0 12px;
background: var(--bg-tertiary);
border-top: 1px solid var(--sep);
flex-shrink: 0;
font-size: 11px;
color: var(--label-3);
gap: 8px;
}
#sb-left { flex: 0 0 auto; }
#sb-center{ flex: 1; text-align: center; color: var(--label-2); }
#sb-right { flex: 0 0 auto; }
/* ── Context menu ────────────────────────────────────────────────────────── */
.ctx-menu {
position: fixed;
z-index: 9999;
background: #2a2a2e;
border: 1px solid rgba(255,255,255,0.12);
border-radius: 8px;
padding: 4px 0;
min-width: 160px;
box-shadow: 0 8px 32px rgba(0,0,0,0.55);
}
.ctx-menu.hidden { display: none; }
.ctx-item {
padding: 6px 14px;
font-size: 13px;
cursor: pointer;
color: var(--label);
transition: background 60ms;
}
.ctx-item:hover { background: var(--bg-hover); }
.ctx-item.danger { color: var(--red); }
.ctx-item.ctx-sep {
height: 1px;
background: var(--sep);
padding: 0;
margin: 3px 0;
cursor: default;
}
.ctx-item.ctx-sep:hover { background: var(--sep); }
/* ── Marquee selection ───────────────────────────────────────────────────── */
.marquee-box {
position: fixed;
z-index: 9998;
border: 1px solid var(--accent);
background: rgba(10,132,255,0.12);
pointer-events: none;
}
/* ── Buttons ─────────────────────────────────────────────────────────────── */
.action-btn {
-webkit-app-region: no-drag;
background: rgba(255,255,255,0.07);
border: 1px solid rgba(255,255,255,0.10);
border-radius: var(--radius-sm);
color: var(--label);
font-family: var(--font);
font-size: 12px;
padding: 4px 12px;
cursor: pointer;
transition: background 80ms, border-color 80ms;
white-space: nowrap;
}
.action-btn:hover { background: rgba(255,255,255,0.11); border-color: rgba(255,255,255,0.16); }
.action-btn:active{ background: rgba(255,255,255,0.15); }
.action-btn.primary { background: rgba(10,132,255,0.22); border-color: rgba(10,132,255,0.35); color: var(--accent); }
.action-btn.primary:hover { background: rgba(10,132,255,0.30); border-color: rgba(10,132,255,0.50); }
.action-btn.danger { background: rgba(255,69,58,0.15); border-color: rgba(255,69,58,0.25); color: var(--red); }
.action-btn.danger:hover { background: rgba(255,69,58,0.25); }
.action-btn.small { font-size: 11px; padding: 2px 8px; }

682
src/ui/webroot/app.js Normal file
View File

@@ -0,0 +1,682 @@
'use strict';
// ── Bridge ────────────────────────────────────────────────────────────────────
let _reqId = 0;
const _pending = {};
function call(action, params = {}) {
return new Promise(resolve => {
const id = ++_reqId;
_pending[id] = resolve;
try {
window.webkit.messageHandlers.bridge.postMessage({id, action, ...params});
} catch(e) {
// Dev fallback
setTimeout(() => { if (_pending[id]) { _pending[id](null); delete _pending[id]; } }, 100);
}
});
}
window.bridgeResponse = function(id, data) {
if (_pending[id]) { _pending[id](data); delete _pending[id]; }
};
// ── State ─────────────────────────────────────────────────────────────────────
const S = {
connected: false, deviceName: '',
storages: [], activeStorageId: 0,
remoteNavStack: [],
localPath: '', localFiles: [],
localDrives: [], activeLocalDrive: '',
remoteFiles: [], remoteRefreshing: false,
transfers: [],
statusMessage: '', statusTimer: 0,
selLocal: new Set(), // keys = file.path strings
selRemote: new Set(), // keys = String(file.handle)
lastLocalIdx: -1, // for shift+click range
lastRemoteIdx: -1,
};
// ── Smart-render fingerprints ──────────────────────────────────────────────────
// The file lists are only fully rebuilt when the files themselves change.
// Selection and toolbar updates run every poll without touching the table DOM,
// which eliminates the 400 ms hover-flash from innerHTML replacement.
let _localFingerprint = '';
let _remoteFingerprint = '';
function fingerprintFiles(files) {
// Fast hash: join paths/handles — changes on any navigation or refresh
return files.map(f => f.path || f.handle || f.name).join('\0');
}
// ── Polling ───────────────────────────────────────────────────────────────────
async function poll() {
try {
const data = await call('get_state');
if (data) {
const sl = S.selLocal, sr = S.selRemote;
const lli = S.lastLocalIdx, lri = S.lastRemoteIdx;
Object.assign(S, data);
S.selLocal = sl; S.selRemote = sr;
S.lastLocalIdx = lli; S.lastRemoteIdx = lri;
render();
}
} catch(e) {}
setTimeout(poll, 400);
}
// ── Rendering ─────────────────────────────────────────────────────────────────
function render() {
renderDeviceBar();
renderLocalToolbar();
renderLocalFiles();
renderRemoteToolbar();
renderRemoteFiles();
renderTransfers();
renderStatusBar();
updateDropZones();
}
function updateDropZones() {
const rem = document.getElementById('panel-remote')?.getBoundingClientRect();
const loc = document.getElementById('panel-local')?.getBoundingClientRect();
if (!rem || !loc) return;
call('update_drop_zones', {
remote: {x: rem.left, y: rem.top, w: rem.width, h: rem.height},
local: {x: loc.left, y: loc.top, w: loc.width, h: loc.height},
});
}
function esc(s) {
if (s == null) return '';
const d = document.createElement('div');
d.textContent = String(s);
return d.innerHTML;
}
function fileColor(f) {
if (f.isDir) return 'color-dir';
const ext = (f.name.split('.').pop() || '').toLowerCase();
if (['nsp','xci','nsz','xcz'].includes(ext)) return 'color-game';
if (ext === 'nro') return 'color-hb';
return 'color-file';
}
// ── File table builder ────────────────────────────────────────────────────────
const FILE_TABLE_HEADER = `<thead><tr>
<th>Name</th><th class="col-size">Size</th><th class="col-date">Modified</th>
</tr></thead>`;
function buildFileRows(files, selSet, idKey, allowDrag = false) {
return files.map((f, i) => {
const key = String(f[idKey] != null ? f[idKey] : (f.path || f.name));
const sel = selSet.has(key) ? ' selected' : '';
const cc = fileColor(f);
const drag = allowDrag && !f.isDir ? ' draggable="true"' : '';
return `<tr class="frow${sel}" data-idx="${i}" data-key="${esc(key)}" data-is-dir="${f.isDir ? '1' : '0'}"${drag}>
<td class="col-name"><div class="fname-cell">
<span class="dot ${cc}"></span>
<span class="${cc}">${esc(f.name)}</span>
</div></td>
<td class="col-size">${f.isDir ? '' : esc(f.sizeStr)}</td>
<td class="col-date">${esc(f.date)}</td>
</tr>`;
}).join('');
}
// ── Selection helpers ─────────────────────────────────────────────────────────
function applySelectionToDOM(container, selSet) {
container.querySelectorAll('.frow').forEach(r => {
r.classList.toggle('selected', selSet.has(r.dataset.key));
});
}
function selectionKeys(files, idKey) {
return files.map(f => String(f[idKey] != null ? f[idKey] : (f.path || f.name)));
}
// ── Local panel ───────────────────────────────────────────────────────────────
function renderLocalFiles() {
const el = document.getElementById('local-files');
const fp = fingerprintFiles(S.localFiles);
if (fp !== _localFingerprint) {
_localFingerprint = fp;
// Full rebuild only when file list actually changed
if (!S.localFiles.length) {
el.innerHTML = `<div class="empty-state">
<svg viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-width="1.5" class="empty-icon">
<path d="M6 38V14a2 2 0 012-2h12l4 4h16a2 2 0 012 2v20a2 2 0 01-2 2H8a2 2 0 01-2-2z"/>
</svg>
<div class="empty-title">No files in this folder</div></div>`;
return;
}
el.innerHTML = `<table class="ftable">${FILE_TABLE_HEADER}
<tbody>${buildFileRows(S.localFiles, S.selLocal, 'path', true)}</tbody></table>`;
attachLocalEvents(el);
} else {
// Cheap path: just sync selection highlight
applySelectionToDOM(el, S.selLocal);
}
}
function attachMarqueeSelect(el, selSet) {
let active = false, startX = 0, startY = 0, box = null, additive = false;
const finish = () => {
active = false;
if (box) { box.remove(); box = null; }
window.removeEventListener('mousemove', onMove);
window.removeEventListener('mouseup', finish);
};
const onMove = e => {
if (!active) return;
window.omniClearSelection();
const x1 = Math.min(startX, e.clientX), y1 = Math.min(startY, e.clientY);
const x2 = Math.max(startX, e.clientX), y2 = Math.max(startY, e.clientY);
Object.assign(box.style, {
left: x1 + 'px', top: y1 + 'px',
width: (x2 - x1) + 'px', height: (y2 - y1) + 'px',
});
if (!additive) selSet.clear();
el.querySelectorAll('.frow').forEach(row => {
const r = row.getBoundingClientRect();
const hit = r.right >= x1 && r.left <= x2 && r.bottom >= y1 && r.top <= y2;
if (hit) selSet.add(row.dataset.key);
});
applySelectionToDOM(el, selSet);
};
el.addEventListener('mousedown', e => {
if (e.button !== 0 || e.target.closest('.frow')) return;
e.preventDefault();
active = true;
additive = e.metaKey || e.shiftKey;
startX = e.clientX;
startY = e.clientY;
box = document.createElement('div');
box.className = 'marquee-box';
document.body.appendChild(box);
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', finish);
});
}
function attachLocalEvents(el) {
const files = S.localFiles;
const keys = selectionKeys(files, 'path');
el.querySelectorAll('.frow').forEach((row, i) => {
const f = files[i];
const key = keys[i];
// Click → select
row.addEventListener('click', e => {
handleRowClick(e, i, key, S.selLocal, keys, 'lastLocalIdx');
applySelectionToDOM(el, S.selLocal);
});
// Double-click → navigate dir OR upload file to Switch
row.addEventListener('dblclick', () => {
if (f.isDir) { call('navigate_local', {path: f.path}); }
else if (S.connected) { call('start_upload', {srcPath: f.path}); }
});
// Right-click → local context menu
row.addEventListener('contextmenu', e => {
e.preventDefault();
showLocalCtxMenu(f, e);
});
// Drag source: drag selected local file(s) to the remote panel
if (!f.isDir) {
row.addEventListener('dragstart', e => {
const dragFiles = (S.selLocal.has(key)
? files.filter((_, j) => S.selLocal.has(keys[j]))
: [f]).filter(df => !df.isDir);
if (!dragFiles.length) { e.preventDefault(); return; }
window.omniClearSelection();
e.dataTransfer.clearData();
e.dataTransfer.setData('application/x-omnimtp-local',
JSON.stringify(dragFiles.map(df => ({path: df.path, name: df.name}))));
e.dataTransfer.effectAllowed = 'copy';
e.dataTransfer.setDragImage(dragBadge(dragFiles.length, dragFiles[0].name), 0, 0);
});
}
});
attachMarqueeSelect(el, S.selLocal);
// Drop target: receive remote files dropped here → download
el.addEventListener('dragover', e => { e.preventDefault(); el.classList.add('drag-over'); });
el.addEventListener('dragleave', e => { if (!el.contains(e.relatedTarget)) el.classList.remove('drag-over'); });
el.addEventListener('drop', e => {
e.preventDefault();
el.classList.remove('drag-over');
const d = e.dataTransfer.getData('application/x-omnimtp-remote');
if (d) {
const items = JSON.parse(d);
(Array.isArray(items) ? items : [items]).forEach(rf => {
call('start_download', {handle: rf.handle, storageId: rf.storageId, filename: rf.name, size: rf.size});
});
}
});
}
// ── Remote panel ──────────────────────────────────────────────────────────────
function renderRemoteFiles() {
const el = document.getElementById('remote-files');
const empty = document.getElementById('remote-empty-state');
if (!S.connected) {
if (empty) empty.style.display = '';
el.querySelectorAll('table,.loading,.empty-state:not(#remote-empty-state)')
.forEach(n => n.remove());
_remoteFingerprint = '';
return;
}
if (empty) empty.style.display = 'none';
if (S.remoteRefreshing) {
el.innerHTML = '<div class="loading">Loading…</div>';
_remoteFingerprint = '__loading__';
return;
}
const fp = fingerprintFiles(S.remoteFiles);
if (fp !== _remoteFingerprint) {
_remoteFingerprint = fp;
if (!S.remoteFiles.length) {
el.innerHTML = `<div class="empty-state">
<svg viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-width="1.5" class="empty-icon">
<path d="M6 38V14a2 2 0 012-2h12l4 4h16a2 2 0 012 2v20a2 2 0 01-2 2H8a2 2 0 01-2-2z"/>
</svg>
<div class="empty-title">Empty folder</div></div>`;
} else {
el.innerHTML = `<table class="ftable">${FILE_TABLE_HEADER}
<tbody>${buildFileRows(S.remoteFiles, S.selRemote, 'handle', false)}</tbody></table>`;
}
attachRemoteEvents(el);
} else {
applySelectionToDOM(el, S.selRemote);
}
}
function attachRemoteEvents(el) {
const files = S.remoteFiles;
const keys = selectionKeys(files, 'handle');
el.querySelectorAll('.frow').forEach((row, i) => {
const f = files[i];
const key = keys[i];
row.addEventListener('click', e => {
handleRowClick(e, i, key, S.selRemote, keys, 'lastRemoteIdx');
applySelectionToDOM(el, S.selRemote);
});
row.addEventListener('dblclick', () => {
if (f.isDir) call('navigate_remote', {handle: f.handle, storageId: f.storageId, name: f.name});
});
row.addEventListener('contextmenu', e => { e.preventDefault(); showRemoteCtxMenu(f, e); });
});
attachMarqueeSelect(el, S.selRemote);
// Drop target: receive local files → upload
el.addEventListener('dragover', e => { e.preventDefault(); el.classList.add('drag-over'); });
el.addEventListener('dragleave', e => { if (!el.contains(e.relatedTarget)) el.classList.remove('drag-over'); });
el.addEventListener('drop', e => {
e.preventDefault();
el.classList.remove('drag-over');
// Internal drag (local app files)
const d = e.dataTransfer.getData('application/x-omnimtp-local');
if (d) {
const items = JSON.parse(d);
(Array.isArray(items) ? items : [items]).forEach(lf => {
call('start_upload', {srcPath: lf.path});
});
return;
}
// Finder drop
for (const file of (e.dataTransfer.files || [])) {
const p = file.path || '';
if (p) call('start_upload', {srcPath: p});
}
});
}
// ── Shared row click with shift-range support ─────────────────────────────────
function handleRowClick(e, idx, key, selSet, allKeys, lastIdxProp) {
if (e.shiftKey && S[lastIdxProp] >= 0) {
const lo = Math.min(idx, S[lastIdxProp]);
const hi = Math.max(idx, S[lastIdxProp]);
if (!e.metaKey) selSet.clear();
allKeys.slice(lo, hi + 1).forEach(k => selSet.add(k));
} else if (e.metaKey) {
if (selSet.has(key)) selSet.delete(key); else selSet.add(key);
S[lastIdxProp] = idx;
} else {
selSet.clear();
selSet.add(key);
S[lastIdxProp] = idx;
}
}
// ── Drag badge ghost image ─────────────────────────────────────────────────────
function dragBadge(count, firstName) {
const el = document.createElement('div');
el.style.cssText = [
'position:fixed', 'top:-200px', 'left:-200px',
'background:rgba(10,132,255,0.85)', 'color:#fff',
'font:600 12px -apple-system', 'padding:4px 10px',
'border-radius:10px', 'white-space:nowrap',
].join(';');
el.textContent = count > 1 ? `${count} files` : firstName;
document.body.appendChild(el);
setTimeout(() => el.remove(), 0);
return el;
}
// ── Toolbars ──────────────────────────────────────────────────────────────────
function renderLocalToolbar() {
const sel = document.getElementById('local-drive-sel');
if (S.localDrives && S.localDrives.length) {
const prev = sel.value;
sel.innerHTML = S.localDrives.map(d =>
`<option value="${esc(d.path)}">${esc(d.name)}</option>`
).join('');
const active = S.activeLocalDrive || prev;
if ([...sel.options].some(o => o.value === active)) sel.value = active;
sel.style.display = '';
} else {
sel.style.display = 'none';
}
const crumb = document.getElementById('local-breadcrumb');
const parts = (S.localPath || '').split('/').filter(Boolean);
const show = parts.slice(-4);
let html = show.length < parts.length ? '<span class="crumb-ellipsis">…</span><span class="crumb-sep"></span>' : '';
show.forEach((p, i) => {
const fullIdx = parts.length - show.length + i;
const path = '/' + parts.slice(0, fullIdx + 1).join('/');
html += i === show.length - 1
? `<span class="crumb-cur">${esc(p)}</span>`
: `<span class="crumb-link" data-path="${esc(path)}">${esc(p)}</span><span class="crumb-sep"></span>`;
});
crumb.innerHTML = html;
crumb.querySelectorAll('.crumb-link').forEach(el =>
el.addEventListener('click', () => call('navigate_local', {path: el.dataset.path})));
document.getElementById('btn-local-up').disabled = parts.length === 0;
}
function renderRemoteToolbar() {
const sel = document.getElementById('storage-sel');
if (S.storages && S.storages.length) {
const prev = sel.value;
sel.innerHTML = S.storages.map(s =>
`<option value="${s.id}">${esc(s.name)}${s.freeStr ? ' (' + s.freeStr + ')' : ''}</option>`
).join('');
sel.value = String(S.activeStorageId || prev);
sel.style.display = '';
} else {
sel.style.display = 'none';
}
const stack = S.remoteNavStack || [];
const crumb = document.getElementById('remote-breadcrumb');
crumb.innerHTML = stack.slice(1).map((e, i) => {
const absIdx = i + 1;
return absIdx === stack.length - 1
? `<span class="crumb-cur">${esc(e.label)}</span>`
: `<span class="crumb-link" data-idx="${absIdx}">${esc(e.label)}</span><span class="crumb-sep"></span>`;
}).join('');
crumb.querySelectorAll('.crumb-link').forEach(el =>
el.addEventListener('click', () => call('navigate_remote_to', {index: +el.dataset.idx})));
document.getElementById('btn-remote-up').disabled = stack.length <= 1;
}
// ── Device bar ────────────────────────────────────────────────────────────────
function renderDeviceBar() {
const badge = document.getElementById('conn-badge');
const status = document.getElementById('device-status-text');
if (S.connected) {
badge.className = 'badge connected';
badge.textContent = S.deviceName || 'Nintendo Switch';
status.textContent = S.deviceName || 'Nintendo Switch connected';
} else {
badge.className = 'badge disconnected';
badge.textContent = 'No Device';
status.textContent = 'Waiting for Nintendo Switch…';
}
}
// ── Transfers panel ───────────────────────────────────────────────────────────
function renderTransfers() {
const list = document.getElementById('transfers-list');
const label = document.getElementById('transfers-label');
const cancelAll= document.getElementById('btn-cancel-all');
const panel = document.getElementById('transfers-panel');
const active = (S.transfers || []).filter(t => t.state <= 1);
label.textContent = active.length ? `${active.length} Transfer${active.length > 1 ? 's' : ''}` : 'Transfers';
cancelAll.style.display = active.length ? '' : 'none';
if (!S.transfers || !S.transfers.length) { list.innerHTML = ''; panel.style.display = 'none'; return; }
panel.style.display = '';
const stateNames = ['Queued','','Done','Failed','Cancelled'];
list.innerHTML = S.transfers.map(t => {
const dir = t.direction === 0 ? 'DL' : 'UP';
const dc = t.direction === 0 ? 'dir-dl' : 'dir-ul';
const pct = ((t.progress || 0) * 100).toFixed(0);
const showBar = t.state <= 1;
const statText = t.state === 1 ? esc(t.speedStr)
: t.state === 3 ? '⚠ ' + esc(t.error || 'Failed')
: stateNames[t.state] || '';
return `<div class="txfer-item">
<div class="txfer-row">
<span class="txfer-dir ${dc}">${dir}</span>
<span class="txfer-name" title="${esc(t.filename)}">${esc(t.filename)}</span>
<span class="txfer-stat">${statText}</span>
${showBar ? `<button class="txfer-cancel" data-id="${esc(t.id)}">×</button>` : ''}
</div>
${showBar ? `<div class="pbar"><div class="pbar-fill" style="width:${pct}%"></div></div>
<div class="txfer-meta">${pct}%${t.etaStr ? ' · ' + t.etaStr : ''}</div>` : ''}
</div>`;
}).join('');
list.querySelectorAll('.txfer-cancel').forEach(btn =>
btn.addEventListener('click', () => call('cancel_transfer', {id: btn.dataset.id})));
}
// ── Status bar ────────────────────────────────────────────────────────────────
function renderStatusBar() {
const lf = (S.localFiles || []).length;
const rf = (S.remoteFiles || []).length;
document.getElementById('sb-left').textContent = `${lf} item${lf !== 1 ? 's' : ''}`;
document.getElementById('sb-right').textContent = S.connected ? `${rf} item${rf !== 1 ? 's' : ''}` : '';
let center = '';
if (S.statusTimer > 0 && S.statusMessage) {
center = S.statusMessage;
} else if (S.connected) {
const act = (S.transfers || []).filter(t => t.state === 1);
if (act.length) {
const spd = act.reduce((s, t) => s + (t.speedBps || 0), 0);
center = `${act.length} active · ` + (spd > 1048576
? (spd/1048576).toFixed(0) + ' MB/s'
: (spd/1024).toFixed(0) + ' KB/s');
}
}
document.getElementById('sb-center').textContent = center;
}
// ── Context menus ─────────────────────────────────────────────────────────────
let _ctxFile = null, _ctxIsLocal = false;
function showLocalCtxMenu(file, e) {
_ctxFile = file;
_ctxIsLocal = true;
const m = document.getElementById('ctx-menu');
document.getElementById('ctx-upload').style.display = (!file.isDir && S.connected) ? '' : 'none';
document.getElementById('ctx-download').style.display = 'none';
document.getElementById('ctx-delete').style.display = 'none';
document.getElementById('ctx-sep-del').style.display = 'none';
m.style.left = e.clientX + 'px';
m.style.top = e.clientY + 'px';
m.classList.remove('hidden');
}
function showRemoteCtxMenu(file, e) {
_ctxFile = file;
_ctxIsLocal = false;
if (!S.selRemote.has(String(file.handle))) {
S.selRemote.clear();
S.selRemote.add(String(file.handle));
applySelectionToDOM(document.getElementById('remote-files'), S.selRemote);
}
const m = document.getElementById('ctx-menu');
const showDownload = !file.isDir;
document.getElementById('ctx-upload').style.display = 'none';
document.getElementById('ctx-download').style.display = showDownload ? '' : 'none';
document.getElementById('ctx-delete').style.display = '';
document.getElementById('ctx-sep-del').style.display = showDownload ? '' : 'none';
m.style.left = e.clientX + 'px';
m.style.top = e.clientY + 'px';
m.classList.remove('hidden');
}
document.addEventListener('click', e => {
if (e.target.closest('#ctx-menu')) return;
document.getElementById('ctx-menu').classList.add('hidden');
});
document.addEventListener('contextmenu', e => { if (!e.target.closest('.frow')) { e.preventDefault(); document.getElementById('ctx-menu').classList.add('hidden'); } });
// ── Init ──────────────────────────────────────────────────────────────────────
window.omniClearSelection = function() {
const sel = window.getSelection();
if (sel && sel.rangeCount) sel.removeAllRanges();
};
window.omniRemoteDragFilesAt = function(x, y) {
if (!S.connected) return '';
const hit = document.elementFromPoint(x, y);
const row = hit?.closest('#remote-files .frow');
if (!row || row.dataset.isDir === '1') return '';
const f = S.remoteFiles[+row.dataset.idx];
if (!f || f.isDir) return '';
const key = String(f.handle);
const dragFiles = S.selRemote.has(key)
? S.remoteFiles.filter(rf => S.selRemote.has(String(rf.handle)) && !rf.isDir)
: [f];
if (!dragFiles.length) return '';
return JSON.stringify(dragFiles.map(df => ({
handle: df.handle, storageId: df.storageId, name: df.name, size: df.size,
})));
};
// Block text selection and text-drag; only explicit file drags allowed.
document.addEventListener('selectstart', e => {
if (e.target.closest('.file-list')) e.preventDefault();
}, true);
document.addEventListener('mousedown', e => {
if (e.button !== 0) return;
if (e.target.closest('.file-list .frow')) e.preventDefault();
}, true);
document.addEventListener('dragstart', e => {
const row = e.target.closest('.file-list .frow');
if (!row) return;
if (row.closest('#remote-files') || row.getAttribute('draggable') !== 'true') {
e.preventDefault();
return;
}
window.omniClearSelection();
if (e.dataTransfer) {
e.dataTransfer.effectAllowed = 'copy';
e.dataTransfer.setData('text/plain', '');
}
}, true);
document.addEventListener('DOMContentLoaded', () => {
document.getElementById('btn-local-up') .addEventListener('click', () => call('navigate_local_up'));
document.getElementById('btn-local-home') .addEventListener('click', () => call('navigate_local', {path: '~'}));
document.getElementById('btn-local-refresh').addEventListener('click', () => call('refresh_local'));
document.getElementById('local-drive-sel') .addEventListener('change', e => call('navigate_local', {path: e.target.value}));
document.getElementById('btn-remote-up') .addEventListener('click', () => call('navigate_remote_up'));
document.getElementById('btn-remote-refresh').addEventListener('click', () => call('refresh_remote'));
document.getElementById('btn-scan') .addEventListener('click', () => call('reconnect'));
document.getElementById('btn-open-picker') .addEventListener('click', () => call('open_file_picker'));
document.getElementById('btn-cancel-all') .addEventListener('click', () => call('cancel_all'));
document.getElementById('storage-sel') .addEventListener('change', e => call('set_storage', {storageId: +e.target.value}));
// Context menu actions
const bindCtx = (id, fn) => {
document.getElementById(id).addEventListener('click', e => {
e.stopPropagation();
fn();
});
};
bindCtx('ctx-upload', () => {
if (!_ctxFile) return;
const uploadFiles = S.selLocal.size > 1 && S.selLocal.has(_ctxFile.path)
? S.localFiles.filter(f => S.selLocal.has(f.path))
: [_ctxFile];
uploadFiles.forEach(f => call('start_upload', {srcPath: f.path}));
});
bindCtx('ctx-download', () => {
if (!_ctxFile) return;
const downloadFiles = S.selRemote.size > 1 && S.selRemote.has(String(_ctxFile.handle))
? S.remoteFiles.filter(f => S.selRemote.has(String(f.handle)))
: [_ctxFile];
downloadFiles.forEach(f => call('start_download', {handle: f.handle, storageId: f.storageId, filename: f.name, size: f.size}));
});
bindCtx('ctx-delete', () => {
if (!_ctxFile) return;
const label = S.selRemote.size > 1 && S.selRemote.has(String(_ctxFile.handle))
? `${S.selRemote.size} items`
: `"${_ctxFile.name}"`;
if (window.confirm(`Delete ${label}?`)) {
const handles = S.selRemote.size > 1 && S.selRemote.has(String(_ctxFile.handle))
? S.remoteFiles.filter(f => S.selRemote.has(String(f.handle))).map(f => f.handle)
: [_ctxFile.handle];
handles.forEach(h => call('delete_remote', {handle: h}));
}
document.getElementById('ctx-menu').classList.add('hidden');
});
// Keyboard
document.addEventListener('keydown', e => {
if (e.target.matches('input,textarea,select')) return;
if (e.key === 'Backspace' || e.key === 'Delete') {
S.selRemote.forEach(h => call('delete_remote', {handle: +h}));
}
if (e.metaKey && e.key === 'r') {
e.preventDefault();
call('refresh_remote');
call('refresh_local');
}
// Cmd+A = select all
if (e.metaKey && e.key === 'a') {
e.preventDefault();
const active = document.activeElement;
if (active && active.closest('#local-files')) {
S.localFiles.forEach(f => S.selLocal.add(f.path));
applySelectionToDOM(document.getElementById('local-files'), S.selLocal);
} else {
S.remoteFiles.forEach(f => S.selRemote.add(String(f.handle)));
applySelectionToDOM(document.getElementById('remote-files'), S.selRemote);
}
}
// Escape = deselect
if (e.key === 'Escape') {
S.selLocal.clear(); S.selRemote.clear();
applySelectionToDOM(document.getElementById('local-files'), S.selLocal);
applySelectionToDOM(document.getElementById('remote-files'), S.selRemote);
}
});
poll();
updateDropZones();
window.addEventListener('resize', updateDropZones);
});

106
src/ui/webroot/index.html Normal file
View File

@@ -0,0 +1,106 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>OmniMTP</title>
<link rel="stylesheet" href="app.css">
</head>
<body>
<div id="titlebar">
<div class="tl-spacer"></div>
<span id="app-title">OmniMTP</span>
<div id="conn-badge" class="badge disconnected">No Device</div>
</div>
<div id="device-bar">
<div class="device-left">
<button class="action-btn" id="btn-open-picker">Add Files</button>
</div>
<div class="device-center" id="device-status-text">Waiting for Nintendo Switch…</div>
<div class="device-right">
<button class="action-btn primary" id="btn-scan">Scan</button>
</div>
</div>
<div id="main">
<!-- Left: Local -->
<div class="panel" id="panel-local">
<div class="panel-toolbar">
<div class="nav-group">
<button class="nav-btn" id="btn-local-up" title="Go Up">
<svg viewBox="0 0 16 16"><path d="M8 3L3 8h3v5h4V8h3L8 3z" fill="currentColor"/></svg>
</button>
<button class="nav-btn" id="btn-local-home" title="Home">
<svg viewBox="0 0 16 16"><path d="M8 2L2 7h2v6h4v-4h4v4h2V7l2 0-6-5z" fill="currentColor"/></svg>
</button>
</div>
<select id="local-drive-sel" class="storage-select" title="Drive"></select>
<div id="local-breadcrumb" class="breadcrumb"></div>
<button class="nav-btn icon-only" id="btn-local-refresh" title="Refresh">
<svg viewBox="0 0 16 16"><path d="M13.5 8A5.5 5.5 0 1 1 8 2.5V1l3 2.5L8 6V4.5A3.5 3.5 0 1 0 11.5 8h2z" fill="currentColor"/></svg>
</button>
</div>
<div class="file-list" id="local-files">
<div class="loading">Loading…</div>
</div>
</div>
<!-- Divider -->
<div id="panel-divider"></div>
<!-- Right: Switch -->
<div class="panel" id="panel-remote">
<div class="panel-toolbar">
<div class="nav-group">
<button class="nav-btn" id="btn-remote-up" title="Go Up" disabled>
<svg viewBox="0 0 16 16"><path d="M8 3L3 8h3v5h4V8h3L8 3z" fill="currentColor"/></svg>
</button>
</div>
<select id="storage-sel" class="storage-select"></select>
<div id="remote-breadcrumb" class="breadcrumb"></div>
<button class="nav-btn icon-only" id="btn-remote-refresh" title="Refresh">
<svg viewBox="0 0 16 16"><path d="M13.5 8A5.5 5.5 0 1 1 8 2.5V1l3 2.5L8 6V4.5A3.5 3.5 0 1 0 11.5 8h2z" fill="currentColor"/></svg>
</button>
</div>
<div class="file-list" id="remote-files">
<div class="empty-state" id="remote-empty-state">
<svg viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-width="1.5" class="empty-icon">
<rect x="8" y="4" width="32" height="40" rx="4"/>
<line x1="24" y1="12" x2="24" y2="18"/>
<circle cx="24" cy="22" r="1.5" fill="currentColor"/>
</svg>
<div class="empty-title">No Switch Connected</div>
<div class="empty-sub">Connect via USB-C and open DBI or Sphaira MTP</div>
</div>
</div>
</div>
</div>
<!-- Transfers -->
<div id="transfers-panel">
<div id="transfers-header">
<span id="transfers-label">Transfers</span>
<button class="action-btn danger small" id="btn-cancel-all" style="display:none">Cancel All</button>
</div>
<div id="transfers-list"></div>
</div>
<!-- Status bar -->
<div id="statusbar">
<span id="sb-left"></span>
<span id="sb-center"></span>
<span id="sb-right"></span>
</div>
<!-- Context menu -->
<div id="ctx-menu" class="ctx-menu hidden">
<div class="ctx-item" id="ctx-upload">Upload to Switch</div>
<div class="ctx-item" id="ctx-download">Download to Mac</div>
<div class="ctx-item ctx-sep" id="ctx-sep-del"></div>
<div class="ctx-item danger" id="ctx-delete">Delete…</div>
</div>
<script src="app.js"></script>
</body>
</html>