diff --git a/CMakeLists.txt b/CMakeLists.txt index b758826..f5e0241 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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 ) diff --git a/src/ui/App.hpp b/src/ui/App.hpp index 89c73c0..4ba5750 100644 --- a/src/ui/App.hpp +++ b/src/ui/App.hpp @@ -7,7 +7,6 @@ #include #include #include -#include #include #include #include @@ -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 args = {}, + float duration_sec = 5.f); // ── Finder drop processing (called from state_json) ─────────────────────── void process_finder_drops(); @@ -128,11 +108,8 @@ private: std::vector> storages_; uint32_t active_storage_ = 0; - // ── State: local panel ──────────────────────────────────────────────────── - fs::path local_path_; - std::vector local_entries_; - std::vector local_drives_; - mutable std::atomic local_needs_refresh_{false}; + // Download destination (defaults to home directory). + fs::path download_path_; // ── State: remote panel ─────────────────────────────────────────────────── std::vector 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_; - float status_msg_timer_ = 0.f; + // Status bar message with timeout (i18n key + args) + std::string status_key_; + std::vector 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 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 diff --git a/src/ui/App.mm b/src/ui/App.mm index 8772fdd..35a95b6 100644 --- a/src/ui/App.mm +++ b/src/ui/App.mm @@ -7,7 +7,6 @@ #include #include #include -#include #include #include @@ -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>; -static CrumbList build_crumbs(const fs::path& path) { - std::vector> 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(*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* 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 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(this)->process_finder_drops(); } - if (local_needs_refresh_.load()) { - local_needs_refresh_.store(false); - const_cast(this)->refresh_local(local_path_); - } if (remote_needs_refresh_ && connected_.load()) { remote_needs_refresh_ = false; const_cast(this)->refresh_remote(); } - const_cast(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+1get_object_handles(storage_id, handle); + std::atomic 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 cancel{false}; mtp::ProgressFn noop = [](uint64_t, uint64_t) {}; ops_->get_object(handle, dest_path, size, noop, cancel); diff --git a/src/ui/WebUI.mm b/src/ui/WebUI.mm index b106d15..91755a0 100644 --- a/src/ui/WebUI.mm +++ b/src/ui/WebUI.mm @@ -1,13 +1,230 @@ #include "WebUI.hpp" #import #import +#import #include #include +// ─── 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* 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* 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* OmniLocaleCandidates(void) { + NSString* override = OmniConfiguredLanguageOverride(); + if (override) return @[override, @"en"]; + + NSMutableArray* 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* OmniAvailableLanguageCodes(NSURL* webroot) { + NSMutableArray* 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* 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 +- (instancetype)initWithFrame:(NSRect)frame + configuration:(WKWebViewConfiguration*)config + app:(omniMTP::App*)app; +- (void)prepareExportDragWithFiles:(NSArray*)files + atWebPoint:(NSPoint)webPoint; +- (void)beginExportDragWithFiles:(NSArray*)files + atWebPoint:(NSPoint)webPoint; +@end + // ─── OmniBridge: JS → C++ message handler ───────────────────────────────────── @interface OmniBridge : NSObject - (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* 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 -- (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* _exportDragEntries; + NSMutableArray* _exportDelegates; // keep alive for NSFilePromiseProvider + NSArray* _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)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*)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; + } + + 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 + }]; } -- (void)mouseDown:(NSEvent*)event { - _mouseDownWebPoint = [self webPointFromEvent:event]; - _pendingDragJSON = nil; - _nativeDragStarted = NO; +- (void)beginExportDragWithFiles:(NSArray*)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*)files + event:(NSEvent*)event { + if (!_app || !_app->connected() || files.count == 0 || !event) return; + + [self clearPendingExport]; [self evaluateJavaScript:@"window.omniClearSelection&&window.omniClearSelection()" completionHandler:nil]; - NSString* js = [NSString stringWithFormat: - @"window.omniRemoteDragFilesAt(%f,%f)", _mouseDownWebPoint.x, _mouseDownWebPoint.y]; - [self evaluateJavaScript:js completionHandler:^(id value, NSError* err) { - (void)err; - if ([value isKindOfClass:[NSString class]] && [(NSString*)value length] > 0) - self->_pendingDragJSON = value; - }]; - [super mouseDown:event]; -} -- (void)mouseDragged:(NSEvent*)event { - if (!_nativeDragStarted && _pendingDragJSON.length > 0 && _app && _app->connected()) { - NSPoint cur = [self webPointFromEvent:event]; - CGFloat dx = cur.x - _mouseDownWebPoint.x; - CGFloat dy = cur.y - _mouseDownWebPoint.y; - if (std::sqrt(dx * dx + dy * dy) >= 4.0) { - _nativeDragStarted = YES; - [self evaluateJavaScript:@"window.omniClearSelection&&window.omniClearSelection()" - completionHandler:nil]; - if ([self beginRemoteExportDrag:event json:_pendingDragJSON]) return; - } - } - if (!_nativeDragStarted) [super mouseDragged:event]; -} - -- (BOOL)beginRemoteExportDrag:(NSEvent*)event json:(NSString*)json { - NSData* data = [json dataUsingEncoding:NSUTF8StringEncoding]; - id parsed = [NSJSONSerialization JSONObjectWithData:data options:0 error:nil]; - if (![parsed isKindOfClass:[NSArray class]] || [(NSArray*)parsed count] == 0) return NO; - - _exportDragEntries = [(NSArray*)parsed copy]; + _exportDragEntries = [files copy]; + [_exportDelegates removeAllObjects]; NSMutableArray* 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)sender { NSArray* 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)sender { @@ -481,68 +787,41 @@ static const CGFloat kTrafficLightInset = 72.0; - (void)draggingExited:(id)sender { (void)sender; [self setRemoteDragOver:NO]; - [self setLocalDragOver:NO]; } - (BOOL)performDragOperation:(id)sender { [self setRemoteDragOver:NO]; - [self setLocalDragOver:NO]; if (!_app) return NO; NSString* target = [self dropTargetAtWebPoint:[self webPointFromDrag:sender]]; NSArray* 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; - if (!path) continue; - BOOL isDir = NO; - if ([fm fileExistsAtPath:path isDirectory:&isDir] && !isDir) - _app->do_upload(path.UTF8String); - handled = YES; - } - return handled; + for (NSURL* url in urls) { + if (!url.isFileURL) continue; + NSString* path = url.path; + if (!path) continue; + BOOL isDir = NO; + if ([fm fileExistsAtPath:path isDirectory:&isDir] && !isDir) + _app->do_upload(path.UTF8String); + handled = YES; } - - 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; + return handled; } @end // ─── OmniAppDelegate ────────────────────────────────────────────────────────── -@interface OmniAppDelegate : NSObject +@interface OmniAppDelegate : NSObject - (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]; } diff --git a/src/ui/webroot/app.css b/src/ui/webroot/app.css index 6049b6f..9e244a6 100644 --- a/src/ui/webroot/app.css +++ b/src/ui/webroot/app.css @@ -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; } diff --git a/src/ui/webroot/app.js b/src/ui/webroot/app.js index 4c36590..64a1f02 100644 --- a/src/ui/webroot/app.js +++ b/src/ui/webroot/app.js @@ -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 = ` - NameSizeModified -`; +function fileTableHeader() { + const ind = (key) => { + if (S.sortKey !== key) return ''; + return `${S.sortAsc ? '▲' : '▼'}`; + }; + return ` + ${esc(t('col.name'))}${ind('name')} + ${esc(t('col.size'))}${ind('size')} + ${esc(t('col.modified'))}${ind('date')} + `; +} -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 ` + return `
${esc(f.name)} @@ -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 = `
- - - -
No files in this folder
`; - return; - } - el.innerHTML = `${FILE_TABLE_HEADER} - ${buildFileRows(S.localFiles, S.selLocal, 'path', true)}
`; - 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 = '
Loading…
'; - _remoteFingerprint = '__loading__'; + el.innerHTML = `
${esc(t('loading'))}
`; + _remoteFingerprint = '__loading__' + I18N.lang; return; } @@ -290,10 +350,11 @@ function renderRemoteFiles() { -
Empty folder
`; +
${esc(t('empty.folder'))}
`; } else { - el.innerHTML = `${FILE_TABLE_HEADER} - ${buildFileRows(S.remoteFiles, S.selRemote, 'handle', false)}
`; + const files = sortedRemoteFiles(); + el.innerHTML = `${fileTableHeader()} + ${buildFileRows(files, S.selRemote, 'handle')}
`; } 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 => - `` - ).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 ? '' : ''; - show.forEach((p, i) => { - const fullIdx = parts.length - show.length + i; - const path = '/' + parts.slice(0, fullIdx + 1).join('/'); - html += i === show.length - 1 - ? `${esc(p)}` - : `${esc(p)}`; - }); - 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 `
- ${dir} - ${esc(t.filename)} + ${esc(dir)} + ${esc(tx.filename)} ${statText} - ${showBar ? `` : ''} + ${showBar ? `` : ''}
${showBar ? `
-
${pct}%${t.etaStr ? ' · ' + t.etaStr : ''}
` : ''} +
${pct}%${tx.etaStr ? ' · ' + tx.etaStr : ''}
` : ''}
`; }).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; + _ctxFile = file; 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); - } + 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); } }); diff --git a/src/ui/webroot/index.html b/src/ui/webroot/index.html index a9a92d5..b2b757f 100644 --- a/src/ui/webroot/index.html +++ b/src/ui/webroot/index.html @@ -9,57 +9,31 @@
- OmniMTP -
No Device
+ OmniMTP +
No Device
- +
-
Waiting for Nintendo Switch…
+
Waiting for Nintendo Switch…
- +
- -
-
- - - - -
-
-
Loading…
-
-
- - -
- -
-
@@ -70,8 +44,8 @@ -
No Switch Connected
-
Connect via USB-C and open DBI or Sphaira MTP
+
No Switch Connected
+
Connect via USB-C and open DBI or Sphaira MTP
@@ -80,8 +54,8 @@
- Transfers - + Transfers +
@@ -95,10 +69,7 @@ diff --git a/src/ui/webroot/lang/de.json b/src/ui/webroot/lang/de.json new file mode 100644 index 0000000..e3fd1a4 --- /dev/null +++ b/src/ui/webroot/lang/de.json @@ -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" +} diff --git a/src/ui/webroot/lang/en.json b/src/ui/webroot/lang/en.json new file mode 100644 index 0000000..d2308c8 --- /dev/null +++ b/src/ui/webroot/lang/en.json @@ -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" +} diff --git a/src/ui/webroot/lang/es.json b/src/ui/webroot/lang/es.json new file mode 100644 index 0000000..8b4db96 --- /dev/null +++ b/src/ui/webroot/lang/es.json @@ -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" +} diff --git a/src/ui/webroot/lang/fr.json b/src/ui/webroot/lang/fr.json new file mode 100644 index 0000000..b57a98e --- /dev/null +++ b/src/ui/webroot/lang/fr.json @@ -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 d’OmniMTP", + "about.tagline": "Client MTP Nintendo Switch pour macOS", + "about.version": "Version {version}", + "about.copyright": "Copyright © 2026 NiklasCFW" +} diff --git a/src/ui/webroot/lang/it.json b/src/ui/webroot/lang/it.json new file mode 100644 index 0000000..d82e677 --- /dev/null +++ b/src/ui/webroot/lang/it.json @@ -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" +} diff --git a/src/ui/webroot/lang/ja.json b/src/ui/webroot/lang/ja.json new file mode 100644 index 0000000..b8177ce --- /dev/null +++ b/src/ui/webroot/lang/ja.json @@ -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" +} diff --git a/src/ui/webroot/lang/nl.json b/src/ui/webroot/lang/nl.json new file mode 100644 index 0000000..b34eee5 --- /dev/null +++ b/src/ui/webroot/lang/nl.json @@ -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" +} diff --git a/src/ui/webroot/lang/pt.json b/src/ui/webroot/lang/pt.json new file mode 100644 index 0000000..0970aee --- /dev/null +++ b/src/ui/webroot/lang/pt.json @@ -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" +} diff --git a/src/ui/webroot/lang/ru.json b/src/ui/webroot/lang/ru.json new file mode 100644 index 0000000..5184344 --- /dev/null +++ b/src/ui/webroot/lang/ru.json @@ -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" +} diff --git a/src/ui/webroot/lang/zh-Hans.json b/src/ui/webroot/lang/zh-Hans.json new file mode 100644 index 0000000..43e6182 --- /dev/null +++ b/src/ui/webroot/lang/zh-Hans.json @@ -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" +}