Improve Switch-only MTP UI with drag export, i18n, and polish.

Ship Finder-style drag-out for files and folders, system-language packs with an Options override, About/menu bar support, and sorting plus selection UX fixes.
This commit is contained in:
2026-07-26 11:24:14 +02:00
parent b3bbba3f2e
commit a69a72cf81
17 changed files with 1562 additions and 746 deletions

View File

@@ -78,6 +78,7 @@ find_library(FW_APPKIT AppKit REQUIRED)
find_library(FW_FOUNDATION Foundation REQUIRED) find_library(FW_FOUNDATION Foundation REQUIRED)
find_library(FW_WEBKIT WebKit REQUIRED) find_library(FW_WEBKIT WebKit REQUIRED)
find_library(FW_CORETEXT CoreText REQUIRED) find_library(FW_CORETEXT CoreText REQUIRED)
find_library(FW_UTTYPES UniformTypeIdentifiers REQUIRED)
# ─── Application ───────────────────────────────────────────────────────────── # ─── Application ─────────────────────────────────────────────────────────────
file(GLOB_RECURSE APP_SOURCES CONFIGURE_DEPENDS file(GLOB_RECURSE APP_SOURCES CONFIGURE_DEPENDS
@@ -87,9 +88,17 @@ file(GLOB_RECURSE APP_SOURCES CONFIGURE_DEPENDS
# webroot resource files — copied into the bundle at Resources/webroot/ # webroot resource files — copied into the bundle at Resources/webroot/
file(GLOB WEBROOT_FILES "src/ui/webroot/*") file(GLOB WEBROOT_FILES "src/ui/webroot/*")
file(GLOB WEBROOT_LANG_FILES "src/ui/webroot/lang/*.json")
foreach(WF ${WEBROOT_FILES}) foreach(WF ${WEBROOT_FILES})
# Skip the lang directory entry if glob picks it up as a file path oddly
if(IS_DIRECTORY "${WF}")
continue()
endif()
set_source_files_properties(${WF} PROPERTIES MACOSX_PACKAGE_LOCATION Resources/webroot) set_source_files_properties(${WF} PROPERTIES MACOSX_PACKAGE_LOCATION Resources/webroot)
endforeach() endforeach()
foreach(LF ${WEBROOT_LANG_FILES})
set_source_files_properties(${LF} PROPERTIES MACOSX_PACKAGE_LOCATION Resources/webroot/lang)
endforeach()
# App icon — generated from assets/AppIcon.png at build time # App icon — generated from assets/AppIcon.png at build time
set(APP_ICON_SOURCE "${CMAKE_SOURCE_DIR}/assets/AppIcon.png") set(APP_ICON_SOURCE "${CMAKE_SOURCE_DIR}/assets/AppIcon.png")
@@ -103,7 +112,7 @@ add_custom_command(
VERBATIM VERBATIM
) )
add_executable(OmniMTP MACOSX_BUNDLE ${APP_SOURCES} ${WEBROOT_FILES} ${APP_ICON_ICNS}) add_executable(OmniMTP MACOSX_BUNDLE ${APP_SOURCES} ${WEBROOT_FILES} ${WEBROOT_LANG_FILES} ${APP_ICON_ICNS})
target_include_directories(OmniMTP PRIVATE target_include_directories(OmniMTP PRIVATE
${CMAKE_SOURCE_DIR}/src ${CMAKE_SOURCE_DIR}/src
@@ -111,7 +120,7 @@ target_include_directories(OmniMTP PRIVATE
) )
target_link_libraries(OmniMTP PRIVATE target_link_libraries(OmniMTP PRIVATE
usb_static usb_static
${FW_APPKIT} ${FW_FOUNDATION} ${FW_METAL} ${FW_WEBKIT} ${FW_CORETEXT} ${FW_APPKIT} ${FW_FOUNDATION} ${FW_METAL} ${FW_WEBKIT} ${FW_CORETEXT} ${FW_UTTYPES}
) )
target_compile_options(OmniMTP PRIVATE target_compile_options(OmniMTP PRIVATE
@@ -125,7 +134,7 @@ set_target_properties(OmniMTP PROPERTIES
MACOSX_BUNDLE_BUNDLE_NAME "OmniMTP" MACOSX_BUNDLE_BUNDLE_NAME "OmniMTP"
MACOSX_BUNDLE_BUNDLE_VERSION "${PROJECT_VERSION}" MACOSX_BUNDLE_BUNDLE_VERSION "${PROJECT_VERSION}"
MACOSX_BUNDLE_SHORT_VERSION_STRING "${PROJECT_VERSION}" MACOSX_BUNDLE_SHORT_VERSION_STRING "${PROJECT_VERSION}"
MACOSX_BUNDLE_COPYRIGHT "Copyright © 2025 OmniMTP" MACOSX_BUNDLE_COPYRIGHT "Copyright © 2026 NiklasCFW"
MACOSX_BUNDLE_INFO_PLIST "${CMAKE_SOURCE_DIR}/cmake/Info.plist.in" MACOSX_BUNDLE_INFO_PLIST "${CMAKE_SOURCE_DIR}/cmake/Info.plist.in"
MACOSX_BUNDLE_ICON_FILE AppIcon MACOSX_BUNDLE_ICON_FILE AppIcon
) )

View File

@@ -7,7 +7,6 @@
#include <filesystem> #include <filesystem>
#include <memory> #include <memory>
#include <mutex> #include <mutex>
#include <set>
#include <string> #include <string>
#include <thread> #include <thread>
#include <vector> #include <vector>
@@ -16,22 +15,6 @@ namespace omniMTP {
namespace fs = std::filesystem; 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 { struct RemoteEntry {
uint32_t handle = 0; uint32_t handle = 0;
uint32_t storage_id = 0; uint32_t storage_id = 0;
@@ -60,9 +43,6 @@ public:
// ── Web bridge API ──────────────────────────────────────────────────────── // ── Web bridge API ────────────────────────────────────────────────────────
std::string state_json() const; 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(uint32_t handle, uint32_t storage_id, const std::string& name);
void navigate_remote_up(); void navigate_remote_up();
void navigate_remote_to(size_t idx); void navigate_remote_to(size_t idx);
@@ -71,8 +51,10 @@ public:
void do_upload(const std::string& src_path); 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); 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). // Blocking download used by Finder drag-export (NSFilePromiseProvider).
// When is_dir is true, dest_path is a folder that will be created and filled recursively.
void download_remote_to_path(uint32_t handle, uint32_t storage_id, void download_remote_to_path(uint32_t handle, uint32_t storage_id,
const std::string& dest_path, uint64_t size); const std::string& dest_path, uint64_t size,
bool is_dir = false);
void do_cancel(const std::string& id); void do_cancel(const std::string& id);
void do_cancel_all(); void do_cancel_all();
void do_delete(uint32_t handle); void do_delete(uint32_t handle);
@@ -81,21 +63,15 @@ public:
bool connected() const { return connected_.load(); } bool connected() const { return connected_.load(); }
// Drop-zone rects (CSS pixels, origin top-left) updated by the web UI. // Drop-zone rect (CSS pixels, origin top-left) updated by the web UI.
void update_drop_zones(double rx, double ry, double rw, double rh, void update_drop_zone(double x, double y, double w, double h);
double lx, double ly, double lw, double lh); // Returns "remote" or "none".
// Returns "remote", "local", or "none".
std::string drop_target_at(double x, double y) const; std::string drop_target_at(double x, double y) const;
// Called from the OS drag-and-drop callback (may be on a different thread). // Called from the OS drag-and-drop callback (may be on a different thread).
void enqueue_finder_drop(const std::string& path); void enqueue_finder_drop(const std::string& path);
private: private:
// ── Local filesystem ──────────────────────────────────────────────────────
void refresh_local(const fs::path& path);
void refresh_local_drives();
std::string active_local_drive_path() const;
// ── Remote filesystem ───────────────────────────────────────────────────── // ── Remote filesystem ─────────────────────────────────────────────────────
void refresh_remote(); void refresh_remote();
@@ -103,6 +79,8 @@ private:
void start_download(uint32_t handle, uint32_t storage_id, void start_download(uint32_t handle, uint32_t storage_id,
const std::string& filename, uint64_t size); const std::string& filename, uint64_t size);
void start_upload(const fs::path& src_path, uint64_t file_size); void start_upload(const fs::path& src_path, uint64_t file_size);
// Recursively download an MTP association (folder) into dest (must hold device_mtx_).
void download_remote_tree(uint32_t handle, uint32_t storage_id, const fs::path& dest);
// ── Device connection loop (runs on background thread) ──────────────────── // ── Device connection loop (runs on background thread) ────────────────────
void device_monitor_loop(std::stop_token st); void device_monitor_loop(std::stop_token st);
@@ -110,7 +88,9 @@ private:
void on_device_disconnected(); void on_device_disconnected();
// ── Status bar ──────────────────────────────────────────────────────────── // ── Status bar ────────────────────────────────────────────────────────────
void set_status(const std::string& msg, float duration_sec = 5.f); // key is an i18n id (e.g. "status.connected"); args fill {name}/{error}/… placeholders.
void set_status(const std::string& key, std::vector<std::string> args = {},
float duration_sec = 5.f);
// ── Finder drop processing (called from state_json) ─────────────────────── // ── Finder drop processing (called from state_json) ───────────────────────
void process_finder_drops(); void process_finder_drops();
@@ -128,11 +108,8 @@ private:
std::vector<std::pair<uint32_t, mtp::StorageInfo>> storages_; std::vector<std::pair<uint32_t, mtp::StorageInfo>> storages_;
uint32_t active_storage_ = 0; uint32_t active_storage_ = 0;
// ── State: local panel ──────────────────────────────────────────────────── // Download destination (defaults to home directory).
fs::path local_path_; fs::path download_path_;
std::vector<LocalEntry> local_entries_;
std::vector<LocalDrive> local_drives_;
mutable std::atomic<bool> local_needs_refresh_{false};
// ── State: remote panel ─────────────────────────────────────────────────── // ── State: remote panel ───────────────────────────────────────────────────
std::vector<NavEntry> remote_nav_stack_; std::vector<NavEntry> remote_nav_stack_;
@@ -140,19 +117,18 @@ private:
mutable bool remote_needs_refresh_ = true; mutable bool remote_needs_refresh_ = true;
bool remote_refreshing_ = false; bool remote_refreshing_ = false;
// Status bar message with timeout // Status bar message with timeout (i18n key + args)
std::string status_msg_; std::string status_key_;
std::vector<std::string> status_args_;
float status_msg_timer_ = 0.f; float status_msg_timer_ = 0.f;
// Files/folders dropped from macOS Finder. // Files dropped from macOS Finder.
// Written from the drop callback thread, read + cleared elsewhere.
std::vector<std::string> finder_drops_; std::vector<std::string> finder_drops_;
std::mutex finder_drops_mtx_; std::mutex finder_drops_mtx_;
struct DropZone { double x = 0, y = 0, w = 0, h = 0; }; struct DropZone { double x = 0, y = 0, w = 0, h = 0; };
DropZone remote_drop_zone_; DropZone remote_drop_zone_;
DropZone local_drop_zone_; bool drop_zone_valid_ = false;
bool drop_zones_valid_ = false;
}; };
} // namespace omniMTP } // namespace omniMTP

View File

@@ -7,7 +7,6 @@
#include <algorithm> #include <algorithm>
#include <chrono> #include <chrono>
#include <thread> #include <thread>
#include <sys/stat.h>
#include <cstdlib> #include <cstdlib>
#include <cstring> #include <cstring>
@@ -16,29 +15,6 @@ namespace omniMTP {
// ─── Static helpers ─────────────────────────────────────────────────────────── // ─── 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) { static std::string jstr(const std::string& s) {
std::string r; r.reserve(s.size()+4); std::string r; r.reserve(s.size()+4);
for (char c : s) { for (char c : s) {
@@ -76,12 +52,11 @@ static std::string fmt_eta(double s) {
} }
// ─── App lifecycle ──────────────────────────────────────────────────────────── // ─── App lifecycle ────────────────────────────────────────────────────────────
App::App() : local_path_(fs::path(std::getenv("HOME") ? std::getenv("HOME") : "/")) {} App::App() : download_path_(fs::path(std::getenv("HOME") ? std::getenv("HOME") : "/")) {}
App::~App() { shutdown(); } App::~App() { shutdown(); }
void App::init() { void App::init() {
local_path_ = fs::path(std::getenv("HOME") ? std::getenv("HOME") : "/"); download_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); }); monitor_thread_ = std::jthread([this](std::stop_token st) { device_monitor_loop(st); });
} }
@@ -106,9 +81,7 @@ void App::device_monitor_loop(std::stop_token st) {
engine_ = std::make_unique<transfer::TransferEngine>(*session_); engine_ = std::make_unique<transfer::TransferEngine>(*session_);
engine_->set_completion_handler([this](const transfer::TransferItem& item) { engine_->set_completion_handler([this](const transfer::TransferItem& item) {
if (item.state.load() != transfer::State::Done) return; if (item.state.load() != transfer::State::Done) return;
if (item.direction == transfer::Direction::Download) if (item.direction != transfer::Direction::Download)
local_needs_refresh_.store(true);
else
remote_needs_refresh_ = true; remote_needs_refresh_ = true;
}); });
} }
@@ -162,7 +135,7 @@ void App::on_device_connected(mtp::DeviceInfo di) {
} }
connected_.store(true); connected_.store(true);
remote_needs_refresh_ = true; remote_needs_refresh_ = true;
set_status(std::format("Connected {}", device_info_.model)); set_status("status.connected", {device_info_.model});
refresh_remote(); refresh_remote();
} }
@@ -178,104 +151,7 @@ void App::on_device_disconnected() {
} }
connected_.store(false); connected_.store(false);
remote_needs_refresh_ = false; remote_needs_refresh_ = false;
set_status("Switch disconnected"); set_status("status.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 ──────────────────────────────────────────────────────── // ─── Remote filesystem ────────────────────────────────────────────────────────
@@ -305,7 +181,7 @@ void App::refresh_remote() {
return a.name < b.name; return a.name < b.name;
}); });
} catch (const mtp::MTPException& e) { } catch (const mtp::MTPException& e) {
set_status(std::format("Error listing: {}", e.what())); set_status("status.listing_error", {e.what()});
} }
remote_refreshing_ = false; remote_refreshing_ = false;
remote_needs_refresh_ = false; remote_needs_refresh_ = false;
@@ -316,60 +192,48 @@ void App::start_download(uint32_t handle, uint32_t storage_id,
const std::string& filename, uint64_t size) { const std::string& filename, uint64_t size) {
if (!engine_) return; if (!engine_) return;
engine_->enqueue_download(handle, storage_id, engine_->enqueue_download(handle, storage_id,
(local_path_ / filename).string(), filename, size); (download_path_ / filename).string(), filename, size);
set_status(std::format("Downloading {}", filename)); set_status("status.downloading", {filename});
} }
void App::start_upload(const fs::path& src, uint64_t sz) { void App::start_upload(const fs::path& src, uint64_t sz) {
if (!engine_ || remote_nav_stack_.empty()) return; if (!engine_ || remote_nav_stack_.empty()) return;
const auto& top = remote_nav_stack_.back(); const auto& top = remote_nav_stack_.back();
engine_->enqueue_upload(src.string(), top.handle, top.storage_id, engine_->enqueue_upload(src.string(), top.handle, top.storage_id,
src.filename().string(), sz); src.filename().string(), sz);
set_status(std::format("Uploading {}", src.filename().string())); set_status("status.uploading", {src.filename().string()});
} }
void App::set_status(const std::string& msg, float dur) { void App::set_status(const std::string& key, std::vector<std::string> args, float dur) {
status_msg_ = msg; status_msg_timer_ = dur; status_key_ = key;
status_args_ = std::move(args);
status_msg_timer_ = dur;
} }
void App::enqueue_finder_drop(const std::string& path) { void App::enqueue_finder_drop(const std::string& path) {
std::lock_guard lock(finder_drops_mtx_); std::lock_guard lock(finder_drops_mtx_);
finder_drops_.push_back(path); finder_drops_.push_back(path);
} }
void App::update_drop_zones(double rx, double ry, double rw, double rh, void App::update_drop_zone(double x, double y, double w, double h) {
double lx, double ly, double lw, double lh) { remote_drop_zone_ = {x, y, w, h};
remote_drop_zone_ = {rx, ry, rw, rh}; drop_zone_valid_ = w > 0 && h > 0;
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 { std::string App::drop_target_at(double x, double y) const {
if (!drop_zones_valid_) return "none"; if (!drop_zone_valid_) return "none";
auto inside = [](const DropZone& z, double px, double py) { const auto& z = remote_drop_zone_;
return px >= z.x && px <= z.x + z.w && py >= z.y && py <= z.y + z.h; if (x >= z.x && x <= z.x + z.w && y >= z.y && y <= z.y + z.h) return "remote";
};
if (inside(remote_drop_zone_, x, y)) return "remote";
if (inside(local_drop_zone_, x, y)) return "local";
return "none"; return "none";
} }
// ─── Web bridge API ─────────────────────────────────────────────────────────── // ─── Web bridge API ───────────────────────────────────────────────────────────
std::string App::current_local_path() const { return local_path_.string(); }
std::string App::state_json() const { std::string App::state_json() const {
// Process any pending finder drops
{ {
const_cast<App*>(this)->process_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()) { if (remote_needs_refresh_ && connected_.load()) {
remote_needs_refresh_ = false; remote_needs_refresh_ = false;
const_cast<App*>(this)->refresh_remote(); 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(); bool conn = connected_.load();
std::string dn; std::string dn;
@@ -403,22 +267,6 @@ std::string App::state_json() const {
j+="{\"handle\":"+std::to_string(nav[i].handle)+",\"storageId\":"+std::to_string(nav[i].storage_id)+",\"label\":\""+jstr(nav[i].label)+"\"}"; 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+=","; 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\":["; j+="],\"remoteRefreshing\":"+std::string(refreshing?"true":"false")+",\"remoteFiles\":[";
for (size_t i=0;i<remote_entries_.size();++i) { for (size_t i=0;i<remote_entries_.size();++i) {
auto& e=remote_entries_[i]; auto& e=remote_entries_[i];
@@ -443,9 +291,13 @@ std::string App::state_json() const {
if (i+1<items.size()) j+=","; if (i+1<items.size()) j+=",";
} }
} }
j+="],\"statusMessage\":\""+jstr(status_msg_)+"\","; j+="],\"statusKey\":\""+jstr(status_key_)+"\",\"statusArgs\":[";
for (size_t i=0;i<status_args_.size();++i) {
j+="\""+jstr(status_args_[i])+"\"";
if (i+1<status_args_.size()) j+=",";
}
char tm[16]; snprintf(tm,sizeof(tm),"%.2f",(double)status_msg_timer_); char tm[16]; snprintf(tm,sizeof(tm),"%.2f",(double)status_msg_timer_);
j+="\"statusTimer\":"+std::string(tm)+"}"; j+="],\"statusTimer\":"+std::string(tm)+"}";
return j; return j;
} }
@@ -458,22 +310,14 @@ void App::process_finder_drops() {
for (auto& dropped : drops) { for (auto& dropped : drops) {
fs::path p(dropped); fs::path p(dropped);
std::error_code ec; std::error_code ec;
if (fs::is_directory(p, ec)) { if (fs::is_directory(p, ec)) continue;
refresh_local(p); if (!ec && connected_.load()) {
} else if (!ec && connected_.load()) {
auto sz = fs::file_size(p, ec); auto sz = fs::file_size(p, ec);
if (!ec) start_upload(p, sz); 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) { void App::navigate_remote(uint32_t h, uint32_t s, const std::string& name) {
remote_nav_stack_.push_back({h, s, name}); remote_nav_stack_.push_back({h, s, name});
refresh_remote(); refresh_remote();
@@ -510,10 +354,36 @@ void App::do_upload(const std::string& src) {
void App::do_download(uint32_t h, uint32_t s, const std::string& fn, uint64_t sz) { void App::do_download(uint32_t h, uint32_t s, const std::string& fn, uint64_t sz) {
start_download(h, s, fn, sz); start_download(h, s, fn, sz);
} }
void App::download_remote_tree(uint32_t handle, uint32_t storage_id, const fs::path& dest) {
std::error_code ec;
fs::create_directories(dest, ec);
if (ec) throw std::runtime_error("Failed to create folder: " + dest.string());
auto child_handles = ops_->get_object_handles(storage_id, handle);
std::atomic<bool> cancel{false};
mtp::ProgressFn noop = [](uint64_t, uint64_t) {};
for (uint32_t h : child_handles) {
auto oi = ops_->get_object_info(h);
if (oi.filename.empty()) continue;
fs::path child = dest / oi.filename;
if (oi.is_directory()) {
download_remote_tree(h, storage_id, child);
} else {
ops_->get_object(h, child.string(), oi.compressed_size, noop, cancel);
}
}
}
void App::download_remote_to_path(uint32_t handle, uint32_t storage_id, void App::download_remote_to_path(uint32_t handle, uint32_t storage_id,
const std::string& dest_path, uint64_t size) { const std::string& dest_path, uint64_t size,
bool is_dir) {
std::lock_guard lock(device_mtx_); std::lock_guard lock(device_mtx_);
if (!ops_) throw std::runtime_error("Not connected"); if (!ops_) throw std::runtime_error("Not connected");
if (is_dir) {
download_remote_tree(handle, storage_id, fs::path(dest_path));
return;
}
std::atomic<bool> cancel{false}; std::atomic<bool> cancel{false};
mtp::ProgressFn noop = [](uint64_t, uint64_t) {}; mtp::ProgressFn noop = [](uint64_t, uint64_t) {};
ops_->get_object(handle, dest_path, size, noop, cancel); ops_->get_object(handle, dest_path, size, noop, cancel);

View File

@@ -1,13 +1,230 @@
#include "WebUI.hpp" #include "WebUI.hpp"
#import <Cocoa/Cocoa.h> #import <Cocoa/Cocoa.h>
#import <WebKit/WebKit.h> #import <WebKit/WebKit.h>
#import <UniformTypeIdentifiers/UniformTypeIdentifiers.h>
#include <string> #include <string>
#include <cmath> #include <cmath>
// ─── Locale JSON helpers ──────────────────────────────────────────────────────
static BOOL OmniLangCodeOK(NSString* code) {
if (code.length == 0 || code.length > 32) return NO;
NSCharacterSet* allowed = [NSCharacterSet characterSetWithCharactersInString:
@"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-"];
return [code rangeOfCharacterFromSet:allowed.invertedSet].location == NSNotFound;
}
static NSURL* OmniFindWebrootURL(void) {
NSBundle* bundle = NSBundle.mainBundle;
NSURL* indexURL = [bundle URLForResource:@"index" withExtension:@"html" subdirectory:@"webroot"];
if (indexURL) return indexURL.URLByDeletingLastPathComponent;
NSString* base = bundle.executableURL.URLByDeletingLastPathComponent.path;
NSArray<NSString*>* candidates = @[
[base stringByAppendingPathComponent:@"webroot/index.html"],
[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])
return [NSURL fileURLWithPath:resolved.stringByDeletingLastPathComponent];
}
return nil;
}
static NSURL* OmniBundleConfigURL(void) {
NSString* res = NSBundle.mainBundle.resourcePath;
if (!res) return nil;
return [NSURL fileURLWithPath:[res stringByAppendingPathComponent:@"config.json"]];
}
static NSURL* OmniFallbackConfigURL(void) {
NSArray* dirs = NSSearchPathForDirectoriesInDomains(NSApplicationSupportDirectory,
NSUserDomainMask, YES);
if (!dirs.count) return nil;
NSString* dir = [dirs[0] stringByAppendingPathComponent:@"OmniMTP"];
[[NSFileManager defaultManager] createDirectoryAtPath:dir
withIntermediateDirectories:YES
attributes:nil
error:nil];
return [NSURL fileURLWithPath:[dir stringByAppendingPathComponent:@"config.json"]];
}
static NSDictionary* OmniLoadConfig(void) {
NSMutableArray<NSURL*>* urls = [NSMutableArray array];
NSURL* bundleURL = OmniBundleConfigURL();
NSURL* fallbackURL = OmniFallbackConfigURL();
if (bundleURL) [urls addObject:bundleURL];
if (fallbackURL) [urls addObject:fallbackURL];
for (NSURL* url in urls) {
NSData* data = [NSData dataWithContentsOfURL:url];
if (!data) continue;
id obj = [NSJSONSerialization JSONObjectWithData:data options:0 error:nil];
if ([obj isKindOfClass:[NSDictionary class]]) return obj;
}
return @{};
}
static BOOL OmniSaveConfig(NSDictionary* cfg) {
NSData* data = [NSJSONSerialization dataWithJSONObject:cfg
options:NSJSONWritingPrettyPrinted
error:nil];
if (!data) return NO;
// Prefer writing inside the .app bundle (Resources/config.json).
NSURL* bundleURL = OmniBundleConfigURL();
if (bundleURL && [data writeToURL:bundleURL atomically:YES]) return YES;
// Installed apps are often not writable; keep a working fallback.
NSURL* fallback = OmniFallbackConfigURL();
return fallback && [data writeToURL:fallback atomically:YES];
}
static NSString* OmniConfiguredLanguageOverride(void) {
NSString* lang = OmniLoadConfig()[@"language"];
if (![lang isKindOfClass:[NSString class]]) return nil;
if (lang.length == 0 || [lang isEqualToString:@"system"]) return nil;
return OmniLangCodeOK(lang) ? lang : nil;
}
static NSDictionary* OmniLoadLangPack(NSURL* webroot, NSString* code) {
if (!webroot || !OmniLangCodeOK(code)) return nil;
NSURL* url = [webroot URLByAppendingPathComponent:
[NSString stringWithFormat:@"lang/%@.json", code]];
NSData* data = [NSData dataWithContentsOfURL:url];
if (!data) return nil;
id obj = [NSJSONSerialization JSONObjectWithData:data options:0 error:nil];
return [obj isKindOfClass:[NSDictionary class]] ? obj : nil;
}
static NSArray<NSString*>* OmniLocaleCandidates(void) {
NSString* override = OmniConfiguredLanguageOverride();
if (override) return @[override, @"en"];
NSMutableArray<NSString*>* out = [NSMutableArray array];
void (^add)(NSString*) = ^(NSString* c) {
if (c.length && ![out containsObject:c]) [out addObject:c];
};
for (NSString* raw in (NSLocale.preferredLanguages ?: @[@"en"])) {
NSString* tag = [raw stringByReplacingOccurrencesOfString:@"_" withString:@"-"];
add(tag);
NSArray* parts = [tag componentsSeparatedByString:@"-"];
NSString* primary = [(NSString*)parts.firstObject lowercaseString];
if ([primary isEqualToString:@"zh"]) {
NSString* script = parts.count > 1 ? [(NSString*)parts[1] lowercaseString] : @"";
if ([script hasPrefix:@"hant"] || [script isEqualToString:@"tw"] ||
[script isEqualToString:@"hk"] || [script isEqualToString:@"mo"])
add(@"zh-Hant");
else
add(@"zh-Hans");
add(@"zh");
} else if (primary.length) {
add(primary);
}
}
add(@"en");
return out;
}
static NSArray<NSString*>* OmniAvailableLanguageCodes(NSURL* webroot) {
NSMutableArray<NSString*>* codes = [NSMutableArray array];
NSURL* langDir = [webroot URLByAppendingPathComponent:@"lang"];
NSArray* files = [[NSFileManager defaultManager] contentsOfDirectoryAtURL:langDir
includingPropertiesForKeys:nil
options:NSDirectoryEnumerationSkipsHiddenFiles
error:nil];
for (NSURL* file in files) {
if (![file.pathExtension isEqualToString:@"json"]) continue;
NSString* code = file.URLByDeletingPathExtension.lastPathComponent;
if (OmniLangCodeOK(code)) [codes addObject:code];
}
[codes sortUsingSelector:@selector(compare:)];
if (![codes containsObject:@"en"]) [codes insertObject:@"en" atIndex:0];
return codes;
}
static NSString* OmniLanguageDisplayName(NSString* code) {
static NSDictionary* names;
static dispatch_once_t once;
dispatch_once(&once, ^{
names = @{
@"en": @"English",
@"de": @"Deutsch",
@"fr": @"Français",
@"es": @"Español",
@"it": @"Italiano",
@"nl": @"Nederlands",
@"pt": @"Português",
@"ru": @"Русский",
@"ja": @"日本語",
@"zh-Hans": @"简体中文",
@"zh-Hant": @"繁體中文",
};
});
return names[code] ?: code;
}
static NSDictionary* OmniBuildI18nPayload(NSURL* webroot) {
NSDictionary* fallback = OmniLoadLangPack(webroot, @"en") ?: @{};
NSString* lang = @"en";
NSDictionary* strings = fallback;
for (NSString* code in OmniLocaleCandidates()) {
if ([code isEqualToString:@"en"]) {
lang = @"en";
strings = fallback;
break;
}
NSDictionary* pack = OmniLoadLangPack(webroot, code);
if (pack) {
lang = code;
NSMutableDictionary* merged = [fallback mutableCopy];
[merged addEntriesFromDictionary:pack];
strings = merged;
break;
}
}
return @{
@"lang": lang,
@"fallback": fallback,
@"strings": strings,
};
}
static NSString* OmniI18nBootstrapScript(NSURL* webroot) {
NSDictionary* payload = OmniBuildI18nPayload(webroot);
NSData* data = [NSJSONSerialization dataWithJSONObject:payload options:0 error:nil];
NSString* json = data ? [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding] : @"{}";
return [NSString stringWithFormat:@"window.__OMNI_I18N__=%@;", json];
}
static NSString* OmniMenuString(NSString* key) {
NSDictionary* payload = OmniBuildI18nPayload(OmniFindWebrootURL());
NSDictionary* strings = payload[@"strings"];
NSString* val = [strings isKindOfClass:[NSDictionary class]] ? strings[key] : nil;
return [val isKindOfClass:[NSString class]] ? val : key;
}
static NSString* OmniMenuFormat(NSString* key, NSDictionary<NSString*, NSString*>* vars) {
NSString* s = OmniMenuString(key);
for (NSString* k in vars)
s = [s stringByReplacingOccurrencesOfString:[NSString stringWithFormat:@"{%@}", k]
withString:vars[k] ?: @""];
return s;
}
// ─── Forward declarations ───────────────────────────────────────────────────── // ─── Forward declarations ─────────────────────────────────────────────────────
@class OmniBridge; @class OmniBridge;
@class OmniAppDelegate; @class OmniAppDelegate;
@interface OmniWebView : WKWebView <NSDraggingSource>
- (instancetype)initWithFrame:(NSRect)frame
configuration:(WKWebViewConfiguration*)config
app:(omniMTP::App*)app;
- (void)prepareExportDragWithFiles:(NSArray<NSDictionary*>*)files
atWebPoint:(NSPoint)webPoint;
- (void)beginExportDragWithFiles:(NSArray<NSDictionary*>*)files
atWebPoint:(NSPoint)webPoint;
@end
// ─── OmniBridge: JS → C++ message handler ───────────────────────────────────── // ─── OmniBridge: JS → C++ message handler ─────────────────────────────────────
@interface OmniBridge : NSObject <WKScriptMessageHandler, WKNavigationDelegate, WKUIDelegate> @interface OmniBridge : NSObject <WKScriptMessageHandler, WKNavigationDelegate, WKUIDelegate>
- (instancetype)initWithApp:(omniMTP::App*)app webView:(WKWebView*)webView; - (instancetype)initWithApp:(omniMTP::App*)app webView:(WKWebView*)webView;
@@ -39,6 +256,15 @@
long long rid = msgId.longLongValue; long long rid = msgId.longLongValue;
// Locale + export must run immediately (sync) so JS can boot strings / arm drag.
if ([action isEqualToString:@"prepare_export_drag"] ||
[action isEqualToString:@"begin_export_drag"] ||
[action isEqualToString:@"get_preferred_languages"] ||
[action isEqualToString:@"get_lang_pack"]) {
[self handleAction:action body:body responseId:rid];
return;
}
// All dispatched on main thread already (WKWebView guarantee), but we // All dispatched on main thread already (WKWebView guarantee), but we
// use dispatch_async to avoid blocking the JS engine during heavy ops. // use dispatch_async to avoid blocking the JS engine during heavy ops.
dispatch_async(dispatch_get_main_queue(), ^{ dispatch_async(dispatch_get_main_queue(), ^{
@@ -56,16 +282,6 @@
[self sendRawResult:result responseId:rid]; [self sendRawResult:result responseId:rid];
return; 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"]) { } else if ([action isEqualToString:@"navigate_remote"]) {
NSNumber* handle = body[@"handle"]; NSNumber* handle = body[@"handle"];
NSNumber* storageId = body[@"storageId"]; NSNumber* storageId = body[@"storageId"];
@@ -139,15 +355,63 @@
}]; }];
}); });
} else if ([action isEqualToString:@"get_preferred_languages"]) {
NSArray<NSString*>* langs = NSLocale.preferredLanguages ?: @[@"en"];
NSData* data = [NSJSONSerialization dataWithJSONObject:langs options:0 error:nil];
NSString* json = data ? [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding] : @"[\"en\"]";
[self sendRawResult:json responseId:rid];
return;
} else if ([action isEqualToString:@"get_lang_pack"]) {
NSString* code = body[@"code"];
if (![code isKindOfClass:[NSString class]] || !OmniLangCodeOK(code)) {
[self sendNullResult:rid];
return;
}
NSURL* webroot = OmniFindWebrootURL();
NSDictionary* pack = OmniLoadLangPack(webroot, code);
if (!pack) {
[self sendNullResult:rid];
return;
}
NSData* data = [NSJSONSerialization dataWithJSONObject:pack options:0 error:nil];
NSString* json = data ? [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding] : @"null";
[self sendRawResult:json responseId:rid];
return;
} else if ([action isEqualToString:@"confirm"]) {
NSString* message = body[@"message"] ?: @"";
NSString* okTitle = body[@"ok"] ?: @"OK";
NSString* cancelTitle = body[@"cancel"] ?: @"Cancel";
NSAlert* alert = [[NSAlert alloc] init];
alert.messageText = message;
[alert addButtonWithTitle:okTitle];
[alert addButtonWithTitle:cancelTitle];
BOOL ok = [alert runModal] == NSAlertFirstButtonReturn;
[self sendRawResult:(ok ? @"true" : @"false") responseId:rid];
return;
} else if ([action isEqualToString:@"update_drop_zones"]) { } else if ([action isEqualToString:@"update_drop_zones"]) {
NSDictionary* remote = body[@"remote"]; NSDictionary* remote = body[@"remote"];
NSDictionary* local = body[@"local"]; if ([remote isKindOfClass:[NSDictionary class]]) {
if ([remote isKindOfClass:[NSDictionary class]] && [local isKindOfClass:[NSDictionary class]]) { _app->update_drop_zone(
_app->update_drop_zones(
[remote[@"x"] doubleValue], [remote[@"y"] doubleValue], [remote[@"x"] doubleValue], [remote[@"y"] doubleValue],
[remote[@"w"] doubleValue], [remote[@"h"] doubleValue], [remote[@"w"] doubleValue], [remote[@"h"] doubleValue]);
[local[@"x"] doubleValue], [local[@"y"] doubleValue], }
[local[@"w"] doubleValue], [local[@"h"] doubleValue]);
} else if ([action isEqualToString:@"prepare_export_drag"] ||
[action isEqualToString:@"begin_export_drag"]) {
NSArray* files = body[@"files"];
NSNumber* x = body[@"x"];
NSNumber* y = body[@"y"];
if ([files isKindOfClass:[NSArray class]] && files.count > 0 &&
[x isKindOfClass:[NSNumber class]] && [y isKindOfClass:[NSNumber class]] &&
[_webView isKindOfClass:[OmniWebView class]]) {
NSPoint pt = NSMakePoint(x.doubleValue, y.doubleValue);
if ([action isEqualToString:@"prepare_export_drag"])
[(OmniWebView*)_webView prepareExportDragWithFiles:files atWebPoint:pt];
else
[(OmniWebView*)_webView beginExportDragWithFiles:files atWebPoint:pt];
} }
} }
@@ -193,9 +457,10 @@ runJavaScriptConfirmPanelWithMessage:(NSString*)message
initiatedByFrame:(WKFrameInfo*)frame initiatedByFrame:(WKFrameInfo*)frame
completionHandler:(void (^)(BOOL result))completionHandler { completionHandler:(void (^)(BOOL result))completionHandler {
(void)webView; (void)frame; (void)webView; (void)frame;
// Prefer the bridge `confirm` action from JS so button titles are localized.
NSAlert* alert = [[NSAlert alloc] init]; NSAlert* alert = [[NSAlert alloc] init];
alert.messageText = message; alert.messageText = message;
[alert addButtonWithTitle:@"Delete"]; [alert addButtonWithTitle:@"OK"];
[alert addButtonWithTitle:@"Cancel"]; [alert addButtonWithTitle:@"Cancel"];
NSModalResponse response = [alert runModal]; NSModalResponse response = [alert runModal];
completionHandler(response == NSAlertFirstButtonReturn); completionHandler(response == NSAlertFirstButtonReturn);
@@ -252,6 +517,7 @@ static const CGFloat kTrafficLightInset = 72.0;
@property (nonatomic, assign) uint32_t handle; @property (nonatomic, assign) uint32_t handle;
@property (nonatomic, assign) uint32_t storageId; @property (nonatomic, assign) uint32_t storageId;
@property (nonatomic, assign) uint64_t size; @property (nonatomic, assign) uint64_t size;
@property (nonatomic, assign) BOOL isDir;
@property (nonatomic, copy) NSString* filename; @property (nonatomic, copy) NSString* filename;
@end @end
@@ -261,12 +527,17 @@ static const CGFloat kTrafficLightInset = 72.0;
writePromiseToURL:(NSURL*)url writePromiseToURL:(NSURL*)url
completionHandler:(void (^)(NSError* _Nullable))completionHandler { completionHandler:(void (^)(NSError* _Nullable))completionHandler {
(void)provider; (void)provider;
@try { try {
if (_app) _app->download_remote_to_path(_handle, _storageId, url.path.UTF8String, _size); if (_app)
_app->download_remote_to_path(_handle, _storageId, url.path.UTF8String, _size, _isDir);
completionHandler(nil); completionHandler(nil);
} @catch (NSException* ex) { } catch (const std::exception& ex) {
NSString* msg = [NSString stringWithUTF8String:ex.what()];
completionHandler([NSError errorWithDomain:@"OmniMTP" code:1 completionHandler([NSError errorWithDomain:@"OmniMTP" code:1
userInfo:@{NSLocalizedDescriptionKey: ex.reason ?: @"Download failed"}]); userInfo:@{NSLocalizedDescriptionKey: msg ?: @"Download failed"}]);
} catch (...) {
completionHandler([NSError errorWithDomain:@"OmniMTP" code:1
userInfo:@{NSLocalizedDescriptionKey: @"Download failed"}]);
} }
} }
@@ -284,20 +555,15 @@ static const CGFloat kTrafficLightInset = 72.0;
@end @end
// ─── OmniWebView: native drag-and-drop ──────────────────────────────────────── // ─── OmniWebView: native drag-and-drop ────────────────────────────────────────
@interface OmniWebView : WKWebView <NSDraggingSource>
- (instancetype)initWithFrame:(NSRect)frame
configuration:(WKWebViewConfiguration*)config
app:(omniMTP::App*)app;
@end
@implementation OmniWebView { @implementation OmniWebView {
omniMTP::App* _app; omniMTP::App* _app;
BOOL _remoteDragHighlighted; BOOL _remoteDragHighlighted;
BOOL _localDragHighlighted;
NSPoint _mouseDownWebPoint;
NSString* _pendingDragJSON;
BOOL _nativeDragStarted;
NSArray<NSDictionary*>* _exportDragEntries; NSArray<NSDictionary*>* _exportDragEntries;
NSMutableArray* _exportDelegates; // keep alive for NSFilePromiseProvider
NSArray<NSDictionary*>* _pendingExportFiles;
NSPoint _pendingExportViewPoint; // AppKit view coords (bottom-left origin)
BOOL _exportDragStarted;
id _exportEventMonitor;
} }
- (instancetype)initWithFrame:(NSRect)frame - (instancetype)initWithFrame:(NSRect)frame
@@ -306,15 +572,19 @@ static const CGFloat kTrafficLightInset = 72.0;
if ((self = [super initWithFrame:frame configuration:config])) { if ((self = [super initWithFrame:frame configuration:config])) {
_app = app; _app = app;
_remoteDragHighlighted = NO; _remoteDragHighlighted = NO;
_localDragHighlighted = NO; _exportDelegates = [NSMutableArray array];
[self registerForDraggedTypes:@[NSPasteboardTypeFileURL]]; [self registerForDraggedTypes:@[NSPasteboardTypeFileURL]];
} }
return self; return self;
} }
- (NSPoint)webPointFromEvent:(NSEvent*)event { - (void)clearPendingExport {
NSPoint loc = [self convertPoint:event.locationInWindow fromView:nil]; if (_exportEventMonitor) {
return NSMakePoint(loc.x, self.bounds.size.height - loc.y); [NSEvent removeMonitor:_exportEventMonitor];
_exportEventMonitor = nil;
}
_pendingExportFiles = nil;
_exportDragStarted = NO;
} }
- (NSPoint)webPointFromDrag:(id<NSDraggingInfo>)sender { - (NSPoint)webPointFromDrag:(id<NSDraggingInfo>)sender {
@@ -347,92 +617,141 @@ static const CGFloat kTrafficLightInset = 72.0;
[self evaluateJavaScript:js completionHandler:nil]; [self evaluateJavaScript:js completionHandler:nil];
} }
- (void)setLocalDragOver:(BOOL)over { - (void)prepareExportDragWithFiles:(NSArray<NSDictionary*>*)files
if (_localDragHighlighted == over) return; atWebPoint:(NSPoint)webPoint {
_localDragHighlighted = over; (void)webPoint; // JS coords can disagree with AppKit; use the live mouse event.
NSString* js = over if (!_app || !_app->connected() || files.count == 0) return;
? @"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 { [self clearPendingExport];
_mouseDownWebPoint = [self webPointFromEvent:event]; _pendingExportFiles = [files copy];
_pendingDragJSON = nil; _exportDragStarted = NO;
_nativeDragStarted = NO;
[self evaluateJavaScript:@"window.omniClearSelection&&window.omniClearSelection()" // Prefer the real mouse location so the drag image anchors under the cursor.
completionHandler:nil]; NSEvent* down = [NSApp currentEvent];
NSString* js = [NSString stringWithFormat: if (down)
@"window.omniRemoteDragFilesAt(%f,%f)", _mouseDownWebPoint.x, _mouseDownWebPoint.y]; _pendingExportViewPoint = [self convertPoint:down.locationInWindow fromView:nil];
[self evaluateJavaScript:js completionHandler:^(id value, NSError* err) { else
(void)err; _pendingExportViewPoint = NSMakePoint(webPoint.x, self.bounds.size.height - webPoint.y);
if ([value isKindOfClass:[NSString class]] && [(NSString*)value length] > 0)
self->_pendingDragJSON = value; __weak OmniWebView* weakSelf = self;
_exportEventMonitor = [NSEvent addLocalMonitorForEventsMatchingMask:
(NSEventMaskLeftMouseDragged | NSEventMaskLeftMouseUp)
handler:^NSEvent* (NSEvent* event) {
OmniWebView* strongSelf = weakSelf;
if (!strongSelf) return event;
if (event.type == NSEventTypeLeftMouseUp) {
[strongSelf clearPendingExport];
return event;
}
if (strongSelf->_exportDragStarted || !strongSelf->_pendingExportFiles.count)
return event;
NSPoint loc = [strongSelf convertPoint:event.locationInWindow fromView:nil];
CGFloat dx = loc.x - strongSelf->_pendingExportViewPoint.x;
CGFloat dy = loc.y - strongSelf->_pendingExportViewPoint.y;
if (std::sqrt(dx * dx + dy * dy) < 4.0) return event;
strongSelf->_exportDragStarted = YES;
NSArray* filesCopy = strongSelf->_pendingExportFiles;
[strongSelf clearPendingExport];
[strongSelf beginExportDragWithFiles:filesCopy event:event];
return nil; // consume; session owns the drag
}]; }];
[super mouseDown:event];
} }
- (void)mouseDragged:(NSEvent*)event { - (void)beginExportDragWithFiles:(NSArray<NSDictionary*>*)files
if (!_nativeDragStarted && _pendingDragJSON.length > 0 && _app && _app->connected()) { atWebPoint:(NSPoint)webPoint {
NSPoint cur = [self webPointFromEvent:event]; (void)webPoint;
CGFloat dx = cur.x - _mouseDownWebPoint.x; NSEvent* event = [NSApp currentEvent];
CGFloat dy = cur.y - _mouseDownWebPoint.y; if (!event || (event.type != NSEventTypeLeftMouseDown &&
if (std::sqrt(dx * dx + dy * dy) >= 4.0) { event.type != NSEventTypeLeftMouseDragged)) {
_nativeDragStarted = YES; NSPoint screen = NSEvent.mouseLocation;
NSRect windowRect = [self.window convertRectFromScreen:NSMakeRect(screen.x, screen.y, 0, 0)];
event = [NSEvent mouseEventWithType:NSEventTypeLeftMouseDragged
location:windowRect.origin
modifierFlags:0
timestamp:(NSApp.currentEvent ? NSApp.currentEvent.timestamp : 0)
windowNumber:self.window.windowNumber
context:nil
eventNumber:0
clickCount:1
pressure:1.0];
}
[self beginExportDragWithFiles:files event:event];
}
- (void)beginExportDragWithFiles:(NSArray<NSDictionary*>*)files
event:(NSEvent*)event {
if (!_app || !_app->connected() || files.count == 0 || !event) return;
[self clearPendingExport];
[self evaluateJavaScript:@"window.omniClearSelection&&window.omniClearSelection()" [self evaluateJavaScript:@"window.omniClearSelection&&window.omniClearSelection()"
completionHandler:nil]; completionHandler:nil];
if ([self beginRemoteExportDrag:event json:_pendingDragJSON]) return;
}
}
if (!_nativeDragStarted) [super mouseDragged:event];
}
- (BOOL)beginRemoteExportDrag:(NSEvent*)event json:(NSString*)json { _exportDragEntries = [files copy];
NSData* data = [json dataUsingEncoding:NSUTF8StringEncoding]; [_exportDelegates removeAllObjects];
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]; NSMutableArray<NSDraggingItem*>* dragItems = [NSMutableArray array];
CGFloat viewY = self.bounds.size.height - _mouseDownWebPoint.y; // draggingFrame must be in this view's coordinate system and match the
NSRect frame = NSMakeRect(_mouseDownWebPoint.x - 1, viewY - 1, 2, 2); // mouse event, otherwise the ghost icon jumps away from the cursor.
NSPoint loc = [self convertPoint:event.locationInWindow fromView:nil];
NSRect frame = NSMakeRect(loc.x - 16, loc.y - 16, 32, 32);
for (NSDictionary* entry in (NSArray*)parsed) { for (NSDictionary* entry in files) {
NSNumber* handle = entry[@"handle"]; NSNumber* handle = entry[@"handle"];
NSNumber* storageId = entry[@"storageId"]; NSNumber* storageId = entry[@"storageId"];
NSString* name = entry[@"name"]; NSString* name = entry[@"name"];
NSNumber* size = entry[@"size"]; NSNumber* size = entry[@"size"];
if (!handle || !storageId || !name || !size) continue; BOOL isDir = [entry[@"isDir"] boolValue];
if (!handle || !storageId || !name.length) continue;
OmniExportPromiseDelegate* delegate = [OmniExportPromiseDelegate new]; OmniExportPromiseDelegate* delegate = [OmniExportPromiseDelegate new];
delegate.app = _app; delegate.app = _app;
delegate.handle = handle.unsignedIntValue; delegate.handle = handle.unsignedIntValue;
delegate.storageId = storageId.unsignedIntValue; delegate.storageId = storageId.unsignedIntValue;
delegate.size = size.unsignedLongLongValue; delegate.size = size ? size.unsignedLongLongValue : 0;
delegate.isDir = isDir;
delegate.filename = name; delegate.filename = name;
[_exportDelegates addObject:delegate];
NSString* typeId;
NSImage* icon;
if (isDir) {
typeId = UTTypeFolder.identifier;
icon = [[NSWorkspace sharedWorkspace] iconForContentType:UTTypeFolder];
} else {
NSString* ext = name.pathExtension;
UTType* ut = ext.length ? [UTType typeWithFilenameExtension:ext] : nil;
typeId = (ut && ut.identifier.length) ? ut.identifier : UTTypeData.identifier;
icon = ut
? [[NSWorkspace sharedWorkspace] iconForContentType:ut]
: [[NSWorkspace sharedWorkspace] iconForContentType:UTTypeData];
}
NSFilePromiseProvider* provider = NSFilePromiseProvider* provider =
[[NSFilePromiseProvider alloc] initWithFileType:@"public.data" delegate:delegate]; [[NSFilePromiseProvider alloc] initWithFileType:typeId delegate:delegate];
NSDraggingItem* dragItem = [[NSDraggingItem alloc] initWithPasteboardWriter:provider]; NSDraggingItem* dragItem = [[NSDraggingItem alloc] initWithPasteboardWriter:provider];
[dragItem setDraggingFrame:frame contents:nil]; icon.size = NSMakeSize(32, 32);
[dragItem setDraggingFrame:frame contents:icon];
[dragItems addObject:dragItem]; [dragItems addObject:dragItem];
} }
if (dragItems.count == 0) { if (dragItems.count == 0) {
_exportDragEntries = nil; _exportDragEntries = nil;
return NO; [_exportDelegates removeAllObjects];
return;
} }
[self evaluateJavaScript:@"window.omniClearSelection&&window.omniClearSelection()"
completionHandler:nil];
[self beginDraggingSessionWithItems:dragItems event:event source:self]; [self beginDraggingSessionWithItems:dragItems event:event source:self];
return YES;
} }
- (NSDragOperation)draggingSession:(NSDraggingSession*)session - (NSDragOperation)draggingSession:(NSDraggingSession*)session
sourceOperationMaskForDraggingContext:(NSDraggingContext)context { sourceOperationMaskForDraggingContext:(NSDraggingContext)context {
(void)session; (void)context; (void)session;
// Outside the app (Finder) and inside both allow copy.
return NSDragOperationCopy; return NSDragOperationCopy;
} }
@@ -441,37 +760,24 @@ static const CGFloat kTrafficLightInset = 72.0;
operation:(NSDragOperation)operation { operation:(NSDragOperation)operation {
(void)session; (void)screenPoint; (void)operation; (void)session; (void)screenPoint; (void)operation;
_exportDragEntries = nil; _exportDragEntries = nil;
[_exportDelegates removeAllObjects];
} }
- (NSDragOperation)draggingUpdated:(id<NSDraggingInfo>)sender { - (NSDragOperation)draggingUpdated:(id<NSDraggingInfo>)sender {
NSArray<NSURL*>* urls = [self fileURLsFromDrag:sender]; NSArray<NSURL*>* urls = [self fileURLsFromDrag:sender];
const BOOL exportDrag = _exportDragEntries.count > 0; if (urls.count == 0) {
if (!exportDrag && urls.count == 0) {
[self setRemoteDragOver:NO]; [self setRemoteDragOver:NO];
[self setLocalDragOver:NO];
return NSDragOperationNone; return NSDragOperationNone;
} }
NSString* target = [self dropTargetAtWebPoint:[self webPointFromDrag:sender]]; NSString* target = [self dropTargetAtWebPoint:[self webPointFromDrag:sender]];
if (exportDrag && [target isEqualToString:@"local"]) { if ([target isEqualToString:@"remote"] && _app && _app->connected()) {
[self setLocalDragOver:YES];
[self setRemoteDragOver:NO];
return NSDragOperationCopy;
}
if (urls.count > 0 && [target isEqualToString:@"remote"] && _app && _app->connected()) {
[self setRemoteDragOver:YES]; [self setRemoteDragOver:YES];
[self setLocalDragOver:NO];
return NSDragOperationCopy; return NSDragOperationCopy;
} }
if (urls.count > 0 && [target isEqualToString:@"local"]) {
[self setRemoteDragOver:NO];
[self setLocalDragOver:NO];
return NSDragOperationLink;
}
[self setRemoteDragOver:NO]; [self setRemoteDragOver:NO];
[self setLocalDragOver:NO]; return NSDragOperationNone;
return exportDrag ? NSDragOperationCopy : NSDragOperationNone;
} }
- (NSDragOperation)draggingEntered:(id<NSDraggingInfo>)sender { - (NSDragOperation)draggingEntered:(id<NSDraggingInfo>)sender {
@@ -481,35 +787,19 @@ static const CGFloat kTrafficLightInset = 72.0;
- (void)draggingExited:(id<NSDraggingInfo>)sender { - (void)draggingExited:(id<NSDraggingInfo>)sender {
(void)sender; (void)sender;
[self setRemoteDragOver:NO]; [self setRemoteDragOver:NO];
[self setLocalDragOver:NO];
} }
- (BOOL)performDragOperation:(id<NSDraggingInfo>)sender { - (BOOL)performDragOperation:(id<NSDraggingInfo>)sender {
[self setRemoteDragOver:NO]; [self setRemoteDragOver:NO];
[self setLocalDragOver:NO];
if (!_app) return NO; if (!_app) return NO;
NSString* target = [self dropTargetAtWebPoint:[self webPointFromDrag:sender]]; NSString* target = [self dropTargetAtWebPoint:[self webPointFromDrag:sender]];
NSArray<NSURL*>* urls = [self fileURLsFromDrag:sender]; NSArray<NSURL*>* urls = [self fileURLsFromDrag:sender];
if ([target isEqualToString:@"local"] && _exportDragEntries.count > 0) { if (![target isEqualToString:@"remote"]) return NO;
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; NSFileManager* fm = NSFileManager.defaultManager;
BOOL handled = NO; BOOL handled = NO;
if ([target isEqualToString:@"remote"]) {
for (NSURL* url in urls) { for (NSURL* url in urls) {
if (!url.isFileURL) continue; if (!url.isFileURL) continue;
NSString* path = url.path; NSString* path = url.path;
@@ -520,29 +810,18 @@ static const CGFloat kTrafficLightInset = 72.0;
handled = YES; handled = YES;
} }
return handled; 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 @end
// ─── OmniAppDelegate ────────────────────────────────────────────────────────── // ─── OmniAppDelegate ──────────────────────────────────────────────────────────
@interface OmniAppDelegate : NSObject <NSApplicationDelegate> @interface OmniAppDelegate : NSObject <NSApplicationDelegate, NSWindowDelegate>
- (instancetype)initWithApp:(omniMTP::App*)app; - (instancetype)initWithApp:(omniMTP::App*)app;
- (void)buildMainMenu;
- (void)selectLanguage:(id)sender;
- (void)showAbout:(id)sender;
- (void)closeAboutPanel:(id)sender;
- (void)applyLanguageToWebView;
@end @end
@implementation OmniAppDelegate { @implementation OmniAppDelegate {
@@ -550,6 +829,11 @@ static const CGFloat kTrafficLightInset = 72.0;
NSWindow* _window; NSWindow* _window;
WKWebView* _webView; WKWebView* _webView;
OmniBridge* _bridge; OmniBridge* _bridge;
NSPanel* _aboutPanel;
NSMenuItem* _aboutItem;
NSMenuItem* _optionsItem;
NSMenuItem* _languageItem;
NSMenuItem* _quitItem;
} }
- (instancetype)initWithApp:(omniMTP::App*)app { - (instancetype)initWithApp:(omniMTP::App*)app {
@@ -559,9 +843,221 @@ static const CGFloat kTrafficLightInset = 72.0;
return self; return self;
} }
- (void)buildMainMenu {
NSMenu* menuBar = [[NSMenu alloc] init];
NSMenuItem* appItem = [[NSMenuItem alloc] init];
[menuBar addItem:appItem];
NSMenu* appMenu = [[NSMenu alloc] initWithTitle:@"OmniMTP"];
_aboutItem = [[NSMenuItem alloc] initWithTitle:OmniMenuString(@"menu.about")
action:@selector(showAbout:)
keyEquivalent:@""];
_aboutItem.target = self;
[appMenu addItem:_aboutItem];
[appMenu addItem:[NSMenuItem separatorItem]];
// Options → Language
_optionsItem = [[NSMenuItem alloc] initWithTitle:OmniMenuString(@"menu.options")
action:nil
keyEquivalent:@""];
if (@available(macOS 11.0, *)) {
NSImage* gear = [NSImage imageWithSystemSymbolName:@"gearshape"
accessibilityDescription:nil];
if (gear) {
gear.size = NSMakeSize(16, 16);
_optionsItem.image = gear;
}
}
NSMenu* optionsMenu = [[NSMenu alloc] initWithTitle:OmniMenuString(@"menu.options")];
_optionsItem.submenu = optionsMenu;
_languageItem = [[NSMenuItem alloc] initWithTitle:OmniMenuString(@"menu.language")
action:nil
keyEquivalent:@""];
NSMenu* langMenu = [[NSMenu alloc] initWithTitle:OmniMenuString(@"menu.language")];
_languageItem.submenu = langMenu;
[optionsMenu addItem:_languageItem];
NSString* current = OmniLoadConfig()[@"language"];
if (![current isKindOfClass:[NSString class]] || current.length == 0)
current = @"system";
NSMenuItem* systemItem = [[NSMenuItem alloc]
initWithTitle:OmniMenuString(@"menu.language_system")
action:@selector(selectLanguage:)
keyEquivalent:@""];
systemItem.target = self;
systemItem.representedObject = @"system";
systemItem.state = [current isEqualToString:@"system"] ? NSControlStateValueOn
: NSControlStateValueOff;
[langMenu addItem:systemItem];
[langMenu addItem:[NSMenuItem separatorItem]];
NSURL* webroot = OmniFindWebrootURL();
for (NSString* code in OmniAvailableLanguageCodes(webroot)) {
NSMenuItem* item = [[NSMenuItem alloc]
initWithTitle:OmniLanguageDisplayName(code)
action:@selector(selectLanguage:)
keyEquivalent:@""];
item.target = self;
item.representedObject = code;
item.state = [current isEqualToString:code] ? NSControlStateValueOn
: NSControlStateValueOff;
[langMenu addItem:item];
}
[appMenu addItem:_optionsItem];
[appMenu addItem:[NSMenuItem separatorItem]];
_quitItem = [[NSMenuItem alloc] initWithTitle:OmniMenuString(@"menu.quit")
action:@selector(terminate:)
keyEquivalent:@"q"];
[appMenu addItem:_quitItem];
appItem.submenu = appMenu;
[NSApp setMainMenu:menuBar];
}
- (void)showAbout:(id)sender {
(void)sender;
if (_aboutPanel) {
[NSApp stopModal];
[_aboutPanel orderOut:nil];
_aboutPanel = nil;
}
NSBundle* bundle = NSBundle.mainBundle;
NSString* version = [bundle objectForInfoDictionaryKey:@"CFBundleShortVersionString"] ?: @"1.0.0";
NSString* build = [bundle objectForInfoDictionaryKey:@"CFBundleVersion"] ?: version;
NSString* versionText = [version isEqualToString:build]
? version
: [NSString stringWithFormat:@"%@ (%@)", version, build];
NSPanel* panel = [[NSPanel alloc]
initWithContentRect:NSMakeRect(0, 0, 340, 320)
styleMask:(NSWindowStyleMaskTitled | NSWindowStyleMaskClosable)
backing:NSBackingStoreBuffered
defer:NO];
panel.title = OmniMenuString(@"menu.about");
panel.releasedWhenClosed = NO;
panel.delegate = self;
if (@available(macOS 10.14, *))
panel.appearance = [NSAppearance appearanceNamed:NSAppearanceNameDarkAqua];
NSView* content = panel.contentView;
NSImageView* iconView = [[NSImageView alloc] initWithFrame:NSZeroRect];
iconView.translatesAutoresizingMaskIntoConstraints = NO;
iconView.image = NSApp.applicationIconImage;
iconView.imageScaling = NSImageScaleProportionallyUpOrDown;
[content addSubview:iconView];
NSTextField* (^makeLabel)(NSString*, CGFloat, BOOL) = ^NSTextField*(NSString* text, CGFloat size, BOOL bold) {
NSTextField* label = [NSTextField labelWithString:text ?: @""];
label.translatesAutoresizingMaskIntoConstraints = NO;
label.alignment = NSTextAlignmentCenter;
label.font = bold ? [NSFont boldSystemFontOfSize:size] : [NSFont systemFontOfSize:size];
label.textColor = bold ? NSColor.labelColor : NSColor.secondaryLabelColor;
label.maximumNumberOfLines = 3;
label.lineBreakMode = NSLineBreakByWordWrapping;
[content addSubview:label];
return label;
};
NSTextField* titleLabel = makeLabel(@"OmniMTP", 22, YES);
titleLabel.textColor = NSColor.labelColor;
NSTextField* taglineLabel = makeLabel(OmniMenuString(@"about.tagline"), 13, NO);
NSTextField* versionLabel = makeLabel(OmniMenuFormat(@"about.version", @{@"version": versionText}), 12, NO);
NSTextField* copyLabel = makeLabel(OmniMenuString(@"about.copyright"), 11, NO);
NSButton* okButton = [[NSButton alloc] initWithFrame:NSZeroRect];
okButton.translatesAutoresizingMaskIntoConstraints = NO;
okButton.title = OmniMenuString(@"confirm.ok_btn");
okButton.bezelStyle = NSBezelStyleRounded;
okButton.keyEquivalent = @"\r";
okButton.target = self;
okButton.action = @selector(closeAboutPanel:);
[content addSubview:okButton];
[NSLayoutConstraint activateConstraints:@[
[iconView.topAnchor constraintEqualToAnchor:content.topAnchor constant:28],
[iconView.centerXAnchor constraintEqualToAnchor:content.centerXAnchor],
[iconView.widthAnchor constraintEqualToConstant:96],
[iconView.heightAnchor constraintEqualToConstant:96],
[titleLabel.topAnchor constraintEqualToAnchor:iconView.bottomAnchor constant:14],
[titleLabel.leadingAnchor constraintEqualToAnchor:content.leadingAnchor constant:24],
[titleLabel.trailingAnchor constraintEqualToAnchor:content.trailingAnchor constant:-24],
[taglineLabel.topAnchor constraintEqualToAnchor:titleLabel.bottomAnchor constant:8],
[taglineLabel.leadingAnchor constraintEqualToAnchor:content.leadingAnchor constant:24],
[taglineLabel.trailingAnchor constraintEqualToAnchor:content.trailingAnchor constant:-24],
[versionLabel.topAnchor constraintEqualToAnchor:taglineLabel.bottomAnchor constant:10],
[versionLabel.leadingAnchor constraintEqualToAnchor:content.leadingAnchor constant:24],
[versionLabel.trailingAnchor constraintEqualToAnchor:content.trailingAnchor constant:-24],
[copyLabel.topAnchor constraintEqualToAnchor:versionLabel.bottomAnchor constant:4],
[copyLabel.leadingAnchor constraintEqualToAnchor:content.leadingAnchor constant:24],
[copyLabel.trailingAnchor constraintEqualToAnchor:content.trailingAnchor constant:-24],
[okButton.topAnchor constraintEqualToAnchor:copyLabel.bottomAnchor constant:20],
[okButton.centerXAnchor constraintEqualToAnchor:content.centerXAnchor],
[okButton.bottomAnchor constraintEqualToAnchor:content.bottomAnchor constant:-24],
[okButton.widthAnchor constraintGreaterThanOrEqualToConstant:80],
]];
_aboutPanel = panel;
[panel center];
[NSApp runModalForWindow:panel];
}
- (void)closeAboutPanel:(id)sender {
(void)sender;
[NSApp stopModal];
[_aboutPanel orderOut:nil];
_aboutPanel = nil;
}
- (BOOL)windowShouldClose:(NSWindow*)sender {
if (sender == _aboutPanel) {
[NSApp stopModal];
_aboutPanel = nil;
}
return YES;
}
- (void)selectLanguage:(id)sender {
NSMenuItem* item = (NSMenuItem*)sender;
if (![item isKindOfClass:[NSMenuItem class]]) return;
NSString* code = item.representedObject;
if (![code isKindOfClass:[NSString class]]) return;
NSMutableDictionary* cfg = [OmniLoadConfig() mutableCopy] ?: [NSMutableDictionary dictionary];
cfg[@"language"] = code;
OmniSaveConfig(cfg);
[self applyLanguageToWebView];
[self buildMainMenu];
}
- (void)applyLanguageToWebView {
if (!_webView) return;
NSDictionary* payload = OmniBuildI18nPayload(OmniFindWebrootURL());
NSData* data = [NSJSONSerialization dataWithJSONObject:payload options:0 error:nil];
NSString* json = data ? [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding] : @"{}";
NSString* js = [NSString stringWithFormat:
@"window.__OMNI_I18N__=%@;"
"if(window.omniApplyI18n)window.omniApplyI18n(window.__OMNI_I18N__);", json];
[_webView evaluateJavaScript:js completionHandler:nil];
}
- (void)applicationDidFinishLaunching:(NSNotification*)note { - (void)applicationDidFinishLaunching:(NSNotification*)note {
(void)note; (void)note;
[self buildMainMenu];
// ── WKWebView configuration ─────────────────────────────────────────────── // ── WKWebView configuration ───────────────────────────────────────────────
WKWebViewConfiguration* config = [[WKWebViewConfiguration alloc] init]; WKWebViewConfiguration* config = [[WKWebViewConfiguration alloc] init];
@@ -582,16 +1078,21 @@ static const CGFloat kTrafficLightInset = 72.0;
"if(e.button===0&&e.target.closest('.file-list .frow'))e.preventDefault();" "if(e.button===0&&e.target.closest('.file-list .frow'))e.preventDefault();"
"},true);" "},true);"
"document.addEventListener('dragstart',function(e){" "document.addEventListener('dragstart',function(e){"
"var row=e.target.closest('.file-list .frow');" "if(e.target.closest('.file-list .frow'))e.preventDefault();"
"if(!row)return;"
"if(row.closest('#remote-files')||row.getAttribute('draggable')!=='true')"
"e.preventDefault();"
"},true);" "},true);"
"})();" "})();"
injectionTime:WKUserScriptInjectionTimeAtDocumentStart injectionTime:WKUserScriptInjectionTimeAtDocumentStart
forMainFrameOnly:YES]; forMainFrameOnly:YES];
[config.userContentController addUserScript:noTextDrag]; [config.userContentController addUserScript:noTextDrag];
// Inject locale strings before page JS runs (WKWebView blocks fetch() on file://).
NSURL* webrootURL = OmniFindWebrootURL();
WKUserScript* i18nBoot = [[WKUserScript alloc]
initWithSource:OmniI18nBootstrapScript(webrootURL)
injectionTime:WKUserScriptInjectionTimeAtDocumentStart
forMainFrameOnly:YES];
[config.userContentController addUserScript:i18nBoot];
// Register the "bridge" message handler // Register the "bridge" message handler
_webView = [[OmniWebView alloc] initWithFrame:NSMakeRect(0, 0, 1280, 800) _webView = [[OmniWebView alloc] initWithFrame:NSMakeRect(0, 0, 1280, 800)
configuration:config configuration:config
@@ -720,19 +1221,6 @@ void WebUI::run() {
OmniAppDelegate* delegate = [[OmniAppDelegate alloc] initWithApp:&app_]; OmniAppDelegate* delegate = [[OmniAppDelegate alloc] initWithApp:&app_];
[NSApp setDelegate:delegate]; [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 activateIgnoringOtherApps:YES];
[NSApp run]; [NSApp run];
} }

View File

@@ -113,15 +113,25 @@ body {
flex-shrink: 0; flex-shrink: 0;
gap: 8px; gap: 8px;
} }
.device-left, .device-right { display: flex; align-items: center; gap: 6px; flex-shrink: 0; } .device-left, .device-right {
.device-center { display: flex;
align-items: center;
gap: 6px;
flex: 1; flex: 1;
min-width: 0;
}
.device-left { justify-content: flex-start; }
.device-right { justify-content: flex-end; }
.device-center {
flex: 0 1 auto;
max-width: 50%;
text-align: center; text-align: center;
font-size: 12px; font-size: 12px;
color: var(--label-2); color: var(--label-2);
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
pointer-events: none;
} }
/* ── Main two-panel area ──────────────────────────────────────────────────── */ /* ── Main two-panel area ──────────────────────────────────────────────────── */
@@ -301,6 +311,22 @@ body {
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
} }
.ftable th.sortable {
cursor: pointer;
user-select: none;
-webkit-user-select: none;
}
.ftable th.sortable:hover { color: var(--label-1); }
.ftable th .sort-ind {
display: inline-block;
min-width: 1em;
margin-left: 4px;
font-size: 9px;
opacity: 0;
vertical-align: middle;
}
.ftable th.sortable:hover .sort-ind { opacity: 0.35; }
.ftable th .sort-ind.active { opacity: 1; color: var(--accent, #0a84ff); }
.ftable th:first-child { padding-left: 12px; } .ftable th:first-child { padding-left: 12px; }
.col-size { width: 72px; text-align: right; } .col-size { width: 72px; text-align: right; }
.col-date { width: 110px; } .col-date { width: 110px; }
@@ -545,8 +571,8 @@ body {
.marquee-box { .marquee-box {
position: fixed; position: fixed;
z-index: 9998; z-index: 9998;
border: 1px solid var(--accent); border: 1px solid rgba(10, 132, 255, 0.85);
background: rgba(10,132,255,0.12); background: rgba(10, 132, 255, 0.06);
pointer-events: none; pointer-events: none;
} }

View File

@@ -10,7 +10,6 @@ function call(action, params = {}) {
try { try {
window.webkit.messageHandlers.bridge.postMessage({id, action, ...params}); window.webkit.messageHandlers.bridge.postMessage({id, action, ...params});
} catch(e) { } catch(e) {
// Dev fallback
setTimeout(() => { if (_pending[id]) { _pending[id](null); delete _pending[id]; } }, 100); setTimeout(() => { if (_pending[id]) { _pending[id](null); delete _pending[id]; } }, 100);
} }
}); });
@@ -19,55 +18,189 @@ window.bridgeResponse = function(id, data) {
if (_pending[id]) { _pending[id](data); delete _pending[id]; } if (_pending[id]) { _pending[id](data); delete _pending[id]; }
}; };
// ── i18n ──────────────────────────────────────────────────────────────────────
const I18N = {
lang: 'en',
strings: {},
fallback: {},
};
function t(key, vars = {}) {
let s = I18N.strings[key] ?? I18N.fallback[key] ?? key;
for (const [k, v] of Object.entries(vars))
s = s.replaceAll(`{${k}}`, String(v));
return s;
}
function tPlural(oneKey, manyKey, n, extra = {}) {
return t(n === 1 ? oneKey : manyKey, { n, ...extra });
}
function localeCandidates(preferred) {
const out = [];
const add = (c) => { if (c && !out.includes(c)) out.push(c); };
for (const raw of preferred || []) {
const tag = String(raw || '').replace(/_/g, '-');
if (!tag) continue;
add(tag);
const parts = tag.split('-');
if (parts[0] === 'zh') {
const script = (parts[1] || '').toLowerCase();
if (script.startsWith('hant') || script === 'tw' || script === 'hk' || script === 'mo')
add('zh-Hant');
else
add('zh-Hans');
add('zh');
} else {
add(parts[0]);
}
}
add('en');
return out;
}
async function fetchLang(code) {
const pack = await call('get_lang_pack', { code });
return (pack && typeof pack === 'object') ? pack : null;
}
async function initI18n() {
// Native injects packs at document start (fetch() is blocked on file://).
if (window.__OMNI_I18N__ && typeof window.__OMNI_I18N__ === 'object') {
const boot = window.__OMNI_I18N__;
I18N.lang = boot.lang || 'en';
I18N.fallback = boot.fallback || {};
I18N.strings = boot.strings || { ...I18N.fallback };
} else {
I18N.fallback = (await fetchLang('en')) || {};
I18N.strings = { ...I18N.fallback };
let preferred = await call('get_preferred_languages');
if (!Array.isArray(preferred)) preferred = ['en'];
for (const code of localeCandidates(preferred)) {
if (code === 'en') {
I18N.lang = 'en';
break;
}
const pack = await fetchLang(code);
if (pack) {
I18N.lang = code;
I18N.strings = { ...I18N.fallback, ...pack };
break;
}
}
}
document.documentElement.lang = I18N.lang;
applyStaticI18n();
}
function applyStaticI18n() {
document.querySelectorAll('[data-i18n]').forEach(el => {
el.textContent = t(el.dataset.i18n);
});
document.querySelectorAll('[data-i18n-title]').forEach(el => {
el.title = t(el.dataset.i18nTitle);
});
document.title = t('app.title');
}
window.omniApplyI18n = function(payload) {
if (!payload || typeof payload !== 'object') return;
window.__OMNI_I18N__ = payload;
I18N.lang = payload.lang || 'en';
I18N.fallback = payload.fallback || {};
I18N.strings = payload.strings || { ...I18N.fallback };
document.documentElement.lang = I18N.lang;
applyStaticI18n();
_remoteFingerprint = '';
if (typeof render === 'function') render();
};
async function confirmDialog(message) {
const result = await call('confirm', {
message,
ok: t('confirm.delete_btn'),
cancel: t('confirm.cancel_btn'),
});
return result === true || result === 'true';
}
// ── State ───────────────────────────────────────────────────────────────────── // ── State ─────────────────────────────────────────────────────────────────────
const S = { const S = {
connected: false, deviceName: '', connected: false, deviceName: '',
storages: [], activeStorageId: 0, storages: [], activeStorageId: 0,
remoteNavStack: [], remoteNavStack: [],
localPath: '', localFiles: [],
localDrives: [], activeLocalDrive: '',
remoteFiles: [], remoteRefreshing: false, remoteFiles: [], remoteRefreshing: false,
transfers: [], transfers: [],
statusMessage: '', statusTimer: 0, statusKey: '', statusArgs: [], statusTimer: 0,
selLocal: new Set(), // keys = file.path strings selRemote: new Set(),
selRemote: new Set(), // keys = String(file.handle)
lastLocalIdx: -1, // for shift+click range
lastRemoteIdx: -1, lastRemoteIdx: -1,
sortKey: 'name', // name | size | date
sortAsc: true,
}; };
// ── 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 = ''; let _remoteFingerprint = '';
function fingerprintFiles(files) { 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')
return files.map(f => f.path || f.handle || f.name).join('\0'); + '\0' + I18N.lang + '\0' + S.sortKey + '\0' + (S.sortAsc ? 'a' : 'd');
}
function sortedRemoteFiles() {
const files = (S.remoteFiles || []).slice();
const dir = S.sortAsc ? 1 : -1;
const key = S.sortKey;
files.sort((a, b) => {
if (!!a.isDir !== !!b.isDir) return a.isDir ? -1 : 1;
let cmp = 0;
if (key === 'size') {
cmp = (a.size || 0) - (b.size || 0);
} else if (key === 'date') {
cmp = String(a.date || '').localeCompare(String(b.date || ''));
} else {
cmp = String(a.name || '').localeCompare(String(b.name || ''), undefined, {
sensitivity: 'base', numeric: true,
});
}
if (cmp === 0) {
cmp = String(a.name || '').localeCompare(String(b.name || ''), undefined, {
sensitivity: 'base', numeric: true,
});
}
return cmp * dir;
});
return files;
}
function setSortColumn(key) {
if (S.sortKey === key) S.sortAsc = !S.sortAsc;
else {
S.sortKey = key;
S.sortAsc = key !== 'size'; // size defaults to largest-first feels odd; use ascending
}
_remoteFingerprint = '';
renderRemoteFiles();
} }
// ── Polling ───────────────────────────────────────────────────────────────────
async function poll() { async function poll() {
try { try {
const data = await call('get_state'); const data = await call('get_state');
if (data) { if (data) {
const sl = S.selLocal, sr = S.selRemote; const sr = S.selRemote;
const lli = S.lastLocalIdx, lri = S.lastRemoteIdx; const lri = S.lastRemoteIdx;
Object.assign(S, data); Object.assign(S, data);
S.selLocal = sl; S.selRemote = sr; S.selRemote = sr;
S.lastLocalIdx = lli; S.lastRemoteIdx = lri; S.lastRemoteIdx = lri;
render(); render();
} }
} catch(e) {} } catch(e) {}
setTimeout(poll, 400); setTimeout(poll, 400);
} }
// ── Rendering ─────────────────────────────────────────────────────────────────
function render() { function render() {
renderDeviceBar(); renderDeviceBar();
renderLocalToolbar();
renderLocalFiles();
renderRemoteToolbar(); renderRemoteToolbar();
renderRemoteFiles(); renderRemoteFiles();
renderTransfers(); renderTransfers();
@@ -77,11 +210,9 @@ function render() {
function updateDropZones() { function updateDropZones() {
const rem = document.getElementById('panel-remote')?.getBoundingClientRect(); const rem = document.getElementById('panel-remote')?.getBoundingClientRect();
const loc = document.getElementById('panel-local')?.getBoundingClientRect(); if (!rem) return;
if (!rem || !loc) return;
call('update_drop_zones', { call('update_drop_zones', {
remote: {x: rem.left, y: rem.top, w: rem.width, h: rem.height}, 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},
}); });
} }
@@ -100,18 +231,24 @@ function fileColor(f) {
return 'color-file'; return 'color-file';
} }
// ── File table builder ──────────────────────────────────────────────────────── function fileTableHeader() {
const FILE_TABLE_HEADER = `<thead><tr> const ind = (key) => {
<th>Name</th><th class="col-size">Size</th><th class="col-date">Modified</th> if (S.sortKey !== key) return '<span class="sort-ind"></span>';
</tr></thead>`; return `<span class="sort-ind active">${S.sortAsc ? '▲' : '▼'}</span>`;
};
return `<thead><tr>
<th class="sortable" data-sort="name">${esc(t('col.name'))}${ind('name')}</th>
<th class="col-size sortable" data-sort="size">${esc(t('col.size'))}${ind('size')}</th>
<th class="col-date sortable" data-sort="date">${esc(t('col.modified'))}${ind('date')}</th>
</tr></thead>`;
}
function buildFileRows(files, selSet, idKey, allowDrag = false) { function buildFileRows(files, selSet, idKey) {
return files.map((f, i) => { return files.map((f, i) => {
const key = String(f[idKey] != null ? f[idKey] : (f.path || f.name)); const key = String(f[idKey] != null ? f[idKey] : (f.path || f.name));
const sel = selSet.has(key) ? ' selected' : ''; const sel = selSet.has(key) ? ' selected' : '';
const cc = fileColor(f); 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'}">
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"> <td class="col-name"><div class="fname-cell">
<span class="dot ${cc}"></span> <span class="dot ${cc}"></span>
<span class="${cc}">${esc(f.name)}</span> <span class="${cc}">${esc(f.name)}</span>
@@ -122,7 +259,6 @@ function buildFileRows(files, selSet, idKey, allowDrag = false) {
}).join(''); }).join('');
} }
// ── Selection helpers ─────────────────────────────────────────────────────────
function applySelectionToDOM(container, selSet) { function applySelectionToDOM(container, selSet) {
container.querySelectorAll('.frow').forEach(r => { container.querySelectorAll('.frow').forEach(r => {
r.classList.toggle('selected', selSet.has(r.dataset.key)); r.classList.toggle('selected', selSet.has(r.dataset.key));
@@ -133,43 +269,28 @@ function selectionKeys(files, idKey) {
return files.map(f => String(f[idKey] != null ? f[idKey] : (f.path || f.name))); 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) { function attachMarqueeSelect(el, selSet) {
let active = false, startX = 0, startY = 0, box = null, additive = false; let active = false, startX = 0, startY = 0, box = null, additive = false, moved = false;
const finish = () => { const finish = () => {
if (!active) return;
const clearOnClick = !moved && !additive;
active = false; active = false;
if (box) { box.remove(); box = null; } if (box) { box.remove(); box = null; }
window.removeEventListener('mousemove', onMove); window.removeEventListener('mousemove', onMove);
window.removeEventListener('mouseup', finish); window.removeEventListener('mouseup', finish);
if (clearOnClick) {
selSet.clear();
applySelectionToDOM(el, selSet);
S.lastRemoteIdx = -1;
}
}; };
const onMove = e => { const onMove = e => {
if (!active) return; if (!active) return;
const dx = e.clientX - startX, dy = e.clientY - startY;
if (!moved && Math.hypot(dx, dy) < 4) return;
moved = true;
window.omniClearSelection(); window.omniClearSelection();
const x1 = Math.min(startX, e.clientX), y1 = Math.min(startY, e.clientY); 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); const x2 = Math.max(startX, e.clientX), y2 = Math.max(startY, e.clientY);
@@ -187,9 +308,10 @@ function attachMarqueeSelect(el, selSet) {
}; };
el.addEventListener('mousedown', e => { el.addEventListener('mousedown', e => {
if (e.button !== 0 || e.target.closest('.frow')) return; if (e.button !== 0 || e.target.closest('.frow') || e.target.closest('thead')) return;
e.preventDefault(); e.preventDefault();
active = true; active = true;
moved = false;
additive = e.metaKey || e.shiftKey; additive = e.metaKey || e.shiftKey;
startX = e.clientX; startX = e.clientX;
startY = e.clientY; startY = e.clientY;
@@ -201,68 +323,6 @@ function attachMarqueeSelect(el, selSet) {
}); });
} }
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() { function renderRemoteFiles() {
const el = document.getElementById('remote-files'); const el = document.getElementById('remote-files');
const empty = document.getElementById('remote-empty-state'); const empty = document.getElementById('remote-empty-state');
@@ -277,8 +337,8 @@ function renderRemoteFiles() {
if (empty) empty.style.display = 'none'; if (empty) empty.style.display = 'none';
if (S.remoteRefreshing) { if (S.remoteRefreshing) {
el.innerHTML = '<div class="loading">Loading…</div>'; el.innerHTML = `<div class="loading">${esc(t('loading'))}</div>`;
_remoteFingerprint = '__loading__'; _remoteFingerprint = '__loading__' + I18N.lang;
return; return;
} }
@@ -290,10 +350,11 @@ function renderRemoteFiles() {
<svg viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-width="1.5" class="empty-icon"> <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"/> <path d="M6 38V14a2 2 0 012-2h12l4 4h16a2 2 0 012 2v20a2 2 0 01-2 2H8a2 2 0 01-2-2z"/>
</svg> </svg>
<div class="empty-title">Empty folder</div></div>`; <div class="empty-title">${esc(t('empty.folder'))}</div></div>`;
} else { } else {
el.innerHTML = `<table class="ftable">${FILE_TABLE_HEADER} const files = sortedRemoteFiles();
<tbody>${buildFileRows(S.remoteFiles, S.selRemote, 'handle', false)}</tbody></table>`; el.innerHTML = `<table class="ftable">${fileTableHeader()}
<tbody>${buildFileRows(files, S.selRemote, 'handle')}</tbody></table>`;
} }
attachRemoteEvents(el); attachRemoteEvents(el);
} else { } else {
@@ -302,9 +363,17 @@ function renderRemoteFiles() {
} }
function attachRemoteEvents(el) { function attachRemoteEvents(el) {
const files = S.remoteFiles; const files = sortedRemoteFiles();
const keys = selectionKeys(files, 'handle'); const keys = selectionKeys(files, 'handle');
el.querySelectorAll('th.sortable').forEach(th => {
th.addEventListener('click', e => {
e.preventDefault();
e.stopPropagation();
setSortColumn(th.dataset.sort);
});
});
el.querySelectorAll('.frow').forEach((row, i) => { el.querySelectorAll('.frow').forEach((row, i) => {
const f = files[i]; const f = files[i];
const key = keys[i]; const key = keys[i];
@@ -323,22 +392,11 @@ function attachRemoteEvents(el) {
attachMarqueeSelect(el, S.selRemote); attachMarqueeSelect(el, S.selRemote);
// Drop target: receive local files → upload
el.addEventListener('dragover', e => { e.preventDefault(); el.classList.add('drag-over'); }); 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('dragleave', e => { if (!el.contains(e.relatedTarget)) el.classList.remove('drag-over'); });
el.addEventListener('drop', e => { el.addEventListener('drop', e => {
e.preventDefault(); e.preventDefault();
el.classList.remove('drag-over'); 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 || [])) { for (const file of (e.dataTransfer.files || [])) {
const p = file.path || ''; const p = file.path || '';
if (p) call('start_upload', {srcPath: p}); if (p) call('start_upload', {srcPath: p});
@@ -346,7 +404,6 @@ function attachRemoteEvents(el) {
}); });
} }
// ── Shared row click with shift-range support ─────────────────────────────────
function handleRowClick(e, idx, key, selSet, allKeys, lastIdxProp) { function handleRowClick(e, idx, key, selSet, allKeys, lastIdxProp) {
if (e.shiftKey && S[lastIdxProp] >= 0) { if (e.shiftKey && S[lastIdxProp] >= 0) {
const lo = Math.min(idx, S[lastIdxProp]); const lo = Math.min(idx, S[lastIdxProp]);
@@ -363,53 +420,6 @@ function handleRowClick(e, idx, key, selSet, allKeys, lastIdxProp) {
} }
} }
// ── 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() { function renderRemoteToolbar() {
const sel = document.getElementById('storage-sel'); const sel = document.getElementById('storage-sel');
if (S.storages && S.storages.length) { if (S.storages && S.storages.length) {
@@ -435,22 +445,21 @@ function renderRemoteToolbar() {
document.getElementById('btn-remote-up').disabled = stack.length <= 1; document.getElementById('btn-remote-up').disabled = stack.length <= 1;
} }
// ── Device bar ────────────────────────────────────────────────────────────────
function renderDeviceBar() { function renderDeviceBar() {
const badge = document.getElementById('conn-badge'); const badge = document.getElementById('conn-badge');
const status = document.getElementById('device-status-text'); const status = document.getElementById('device-status-text');
if (S.connected) { if (S.connected) {
const name = S.deviceName || t('device.nintendo_switch');
badge.className = 'badge connected'; badge.className = 'badge connected';
badge.textContent = S.deviceName || 'Nintendo Switch'; badge.textContent = name;
status.textContent = S.deviceName || 'Nintendo Switch connected'; status.textContent = t('device.connected', { name });
} else { } else {
badge.className = 'badge disconnected'; badge.className = 'badge disconnected';
badge.textContent = 'No Device'; badge.textContent = t('badge.no_device');
status.textContent = 'Waiting for Nintendo Switch…'; status.textContent = t('device.waiting');
} }
} }
// ── Transfers panel ───────────────────────────────────────────────────────────
function renderTransfers() { function renderTransfers() {
const list = document.getElementById('transfers-list'); const list = document.getElementById('transfers-list');
const label = document.getElementById('transfers-label'); const label = document.getElementById('transfers-label');
@@ -458,87 +467,87 @@ function renderTransfers() {
const panel = document.getElementById('transfers-panel'); const panel = document.getElementById('transfers-panel');
const active = (S.transfers || []).filter(t => t.state <= 1); const active = (S.transfers || []).filter(t => t.state <= 1);
label.textContent = active.length ? `${active.length} Transfer${active.length > 1 ? 's' : ''}` : 'Transfers'; label.textContent = active.length
? tPlural('transfers.count', 'transfers.count_plural', active.length)
: t('transfers');
cancelAll.style.display = active.length ? '' : 'none'; cancelAll.style.display = active.length ? '' : 'none';
cancelAll.textContent = t('btn.cancel_all');
if (!S.transfers || !S.transfers.length) { list.innerHTML = ''; panel.style.display = 'none'; return; } if (!S.transfers || !S.transfers.length) { list.innerHTML = ''; panel.style.display = 'none'; return; }
panel.style.display = ''; panel.style.display = '';
const stateNames = ['Queued','','Done','Failed','Cancelled']; const stateNames = {
list.innerHTML = S.transfers.map(t => { 0: t('transfer.queued'),
const dir = t.direction === 0 ? 'DL' : 'UP'; 2: t('transfer.done'),
const dc = t.direction === 0 ? 'dir-dl' : 'dir-ul'; 3: t('transfer.failed'),
const pct = ((t.progress || 0) * 100).toFixed(0); 4: t('transfer.cancelled'),
const showBar = t.state <= 1; };
const statText = t.state === 1 ? esc(t.speedStr) list.innerHTML = S.transfers.map(tx => {
: t.state === 3 ? '⚠ ' + esc(t.error || 'Failed') const dir = tx.direction === 0 ? t('transfer.dl') : t('transfer.up');
: stateNames[t.state] || ''; const dc = tx.direction === 0 ? 'dir-dl' : 'dir-ul';
const pct = ((tx.progress || 0) * 100).toFixed(0);
const showBar = tx.state <= 1;
const statText = tx.state === 1 ? esc(tx.speedStr)
: tx.state === 3 ? '⚠ ' + esc(tx.error || t('transfer.failed'))
: (stateNames[tx.state] || '');
return `<div class="txfer-item"> return `<div class="txfer-item">
<div class="txfer-row"> <div class="txfer-row">
<span class="txfer-dir ${dc}">${dir}</span> <span class="txfer-dir ${dc}">${esc(dir)}</span>
<span class="txfer-name" title="${esc(t.filename)}">${esc(t.filename)}</span> <span class="txfer-name" title="${esc(tx.filename)}">${esc(tx.filename)}</span>
<span class="txfer-stat">${statText}</span> <span class="txfer-stat">${statText}</span>
${showBar ? `<button class="txfer-cancel" data-id="${esc(t.id)}">×</button>` : ''} ${showBar ? `<button class="txfer-cancel" data-id="${esc(tx.id)}">×</button>` : ''}
</div> </div>
${showBar ? `<div class="pbar"><div class="pbar-fill" style="width:${pct}%"></div></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 class="txfer-meta">${pct}%${tx.etaStr ? ' · ' + tx.etaStr : ''}</div>` : ''}
</div>`; </div>`;
}).join(''); }).join('');
list.querySelectorAll('.txfer-cancel').forEach(btn => list.querySelectorAll('.txfer-cancel').forEach(btn =>
btn.addEventListener('click', () => call('cancel_transfer', {id: btn.dataset.id}))); btn.addEventListener('click', () => call('cancel_transfer', {id: btn.dataset.id})));
} }
// ── Status bar ──────────────────────────────────────────────────────────────── function statusArgsMap(args) {
const a = args || [];
return {
name: a[0] || '',
error: a[0] || '',
label: a[0] || '',
n: a[0] || '',
};
}
function renderStatusBar() { function renderStatusBar() {
const lf = (S.localFiles || []).length;
const rf = (S.remoteFiles || []).length; const rf = (S.remoteFiles || []).length;
document.getElementById('sb-left').textContent = `${lf} item${lf !== 1 ? 's' : ''}`; document.getElementById('sb-left').textContent = S.connected
document.getElementById('sb-right').textContent = S.connected ? `${rf} item${rf !== 1 ? 's' : ''}` : ''; ? tPlural('items.count', 'items.count_plural', rf)
: '';
document.getElementById('sb-right').textContent = '';
let center = ''; let center = '';
if (S.statusTimer > 0 && S.statusMessage) { const skipStatus = S.statusKey === 'status.connected' || S.statusKey === 'status.disconnected';
center = S.statusMessage; if (S.statusTimer > 0 && S.statusKey && !skipStatus) {
center = t(S.statusKey, statusArgsMap(S.statusArgs));
} else if (S.connected) { } else if (S.connected) {
const act = (S.transfers || []).filter(t => t.state === 1); const act = (S.transfers || []).filter(tx => tx.state === 1);
if (act.length) { if (act.length) {
const spd = act.reduce((s, t) => s + (t.speedBps || 0), 0); const spd = act.reduce((s, tx) => s + (tx.speedBps || 0), 0);
center = `${act.length} active · ` + (spd > 1048576 const speed = spd > 1048576
? (spd/1048576).toFixed(0) + ' MB/s' ? (spd/1048576).toFixed(0) + ' MB/s'
: (spd/1024).toFixed(0) + ' KB/s'); : (spd/1024).toFixed(0) + ' KB/s';
center = t('status.active', { n: act.length, speed });
} }
} }
document.getElementById('sb-center').textContent = center; document.getElementById('sb-center').textContent = center;
} }
// ── Context menus ───────────────────────────────────────────────────────────── let _ctxFile = null;
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) { function showRemoteCtxMenu(file, e) {
_ctxFile = file; _ctxFile = file;
_ctxIsLocal = false;
if (!S.selRemote.has(String(file.handle))) { if (!S.selRemote.has(String(file.handle))) {
S.selRemote.clear(); S.selRemote.clear();
S.selRemote.add(String(file.handle)); S.selRemote.add(String(file.handle));
applySelectionToDOM(document.getElementById('remote-files'), S.selRemote); applySelectionToDOM(document.getElementById('remote-files'), S.selRemote);
} }
const m = document.getElementById('ctx-menu'); 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.left = e.clientX + 'px';
m.style.top = e.clientY + 'px'; m.style.top = e.clientY + 'px';
m.classList.remove('hidden'); m.classList.remove('hidden');
@@ -550,58 +559,50 @@ document.addEventListener('click', e => {
}); });
document.addEventListener('contextmenu', e => { if (!e.target.closest('.frow')) { e.preventDefault(); 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() { window.omniClearSelection = function() {
const sel = window.getSelection(); const sel = window.getSelection();
if (sel && sel.rangeCount) sel.removeAllRanges(); if (sel && sel.rangeCount) sel.removeAllRanges();
}; };
window.omniRemoteDragFilesAt = function(x, y) { function exportDragFilesForRow(row) {
if (!S.connected) return ''; if (!S.connected || !row) return [];
const hit = document.elementFromPoint(x, y); const files = sortedRemoteFiles();
const row = hit?.closest('#remote-files .frow'); const f = files[+row.dataset.idx];
if (!row || row.dataset.isDir === '1') return ''; if (!f) return [];
const f = S.remoteFiles[+row.dataset.idx];
if (!f || f.isDir) return '';
const key = String(f.handle); const key = String(f.handle);
const dragFiles = S.selRemote.has(key) const dragFiles = S.selRemote.has(key)
? S.remoteFiles.filter(rf => S.selRemote.has(String(rf.handle)) && !rf.isDir) ? files.filter(rf => S.selRemote.has(String(rf.handle)))
: [f]; : [f];
if (!dragFiles.length) return ''; return dragFiles.map(df => ({
return JSON.stringify(dragFiles.map(df => ({ handle: df.handle,
handle: df.handle, storageId: df.storageId, name: df.name, size: df.size, storageId: df.storageId,
}))); name: df.name,
}; size: df.size || 0,
isDir: !!df.isDir,
}));
}
// Block text selection and text-drag; only explicit file drags allowed.
document.addEventListener('selectstart', e => { document.addEventListener('selectstart', e => {
if (e.target.closest('.file-list')) e.preventDefault(); if (e.target.closest('.file-list')) e.preventDefault();
}, true); }, true);
document.addEventListener('mousedown', e => { document.addEventListener('mousedown', e => {
if (e.button !== 0) return; if (e.button !== 0) return;
if (e.target.closest('.file-list .frow')) e.preventDefault(); const row = e.target.closest('#remote-files .frow');
if (!row) return;
e.preventDefault();
const files = exportDragFilesForRow(row);
if (!files.length) return;
call('prepare_export_drag', { files, x: e.clientX, y: e.clientY });
}, true); }, true);
document.addEventListener('dragstart', e => { document.addEventListener('dragstart', e => {
const row = e.target.closest('.file-list .frow'); if (e.target.closest('.file-list .frow')) e.preventDefault();
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); }, true);
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', async () => {
document.getElementById('btn-local-up') .addEventListener('click', () => call('navigate_local_up')); await initI18n();
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-up') .addEventListener('click', () => call('navigate_remote_up'));
document.getElementById('btn-remote-refresh').addEventListener('click', () => call('refresh_remote')); document.getElementById('btn-remote-refresh').addEventListener('click', () => call('refresh_remote'));
document.getElementById('btn-scan') .addEventListener('click', () => call('reconnect')); document.getElementById('btn-scan') .addEventListener('click', () => call('reconnect'));
@@ -609,7 +610,6 @@ document.addEventListener('DOMContentLoaded', () => {
document.getElementById('btn-cancel-all') .addEventListener('click', () => call('cancel_all')); document.getElementById('btn-cancel-all') .addEventListener('click', () => call('cancel_all'));
document.getElementById('storage-sel') .addEventListener('change', e => call('set_storage', {storageId: +e.target.value})); document.getElementById('storage-sel') .addEventListener('change', e => call('set_storage', {storageId: +e.target.value}));
// Context menu actions
const bindCtx = (id, fn) => { const bindCtx = (id, fn) => {
document.getElementById(id).addEventListener('click', e => { document.getElementById(id).addEventListener('click', e => {
e.stopPropagation(); e.stopPropagation();
@@ -617,27 +617,14 @@ document.addEventListener('DOMContentLoaded', () => {
}); });
}; };
bindCtx('ctx-upload', () => { bindCtx('ctx-delete', async () => {
if (!_ctxFile) return; if (!_ctxFile) return;
const uploadFiles = S.selLocal.size > 1 && S.selLocal.has(_ctxFile.path) const multi = S.selRemote.size > 1 && S.selRemote.has(String(_ctxFile.handle));
? S.localFiles.filter(f => S.selLocal.has(f.path)) const message = multi
: [_ctxFile]; ? t('confirm.delete_items', { n: S.selRemote.size })
uploadFiles.forEach(f => call('start_upload', {srcPath: f.path})); : t('confirm.delete', { label: `"${_ctxFile.name}"` });
}); if (await confirmDialog(message)) {
bindCtx('ctx-download', () => { const handles = multi
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) ? S.remoteFiles.filter(f => S.selRemote.has(String(f.handle))).map(f => f.handle)
: [_ctxFile.handle]; : [_ctxFile.handle];
handles.forEach(h => call('delete_remote', {handle: h})); handles.forEach(h => call('delete_remote', {handle: h}));
@@ -645,7 +632,6 @@ document.addEventListener('DOMContentLoaded', () => {
document.getElementById('ctx-menu').classList.add('hidden'); document.getElementById('ctx-menu').classList.add('hidden');
}); });
// Keyboard
document.addEventListener('keydown', e => { document.addEventListener('keydown', e => {
if (e.target.matches('input,textarea,select')) return; if (e.target.matches('input,textarea,select')) return;
if (e.key === 'Backspace' || e.key === 'Delete') { if (e.key === 'Backspace' || e.key === 'Delete') {
@@ -654,24 +640,14 @@ document.addEventListener('DOMContentLoaded', () => {
if (e.metaKey && e.key === 'r') { if (e.metaKey && e.key === 'r') {
e.preventDefault(); e.preventDefault();
call('refresh_remote'); call('refresh_remote');
call('refresh_local');
} }
// Cmd+A = select all
if (e.metaKey && e.key === 'a') { if (e.metaKey && e.key === 'a') {
e.preventDefault(); 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))); S.remoteFiles.forEach(f => S.selRemote.add(String(f.handle)));
applySelectionToDOM(document.getElementById('remote-files'), S.selRemote); applySelectionToDOM(document.getElementById('remote-files'), S.selRemote);
} }
}
// Escape = deselect
if (e.key === 'Escape') { if (e.key === 'Escape') {
S.selLocal.clear(); S.selRemote.clear(); S.selRemote.clear();
applySelectionToDOM(document.getElementById('local-files'), S.selLocal);
applySelectionToDOM(document.getElementById('remote-files'), S.selRemote); applySelectionToDOM(document.getElementById('remote-files'), S.selRemote);
} }
}); });

View File

@@ -9,57 +9,31 @@
<body> <body>
<div id="titlebar"> <div id="titlebar">
<div class="tl-spacer"></div> <div class="tl-spacer"></div>
<span id="app-title">OmniMTP</span> <span id="app-title" data-i18n="app.title">OmniMTP</span>
<div id="conn-badge" class="badge disconnected">No Device</div> <div id="conn-badge" class="badge disconnected" data-i18n="badge.no_device">No Device</div>
</div> </div>
<div id="device-bar"> <div id="device-bar">
<div class="device-left"> <div class="device-left">
<button class="action-btn" id="btn-open-picker">Add Files</button> <button class="action-btn" id="btn-open-picker" data-i18n="btn.add_files">Add Files</button>
</div> </div>
<div class="device-center" id="device-status-text">Waiting for Nintendo Switch…</div> <div class="device-center" id="device-status-text" data-i18n="device.waiting">Waiting for Nintendo Switch…</div>
<div class="device-right"> <div class="device-right">
<button class="action-btn primary" id="btn-scan">Scan</button> <button class="action-btn primary" id="btn-scan" data-i18n="btn.scan">Scan</button>
</div> </div>
</div> </div>
<div id="main"> <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" id="panel-remote">
<div class="panel-toolbar"> <div class="panel-toolbar">
<div class="nav-group"> <div class="nav-group">
<button class="nav-btn" id="btn-remote-up" title="Go Up" disabled> <button class="nav-btn" id="btn-remote-up" data-i18n-title="btn.go_up" title="Go Up" disabled>
<svg viewBox="0 0 16 16"><path d="M8 3L3 8h3v5h4V8h3L8 3z" fill="currentColor"/></svg> <svg viewBox="0 0 16 16"><path d="M8 3L3 8h3v5h4V8h3L8 3z" fill="currentColor"/></svg>
</button> </button>
</div> </div>
<select id="storage-sel" class="storage-select"></select> <select id="storage-sel" class="storage-select"></select>
<div id="remote-breadcrumb" class="breadcrumb"></div> <div id="remote-breadcrumb" class="breadcrumb"></div>
<button class="nav-btn icon-only" id="btn-remote-refresh" title="Refresh"> <button class="nav-btn icon-only" id="btn-remote-refresh" data-i18n-title="btn.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> <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> </button>
</div> </div>
@@ -70,8 +44,8 @@
<line x1="24" y1="12" x2="24" y2="18"/> <line x1="24" y1="12" x2="24" y2="18"/>
<circle cx="24" cy="22" r="1.5" fill="currentColor"/> <circle cx="24" cy="22" r="1.5" fill="currentColor"/>
</svg> </svg>
<div class="empty-title">No Switch Connected</div> <div class="empty-title" data-i18n="empty.no_switch_title">No Switch Connected</div>
<div class="empty-sub">Connect via USB-C and open DBI or Sphaira MTP</div> <div class="empty-sub" data-i18n="empty.no_switch_sub">Connect via USB-C and open DBI or Sphaira MTP</div>
</div> </div>
</div> </div>
</div> </div>
@@ -80,8 +54,8 @@
<!-- Transfers --> <!-- Transfers -->
<div id="transfers-panel"> <div id="transfers-panel">
<div id="transfers-header"> <div id="transfers-header">
<span id="transfers-label">Transfers</span> <span id="transfers-label" data-i18n="transfers">Transfers</span>
<button class="action-btn danger small" id="btn-cancel-all" style="display:none">Cancel All</button> <button class="action-btn danger small" id="btn-cancel-all" data-i18n="btn.cancel_all" style="display:none">Cancel All</button>
</div> </div>
<div id="transfers-list"></div> <div id="transfers-list"></div>
</div> </div>
@@ -95,10 +69,7 @@
<!-- Context menu --> <!-- Context menu -->
<div id="ctx-menu" class="ctx-menu hidden"> <div id="ctx-menu" class="ctx-menu hidden">
<div class="ctx-item" id="ctx-upload">Upload to Switch</div> <div class="ctx-item danger" id="ctx-delete" data-i18n="ctx.delete">Delete…</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> </div>
<script src="app.js"></script> <script src="app.js"></script>

View File

@@ -0,0 +1,50 @@
{
"app.title": "OmniMTP",
"badge.no_device": "Kein Gerät",
"device.waiting": "Warte auf Nintendo Switch…",
"device.connected": "{name} verbunden",
"device.nintendo_switch": "Nintendo Switch",
"btn.add_files": "Dateien hinzufügen",
"btn.scan": "Suchen",
"btn.cancel_all": "Alle abbrechen",
"btn.go_up": "Nach oben",
"btn.refresh": "Aktualisieren",
"empty.no_switch_title": "Keine Switch verbunden",
"empty.no_switch_sub": "Per USB-C verbinden und DBI oder Sphaira MTP öffnen",
"empty.folder": "Leerer Ordner",
"loading": "Laden…",
"col.name": "Name",
"col.size": "Größe",
"col.modified": "Geändert",
"transfers": "Übertragungen",
"transfers.count": "{n} Übertragung",
"transfers.count_plural": "{n} Übertragungen",
"transfer.queued": "Warteschlange",
"transfer.done": "Fertig",
"transfer.failed": "Fehlgeschlagen",
"transfer.cancelled": "Abgebrochen",
"transfer.dl": "DL",
"transfer.up": "UP",
"ctx.delete": "Löschen…",
"confirm.delete": "{label} löschen?",
"confirm.delete_items": "{n} Elemente löschen?",
"confirm.delete_btn": "Löschen",
"confirm.cancel_btn": "Abbrechen",
"confirm.ok_btn": "OK",
"items.count": "{n} Element",
"items.count_plural": "{n} Elemente",
"status.active": "{n} aktiv · {speed}",
"status.connected": "Verbunden {name}",
"status.disconnected": "Switch getrennt",
"status.listing_error": "Fehler beim Auflisten: {error}",
"status.downloading": "Herunterladen {name}",
"status.uploading": "Hochladen {name}",
"menu.options": "Optionen",
"menu.language": "Sprache",
"menu.language_system": "Systemstandard",
"menu.quit": "OmniMTP beenden",
"menu.about": "Über OmniMTP",
"about.tagline": "Nintendo Switch MTP-Client für macOS",
"about.version": "Version {version}",
"about.copyright": "Copyright © 2026 NiklasCFW"
}

View File

@@ -0,0 +1,50 @@
{
"app.title": "OmniMTP",
"badge.no_device": "No Device",
"device.waiting": "Waiting for Nintendo Switch…",
"device.connected": "{name} connected",
"device.nintendo_switch": "Nintendo Switch",
"btn.add_files": "Add Files",
"btn.scan": "Scan",
"btn.cancel_all": "Cancel All",
"btn.go_up": "Go Up",
"btn.refresh": "Refresh",
"empty.no_switch_title": "No Switch Connected",
"empty.no_switch_sub": "Connect via USB-C and open DBI or Sphaira MTP",
"empty.folder": "Empty folder",
"loading": "Loading…",
"col.name": "Name",
"col.size": "Size",
"col.modified": "Modified",
"transfers": "Transfers",
"transfers.count": "{n} Transfer",
"transfers.count_plural": "{n} Transfers",
"transfer.queued": "Queued",
"transfer.done": "Done",
"transfer.failed": "Failed",
"transfer.cancelled": "Cancelled",
"transfer.dl": "DL",
"transfer.up": "UP",
"ctx.delete": "Delete…",
"confirm.delete": "Delete {label}?",
"confirm.delete_items": "Delete {n} items?",
"confirm.delete_btn": "Delete",
"confirm.cancel_btn": "Cancel",
"confirm.ok_btn": "OK",
"items.count": "{n} item",
"items.count_plural": "{n} items",
"status.active": "{n} active · {speed}",
"status.connected": "Connected {name}",
"status.disconnected": "Switch disconnected",
"status.listing_error": "Error listing: {error}",
"status.downloading": "Downloading {name}",
"status.uploading": "Uploading {name}",
"menu.options": "Options",
"menu.language": "Language",
"menu.language_system": "System Default",
"menu.quit": "Quit OmniMTP",
"menu.about": "About OmniMTP",
"about.tagline": "Nintendo Switch MTP Client for macOS",
"about.version": "Version {version}",
"about.copyright": "Copyright © 2026 NiklasCFW"
}

View File

@@ -0,0 +1,50 @@
{
"app.title": "OmniMTP",
"badge.no_device": "Sin dispositivo",
"device.waiting": "Esperando Nintendo Switch…",
"device.connected": "{name} conectada",
"device.nintendo_switch": "Nintendo Switch",
"btn.add_files": "Añadir archivos",
"btn.scan": "Buscar",
"btn.cancel_all": "Cancelar todo",
"btn.go_up": "Subir",
"btn.refresh": "Actualizar",
"empty.no_switch_title": "Ninguna Switch conectada",
"empty.no_switch_sub": "Conecta por USB-C y abre DBI o Sphaira MTP",
"empty.folder": "Carpeta vacía",
"loading": "Cargando…",
"col.name": "Nombre",
"col.size": "Tamaño",
"col.modified": "Modificado",
"transfers": "Transferencias",
"transfers.count": "{n} transferencia",
"transfers.count_plural": "{n} transferencias",
"transfer.queued": "En cola",
"transfer.done": "Hecho",
"transfer.failed": "Error",
"transfer.cancelled": "Cancelado",
"transfer.dl": "DL",
"transfer.up": "UP",
"ctx.delete": "Eliminar…",
"confirm.delete": "¿Eliminar {label}?",
"confirm.delete_items": "¿Eliminar {n} elementos?",
"confirm.delete_btn": "Eliminar",
"confirm.cancel_btn": "Cancelar",
"confirm.ok_btn": "OK",
"items.count": "{n} elemento",
"items.count_plural": "{n} elementos",
"status.active": "{n} activo · {speed}",
"status.connected": "Conectado {name}",
"status.disconnected": "Switch desconectada",
"status.listing_error": "Error al listar: {error}",
"status.downloading": "Descargando {name}",
"status.uploading": "Subiendo {name}",
"menu.options": "Opciones",
"menu.language": "Idioma",
"menu.language_system": "Idioma del sistema",
"menu.quit": "Salir de OmniMTP",
"menu.about": "Acerca de OmniMTP",
"about.tagline": "Cliente MTP de Nintendo Switch para macOS",
"about.version": "Versión {version}",
"about.copyright": "Copyright © 2026 NiklasCFW"
}

View File

@@ -0,0 +1,50 @@
{
"app.title": "OmniMTP",
"badge.no_device": "Aucun appareil",
"device.waiting": "En attente de la Nintendo Switch…",
"device.connected": "{name} connectée",
"device.nintendo_switch": "Nintendo Switch",
"btn.add_files": "Ajouter des fichiers",
"btn.scan": "Rechercher",
"btn.cancel_all": "Tout annuler",
"btn.go_up": "Remonter",
"btn.refresh": "Actualiser",
"empty.no_switch_title": "Aucune Switch connectée",
"empty.no_switch_sub": "Connectez en USB-C et ouvrez DBI ou Sphaira MTP",
"empty.folder": "Dossier vide",
"loading": "Chargement…",
"col.name": "Nom",
"col.size": "Taille",
"col.modified": "Modifié",
"transfers": "Transferts",
"transfers.count": "{n} transfert",
"transfers.count_plural": "{n} transferts",
"transfer.queued": "En file",
"transfer.done": "Terminé",
"transfer.failed": "Échoué",
"transfer.cancelled": "Annulé",
"transfer.dl": "DL",
"transfer.up": "UP",
"ctx.delete": "Supprimer…",
"confirm.delete": "Supprimer {label} ?",
"confirm.delete_items": "Supprimer {n} éléments ?",
"confirm.delete_btn": "Supprimer",
"confirm.cancel_btn": "Annuler",
"confirm.ok_btn": "OK",
"items.count": "{n} élément",
"items.count_plural": "{n} éléments",
"status.active": "{n} actif · {speed}",
"status.connected": "Connecté {name}",
"status.disconnected": "Switch déconnectée",
"status.listing_error": "Erreur de listage : {error}",
"status.downloading": "Téléchargement {name}",
"status.uploading": "Envoi {name}",
"menu.options": "Options",
"menu.language": "Langue",
"menu.language_system": "Langue du système",
"menu.quit": "Quitter OmniMTP",
"menu.about": "À propos dOmniMTP",
"about.tagline": "Client MTP Nintendo Switch pour macOS",
"about.version": "Version {version}",
"about.copyright": "Copyright © 2026 NiklasCFW"
}

View File

@@ -0,0 +1,50 @@
{
"app.title": "OmniMTP",
"badge.no_device": "Nessun dispositivo",
"device.waiting": "In attesa della Nintendo Switch…",
"device.connected": "{name} connessa",
"device.nintendo_switch": "Nintendo Switch",
"btn.add_files": "Aggiungi file",
"btn.scan": "Cerca",
"btn.cancel_all": "Annulla tutto",
"btn.go_up": "Su",
"btn.refresh": "Aggiorna",
"empty.no_switch_title": "Nessuna Switch collegata",
"empty.no_switch_sub": "Collega via USB-C e apri DBI o Sphaira MTP",
"empty.folder": "Cartella vuota",
"loading": "Caricamento…",
"col.name": "Nome",
"col.size": "Dimensione",
"col.modified": "Modificato",
"transfers": "Trasferimenti",
"transfers.count": "{n} trasferimento",
"transfers.count_plural": "{n} trasferimenti",
"transfer.queued": "In coda",
"transfer.done": "Completato",
"transfer.failed": "Non riuscito",
"transfer.cancelled": "Annullato",
"transfer.dl": "DL",
"transfer.up": "UP",
"ctx.delete": "Elimina…",
"confirm.delete": "Eliminare {label}?",
"confirm.delete_items": "Eliminare {n} elementi?",
"confirm.delete_btn": "Elimina",
"confirm.cancel_btn": "Annulla",
"confirm.ok_btn": "OK",
"items.count": "{n} elemento",
"items.count_plural": "{n} elementi",
"status.active": "{n} attivi · {speed}",
"status.connected": "Connesso {name}",
"status.disconnected": "Switch scollegata",
"status.listing_error": "Errore elenco: {error}",
"status.downloading": "Download {name}",
"status.uploading": "Upload {name}",
"menu.options": "Opzioni",
"menu.language": "Lingua",
"menu.language_system": "Predefinita di sistema",
"menu.quit": "Esci da OmniMTP",
"menu.about": "Informazioni su OmniMTP",
"about.tagline": "Client MTP Nintendo Switch per macOS",
"about.version": "Versione {version}",
"about.copyright": "Copyright © 2026 NiklasCFW"
}

View File

@@ -0,0 +1,50 @@
{
"app.title": "OmniMTP",
"badge.no_device": "デバイスなし",
"device.waiting": "Nintendo Switchを待機中…",
"device.connected": "{name} 接続中",
"device.nintendo_switch": "Nintendo Switch",
"btn.add_files": "ファイルを追加",
"btn.scan": "スキャン",
"btn.cancel_all": "すべてキャンセル",
"btn.go_up": "上へ",
"btn.refresh": "更新",
"empty.no_switch_title": "Switch未接続",
"empty.no_switch_sub": "USB-Cで接続し、DBIまたはSphaira MTPを開いてください",
"empty.folder": "空のフォルダ",
"loading": "読み込み中…",
"col.name": "名前",
"col.size": "サイズ",
"col.modified": "更新日時",
"transfers": "転送",
"transfers.count": "{n} 件の転送",
"transfers.count_plural": "{n} 件の転送",
"transfer.queued": "待機中",
"transfer.done": "完了",
"transfer.failed": "失敗",
"transfer.cancelled": "キャンセル",
"transfer.dl": "DL",
"transfer.up": "UP",
"ctx.delete": "削除…",
"confirm.delete": "{label} を削除しますか?",
"confirm.delete_items": "{n} 項目を削除しますか?",
"confirm.delete_btn": "削除",
"confirm.cancel_btn": "キャンセル",
"confirm.ok_btn": "OK",
"items.count": "{n} 項目",
"items.count_plural": "{n} 項目",
"status.active": "{n} 実行中 · {speed}",
"status.connected": "接続済み {name}",
"status.disconnected": "Switch切断",
"status.listing_error": "一覧エラー: {error}",
"status.downloading": "ダウンロード中 {name}",
"status.uploading": "アップロード中 {name}",
"menu.options": "オプション",
"menu.language": "言語",
"menu.language_system": "システムデフォルト",
"menu.quit": "OmniMTPを終了",
"menu.about": "OmniMTPについて",
"about.tagline": "macOS向け Nintendo Switch MTPクライアント",
"about.version": "バージョン {version}",
"about.copyright": "Copyright © 2026 NiklasCFW"
}

View File

@@ -0,0 +1,50 @@
{
"app.title": "OmniMTP",
"badge.no_device": "Geen apparaat",
"device.waiting": "Wachten op Nintendo Switch…",
"device.connected": "{name} verbonden",
"device.nintendo_switch": "Nintendo Switch",
"btn.add_files": "Bestanden toevoegen",
"btn.scan": "Zoeken",
"btn.cancel_all": "Alles annuleren",
"btn.go_up": "Omhoog",
"btn.refresh": "Vernieuwen",
"empty.no_switch_title": "Geen Switch verbonden",
"empty.no_switch_sub": "Verbind via USB-C en open DBI of Sphaira MTP",
"empty.folder": "Lege map",
"loading": "Laden…",
"col.name": "Naam",
"col.size": "Grootte",
"col.modified": "Gewijzigd",
"transfers": "Overdrachten",
"transfers.count": "{n} overdracht",
"transfers.count_plural": "{n} overdrachten",
"transfer.queued": "Wachtrij",
"transfer.done": "Klaar",
"transfer.failed": "Mislukt",
"transfer.cancelled": "Geannuleerd",
"transfer.dl": "DL",
"transfer.up": "UP",
"ctx.delete": "Verwijderen…",
"confirm.delete": "{label} verwijderen?",
"confirm.delete_items": "{n} items verwijderen?",
"confirm.delete_btn": "Verwijderen",
"confirm.cancel_btn": "Annuleren",
"confirm.ok_btn": "OK",
"items.count": "{n} item",
"items.count_plural": "{n} items",
"status.active": "{n} actief · {speed}",
"status.connected": "Verbonden {name}",
"status.disconnected": "Switch verbroken",
"status.listing_error": "Fout bij weergeven: {error}",
"status.downloading": "Downloaden {name}",
"status.uploading": "Uploaden {name}",
"menu.options": "Opties",
"menu.language": "Taal",
"menu.language_system": "Systeemstandaard",
"menu.quit": "OmniMTP afsluiten",
"menu.about": "Over OmniMTP",
"about.tagline": "Nintendo Switch MTP-client voor macOS",
"about.version": "Versie {version}",
"about.copyright": "Copyright © 2026 NiklasCFW"
}

View File

@@ -0,0 +1,50 @@
{
"app.title": "OmniMTP",
"badge.no_device": "Sem dispositivo",
"device.waiting": "Aguardando Nintendo Switch…",
"device.connected": "{name} conectada",
"device.nintendo_switch": "Nintendo Switch",
"btn.add_files": "Adicionar arquivos",
"btn.scan": "Procurar",
"btn.cancel_all": "Cancelar tudo",
"btn.go_up": "Subir",
"btn.refresh": "Atualizar",
"empty.no_switch_title": "Nenhuma Switch conectada",
"empty.no_switch_sub": "Conecte via USB-C e abra o DBI ou Sphaira MTP",
"empty.folder": "Pasta vazia",
"loading": "Carregando…",
"col.name": "Nome",
"col.size": "Tamanho",
"col.modified": "Modificado",
"transfers": "Transferências",
"transfers.count": "{n} transferência",
"transfers.count_plural": "{n} transferências",
"transfer.queued": "Na fila",
"transfer.done": "Concluído",
"transfer.failed": "Falhou",
"transfer.cancelled": "Cancelado",
"transfer.dl": "DL",
"transfer.up": "UP",
"ctx.delete": "Excluir…",
"confirm.delete": "Excluir {label}?",
"confirm.delete_items": "Excluir {n} itens?",
"confirm.delete_btn": "Excluir",
"confirm.cancel_btn": "Cancelar",
"confirm.ok_btn": "OK",
"items.count": "{n} item",
"items.count_plural": "{n} itens",
"status.active": "{n} ativo · {speed}",
"status.connected": "Conectado {name}",
"status.disconnected": "Switch desconectada",
"status.listing_error": "Erro ao listar: {error}",
"status.downloading": "Baixando {name}",
"status.uploading": "Enviando {name}",
"menu.options": "Opções",
"menu.language": "Idioma",
"menu.language_system": "Padrão do sistema",
"menu.quit": "Sair do OmniMTP",
"menu.about": "Sobre o OmniMTP",
"about.tagline": "Cliente MTP Nintendo Switch para macOS",
"about.version": "Versão {version}",
"about.copyright": "Copyright © 2026 NiklasCFW"
}

View File

@@ -0,0 +1,50 @@
{
"app.title": "OmniMTP",
"badge.no_device": "Нет устройства",
"device.waiting": "Ожидание Nintendo Switch…",
"device.connected": "{name} подключена",
"device.nintendo_switch": "Nintendo Switch",
"btn.add_files": "Добавить файлы",
"btn.scan": "Поиск",
"btn.cancel_all": "Отменить всё",
"btn.go_up": "Вверх",
"btn.refresh": "Обновить",
"empty.no_switch_title": "Switch не подключена",
"empty.no_switch_sub": "Подключите по USB-C и откройте DBI или Sphaira MTP",
"empty.folder": "Пустая папка",
"loading": "Загрузка…",
"col.name": "Имя",
"col.size": "Размер",
"col.modified": "Изменён",
"transfers": "Передачи",
"transfers.count": "{n} передача",
"transfers.count_plural": "{n} передач",
"transfer.queued": "В очереди",
"transfer.done": "Готово",
"transfer.failed": "Ошибка",
"transfer.cancelled": "Отменено",
"transfer.dl": "DL",
"transfer.up": "UP",
"ctx.delete": "Удалить…",
"confirm.delete": "Удалить {label}?",
"confirm.delete_items": "Удалить {n} элементов?",
"confirm.delete_btn": "Удалить",
"confirm.cancel_btn": "Отмена",
"confirm.ok_btn": "OK",
"items.count": "{n} элемент",
"items.count_plural": "{n} элементов",
"status.active": "{n} активно · {speed}",
"status.connected": "Подключено {name}",
"status.disconnected": "Switch отключена",
"status.listing_error": "Ошибка списка: {error}",
"status.downloading": "Скачивание {name}",
"status.uploading": "Загрузка {name}",
"menu.options": "Параметры",
"menu.language": "Язык",
"menu.language_system": "Системный",
"menu.quit": "Завершить OmniMTP",
"menu.about": "О программе OmniMTP",
"about.tagline": "MTP-клиент Nintendo Switch для macOS",
"about.version": "Версия {version}",
"about.copyright": "Copyright © 2026 NiklasCFW"
}

View File

@@ -0,0 +1,50 @@
{
"app.title": "OmniMTP",
"badge.no_device": "无设备",
"device.waiting": "正在等待 Nintendo Switch…",
"device.connected": "{name} 已连接",
"device.nintendo_switch": "Nintendo Switch",
"btn.add_files": "添加文件",
"btn.scan": "扫描",
"btn.cancel_all": "全部取消",
"btn.go_up": "上级",
"btn.refresh": "刷新",
"empty.no_switch_title": "未连接 Switch",
"empty.no_switch_sub": "通过 USB-C 连接并打开 DBI 或 Sphaira MTP",
"empty.folder": "空文件夹",
"loading": "加载中…",
"col.name": "名称",
"col.size": "大小",
"col.modified": "修改时间",
"transfers": "传输",
"transfers.count": "{n} 个传输",
"transfers.count_plural": "{n} 个传输",
"transfer.queued": "排队中",
"transfer.done": "完成",
"transfer.failed": "失败",
"transfer.cancelled": "已取消",
"transfer.dl": "下载",
"transfer.up": "上传",
"ctx.delete": "删除…",
"confirm.delete": "删除 {label}",
"confirm.delete_items": "删除 {n} 项?",
"confirm.delete_btn": "删除",
"confirm.cancel_btn": "取消",
"confirm.ok_btn": "确定",
"items.count": "{n} 项",
"items.count_plural": "{n} 项",
"status.active": "{n} 进行中 · {speed}",
"status.connected": "已连接 {name}",
"status.disconnected": "Switch 已断开",
"status.listing_error": "列出错误:{error}",
"status.downloading": "正在下载 {name}",
"status.uploading": "正在上传 {name}",
"menu.options": "选项",
"menu.language": "语言",
"menu.language_system": "跟随系统",
"menu.quit": "退出 OmniMTP",
"menu.about": "关于 OmniMTP",
"about.tagline": "适用于 macOS 的 Nintendo Switch MTP 客户端",
"about.version": "版本 {version}",
"about.copyright": "Copyright © 2026 NiklasCFW"
}