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_WEBKIT WebKit REQUIRED)
find_library(FW_CORETEXT CoreText REQUIRED)
find_library(FW_UTTYPES UniformTypeIdentifiers REQUIRED)
# ─── Application ─────────────────────────────────────────────────────────────
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/
file(GLOB WEBROOT_FILES "src/ui/webroot/*")
file(GLOB WEBROOT_LANG_FILES "src/ui/webroot/lang/*.json")
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)
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
set(APP_ICON_SOURCE "${CMAKE_SOURCE_DIR}/assets/AppIcon.png")
@@ -103,7 +112,7 @@ add_custom_command(
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
${CMAKE_SOURCE_DIR}/src
@@ -111,7 +120,7 @@ target_include_directories(OmniMTP PRIVATE
)
target_link_libraries(OmniMTP PRIVATE
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
@@ -125,7 +134,7 @@ set_target_properties(OmniMTP PROPERTIES
MACOSX_BUNDLE_BUNDLE_NAME "OmniMTP"
MACOSX_BUNDLE_BUNDLE_VERSION "${PROJECT_VERSION}"
MACOSX_BUNDLE_SHORT_VERSION_STRING "${PROJECT_VERSION}"
MACOSX_BUNDLE_COPYRIGHT "Copyright © 2025 OmniMTP"
MACOSX_BUNDLE_COPYRIGHT "Copyright © 2026 NiklasCFW"
MACOSX_BUNDLE_INFO_PLIST "${CMAKE_SOURCE_DIR}/cmake/Info.plist.in"
MACOSX_BUNDLE_ICON_FILE AppIcon
)

View File

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

View File

@@ -7,7 +7,6 @@
#include <algorithm>
#include <chrono>
#include <thread>
#include <sys/stat.h>
#include <cstdlib>
#include <cstring>
@@ -16,29 +15,6 @@ namespace omniMTP {
// ─── Static helpers ───────────────────────────────────────────────────────────
static std::string date_str_from_fs(const fs::directory_entry& e) {
struct ::stat st{};
if (::stat(e.path().c_str(), &st) == 0) {
char buf[32];
std::strftime(buf, sizeof(buf), "%b %e, %Y", std::localtime(&st.st_mtime));
return buf;
}
return "";
}
using CrumbList = std::vector<std::pair<std::string, fs::path>>;
static CrumbList build_crumbs(const fs::path& path) {
std::vector<std::pair<std::string, fs::path>> segs;
fs::path p = path;
while (true) {
auto par = p.parent_path();
if (par == p) break; // reached root — skip it
segs.insert(segs.begin(), {p.filename().string(), p});
p = par;
}
return segs;
}
static std::string jstr(const std::string& s) {
std::string r; r.reserve(s.size()+4);
for (char c : s) {
@@ -76,12 +52,11 @@ static std::string fmt_eta(double s) {
}
// ─── 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(); }
void App::init() {
local_path_ = fs::path(std::getenv("HOME") ? std::getenv("HOME") : "/");
refresh_local(local_path_);
download_path_ = fs::path(std::getenv("HOME") ? std::getenv("HOME") : "/");
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_->set_completion_handler([this](const transfer::TransferItem& item) {
if (item.state.load() != transfer::State::Done) return;
if (item.direction == transfer::Direction::Download)
local_needs_refresh_.store(true);
else
if (item.direction != transfer::Direction::Download)
remote_needs_refresh_ = true;
});
}
@@ -162,7 +135,7 @@ void App::on_device_connected(mtp::DeviceInfo di) {
}
connected_.store(true);
remote_needs_refresh_ = true;
set_status(std::format("Connected {}", device_info_.model));
set_status("status.connected", {device_info_.model});
refresh_remote();
}
@@ -178,104 +151,7 @@ void App::on_device_disconnected() {
}
connected_.store(false);
remote_needs_refresh_ = false;
set_status("Switch disconnected");
}
// ─── Local filesystem ─────────────────────────────────────────────────────────
void App::refresh_local(const fs::path& path) {
local_entries_.clear();
try {
for (auto& e : fs::directory_iterator(path,
fs::directory_options::skip_permission_denied)) {
LocalEntry le{};
le.path = e.path();
le.name = e.path().filename().string();
le.is_dir = e.is_directory();
if (!le.is_dir) { le.size = e.file_size(); le.size_str = fmt_size(le.size); }
le.date_str = date_str_from_fs(e);
local_entries_.push_back(std::move(le));
}
std::sort(local_entries_.begin(), local_entries_.end(), [](auto& a, auto& b) {
if (a.is_dir != b.is_dir) return a.is_dir > b.is_dir;
return a.name < b.name;
});
} catch (...) {}
local_path_ = path;
}
void App::refresh_local_drives() {
local_drives_.clear();
@autoreleasepool {
NSFileManager* fm = NSFileManager.defaultManager;
NSArray* keys = @[
NSURLVolumeNameKey,
NSURLVolumeIsRemovableKey,
];
NSArray<NSURL*>* urls =
[fm mountedVolumeURLsIncludingResourceValuesForKeys:keys
options:NSVolumeEnumerationSkipHiddenVolumes];
bool has_data_volume = false;
for (NSURL* url in urls) {
if ([url.path isEqualToString:@"/System/Volumes/Data"])
has_data_volume = true;
}
for (NSURL* url in urls) {
NSString* path = url.path;
if (!path || path.length == 0) continue;
std::string mount = path.UTF8String;
if (mount == "/dev" || mount == "/home" || mount == "/net") continue;
if (mount.rfind("/private/", 0) == 0) continue;
if (mount.rfind("/System/Volumes/", 0) == 0 && mount != "/System/Volumes/Data") continue;
if (mount == "/" && has_data_volume) continue;
NSString* vol_name = nil;
NSNumber* removable = nil;
[url getResourceValue:&vol_name forKey:NSURLVolumeNameKey error:nil];
[url getResourceValue:&removable forKey:NSURLVolumeIsRemovableKey error:nil];
LocalDrive drive{};
drive.path = mount;
drive.name = vol_name.length ? vol_name.UTF8String : fs::path(mount).filename().string();
if (drive.name.empty()) drive.name = mount;
drive.removable = removable.boolValue;
local_drives_.push_back(std::move(drive));
}
}
std::sort(local_drives_.begin(), local_drives_.end(),
[](const LocalDrive& a, const LocalDrive& b) {
if (a.removable != b.removable) return a.removable < b.removable;
return a.name < b.name;
});
}
std::string App::active_local_drive_path() const {
std::error_code ec;
fs::path current = fs::weakly_canonical(local_path_, ec);
if (ec) current = local_path_;
const std::string current_str = current.string();
std::string best;
size_t best_len = 0;
for (const auto& drive : local_drives_) {
fs::path root = fs::path(drive.path);
fs::path root_canonical = fs::weakly_canonical(root, ec);
if (ec) root_canonical = root;
const std::string root_str = root_canonical.string();
const bool under =
current == root_canonical ||
current_str.rfind(root_str + "/", 0) == 0;
if (under && root_str.size() >= best_len) {
best = drive.path;
best_len = root_str.size();
}
}
if (!best.empty()) return best;
return local_drives_.empty() ? "" : local_drives_.front().path;
set_status("status.disconnected");
}
// ─── Remote filesystem ────────────────────────────────────────────────────────
@@ -305,7 +181,7 @@ void App::refresh_remote() {
return a.name < b.name;
});
} catch (const mtp::MTPException& e) {
set_status(std::format("Error listing: {}", e.what()));
set_status("status.listing_error", {e.what()});
}
remote_refreshing_ = 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) {
if (!engine_) return;
engine_->enqueue_download(handle, storage_id,
(local_path_ / filename).string(), filename, size);
set_status(std::format("Downloading {}", filename));
(download_path_ / filename).string(), filename, size);
set_status("status.downloading", {filename});
}
void App::start_upload(const fs::path& src, uint64_t sz) {
if (!engine_ || remote_nav_stack_.empty()) return;
const auto& top = remote_nav_stack_.back();
engine_->enqueue_upload(src.string(), top.handle, top.storage_id,
src.filename().string(), sz);
set_status(std::format("Uploading {}", src.filename().string()));
set_status("status.uploading", {src.filename().string()});
}
void App::set_status(const std::string& msg, float dur) {
status_msg_ = msg; status_msg_timer_ = dur;
void App::set_status(const std::string& key, std::vector<std::string> args, float dur) {
status_key_ = key;
status_args_ = std::move(args);
status_msg_timer_ = dur;
}
void App::enqueue_finder_drop(const std::string& path) {
std::lock_guard lock(finder_drops_mtx_);
finder_drops_.push_back(path);
}
void App::update_drop_zones(double rx, double ry, double rw, double rh,
double lx, double ly, double lw, double lh) {
remote_drop_zone_ = {rx, ry, rw, rh};
local_drop_zone_ = {lx, ly, lw, lh};
drop_zones_valid_ = rw > 0 && rh > 0 && lw > 0 && lh > 0;
void App::update_drop_zone(double x, double y, double w, double h) {
remote_drop_zone_ = {x, y, w, h};
drop_zone_valid_ = w > 0 && h > 0;
}
std::string App::drop_target_at(double x, double y) const {
if (!drop_zones_valid_) return "none";
auto inside = [](const DropZone& z, double px, double py) {
return px >= z.x && px <= z.x + z.w && py >= z.y && py <= z.y + z.h;
};
if (inside(remote_drop_zone_, x, y)) return "remote";
if (inside(local_drop_zone_, x, y)) return "local";
if (!drop_zone_valid_) return "none";
const auto& z = remote_drop_zone_;
if (x >= z.x && x <= z.x + z.w && y >= z.y && y <= z.y + z.h) return "remote";
return "none";
}
// ─── Web bridge API ───────────────────────────────────────────────────────────
std::string App::current_local_path() const { return local_path_.string(); }
std::string App::state_json() const {
// Process any pending finder drops
{
const_cast<App*>(this)->process_finder_drops();
}
if (local_needs_refresh_.load()) {
local_needs_refresh_.store(false);
const_cast<App*>(this)->refresh_local(local_path_);
}
if (remote_needs_refresh_ && connected_.load()) {
remote_needs_refresh_ = false;
const_cast<App*>(this)->refresh_remote();
}
const_cast<App*>(this)->refresh_local_drives();
const std::string active_drive = active_local_drive_path();
bool conn = connected_.load();
std::string dn;
@@ -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)+"\"}";
if (i+1<nav.size()) j+=",";
}
j+="],\"localDrives\":[";
for (size_t i = 0; i < local_drives_.size(); ++i) {
auto& d = local_drives_[i];
j += "{\"path\":\"" + jstr(d.path) + "\",\"name\":\"" + jstr(d.name) + "\",";
j += "\"removable\":" + std::string(d.removable ? "true" : "false") + "}";
if (i + 1 < local_drives_.size()) j += ",";
}
j += "],\"activeLocalDrive\":\"" + jstr(active_drive) + "\",";
j+="\"localPath\":\""+jstr(local_path_.string())+"\",\"localFiles\":[";
for (size_t i=0;i<local_entries_.size();++i) {
auto& e=local_entries_[i];
j+="{\"name\":\""+jstr(e.name)+"\",\"isDir\":"+std::string(e.is_dir?"true":"false")+",";
j+="\"size\":"+std::to_string(e.size)+",\"sizeStr\":\""+jstr(e.size_str)+"\",";
j+="\"date\":\""+jstr(e.date_str)+"\",\"path\":\""+jstr(e.path.string())+"\"}";
if (i+1<local_entries_.size()) j+=",";
}
j+="],\"remoteRefreshing\":"+std::string(refreshing?"true":"false")+",\"remoteFiles\":[";
for (size_t i=0;i<remote_entries_.size();++i) {
auto& e=remote_entries_[i];
@@ -443,9 +291,13 @@ std::string App::state_json() const {
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_);
j+="\"statusTimer\":"+std::string(tm)+"}";
j+="],\"statusTimer\":"+std::string(tm)+"}";
return j;
}
@@ -458,22 +310,14 @@ void App::process_finder_drops() {
for (auto& dropped : drops) {
fs::path p(dropped);
std::error_code ec;
if (fs::is_directory(p, ec)) {
refresh_local(p);
} else if (!ec && connected_.load()) {
if (fs::is_directory(p, ec)) continue;
if (!ec && connected_.load()) {
auto sz = fs::file_size(p, ec);
if (!ec) start_upload(p, sz);
}
}
}
void App::navigate_local(const std::string& p) {
refresh_local(fs::path(p == "~" ? (std::getenv("HOME") ? std::getenv("HOME") : "/") : p));
}
void App::navigate_local_up() {
if (local_path_.has_parent_path() && local_path_ != local_path_.parent_path())
refresh_local(local_path_.parent_path());
}
void App::navigate_remote(uint32_t h, uint32_t s, const std::string& name) {
remote_nav_stack_.push_back({h, s, name});
refresh_remote();
@@ -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) {
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,
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_);
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};
mtp::ProgressFn noop = [](uint64_t, uint64_t) {};
ops_->get_object(handle, dest_path, size, noop, cancel);

View File

@@ -1,13 +1,230 @@
#include "WebUI.hpp"
#import <Cocoa/Cocoa.h>
#import <WebKit/WebKit.h>
#import <UniformTypeIdentifiers/UniformTypeIdentifiers.h>
#include <string>
#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 ─────────────────────────────────────────────────────
@class OmniBridge;
@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 ─────────────────────────────────────
@interface OmniBridge : NSObject <WKScriptMessageHandler, WKNavigationDelegate, WKUIDelegate>
- (instancetype)initWithApp:(omniMTP::App*)app webView:(WKWebView*)webView;
@@ -39,6 +256,15 @@
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
// use dispatch_async to avoid blocking the JS engine during heavy ops.
dispatch_async(dispatch_get_main_queue(), ^{
@@ -56,16 +282,6 @@
[self sendRawResult:result responseId:rid];
return;
} else if ([action isEqualToString:@"navigate_local"]) {
NSString* path = body[@"path"];
if (path) _app->navigate_local(path.UTF8String);
} else if ([action isEqualToString:@"navigate_local_up"]) {
_app->navigate_local_up();
} else if ([action isEqualToString:@"refresh_local"]) {
_app->navigate_local(_app->current_local_path());
} else if ([action isEqualToString:@"navigate_remote"]) {
NSNumber* handle = body[@"handle"];
NSNumber* storageId = body[@"storageId"];
@@ -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"]) {
NSDictionary* remote = body[@"remote"];
NSDictionary* local = body[@"local"];
if ([remote isKindOfClass:[NSDictionary class]] && [local isKindOfClass:[NSDictionary class]]) {
_app->update_drop_zones(
if ([remote isKindOfClass:[NSDictionary class]]) {
_app->update_drop_zone(
[remote[@"x"] doubleValue], [remote[@"y"] doubleValue],
[remote[@"w"] doubleValue], [remote[@"h"] doubleValue],
[local[@"x"] doubleValue], [local[@"y"] doubleValue],
[local[@"w"] doubleValue], [local[@"h"] doubleValue]);
[remote[@"w"] doubleValue], [remote[@"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
completionHandler:(void (^)(BOOL result))completionHandler {
(void)webView; (void)frame;
// Prefer the bridge `confirm` action from JS so button titles are localized.
NSAlert* alert = [[NSAlert alloc] init];
alert.messageText = message;
[alert addButtonWithTitle:@"Delete"];
[alert addButtonWithTitle:@"OK"];
[alert addButtonWithTitle:@"Cancel"];
NSModalResponse response = [alert runModal];
completionHandler(response == NSAlertFirstButtonReturn);
@@ -252,6 +517,7 @@ static const CGFloat kTrafficLightInset = 72.0;
@property (nonatomic, assign) uint32_t handle;
@property (nonatomic, assign) uint32_t storageId;
@property (nonatomic, assign) uint64_t size;
@property (nonatomic, assign) BOOL isDir;
@property (nonatomic, copy) NSString* filename;
@end
@@ -261,12 +527,17 @@ static const CGFloat kTrafficLightInset = 72.0;
writePromiseToURL:(NSURL*)url
completionHandler:(void (^)(NSError* _Nullable))completionHandler {
(void)provider;
@try {
if (_app) _app->download_remote_to_path(_handle, _storageId, url.path.UTF8String, _size);
try {
if (_app)
_app->download_remote_to_path(_handle, _storageId, url.path.UTF8String, _size, _isDir);
completionHandler(nil);
} @catch (NSException* ex) {
} catch (const std::exception& ex) {
NSString* msg = [NSString stringWithUTF8String:ex.what()];
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
// ─── OmniWebView: native drag-and-drop ────────────────────────────────────────
@interface OmniWebView : WKWebView <NSDraggingSource>
- (instancetype)initWithFrame:(NSRect)frame
configuration:(WKWebViewConfiguration*)config
app:(omniMTP::App*)app;
@end
@implementation OmniWebView {
omniMTP::App* _app;
BOOL _remoteDragHighlighted;
BOOL _localDragHighlighted;
NSPoint _mouseDownWebPoint;
NSString* _pendingDragJSON;
BOOL _nativeDragStarted;
NSArray<NSDictionary*>* _exportDragEntries;
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
@@ -306,15 +572,19 @@ static const CGFloat kTrafficLightInset = 72.0;
if ((self = [super initWithFrame:frame configuration:config])) {
_app = app;
_remoteDragHighlighted = NO;
_localDragHighlighted = NO;
_exportDelegates = [NSMutableArray array];
[self registerForDraggedTypes:@[NSPasteboardTypeFileURL]];
}
return self;
}
- (NSPoint)webPointFromEvent:(NSEvent*)event {
NSPoint loc = [self convertPoint:event.locationInWindow fromView:nil];
return NSMakePoint(loc.x, self.bounds.size.height - loc.y);
- (void)clearPendingExport {
if (_exportEventMonitor) {
[NSEvent removeMonitor:_exportEventMonitor];
_exportEventMonitor = nil;
}
_pendingExportFiles = nil;
_exportDragStarted = NO;
}
- (NSPoint)webPointFromDrag:(id<NSDraggingInfo>)sender {
@@ -347,92 +617,141 @@ static const CGFloat kTrafficLightInset = 72.0;
[self evaluateJavaScript:js completionHandler:nil];
}
- (void)setLocalDragOver:(BOOL)over {
if (_localDragHighlighted == over) return;
_localDragHighlighted = over;
NSString* js = over
? @"document.getElementById('local-files')?.classList.add('drag-over')"
: @"document.getElementById('local-files')?.classList.remove('drag-over')";
[self evaluateJavaScript:js completionHandler:nil];
- (void)prepareExportDragWithFiles:(NSArray<NSDictionary*>*)files
atWebPoint:(NSPoint)webPoint {
(void)webPoint; // JS coords can disagree with AppKit; use the live mouse event.
if (!_app || !_app->connected() || files.count == 0) return;
[self clearPendingExport];
_pendingExportFiles = [files copy];
_exportDragStarted = NO;
// Prefer the real mouse location so the drag image anchors under the cursor.
NSEvent* down = [NSApp currentEvent];
if (down)
_pendingExportViewPoint = [self convertPoint:down.locationInWindow fromView:nil];
else
_pendingExportViewPoint = NSMakePoint(webPoint.x, self.bounds.size.height - webPoint.y);
__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;
}
- (void)mouseDown:(NSEvent*)event {
_mouseDownWebPoint = [self webPointFromEvent:event];
_pendingDragJSON = nil;
_nativeDragStarted = NO;
[self evaluateJavaScript:@"window.omniClearSelection&&window.omniClearSelection()"
completionHandler:nil];
NSString* js = [NSString stringWithFormat:
@"window.omniRemoteDragFilesAt(%f,%f)", _mouseDownWebPoint.x, _mouseDownWebPoint.y];
[self evaluateJavaScript:js completionHandler:^(id value, NSError* err) {
(void)err;
if ([value isKindOfClass:[NSString class]] && [(NSString*)value length] > 0)
self->_pendingDragJSON = value;
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 {
if (!_nativeDragStarted && _pendingDragJSON.length > 0 && _app && _app->connected()) {
NSPoint cur = [self webPointFromEvent:event];
CGFloat dx = cur.x - _mouseDownWebPoint.x;
CGFloat dy = cur.y - _mouseDownWebPoint.y;
if (std::sqrt(dx * dx + dy * dy) >= 4.0) {
_nativeDragStarted = YES;
- (void)beginExportDragWithFiles:(NSArray<NSDictionary*>*)files
atWebPoint:(NSPoint)webPoint {
(void)webPoint;
NSEvent* event = [NSApp currentEvent];
if (!event || (event.type != NSEventTypeLeftMouseDown &&
event.type != NSEventTypeLeftMouseDragged)) {
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()"
completionHandler:nil];
if ([self beginRemoteExportDrag:event json:_pendingDragJSON]) return;
}
}
if (!_nativeDragStarted) [super mouseDragged:event];
}
- (BOOL)beginRemoteExportDrag:(NSEvent*)event json:(NSString*)json {
NSData* data = [json dataUsingEncoding:NSUTF8StringEncoding];
id parsed = [NSJSONSerialization JSONObjectWithData:data options:0 error:nil];
if (![parsed isKindOfClass:[NSArray class]] || [(NSArray*)parsed count] == 0) return NO;
_exportDragEntries = [(NSArray*)parsed copy];
_exportDragEntries = [files copy];
[_exportDelegates removeAllObjects];
NSMutableArray<NSDraggingItem*>* dragItems = [NSMutableArray array];
CGFloat viewY = self.bounds.size.height - _mouseDownWebPoint.y;
NSRect frame = NSMakeRect(_mouseDownWebPoint.x - 1, viewY - 1, 2, 2);
// draggingFrame must be in this view's coordinate system and match the
// 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* storageId = entry[@"storageId"];
NSString* name = entry[@"name"];
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];
delegate.app = _app;
delegate.handle = handle.unsignedIntValue;
delegate.storageId = storageId.unsignedIntValue;
delegate.size = size.unsignedLongLongValue;
delegate.size = size ? size.unsignedLongLongValue : 0;
delegate.isDir = isDir;
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 alloc] initWithFileType:@"public.data" delegate:delegate];
[[NSFilePromiseProvider alloc] initWithFileType:typeId delegate:delegate];
NSDraggingItem* dragItem = [[NSDraggingItem alloc] initWithPasteboardWriter:provider];
[dragItem setDraggingFrame:frame contents:nil];
icon.size = NSMakeSize(32, 32);
[dragItem setDraggingFrame:frame contents:icon];
[dragItems addObject:dragItem];
}
if (dragItems.count == 0) {
_exportDragEntries = nil;
return NO;
[_exportDelegates removeAllObjects];
return;
}
[self evaluateJavaScript:@"window.omniClearSelection&&window.omniClearSelection()"
completionHandler:nil];
[self beginDraggingSessionWithItems:dragItems event:event source:self];
return YES;
}
- (NSDragOperation)draggingSession:(NSDraggingSession*)session
sourceOperationMaskForDraggingContext:(NSDraggingContext)context {
(void)session; (void)context;
(void)session;
// Outside the app (Finder) and inside both allow copy.
return NSDragOperationCopy;
}
@@ -441,37 +760,24 @@ static const CGFloat kTrafficLightInset = 72.0;
operation:(NSDragOperation)operation {
(void)session; (void)screenPoint; (void)operation;
_exportDragEntries = nil;
[_exportDelegates removeAllObjects];
}
- (NSDragOperation)draggingUpdated:(id<NSDraggingInfo>)sender {
NSArray<NSURL*>* urls = [self fileURLsFromDrag:sender];
const BOOL exportDrag = _exportDragEntries.count > 0;
if (!exportDrag && urls.count == 0) {
if (urls.count == 0) {
[self setRemoteDragOver:NO];
[self setLocalDragOver:NO];
return NSDragOperationNone;
}
NSString* target = [self dropTargetAtWebPoint:[self webPointFromDrag:sender]];
if (exportDrag && [target isEqualToString:@"local"]) {
[self setLocalDragOver:YES];
[self setRemoteDragOver:NO];
return NSDragOperationCopy;
}
if (urls.count > 0 && [target isEqualToString:@"remote"] && _app && _app->connected()) {
if ([target isEqualToString:@"remote"] && _app && _app->connected()) {
[self setRemoteDragOver:YES];
[self setLocalDragOver:NO];
return NSDragOperationCopy;
}
if (urls.count > 0 && [target isEqualToString:@"local"]) {
[self setRemoteDragOver:NO];
[self setLocalDragOver:NO];
return NSDragOperationLink;
}
[self setRemoteDragOver:NO];
[self setLocalDragOver:NO];
return exportDrag ? NSDragOperationCopy : NSDragOperationNone;
return NSDragOperationNone;
}
- (NSDragOperation)draggingEntered:(id<NSDraggingInfo>)sender {
@@ -481,35 +787,19 @@ static const CGFloat kTrafficLightInset = 72.0;
- (void)draggingExited:(id<NSDraggingInfo>)sender {
(void)sender;
[self setRemoteDragOver:NO];
[self setLocalDragOver:NO];
}
- (BOOL)performDragOperation:(id<NSDraggingInfo>)sender {
[self setRemoteDragOver:NO];
[self setLocalDragOver:NO];
if (!_app) return NO;
NSString* target = [self dropTargetAtWebPoint:[self webPointFromDrag:sender]];
NSArray<NSURL*>* urls = [self fileURLsFromDrag:sender];
if ([target isEqualToString:@"local"] && _exportDragEntries.count > 0) {
for (NSDictionary* entry in _exportDragEntries) {
NSNumber* handle = entry[@"handle"];
NSNumber* storageId = entry[@"storageId"];
NSString* name = entry[@"name"];
NSNumber* size = entry[@"size"];
if (handle && storageId && name && size)
_app->do_download(handle.unsignedIntValue, storageId.unsignedIntValue,
name.UTF8String, size.unsignedLongLongValue);
}
_exportDragEntries = nil;
return YES;
}
if (![target isEqualToString:@"remote"]) return NO;
NSFileManager* fm = NSFileManager.defaultManager;
BOOL handled = NO;
if ([target isEqualToString:@"remote"]) {
for (NSURL* url in urls) {
if (!url.isFileURL) continue;
NSString* path = url.path;
@@ -522,27 +812,16 @@ static const CGFloat kTrafficLightInset = 72.0;
return handled;
}
if ([target isEqualToString:@"local"]) {
for (NSURL* url in urls) {
if (!url.isFileURL) continue;
NSString* path = url.path;
if (!path) continue;
BOOL isDir = NO;
if ([fm fileExistsAtPath:path isDirectory:&isDir] && isDir)
_app->navigate_local(path.UTF8String);
handled = YES;
}
return handled;
}
return NO;
}
@end
// ─── OmniAppDelegate ──────────────────────────────────────────────────────────
@interface OmniAppDelegate : NSObject <NSApplicationDelegate>
@interface OmniAppDelegate : NSObject <NSApplicationDelegate, NSWindowDelegate>
- (instancetype)initWithApp:(omniMTP::App*)app;
- (void)buildMainMenu;
- (void)selectLanguage:(id)sender;
- (void)showAbout:(id)sender;
- (void)closeAboutPanel:(id)sender;
- (void)applyLanguageToWebView;
@end
@implementation OmniAppDelegate {
@@ -550,6 +829,11 @@ static const CGFloat kTrafficLightInset = 72.0;
NSWindow* _window;
WKWebView* _webView;
OmniBridge* _bridge;
NSPanel* _aboutPanel;
NSMenuItem* _aboutItem;
NSMenuItem* _optionsItem;
NSMenuItem* _languageItem;
NSMenuItem* _quitItem;
}
- (instancetype)initWithApp:(omniMTP::App*)app {
@@ -559,9 +843,221 @@ static const CGFloat kTrafficLightInset = 72.0;
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)note;
[self buildMainMenu];
// ── WKWebView configuration ───────────────────────────────────────────────
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();"
"},true);"
"document.addEventListener('dragstart',function(e){"
"var row=e.target.closest('.file-list .frow');"
"if(!row)return;"
"if(row.closest('#remote-files')||row.getAttribute('draggable')!=='true')"
"e.preventDefault();"
"if(e.target.closest('.file-list .frow'))e.preventDefault();"
"},true);"
"})();"
injectionTime:WKUserScriptInjectionTimeAtDocumentStart
forMainFrameOnly:YES];
[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
_webView = [[OmniWebView alloc] initWithFrame:NSMakeRect(0, 0, 1280, 800)
configuration:config
@@ -720,19 +1221,6 @@ void WebUI::run() {
OmniAppDelegate* delegate = [[OmniAppDelegate alloc] initWithApp:&app_];
[NSApp setDelegate:delegate];
// App menu (Cmd+Q to quit)
NSMenu* menuBar = [[NSMenu alloc] init];
NSMenuItem* appItem = [[NSMenuItem alloc] init];
[menuBar addItem:appItem];
NSMenu* appMenu = [[NSMenu alloc] init];
NSMenuItem* quitItem = [[NSMenuItem alloc]
initWithTitle:@"Quit OmniMTP"
action:@selector(terminate:)
keyEquivalent:@"q"];
[appMenu addItem:quitItem];
appItem.submenu = appMenu;
[NSApp setMainMenu:menuBar];
[NSApp activateIgnoringOtherApps:YES];
[NSApp run];
}

View File

@@ -113,15 +113,25 @@ body {
flex-shrink: 0;
gap: 8px;
}
.device-left, .device-right { display: flex; align-items: center; gap: 6px; flex-shrink: 0; }
.device-center {
.device-left, .device-right {
display: flex;
align-items: center;
gap: 6px;
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;
font-size: 12px;
color: var(--label-2);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
/* ── Main two-panel area ──────────────────────────────────────────────────── */
@@ -301,6 +311,22 @@ body {
white-space: nowrap;
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; }
.col-size { width: 72px; text-align: right; }
.col-date { width: 110px; }
@@ -545,8 +571,8 @@ body {
.marquee-box {
position: fixed;
z-index: 9998;
border: 1px solid var(--accent);
background: rgba(10,132,255,0.12);
border: 1px solid rgba(10, 132, 255, 0.85);
background: rgba(10, 132, 255, 0.06);
pointer-events: none;
}

View File

@@ -10,7 +10,6 @@ function call(action, params = {}) {
try {
window.webkit.messageHandlers.bridge.postMessage({id, action, ...params});
} catch(e) {
// Dev fallback
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]; }
};
// ── 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 ─────────────────────────────────────────────────────────────────────
const S = {
connected: false, deviceName: '',
storages: [], activeStorageId: 0,
remoteNavStack: [],
localPath: '', localFiles: [],
localDrives: [], activeLocalDrive: '',
remoteFiles: [], remoteRefreshing: false,
transfers: [],
statusMessage: '', statusTimer: 0,
selLocal: new Set(), // keys = file.path strings
selRemote: new Set(), // keys = String(file.handle)
lastLocalIdx: -1, // for shift+click range
statusKey: '', statusArgs: [], statusTimer: 0,
selRemote: new Set(),
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 = '';
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() {
try {
const data = await call('get_state');
if (data) {
const sl = S.selLocal, sr = S.selRemote;
const lli = S.lastLocalIdx, lri = S.lastRemoteIdx;
const sr = S.selRemote;
const lri = S.lastRemoteIdx;
Object.assign(S, data);
S.selLocal = sl; S.selRemote = sr;
S.lastLocalIdx = lli; S.lastRemoteIdx = lri;
S.selRemote = sr;
S.lastRemoteIdx = lri;
render();
}
} catch(e) {}
setTimeout(poll, 400);
}
// ── Rendering ─────────────────────────────────────────────────────────────────
function render() {
renderDeviceBar();
renderLocalToolbar();
renderLocalFiles();
renderRemoteToolbar();
renderRemoteFiles();
renderTransfers();
@@ -77,11 +210,9 @@ function render() {
function updateDropZones() {
const rem = document.getElementById('panel-remote')?.getBoundingClientRect();
const loc = document.getElementById('panel-local')?.getBoundingClientRect();
if (!rem || !loc) return;
if (!rem) return;
call('update_drop_zones', {
remote: {x: rem.left, y: rem.top, w: rem.width, h: rem.height},
local: {x: loc.left, y: loc.top, w: loc.width, h: loc.height},
});
}
@@ -100,18 +231,24 @@ function fileColor(f) {
return 'color-file';
}
// ── File table builder ────────────────────────────────────────────────────────
const FILE_TABLE_HEADER = `<thead><tr>
<th>Name</th><th class="col-size">Size</th><th class="col-date">Modified</th>
function fileTableHeader() {
const ind = (key) => {
if (S.sortKey !== key) return '<span class="sort-ind"></span>';
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) => {
const key = String(f[idKey] != null ? f[idKey] : (f.path || f.name));
const sel = selSet.has(key) ? ' selected' : '';
const cc = fileColor(f);
const drag = allowDrag && !f.isDir ? ' draggable="true"' : '';
return `<tr class="frow${sel}" data-idx="${i}" data-key="${esc(key)}" data-is-dir="${f.isDir ? '1' : '0'}"${drag}>
return `<tr class="frow${sel}" data-idx="${i}" data-key="${esc(key)}" data-is-dir="${f.isDir ? '1' : '0'}">
<td class="col-name"><div class="fname-cell">
<span class="dot ${cc}"></span>
<span class="${cc}">${esc(f.name)}</span>
@@ -122,7 +259,6 @@ function buildFileRows(files, selSet, idKey, allowDrag = false) {
}).join('');
}
// ── Selection helpers ─────────────────────────────────────────────────────────
function applySelectionToDOM(container, selSet) {
container.querySelectorAll('.frow').forEach(r => {
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)));
}
// ── Local panel ───────────────────────────────────────────────────────────────
function renderLocalFiles() {
const el = document.getElementById('local-files');
const fp = fingerprintFiles(S.localFiles);
if (fp !== _localFingerprint) {
_localFingerprint = fp;
// Full rebuild only when file list actually changed
if (!S.localFiles.length) {
el.innerHTML = `<div class="empty-state">
<svg viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-width="1.5" class="empty-icon">
<path d="M6 38V14a2 2 0 012-2h12l4 4h16a2 2 0 012 2v20a2 2 0 01-2 2H8a2 2 0 01-2-2z"/>
</svg>
<div class="empty-title">No files in this folder</div></div>`;
return;
}
el.innerHTML = `<table class="ftable">${FILE_TABLE_HEADER}
<tbody>${buildFileRows(S.localFiles, S.selLocal, 'path', true)}</tbody></table>`;
attachLocalEvents(el);
} else {
// Cheap path: just sync selection highlight
applySelectionToDOM(el, S.selLocal);
}
}
function attachMarqueeSelect(el, selSet) {
let active = false, startX = 0, startY = 0, box = null, additive = false;
let active = false, startX = 0, startY = 0, box = null, additive = false, moved = false;
const finish = () => {
if (!active) return;
const clearOnClick = !moved && !additive;
active = false;
if (box) { box.remove(); box = null; }
window.removeEventListener('mousemove', onMove);
window.removeEventListener('mouseup', finish);
if (clearOnClick) {
selSet.clear();
applySelectionToDOM(el, selSet);
S.lastRemoteIdx = -1;
}
};
const onMove = e => {
if (!active) return;
const dx = e.clientX - startX, dy = e.clientY - startY;
if (!moved && Math.hypot(dx, dy) < 4) return;
moved = true;
window.omniClearSelection();
const x1 = Math.min(startX, e.clientX), y1 = Math.min(startY, e.clientY);
const x2 = Math.max(startX, e.clientX), y2 = Math.max(startY, e.clientY);
@@ -187,9 +308,10 @@ function attachMarqueeSelect(el, selSet) {
};
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();
active = true;
moved = false;
additive = e.metaKey || e.shiftKey;
startX = e.clientX;
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() {
const el = document.getElementById('remote-files');
const empty = document.getElementById('remote-empty-state');
@@ -277,8 +337,8 @@ function renderRemoteFiles() {
if (empty) empty.style.display = 'none';
if (S.remoteRefreshing) {
el.innerHTML = '<div class="loading">Loading…</div>';
_remoteFingerprint = '__loading__';
el.innerHTML = `<div class="loading">${esc(t('loading'))}</div>`;
_remoteFingerprint = '__loading__' + I18N.lang;
return;
}
@@ -290,10 +350,11 @@ function renderRemoteFiles() {
<svg viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-width="1.5" class="empty-icon">
<path d="M6 38V14a2 2 0 012-2h12l4 4h16a2 2 0 012 2v20a2 2 0 01-2 2H8a2 2 0 01-2-2z"/>
</svg>
<div class="empty-title">Empty folder</div></div>`;
<div class="empty-title">${esc(t('empty.folder'))}</div></div>`;
} else {
el.innerHTML = `<table class="ftable">${FILE_TABLE_HEADER}
<tbody>${buildFileRows(S.remoteFiles, S.selRemote, 'handle', false)}</tbody></table>`;
const files = sortedRemoteFiles();
el.innerHTML = `<table class="ftable">${fileTableHeader()}
<tbody>${buildFileRows(files, S.selRemote, 'handle')}</tbody></table>`;
}
attachRemoteEvents(el);
} else {
@@ -302,9 +363,17 @@ function renderRemoteFiles() {
}
function attachRemoteEvents(el) {
const files = S.remoteFiles;
const files = sortedRemoteFiles();
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) => {
const f = files[i];
const key = keys[i];
@@ -323,22 +392,11 @@ function attachRemoteEvents(el) {
attachMarqueeSelect(el, S.selRemote);
// Drop target: receive local files → upload
el.addEventListener('dragover', e => { e.preventDefault(); el.classList.add('drag-over'); });
el.addEventListener('dragleave', e => { if (!el.contains(e.relatedTarget)) el.classList.remove('drag-over'); });
el.addEventListener('drop', e => {
e.preventDefault();
el.classList.remove('drag-over');
// Internal drag (local app files)
const d = e.dataTransfer.getData('application/x-omnimtp-local');
if (d) {
const items = JSON.parse(d);
(Array.isArray(items) ? items : [items]).forEach(lf => {
call('start_upload', {srcPath: lf.path});
});
return;
}
// Finder drop
for (const file of (e.dataTransfer.files || [])) {
const p = file.path || '';
if (p) call('start_upload', {srcPath: p});
@@ -346,7 +404,6 @@ function attachRemoteEvents(el) {
});
}
// ── Shared row click with shift-range support ─────────────────────────────────
function handleRowClick(e, idx, key, selSet, allKeys, lastIdxProp) {
if (e.shiftKey && S[lastIdxProp] >= 0) {
const lo = Math.min(idx, S[lastIdxProp]);
@@ -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() {
const sel = document.getElementById('storage-sel');
if (S.storages && S.storages.length) {
@@ -435,22 +445,21 @@ function renderRemoteToolbar() {
document.getElementById('btn-remote-up').disabled = stack.length <= 1;
}
// ── Device bar ────────────────────────────────────────────────────────────────
function renderDeviceBar() {
const badge = document.getElementById('conn-badge');
const status = document.getElementById('device-status-text');
if (S.connected) {
const name = S.deviceName || t('device.nintendo_switch');
badge.className = 'badge connected';
badge.textContent = S.deviceName || 'Nintendo Switch';
status.textContent = S.deviceName || 'Nintendo Switch connected';
badge.textContent = name;
status.textContent = t('device.connected', { name });
} else {
badge.className = 'badge disconnected';
badge.textContent = 'No Device';
status.textContent = 'Waiting for Nintendo Switch…';
badge.textContent = t('badge.no_device');
status.textContent = t('device.waiting');
}
}
// ── Transfers panel ───────────────────────────────────────────────────────────
function renderTransfers() {
const list = document.getElementById('transfers-list');
const label = document.getElementById('transfers-label');
@@ -458,87 +467,87 @@ function renderTransfers() {
const panel = document.getElementById('transfers-panel');
const active = (S.transfers || []).filter(t => t.state <= 1);
label.textContent = active.length ? `${active.length} Transfer${active.length > 1 ? 's' : ''}` : 'Transfers';
label.textContent = active.length
? tPlural('transfers.count', 'transfers.count_plural', active.length)
: t('transfers');
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; }
panel.style.display = '';
const stateNames = ['Queued','','Done','Failed','Cancelled'];
list.innerHTML = S.transfers.map(t => {
const dir = t.direction === 0 ? 'DL' : 'UP';
const dc = t.direction === 0 ? 'dir-dl' : 'dir-ul';
const pct = ((t.progress || 0) * 100).toFixed(0);
const showBar = t.state <= 1;
const statText = t.state === 1 ? esc(t.speedStr)
: t.state === 3 ? '⚠ ' + esc(t.error || 'Failed')
: stateNames[t.state] || '';
const stateNames = {
0: t('transfer.queued'),
2: t('transfer.done'),
3: t('transfer.failed'),
4: t('transfer.cancelled'),
};
list.innerHTML = S.transfers.map(tx => {
const dir = tx.direction === 0 ? t('transfer.dl') : t('transfer.up');
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">
<div class="txfer-row">
<span class="txfer-dir ${dc}">${dir}</span>
<span class="txfer-name" title="${esc(t.filename)}">${esc(t.filename)}</span>
<span class="txfer-dir ${dc}">${esc(dir)}</span>
<span class="txfer-name" title="${esc(tx.filename)}">${esc(tx.filename)}</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>
${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>`;
}).join('');
list.querySelectorAll('.txfer-cancel').forEach(btn =>
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() {
const lf = (S.localFiles || []).length;
const rf = (S.remoteFiles || []).length;
document.getElementById('sb-left').textContent = `${lf} item${lf !== 1 ? 's' : ''}`;
document.getElementById('sb-right').textContent = S.connected ? `${rf} item${rf !== 1 ? 's' : ''}` : '';
document.getElementById('sb-left').textContent = S.connected
? tPlural('items.count', 'items.count_plural', rf)
: '';
document.getElementById('sb-right').textContent = '';
let center = '';
if (S.statusTimer > 0 && S.statusMessage) {
center = S.statusMessage;
const skipStatus = S.statusKey === 'status.connected' || S.statusKey === 'status.disconnected';
if (S.statusTimer > 0 && S.statusKey && !skipStatus) {
center = t(S.statusKey, statusArgsMap(S.statusArgs));
} 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) {
const spd = act.reduce((s, t) => s + (t.speedBps || 0), 0);
center = `${act.length} active · ` + (spd > 1048576
const spd = act.reduce((s, tx) => s + (tx.speedBps || 0), 0);
const speed = spd > 1048576
? (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;
}
// ── Context menus ─────────────────────────────────────────────────────────────
let _ctxFile = null, _ctxIsLocal = false;
function showLocalCtxMenu(file, e) {
_ctxFile = file;
_ctxIsLocal = true;
const m = document.getElementById('ctx-menu');
document.getElementById('ctx-upload').style.display = (!file.isDir && S.connected) ? '' : 'none';
document.getElementById('ctx-download').style.display = 'none';
document.getElementById('ctx-delete').style.display = 'none';
document.getElementById('ctx-sep-del').style.display = 'none';
m.style.left = e.clientX + 'px';
m.style.top = e.clientY + 'px';
m.classList.remove('hidden');
}
let _ctxFile = null;
function showRemoteCtxMenu(file, e) {
_ctxFile = file;
_ctxIsLocal = false;
if (!S.selRemote.has(String(file.handle))) {
S.selRemote.clear();
S.selRemote.add(String(file.handle));
applySelectionToDOM(document.getElementById('remote-files'), S.selRemote);
}
const m = document.getElementById('ctx-menu');
const showDownload = !file.isDir;
document.getElementById('ctx-upload').style.display = 'none';
document.getElementById('ctx-download').style.display = showDownload ? '' : 'none';
document.getElementById('ctx-delete').style.display = '';
document.getElementById('ctx-sep-del').style.display = showDownload ? '' : 'none';
m.style.left = e.clientX + 'px';
m.style.top = e.clientY + 'px';
m.classList.remove('hidden');
@@ -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'); } });
// ── Init ──────────────────────────────────────────────────────────────────────
window.omniClearSelection = function() {
const sel = window.getSelection();
if (sel && sel.rangeCount) sel.removeAllRanges();
};
window.omniRemoteDragFilesAt = function(x, y) {
if (!S.connected) return '';
const hit = document.elementFromPoint(x, y);
const row = hit?.closest('#remote-files .frow');
if (!row || row.dataset.isDir === '1') return '';
const f = S.remoteFiles[+row.dataset.idx];
if (!f || f.isDir) return '';
function exportDragFilesForRow(row) {
if (!S.connected || !row) return [];
const files = sortedRemoteFiles();
const f = files[+row.dataset.idx];
if (!f) return [];
const key = String(f.handle);
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];
if (!dragFiles.length) return '';
return JSON.stringify(dragFiles.map(df => ({
handle: df.handle, storageId: df.storageId, name: df.name, size: df.size,
})));
};
return dragFiles.map(df => ({
handle: df.handle,
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 => {
if (e.target.closest('.file-list')) e.preventDefault();
}, true);
document.addEventListener('mousedown', e => {
if (e.button !== 0) return;
if (e.target.closest('.file-list .frow')) e.preventDefault();
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);
document.addEventListener('dragstart', e => {
const row = e.target.closest('.file-list .frow');
if (!row) return;
if (row.closest('#remote-files') || row.getAttribute('draggable') !== 'true') {
e.preventDefault();
return;
}
window.omniClearSelection();
if (e.dataTransfer) {
e.dataTransfer.effectAllowed = 'copy';
e.dataTransfer.setData('text/plain', '');
}
if (e.target.closest('.file-list .frow')) e.preventDefault();
}, true);
document.addEventListener('DOMContentLoaded', () => {
document.getElementById('btn-local-up') .addEventListener('click', () => call('navigate_local_up'));
document.getElementById('btn-local-home') .addEventListener('click', () => call('navigate_local', {path: '~'}));
document.getElementById('btn-local-refresh').addEventListener('click', () => call('refresh_local'));
document.getElementById('local-drive-sel') .addEventListener('change', e => call('navigate_local', {path: e.target.value}));
document.addEventListener('DOMContentLoaded', async () => {
await initI18n();
document.getElementById('btn-remote-up') .addEventListener('click', () => call('navigate_remote_up'));
document.getElementById('btn-remote-refresh').addEventListener('click', () => call('refresh_remote'));
document.getElementById('btn-scan') .addEventListener('click', () => call('reconnect'));
@@ -609,7 +610,6 @@ document.addEventListener('DOMContentLoaded', () => {
document.getElementById('btn-cancel-all') .addEventListener('click', () => call('cancel_all'));
document.getElementById('storage-sel') .addEventListener('change', e => call('set_storage', {storageId: +e.target.value}));
// Context menu actions
const bindCtx = (id, fn) => {
document.getElementById(id).addEventListener('click', e => {
e.stopPropagation();
@@ -617,27 +617,14 @@ document.addEventListener('DOMContentLoaded', () => {
});
};
bindCtx('ctx-upload', () => {
bindCtx('ctx-delete', async () => {
if (!_ctxFile) return;
const uploadFiles = S.selLocal.size > 1 && S.selLocal.has(_ctxFile.path)
? S.localFiles.filter(f => S.selLocal.has(f.path))
: [_ctxFile];
uploadFiles.forEach(f => call('start_upload', {srcPath: f.path}));
});
bindCtx('ctx-download', () => {
if (!_ctxFile) return;
const downloadFiles = S.selRemote.size > 1 && S.selRemote.has(String(_ctxFile.handle))
? S.remoteFiles.filter(f => S.selRemote.has(String(f.handle)))
: [_ctxFile];
downloadFiles.forEach(f => call('start_download', {handle: f.handle, storageId: f.storageId, filename: f.name, size: f.size}));
});
bindCtx('ctx-delete', () => {
if (!_ctxFile) return;
const label = S.selRemote.size > 1 && S.selRemote.has(String(_ctxFile.handle))
? `${S.selRemote.size} items`
: `"${_ctxFile.name}"`;
if (window.confirm(`Delete ${label}?`)) {
const handles = S.selRemote.size > 1 && S.selRemote.has(String(_ctxFile.handle))
const multi = S.selRemote.size > 1 && S.selRemote.has(String(_ctxFile.handle));
const message = multi
? t('confirm.delete_items', { n: S.selRemote.size })
: t('confirm.delete', { label: `"${_ctxFile.name}"` });
if (await confirmDialog(message)) {
const handles = multi
? S.remoteFiles.filter(f => S.selRemote.has(String(f.handle))).map(f => f.handle)
: [_ctxFile.handle];
handles.forEach(h => call('delete_remote', {handle: h}));
@@ -645,7 +632,6 @@ document.addEventListener('DOMContentLoaded', () => {
document.getElementById('ctx-menu').classList.add('hidden');
});
// Keyboard
document.addEventListener('keydown', e => {
if (e.target.matches('input,textarea,select')) return;
if (e.key === 'Backspace' || e.key === 'Delete') {
@@ -654,24 +640,14 @@ document.addEventListener('DOMContentLoaded', () => {
if (e.metaKey && e.key === 'r') {
e.preventDefault();
call('refresh_remote');
call('refresh_local');
}
// Cmd+A = select all
if (e.metaKey && e.key === 'a') {
e.preventDefault();
const active = document.activeElement;
if (active && active.closest('#local-files')) {
S.localFiles.forEach(f => S.selLocal.add(f.path));
applySelectionToDOM(document.getElementById('local-files'), S.selLocal);
} else {
S.remoteFiles.forEach(f => S.selRemote.add(String(f.handle)));
applySelectionToDOM(document.getElementById('remote-files'), S.selRemote);
}
}
// Escape = deselect
if (e.key === 'Escape') {
S.selLocal.clear(); S.selRemote.clear();
applySelectionToDOM(document.getElementById('local-files'), S.selLocal);
S.selRemote.clear();
applySelectionToDOM(document.getElementById('remote-files'), S.selRemote);
}
});

View File

@@ -9,57 +9,31 @@
<body>
<div id="titlebar">
<div class="tl-spacer"></div>
<span id="app-title">OmniMTP</span>
<div id="conn-badge" class="badge disconnected">No Device</div>
<span id="app-title" data-i18n="app.title">OmniMTP</span>
<div id="conn-badge" class="badge disconnected" data-i18n="badge.no_device">No Device</div>
</div>
<div id="device-bar">
<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 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">
<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 id="main">
<!-- Left: Local -->
<div class="panel" id="panel-local">
<div class="panel-toolbar">
<div class="nav-group">
<button class="nav-btn" id="btn-local-up" title="Go Up">
<svg viewBox="0 0 16 16"><path d="M8 3L3 8h3v5h4V8h3L8 3z" fill="currentColor"/></svg>
</button>
<button class="nav-btn" id="btn-local-home" title="Home">
<svg viewBox="0 0 16 16"><path d="M8 2L2 7h2v6h4v-4h4v4h2V7l2 0-6-5z" fill="currentColor"/></svg>
</button>
</div>
<select id="local-drive-sel" class="storage-select" title="Drive"></select>
<div id="local-breadcrumb" class="breadcrumb"></div>
<button class="nav-btn icon-only" id="btn-local-refresh" title="Refresh">
<svg viewBox="0 0 16 16"><path d="M13.5 8A5.5 5.5 0 1 1 8 2.5V1l3 2.5L8 6V4.5A3.5 3.5 0 1 0 11.5 8h2z" fill="currentColor"/></svg>
</button>
</div>
<div class="file-list" id="local-files">
<div class="loading">Loading…</div>
</div>
</div>
<!-- Divider -->
<div id="panel-divider"></div>
<!-- Right: Switch -->
<div class="panel" id="panel-remote">
<div class="panel-toolbar">
<div class="nav-group">
<button class="nav-btn" id="btn-remote-up" title="Go Up" disabled>
<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>
</button>
</div>
<select id="storage-sel" class="storage-select"></select>
<div id="remote-breadcrumb" class="breadcrumb"></div>
<button class="nav-btn icon-only" id="btn-remote-refresh" title="Refresh">
<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>
</button>
</div>
@@ -70,8 +44,8 @@
<line x1="24" y1="12" x2="24" y2="18"/>
<circle cx="24" cy="22" r="1.5" fill="currentColor"/>
</svg>
<div class="empty-title">No Switch Connected</div>
<div class="empty-sub">Connect via USB-C and open DBI or Sphaira MTP</div>
<div class="empty-title" data-i18n="empty.no_switch_title">No Switch Connected</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>
@@ -80,8 +54,8 @@
<!-- Transfers -->
<div id="transfers-panel">
<div id="transfers-header">
<span id="transfers-label">Transfers</span>
<button class="action-btn danger small" id="btn-cancel-all" style="display:none">Cancel All</button>
<span id="transfers-label" data-i18n="transfers">Transfers</span>
<button class="action-btn danger small" id="btn-cancel-all" data-i18n="btn.cancel_all" style="display:none">Cancel All</button>
</div>
<div id="transfers-list"></div>
</div>
@@ -95,10 +69,7 @@
<!-- Context menu -->
<div id="ctx-menu" class="ctx-menu hidden">
<div class="ctx-item" id="ctx-upload">Upload to Switch</div>
<div class="ctx-item" id="ctx-download">Download to Mac</div>
<div class="ctx-item ctx-sep" id="ctx-sep-del"></div>
<div class="ctx-item danger" id="ctx-delete">Delete…</div>
<div class="ctx-item danger" id="ctx-delete" data-i18n="ctx.delete">Delete…</div>
</div>
<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"
}