Initial commit
This commit is contained in:
145
CMakeLists.txt
Normal file
145
CMakeLists.txt
Normal 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
21
LICENSE
Normal 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
163
README.md
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
<div align="center">
|
||||||
|
|
||||||
|
<h1>OmniMTP</h1>
|
||||||
|
|
||||||
|
<p>Ein schneller (<strong>3–4× 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>
|
||||||
|
|
||||||
|
[](https://www.apple.com/macos/)
|
||||||
|
[](LICENSE)
|
||||||
|
[]()
|
||||||
|
[]()
|
||||||
|
|
||||||
|
<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 60–150+ MB/s, abhängig von Switch und Kabel
|
||||||
|
- **Zwei-Panel-Dateibrowser** — Mac-Dateisystem links, Switch rechts; Dateien per Drag & Drop zwischen beiden Seiten verschieben
|
||||||
|
- **Mehrfachübertragung** — mehrere Dateien mit Cmd+Klick oder Shift+Klick auswählen und gemeinsam ziehen
|
||||||
|
- **Große Dateien** — Dateien >4 GB werden korrekt unterstützt
|
||||||
|
- **Automatische Geräteerkennung** — einstecken und loslegen, kein manuelles Scannen nötig
|
||||||
|
- **Modernes macOS-UI** — natives Dark Mode, SF Pro, macOS-12+-Fensterdesign
|
||||||
|
- **Keine Abhängigkeiten** — libusb ist statisch eingebunden; nichts extra installieren
|
||||||
|
|
||||||
|
## Voraussetzungen
|
||||||
|
|
||||||
|
- **Mac:** macOS 12 Monterey oder neuer (Universal Binary — Apple Silicon + Intel)
|
||||||
|
- **Switch:** Custom Firmware mit [DBI](https://github.com/rashevskyv/dbi) oder [Sphaira](https://github.com/ITotalJustice/sphaira)
|
||||||
|
- **Kabel:** ein Kabel mit Datenübertragung (kein reines Ladekabel)
|
||||||
|
|
||||||
|
## Erste Schritte
|
||||||
|
|
||||||
|
### 1. Switch vorbereiten
|
||||||
|
|
||||||
|
In DBI: **Tools → MTP Responder**
|
||||||
|
|
||||||
|
In Sphaira: **Tools → MTP**
|
||||||
|
|
||||||
|
USB-C-Kabel anschließen und den Bildschirm offen lassen
|
||||||
|
|
||||||
|
### 2. Aus dem Quellcode bauen
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/helzeiah/OmniMTP.git
|
||||||
|
cd OmniMTP
|
||||||
|
./build.sh # Release-Build
|
||||||
|
./build.sh run # bauen + starten
|
||||||
|
```
|
||||||
|
|
||||||
|
Oder manuell:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cmake -B build -DCMAKE_BUILD_TYPE=Release
|
||||||
|
cmake --build build -j$(sysctl -n hw.logicalcpu)
|
||||||
|
open build/OmniMTP.app
|
||||||
|
```
|
||||||
|
|
||||||
|
**Build-Abhängigkeiten** (werden von CMake automatisch geladen):
|
||||||
|
|
||||||
|
- libusb 1.0.27
|
||||||
|
- keine weiteren externen Abhängigkeiten
|
||||||
|
|
||||||
|
### 3. Dateien übertragen
|
||||||
|
|
||||||
|
- **Links** den Mac durchsuchen, **rechts** die Switch
|
||||||
|
- **Ziehen:** links → rechts = hochladen, rechts → links = herunterladen
|
||||||
|
- **Doppelklick** auf eine lokale Datei lädt sie bei aktiver Verbindung direkt hoch
|
||||||
|
- **Rechtsklick** für Kontextmenü (Auf Switch hochladen / Auf Mac laden / Löschen)
|
||||||
|
- **Add Files** öffnet einen nativen Datei-Dialog für Mehrfach-Uploads
|
||||||
|
|
||||||
|
## Wie schnell ist es?
|
||||||
|
|
||||||
|
| Verbindung | Typische Geschwindigkeit |
|
||||||
|
| ---------------------------------------- | ------------------------ |
|
||||||
|
| USB 2.0 | 25–45 MB/s |
|
||||||
|
| USB 3.0 (Switch OLED / direktes USB-C 3.x) | 60–150+ MB/s |
|
||||||
|
|
||||||
|
Die Geschwindigkeit hängt von der microSD-Schreibgeschwindigkeit (Uploads) und der USB-Verbindung ab. Eine schnelle UHS-I-Karte plus USB-3.0-Kabel bringt das meiste raus.
|
||||||
|
|
||||||
|
## Hinweis zur Installation (macOS Gatekeeper)
|
||||||
|
|
||||||
|
Da OmniMTP nicht mit einem Apple-Developer-Zertifikat notarisiert ist, zeigt macOS beim ersten Start eine Warnung **„Unbekannter Entwickler“**. Das ist bei Open-Source-Apps außerhalb des App Store normal.
|
||||||
|
|
||||||
|
**So öffnest du die App:**
|
||||||
|
|
||||||
|
**Option A — Rechtsklick (am einfachsten)**
|
||||||
|
Rechtsklick (oder Control-Klick) auf `OmniMTP.app` → **Öffnen** → im Dialog erneut **Öffnen**. Das musst du nur einmal machen.
|
||||||
|
|
||||||
|
**Option B — Terminal**
|
||||||
|
```bash
|
||||||
|
xattr -r -d com.apple.quarantine /Applications/OmniMTP.app
|
||||||
|
# oder wo auch immer du die App abgelegt hast:
|
||||||
|
xattr -r -d com.apple.quarantine ~/Downloads/OmniMTP.app
|
||||||
|
```
|
||||||
|
|
||||||
|
**Option C — Systemeinstellungen**
|
||||||
|
Systemeinstellungen → Datenschutz & Sicherheit → Bereich Sicherheit → **Trotzdem öffnen**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fehlerbehebung
|
||||||
|
|
||||||
|
**Die App erkennt meine Switch nicht**
|
||||||
|
|
||||||
|
- Prüfe, ob DBI oder Sphaira im MTP-Modus läuft (nicht nur im Home-Menü)
|
||||||
|
- Probiere ein anderes USB-C-Kabel — viele Ladekabel haben keine Datenleitungen
|
||||||
|
- Klicke in der App auf **Scan**
|
||||||
|
|
||||||
|
**Fehler „Operation not supported“**
|
||||||
|
|
||||||
|
- DBI/Sphaira auf eine aktuelle Version aktualisieren
|
||||||
|
- Sphaira: sicherstellen, dass du im Menü **MTP Install** bist
|
||||||
|
|
||||||
|
## Geplante Erweiterungen
|
||||||
|
|
||||||
|
- **Breitere MTP-Geräteunterstützung** — die MTP/USB-Schicht ist geräteunabhängig. Android-Handys, Kameras und andere MTP-Geräte sollten mit kleinen Anpassungen an Erkennung und Protokoll funktionieren
|
||||||
|
- Ordner-Upload (rekursiv)
|
||||||
|
- Unterbrochene Übertragungen fortsetzen
|
||||||
|
- Warteschlange umsortieren
|
||||||
|
- Dateien auf dem Gerät umbenennen
|
||||||
|
|
||||||
|
## Bauen
|
||||||
|
|
||||||
|
```
|
||||||
|
./build.sh # Release (Standard)
|
||||||
|
./build.sh debug # Debug-Build
|
||||||
|
./build.sh run # Release + starten
|
||||||
|
./build.sh clean # Build-Verzeichnisse löschen
|
||||||
|
```
|
||||||
|
|
||||||
|
Das Build-System lädt libusb aus dem Quellcode und kompiliert es statisch — kein Homebrew oder Systempakete nötig.
|
||||||
|
|
||||||
|
## Architektur
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── main.mm Einstiegspunkt (NSApp + WKWebView)
|
||||||
|
├── ui/
|
||||||
|
│ ├── App.hpp/.mm Backend: Geräteüberwachung, lokale/remote Dateien, Transfers
|
||||||
|
│ ├── WebUI.hpp/.mm Bridge: WKWebView-Setup + JS↔C++ Message Handler
|
||||||
|
│ └── webroot/ Frontend: HTML/CSS/JS (ohne Build-Schritt, ohne Frameworks)
|
||||||
|
├── mtp/
|
||||||
|
│ ├── MTPProtocol.hpp MTP-Konstanten, Container-Format, Datenstrukturen
|
||||||
|
│ ├── MTPSession.* USB/libusb-Transport, Chunked Transfers
|
||||||
|
│ └── MTPOperations.* High-Level-MTP-Operationen (GetObject, SendObject, …)
|
||||||
|
└── transfer/
|
||||||
|
└── TransferEngine.* Hintergrund-Warteschlange mit Fortschrittsanzeige
|
||||||
|
```
|
||||||
|
|
||||||
|
Die UI ist eine WKWebView-basierte HTML/CSS/JS-App, die über native Message Passing mit einem C++20-MTP-Backend spricht. Kein Electron, kein Node, keine externe Runtime — nur AppKit + WebKit + libusb.
|
||||||
|
|
||||||
|
## Lizenz
|
||||||
|
|
||||||
|
MIT — siehe [LICENSE](LICENSE).
|
||||||
BIN
assets/AppIcon.png
Normal file
BIN
assets/AppIcon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 452 KiB |
75
build.sh
Executable file
75
build.sh
Executable 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
36
cmake/Info.plist.in
Normal 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
34
cmake/generate_icns.sh
Executable 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
39
cmake/libusb_config.h
Normal 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
BIN
heziMTP.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 257 KiB |
14
src/main.mm
Normal file
14
src/main.mm
Normal 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
333
src/mtp/MTPOperations.cpp
Normal 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
70
src/mtp/MTPOperations.hpp
Normal 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
226
src/mtp/MTPProtocol.hpp
Normal 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
603
src/mtp/MTPSession.cpp
Normal 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
125
src/mtp/MTPSession.hpp
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
#pragma once
|
||||||
|
#include "MTPProtocol.hpp"
|
||||||
|
#include <libusb.h>
|
||||||
|
#include <atomic>
|
||||||
|
#include <functional>
|
||||||
|
#include <mutex>
|
||||||
|
#include <span>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace mtp {
|
||||||
|
|
||||||
|
struct CommandResponse {
|
||||||
|
ResponseCode code;
|
||||||
|
std::vector<uint32_t> params;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct USBEndpoints {
|
||||||
|
uint8_t bulk_in = 0;
|
||||||
|
uint8_t bulk_out = 0;
|
||||||
|
uint8_t interrupt_in = 0;
|
||||||
|
int interface_num = 0;
|
||||||
|
int bulk_out_max_pkt = 512; // wMaxPacketSize: 512 for USB 2.0 HS, 1024 for SS
|
||||||
|
};
|
||||||
|
|
||||||
|
using ProgressFn = std::function<void(uint64_t done, uint64_t total)>;
|
||||||
|
|
||||||
|
// MTPSession owns the libusb handle, USB endpoints, and MTP session state.
|
||||||
|
// All operations are serialized through the session mutex — MTP is strictly
|
||||||
|
// single-threaded from the device's perspective.
|
||||||
|
class MTPSession {
|
||||||
|
public:
|
||||||
|
MTPSession();
|
||||||
|
~MTPSession();
|
||||||
|
|
||||||
|
MTPSession(const MTPSession&) = delete;
|
||||||
|
MTPSession& operator=(const MTPSession&) = delete;
|
||||||
|
|
||||||
|
// Scan USB bus, open the Switch, negotiate MTP session.
|
||||||
|
// Returns DeviceInfo on success; throws MTPException on failure.
|
||||||
|
DeviceInfo connect();
|
||||||
|
|
||||||
|
// Close MTP session and release USB resources.
|
||||||
|
void disconnect();
|
||||||
|
|
||||||
|
bool is_connected() const { return connected_.load(); }
|
||||||
|
|
||||||
|
// ── Low-level MTP API (called by MTPOperations) ──────────────────────────
|
||||||
|
|
||||||
|
// Send a command container with up to 5 parameters.
|
||||||
|
// Returns the assigned transaction_id via the optional out-param.
|
||||||
|
void send_command(OpCode op,
|
||||||
|
std::span<const uint32_t> params = {},
|
||||||
|
uint32_t* out_tid = nullptr);
|
||||||
|
|
||||||
|
// Send a data container following a command.
|
||||||
|
void send_data(std::span<const uint8_t> data);
|
||||||
|
|
||||||
|
// Receive a data container into a heap buffer.
|
||||||
|
std::vector<uint8_t> receive_data();
|
||||||
|
|
||||||
|
// Chunked receive directly into a file — avoids buffering whole file in RAM.
|
||||||
|
void receive_data_to_file(const std::string& dest_path,
|
||||||
|
uint64_t expected_size,
|
||||||
|
const ProgressFn& progress,
|
||||||
|
std::atomic<bool>& cancel);
|
||||||
|
|
||||||
|
// Chunked send of a file as a data container.
|
||||||
|
// op must be the OpCode from the preceding send_command() call so the
|
||||||
|
// data container echoes the correct operation code (MTP spec §6.4).
|
||||||
|
void send_data_from_file(const std::string& src_path,
|
||||||
|
uint64_t file_size,
|
||||||
|
OpCode op,
|
||||||
|
const ProgressFn& progress,
|
||||||
|
std::atomic<bool>& cancel);
|
||||||
|
|
||||||
|
// Receive the response container after a command (+optional data exchange).
|
||||||
|
// Pass TIMEOUT_INSTALL_MS after large file transfers so post-install DB work
|
||||||
|
// on the Switch side has time to complete before we declare a timeout error.
|
||||||
|
CommandResponse receive_response(int timeout_ms = TIMEOUT_CMD_MS);
|
||||||
|
|
||||||
|
// Convenience: send command then immediately receive response (no data phase).
|
||||||
|
CommandResponse command(OpCode op, std::span<const uint32_t> params = {});
|
||||||
|
|
||||||
|
uint32_t next_transaction_id() { return ++transaction_id_; }
|
||||||
|
|
||||||
|
// Accessors for device metadata
|
||||||
|
uint16_t vendor_id() const { return vendor_id_; }
|
||||||
|
uint16_t product_id() const { return product_id_; }
|
||||||
|
std::string device_name() const { return device_name_; }
|
||||||
|
const DeviceInfo& device_info() const { return device_info_; }
|
||||||
|
|
||||||
|
bool supports_op(OpCode op) const {
|
||||||
|
auto code = static_cast<uint16_t>(op);
|
||||||
|
for (auto c : device_info_.operations_supported)
|
||||||
|
if (c == code) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Serialize all MTP access through this mutex (TransferEngine uses it)
|
||||||
|
std::mutex& mutex() { return mtx_; }
|
||||||
|
|
||||||
|
private:
|
||||||
|
bool find_device();
|
||||||
|
USBEndpoints find_endpoints(libusb_device* dev);
|
||||||
|
|
||||||
|
// Raw USB bulk I/O
|
||||||
|
std::vector<uint8_t> bulk_read(size_t max_bytes, int timeout_ms = TIMEOUT_MS);
|
||||||
|
void bulk_write(std::span<const uint8_t> data, int timeout_ms = TIMEOUT_MS);
|
||||||
|
|
||||||
|
libusb_context* ctx_ = nullptr;
|
||||||
|
libusb_device_handle* handle_ = nullptr;
|
||||||
|
USBEndpoints ep_;
|
||||||
|
|
||||||
|
uint32_t transaction_id_ = 0;
|
||||||
|
uint16_t last_op_code_ = 0;
|
||||||
|
uint16_t vendor_id_ = 0;
|
||||||
|
uint16_t product_id_ = 0;
|
||||||
|
DeviceInfo device_info_;
|
||||||
|
std::string device_name_;
|
||||||
|
|
||||||
|
std::atomic<bool> connected_{false};
|
||||||
|
mutable std::mutex mtx_;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace mtp
|
||||||
180
src/transfer/TransferEngine.cpp
Normal file
180
src/transfer/TransferEngine.cpp
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
#include "TransferEngine.hpp"
|
||||||
|
#include <filesystem>
|
||||||
|
#include <format>
|
||||||
|
#include <random>
|
||||||
|
#include <sstream>
|
||||||
|
#include <iomanip>
|
||||||
|
|
||||||
|
namespace fs = std::filesystem;
|
||||||
|
|
||||||
|
namespace transfer {
|
||||||
|
|
||||||
|
std::string TransferEngine::make_id() {
|
||||||
|
static std::mt19937_64 rng{std::random_device{}()};
|
||||||
|
static std::uniform_int_distribution<uint64_t> dist;
|
||||||
|
std::ostringstream ss;
|
||||||
|
ss << std::hex << std::setw(16) << std::setfill('0') << dist(rng);
|
||||||
|
return ss.str();
|
||||||
|
}
|
||||||
|
|
||||||
|
TransferEngine::TransferEngine(mtp::MTPSession& session)
|
||||||
|
: session_(session), ops_(session)
|
||||||
|
{
|
||||||
|
worker_ = std::jthread([this](std::stop_token) { worker_loop(); });
|
||||||
|
}
|
||||||
|
|
||||||
|
TransferEngine::~TransferEngine() {
|
||||||
|
shutdown_.store(true);
|
||||||
|
cv_.notify_all();
|
||||||
|
// std::jthread destructor requests stop and joins automatically
|
||||||
|
}
|
||||||
|
|
||||||
|
std::shared_ptr<TransferItem>
|
||||||
|
TransferEngine::enqueue_download(uint32_t handle, uint32_t storage_id,
|
||||||
|
const std::string& dest_path,
|
||||||
|
const std::string& filename,
|
||||||
|
uint64_t expected_size) {
|
||||||
|
auto item = std::make_shared<TransferItem>();
|
||||||
|
item->id = make_id();
|
||||||
|
item->direction = Direction::Download;
|
||||||
|
item->filename = filename;
|
||||||
|
item->total_bytes = expected_size;
|
||||||
|
item->object_handle = handle;
|
||||||
|
item->storage_id = storage_id;
|
||||||
|
item->dest_path = dest_path;
|
||||||
|
|
||||||
|
std::lock_guard lock(mtx_);
|
||||||
|
history_.push_back(item);
|
||||||
|
pending_.push(item);
|
||||||
|
cv_.notify_one();
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::shared_ptr<TransferItem>
|
||||||
|
TransferEngine::enqueue_upload(const std::string& src_path,
|
||||||
|
uint32_t parent_handle, uint32_t storage_id,
|
||||||
|
const std::string& filename,
|
||||||
|
uint64_t file_size) {
|
||||||
|
auto item = std::make_shared<TransferItem>();
|
||||||
|
item->id = make_id();
|
||||||
|
item->direction = Direction::Upload;
|
||||||
|
item->filename = filename;
|
||||||
|
item->total_bytes = file_size;
|
||||||
|
item->src_path = src_path;
|
||||||
|
item->parent_handle = parent_handle;
|
||||||
|
item->storage_id = storage_id;
|
||||||
|
|
||||||
|
std::lock_guard lock(mtx_);
|
||||||
|
history_.push_back(item);
|
||||||
|
pending_.push(item);
|
||||||
|
cv_.notify_one();
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
void TransferEngine::cancel(const std::string& transfer_id) {
|
||||||
|
std::lock_guard lock(mtx_);
|
||||||
|
for (auto& item : history_) {
|
||||||
|
if (item->id == transfer_id) {
|
||||||
|
item->cancel.store(true);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void TransferEngine::cancel_all() {
|
||||||
|
std::lock_guard lock(mtx_);
|
||||||
|
for (auto& item : history_)
|
||||||
|
item->cancel.store(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<std::shared_ptr<TransferItem>> TransferEngine::all_transfers() const {
|
||||||
|
std::lock_guard lock(mtx_);
|
||||||
|
return history_;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool TransferEngine::has_active() const {
|
||||||
|
std::lock_guard lock(mtx_);
|
||||||
|
for (auto& item : history_)
|
||||||
|
if (item->state.load() == State::Active) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
void TransferEngine::set_completion_handler(CompletionFn fn) {
|
||||||
|
std::lock_guard lock(mtx_);
|
||||||
|
completion_ = std::move(fn);
|
||||||
|
}
|
||||||
|
|
||||||
|
void TransferEngine::worker_loop() {
|
||||||
|
while (!shutdown_.load()) {
|
||||||
|
std::shared_ptr<TransferItem> item;
|
||||||
|
{
|
||||||
|
std::unique_lock lock(mtx_);
|
||||||
|
cv_.wait(lock, [&]{ return shutdown_.load() || !pending_.empty(); });
|
||||||
|
if (shutdown_.load()) break;
|
||||||
|
if (pending_.empty()) continue;
|
||||||
|
item = pending_.front();
|
||||||
|
pending_.pop();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item->cancel.load()) {
|
||||||
|
item->state.store(State::Cancelled);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
item->state.store(State::Active);
|
||||||
|
item->started_at = std::chrono::steady_clock::now();
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (item->direction == Direction::Download)
|
||||||
|
run_download(*item);
|
||||||
|
else
|
||||||
|
run_upload(*item);
|
||||||
|
|
||||||
|
if (item->cancel.load())
|
||||||
|
item->state.store(State::Cancelled);
|
||||||
|
else
|
||||||
|
item->state.store(State::Done);
|
||||||
|
} catch (const mtp::MTPException& e) {
|
||||||
|
item->error_msg = e.what();
|
||||||
|
item->state.store(State::Failed);
|
||||||
|
} catch (const std::exception& e) {
|
||||||
|
item->error_msg = e.what();
|
||||||
|
item->state.store(State::Failed);
|
||||||
|
}
|
||||||
|
|
||||||
|
item->finished_at = std::chrono::steady_clock::now();
|
||||||
|
|
||||||
|
CompletionFn cb;
|
||||||
|
{
|
||||||
|
std::lock_guard lock(mtx_);
|
||||||
|
cb = completion_;
|
||||||
|
}
|
||||||
|
if (cb) cb(*item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void TransferEngine::run_download(TransferItem& item) {
|
||||||
|
// Ensure destination directory exists
|
||||||
|
if (auto parent = fs::path(item.dest_path).parent_path(); !parent.empty())
|
||||||
|
fs::create_directories(parent);
|
||||||
|
|
||||||
|
mtp::ProgressFn progress = [&](uint64_t done, uint64_t total) {
|
||||||
|
item.bytes_done.store(done);
|
||||||
|
if (total && !item.total_bytes) item.total_bytes = total;
|
||||||
|
};
|
||||||
|
|
||||||
|
ops_.get_object(item.object_handle, item.dest_path, item.total_bytes,
|
||||||
|
progress, item.cancel);
|
||||||
|
}
|
||||||
|
|
||||||
|
void TransferEngine::run_upload(TransferItem& item) {
|
||||||
|
mtp::ProgressFn progress = [&](uint64_t done, uint64_t /*total*/) {
|
||||||
|
item.bytes_done.store(done);
|
||||||
|
};
|
||||||
|
|
||||||
|
ops_.send_object(item.storage_id, item.parent_handle,
|
||||||
|
item.src_path, item.filename, item.total_bytes,
|
||||||
|
progress, item.cancel);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace transfer
|
||||||
115
src/transfer/TransferEngine.hpp
Normal file
115
src/transfer/TransferEngine.hpp
Normal 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
158
src/ui/App.hpp
Normal 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
537
src/ui/App.mm
Normal 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
15
src/ui/WebUI.hpp
Normal 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
741
src/ui/WebUI.mm
Normal 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
573
src/ui/webroot/app.css
Normal 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
682
src/ui/webroot/app.js
Normal 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
106
src/ui/webroot/index.html
Normal 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>
|
||||||
Reference in New Issue
Block a user