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