Improve Switch-only MTP UI with drag export, i18n, and polish.
Ship Finder-style drag-out for files and folders, system-language packs with an Options override, About/menu bar support, and sorting plus selection UX fixes.
This commit is contained in:
@@ -78,6 +78,7 @@ find_library(FW_APPKIT AppKit REQUIRED)
|
|||||||
find_library(FW_FOUNDATION Foundation REQUIRED)
|
find_library(FW_FOUNDATION Foundation REQUIRED)
|
||||||
find_library(FW_WEBKIT WebKit REQUIRED)
|
find_library(FW_WEBKIT WebKit REQUIRED)
|
||||||
find_library(FW_CORETEXT CoreText REQUIRED)
|
find_library(FW_CORETEXT CoreText REQUIRED)
|
||||||
|
find_library(FW_UTTYPES UniformTypeIdentifiers REQUIRED)
|
||||||
|
|
||||||
# ─── Application ─────────────────────────────────────────────────────────────
|
# ─── Application ─────────────────────────────────────────────────────────────
|
||||||
file(GLOB_RECURSE APP_SOURCES CONFIGURE_DEPENDS
|
file(GLOB_RECURSE APP_SOURCES CONFIGURE_DEPENDS
|
||||||
@@ -87,9 +88,17 @@ file(GLOB_RECURSE APP_SOURCES CONFIGURE_DEPENDS
|
|||||||
|
|
||||||
# webroot resource files — copied into the bundle at Resources/webroot/
|
# webroot resource files — copied into the bundle at Resources/webroot/
|
||||||
file(GLOB WEBROOT_FILES "src/ui/webroot/*")
|
file(GLOB WEBROOT_FILES "src/ui/webroot/*")
|
||||||
|
file(GLOB WEBROOT_LANG_FILES "src/ui/webroot/lang/*.json")
|
||||||
foreach(WF ${WEBROOT_FILES})
|
foreach(WF ${WEBROOT_FILES})
|
||||||
|
# Skip the lang directory entry if glob picks it up as a file path oddly
|
||||||
|
if(IS_DIRECTORY "${WF}")
|
||||||
|
continue()
|
||||||
|
endif()
|
||||||
set_source_files_properties(${WF} PROPERTIES MACOSX_PACKAGE_LOCATION Resources/webroot)
|
set_source_files_properties(${WF} PROPERTIES MACOSX_PACKAGE_LOCATION Resources/webroot)
|
||||||
endforeach()
|
endforeach()
|
||||||
|
foreach(LF ${WEBROOT_LANG_FILES})
|
||||||
|
set_source_files_properties(${LF} PROPERTIES MACOSX_PACKAGE_LOCATION Resources/webroot/lang)
|
||||||
|
endforeach()
|
||||||
|
|
||||||
# App icon — generated from assets/AppIcon.png at build time
|
# App icon — generated from assets/AppIcon.png at build time
|
||||||
set(APP_ICON_SOURCE "${CMAKE_SOURCE_DIR}/assets/AppIcon.png")
|
set(APP_ICON_SOURCE "${CMAKE_SOURCE_DIR}/assets/AppIcon.png")
|
||||||
@@ -103,7 +112,7 @@ add_custom_command(
|
|||||||
VERBATIM
|
VERBATIM
|
||||||
)
|
)
|
||||||
|
|
||||||
add_executable(OmniMTP MACOSX_BUNDLE ${APP_SOURCES} ${WEBROOT_FILES} ${APP_ICON_ICNS})
|
add_executable(OmniMTP MACOSX_BUNDLE ${APP_SOURCES} ${WEBROOT_FILES} ${WEBROOT_LANG_FILES} ${APP_ICON_ICNS})
|
||||||
|
|
||||||
target_include_directories(OmniMTP PRIVATE
|
target_include_directories(OmniMTP PRIVATE
|
||||||
${CMAKE_SOURCE_DIR}/src
|
${CMAKE_SOURCE_DIR}/src
|
||||||
@@ -111,7 +120,7 @@ target_include_directories(OmniMTP PRIVATE
|
|||||||
)
|
)
|
||||||
target_link_libraries(OmniMTP PRIVATE
|
target_link_libraries(OmniMTP PRIVATE
|
||||||
usb_static
|
usb_static
|
||||||
${FW_APPKIT} ${FW_FOUNDATION} ${FW_METAL} ${FW_WEBKIT} ${FW_CORETEXT}
|
${FW_APPKIT} ${FW_FOUNDATION} ${FW_METAL} ${FW_WEBKIT} ${FW_CORETEXT} ${FW_UTTYPES}
|
||||||
)
|
)
|
||||||
|
|
||||||
target_compile_options(OmniMTP PRIVATE
|
target_compile_options(OmniMTP PRIVATE
|
||||||
@@ -125,7 +134,7 @@ set_target_properties(OmniMTP PROPERTIES
|
|||||||
MACOSX_BUNDLE_BUNDLE_NAME "OmniMTP"
|
MACOSX_BUNDLE_BUNDLE_NAME "OmniMTP"
|
||||||
MACOSX_BUNDLE_BUNDLE_VERSION "${PROJECT_VERSION}"
|
MACOSX_BUNDLE_BUNDLE_VERSION "${PROJECT_VERSION}"
|
||||||
MACOSX_BUNDLE_SHORT_VERSION_STRING "${PROJECT_VERSION}"
|
MACOSX_BUNDLE_SHORT_VERSION_STRING "${PROJECT_VERSION}"
|
||||||
MACOSX_BUNDLE_COPYRIGHT "Copyright © 2025 OmniMTP"
|
MACOSX_BUNDLE_COPYRIGHT "Copyright © 2026 NiklasCFW"
|
||||||
MACOSX_BUNDLE_INFO_PLIST "${CMAKE_SOURCE_DIR}/cmake/Info.plist.in"
|
MACOSX_BUNDLE_INFO_PLIST "${CMAKE_SOURCE_DIR}/cmake/Info.plist.in"
|
||||||
MACOSX_BUNDLE_ICON_FILE AppIcon
|
MACOSX_BUNDLE_ICON_FILE AppIcon
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -7,7 +7,6 @@
|
|||||||
#include <filesystem>
|
#include <filesystem>
|
||||||
#include <memory>
|
#include <memory>
|
||||||
#include <mutex>
|
#include <mutex>
|
||||||
#include <set>
|
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <thread>
|
#include <thread>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
@@ -16,22 +15,6 @@ namespace omniMTP {
|
|||||||
|
|
||||||
namespace fs = std::filesystem;
|
namespace fs = std::filesystem;
|
||||||
|
|
||||||
// ── Entry in a file browser panel ────────────────────────────────────────────
|
|
||||||
struct LocalEntry {
|
|
||||||
fs::path path;
|
|
||||||
std::string name;
|
|
||||||
bool is_dir = false;
|
|
||||||
uint64_t size = 0;
|
|
||||||
std::string size_str;
|
|
||||||
std::string date_str;
|
|
||||||
};
|
|
||||||
|
|
||||||
struct LocalDrive {
|
|
||||||
std::string path;
|
|
||||||
std::string name;
|
|
||||||
bool removable = false;
|
|
||||||
};
|
|
||||||
|
|
||||||
struct RemoteEntry {
|
struct RemoteEntry {
|
||||||
uint32_t handle = 0;
|
uint32_t handle = 0;
|
||||||
uint32_t storage_id = 0;
|
uint32_t storage_id = 0;
|
||||||
@@ -60,9 +43,6 @@ public:
|
|||||||
|
|
||||||
// ── Web bridge API ────────────────────────────────────────────────────────
|
// ── Web bridge API ────────────────────────────────────────────────────────
|
||||||
std::string state_json() const;
|
std::string state_json() const;
|
||||||
std::string current_local_path() const;
|
|
||||||
void navigate_local(const std::string& path);
|
|
||||||
void navigate_local_up();
|
|
||||||
void navigate_remote(uint32_t handle, uint32_t storage_id, const std::string& name);
|
void navigate_remote(uint32_t handle, uint32_t storage_id, const std::string& name);
|
||||||
void navigate_remote_up();
|
void navigate_remote_up();
|
||||||
void navigate_remote_to(size_t idx);
|
void navigate_remote_to(size_t idx);
|
||||||
@@ -71,8 +51,10 @@ public:
|
|||||||
void do_upload(const std::string& src_path);
|
void do_upload(const std::string& src_path);
|
||||||
void do_download(uint32_t handle, uint32_t storage_id, const std::string& filename, uint64_t size);
|
void do_download(uint32_t handle, uint32_t storage_id, const std::string& filename, uint64_t size);
|
||||||
// Blocking download used by Finder drag-export (NSFilePromiseProvider).
|
// Blocking download used by Finder drag-export (NSFilePromiseProvider).
|
||||||
|
// When is_dir is true, dest_path is a folder that will be created and filled recursively.
|
||||||
void download_remote_to_path(uint32_t handle, uint32_t storage_id,
|
void download_remote_to_path(uint32_t handle, uint32_t storage_id,
|
||||||
const std::string& dest_path, uint64_t size);
|
const std::string& dest_path, uint64_t size,
|
||||||
|
bool is_dir = false);
|
||||||
void do_cancel(const std::string& id);
|
void do_cancel(const std::string& id);
|
||||||
void do_cancel_all();
|
void do_cancel_all();
|
||||||
void do_delete(uint32_t handle);
|
void do_delete(uint32_t handle);
|
||||||
@@ -81,21 +63,15 @@ public:
|
|||||||
|
|
||||||
bool connected() const { return connected_.load(); }
|
bool connected() const { return connected_.load(); }
|
||||||
|
|
||||||
// Drop-zone rects (CSS pixels, origin top-left) updated by the web UI.
|
// Drop-zone rect (CSS pixels, origin top-left) updated by the web UI.
|
||||||
void update_drop_zones(double rx, double ry, double rw, double rh,
|
void update_drop_zone(double x, double y, double w, double h);
|
||||||
double lx, double ly, double lw, double lh);
|
// Returns "remote" or "none".
|
||||||
// Returns "remote", "local", or "none".
|
|
||||||
std::string drop_target_at(double x, double y) const;
|
std::string drop_target_at(double x, double y) const;
|
||||||
|
|
||||||
// Called from the OS drag-and-drop callback (may be on a different thread).
|
// Called from the OS drag-and-drop callback (may be on a different thread).
|
||||||
void enqueue_finder_drop(const std::string& path);
|
void enqueue_finder_drop(const std::string& path);
|
||||||
|
|
||||||
private:
|
private:
|
||||||
// ── Local filesystem ──────────────────────────────────────────────────────
|
|
||||||
void refresh_local(const fs::path& path);
|
|
||||||
void refresh_local_drives();
|
|
||||||
std::string active_local_drive_path() const;
|
|
||||||
|
|
||||||
// ── Remote filesystem ─────────────────────────────────────────────────────
|
// ── Remote filesystem ─────────────────────────────────────────────────────
|
||||||
void refresh_remote();
|
void refresh_remote();
|
||||||
|
|
||||||
@@ -103,6 +79,8 @@ private:
|
|||||||
void start_download(uint32_t handle, uint32_t storage_id,
|
void start_download(uint32_t handle, uint32_t storage_id,
|
||||||
const std::string& filename, uint64_t size);
|
const std::string& filename, uint64_t size);
|
||||||
void start_upload(const fs::path& src_path, uint64_t file_size);
|
void start_upload(const fs::path& src_path, uint64_t file_size);
|
||||||
|
// Recursively download an MTP association (folder) into dest (must hold device_mtx_).
|
||||||
|
void download_remote_tree(uint32_t handle, uint32_t storage_id, const fs::path& dest);
|
||||||
|
|
||||||
// ── Device connection loop (runs on background thread) ────────────────────
|
// ── Device connection loop (runs on background thread) ────────────────────
|
||||||
void device_monitor_loop(std::stop_token st);
|
void device_monitor_loop(std::stop_token st);
|
||||||
@@ -110,7 +88,9 @@ private:
|
|||||||
void on_device_disconnected();
|
void on_device_disconnected();
|
||||||
|
|
||||||
// ── Status bar ────────────────────────────────────────────────────────────
|
// ── Status bar ────────────────────────────────────────────────────────────
|
||||||
void set_status(const std::string& msg, float duration_sec = 5.f);
|
// key is an i18n id (e.g. "status.connected"); args fill {name}/{error}/… placeholders.
|
||||||
|
void set_status(const std::string& key, std::vector<std::string> args = {},
|
||||||
|
float duration_sec = 5.f);
|
||||||
|
|
||||||
// ── Finder drop processing (called from state_json) ───────────────────────
|
// ── Finder drop processing (called from state_json) ───────────────────────
|
||||||
void process_finder_drops();
|
void process_finder_drops();
|
||||||
@@ -128,11 +108,8 @@ private:
|
|||||||
std::vector<std::pair<uint32_t, mtp::StorageInfo>> storages_;
|
std::vector<std::pair<uint32_t, mtp::StorageInfo>> storages_;
|
||||||
uint32_t active_storage_ = 0;
|
uint32_t active_storage_ = 0;
|
||||||
|
|
||||||
// ── State: local panel ────────────────────────────────────────────────────
|
// Download destination (defaults to home directory).
|
||||||
fs::path local_path_;
|
fs::path download_path_;
|
||||||
std::vector<LocalEntry> local_entries_;
|
|
||||||
std::vector<LocalDrive> local_drives_;
|
|
||||||
mutable std::atomic<bool> local_needs_refresh_{false};
|
|
||||||
|
|
||||||
// ── State: remote panel ───────────────────────────────────────────────────
|
// ── State: remote panel ───────────────────────────────────────────────────
|
||||||
std::vector<NavEntry> remote_nav_stack_;
|
std::vector<NavEntry> remote_nav_stack_;
|
||||||
@@ -140,19 +117,18 @@ private:
|
|||||||
mutable bool remote_needs_refresh_ = true;
|
mutable bool remote_needs_refresh_ = true;
|
||||||
bool remote_refreshing_ = false;
|
bool remote_refreshing_ = false;
|
||||||
|
|
||||||
// Status bar message with timeout
|
// Status bar message with timeout (i18n key + args)
|
||||||
std::string status_msg_;
|
std::string status_key_;
|
||||||
|
std::vector<std::string> status_args_;
|
||||||
float status_msg_timer_ = 0.f;
|
float status_msg_timer_ = 0.f;
|
||||||
|
|
||||||
// Files/folders dropped from macOS Finder.
|
// Files dropped from macOS Finder.
|
||||||
// Written from the drop callback thread, read + cleared elsewhere.
|
|
||||||
std::vector<std::string> finder_drops_;
|
std::vector<std::string> finder_drops_;
|
||||||
std::mutex finder_drops_mtx_;
|
std::mutex finder_drops_mtx_;
|
||||||
|
|
||||||
struct DropZone { double x = 0, y = 0, w = 0, h = 0; };
|
struct DropZone { double x = 0, y = 0, w = 0, h = 0; };
|
||||||
DropZone remote_drop_zone_;
|
DropZone remote_drop_zone_;
|
||||||
DropZone local_drop_zone_;
|
bool drop_zone_valid_ = false;
|
||||||
bool drop_zones_valid_ = false;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
} // namespace omniMTP
|
} // namespace omniMTP
|
||||||
|
|||||||
238
src/ui/App.mm
238
src/ui/App.mm
@@ -7,7 +7,6 @@
|
|||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <chrono>
|
#include <chrono>
|
||||||
#include <thread>
|
#include <thread>
|
||||||
#include <sys/stat.h>
|
|
||||||
#include <cstdlib>
|
#include <cstdlib>
|
||||||
#include <cstring>
|
#include <cstring>
|
||||||
|
|
||||||
@@ -16,29 +15,6 @@ namespace omniMTP {
|
|||||||
|
|
||||||
// ─── Static helpers ───────────────────────────────────────────────────────────
|
// ─── Static helpers ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
static std::string date_str_from_fs(const fs::directory_entry& e) {
|
|
||||||
struct ::stat st{};
|
|
||||||
if (::stat(e.path().c_str(), &st) == 0) {
|
|
||||||
char buf[32];
|
|
||||||
std::strftime(buf, sizeof(buf), "%b %e, %Y", std::localtime(&st.st_mtime));
|
|
||||||
return buf;
|
|
||||||
}
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
using CrumbList = std::vector<std::pair<std::string, fs::path>>;
|
|
||||||
static CrumbList build_crumbs(const fs::path& path) {
|
|
||||||
std::vector<std::pair<std::string, fs::path>> segs;
|
|
||||||
fs::path p = path;
|
|
||||||
while (true) {
|
|
||||||
auto par = p.parent_path();
|
|
||||||
if (par == p) break; // reached root — skip it
|
|
||||||
segs.insert(segs.begin(), {p.filename().string(), p});
|
|
||||||
p = par;
|
|
||||||
}
|
|
||||||
return segs;
|
|
||||||
}
|
|
||||||
|
|
||||||
static std::string jstr(const std::string& s) {
|
static std::string jstr(const std::string& s) {
|
||||||
std::string r; r.reserve(s.size()+4);
|
std::string r; r.reserve(s.size()+4);
|
||||||
for (char c : s) {
|
for (char c : s) {
|
||||||
@@ -76,12 +52,11 @@ static std::string fmt_eta(double s) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ─── App lifecycle ────────────────────────────────────────────────────────────
|
// ─── App lifecycle ────────────────────────────────────────────────────────────
|
||||||
App::App() : local_path_(fs::path(std::getenv("HOME") ? std::getenv("HOME") : "/")) {}
|
App::App() : download_path_(fs::path(std::getenv("HOME") ? std::getenv("HOME") : "/")) {}
|
||||||
App::~App() { shutdown(); }
|
App::~App() { shutdown(); }
|
||||||
|
|
||||||
void App::init() {
|
void App::init() {
|
||||||
local_path_ = fs::path(std::getenv("HOME") ? std::getenv("HOME") : "/");
|
download_path_ = fs::path(std::getenv("HOME") ? std::getenv("HOME") : "/");
|
||||||
refresh_local(local_path_);
|
|
||||||
monitor_thread_ = std::jthread([this](std::stop_token st) { device_monitor_loop(st); });
|
monitor_thread_ = std::jthread([this](std::stop_token st) { device_monitor_loop(st); });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,9 +81,7 @@ void App::device_monitor_loop(std::stop_token st) {
|
|||||||
engine_ = std::make_unique<transfer::TransferEngine>(*session_);
|
engine_ = std::make_unique<transfer::TransferEngine>(*session_);
|
||||||
engine_->set_completion_handler([this](const transfer::TransferItem& item) {
|
engine_->set_completion_handler([this](const transfer::TransferItem& item) {
|
||||||
if (item.state.load() != transfer::State::Done) return;
|
if (item.state.load() != transfer::State::Done) return;
|
||||||
if (item.direction == transfer::Direction::Download)
|
if (item.direction != transfer::Direction::Download)
|
||||||
local_needs_refresh_.store(true);
|
|
||||||
else
|
|
||||||
remote_needs_refresh_ = true;
|
remote_needs_refresh_ = true;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -162,7 +135,7 @@ void App::on_device_connected(mtp::DeviceInfo di) {
|
|||||||
}
|
}
|
||||||
connected_.store(true);
|
connected_.store(true);
|
||||||
remote_needs_refresh_ = true;
|
remote_needs_refresh_ = true;
|
||||||
set_status(std::format("Connected {}", device_info_.model));
|
set_status("status.connected", {device_info_.model});
|
||||||
refresh_remote();
|
refresh_remote();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,104 +151,7 @@ void App::on_device_disconnected() {
|
|||||||
}
|
}
|
||||||
connected_.store(false);
|
connected_.store(false);
|
||||||
remote_needs_refresh_ = false;
|
remote_needs_refresh_ = false;
|
||||||
set_status("Switch disconnected");
|
set_status("status.disconnected");
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Local filesystem ─────────────────────────────────────────────────────────
|
|
||||||
void App::refresh_local(const fs::path& path) {
|
|
||||||
local_entries_.clear();
|
|
||||||
try {
|
|
||||||
for (auto& e : fs::directory_iterator(path,
|
|
||||||
fs::directory_options::skip_permission_denied)) {
|
|
||||||
LocalEntry le{};
|
|
||||||
le.path = e.path();
|
|
||||||
le.name = e.path().filename().string();
|
|
||||||
le.is_dir = e.is_directory();
|
|
||||||
if (!le.is_dir) { le.size = e.file_size(); le.size_str = fmt_size(le.size); }
|
|
||||||
le.date_str = date_str_from_fs(e);
|
|
||||||
local_entries_.push_back(std::move(le));
|
|
||||||
}
|
|
||||||
std::sort(local_entries_.begin(), local_entries_.end(), [](auto& a, auto& b) {
|
|
||||||
if (a.is_dir != b.is_dir) return a.is_dir > b.is_dir;
|
|
||||||
return a.name < b.name;
|
|
||||||
});
|
|
||||||
} catch (...) {}
|
|
||||||
local_path_ = path;
|
|
||||||
}
|
|
||||||
|
|
||||||
void App::refresh_local_drives() {
|
|
||||||
local_drives_.clear();
|
|
||||||
@autoreleasepool {
|
|
||||||
NSFileManager* fm = NSFileManager.defaultManager;
|
|
||||||
NSArray* keys = @[
|
|
||||||
NSURLVolumeNameKey,
|
|
||||||
NSURLVolumeIsRemovableKey,
|
|
||||||
];
|
|
||||||
NSArray<NSURL*>* urls =
|
|
||||||
[fm mountedVolumeURLsIncludingResourceValuesForKeys:keys
|
|
||||||
options:NSVolumeEnumerationSkipHiddenVolumes];
|
|
||||||
|
|
||||||
bool has_data_volume = false;
|
|
||||||
for (NSURL* url in urls) {
|
|
||||||
if ([url.path isEqualToString:@"/System/Volumes/Data"])
|
|
||||||
has_data_volume = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (NSURL* url in urls) {
|
|
||||||
NSString* path = url.path;
|
|
||||||
if (!path || path.length == 0) continue;
|
|
||||||
std::string mount = path.UTF8String;
|
|
||||||
|
|
||||||
if (mount == "/dev" || mount == "/home" || mount == "/net") continue;
|
|
||||||
if (mount.rfind("/private/", 0) == 0) continue;
|
|
||||||
if (mount.rfind("/System/Volumes/", 0) == 0 && mount != "/System/Volumes/Data") continue;
|
|
||||||
if (mount == "/" && has_data_volume) continue;
|
|
||||||
|
|
||||||
NSString* vol_name = nil;
|
|
||||||
NSNumber* removable = nil;
|
|
||||||
[url getResourceValue:&vol_name forKey:NSURLVolumeNameKey error:nil];
|
|
||||||
[url getResourceValue:&removable forKey:NSURLVolumeIsRemovableKey error:nil];
|
|
||||||
|
|
||||||
LocalDrive drive{};
|
|
||||||
drive.path = mount;
|
|
||||||
drive.name = vol_name.length ? vol_name.UTF8String : fs::path(mount).filename().string();
|
|
||||||
if (drive.name.empty()) drive.name = mount;
|
|
||||||
drive.removable = removable.boolValue;
|
|
||||||
local_drives_.push_back(std::move(drive));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
std::sort(local_drives_.begin(), local_drives_.end(),
|
|
||||||
[](const LocalDrive& a, const LocalDrive& b) {
|
|
||||||
if (a.removable != b.removable) return a.removable < b.removable;
|
|
||||||
return a.name < b.name;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
std::string App::active_local_drive_path() const {
|
|
||||||
std::error_code ec;
|
|
||||||
fs::path current = fs::weakly_canonical(local_path_, ec);
|
|
||||||
if (ec) current = local_path_;
|
|
||||||
const std::string current_str = current.string();
|
|
||||||
|
|
||||||
std::string best;
|
|
||||||
size_t best_len = 0;
|
|
||||||
for (const auto& drive : local_drives_) {
|
|
||||||
fs::path root = fs::path(drive.path);
|
|
||||||
fs::path root_canonical = fs::weakly_canonical(root, ec);
|
|
||||||
if (ec) root_canonical = root;
|
|
||||||
const std::string root_str = root_canonical.string();
|
|
||||||
|
|
||||||
const bool under =
|
|
||||||
current == root_canonical ||
|
|
||||||
current_str.rfind(root_str + "/", 0) == 0;
|
|
||||||
if (under && root_str.size() >= best_len) {
|
|
||||||
best = drive.path;
|
|
||||||
best_len = root_str.size();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!best.empty()) return best;
|
|
||||||
return local_drives_.empty() ? "" : local_drives_.front().path;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Remote filesystem ────────────────────────────────────────────────────────
|
// ─── Remote filesystem ────────────────────────────────────────────────────────
|
||||||
@@ -305,7 +181,7 @@ void App::refresh_remote() {
|
|||||||
return a.name < b.name;
|
return a.name < b.name;
|
||||||
});
|
});
|
||||||
} catch (const mtp::MTPException& e) {
|
} catch (const mtp::MTPException& e) {
|
||||||
set_status(std::format("Error listing: {}", e.what()));
|
set_status("status.listing_error", {e.what()});
|
||||||
}
|
}
|
||||||
remote_refreshing_ = false;
|
remote_refreshing_ = false;
|
||||||
remote_needs_refresh_ = false;
|
remote_needs_refresh_ = false;
|
||||||
@@ -316,60 +192,48 @@ void App::start_download(uint32_t handle, uint32_t storage_id,
|
|||||||
const std::string& filename, uint64_t size) {
|
const std::string& filename, uint64_t size) {
|
||||||
if (!engine_) return;
|
if (!engine_) return;
|
||||||
engine_->enqueue_download(handle, storage_id,
|
engine_->enqueue_download(handle, storage_id,
|
||||||
(local_path_ / filename).string(), filename, size);
|
(download_path_ / filename).string(), filename, size);
|
||||||
set_status(std::format("Downloading {}", filename));
|
set_status("status.downloading", {filename});
|
||||||
}
|
}
|
||||||
void App::start_upload(const fs::path& src, uint64_t sz) {
|
void App::start_upload(const fs::path& src, uint64_t sz) {
|
||||||
if (!engine_ || remote_nav_stack_.empty()) return;
|
if (!engine_ || remote_nav_stack_.empty()) return;
|
||||||
const auto& top = remote_nav_stack_.back();
|
const auto& top = remote_nav_stack_.back();
|
||||||
engine_->enqueue_upload(src.string(), top.handle, top.storage_id,
|
engine_->enqueue_upload(src.string(), top.handle, top.storage_id,
|
||||||
src.filename().string(), sz);
|
src.filename().string(), sz);
|
||||||
set_status(std::format("Uploading {}", src.filename().string()));
|
set_status("status.uploading", {src.filename().string()});
|
||||||
}
|
}
|
||||||
void App::set_status(const std::string& msg, float dur) {
|
void App::set_status(const std::string& key, std::vector<std::string> args, float dur) {
|
||||||
status_msg_ = msg; status_msg_timer_ = dur;
|
status_key_ = key;
|
||||||
|
status_args_ = std::move(args);
|
||||||
|
status_msg_timer_ = dur;
|
||||||
}
|
}
|
||||||
void App::enqueue_finder_drop(const std::string& path) {
|
void App::enqueue_finder_drop(const std::string& path) {
|
||||||
std::lock_guard lock(finder_drops_mtx_);
|
std::lock_guard lock(finder_drops_mtx_);
|
||||||
finder_drops_.push_back(path);
|
finder_drops_.push_back(path);
|
||||||
}
|
}
|
||||||
|
|
||||||
void App::update_drop_zones(double rx, double ry, double rw, double rh,
|
void App::update_drop_zone(double x, double y, double w, double h) {
|
||||||
double lx, double ly, double lw, double lh) {
|
remote_drop_zone_ = {x, y, w, h};
|
||||||
remote_drop_zone_ = {rx, ry, rw, rh};
|
drop_zone_valid_ = w > 0 && h > 0;
|
||||||
local_drop_zone_ = {lx, ly, lw, lh};
|
|
||||||
drop_zones_valid_ = rw > 0 && rh > 0 && lw > 0 && lh > 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
std::string App::drop_target_at(double x, double y) const {
|
std::string App::drop_target_at(double x, double y) const {
|
||||||
if (!drop_zones_valid_) return "none";
|
if (!drop_zone_valid_) return "none";
|
||||||
auto inside = [](const DropZone& z, double px, double py) {
|
const auto& z = remote_drop_zone_;
|
||||||
return px >= z.x && px <= z.x + z.w && py >= z.y && py <= z.y + z.h;
|
if (x >= z.x && x <= z.x + z.w && y >= z.y && y <= z.y + z.h) return "remote";
|
||||||
};
|
|
||||||
if (inside(remote_drop_zone_, x, y)) return "remote";
|
|
||||||
if (inside(local_drop_zone_, x, y)) return "local";
|
|
||||||
return "none";
|
return "none";
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Web bridge API ───────────────────────────────────────────────────────────
|
// ─── Web bridge API ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
std::string App::current_local_path() const { return local_path_.string(); }
|
|
||||||
|
|
||||||
std::string App::state_json() const {
|
std::string App::state_json() const {
|
||||||
// Process any pending finder drops
|
|
||||||
{
|
{
|
||||||
const_cast<App*>(this)->process_finder_drops();
|
const_cast<App*>(this)->process_finder_drops();
|
||||||
}
|
}
|
||||||
if (local_needs_refresh_.load()) {
|
|
||||||
local_needs_refresh_.store(false);
|
|
||||||
const_cast<App*>(this)->refresh_local(local_path_);
|
|
||||||
}
|
|
||||||
if (remote_needs_refresh_ && connected_.load()) {
|
if (remote_needs_refresh_ && connected_.load()) {
|
||||||
remote_needs_refresh_ = false;
|
remote_needs_refresh_ = false;
|
||||||
const_cast<App*>(this)->refresh_remote();
|
const_cast<App*>(this)->refresh_remote();
|
||||||
}
|
}
|
||||||
const_cast<App*>(this)->refresh_local_drives();
|
|
||||||
const std::string active_drive = active_local_drive_path();
|
|
||||||
|
|
||||||
bool conn = connected_.load();
|
bool conn = connected_.load();
|
||||||
std::string dn;
|
std::string dn;
|
||||||
@@ -403,22 +267,6 @@ std::string App::state_json() const {
|
|||||||
j+="{\"handle\":"+std::to_string(nav[i].handle)+",\"storageId\":"+std::to_string(nav[i].storage_id)+",\"label\":\""+jstr(nav[i].label)+"\"}";
|
j+="{\"handle\":"+std::to_string(nav[i].handle)+",\"storageId\":"+std::to_string(nav[i].storage_id)+",\"label\":\""+jstr(nav[i].label)+"\"}";
|
||||||
if (i+1<nav.size()) j+=",";
|
if (i+1<nav.size()) j+=",";
|
||||||
}
|
}
|
||||||
j+="],\"localDrives\":[";
|
|
||||||
for (size_t i = 0; i < local_drives_.size(); ++i) {
|
|
||||||
auto& d = local_drives_[i];
|
|
||||||
j += "{\"path\":\"" + jstr(d.path) + "\",\"name\":\"" + jstr(d.name) + "\",";
|
|
||||||
j += "\"removable\":" + std::string(d.removable ? "true" : "false") + "}";
|
|
||||||
if (i + 1 < local_drives_.size()) j += ",";
|
|
||||||
}
|
|
||||||
j += "],\"activeLocalDrive\":\"" + jstr(active_drive) + "\",";
|
|
||||||
j+="\"localPath\":\""+jstr(local_path_.string())+"\",\"localFiles\":[";
|
|
||||||
for (size_t i=0;i<local_entries_.size();++i) {
|
|
||||||
auto& e=local_entries_[i];
|
|
||||||
j+="{\"name\":\""+jstr(e.name)+"\",\"isDir\":"+std::string(e.is_dir?"true":"false")+",";
|
|
||||||
j+="\"size\":"+std::to_string(e.size)+",\"sizeStr\":\""+jstr(e.size_str)+"\",";
|
|
||||||
j+="\"date\":\""+jstr(e.date_str)+"\",\"path\":\""+jstr(e.path.string())+"\"}";
|
|
||||||
if (i+1<local_entries_.size()) j+=",";
|
|
||||||
}
|
|
||||||
j+="],\"remoteRefreshing\":"+std::string(refreshing?"true":"false")+",\"remoteFiles\":[";
|
j+="],\"remoteRefreshing\":"+std::string(refreshing?"true":"false")+",\"remoteFiles\":[";
|
||||||
for (size_t i=0;i<remote_entries_.size();++i) {
|
for (size_t i=0;i<remote_entries_.size();++i) {
|
||||||
auto& e=remote_entries_[i];
|
auto& e=remote_entries_[i];
|
||||||
@@ -443,9 +291,13 @@ std::string App::state_json() const {
|
|||||||
if (i+1<items.size()) j+=",";
|
if (i+1<items.size()) j+=",";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
j+="],\"statusMessage\":\""+jstr(status_msg_)+"\",";
|
j+="],\"statusKey\":\""+jstr(status_key_)+"\",\"statusArgs\":[";
|
||||||
|
for (size_t i=0;i<status_args_.size();++i) {
|
||||||
|
j+="\""+jstr(status_args_[i])+"\"";
|
||||||
|
if (i+1<status_args_.size()) j+=",";
|
||||||
|
}
|
||||||
char tm[16]; snprintf(tm,sizeof(tm),"%.2f",(double)status_msg_timer_);
|
char tm[16]; snprintf(tm,sizeof(tm),"%.2f",(double)status_msg_timer_);
|
||||||
j+="\"statusTimer\":"+std::string(tm)+"}";
|
j+="],\"statusTimer\":"+std::string(tm)+"}";
|
||||||
return j;
|
return j;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -458,22 +310,14 @@ void App::process_finder_drops() {
|
|||||||
for (auto& dropped : drops) {
|
for (auto& dropped : drops) {
|
||||||
fs::path p(dropped);
|
fs::path p(dropped);
|
||||||
std::error_code ec;
|
std::error_code ec;
|
||||||
if (fs::is_directory(p, ec)) {
|
if (fs::is_directory(p, ec)) continue;
|
||||||
refresh_local(p);
|
if (!ec && connected_.load()) {
|
||||||
} else if (!ec && connected_.load()) {
|
|
||||||
auto sz = fs::file_size(p, ec);
|
auto sz = fs::file_size(p, ec);
|
||||||
if (!ec) start_upload(p, sz);
|
if (!ec) start_upload(p, sz);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void App::navigate_local(const std::string& p) {
|
|
||||||
refresh_local(fs::path(p == "~" ? (std::getenv("HOME") ? std::getenv("HOME") : "/") : p));
|
|
||||||
}
|
|
||||||
void App::navigate_local_up() {
|
|
||||||
if (local_path_.has_parent_path() && local_path_ != local_path_.parent_path())
|
|
||||||
refresh_local(local_path_.parent_path());
|
|
||||||
}
|
|
||||||
void App::navigate_remote(uint32_t h, uint32_t s, const std::string& name) {
|
void App::navigate_remote(uint32_t h, uint32_t s, const std::string& name) {
|
||||||
remote_nav_stack_.push_back({h, s, name});
|
remote_nav_stack_.push_back({h, s, name});
|
||||||
refresh_remote();
|
refresh_remote();
|
||||||
@@ -510,10 +354,36 @@ void App::do_upload(const std::string& src) {
|
|||||||
void App::do_download(uint32_t h, uint32_t s, const std::string& fn, uint64_t sz) {
|
void App::do_download(uint32_t h, uint32_t s, const std::string& fn, uint64_t sz) {
|
||||||
start_download(h, s, fn, sz);
|
start_download(h, s, fn, sz);
|
||||||
}
|
}
|
||||||
|
void App::download_remote_tree(uint32_t handle, uint32_t storage_id, const fs::path& dest) {
|
||||||
|
std::error_code ec;
|
||||||
|
fs::create_directories(dest, ec);
|
||||||
|
if (ec) throw std::runtime_error("Failed to create folder: " + dest.string());
|
||||||
|
|
||||||
|
auto child_handles = ops_->get_object_handles(storage_id, handle);
|
||||||
|
std::atomic<bool> cancel{false};
|
||||||
|
mtp::ProgressFn noop = [](uint64_t, uint64_t) {};
|
||||||
|
|
||||||
|
for (uint32_t h : child_handles) {
|
||||||
|
auto oi = ops_->get_object_info(h);
|
||||||
|
if (oi.filename.empty()) continue;
|
||||||
|
fs::path child = dest / oi.filename;
|
||||||
|
if (oi.is_directory()) {
|
||||||
|
download_remote_tree(h, storage_id, child);
|
||||||
|
} else {
|
||||||
|
ops_->get_object(h, child.string(), oi.compressed_size, noop, cancel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void App::download_remote_to_path(uint32_t handle, uint32_t storage_id,
|
void App::download_remote_to_path(uint32_t handle, uint32_t storage_id,
|
||||||
const std::string& dest_path, uint64_t size) {
|
const std::string& dest_path, uint64_t size,
|
||||||
|
bool is_dir) {
|
||||||
std::lock_guard lock(device_mtx_);
|
std::lock_guard lock(device_mtx_);
|
||||||
if (!ops_) throw std::runtime_error("Not connected");
|
if (!ops_) throw std::runtime_error("Not connected");
|
||||||
|
if (is_dir) {
|
||||||
|
download_remote_tree(handle, storage_id, fs::path(dest_path));
|
||||||
|
return;
|
||||||
|
}
|
||||||
std::atomic<bool> cancel{false};
|
std::atomic<bool> cancel{false};
|
||||||
mtp::ProgressFn noop = [](uint64_t, uint64_t) {};
|
mtp::ProgressFn noop = [](uint64_t, uint64_t) {};
|
||||||
ops_->get_object(handle, dest_path, size, noop, cancel);
|
ops_->get_object(handle, dest_path, size, noop, cancel);
|
||||||
|
|||||||
796
src/ui/WebUI.mm
796
src/ui/WebUI.mm
@@ -1,13 +1,230 @@
|
|||||||
#include "WebUI.hpp"
|
#include "WebUI.hpp"
|
||||||
#import <Cocoa/Cocoa.h>
|
#import <Cocoa/Cocoa.h>
|
||||||
#import <WebKit/WebKit.h>
|
#import <WebKit/WebKit.h>
|
||||||
|
#import <UniformTypeIdentifiers/UniformTypeIdentifiers.h>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <cmath>
|
#include <cmath>
|
||||||
|
|
||||||
|
// ─── Locale JSON helpers ──────────────────────────────────────────────────────
|
||||||
|
static BOOL OmniLangCodeOK(NSString* code) {
|
||||||
|
if (code.length == 0 || code.length > 32) return NO;
|
||||||
|
NSCharacterSet* allowed = [NSCharacterSet characterSetWithCharactersInString:
|
||||||
|
@"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-"];
|
||||||
|
return [code rangeOfCharacterFromSet:allowed.invertedSet].location == NSNotFound;
|
||||||
|
}
|
||||||
|
|
||||||
|
static NSURL* OmniFindWebrootURL(void) {
|
||||||
|
NSBundle* bundle = NSBundle.mainBundle;
|
||||||
|
NSURL* indexURL = [bundle URLForResource:@"index" withExtension:@"html" subdirectory:@"webroot"];
|
||||||
|
if (indexURL) return indexURL.URLByDeletingLastPathComponent;
|
||||||
|
|
||||||
|
NSString* base = bundle.executableURL.URLByDeletingLastPathComponent.path;
|
||||||
|
NSArray<NSString*>* candidates = @[
|
||||||
|
[base stringByAppendingPathComponent:@"webroot/index.html"],
|
||||||
|
[base stringByAppendingPathComponent:@"../src/ui/webroot/index.html"],
|
||||||
|
[base stringByAppendingPathComponent:@"../../src/ui/webroot/index.html"],
|
||||||
|
[base stringByAppendingPathComponent:@"../../../src/ui/webroot/index.html"],
|
||||||
|
];
|
||||||
|
for (NSString* c in candidates) {
|
||||||
|
NSString* resolved = c.stringByResolvingSymlinksInPath;
|
||||||
|
if ([[NSFileManager defaultManager] fileExistsAtPath:resolved])
|
||||||
|
return [NSURL fileURLWithPath:resolved.stringByDeletingLastPathComponent];
|
||||||
|
}
|
||||||
|
return nil;
|
||||||
|
}
|
||||||
|
|
||||||
|
static NSURL* OmniBundleConfigURL(void) {
|
||||||
|
NSString* res = NSBundle.mainBundle.resourcePath;
|
||||||
|
if (!res) return nil;
|
||||||
|
return [NSURL fileURLWithPath:[res stringByAppendingPathComponent:@"config.json"]];
|
||||||
|
}
|
||||||
|
|
||||||
|
static NSURL* OmniFallbackConfigURL(void) {
|
||||||
|
NSArray* dirs = NSSearchPathForDirectoriesInDomains(NSApplicationSupportDirectory,
|
||||||
|
NSUserDomainMask, YES);
|
||||||
|
if (!dirs.count) return nil;
|
||||||
|
NSString* dir = [dirs[0] stringByAppendingPathComponent:@"OmniMTP"];
|
||||||
|
[[NSFileManager defaultManager] createDirectoryAtPath:dir
|
||||||
|
withIntermediateDirectories:YES
|
||||||
|
attributes:nil
|
||||||
|
error:nil];
|
||||||
|
return [NSURL fileURLWithPath:[dir stringByAppendingPathComponent:@"config.json"]];
|
||||||
|
}
|
||||||
|
|
||||||
|
static NSDictionary* OmniLoadConfig(void) {
|
||||||
|
NSMutableArray<NSURL*>* urls = [NSMutableArray array];
|
||||||
|
NSURL* bundleURL = OmniBundleConfigURL();
|
||||||
|
NSURL* fallbackURL = OmniFallbackConfigURL();
|
||||||
|
if (bundleURL) [urls addObject:bundleURL];
|
||||||
|
if (fallbackURL) [urls addObject:fallbackURL];
|
||||||
|
for (NSURL* url in urls) {
|
||||||
|
NSData* data = [NSData dataWithContentsOfURL:url];
|
||||||
|
if (!data) continue;
|
||||||
|
id obj = [NSJSONSerialization JSONObjectWithData:data options:0 error:nil];
|
||||||
|
if ([obj isKindOfClass:[NSDictionary class]]) return obj;
|
||||||
|
}
|
||||||
|
return @{};
|
||||||
|
}
|
||||||
|
|
||||||
|
static BOOL OmniSaveConfig(NSDictionary* cfg) {
|
||||||
|
NSData* data = [NSJSONSerialization dataWithJSONObject:cfg
|
||||||
|
options:NSJSONWritingPrettyPrinted
|
||||||
|
error:nil];
|
||||||
|
if (!data) return NO;
|
||||||
|
// Prefer writing inside the .app bundle (Resources/config.json).
|
||||||
|
NSURL* bundleURL = OmniBundleConfigURL();
|
||||||
|
if (bundleURL && [data writeToURL:bundleURL atomically:YES]) return YES;
|
||||||
|
// Installed apps are often not writable; keep a working fallback.
|
||||||
|
NSURL* fallback = OmniFallbackConfigURL();
|
||||||
|
return fallback && [data writeToURL:fallback atomically:YES];
|
||||||
|
}
|
||||||
|
|
||||||
|
static NSString* OmniConfiguredLanguageOverride(void) {
|
||||||
|
NSString* lang = OmniLoadConfig()[@"language"];
|
||||||
|
if (![lang isKindOfClass:[NSString class]]) return nil;
|
||||||
|
if (lang.length == 0 || [lang isEqualToString:@"system"]) return nil;
|
||||||
|
return OmniLangCodeOK(lang) ? lang : nil;
|
||||||
|
}
|
||||||
|
|
||||||
|
static NSDictionary* OmniLoadLangPack(NSURL* webroot, NSString* code) {
|
||||||
|
if (!webroot || !OmniLangCodeOK(code)) return nil;
|
||||||
|
NSURL* url = [webroot URLByAppendingPathComponent:
|
||||||
|
[NSString stringWithFormat:@"lang/%@.json", code]];
|
||||||
|
NSData* data = [NSData dataWithContentsOfURL:url];
|
||||||
|
if (!data) return nil;
|
||||||
|
id obj = [NSJSONSerialization JSONObjectWithData:data options:0 error:nil];
|
||||||
|
return [obj isKindOfClass:[NSDictionary class]] ? obj : nil;
|
||||||
|
}
|
||||||
|
|
||||||
|
static NSArray<NSString*>* OmniLocaleCandidates(void) {
|
||||||
|
NSString* override = OmniConfiguredLanguageOverride();
|
||||||
|
if (override) return @[override, @"en"];
|
||||||
|
|
||||||
|
NSMutableArray<NSString*>* out = [NSMutableArray array];
|
||||||
|
void (^add)(NSString*) = ^(NSString* c) {
|
||||||
|
if (c.length && ![out containsObject:c]) [out addObject:c];
|
||||||
|
};
|
||||||
|
for (NSString* raw in (NSLocale.preferredLanguages ?: @[@"en"])) {
|
||||||
|
NSString* tag = [raw stringByReplacingOccurrencesOfString:@"_" withString:@"-"];
|
||||||
|
add(tag);
|
||||||
|
NSArray* parts = [tag componentsSeparatedByString:@"-"];
|
||||||
|
NSString* primary = [(NSString*)parts.firstObject lowercaseString];
|
||||||
|
if ([primary isEqualToString:@"zh"]) {
|
||||||
|
NSString* script = parts.count > 1 ? [(NSString*)parts[1] lowercaseString] : @"";
|
||||||
|
if ([script hasPrefix:@"hant"] || [script isEqualToString:@"tw"] ||
|
||||||
|
[script isEqualToString:@"hk"] || [script isEqualToString:@"mo"])
|
||||||
|
add(@"zh-Hant");
|
||||||
|
else
|
||||||
|
add(@"zh-Hans");
|
||||||
|
add(@"zh");
|
||||||
|
} else if (primary.length) {
|
||||||
|
add(primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
add(@"en");
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
static NSArray<NSString*>* OmniAvailableLanguageCodes(NSURL* webroot) {
|
||||||
|
NSMutableArray<NSString*>* codes = [NSMutableArray array];
|
||||||
|
NSURL* langDir = [webroot URLByAppendingPathComponent:@"lang"];
|
||||||
|
NSArray* files = [[NSFileManager defaultManager] contentsOfDirectoryAtURL:langDir
|
||||||
|
includingPropertiesForKeys:nil
|
||||||
|
options:NSDirectoryEnumerationSkipsHiddenFiles
|
||||||
|
error:nil];
|
||||||
|
for (NSURL* file in files) {
|
||||||
|
if (![file.pathExtension isEqualToString:@"json"]) continue;
|
||||||
|
NSString* code = file.URLByDeletingPathExtension.lastPathComponent;
|
||||||
|
if (OmniLangCodeOK(code)) [codes addObject:code];
|
||||||
|
}
|
||||||
|
[codes sortUsingSelector:@selector(compare:)];
|
||||||
|
if (![codes containsObject:@"en"]) [codes insertObject:@"en" atIndex:0];
|
||||||
|
return codes;
|
||||||
|
}
|
||||||
|
|
||||||
|
static NSString* OmniLanguageDisplayName(NSString* code) {
|
||||||
|
static NSDictionary* names;
|
||||||
|
static dispatch_once_t once;
|
||||||
|
dispatch_once(&once, ^{
|
||||||
|
names = @{
|
||||||
|
@"en": @"English",
|
||||||
|
@"de": @"Deutsch",
|
||||||
|
@"fr": @"Français",
|
||||||
|
@"es": @"Español",
|
||||||
|
@"it": @"Italiano",
|
||||||
|
@"nl": @"Nederlands",
|
||||||
|
@"pt": @"Português",
|
||||||
|
@"ru": @"Русский",
|
||||||
|
@"ja": @"日本語",
|
||||||
|
@"zh-Hans": @"简体中文",
|
||||||
|
@"zh-Hant": @"繁體中文",
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return names[code] ?: code;
|
||||||
|
}
|
||||||
|
|
||||||
|
static NSDictionary* OmniBuildI18nPayload(NSURL* webroot) {
|
||||||
|
NSDictionary* fallback = OmniLoadLangPack(webroot, @"en") ?: @{};
|
||||||
|
NSString* lang = @"en";
|
||||||
|
NSDictionary* strings = fallback;
|
||||||
|
for (NSString* code in OmniLocaleCandidates()) {
|
||||||
|
if ([code isEqualToString:@"en"]) {
|
||||||
|
lang = @"en";
|
||||||
|
strings = fallback;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
NSDictionary* pack = OmniLoadLangPack(webroot, code);
|
||||||
|
if (pack) {
|
||||||
|
lang = code;
|
||||||
|
NSMutableDictionary* merged = [fallback mutableCopy];
|
||||||
|
[merged addEntriesFromDictionary:pack];
|
||||||
|
strings = merged;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return @{
|
||||||
|
@"lang": lang,
|
||||||
|
@"fallback": fallback,
|
||||||
|
@"strings": strings,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static NSString* OmniI18nBootstrapScript(NSURL* webroot) {
|
||||||
|
NSDictionary* payload = OmniBuildI18nPayload(webroot);
|
||||||
|
NSData* data = [NSJSONSerialization dataWithJSONObject:payload options:0 error:nil];
|
||||||
|
NSString* json = data ? [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding] : @"{}";
|
||||||
|
return [NSString stringWithFormat:@"window.__OMNI_I18N__=%@;", json];
|
||||||
|
}
|
||||||
|
|
||||||
|
static NSString* OmniMenuString(NSString* key) {
|
||||||
|
NSDictionary* payload = OmniBuildI18nPayload(OmniFindWebrootURL());
|
||||||
|
NSDictionary* strings = payload[@"strings"];
|
||||||
|
NSString* val = [strings isKindOfClass:[NSDictionary class]] ? strings[key] : nil;
|
||||||
|
return [val isKindOfClass:[NSString class]] ? val : key;
|
||||||
|
}
|
||||||
|
|
||||||
|
static NSString* OmniMenuFormat(NSString* key, NSDictionary<NSString*, NSString*>* vars) {
|
||||||
|
NSString* s = OmniMenuString(key);
|
||||||
|
for (NSString* k in vars)
|
||||||
|
s = [s stringByReplacingOccurrencesOfString:[NSString stringWithFormat:@"{%@}", k]
|
||||||
|
withString:vars[k] ?: @""];
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Forward declarations ─────────────────────────────────────────────────────
|
// ─── Forward declarations ─────────────────────────────────────────────────────
|
||||||
@class OmniBridge;
|
@class OmniBridge;
|
||||||
@class OmniAppDelegate;
|
@class OmniAppDelegate;
|
||||||
|
|
||||||
|
@interface OmniWebView : WKWebView <NSDraggingSource>
|
||||||
|
- (instancetype)initWithFrame:(NSRect)frame
|
||||||
|
configuration:(WKWebViewConfiguration*)config
|
||||||
|
app:(omniMTP::App*)app;
|
||||||
|
- (void)prepareExportDragWithFiles:(NSArray<NSDictionary*>*)files
|
||||||
|
atWebPoint:(NSPoint)webPoint;
|
||||||
|
- (void)beginExportDragWithFiles:(NSArray<NSDictionary*>*)files
|
||||||
|
atWebPoint:(NSPoint)webPoint;
|
||||||
|
@end
|
||||||
|
|
||||||
// ─── OmniBridge: JS → C++ message handler ─────────────────────────────────────
|
// ─── OmniBridge: JS → C++ message handler ─────────────────────────────────────
|
||||||
@interface OmniBridge : NSObject <WKScriptMessageHandler, WKNavigationDelegate, WKUIDelegate>
|
@interface OmniBridge : NSObject <WKScriptMessageHandler, WKNavigationDelegate, WKUIDelegate>
|
||||||
- (instancetype)initWithApp:(omniMTP::App*)app webView:(WKWebView*)webView;
|
- (instancetype)initWithApp:(omniMTP::App*)app webView:(WKWebView*)webView;
|
||||||
@@ -39,6 +256,15 @@
|
|||||||
|
|
||||||
long long rid = msgId.longLongValue;
|
long long rid = msgId.longLongValue;
|
||||||
|
|
||||||
|
// Locale + export must run immediately (sync) so JS can boot strings / arm drag.
|
||||||
|
if ([action isEqualToString:@"prepare_export_drag"] ||
|
||||||
|
[action isEqualToString:@"begin_export_drag"] ||
|
||||||
|
[action isEqualToString:@"get_preferred_languages"] ||
|
||||||
|
[action isEqualToString:@"get_lang_pack"]) {
|
||||||
|
[self handleAction:action body:body responseId:rid];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// All dispatched on main thread already (WKWebView guarantee), but we
|
// All dispatched on main thread already (WKWebView guarantee), but we
|
||||||
// use dispatch_async to avoid blocking the JS engine during heavy ops.
|
// use dispatch_async to avoid blocking the JS engine during heavy ops.
|
||||||
dispatch_async(dispatch_get_main_queue(), ^{
|
dispatch_async(dispatch_get_main_queue(), ^{
|
||||||
@@ -56,16 +282,6 @@
|
|||||||
[self sendRawResult:result responseId:rid];
|
[self sendRawResult:result responseId:rid];
|
||||||
return;
|
return;
|
||||||
|
|
||||||
} else if ([action isEqualToString:@"navigate_local"]) {
|
|
||||||
NSString* path = body[@"path"];
|
|
||||||
if (path) _app->navigate_local(path.UTF8String);
|
|
||||||
|
|
||||||
} else if ([action isEqualToString:@"navigate_local_up"]) {
|
|
||||||
_app->navigate_local_up();
|
|
||||||
|
|
||||||
} else if ([action isEqualToString:@"refresh_local"]) {
|
|
||||||
_app->navigate_local(_app->current_local_path());
|
|
||||||
|
|
||||||
} else if ([action isEqualToString:@"navigate_remote"]) {
|
} else if ([action isEqualToString:@"navigate_remote"]) {
|
||||||
NSNumber* handle = body[@"handle"];
|
NSNumber* handle = body[@"handle"];
|
||||||
NSNumber* storageId = body[@"storageId"];
|
NSNumber* storageId = body[@"storageId"];
|
||||||
@@ -139,15 +355,63 @@
|
|||||||
}];
|
}];
|
||||||
});
|
});
|
||||||
|
|
||||||
|
} else if ([action isEqualToString:@"get_preferred_languages"]) {
|
||||||
|
NSArray<NSString*>* langs = NSLocale.preferredLanguages ?: @[@"en"];
|
||||||
|
NSData* data = [NSJSONSerialization dataWithJSONObject:langs options:0 error:nil];
|
||||||
|
NSString* json = data ? [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding] : @"[\"en\"]";
|
||||||
|
[self sendRawResult:json responseId:rid];
|
||||||
|
return;
|
||||||
|
|
||||||
|
} else if ([action isEqualToString:@"get_lang_pack"]) {
|
||||||
|
NSString* code = body[@"code"];
|
||||||
|
if (![code isKindOfClass:[NSString class]] || !OmniLangCodeOK(code)) {
|
||||||
|
[self sendNullResult:rid];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
NSURL* webroot = OmniFindWebrootURL();
|
||||||
|
NSDictionary* pack = OmniLoadLangPack(webroot, code);
|
||||||
|
if (!pack) {
|
||||||
|
[self sendNullResult:rid];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
NSData* data = [NSJSONSerialization dataWithJSONObject:pack options:0 error:nil];
|
||||||
|
NSString* json = data ? [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding] : @"null";
|
||||||
|
[self sendRawResult:json responseId:rid];
|
||||||
|
return;
|
||||||
|
|
||||||
|
} else if ([action isEqualToString:@"confirm"]) {
|
||||||
|
NSString* message = body[@"message"] ?: @"";
|
||||||
|
NSString* okTitle = body[@"ok"] ?: @"OK";
|
||||||
|
NSString* cancelTitle = body[@"cancel"] ?: @"Cancel";
|
||||||
|
NSAlert* alert = [[NSAlert alloc] init];
|
||||||
|
alert.messageText = message;
|
||||||
|
[alert addButtonWithTitle:okTitle];
|
||||||
|
[alert addButtonWithTitle:cancelTitle];
|
||||||
|
BOOL ok = [alert runModal] == NSAlertFirstButtonReturn;
|
||||||
|
[self sendRawResult:(ok ? @"true" : @"false") responseId:rid];
|
||||||
|
return;
|
||||||
|
|
||||||
} else if ([action isEqualToString:@"update_drop_zones"]) {
|
} else if ([action isEqualToString:@"update_drop_zones"]) {
|
||||||
NSDictionary* remote = body[@"remote"];
|
NSDictionary* remote = body[@"remote"];
|
||||||
NSDictionary* local = body[@"local"];
|
if ([remote isKindOfClass:[NSDictionary class]]) {
|
||||||
if ([remote isKindOfClass:[NSDictionary class]] && [local isKindOfClass:[NSDictionary class]]) {
|
_app->update_drop_zone(
|
||||||
_app->update_drop_zones(
|
|
||||||
[remote[@"x"] doubleValue], [remote[@"y"] doubleValue],
|
[remote[@"x"] doubleValue], [remote[@"y"] doubleValue],
|
||||||
[remote[@"w"] doubleValue], [remote[@"h"] doubleValue],
|
[remote[@"w"] doubleValue], [remote[@"h"] doubleValue]);
|
||||||
[local[@"x"] doubleValue], [local[@"y"] doubleValue],
|
}
|
||||||
[local[@"w"] doubleValue], [local[@"h"] doubleValue]);
|
|
||||||
|
} else if ([action isEqualToString:@"prepare_export_drag"] ||
|
||||||
|
[action isEqualToString:@"begin_export_drag"]) {
|
||||||
|
NSArray* files = body[@"files"];
|
||||||
|
NSNumber* x = body[@"x"];
|
||||||
|
NSNumber* y = body[@"y"];
|
||||||
|
if ([files isKindOfClass:[NSArray class]] && files.count > 0 &&
|
||||||
|
[x isKindOfClass:[NSNumber class]] && [y isKindOfClass:[NSNumber class]] &&
|
||||||
|
[_webView isKindOfClass:[OmniWebView class]]) {
|
||||||
|
NSPoint pt = NSMakePoint(x.doubleValue, y.doubleValue);
|
||||||
|
if ([action isEqualToString:@"prepare_export_drag"])
|
||||||
|
[(OmniWebView*)_webView prepareExportDragWithFiles:files atWebPoint:pt];
|
||||||
|
else
|
||||||
|
[(OmniWebView*)_webView beginExportDragWithFiles:files atWebPoint:pt];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -193,9 +457,10 @@ runJavaScriptConfirmPanelWithMessage:(NSString*)message
|
|||||||
initiatedByFrame:(WKFrameInfo*)frame
|
initiatedByFrame:(WKFrameInfo*)frame
|
||||||
completionHandler:(void (^)(BOOL result))completionHandler {
|
completionHandler:(void (^)(BOOL result))completionHandler {
|
||||||
(void)webView; (void)frame;
|
(void)webView; (void)frame;
|
||||||
|
// Prefer the bridge `confirm` action from JS so button titles are localized.
|
||||||
NSAlert* alert = [[NSAlert alloc] init];
|
NSAlert* alert = [[NSAlert alloc] init];
|
||||||
alert.messageText = message;
|
alert.messageText = message;
|
||||||
[alert addButtonWithTitle:@"Delete"];
|
[alert addButtonWithTitle:@"OK"];
|
||||||
[alert addButtonWithTitle:@"Cancel"];
|
[alert addButtonWithTitle:@"Cancel"];
|
||||||
NSModalResponse response = [alert runModal];
|
NSModalResponse response = [alert runModal];
|
||||||
completionHandler(response == NSAlertFirstButtonReturn);
|
completionHandler(response == NSAlertFirstButtonReturn);
|
||||||
@@ -252,6 +517,7 @@ static const CGFloat kTrafficLightInset = 72.0;
|
|||||||
@property (nonatomic, assign) uint32_t handle;
|
@property (nonatomic, assign) uint32_t handle;
|
||||||
@property (nonatomic, assign) uint32_t storageId;
|
@property (nonatomic, assign) uint32_t storageId;
|
||||||
@property (nonatomic, assign) uint64_t size;
|
@property (nonatomic, assign) uint64_t size;
|
||||||
|
@property (nonatomic, assign) BOOL isDir;
|
||||||
@property (nonatomic, copy) NSString* filename;
|
@property (nonatomic, copy) NSString* filename;
|
||||||
@end
|
@end
|
||||||
|
|
||||||
@@ -261,12 +527,17 @@ static const CGFloat kTrafficLightInset = 72.0;
|
|||||||
writePromiseToURL:(NSURL*)url
|
writePromiseToURL:(NSURL*)url
|
||||||
completionHandler:(void (^)(NSError* _Nullable))completionHandler {
|
completionHandler:(void (^)(NSError* _Nullable))completionHandler {
|
||||||
(void)provider;
|
(void)provider;
|
||||||
@try {
|
try {
|
||||||
if (_app) _app->download_remote_to_path(_handle, _storageId, url.path.UTF8String, _size);
|
if (_app)
|
||||||
|
_app->download_remote_to_path(_handle, _storageId, url.path.UTF8String, _size, _isDir);
|
||||||
completionHandler(nil);
|
completionHandler(nil);
|
||||||
} @catch (NSException* ex) {
|
} catch (const std::exception& ex) {
|
||||||
|
NSString* msg = [NSString stringWithUTF8String:ex.what()];
|
||||||
completionHandler([NSError errorWithDomain:@"OmniMTP" code:1
|
completionHandler([NSError errorWithDomain:@"OmniMTP" code:1
|
||||||
userInfo:@{NSLocalizedDescriptionKey: ex.reason ?: @"Download failed"}]);
|
userInfo:@{NSLocalizedDescriptionKey: msg ?: @"Download failed"}]);
|
||||||
|
} catch (...) {
|
||||||
|
completionHandler([NSError errorWithDomain:@"OmniMTP" code:1
|
||||||
|
userInfo:@{NSLocalizedDescriptionKey: @"Download failed"}]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -284,20 +555,15 @@ static const CGFloat kTrafficLightInset = 72.0;
|
|||||||
@end
|
@end
|
||||||
|
|
||||||
// ─── OmniWebView: native drag-and-drop ────────────────────────────────────────
|
// ─── OmniWebView: native drag-and-drop ────────────────────────────────────────
|
||||||
@interface OmniWebView : WKWebView <NSDraggingSource>
|
|
||||||
- (instancetype)initWithFrame:(NSRect)frame
|
|
||||||
configuration:(WKWebViewConfiguration*)config
|
|
||||||
app:(omniMTP::App*)app;
|
|
||||||
@end
|
|
||||||
|
|
||||||
@implementation OmniWebView {
|
@implementation OmniWebView {
|
||||||
omniMTP::App* _app;
|
omniMTP::App* _app;
|
||||||
BOOL _remoteDragHighlighted;
|
BOOL _remoteDragHighlighted;
|
||||||
BOOL _localDragHighlighted;
|
|
||||||
NSPoint _mouseDownWebPoint;
|
|
||||||
NSString* _pendingDragJSON;
|
|
||||||
BOOL _nativeDragStarted;
|
|
||||||
NSArray<NSDictionary*>* _exportDragEntries;
|
NSArray<NSDictionary*>* _exportDragEntries;
|
||||||
|
NSMutableArray* _exportDelegates; // keep alive for NSFilePromiseProvider
|
||||||
|
NSArray<NSDictionary*>* _pendingExportFiles;
|
||||||
|
NSPoint _pendingExportViewPoint; // AppKit view coords (bottom-left origin)
|
||||||
|
BOOL _exportDragStarted;
|
||||||
|
id _exportEventMonitor;
|
||||||
}
|
}
|
||||||
|
|
||||||
- (instancetype)initWithFrame:(NSRect)frame
|
- (instancetype)initWithFrame:(NSRect)frame
|
||||||
@@ -306,15 +572,19 @@ static const CGFloat kTrafficLightInset = 72.0;
|
|||||||
if ((self = [super initWithFrame:frame configuration:config])) {
|
if ((self = [super initWithFrame:frame configuration:config])) {
|
||||||
_app = app;
|
_app = app;
|
||||||
_remoteDragHighlighted = NO;
|
_remoteDragHighlighted = NO;
|
||||||
_localDragHighlighted = NO;
|
_exportDelegates = [NSMutableArray array];
|
||||||
[self registerForDraggedTypes:@[NSPasteboardTypeFileURL]];
|
[self registerForDraggedTypes:@[NSPasteboardTypeFileURL]];
|
||||||
}
|
}
|
||||||
return self;
|
return self;
|
||||||
}
|
}
|
||||||
|
|
||||||
- (NSPoint)webPointFromEvent:(NSEvent*)event {
|
- (void)clearPendingExport {
|
||||||
NSPoint loc = [self convertPoint:event.locationInWindow fromView:nil];
|
if (_exportEventMonitor) {
|
||||||
return NSMakePoint(loc.x, self.bounds.size.height - loc.y);
|
[NSEvent removeMonitor:_exportEventMonitor];
|
||||||
|
_exportEventMonitor = nil;
|
||||||
|
}
|
||||||
|
_pendingExportFiles = nil;
|
||||||
|
_exportDragStarted = NO;
|
||||||
}
|
}
|
||||||
|
|
||||||
- (NSPoint)webPointFromDrag:(id<NSDraggingInfo>)sender {
|
- (NSPoint)webPointFromDrag:(id<NSDraggingInfo>)sender {
|
||||||
@@ -347,92 +617,141 @@ static const CGFloat kTrafficLightInset = 72.0;
|
|||||||
[self evaluateJavaScript:js completionHandler:nil];
|
[self evaluateJavaScript:js completionHandler:nil];
|
||||||
}
|
}
|
||||||
|
|
||||||
- (void)setLocalDragOver:(BOOL)over {
|
- (void)prepareExportDragWithFiles:(NSArray<NSDictionary*>*)files
|
||||||
if (_localDragHighlighted == over) return;
|
atWebPoint:(NSPoint)webPoint {
|
||||||
_localDragHighlighted = over;
|
(void)webPoint; // JS coords can disagree with AppKit; use the live mouse event.
|
||||||
NSString* js = over
|
if (!_app || !_app->connected() || files.count == 0) return;
|
||||||
? @"document.getElementById('local-files')?.classList.add('drag-over')"
|
|
||||||
: @"document.getElementById('local-files')?.classList.remove('drag-over')";
|
|
||||||
[self evaluateJavaScript:js completionHandler:nil];
|
|
||||||
}
|
|
||||||
|
|
||||||
- (void)mouseDown:(NSEvent*)event {
|
[self clearPendingExport];
|
||||||
_mouseDownWebPoint = [self webPointFromEvent:event];
|
_pendingExportFiles = [files copy];
|
||||||
_pendingDragJSON = nil;
|
_exportDragStarted = NO;
|
||||||
_nativeDragStarted = NO;
|
|
||||||
[self evaluateJavaScript:@"window.omniClearSelection&&window.omniClearSelection()"
|
// Prefer the real mouse location so the drag image anchors under the cursor.
|
||||||
completionHandler:nil];
|
NSEvent* down = [NSApp currentEvent];
|
||||||
NSString* js = [NSString stringWithFormat:
|
if (down)
|
||||||
@"window.omniRemoteDragFilesAt(%f,%f)", _mouseDownWebPoint.x, _mouseDownWebPoint.y];
|
_pendingExportViewPoint = [self convertPoint:down.locationInWindow fromView:nil];
|
||||||
[self evaluateJavaScript:js completionHandler:^(id value, NSError* err) {
|
else
|
||||||
(void)err;
|
_pendingExportViewPoint = NSMakePoint(webPoint.x, self.bounds.size.height - webPoint.y);
|
||||||
if ([value isKindOfClass:[NSString class]] && [(NSString*)value length] > 0)
|
|
||||||
self->_pendingDragJSON = value;
|
__weak OmniWebView* weakSelf = self;
|
||||||
|
_exportEventMonitor = [NSEvent addLocalMonitorForEventsMatchingMask:
|
||||||
|
(NSEventMaskLeftMouseDragged | NSEventMaskLeftMouseUp)
|
||||||
|
handler:^NSEvent* (NSEvent* event) {
|
||||||
|
OmniWebView* strongSelf = weakSelf;
|
||||||
|
if (!strongSelf) return event;
|
||||||
|
|
||||||
|
if (event.type == NSEventTypeLeftMouseUp) {
|
||||||
|
[strongSelf clearPendingExport];
|
||||||
|
return event;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (strongSelf->_exportDragStarted || !strongSelf->_pendingExportFiles.count)
|
||||||
|
return event;
|
||||||
|
|
||||||
|
NSPoint loc = [strongSelf convertPoint:event.locationInWindow fromView:nil];
|
||||||
|
CGFloat dx = loc.x - strongSelf->_pendingExportViewPoint.x;
|
||||||
|
CGFloat dy = loc.y - strongSelf->_pendingExportViewPoint.y;
|
||||||
|
if (std::sqrt(dx * dx + dy * dy) < 4.0) return event;
|
||||||
|
|
||||||
|
strongSelf->_exportDragStarted = YES;
|
||||||
|
NSArray* filesCopy = strongSelf->_pendingExportFiles;
|
||||||
|
[strongSelf clearPendingExport];
|
||||||
|
[strongSelf beginExportDragWithFiles:filesCopy event:event];
|
||||||
|
return nil; // consume; session owns the drag
|
||||||
}];
|
}];
|
||||||
[super mouseDown:event];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
- (void)mouseDragged:(NSEvent*)event {
|
- (void)beginExportDragWithFiles:(NSArray<NSDictionary*>*)files
|
||||||
if (!_nativeDragStarted && _pendingDragJSON.length > 0 && _app && _app->connected()) {
|
atWebPoint:(NSPoint)webPoint {
|
||||||
NSPoint cur = [self webPointFromEvent:event];
|
(void)webPoint;
|
||||||
CGFloat dx = cur.x - _mouseDownWebPoint.x;
|
NSEvent* event = [NSApp currentEvent];
|
||||||
CGFloat dy = cur.y - _mouseDownWebPoint.y;
|
if (!event || (event.type != NSEventTypeLeftMouseDown &&
|
||||||
if (std::sqrt(dx * dx + dy * dy) >= 4.0) {
|
event.type != NSEventTypeLeftMouseDragged)) {
|
||||||
_nativeDragStarted = YES;
|
NSPoint screen = NSEvent.mouseLocation;
|
||||||
|
NSRect windowRect = [self.window convertRectFromScreen:NSMakeRect(screen.x, screen.y, 0, 0)];
|
||||||
|
event = [NSEvent mouseEventWithType:NSEventTypeLeftMouseDragged
|
||||||
|
location:windowRect.origin
|
||||||
|
modifierFlags:0
|
||||||
|
timestamp:(NSApp.currentEvent ? NSApp.currentEvent.timestamp : 0)
|
||||||
|
windowNumber:self.window.windowNumber
|
||||||
|
context:nil
|
||||||
|
eventNumber:0
|
||||||
|
clickCount:1
|
||||||
|
pressure:1.0];
|
||||||
|
}
|
||||||
|
[self beginExportDragWithFiles:files event:event];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)beginExportDragWithFiles:(NSArray<NSDictionary*>*)files
|
||||||
|
event:(NSEvent*)event {
|
||||||
|
if (!_app || !_app->connected() || files.count == 0 || !event) return;
|
||||||
|
|
||||||
|
[self clearPendingExport];
|
||||||
[self evaluateJavaScript:@"window.omniClearSelection&&window.omniClearSelection()"
|
[self evaluateJavaScript:@"window.omniClearSelection&&window.omniClearSelection()"
|
||||||
completionHandler:nil];
|
completionHandler:nil];
|
||||||
if ([self beginRemoteExportDrag:event json:_pendingDragJSON]) return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!_nativeDragStarted) [super mouseDragged:event];
|
|
||||||
}
|
|
||||||
|
|
||||||
- (BOOL)beginRemoteExportDrag:(NSEvent*)event json:(NSString*)json {
|
_exportDragEntries = [files copy];
|
||||||
NSData* data = [json dataUsingEncoding:NSUTF8StringEncoding];
|
[_exportDelegates removeAllObjects];
|
||||||
id parsed = [NSJSONSerialization JSONObjectWithData:data options:0 error:nil];
|
|
||||||
if (![parsed isKindOfClass:[NSArray class]] || [(NSArray*)parsed count] == 0) return NO;
|
|
||||||
|
|
||||||
_exportDragEntries = [(NSArray*)parsed copy];
|
|
||||||
|
|
||||||
NSMutableArray<NSDraggingItem*>* dragItems = [NSMutableArray array];
|
NSMutableArray<NSDraggingItem*>* dragItems = [NSMutableArray array];
|
||||||
CGFloat viewY = self.bounds.size.height - _mouseDownWebPoint.y;
|
// draggingFrame must be in this view's coordinate system and match the
|
||||||
NSRect frame = NSMakeRect(_mouseDownWebPoint.x - 1, viewY - 1, 2, 2);
|
// mouse event, otherwise the ghost icon jumps away from the cursor.
|
||||||
|
NSPoint loc = [self convertPoint:event.locationInWindow fromView:nil];
|
||||||
|
NSRect frame = NSMakeRect(loc.x - 16, loc.y - 16, 32, 32);
|
||||||
|
|
||||||
for (NSDictionary* entry in (NSArray*)parsed) {
|
for (NSDictionary* entry in files) {
|
||||||
NSNumber* handle = entry[@"handle"];
|
NSNumber* handle = entry[@"handle"];
|
||||||
NSNumber* storageId = entry[@"storageId"];
|
NSNumber* storageId = entry[@"storageId"];
|
||||||
NSString* name = entry[@"name"];
|
NSString* name = entry[@"name"];
|
||||||
NSNumber* size = entry[@"size"];
|
NSNumber* size = entry[@"size"];
|
||||||
if (!handle || !storageId || !name || !size) continue;
|
BOOL isDir = [entry[@"isDir"] boolValue];
|
||||||
|
if (!handle || !storageId || !name.length) continue;
|
||||||
|
|
||||||
OmniExportPromiseDelegate* delegate = [OmniExportPromiseDelegate new];
|
OmniExportPromiseDelegate* delegate = [OmniExportPromiseDelegate new];
|
||||||
delegate.app = _app;
|
delegate.app = _app;
|
||||||
delegate.handle = handle.unsignedIntValue;
|
delegate.handle = handle.unsignedIntValue;
|
||||||
delegate.storageId = storageId.unsignedIntValue;
|
delegate.storageId = storageId.unsignedIntValue;
|
||||||
delegate.size = size.unsignedLongLongValue;
|
delegate.size = size ? size.unsignedLongLongValue : 0;
|
||||||
|
delegate.isDir = isDir;
|
||||||
delegate.filename = name;
|
delegate.filename = name;
|
||||||
|
[_exportDelegates addObject:delegate];
|
||||||
|
|
||||||
|
NSString* typeId;
|
||||||
|
NSImage* icon;
|
||||||
|
if (isDir) {
|
||||||
|
typeId = UTTypeFolder.identifier;
|
||||||
|
icon = [[NSWorkspace sharedWorkspace] iconForContentType:UTTypeFolder];
|
||||||
|
} else {
|
||||||
|
NSString* ext = name.pathExtension;
|
||||||
|
UTType* ut = ext.length ? [UTType typeWithFilenameExtension:ext] : nil;
|
||||||
|
typeId = (ut && ut.identifier.length) ? ut.identifier : UTTypeData.identifier;
|
||||||
|
icon = ut
|
||||||
|
? [[NSWorkspace sharedWorkspace] iconForContentType:ut]
|
||||||
|
: [[NSWorkspace sharedWorkspace] iconForContentType:UTTypeData];
|
||||||
|
}
|
||||||
|
|
||||||
NSFilePromiseProvider* provider =
|
NSFilePromiseProvider* provider =
|
||||||
[[NSFilePromiseProvider alloc] initWithFileType:@"public.data" delegate:delegate];
|
[[NSFilePromiseProvider alloc] initWithFileType:typeId delegate:delegate];
|
||||||
|
|
||||||
NSDraggingItem* dragItem = [[NSDraggingItem alloc] initWithPasteboardWriter:provider];
|
NSDraggingItem* dragItem = [[NSDraggingItem alloc] initWithPasteboardWriter:provider];
|
||||||
[dragItem setDraggingFrame:frame contents:nil];
|
icon.size = NSMakeSize(32, 32);
|
||||||
|
[dragItem setDraggingFrame:frame contents:icon];
|
||||||
[dragItems addObject:dragItem];
|
[dragItems addObject:dragItem];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (dragItems.count == 0) {
|
if (dragItems.count == 0) {
|
||||||
_exportDragEntries = nil;
|
_exportDragEntries = nil;
|
||||||
return NO;
|
[_exportDelegates removeAllObjects];
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
[self evaluateJavaScript:@"window.omniClearSelection&&window.omniClearSelection()"
|
|
||||||
completionHandler:nil];
|
|
||||||
[self beginDraggingSessionWithItems:dragItems event:event source:self];
|
[self beginDraggingSessionWithItems:dragItems event:event source:self];
|
||||||
return YES;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
- (NSDragOperation)draggingSession:(NSDraggingSession*)session
|
- (NSDragOperation)draggingSession:(NSDraggingSession*)session
|
||||||
sourceOperationMaskForDraggingContext:(NSDraggingContext)context {
|
sourceOperationMaskForDraggingContext:(NSDraggingContext)context {
|
||||||
(void)session; (void)context;
|
(void)session;
|
||||||
|
// Outside the app (Finder) and inside both allow copy.
|
||||||
return NSDragOperationCopy;
|
return NSDragOperationCopy;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -441,37 +760,24 @@ static const CGFloat kTrafficLightInset = 72.0;
|
|||||||
operation:(NSDragOperation)operation {
|
operation:(NSDragOperation)operation {
|
||||||
(void)session; (void)screenPoint; (void)operation;
|
(void)session; (void)screenPoint; (void)operation;
|
||||||
_exportDragEntries = nil;
|
_exportDragEntries = nil;
|
||||||
|
[_exportDelegates removeAllObjects];
|
||||||
}
|
}
|
||||||
|
|
||||||
- (NSDragOperation)draggingUpdated:(id<NSDraggingInfo>)sender {
|
- (NSDragOperation)draggingUpdated:(id<NSDraggingInfo>)sender {
|
||||||
NSArray<NSURL*>* urls = [self fileURLsFromDrag:sender];
|
NSArray<NSURL*>* urls = [self fileURLsFromDrag:sender];
|
||||||
const BOOL exportDrag = _exportDragEntries.count > 0;
|
if (urls.count == 0) {
|
||||||
if (!exportDrag && urls.count == 0) {
|
|
||||||
[self setRemoteDragOver:NO];
|
[self setRemoteDragOver:NO];
|
||||||
[self setLocalDragOver:NO];
|
|
||||||
return NSDragOperationNone;
|
return NSDragOperationNone;
|
||||||
}
|
}
|
||||||
|
|
||||||
NSString* target = [self dropTargetAtWebPoint:[self webPointFromDrag:sender]];
|
NSString* target = [self dropTargetAtWebPoint:[self webPointFromDrag:sender]];
|
||||||
if (exportDrag && [target isEqualToString:@"local"]) {
|
if ([target isEqualToString:@"remote"] && _app && _app->connected()) {
|
||||||
[self setLocalDragOver:YES];
|
|
||||||
[self setRemoteDragOver:NO];
|
|
||||||
return NSDragOperationCopy;
|
|
||||||
}
|
|
||||||
if (urls.count > 0 && [target isEqualToString:@"remote"] && _app && _app->connected()) {
|
|
||||||
[self setRemoteDragOver:YES];
|
[self setRemoteDragOver:YES];
|
||||||
[self setLocalDragOver:NO];
|
|
||||||
return NSDragOperationCopy;
|
return NSDragOperationCopy;
|
||||||
}
|
}
|
||||||
if (urls.count > 0 && [target isEqualToString:@"local"]) {
|
|
||||||
[self setRemoteDragOver:NO];
|
|
||||||
[self setLocalDragOver:NO];
|
|
||||||
return NSDragOperationLink;
|
|
||||||
}
|
|
||||||
|
|
||||||
[self setRemoteDragOver:NO];
|
[self setRemoteDragOver:NO];
|
||||||
[self setLocalDragOver:NO];
|
return NSDragOperationNone;
|
||||||
return exportDrag ? NSDragOperationCopy : NSDragOperationNone;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
- (NSDragOperation)draggingEntered:(id<NSDraggingInfo>)sender {
|
- (NSDragOperation)draggingEntered:(id<NSDraggingInfo>)sender {
|
||||||
@@ -481,35 +787,19 @@ static const CGFloat kTrafficLightInset = 72.0;
|
|||||||
- (void)draggingExited:(id<NSDraggingInfo>)sender {
|
- (void)draggingExited:(id<NSDraggingInfo>)sender {
|
||||||
(void)sender;
|
(void)sender;
|
||||||
[self setRemoteDragOver:NO];
|
[self setRemoteDragOver:NO];
|
||||||
[self setLocalDragOver:NO];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
- (BOOL)performDragOperation:(id<NSDraggingInfo>)sender {
|
- (BOOL)performDragOperation:(id<NSDraggingInfo>)sender {
|
||||||
[self setRemoteDragOver:NO];
|
[self setRemoteDragOver:NO];
|
||||||
[self setLocalDragOver:NO];
|
|
||||||
if (!_app) return NO;
|
if (!_app) return NO;
|
||||||
|
|
||||||
NSString* target = [self dropTargetAtWebPoint:[self webPointFromDrag:sender]];
|
NSString* target = [self dropTargetAtWebPoint:[self webPointFromDrag:sender]];
|
||||||
NSArray<NSURL*>* urls = [self fileURLsFromDrag:sender];
|
NSArray<NSURL*>* urls = [self fileURLsFromDrag:sender];
|
||||||
|
|
||||||
if ([target isEqualToString:@"local"] && _exportDragEntries.count > 0) {
|
if (![target isEqualToString:@"remote"]) return NO;
|
||||||
for (NSDictionary* entry in _exportDragEntries) {
|
|
||||||
NSNumber* handle = entry[@"handle"];
|
|
||||||
NSNumber* storageId = entry[@"storageId"];
|
|
||||||
NSString* name = entry[@"name"];
|
|
||||||
NSNumber* size = entry[@"size"];
|
|
||||||
if (handle && storageId && name && size)
|
|
||||||
_app->do_download(handle.unsignedIntValue, storageId.unsignedIntValue,
|
|
||||||
name.UTF8String, size.unsignedLongLongValue);
|
|
||||||
}
|
|
||||||
_exportDragEntries = nil;
|
|
||||||
return YES;
|
|
||||||
}
|
|
||||||
|
|
||||||
NSFileManager* fm = NSFileManager.defaultManager;
|
NSFileManager* fm = NSFileManager.defaultManager;
|
||||||
BOOL handled = NO;
|
BOOL handled = NO;
|
||||||
|
|
||||||
if ([target isEqualToString:@"remote"]) {
|
|
||||||
for (NSURL* url in urls) {
|
for (NSURL* url in urls) {
|
||||||
if (!url.isFileURL) continue;
|
if (!url.isFileURL) continue;
|
||||||
NSString* path = url.path;
|
NSString* path = url.path;
|
||||||
@@ -520,29 +810,18 @@ static const CGFloat kTrafficLightInset = 72.0;
|
|||||||
handled = YES;
|
handled = YES;
|
||||||
}
|
}
|
||||||
return handled;
|
return handled;
|
||||||
}
|
|
||||||
|
|
||||||
if ([target isEqualToString:@"local"]) {
|
|
||||||
for (NSURL* url in urls) {
|
|
||||||
if (!url.isFileURL) continue;
|
|
||||||
NSString* path = url.path;
|
|
||||||
if (!path) continue;
|
|
||||||
BOOL isDir = NO;
|
|
||||||
if ([fm fileExistsAtPath:path isDirectory:&isDir] && isDir)
|
|
||||||
_app->navigate_local(path.UTF8String);
|
|
||||||
handled = YES;
|
|
||||||
}
|
|
||||||
return handled;
|
|
||||||
}
|
|
||||||
|
|
||||||
return NO;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@end
|
@end
|
||||||
|
|
||||||
// ─── OmniAppDelegate ──────────────────────────────────────────────────────────
|
// ─── OmniAppDelegate ──────────────────────────────────────────────────────────
|
||||||
@interface OmniAppDelegate : NSObject <NSApplicationDelegate>
|
@interface OmniAppDelegate : NSObject <NSApplicationDelegate, NSWindowDelegate>
|
||||||
- (instancetype)initWithApp:(omniMTP::App*)app;
|
- (instancetype)initWithApp:(omniMTP::App*)app;
|
||||||
|
- (void)buildMainMenu;
|
||||||
|
- (void)selectLanguage:(id)sender;
|
||||||
|
- (void)showAbout:(id)sender;
|
||||||
|
- (void)closeAboutPanel:(id)sender;
|
||||||
|
- (void)applyLanguageToWebView;
|
||||||
@end
|
@end
|
||||||
|
|
||||||
@implementation OmniAppDelegate {
|
@implementation OmniAppDelegate {
|
||||||
@@ -550,6 +829,11 @@ static const CGFloat kTrafficLightInset = 72.0;
|
|||||||
NSWindow* _window;
|
NSWindow* _window;
|
||||||
WKWebView* _webView;
|
WKWebView* _webView;
|
||||||
OmniBridge* _bridge;
|
OmniBridge* _bridge;
|
||||||
|
NSPanel* _aboutPanel;
|
||||||
|
NSMenuItem* _aboutItem;
|
||||||
|
NSMenuItem* _optionsItem;
|
||||||
|
NSMenuItem* _languageItem;
|
||||||
|
NSMenuItem* _quitItem;
|
||||||
}
|
}
|
||||||
|
|
||||||
- (instancetype)initWithApp:(omniMTP::App*)app {
|
- (instancetype)initWithApp:(omniMTP::App*)app {
|
||||||
@@ -559,9 +843,221 @@ static const CGFloat kTrafficLightInset = 72.0;
|
|||||||
return self;
|
return self;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
- (void)buildMainMenu {
|
||||||
|
NSMenu* menuBar = [[NSMenu alloc] init];
|
||||||
|
NSMenuItem* appItem = [[NSMenuItem alloc] init];
|
||||||
|
[menuBar addItem:appItem];
|
||||||
|
|
||||||
|
NSMenu* appMenu = [[NSMenu alloc] initWithTitle:@"OmniMTP"];
|
||||||
|
|
||||||
|
_aboutItem = [[NSMenuItem alloc] initWithTitle:OmniMenuString(@"menu.about")
|
||||||
|
action:@selector(showAbout:)
|
||||||
|
keyEquivalent:@""];
|
||||||
|
_aboutItem.target = self;
|
||||||
|
[appMenu addItem:_aboutItem];
|
||||||
|
[appMenu addItem:[NSMenuItem separatorItem]];
|
||||||
|
|
||||||
|
// Options → Language
|
||||||
|
_optionsItem = [[NSMenuItem alloc] initWithTitle:OmniMenuString(@"menu.options")
|
||||||
|
action:nil
|
||||||
|
keyEquivalent:@""];
|
||||||
|
if (@available(macOS 11.0, *)) {
|
||||||
|
NSImage* gear = [NSImage imageWithSystemSymbolName:@"gearshape"
|
||||||
|
accessibilityDescription:nil];
|
||||||
|
if (gear) {
|
||||||
|
gear.size = NSMakeSize(16, 16);
|
||||||
|
_optionsItem.image = gear;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
NSMenu* optionsMenu = [[NSMenu alloc] initWithTitle:OmniMenuString(@"menu.options")];
|
||||||
|
_optionsItem.submenu = optionsMenu;
|
||||||
|
|
||||||
|
_languageItem = [[NSMenuItem alloc] initWithTitle:OmniMenuString(@"menu.language")
|
||||||
|
action:nil
|
||||||
|
keyEquivalent:@""];
|
||||||
|
NSMenu* langMenu = [[NSMenu alloc] initWithTitle:OmniMenuString(@"menu.language")];
|
||||||
|
_languageItem.submenu = langMenu;
|
||||||
|
[optionsMenu addItem:_languageItem];
|
||||||
|
|
||||||
|
NSString* current = OmniLoadConfig()[@"language"];
|
||||||
|
if (![current isKindOfClass:[NSString class]] || current.length == 0)
|
||||||
|
current = @"system";
|
||||||
|
|
||||||
|
NSMenuItem* systemItem = [[NSMenuItem alloc]
|
||||||
|
initWithTitle:OmniMenuString(@"menu.language_system")
|
||||||
|
action:@selector(selectLanguage:)
|
||||||
|
keyEquivalent:@""];
|
||||||
|
systemItem.target = self;
|
||||||
|
systemItem.representedObject = @"system";
|
||||||
|
systemItem.state = [current isEqualToString:@"system"] ? NSControlStateValueOn
|
||||||
|
: NSControlStateValueOff;
|
||||||
|
[langMenu addItem:systemItem];
|
||||||
|
[langMenu addItem:[NSMenuItem separatorItem]];
|
||||||
|
|
||||||
|
NSURL* webroot = OmniFindWebrootURL();
|
||||||
|
for (NSString* code in OmniAvailableLanguageCodes(webroot)) {
|
||||||
|
NSMenuItem* item = [[NSMenuItem alloc]
|
||||||
|
initWithTitle:OmniLanguageDisplayName(code)
|
||||||
|
action:@selector(selectLanguage:)
|
||||||
|
keyEquivalent:@""];
|
||||||
|
item.target = self;
|
||||||
|
item.representedObject = code;
|
||||||
|
item.state = [current isEqualToString:code] ? NSControlStateValueOn
|
||||||
|
: NSControlStateValueOff;
|
||||||
|
[langMenu addItem:item];
|
||||||
|
}
|
||||||
|
|
||||||
|
[appMenu addItem:_optionsItem];
|
||||||
|
[appMenu addItem:[NSMenuItem separatorItem]];
|
||||||
|
|
||||||
|
_quitItem = [[NSMenuItem alloc] initWithTitle:OmniMenuString(@"menu.quit")
|
||||||
|
action:@selector(terminate:)
|
||||||
|
keyEquivalent:@"q"];
|
||||||
|
[appMenu addItem:_quitItem];
|
||||||
|
|
||||||
|
appItem.submenu = appMenu;
|
||||||
|
[NSApp setMainMenu:menuBar];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)showAbout:(id)sender {
|
||||||
|
(void)sender;
|
||||||
|
if (_aboutPanel) {
|
||||||
|
[NSApp stopModal];
|
||||||
|
[_aboutPanel orderOut:nil];
|
||||||
|
_aboutPanel = nil;
|
||||||
|
}
|
||||||
|
|
||||||
|
NSBundle* bundle = NSBundle.mainBundle;
|
||||||
|
NSString* version = [bundle objectForInfoDictionaryKey:@"CFBundleShortVersionString"] ?: @"1.0.0";
|
||||||
|
NSString* build = [bundle objectForInfoDictionaryKey:@"CFBundleVersion"] ?: version;
|
||||||
|
NSString* versionText = [version isEqualToString:build]
|
||||||
|
? version
|
||||||
|
: [NSString stringWithFormat:@"%@ (%@)", version, build];
|
||||||
|
|
||||||
|
NSPanel* panel = [[NSPanel alloc]
|
||||||
|
initWithContentRect:NSMakeRect(0, 0, 340, 320)
|
||||||
|
styleMask:(NSWindowStyleMaskTitled | NSWindowStyleMaskClosable)
|
||||||
|
backing:NSBackingStoreBuffered
|
||||||
|
defer:NO];
|
||||||
|
panel.title = OmniMenuString(@"menu.about");
|
||||||
|
panel.releasedWhenClosed = NO;
|
||||||
|
panel.delegate = self;
|
||||||
|
if (@available(macOS 10.14, *))
|
||||||
|
panel.appearance = [NSAppearance appearanceNamed:NSAppearanceNameDarkAqua];
|
||||||
|
|
||||||
|
NSView* content = panel.contentView;
|
||||||
|
|
||||||
|
NSImageView* iconView = [[NSImageView alloc] initWithFrame:NSZeroRect];
|
||||||
|
iconView.translatesAutoresizingMaskIntoConstraints = NO;
|
||||||
|
iconView.image = NSApp.applicationIconImage;
|
||||||
|
iconView.imageScaling = NSImageScaleProportionallyUpOrDown;
|
||||||
|
[content addSubview:iconView];
|
||||||
|
|
||||||
|
NSTextField* (^makeLabel)(NSString*, CGFloat, BOOL) = ^NSTextField*(NSString* text, CGFloat size, BOOL bold) {
|
||||||
|
NSTextField* label = [NSTextField labelWithString:text ?: @""];
|
||||||
|
label.translatesAutoresizingMaskIntoConstraints = NO;
|
||||||
|
label.alignment = NSTextAlignmentCenter;
|
||||||
|
label.font = bold ? [NSFont boldSystemFontOfSize:size] : [NSFont systemFontOfSize:size];
|
||||||
|
label.textColor = bold ? NSColor.labelColor : NSColor.secondaryLabelColor;
|
||||||
|
label.maximumNumberOfLines = 3;
|
||||||
|
label.lineBreakMode = NSLineBreakByWordWrapping;
|
||||||
|
[content addSubview:label];
|
||||||
|
return label;
|
||||||
|
};
|
||||||
|
|
||||||
|
NSTextField* titleLabel = makeLabel(@"OmniMTP", 22, YES);
|
||||||
|
titleLabel.textColor = NSColor.labelColor;
|
||||||
|
NSTextField* taglineLabel = makeLabel(OmniMenuString(@"about.tagline"), 13, NO);
|
||||||
|
NSTextField* versionLabel = makeLabel(OmniMenuFormat(@"about.version", @{@"version": versionText}), 12, NO);
|
||||||
|
NSTextField* copyLabel = makeLabel(OmniMenuString(@"about.copyright"), 11, NO);
|
||||||
|
|
||||||
|
NSButton* okButton = [[NSButton alloc] initWithFrame:NSZeroRect];
|
||||||
|
okButton.translatesAutoresizingMaskIntoConstraints = NO;
|
||||||
|
okButton.title = OmniMenuString(@"confirm.ok_btn");
|
||||||
|
okButton.bezelStyle = NSBezelStyleRounded;
|
||||||
|
okButton.keyEquivalent = @"\r";
|
||||||
|
okButton.target = self;
|
||||||
|
okButton.action = @selector(closeAboutPanel:);
|
||||||
|
[content addSubview:okButton];
|
||||||
|
|
||||||
|
[NSLayoutConstraint activateConstraints:@[
|
||||||
|
[iconView.topAnchor constraintEqualToAnchor:content.topAnchor constant:28],
|
||||||
|
[iconView.centerXAnchor constraintEqualToAnchor:content.centerXAnchor],
|
||||||
|
[iconView.widthAnchor constraintEqualToConstant:96],
|
||||||
|
[iconView.heightAnchor constraintEqualToConstant:96],
|
||||||
|
|
||||||
|
[titleLabel.topAnchor constraintEqualToAnchor:iconView.bottomAnchor constant:14],
|
||||||
|
[titleLabel.leadingAnchor constraintEqualToAnchor:content.leadingAnchor constant:24],
|
||||||
|
[titleLabel.trailingAnchor constraintEqualToAnchor:content.trailingAnchor constant:-24],
|
||||||
|
|
||||||
|
[taglineLabel.topAnchor constraintEqualToAnchor:titleLabel.bottomAnchor constant:8],
|
||||||
|
[taglineLabel.leadingAnchor constraintEqualToAnchor:content.leadingAnchor constant:24],
|
||||||
|
[taglineLabel.trailingAnchor constraintEqualToAnchor:content.trailingAnchor constant:-24],
|
||||||
|
|
||||||
|
[versionLabel.topAnchor constraintEqualToAnchor:taglineLabel.bottomAnchor constant:10],
|
||||||
|
[versionLabel.leadingAnchor constraintEqualToAnchor:content.leadingAnchor constant:24],
|
||||||
|
[versionLabel.trailingAnchor constraintEqualToAnchor:content.trailingAnchor constant:-24],
|
||||||
|
|
||||||
|
[copyLabel.topAnchor constraintEqualToAnchor:versionLabel.bottomAnchor constant:4],
|
||||||
|
[copyLabel.leadingAnchor constraintEqualToAnchor:content.leadingAnchor constant:24],
|
||||||
|
[copyLabel.trailingAnchor constraintEqualToAnchor:content.trailingAnchor constant:-24],
|
||||||
|
|
||||||
|
[okButton.topAnchor constraintEqualToAnchor:copyLabel.bottomAnchor constant:20],
|
||||||
|
[okButton.centerXAnchor constraintEqualToAnchor:content.centerXAnchor],
|
||||||
|
[okButton.bottomAnchor constraintEqualToAnchor:content.bottomAnchor constant:-24],
|
||||||
|
[okButton.widthAnchor constraintGreaterThanOrEqualToConstant:80],
|
||||||
|
]];
|
||||||
|
|
||||||
|
_aboutPanel = panel;
|
||||||
|
[panel center];
|
||||||
|
[NSApp runModalForWindow:panel];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)closeAboutPanel:(id)sender {
|
||||||
|
(void)sender;
|
||||||
|
[NSApp stopModal];
|
||||||
|
[_aboutPanel orderOut:nil];
|
||||||
|
_aboutPanel = nil;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (BOOL)windowShouldClose:(NSWindow*)sender {
|
||||||
|
if (sender == _aboutPanel) {
|
||||||
|
[NSApp stopModal];
|
||||||
|
_aboutPanel = nil;
|
||||||
|
}
|
||||||
|
return YES;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)selectLanguage:(id)sender {
|
||||||
|
NSMenuItem* item = (NSMenuItem*)sender;
|
||||||
|
if (![item isKindOfClass:[NSMenuItem class]]) return;
|
||||||
|
NSString* code = item.representedObject;
|
||||||
|
if (![code isKindOfClass:[NSString class]]) return;
|
||||||
|
|
||||||
|
NSMutableDictionary* cfg = [OmniLoadConfig() mutableCopy] ?: [NSMutableDictionary dictionary];
|
||||||
|
cfg[@"language"] = code;
|
||||||
|
OmniSaveConfig(cfg);
|
||||||
|
|
||||||
|
[self applyLanguageToWebView];
|
||||||
|
[self buildMainMenu];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)applyLanguageToWebView {
|
||||||
|
if (!_webView) return;
|
||||||
|
NSDictionary* payload = OmniBuildI18nPayload(OmniFindWebrootURL());
|
||||||
|
NSData* data = [NSJSONSerialization dataWithJSONObject:payload options:0 error:nil];
|
||||||
|
NSString* json = data ? [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding] : @"{}";
|
||||||
|
NSString* js = [NSString stringWithFormat:
|
||||||
|
@"window.__OMNI_I18N__=%@;"
|
||||||
|
"if(window.omniApplyI18n)window.omniApplyI18n(window.__OMNI_I18N__);", json];
|
||||||
|
[_webView evaluateJavaScript:js completionHandler:nil];
|
||||||
|
}
|
||||||
|
|
||||||
- (void)applicationDidFinishLaunching:(NSNotification*)note {
|
- (void)applicationDidFinishLaunching:(NSNotification*)note {
|
||||||
(void)note;
|
(void)note;
|
||||||
|
|
||||||
|
[self buildMainMenu];
|
||||||
|
|
||||||
// ── WKWebView configuration ───────────────────────────────────────────────
|
// ── WKWebView configuration ───────────────────────────────────────────────
|
||||||
WKWebViewConfiguration* config = [[WKWebViewConfiguration alloc] init];
|
WKWebViewConfiguration* config = [[WKWebViewConfiguration alloc] init];
|
||||||
|
|
||||||
@@ -582,16 +1078,21 @@ static const CGFloat kTrafficLightInset = 72.0;
|
|||||||
"if(e.button===0&&e.target.closest('.file-list .frow'))e.preventDefault();"
|
"if(e.button===0&&e.target.closest('.file-list .frow'))e.preventDefault();"
|
||||||
"},true);"
|
"},true);"
|
||||||
"document.addEventListener('dragstart',function(e){"
|
"document.addEventListener('dragstart',function(e){"
|
||||||
"var row=e.target.closest('.file-list .frow');"
|
"if(e.target.closest('.file-list .frow'))e.preventDefault();"
|
||||||
"if(!row)return;"
|
|
||||||
"if(row.closest('#remote-files')||row.getAttribute('draggable')!=='true')"
|
|
||||||
"e.preventDefault();"
|
|
||||||
"},true);"
|
"},true);"
|
||||||
"})();"
|
"})();"
|
||||||
injectionTime:WKUserScriptInjectionTimeAtDocumentStart
|
injectionTime:WKUserScriptInjectionTimeAtDocumentStart
|
||||||
forMainFrameOnly:YES];
|
forMainFrameOnly:YES];
|
||||||
[config.userContentController addUserScript:noTextDrag];
|
[config.userContentController addUserScript:noTextDrag];
|
||||||
|
|
||||||
|
// Inject locale strings before page JS runs (WKWebView blocks fetch() on file://).
|
||||||
|
NSURL* webrootURL = OmniFindWebrootURL();
|
||||||
|
WKUserScript* i18nBoot = [[WKUserScript alloc]
|
||||||
|
initWithSource:OmniI18nBootstrapScript(webrootURL)
|
||||||
|
injectionTime:WKUserScriptInjectionTimeAtDocumentStart
|
||||||
|
forMainFrameOnly:YES];
|
||||||
|
[config.userContentController addUserScript:i18nBoot];
|
||||||
|
|
||||||
// Register the "bridge" message handler
|
// Register the "bridge" message handler
|
||||||
_webView = [[OmniWebView alloc] initWithFrame:NSMakeRect(0, 0, 1280, 800)
|
_webView = [[OmniWebView alloc] initWithFrame:NSMakeRect(0, 0, 1280, 800)
|
||||||
configuration:config
|
configuration:config
|
||||||
@@ -720,19 +1221,6 @@ void WebUI::run() {
|
|||||||
OmniAppDelegate* delegate = [[OmniAppDelegate alloc] initWithApp:&app_];
|
OmniAppDelegate* delegate = [[OmniAppDelegate alloc] initWithApp:&app_];
|
||||||
[NSApp setDelegate:delegate];
|
[NSApp setDelegate:delegate];
|
||||||
|
|
||||||
// App menu (Cmd+Q to quit)
|
|
||||||
NSMenu* menuBar = [[NSMenu alloc] init];
|
|
||||||
NSMenuItem* appItem = [[NSMenuItem alloc] init];
|
|
||||||
[menuBar addItem:appItem];
|
|
||||||
NSMenu* appMenu = [[NSMenu alloc] init];
|
|
||||||
NSMenuItem* quitItem = [[NSMenuItem alloc]
|
|
||||||
initWithTitle:@"Quit OmniMTP"
|
|
||||||
action:@selector(terminate:)
|
|
||||||
keyEquivalent:@"q"];
|
|
||||||
[appMenu addItem:quitItem];
|
|
||||||
appItem.submenu = appMenu;
|
|
||||||
[NSApp setMainMenu:menuBar];
|
|
||||||
|
|
||||||
[NSApp activateIgnoringOtherApps:YES];
|
[NSApp activateIgnoringOtherApps:YES];
|
||||||
[NSApp run];
|
[NSApp run];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -113,15 +113,25 @@ body {
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
.device-left, .device-right { display: flex; align-items: center; gap: 6px; flex-shrink: 0; }
|
.device-left, .device-right {
|
||||||
.device-center {
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.device-left { justify-content: flex-start; }
|
||||||
|
.device-right { justify-content: flex-end; }
|
||||||
|
.device-center {
|
||||||
|
flex: 0 1 auto;
|
||||||
|
max-width: 50%;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--label-2);
|
color: var(--label-2);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Main two-panel area ──────────────────────────────────────────────────── */
|
/* ── Main two-panel area ──────────────────────────────────────────────────── */
|
||||||
@@ -301,6 +311,22 @@ body {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
.ftable th.sortable {
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
}
|
||||||
|
.ftable th.sortable:hover { color: var(--label-1); }
|
||||||
|
.ftable th .sort-ind {
|
||||||
|
display: inline-block;
|
||||||
|
min-width: 1em;
|
||||||
|
margin-left: 4px;
|
||||||
|
font-size: 9px;
|
||||||
|
opacity: 0;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
.ftable th.sortable:hover .sort-ind { opacity: 0.35; }
|
||||||
|
.ftable th .sort-ind.active { opacity: 1; color: var(--accent, #0a84ff); }
|
||||||
.ftable th:first-child { padding-left: 12px; }
|
.ftable th:first-child { padding-left: 12px; }
|
||||||
.col-size { width: 72px; text-align: right; }
|
.col-size { width: 72px; text-align: right; }
|
||||||
.col-date { width: 110px; }
|
.col-date { width: 110px; }
|
||||||
@@ -545,8 +571,8 @@ body {
|
|||||||
.marquee-box {
|
.marquee-box {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
z-index: 9998;
|
z-index: 9998;
|
||||||
border: 1px solid var(--accent);
|
border: 1px solid rgba(10, 132, 255, 0.85);
|
||||||
background: rgba(10,132,255,0.12);
|
background: rgba(10, 132, 255, 0.06);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ function call(action, params = {}) {
|
|||||||
try {
|
try {
|
||||||
window.webkit.messageHandlers.bridge.postMessage({id, action, ...params});
|
window.webkit.messageHandlers.bridge.postMessage({id, action, ...params});
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
// Dev fallback
|
|
||||||
setTimeout(() => { if (_pending[id]) { _pending[id](null); delete _pending[id]; } }, 100);
|
setTimeout(() => { if (_pending[id]) { _pending[id](null); delete _pending[id]; } }, 100);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -19,55 +18,189 @@ window.bridgeResponse = function(id, data) {
|
|||||||
if (_pending[id]) { _pending[id](data); delete _pending[id]; }
|
if (_pending[id]) { _pending[id](data); delete _pending[id]; }
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ── i18n ──────────────────────────────────────────────────────────────────────
|
||||||
|
const I18N = {
|
||||||
|
lang: 'en',
|
||||||
|
strings: {},
|
||||||
|
fallback: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
function t(key, vars = {}) {
|
||||||
|
let s = I18N.strings[key] ?? I18N.fallback[key] ?? key;
|
||||||
|
for (const [k, v] of Object.entries(vars))
|
||||||
|
s = s.replaceAll(`{${k}}`, String(v));
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tPlural(oneKey, manyKey, n, extra = {}) {
|
||||||
|
return t(n === 1 ? oneKey : manyKey, { n, ...extra });
|
||||||
|
}
|
||||||
|
|
||||||
|
function localeCandidates(preferred) {
|
||||||
|
const out = [];
|
||||||
|
const add = (c) => { if (c && !out.includes(c)) out.push(c); };
|
||||||
|
for (const raw of preferred || []) {
|
||||||
|
const tag = String(raw || '').replace(/_/g, '-');
|
||||||
|
if (!tag) continue;
|
||||||
|
add(tag);
|
||||||
|
const parts = tag.split('-');
|
||||||
|
if (parts[0] === 'zh') {
|
||||||
|
const script = (parts[1] || '').toLowerCase();
|
||||||
|
if (script.startsWith('hant') || script === 'tw' || script === 'hk' || script === 'mo')
|
||||||
|
add('zh-Hant');
|
||||||
|
else
|
||||||
|
add('zh-Hans');
|
||||||
|
add('zh');
|
||||||
|
} else {
|
||||||
|
add(parts[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
add('en');
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchLang(code) {
|
||||||
|
const pack = await call('get_lang_pack', { code });
|
||||||
|
return (pack && typeof pack === 'object') ? pack : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function initI18n() {
|
||||||
|
// Native injects packs at document start (fetch() is blocked on file://).
|
||||||
|
if (window.__OMNI_I18N__ && typeof window.__OMNI_I18N__ === 'object') {
|
||||||
|
const boot = window.__OMNI_I18N__;
|
||||||
|
I18N.lang = boot.lang || 'en';
|
||||||
|
I18N.fallback = boot.fallback || {};
|
||||||
|
I18N.strings = boot.strings || { ...I18N.fallback };
|
||||||
|
} else {
|
||||||
|
I18N.fallback = (await fetchLang('en')) || {};
|
||||||
|
I18N.strings = { ...I18N.fallback };
|
||||||
|
|
||||||
|
let preferred = await call('get_preferred_languages');
|
||||||
|
if (!Array.isArray(preferred)) preferred = ['en'];
|
||||||
|
|
||||||
|
for (const code of localeCandidates(preferred)) {
|
||||||
|
if (code === 'en') {
|
||||||
|
I18N.lang = 'en';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const pack = await fetchLang(code);
|
||||||
|
if (pack) {
|
||||||
|
I18N.lang = code;
|
||||||
|
I18N.strings = { ...I18N.fallback, ...pack };
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.documentElement.lang = I18N.lang;
|
||||||
|
applyStaticI18n();
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyStaticI18n() {
|
||||||
|
document.querySelectorAll('[data-i18n]').forEach(el => {
|
||||||
|
el.textContent = t(el.dataset.i18n);
|
||||||
|
});
|
||||||
|
document.querySelectorAll('[data-i18n-title]').forEach(el => {
|
||||||
|
el.title = t(el.dataset.i18nTitle);
|
||||||
|
});
|
||||||
|
document.title = t('app.title');
|
||||||
|
}
|
||||||
|
|
||||||
|
window.omniApplyI18n = function(payload) {
|
||||||
|
if (!payload || typeof payload !== 'object') return;
|
||||||
|
window.__OMNI_I18N__ = payload;
|
||||||
|
I18N.lang = payload.lang || 'en';
|
||||||
|
I18N.fallback = payload.fallback || {};
|
||||||
|
I18N.strings = payload.strings || { ...I18N.fallback };
|
||||||
|
document.documentElement.lang = I18N.lang;
|
||||||
|
applyStaticI18n();
|
||||||
|
_remoteFingerprint = '';
|
||||||
|
if (typeof render === 'function') render();
|
||||||
|
};
|
||||||
|
|
||||||
|
async function confirmDialog(message) {
|
||||||
|
const result = await call('confirm', {
|
||||||
|
message,
|
||||||
|
ok: t('confirm.delete_btn'),
|
||||||
|
cancel: t('confirm.cancel_btn'),
|
||||||
|
});
|
||||||
|
return result === true || result === 'true';
|
||||||
|
}
|
||||||
|
|
||||||
// ── State ─────────────────────────────────────────────────────────────────────
|
// ── State ─────────────────────────────────────────────────────────────────────
|
||||||
const S = {
|
const S = {
|
||||||
connected: false, deviceName: '',
|
connected: false, deviceName: '',
|
||||||
storages: [], activeStorageId: 0,
|
storages: [], activeStorageId: 0,
|
||||||
remoteNavStack: [],
|
remoteNavStack: [],
|
||||||
localPath: '', localFiles: [],
|
|
||||||
localDrives: [], activeLocalDrive: '',
|
|
||||||
remoteFiles: [], remoteRefreshing: false,
|
remoteFiles: [], remoteRefreshing: false,
|
||||||
transfers: [],
|
transfers: [],
|
||||||
statusMessage: '', statusTimer: 0,
|
statusKey: '', statusArgs: [], statusTimer: 0,
|
||||||
selLocal: new Set(), // keys = file.path strings
|
selRemote: new Set(),
|
||||||
selRemote: new Set(), // keys = String(file.handle)
|
|
||||||
lastLocalIdx: -1, // for shift+click range
|
|
||||||
lastRemoteIdx: -1,
|
lastRemoteIdx: -1,
|
||||||
|
sortKey: 'name', // name | size | date
|
||||||
|
sortAsc: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Smart-render fingerprints ──────────────────────────────────────────────────
|
|
||||||
// The file lists are only fully rebuilt when the files themselves change.
|
|
||||||
// Selection and toolbar updates run every poll without touching the table DOM,
|
|
||||||
// which eliminates the 400 ms hover-flash from innerHTML replacement.
|
|
||||||
let _localFingerprint = '';
|
|
||||||
let _remoteFingerprint = '';
|
let _remoteFingerprint = '';
|
||||||
|
|
||||||
function fingerprintFiles(files) {
|
function fingerprintFiles(files) {
|
||||||
// Fast hash: join paths/handles — changes on any navigation or refresh
|
return files.map(f => f.path || f.handle || f.name).join('\0')
|
||||||
return files.map(f => f.path || f.handle || f.name).join('\0');
|
+ '\0' + I18N.lang + '\0' + S.sortKey + '\0' + (S.sortAsc ? 'a' : 'd');
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortedRemoteFiles() {
|
||||||
|
const files = (S.remoteFiles || []).slice();
|
||||||
|
const dir = S.sortAsc ? 1 : -1;
|
||||||
|
const key = S.sortKey;
|
||||||
|
files.sort((a, b) => {
|
||||||
|
if (!!a.isDir !== !!b.isDir) return a.isDir ? -1 : 1;
|
||||||
|
let cmp = 0;
|
||||||
|
if (key === 'size') {
|
||||||
|
cmp = (a.size || 0) - (b.size || 0);
|
||||||
|
} else if (key === 'date') {
|
||||||
|
cmp = String(a.date || '').localeCompare(String(b.date || ''));
|
||||||
|
} else {
|
||||||
|
cmp = String(a.name || '').localeCompare(String(b.name || ''), undefined, {
|
||||||
|
sensitivity: 'base', numeric: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (cmp === 0) {
|
||||||
|
cmp = String(a.name || '').localeCompare(String(b.name || ''), undefined, {
|
||||||
|
sensitivity: 'base', numeric: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return cmp * dir;
|
||||||
|
});
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSortColumn(key) {
|
||||||
|
if (S.sortKey === key) S.sortAsc = !S.sortAsc;
|
||||||
|
else {
|
||||||
|
S.sortKey = key;
|
||||||
|
S.sortAsc = key !== 'size'; // size defaults to largest-first feels odd; use ascending
|
||||||
|
}
|
||||||
|
_remoteFingerprint = '';
|
||||||
|
renderRemoteFiles();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Polling ───────────────────────────────────────────────────────────────────
|
|
||||||
async function poll() {
|
async function poll() {
|
||||||
try {
|
try {
|
||||||
const data = await call('get_state');
|
const data = await call('get_state');
|
||||||
if (data) {
|
if (data) {
|
||||||
const sl = S.selLocal, sr = S.selRemote;
|
const sr = S.selRemote;
|
||||||
const lli = S.lastLocalIdx, lri = S.lastRemoteIdx;
|
const lri = S.lastRemoteIdx;
|
||||||
Object.assign(S, data);
|
Object.assign(S, data);
|
||||||
S.selLocal = sl; S.selRemote = sr;
|
S.selRemote = sr;
|
||||||
S.lastLocalIdx = lli; S.lastRemoteIdx = lri;
|
S.lastRemoteIdx = lri;
|
||||||
render();
|
render();
|
||||||
}
|
}
|
||||||
} catch(e) {}
|
} catch(e) {}
|
||||||
setTimeout(poll, 400);
|
setTimeout(poll, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Rendering ─────────────────────────────────────────────────────────────────
|
|
||||||
function render() {
|
function render() {
|
||||||
renderDeviceBar();
|
renderDeviceBar();
|
||||||
renderLocalToolbar();
|
|
||||||
renderLocalFiles();
|
|
||||||
renderRemoteToolbar();
|
renderRemoteToolbar();
|
||||||
renderRemoteFiles();
|
renderRemoteFiles();
|
||||||
renderTransfers();
|
renderTransfers();
|
||||||
@@ -77,11 +210,9 @@ function render() {
|
|||||||
|
|
||||||
function updateDropZones() {
|
function updateDropZones() {
|
||||||
const rem = document.getElementById('panel-remote')?.getBoundingClientRect();
|
const rem = document.getElementById('panel-remote')?.getBoundingClientRect();
|
||||||
const loc = document.getElementById('panel-local')?.getBoundingClientRect();
|
if (!rem) return;
|
||||||
if (!rem || !loc) return;
|
|
||||||
call('update_drop_zones', {
|
call('update_drop_zones', {
|
||||||
remote: {x: rem.left, y: rem.top, w: rem.width, h: rem.height},
|
remote: {x: rem.left, y: rem.top, w: rem.width, h: rem.height},
|
||||||
local: {x: loc.left, y: loc.top, w: loc.width, h: loc.height},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,18 +231,24 @@ function fileColor(f) {
|
|||||||
return 'color-file';
|
return 'color-file';
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── File table builder ────────────────────────────────────────────────────────
|
function fileTableHeader() {
|
||||||
const FILE_TABLE_HEADER = `<thead><tr>
|
const ind = (key) => {
|
||||||
<th>Name</th><th class="col-size">Size</th><th class="col-date">Modified</th>
|
if (S.sortKey !== key) return '<span class="sort-ind"></span>';
|
||||||
</tr></thead>`;
|
return `<span class="sort-ind active">${S.sortAsc ? '▲' : '▼'}</span>`;
|
||||||
|
};
|
||||||
|
return `<thead><tr>
|
||||||
|
<th class="sortable" data-sort="name">${esc(t('col.name'))}${ind('name')}</th>
|
||||||
|
<th class="col-size sortable" data-sort="size">${esc(t('col.size'))}${ind('size')}</th>
|
||||||
|
<th class="col-date sortable" data-sort="date">${esc(t('col.modified'))}${ind('date')}</th>
|
||||||
|
</tr></thead>`;
|
||||||
|
}
|
||||||
|
|
||||||
function buildFileRows(files, selSet, idKey, allowDrag = false) {
|
function buildFileRows(files, selSet, idKey) {
|
||||||
return files.map((f, i) => {
|
return files.map((f, i) => {
|
||||||
const key = String(f[idKey] != null ? f[idKey] : (f.path || f.name));
|
const key = String(f[idKey] != null ? f[idKey] : (f.path || f.name));
|
||||||
const sel = selSet.has(key) ? ' selected' : '';
|
const sel = selSet.has(key) ? ' selected' : '';
|
||||||
const cc = fileColor(f);
|
const cc = fileColor(f);
|
||||||
const drag = allowDrag && !f.isDir ? ' draggable="true"' : '';
|
return `<tr class="frow${sel}" data-idx="${i}" data-key="${esc(key)}" data-is-dir="${f.isDir ? '1' : '0'}">
|
||||||
return `<tr class="frow${sel}" data-idx="${i}" data-key="${esc(key)}" data-is-dir="${f.isDir ? '1' : '0'}"${drag}>
|
|
||||||
<td class="col-name"><div class="fname-cell">
|
<td class="col-name"><div class="fname-cell">
|
||||||
<span class="dot ${cc}"></span>
|
<span class="dot ${cc}"></span>
|
||||||
<span class="${cc}">${esc(f.name)}</span>
|
<span class="${cc}">${esc(f.name)}</span>
|
||||||
@@ -122,7 +259,6 @@ function buildFileRows(files, selSet, idKey, allowDrag = false) {
|
|||||||
}).join('');
|
}).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Selection helpers ─────────────────────────────────────────────────────────
|
|
||||||
function applySelectionToDOM(container, selSet) {
|
function applySelectionToDOM(container, selSet) {
|
||||||
container.querySelectorAll('.frow').forEach(r => {
|
container.querySelectorAll('.frow').forEach(r => {
|
||||||
r.classList.toggle('selected', selSet.has(r.dataset.key));
|
r.classList.toggle('selected', selSet.has(r.dataset.key));
|
||||||
@@ -133,43 +269,28 @@ function selectionKeys(files, idKey) {
|
|||||||
return files.map(f => String(f[idKey] != null ? f[idKey] : (f.path || f.name)));
|
return files.map(f => String(f[idKey] != null ? f[idKey] : (f.path || f.name)));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Local panel ───────────────────────────────────────────────────────────────
|
|
||||||
function renderLocalFiles() {
|
|
||||||
const el = document.getElementById('local-files');
|
|
||||||
const fp = fingerprintFiles(S.localFiles);
|
|
||||||
|
|
||||||
if (fp !== _localFingerprint) {
|
|
||||||
_localFingerprint = fp;
|
|
||||||
// Full rebuild only when file list actually changed
|
|
||||||
if (!S.localFiles.length) {
|
|
||||||
el.innerHTML = `<div class="empty-state">
|
|
||||||
<svg viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-width="1.5" class="empty-icon">
|
|
||||||
<path d="M6 38V14a2 2 0 012-2h12l4 4h16a2 2 0 012 2v20a2 2 0 01-2 2H8a2 2 0 01-2-2z"/>
|
|
||||||
</svg>
|
|
||||||
<div class="empty-title">No files in this folder</div></div>`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
el.innerHTML = `<table class="ftable">${FILE_TABLE_HEADER}
|
|
||||||
<tbody>${buildFileRows(S.localFiles, S.selLocal, 'path', true)}</tbody></table>`;
|
|
||||||
attachLocalEvents(el);
|
|
||||||
} else {
|
|
||||||
// Cheap path: just sync selection highlight
|
|
||||||
applySelectionToDOM(el, S.selLocal);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function attachMarqueeSelect(el, selSet) {
|
function attachMarqueeSelect(el, selSet) {
|
||||||
let active = false, startX = 0, startY = 0, box = null, additive = false;
|
let active = false, startX = 0, startY = 0, box = null, additive = false, moved = false;
|
||||||
|
|
||||||
const finish = () => {
|
const finish = () => {
|
||||||
|
if (!active) return;
|
||||||
|
const clearOnClick = !moved && !additive;
|
||||||
active = false;
|
active = false;
|
||||||
if (box) { box.remove(); box = null; }
|
if (box) { box.remove(); box = null; }
|
||||||
window.removeEventListener('mousemove', onMove);
|
window.removeEventListener('mousemove', onMove);
|
||||||
window.removeEventListener('mouseup', finish);
|
window.removeEventListener('mouseup', finish);
|
||||||
|
if (clearOnClick) {
|
||||||
|
selSet.clear();
|
||||||
|
applySelectionToDOM(el, selSet);
|
||||||
|
S.lastRemoteIdx = -1;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onMove = e => {
|
const onMove = e => {
|
||||||
if (!active) return;
|
if (!active) return;
|
||||||
|
const dx = e.clientX - startX, dy = e.clientY - startY;
|
||||||
|
if (!moved && Math.hypot(dx, dy) < 4) return;
|
||||||
|
moved = true;
|
||||||
window.omniClearSelection();
|
window.omniClearSelection();
|
||||||
const x1 = Math.min(startX, e.clientX), y1 = Math.min(startY, e.clientY);
|
const x1 = Math.min(startX, e.clientX), y1 = Math.min(startY, e.clientY);
|
||||||
const x2 = Math.max(startX, e.clientX), y2 = Math.max(startY, e.clientY);
|
const x2 = Math.max(startX, e.clientX), y2 = Math.max(startY, e.clientY);
|
||||||
@@ -187,9 +308,10 @@ function attachMarqueeSelect(el, selSet) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
el.addEventListener('mousedown', e => {
|
el.addEventListener('mousedown', e => {
|
||||||
if (e.button !== 0 || e.target.closest('.frow')) return;
|
if (e.button !== 0 || e.target.closest('.frow') || e.target.closest('thead')) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
active = true;
|
active = true;
|
||||||
|
moved = false;
|
||||||
additive = e.metaKey || e.shiftKey;
|
additive = e.metaKey || e.shiftKey;
|
||||||
startX = e.clientX;
|
startX = e.clientX;
|
||||||
startY = e.clientY;
|
startY = e.clientY;
|
||||||
@@ -201,68 +323,6 @@ function attachMarqueeSelect(el, selSet) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function attachLocalEvents(el) {
|
|
||||||
const files = S.localFiles;
|
|
||||||
const keys = selectionKeys(files, 'path');
|
|
||||||
|
|
||||||
el.querySelectorAll('.frow').forEach((row, i) => {
|
|
||||||
const f = files[i];
|
|
||||||
const key = keys[i];
|
|
||||||
|
|
||||||
// Click → select
|
|
||||||
row.addEventListener('click', e => {
|
|
||||||
handleRowClick(e, i, key, S.selLocal, keys, 'lastLocalIdx');
|
|
||||||
applySelectionToDOM(el, S.selLocal);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Double-click → navigate dir OR upload file to Switch
|
|
||||||
row.addEventListener('dblclick', () => {
|
|
||||||
if (f.isDir) { call('navigate_local', {path: f.path}); }
|
|
||||||
else if (S.connected) { call('start_upload', {srcPath: f.path}); }
|
|
||||||
});
|
|
||||||
|
|
||||||
// Right-click → local context menu
|
|
||||||
row.addEventListener('contextmenu', e => {
|
|
||||||
e.preventDefault();
|
|
||||||
showLocalCtxMenu(f, e);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Drag source: drag selected local file(s) to the remote panel
|
|
||||||
if (!f.isDir) {
|
|
||||||
row.addEventListener('dragstart', e => {
|
|
||||||
const dragFiles = (S.selLocal.has(key)
|
|
||||||
? files.filter((_, j) => S.selLocal.has(keys[j]))
|
|
||||||
: [f]).filter(df => !df.isDir);
|
|
||||||
if (!dragFiles.length) { e.preventDefault(); return; }
|
|
||||||
window.omniClearSelection();
|
|
||||||
e.dataTransfer.clearData();
|
|
||||||
e.dataTransfer.setData('application/x-omnimtp-local',
|
|
||||||
JSON.stringify(dragFiles.map(df => ({path: df.path, name: df.name}))));
|
|
||||||
e.dataTransfer.effectAllowed = 'copy';
|
|
||||||
e.dataTransfer.setDragImage(dragBadge(dragFiles.length, dragFiles[0].name), 0, 0);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
attachMarqueeSelect(el, S.selLocal);
|
|
||||||
|
|
||||||
// Drop target: receive remote files dropped here → download
|
|
||||||
el.addEventListener('dragover', e => { e.preventDefault(); el.classList.add('drag-over'); });
|
|
||||||
el.addEventListener('dragleave', e => { if (!el.contains(e.relatedTarget)) el.classList.remove('drag-over'); });
|
|
||||||
el.addEventListener('drop', e => {
|
|
||||||
e.preventDefault();
|
|
||||||
el.classList.remove('drag-over');
|
|
||||||
const d = e.dataTransfer.getData('application/x-omnimtp-remote');
|
|
||||||
if (d) {
|
|
||||||
const items = JSON.parse(d);
|
|
||||||
(Array.isArray(items) ? items : [items]).forEach(rf => {
|
|
||||||
call('start_download', {handle: rf.handle, storageId: rf.storageId, filename: rf.name, size: rf.size});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Remote panel ──────────────────────────────────────────────────────────────
|
|
||||||
function renderRemoteFiles() {
|
function renderRemoteFiles() {
|
||||||
const el = document.getElementById('remote-files');
|
const el = document.getElementById('remote-files');
|
||||||
const empty = document.getElementById('remote-empty-state');
|
const empty = document.getElementById('remote-empty-state');
|
||||||
@@ -277,8 +337,8 @@ function renderRemoteFiles() {
|
|||||||
if (empty) empty.style.display = 'none';
|
if (empty) empty.style.display = 'none';
|
||||||
|
|
||||||
if (S.remoteRefreshing) {
|
if (S.remoteRefreshing) {
|
||||||
el.innerHTML = '<div class="loading">Loading…</div>';
|
el.innerHTML = `<div class="loading">${esc(t('loading'))}</div>`;
|
||||||
_remoteFingerprint = '__loading__';
|
_remoteFingerprint = '__loading__' + I18N.lang;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -290,10 +350,11 @@ function renderRemoteFiles() {
|
|||||||
<svg viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-width="1.5" class="empty-icon">
|
<svg viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-width="1.5" class="empty-icon">
|
||||||
<path d="M6 38V14a2 2 0 012-2h12l4 4h16a2 2 0 012 2v20a2 2 0 01-2 2H8a2 2 0 01-2-2z"/>
|
<path d="M6 38V14a2 2 0 012-2h12l4 4h16a2 2 0 012 2v20a2 2 0 01-2 2H8a2 2 0 01-2-2z"/>
|
||||||
</svg>
|
</svg>
|
||||||
<div class="empty-title">Empty folder</div></div>`;
|
<div class="empty-title">${esc(t('empty.folder'))}</div></div>`;
|
||||||
} else {
|
} else {
|
||||||
el.innerHTML = `<table class="ftable">${FILE_TABLE_HEADER}
|
const files = sortedRemoteFiles();
|
||||||
<tbody>${buildFileRows(S.remoteFiles, S.selRemote, 'handle', false)}</tbody></table>`;
|
el.innerHTML = `<table class="ftable">${fileTableHeader()}
|
||||||
|
<tbody>${buildFileRows(files, S.selRemote, 'handle')}</tbody></table>`;
|
||||||
}
|
}
|
||||||
attachRemoteEvents(el);
|
attachRemoteEvents(el);
|
||||||
} else {
|
} else {
|
||||||
@@ -302,9 +363,17 @@ function renderRemoteFiles() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function attachRemoteEvents(el) {
|
function attachRemoteEvents(el) {
|
||||||
const files = S.remoteFiles;
|
const files = sortedRemoteFiles();
|
||||||
const keys = selectionKeys(files, 'handle');
|
const keys = selectionKeys(files, 'handle');
|
||||||
|
|
||||||
|
el.querySelectorAll('th.sortable').forEach(th => {
|
||||||
|
th.addEventListener('click', e => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setSortColumn(th.dataset.sort);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
el.querySelectorAll('.frow').forEach((row, i) => {
|
el.querySelectorAll('.frow').forEach((row, i) => {
|
||||||
const f = files[i];
|
const f = files[i];
|
||||||
const key = keys[i];
|
const key = keys[i];
|
||||||
@@ -323,22 +392,11 @@ function attachRemoteEvents(el) {
|
|||||||
|
|
||||||
attachMarqueeSelect(el, S.selRemote);
|
attachMarqueeSelect(el, S.selRemote);
|
||||||
|
|
||||||
// Drop target: receive local files → upload
|
|
||||||
el.addEventListener('dragover', e => { e.preventDefault(); el.classList.add('drag-over'); });
|
el.addEventListener('dragover', e => { e.preventDefault(); el.classList.add('drag-over'); });
|
||||||
el.addEventListener('dragleave', e => { if (!el.contains(e.relatedTarget)) el.classList.remove('drag-over'); });
|
el.addEventListener('dragleave', e => { if (!el.contains(e.relatedTarget)) el.classList.remove('drag-over'); });
|
||||||
el.addEventListener('drop', e => {
|
el.addEventListener('drop', e => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
el.classList.remove('drag-over');
|
el.classList.remove('drag-over');
|
||||||
// Internal drag (local app files)
|
|
||||||
const d = e.dataTransfer.getData('application/x-omnimtp-local');
|
|
||||||
if (d) {
|
|
||||||
const items = JSON.parse(d);
|
|
||||||
(Array.isArray(items) ? items : [items]).forEach(lf => {
|
|
||||||
call('start_upload', {srcPath: lf.path});
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Finder drop
|
|
||||||
for (const file of (e.dataTransfer.files || [])) {
|
for (const file of (e.dataTransfer.files || [])) {
|
||||||
const p = file.path || '';
|
const p = file.path || '';
|
||||||
if (p) call('start_upload', {srcPath: p});
|
if (p) call('start_upload', {srcPath: p});
|
||||||
@@ -346,7 +404,6 @@ function attachRemoteEvents(el) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Shared row click with shift-range support ─────────────────────────────────
|
|
||||||
function handleRowClick(e, idx, key, selSet, allKeys, lastIdxProp) {
|
function handleRowClick(e, idx, key, selSet, allKeys, lastIdxProp) {
|
||||||
if (e.shiftKey && S[lastIdxProp] >= 0) {
|
if (e.shiftKey && S[lastIdxProp] >= 0) {
|
||||||
const lo = Math.min(idx, S[lastIdxProp]);
|
const lo = Math.min(idx, S[lastIdxProp]);
|
||||||
@@ -363,53 +420,6 @@ function handleRowClick(e, idx, key, selSet, allKeys, lastIdxProp) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Drag badge ghost image ─────────────────────────────────────────────────────
|
|
||||||
function dragBadge(count, firstName) {
|
|
||||||
const el = document.createElement('div');
|
|
||||||
el.style.cssText = [
|
|
||||||
'position:fixed', 'top:-200px', 'left:-200px',
|
|
||||||
'background:rgba(10,132,255,0.85)', 'color:#fff',
|
|
||||||
'font:600 12px -apple-system', 'padding:4px 10px',
|
|
||||||
'border-radius:10px', 'white-space:nowrap',
|
|
||||||
].join(';');
|
|
||||||
el.textContent = count > 1 ? `${count} files` : firstName;
|
|
||||||
document.body.appendChild(el);
|
|
||||||
setTimeout(() => el.remove(), 0);
|
|
||||||
return el;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Toolbars ──────────────────────────────────────────────────────────────────
|
|
||||||
function renderLocalToolbar() {
|
|
||||||
const sel = document.getElementById('local-drive-sel');
|
|
||||||
if (S.localDrives && S.localDrives.length) {
|
|
||||||
const prev = sel.value;
|
|
||||||
sel.innerHTML = S.localDrives.map(d =>
|
|
||||||
`<option value="${esc(d.path)}">${esc(d.name)}</option>`
|
|
||||||
).join('');
|
|
||||||
const active = S.activeLocalDrive || prev;
|
|
||||||
if ([...sel.options].some(o => o.value === active)) sel.value = active;
|
|
||||||
sel.style.display = '';
|
|
||||||
} else {
|
|
||||||
sel.style.display = 'none';
|
|
||||||
}
|
|
||||||
|
|
||||||
const crumb = document.getElementById('local-breadcrumb');
|
|
||||||
const parts = (S.localPath || '').split('/').filter(Boolean);
|
|
||||||
const show = parts.slice(-4);
|
|
||||||
let html = show.length < parts.length ? '<span class="crumb-ellipsis">…</span><span class="crumb-sep">›</span>' : '';
|
|
||||||
show.forEach((p, i) => {
|
|
||||||
const fullIdx = parts.length - show.length + i;
|
|
||||||
const path = '/' + parts.slice(0, fullIdx + 1).join('/');
|
|
||||||
html += i === show.length - 1
|
|
||||||
? `<span class="crumb-cur">${esc(p)}</span>`
|
|
||||||
: `<span class="crumb-link" data-path="${esc(path)}">${esc(p)}</span><span class="crumb-sep">›</span>`;
|
|
||||||
});
|
|
||||||
crumb.innerHTML = html;
|
|
||||||
crumb.querySelectorAll('.crumb-link').forEach(el =>
|
|
||||||
el.addEventListener('click', () => call('navigate_local', {path: el.dataset.path})));
|
|
||||||
document.getElementById('btn-local-up').disabled = parts.length === 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderRemoteToolbar() {
|
function renderRemoteToolbar() {
|
||||||
const sel = document.getElementById('storage-sel');
|
const sel = document.getElementById('storage-sel');
|
||||||
if (S.storages && S.storages.length) {
|
if (S.storages && S.storages.length) {
|
||||||
@@ -435,22 +445,21 @@ function renderRemoteToolbar() {
|
|||||||
document.getElementById('btn-remote-up').disabled = stack.length <= 1;
|
document.getElementById('btn-remote-up').disabled = stack.length <= 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Device bar ────────────────────────────────────────────────────────────────
|
|
||||||
function renderDeviceBar() {
|
function renderDeviceBar() {
|
||||||
const badge = document.getElementById('conn-badge');
|
const badge = document.getElementById('conn-badge');
|
||||||
const status = document.getElementById('device-status-text');
|
const status = document.getElementById('device-status-text');
|
||||||
if (S.connected) {
|
if (S.connected) {
|
||||||
|
const name = S.deviceName || t('device.nintendo_switch');
|
||||||
badge.className = 'badge connected';
|
badge.className = 'badge connected';
|
||||||
badge.textContent = S.deviceName || 'Nintendo Switch';
|
badge.textContent = name;
|
||||||
status.textContent = S.deviceName || 'Nintendo Switch connected';
|
status.textContent = t('device.connected', { name });
|
||||||
} else {
|
} else {
|
||||||
badge.className = 'badge disconnected';
|
badge.className = 'badge disconnected';
|
||||||
badge.textContent = 'No Device';
|
badge.textContent = t('badge.no_device');
|
||||||
status.textContent = 'Waiting for Nintendo Switch…';
|
status.textContent = t('device.waiting');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Transfers panel ───────────────────────────────────────────────────────────
|
|
||||||
function renderTransfers() {
|
function renderTransfers() {
|
||||||
const list = document.getElementById('transfers-list');
|
const list = document.getElementById('transfers-list');
|
||||||
const label = document.getElementById('transfers-label');
|
const label = document.getElementById('transfers-label');
|
||||||
@@ -458,87 +467,87 @@ function renderTransfers() {
|
|||||||
const panel = document.getElementById('transfers-panel');
|
const panel = document.getElementById('transfers-panel');
|
||||||
|
|
||||||
const active = (S.transfers || []).filter(t => t.state <= 1);
|
const active = (S.transfers || []).filter(t => t.state <= 1);
|
||||||
label.textContent = active.length ? `${active.length} Transfer${active.length > 1 ? 's' : ''}` : 'Transfers';
|
label.textContent = active.length
|
||||||
|
? tPlural('transfers.count', 'transfers.count_plural', active.length)
|
||||||
|
: t('transfers');
|
||||||
cancelAll.style.display = active.length ? '' : 'none';
|
cancelAll.style.display = active.length ? '' : 'none';
|
||||||
|
cancelAll.textContent = t('btn.cancel_all');
|
||||||
|
|
||||||
if (!S.transfers || !S.transfers.length) { list.innerHTML = ''; panel.style.display = 'none'; return; }
|
if (!S.transfers || !S.transfers.length) { list.innerHTML = ''; panel.style.display = 'none'; return; }
|
||||||
panel.style.display = '';
|
panel.style.display = '';
|
||||||
|
|
||||||
const stateNames = ['Queued','','Done','Failed','Cancelled'];
|
const stateNames = {
|
||||||
list.innerHTML = S.transfers.map(t => {
|
0: t('transfer.queued'),
|
||||||
const dir = t.direction === 0 ? 'DL' : 'UP';
|
2: t('transfer.done'),
|
||||||
const dc = t.direction === 0 ? 'dir-dl' : 'dir-ul';
|
3: t('transfer.failed'),
|
||||||
const pct = ((t.progress || 0) * 100).toFixed(0);
|
4: t('transfer.cancelled'),
|
||||||
const showBar = t.state <= 1;
|
};
|
||||||
const statText = t.state === 1 ? esc(t.speedStr)
|
list.innerHTML = S.transfers.map(tx => {
|
||||||
: t.state === 3 ? '⚠ ' + esc(t.error || 'Failed')
|
const dir = tx.direction === 0 ? t('transfer.dl') : t('transfer.up');
|
||||||
: stateNames[t.state] || '';
|
const dc = tx.direction === 0 ? 'dir-dl' : 'dir-ul';
|
||||||
|
const pct = ((tx.progress || 0) * 100).toFixed(0);
|
||||||
|
const showBar = tx.state <= 1;
|
||||||
|
const statText = tx.state === 1 ? esc(tx.speedStr)
|
||||||
|
: tx.state === 3 ? '⚠ ' + esc(tx.error || t('transfer.failed'))
|
||||||
|
: (stateNames[tx.state] || '');
|
||||||
return `<div class="txfer-item">
|
return `<div class="txfer-item">
|
||||||
<div class="txfer-row">
|
<div class="txfer-row">
|
||||||
<span class="txfer-dir ${dc}">${dir}</span>
|
<span class="txfer-dir ${dc}">${esc(dir)}</span>
|
||||||
<span class="txfer-name" title="${esc(t.filename)}">${esc(t.filename)}</span>
|
<span class="txfer-name" title="${esc(tx.filename)}">${esc(tx.filename)}</span>
|
||||||
<span class="txfer-stat">${statText}</span>
|
<span class="txfer-stat">${statText}</span>
|
||||||
${showBar ? `<button class="txfer-cancel" data-id="${esc(t.id)}">×</button>` : ''}
|
${showBar ? `<button class="txfer-cancel" data-id="${esc(tx.id)}">×</button>` : ''}
|
||||||
</div>
|
</div>
|
||||||
${showBar ? `<div class="pbar"><div class="pbar-fill" style="width:${pct}%"></div></div>
|
${showBar ? `<div class="pbar"><div class="pbar-fill" style="width:${pct}%"></div></div>
|
||||||
<div class="txfer-meta">${pct}%${t.etaStr ? ' · ' + t.etaStr : ''}</div>` : ''}
|
<div class="txfer-meta">${pct}%${tx.etaStr ? ' · ' + tx.etaStr : ''}</div>` : ''}
|
||||||
</div>`;
|
</div>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
list.querySelectorAll('.txfer-cancel').forEach(btn =>
|
list.querySelectorAll('.txfer-cancel').forEach(btn =>
|
||||||
btn.addEventListener('click', () => call('cancel_transfer', {id: btn.dataset.id})));
|
btn.addEventListener('click', () => call('cancel_transfer', {id: btn.dataset.id})));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Status bar ────────────────────────────────────────────────────────────────
|
function statusArgsMap(args) {
|
||||||
|
const a = args || [];
|
||||||
|
return {
|
||||||
|
name: a[0] || '',
|
||||||
|
error: a[0] || '',
|
||||||
|
label: a[0] || '',
|
||||||
|
n: a[0] || '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function renderStatusBar() {
|
function renderStatusBar() {
|
||||||
const lf = (S.localFiles || []).length;
|
|
||||||
const rf = (S.remoteFiles || []).length;
|
const rf = (S.remoteFiles || []).length;
|
||||||
document.getElementById('sb-left').textContent = `${lf} item${lf !== 1 ? 's' : ''}`;
|
document.getElementById('sb-left').textContent = S.connected
|
||||||
document.getElementById('sb-right').textContent = S.connected ? `${rf} item${rf !== 1 ? 's' : ''}` : '';
|
? tPlural('items.count', 'items.count_plural', rf)
|
||||||
|
: '';
|
||||||
|
document.getElementById('sb-right').textContent = '';
|
||||||
let center = '';
|
let center = '';
|
||||||
if (S.statusTimer > 0 && S.statusMessage) {
|
const skipStatus = S.statusKey === 'status.connected' || S.statusKey === 'status.disconnected';
|
||||||
center = S.statusMessage;
|
if (S.statusTimer > 0 && S.statusKey && !skipStatus) {
|
||||||
|
center = t(S.statusKey, statusArgsMap(S.statusArgs));
|
||||||
} else if (S.connected) {
|
} else if (S.connected) {
|
||||||
const act = (S.transfers || []).filter(t => t.state === 1);
|
const act = (S.transfers || []).filter(tx => tx.state === 1);
|
||||||
if (act.length) {
|
if (act.length) {
|
||||||
const spd = act.reduce((s, t) => s + (t.speedBps || 0), 0);
|
const spd = act.reduce((s, tx) => s + (tx.speedBps || 0), 0);
|
||||||
center = `${act.length} active · ` + (spd > 1048576
|
const speed = spd > 1048576
|
||||||
? (spd/1048576).toFixed(0) + ' MB/s'
|
? (spd/1048576).toFixed(0) + ' MB/s'
|
||||||
: (spd/1024).toFixed(0) + ' KB/s');
|
: (spd/1024).toFixed(0) + ' KB/s';
|
||||||
|
center = t('status.active', { n: act.length, speed });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
document.getElementById('sb-center').textContent = center;
|
document.getElementById('sb-center').textContent = center;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Context menus ─────────────────────────────────────────────────────────────
|
let _ctxFile = null;
|
||||||
let _ctxFile = null, _ctxIsLocal = false;
|
|
||||||
|
|
||||||
function showLocalCtxMenu(file, e) {
|
|
||||||
_ctxFile = file;
|
|
||||||
_ctxIsLocal = true;
|
|
||||||
const m = document.getElementById('ctx-menu');
|
|
||||||
document.getElementById('ctx-upload').style.display = (!file.isDir && S.connected) ? '' : 'none';
|
|
||||||
document.getElementById('ctx-download').style.display = 'none';
|
|
||||||
document.getElementById('ctx-delete').style.display = 'none';
|
|
||||||
document.getElementById('ctx-sep-del').style.display = 'none';
|
|
||||||
m.style.left = e.clientX + 'px';
|
|
||||||
m.style.top = e.clientY + 'px';
|
|
||||||
m.classList.remove('hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
function showRemoteCtxMenu(file, e) {
|
function showRemoteCtxMenu(file, e) {
|
||||||
_ctxFile = file;
|
_ctxFile = file;
|
||||||
_ctxIsLocal = false;
|
|
||||||
if (!S.selRemote.has(String(file.handle))) {
|
if (!S.selRemote.has(String(file.handle))) {
|
||||||
S.selRemote.clear();
|
S.selRemote.clear();
|
||||||
S.selRemote.add(String(file.handle));
|
S.selRemote.add(String(file.handle));
|
||||||
applySelectionToDOM(document.getElementById('remote-files'), S.selRemote);
|
applySelectionToDOM(document.getElementById('remote-files'), S.selRemote);
|
||||||
}
|
}
|
||||||
const m = document.getElementById('ctx-menu');
|
const m = document.getElementById('ctx-menu');
|
||||||
const showDownload = !file.isDir;
|
|
||||||
document.getElementById('ctx-upload').style.display = 'none';
|
|
||||||
document.getElementById('ctx-download').style.display = showDownload ? '' : 'none';
|
|
||||||
document.getElementById('ctx-delete').style.display = '';
|
|
||||||
document.getElementById('ctx-sep-del').style.display = showDownload ? '' : 'none';
|
|
||||||
m.style.left = e.clientX + 'px';
|
m.style.left = e.clientX + 'px';
|
||||||
m.style.top = e.clientY + 'px';
|
m.style.top = e.clientY + 'px';
|
||||||
m.classList.remove('hidden');
|
m.classList.remove('hidden');
|
||||||
@@ -550,58 +559,50 @@ document.addEventListener('click', e => {
|
|||||||
});
|
});
|
||||||
document.addEventListener('contextmenu', e => { if (!e.target.closest('.frow')) { e.preventDefault(); document.getElementById('ctx-menu').classList.add('hidden'); } });
|
document.addEventListener('contextmenu', e => { if (!e.target.closest('.frow')) { e.preventDefault(); document.getElementById('ctx-menu').classList.add('hidden'); } });
|
||||||
|
|
||||||
// ── Init ──────────────────────────────────────────────────────────────────────
|
|
||||||
window.omniClearSelection = function() {
|
window.omniClearSelection = function() {
|
||||||
const sel = window.getSelection();
|
const sel = window.getSelection();
|
||||||
if (sel && sel.rangeCount) sel.removeAllRanges();
|
if (sel && sel.rangeCount) sel.removeAllRanges();
|
||||||
};
|
};
|
||||||
|
|
||||||
window.omniRemoteDragFilesAt = function(x, y) {
|
function exportDragFilesForRow(row) {
|
||||||
if (!S.connected) return '';
|
if (!S.connected || !row) return [];
|
||||||
const hit = document.elementFromPoint(x, y);
|
const files = sortedRemoteFiles();
|
||||||
const row = hit?.closest('#remote-files .frow');
|
const f = files[+row.dataset.idx];
|
||||||
if (!row || row.dataset.isDir === '1') return '';
|
if (!f) return [];
|
||||||
const f = S.remoteFiles[+row.dataset.idx];
|
|
||||||
if (!f || f.isDir) return '';
|
|
||||||
const key = String(f.handle);
|
const key = String(f.handle);
|
||||||
const dragFiles = S.selRemote.has(key)
|
const dragFiles = S.selRemote.has(key)
|
||||||
? S.remoteFiles.filter(rf => S.selRemote.has(String(rf.handle)) && !rf.isDir)
|
? files.filter(rf => S.selRemote.has(String(rf.handle)))
|
||||||
: [f];
|
: [f];
|
||||||
if (!dragFiles.length) return '';
|
return dragFiles.map(df => ({
|
||||||
return JSON.stringify(dragFiles.map(df => ({
|
handle: df.handle,
|
||||||
handle: df.handle, storageId: df.storageId, name: df.name, size: df.size,
|
storageId: df.storageId,
|
||||||
})));
|
name: df.name,
|
||||||
};
|
size: df.size || 0,
|
||||||
|
isDir: !!df.isDir,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
// Block text selection and text-drag; only explicit file drags allowed.
|
|
||||||
document.addEventListener('selectstart', e => {
|
document.addEventListener('selectstart', e => {
|
||||||
if (e.target.closest('.file-list')) e.preventDefault();
|
if (e.target.closest('.file-list')) e.preventDefault();
|
||||||
}, true);
|
}, true);
|
||||||
|
|
||||||
document.addEventListener('mousedown', e => {
|
document.addEventListener('mousedown', e => {
|
||||||
if (e.button !== 0) return;
|
if (e.button !== 0) return;
|
||||||
if (e.target.closest('.file-list .frow')) e.preventDefault();
|
const row = e.target.closest('#remote-files .frow');
|
||||||
|
if (!row) return;
|
||||||
|
e.preventDefault();
|
||||||
|
const files = exportDragFilesForRow(row);
|
||||||
|
if (!files.length) return;
|
||||||
|
call('prepare_export_drag', { files, x: e.clientX, y: e.clientY });
|
||||||
}, true);
|
}, true);
|
||||||
|
|
||||||
document.addEventListener('dragstart', e => {
|
document.addEventListener('dragstart', e => {
|
||||||
const row = e.target.closest('.file-list .frow');
|
if (e.target.closest('.file-list .frow')) e.preventDefault();
|
||||||
if (!row) return;
|
|
||||||
if (row.closest('#remote-files') || row.getAttribute('draggable') !== 'true') {
|
|
||||||
e.preventDefault();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
window.omniClearSelection();
|
|
||||||
if (e.dataTransfer) {
|
|
||||||
e.dataTransfer.effectAllowed = 'copy';
|
|
||||||
e.dataTransfer.setData('text/plain', '');
|
|
||||||
}
|
|
||||||
}, true);
|
}, true);
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', async () => {
|
||||||
document.getElementById('btn-local-up') .addEventListener('click', () => call('navigate_local_up'));
|
await initI18n();
|
||||||
document.getElementById('btn-local-home') .addEventListener('click', () => call('navigate_local', {path: '~'}));
|
|
||||||
document.getElementById('btn-local-refresh').addEventListener('click', () => call('refresh_local'));
|
|
||||||
document.getElementById('local-drive-sel') .addEventListener('change', e => call('navigate_local', {path: e.target.value}));
|
|
||||||
document.getElementById('btn-remote-up') .addEventListener('click', () => call('navigate_remote_up'));
|
document.getElementById('btn-remote-up') .addEventListener('click', () => call('navigate_remote_up'));
|
||||||
document.getElementById('btn-remote-refresh').addEventListener('click', () => call('refresh_remote'));
|
document.getElementById('btn-remote-refresh').addEventListener('click', () => call('refresh_remote'));
|
||||||
document.getElementById('btn-scan') .addEventListener('click', () => call('reconnect'));
|
document.getElementById('btn-scan') .addEventListener('click', () => call('reconnect'));
|
||||||
@@ -609,7 +610,6 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
document.getElementById('btn-cancel-all') .addEventListener('click', () => call('cancel_all'));
|
document.getElementById('btn-cancel-all') .addEventListener('click', () => call('cancel_all'));
|
||||||
document.getElementById('storage-sel') .addEventListener('change', e => call('set_storage', {storageId: +e.target.value}));
|
document.getElementById('storage-sel') .addEventListener('change', e => call('set_storage', {storageId: +e.target.value}));
|
||||||
|
|
||||||
// Context menu actions
|
|
||||||
const bindCtx = (id, fn) => {
|
const bindCtx = (id, fn) => {
|
||||||
document.getElementById(id).addEventListener('click', e => {
|
document.getElementById(id).addEventListener('click', e => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -617,27 +617,14 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
bindCtx('ctx-upload', () => {
|
bindCtx('ctx-delete', async () => {
|
||||||
if (!_ctxFile) return;
|
if (!_ctxFile) return;
|
||||||
const uploadFiles = S.selLocal.size > 1 && S.selLocal.has(_ctxFile.path)
|
const multi = S.selRemote.size > 1 && S.selRemote.has(String(_ctxFile.handle));
|
||||||
? S.localFiles.filter(f => S.selLocal.has(f.path))
|
const message = multi
|
||||||
: [_ctxFile];
|
? t('confirm.delete_items', { n: S.selRemote.size })
|
||||||
uploadFiles.forEach(f => call('start_upload', {srcPath: f.path}));
|
: t('confirm.delete', { label: `"${_ctxFile.name}"` });
|
||||||
});
|
if (await confirmDialog(message)) {
|
||||||
bindCtx('ctx-download', () => {
|
const handles = multi
|
||||||
if (!_ctxFile) return;
|
|
||||||
const downloadFiles = S.selRemote.size > 1 && S.selRemote.has(String(_ctxFile.handle))
|
|
||||||
? S.remoteFiles.filter(f => S.selRemote.has(String(f.handle)))
|
|
||||||
: [_ctxFile];
|
|
||||||
downloadFiles.forEach(f => call('start_download', {handle: f.handle, storageId: f.storageId, filename: f.name, size: f.size}));
|
|
||||||
});
|
|
||||||
bindCtx('ctx-delete', () => {
|
|
||||||
if (!_ctxFile) return;
|
|
||||||
const label = S.selRemote.size > 1 && S.selRemote.has(String(_ctxFile.handle))
|
|
||||||
? `${S.selRemote.size} items`
|
|
||||||
: `"${_ctxFile.name}"`;
|
|
||||||
if (window.confirm(`Delete ${label}?`)) {
|
|
||||||
const handles = S.selRemote.size > 1 && S.selRemote.has(String(_ctxFile.handle))
|
|
||||||
? S.remoteFiles.filter(f => S.selRemote.has(String(f.handle))).map(f => f.handle)
|
? S.remoteFiles.filter(f => S.selRemote.has(String(f.handle))).map(f => f.handle)
|
||||||
: [_ctxFile.handle];
|
: [_ctxFile.handle];
|
||||||
handles.forEach(h => call('delete_remote', {handle: h}));
|
handles.forEach(h => call('delete_remote', {handle: h}));
|
||||||
@@ -645,7 +632,6 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
document.getElementById('ctx-menu').classList.add('hidden');
|
document.getElementById('ctx-menu').classList.add('hidden');
|
||||||
});
|
});
|
||||||
|
|
||||||
// Keyboard
|
|
||||||
document.addEventListener('keydown', e => {
|
document.addEventListener('keydown', e => {
|
||||||
if (e.target.matches('input,textarea,select')) return;
|
if (e.target.matches('input,textarea,select')) return;
|
||||||
if (e.key === 'Backspace' || e.key === 'Delete') {
|
if (e.key === 'Backspace' || e.key === 'Delete') {
|
||||||
@@ -654,24 +640,14 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
if (e.metaKey && e.key === 'r') {
|
if (e.metaKey && e.key === 'r') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
call('refresh_remote');
|
call('refresh_remote');
|
||||||
call('refresh_local');
|
|
||||||
}
|
}
|
||||||
// Cmd+A = select all
|
|
||||||
if (e.metaKey && e.key === 'a') {
|
if (e.metaKey && e.key === 'a') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const active = document.activeElement;
|
|
||||||
if (active && active.closest('#local-files')) {
|
|
||||||
S.localFiles.forEach(f => S.selLocal.add(f.path));
|
|
||||||
applySelectionToDOM(document.getElementById('local-files'), S.selLocal);
|
|
||||||
} else {
|
|
||||||
S.remoteFiles.forEach(f => S.selRemote.add(String(f.handle)));
|
S.remoteFiles.forEach(f => S.selRemote.add(String(f.handle)));
|
||||||
applySelectionToDOM(document.getElementById('remote-files'), S.selRemote);
|
applySelectionToDOM(document.getElementById('remote-files'), S.selRemote);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
// Escape = deselect
|
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
S.selLocal.clear(); S.selRemote.clear();
|
S.selRemote.clear();
|
||||||
applySelectionToDOM(document.getElementById('local-files'), S.selLocal);
|
|
||||||
applySelectionToDOM(document.getElementById('remote-files'), S.selRemote);
|
applySelectionToDOM(document.getElementById('remote-files'), S.selRemote);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,57 +9,31 @@
|
|||||||
<body>
|
<body>
|
||||||
<div id="titlebar">
|
<div id="titlebar">
|
||||||
<div class="tl-spacer"></div>
|
<div class="tl-spacer"></div>
|
||||||
<span id="app-title">OmniMTP</span>
|
<span id="app-title" data-i18n="app.title">OmniMTP</span>
|
||||||
<div id="conn-badge" class="badge disconnected">No Device</div>
|
<div id="conn-badge" class="badge disconnected" data-i18n="badge.no_device">No Device</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="device-bar">
|
<div id="device-bar">
|
||||||
<div class="device-left">
|
<div class="device-left">
|
||||||
<button class="action-btn" id="btn-open-picker">Add Files</button>
|
<button class="action-btn" id="btn-open-picker" data-i18n="btn.add_files">Add Files</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="device-center" id="device-status-text">Waiting for Nintendo Switch…</div>
|
<div class="device-center" id="device-status-text" data-i18n="device.waiting">Waiting for Nintendo Switch…</div>
|
||||||
<div class="device-right">
|
<div class="device-right">
|
||||||
<button class="action-btn primary" id="btn-scan">Scan</button>
|
<button class="action-btn primary" id="btn-scan" data-i18n="btn.scan">Scan</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="main">
|
<div id="main">
|
||||||
<!-- Left: Local -->
|
|
||||||
<div class="panel" id="panel-local">
|
|
||||||
<div class="panel-toolbar">
|
|
||||||
<div class="nav-group">
|
|
||||||
<button class="nav-btn" id="btn-local-up" title="Go Up">
|
|
||||||
<svg viewBox="0 0 16 16"><path d="M8 3L3 8h3v5h4V8h3L8 3z" fill="currentColor"/></svg>
|
|
||||||
</button>
|
|
||||||
<button class="nav-btn" id="btn-local-home" title="Home">
|
|
||||||
<svg viewBox="0 0 16 16"><path d="M8 2L2 7h2v6h4v-4h4v4h2V7l2 0-6-5z" fill="currentColor"/></svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<select id="local-drive-sel" class="storage-select" title="Drive"></select>
|
|
||||||
<div id="local-breadcrumb" class="breadcrumb"></div>
|
|
||||||
<button class="nav-btn icon-only" id="btn-local-refresh" title="Refresh">
|
|
||||||
<svg viewBox="0 0 16 16"><path d="M13.5 8A5.5 5.5 0 1 1 8 2.5V1l3 2.5L8 6V4.5A3.5 3.5 0 1 0 11.5 8h2z" fill="currentColor"/></svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="file-list" id="local-files">
|
|
||||||
<div class="loading">Loading…</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Divider -->
|
|
||||||
<div id="panel-divider"></div>
|
|
||||||
|
|
||||||
<!-- Right: Switch -->
|
|
||||||
<div class="panel" id="panel-remote">
|
<div class="panel" id="panel-remote">
|
||||||
<div class="panel-toolbar">
|
<div class="panel-toolbar">
|
||||||
<div class="nav-group">
|
<div class="nav-group">
|
||||||
<button class="nav-btn" id="btn-remote-up" title="Go Up" disabled>
|
<button class="nav-btn" id="btn-remote-up" data-i18n-title="btn.go_up" title="Go Up" disabled>
|
||||||
<svg viewBox="0 0 16 16"><path d="M8 3L3 8h3v5h4V8h3L8 3z" fill="currentColor"/></svg>
|
<svg viewBox="0 0 16 16"><path d="M8 3L3 8h3v5h4V8h3L8 3z" fill="currentColor"/></svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<select id="storage-sel" class="storage-select"></select>
|
<select id="storage-sel" class="storage-select"></select>
|
||||||
<div id="remote-breadcrumb" class="breadcrumb"></div>
|
<div id="remote-breadcrumb" class="breadcrumb"></div>
|
||||||
<button class="nav-btn icon-only" id="btn-remote-refresh" title="Refresh">
|
<button class="nav-btn icon-only" id="btn-remote-refresh" data-i18n-title="btn.refresh" title="Refresh">
|
||||||
<svg viewBox="0 0 16 16"><path d="M13.5 8A5.5 5.5 0 1 1 8 2.5V1l3 2.5L8 6V4.5A3.5 3.5 0 1 0 11.5 8h2z" fill="currentColor"/></svg>
|
<svg viewBox="0 0 16 16"><path d="M13.5 8A5.5 5.5 0 1 1 8 2.5V1l3 2.5L8 6V4.5A3.5 3.5 0 1 0 11.5 8h2z" fill="currentColor"/></svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -70,8 +44,8 @@
|
|||||||
<line x1="24" y1="12" x2="24" y2="18"/>
|
<line x1="24" y1="12" x2="24" y2="18"/>
|
||||||
<circle cx="24" cy="22" r="1.5" fill="currentColor"/>
|
<circle cx="24" cy="22" r="1.5" fill="currentColor"/>
|
||||||
</svg>
|
</svg>
|
||||||
<div class="empty-title">No Switch Connected</div>
|
<div class="empty-title" data-i18n="empty.no_switch_title">No Switch Connected</div>
|
||||||
<div class="empty-sub">Connect via USB-C and open DBI or Sphaira MTP</div>
|
<div class="empty-sub" data-i18n="empty.no_switch_sub">Connect via USB-C and open DBI or Sphaira MTP</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -80,8 +54,8 @@
|
|||||||
<!-- Transfers -->
|
<!-- Transfers -->
|
||||||
<div id="transfers-panel">
|
<div id="transfers-panel">
|
||||||
<div id="transfers-header">
|
<div id="transfers-header">
|
||||||
<span id="transfers-label">Transfers</span>
|
<span id="transfers-label" data-i18n="transfers">Transfers</span>
|
||||||
<button class="action-btn danger small" id="btn-cancel-all" style="display:none">Cancel All</button>
|
<button class="action-btn danger small" id="btn-cancel-all" data-i18n="btn.cancel_all" style="display:none">Cancel All</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="transfers-list"></div>
|
<div id="transfers-list"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -95,10 +69,7 @@
|
|||||||
|
|
||||||
<!-- Context menu -->
|
<!-- Context menu -->
|
||||||
<div id="ctx-menu" class="ctx-menu hidden">
|
<div id="ctx-menu" class="ctx-menu hidden">
|
||||||
<div class="ctx-item" id="ctx-upload">Upload to Switch</div>
|
<div class="ctx-item danger" id="ctx-delete" data-i18n="ctx.delete">Delete…</div>
|
||||||
<div class="ctx-item" id="ctx-download">Download to Mac</div>
|
|
||||||
<div class="ctx-item ctx-sep" id="ctx-sep-del"></div>
|
|
||||||
<div class="ctx-item danger" id="ctx-delete">Delete…</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="app.js"></script>
|
<script src="app.js"></script>
|
||||||
|
|||||||
50
src/ui/webroot/lang/de.json
Normal file
50
src/ui/webroot/lang/de.json
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
{
|
||||||
|
"app.title": "OmniMTP",
|
||||||
|
"badge.no_device": "Kein Gerät",
|
||||||
|
"device.waiting": "Warte auf Nintendo Switch…",
|
||||||
|
"device.connected": "{name} verbunden",
|
||||||
|
"device.nintendo_switch": "Nintendo Switch",
|
||||||
|
"btn.add_files": "Dateien hinzufügen",
|
||||||
|
"btn.scan": "Suchen",
|
||||||
|
"btn.cancel_all": "Alle abbrechen",
|
||||||
|
"btn.go_up": "Nach oben",
|
||||||
|
"btn.refresh": "Aktualisieren",
|
||||||
|
"empty.no_switch_title": "Keine Switch verbunden",
|
||||||
|
"empty.no_switch_sub": "Per USB-C verbinden und DBI oder Sphaira MTP öffnen",
|
||||||
|
"empty.folder": "Leerer Ordner",
|
||||||
|
"loading": "Laden…",
|
||||||
|
"col.name": "Name",
|
||||||
|
"col.size": "Größe",
|
||||||
|
"col.modified": "Geändert",
|
||||||
|
"transfers": "Übertragungen",
|
||||||
|
"transfers.count": "{n} Übertragung",
|
||||||
|
"transfers.count_plural": "{n} Übertragungen",
|
||||||
|
"transfer.queued": "Warteschlange",
|
||||||
|
"transfer.done": "Fertig",
|
||||||
|
"transfer.failed": "Fehlgeschlagen",
|
||||||
|
"transfer.cancelled": "Abgebrochen",
|
||||||
|
"transfer.dl": "DL",
|
||||||
|
"transfer.up": "UP",
|
||||||
|
"ctx.delete": "Löschen…",
|
||||||
|
"confirm.delete": "{label} löschen?",
|
||||||
|
"confirm.delete_items": "{n} Elemente löschen?",
|
||||||
|
"confirm.delete_btn": "Löschen",
|
||||||
|
"confirm.cancel_btn": "Abbrechen",
|
||||||
|
"confirm.ok_btn": "OK",
|
||||||
|
"items.count": "{n} Element",
|
||||||
|
"items.count_plural": "{n} Elemente",
|
||||||
|
"status.active": "{n} aktiv · {speed}",
|
||||||
|
"status.connected": "Verbunden {name}",
|
||||||
|
"status.disconnected": "Switch getrennt",
|
||||||
|
"status.listing_error": "Fehler beim Auflisten: {error}",
|
||||||
|
"status.downloading": "Herunterladen {name}",
|
||||||
|
"status.uploading": "Hochladen {name}",
|
||||||
|
"menu.options": "Optionen",
|
||||||
|
"menu.language": "Sprache",
|
||||||
|
"menu.language_system": "Systemstandard",
|
||||||
|
"menu.quit": "OmniMTP beenden",
|
||||||
|
"menu.about": "Über OmniMTP",
|
||||||
|
"about.tagline": "Nintendo Switch MTP-Client für macOS",
|
||||||
|
"about.version": "Version {version}",
|
||||||
|
"about.copyright": "Copyright © 2026 NiklasCFW"
|
||||||
|
}
|
||||||
50
src/ui/webroot/lang/en.json
Normal file
50
src/ui/webroot/lang/en.json
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
{
|
||||||
|
"app.title": "OmniMTP",
|
||||||
|
"badge.no_device": "No Device",
|
||||||
|
"device.waiting": "Waiting for Nintendo Switch…",
|
||||||
|
"device.connected": "{name} connected",
|
||||||
|
"device.nintendo_switch": "Nintendo Switch",
|
||||||
|
"btn.add_files": "Add Files",
|
||||||
|
"btn.scan": "Scan",
|
||||||
|
"btn.cancel_all": "Cancel All",
|
||||||
|
"btn.go_up": "Go Up",
|
||||||
|
"btn.refresh": "Refresh",
|
||||||
|
"empty.no_switch_title": "No Switch Connected",
|
||||||
|
"empty.no_switch_sub": "Connect via USB-C and open DBI or Sphaira MTP",
|
||||||
|
"empty.folder": "Empty folder",
|
||||||
|
"loading": "Loading…",
|
||||||
|
"col.name": "Name",
|
||||||
|
"col.size": "Size",
|
||||||
|
"col.modified": "Modified",
|
||||||
|
"transfers": "Transfers",
|
||||||
|
"transfers.count": "{n} Transfer",
|
||||||
|
"transfers.count_plural": "{n} Transfers",
|
||||||
|
"transfer.queued": "Queued",
|
||||||
|
"transfer.done": "Done",
|
||||||
|
"transfer.failed": "Failed",
|
||||||
|
"transfer.cancelled": "Cancelled",
|
||||||
|
"transfer.dl": "DL",
|
||||||
|
"transfer.up": "UP",
|
||||||
|
"ctx.delete": "Delete…",
|
||||||
|
"confirm.delete": "Delete {label}?",
|
||||||
|
"confirm.delete_items": "Delete {n} items?",
|
||||||
|
"confirm.delete_btn": "Delete",
|
||||||
|
"confirm.cancel_btn": "Cancel",
|
||||||
|
"confirm.ok_btn": "OK",
|
||||||
|
"items.count": "{n} item",
|
||||||
|
"items.count_plural": "{n} items",
|
||||||
|
"status.active": "{n} active · {speed}",
|
||||||
|
"status.connected": "Connected {name}",
|
||||||
|
"status.disconnected": "Switch disconnected",
|
||||||
|
"status.listing_error": "Error listing: {error}",
|
||||||
|
"status.downloading": "Downloading {name}",
|
||||||
|
"status.uploading": "Uploading {name}",
|
||||||
|
"menu.options": "Options",
|
||||||
|
"menu.language": "Language",
|
||||||
|
"menu.language_system": "System Default",
|
||||||
|
"menu.quit": "Quit OmniMTP",
|
||||||
|
"menu.about": "About OmniMTP",
|
||||||
|
"about.tagline": "Nintendo Switch MTP Client for macOS",
|
||||||
|
"about.version": "Version {version}",
|
||||||
|
"about.copyright": "Copyright © 2026 NiklasCFW"
|
||||||
|
}
|
||||||
50
src/ui/webroot/lang/es.json
Normal file
50
src/ui/webroot/lang/es.json
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
{
|
||||||
|
"app.title": "OmniMTP",
|
||||||
|
"badge.no_device": "Sin dispositivo",
|
||||||
|
"device.waiting": "Esperando Nintendo Switch…",
|
||||||
|
"device.connected": "{name} conectada",
|
||||||
|
"device.nintendo_switch": "Nintendo Switch",
|
||||||
|
"btn.add_files": "Añadir archivos",
|
||||||
|
"btn.scan": "Buscar",
|
||||||
|
"btn.cancel_all": "Cancelar todo",
|
||||||
|
"btn.go_up": "Subir",
|
||||||
|
"btn.refresh": "Actualizar",
|
||||||
|
"empty.no_switch_title": "Ninguna Switch conectada",
|
||||||
|
"empty.no_switch_sub": "Conecta por USB-C y abre DBI o Sphaira MTP",
|
||||||
|
"empty.folder": "Carpeta vacía",
|
||||||
|
"loading": "Cargando…",
|
||||||
|
"col.name": "Nombre",
|
||||||
|
"col.size": "Tamaño",
|
||||||
|
"col.modified": "Modificado",
|
||||||
|
"transfers": "Transferencias",
|
||||||
|
"transfers.count": "{n} transferencia",
|
||||||
|
"transfers.count_plural": "{n} transferencias",
|
||||||
|
"transfer.queued": "En cola",
|
||||||
|
"transfer.done": "Hecho",
|
||||||
|
"transfer.failed": "Error",
|
||||||
|
"transfer.cancelled": "Cancelado",
|
||||||
|
"transfer.dl": "DL",
|
||||||
|
"transfer.up": "UP",
|
||||||
|
"ctx.delete": "Eliminar…",
|
||||||
|
"confirm.delete": "¿Eliminar {label}?",
|
||||||
|
"confirm.delete_items": "¿Eliminar {n} elementos?",
|
||||||
|
"confirm.delete_btn": "Eliminar",
|
||||||
|
"confirm.cancel_btn": "Cancelar",
|
||||||
|
"confirm.ok_btn": "OK",
|
||||||
|
"items.count": "{n} elemento",
|
||||||
|
"items.count_plural": "{n} elementos",
|
||||||
|
"status.active": "{n} activo · {speed}",
|
||||||
|
"status.connected": "Conectado {name}",
|
||||||
|
"status.disconnected": "Switch desconectada",
|
||||||
|
"status.listing_error": "Error al listar: {error}",
|
||||||
|
"status.downloading": "Descargando {name}",
|
||||||
|
"status.uploading": "Subiendo {name}",
|
||||||
|
"menu.options": "Opciones",
|
||||||
|
"menu.language": "Idioma",
|
||||||
|
"menu.language_system": "Idioma del sistema",
|
||||||
|
"menu.quit": "Salir de OmniMTP",
|
||||||
|
"menu.about": "Acerca de OmniMTP",
|
||||||
|
"about.tagline": "Cliente MTP de Nintendo Switch para macOS",
|
||||||
|
"about.version": "Versión {version}",
|
||||||
|
"about.copyright": "Copyright © 2026 NiklasCFW"
|
||||||
|
}
|
||||||
50
src/ui/webroot/lang/fr.json
Normal file
50
src/ui/webroot/lang/fr.json
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
{
|
||||||
|
"app.title": "OmniMTP",
|
||||||
|
"badge.no_device": "Aucun appareil",
|
||||||
|
"device.waiting": "En attente de la Nintendo Switch…",
|
||||||
|
"device.connected": "{name} connectée",
|
||||||
|
"device.nintendo_switch": "Nintendo Switch",
|
||||||
|
"btn.add_files": "Ajouter des fichiers",
|
||||||
|
"btn.scan": "Rechercher",
|
||||||
|
"btn.cancel_all": "Tout annuler",
|
||||||
|
"btn.go_up": "Remonter",
|
||||||
|
"btn.refresh": "Actualiser",
|
||||||
|
"empty.no_switch_title": "Aucune Switch connectée",
|
||||||
|
"empty.no_switch_sub": "Connectez en USB-C et ouvrez DBI ou Sphaira MTP",
|
||||||
|
"empty.folder": "Dossier vide",
|
||||||
|
"loading": "Chargement…",
|
||||||
|
"col.name": "Nom",
|
||||||
|
"col.size": "Taille",
|
||||||
|
"col.modified": "Modifié",
|
||||||
|
"transfers": "Transferts",
|
||||||
|
"transfers.count": "{n} transfert",
|
||||||
|
"transfers.count_plural": "{n} transferts",
|
||||||
|
"transfer.queued": "En file",
|
||||||
|
"transfer.done": "Terminé",
|
||||||
|
"transfer.failed": "Échoué",
|
||||||
|
"transfer.cancelled": "Annulé",
|
||||||
|
"transfer.dl": "DL",
|
||||||
|
"transfer.up": "UP",
|
||||||
|
"ctx.delete": "Supprimer…",
|
||||||
|
"confirm.delete": "Supprimer {label} ?",
|
||||||
|
"confirm.delete_items": "Supprimer {n} éléments ?",
|
||||||
|
"confirm.delete_btn": "Supprimer",
|
||||||
|
"confirm.cancel_btn": "Annuler",
|
||||||
|
"confirm.ok_btn": "OK",
|
||||||
|
"items.count": "{n} élément",
|
||||||
|
"items.count_plural": "{n} éléments",
|
||||||
|
"status.active": "{n} actif · {speed}",
|
||||||
|
"status.connected": "Connecté {name}",
|
||||||
|
"status.disconnected": "Switch déconnectée",
|
||||||
|
"status.listing_error": "Erreur de listage : {error}",
|
||||||
|
"status.downloading": "Téléchargement {name}",
|
||||||
|
"status.uploading": "Envoi {name}",
|
||||||
|
"menu.options": "Options",
|
||||||
|
"menu.language": "Langue",
|
||||||
|
"menu.language_system": "Langue du système",
|
||||||
|
"menu.quit": "Quitter OmniMTP",
|
||||||
|
"menu.about": "À propos d’OmniMTP",
|
||||||
|
"about.tagline": "Client MTP Nintendo Switch pour macOS",
|
||||||
|
"about.version": "Version {version}",
|
||||||
|
"about.copyright": "Copyright © 2026 NiklasCFW"
|
||||||
|
}
|
||||||
50
src/ui/webroot/lang/it.json
Normal file
50
src/ui/webroot/lang/it.json
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
{
|
||||||
|
"app.title": "OmniMTP",
|
||||||
|
"badge.no_device": "Nessun dispositivo",
|
||||||
|
"device.waiting": "In attesa della Nintendo Switch…",
|
||||||
|
"device.connected": "{name} connessa",
|
||||||
|
"device.nintendo_switch": "Nintendo Switch",
|
||||||
|
"btn.add_files": "Aggiungi file",
|
||||||
|
"btn.scan": "Cerca",
|
||||||
|
"btn.cancel_all": "Annulla tutto",
|
||||||
|
"btn.go_up": "Su",
|
||||||
|
"btn.refresh": "Aggiorna",
|
||||||
|
"empty.no_switch_title": "Nessuna Switch collegata",
|
||||||
|
"empty.no_switch_sub": "Collega via USB-C e apri DBI o Sphaira MTP",
|
||||||
|
"empty.folder": "Cartella vuota",
|
||||||
|
"loading": "Caricamento…",
|
||||||
|
"col.name": "Nome",
|
||||||
|
"col.size": "Dimensione",
|
||||||
|
"col.modified": "Modificato",
|
||||||
|
"transfers": "Trasferimenti",
|
||||||
|
"transfers.count": "{n} trasferimento",
|
||||||
|
"transfers.count_plural": "{n} trasferimenti",
|
||||||
|
"transfer.queued": "In coda",
|
||||||
|
"transfer.done": "Completato",
|
||||||
|
"transfer.failed": "Non riuscito",
|
||||||
|
"transfer.cancelled": "Annullato",
|
||||||
|
"transfer.dl": "DL",
|
||||||
|
"transfer.up": "UP",
|
||||||
|
"ctx.delete": "Elimina…",
|
||||||
|
"confirm.delete": "Eliminare {label}?",
|
||||||
|
"confirm.delete_items": "Eliminare {n} elementi?",
|
||||||
|
"confirm.delete_btn": "Elimina",
|
||||||
|
"confirm.cancel_btn": "Annulla",
|
||||||
|
"confirm.ok_btn": "OK",
|
||||||
|
"items.count": "{n} elemento",
|
||||||
|
"items.count_plural": "{n} elementi",
|
||||||
|
"status.active": "{n} attivi · {speed}",
|
||||||
|
"status.connected": "Connesso {name}",
|
||||||
|
"status.disconnected": "Switch scollegata",
|
||||||
|
"status.listing_error": "Errore elenco: {error}",
|
||||||
|
"status.downloading": "Download {name}",
|
||||||
|
"status.uploading": "Upload {name}",
|
||||||
|
"menu.options": "Opzioni",
|
||||||
|
"menu.language": "Lingua",
|
||||||
|
"menu.language_system": "Predefinita di sistema",
|
||||||
|
"menu.quit": "Esci da OmniMTP",
|
||||||
|
"menu.about": "Informazioni su OmniMTP",
|
||||||
|
"about.tagline": "Client MTP Nintendo Switch per macOS",
|
||||||
|
"about.version": "Versione {version}",
|
||||||
|
"about.copyright": "Copyright © 2026 NiklasCFW"
|
||||||
|
}
|
||||||
50
src/ui/webroot/lang/ja.json
Normal file
50
src/ui/webroot/lang/ja.json
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
{
|
||||||
|
"app.title": "OmniMTP",
|
||||||
|
"badge.no_device": "デバイスなし",
|
||||||
|
"device.waiting": "Nintendo Switchを待機中…",
|
||||||
|
"device.connected": "{name} 接続中",
|
||||||
|
"device.nintendo_switch": "Nintendo Switch",
|
||||||
|
"btn.add_files": "ファイルを追加",
|
||||||
|
"btn.scan": "スキャン",
|
||||||
|
"btn.cancel_all": "すべてキャンセル",
|
||||||
|
"btn.go_up": "上へ",
|
||||||
|
"btn.refresh": "更新",
|
||||||
|
"empty.no_switch_title": "Switch未接続",
|
||||||
|
"empty.no_switch_sub": "USB-Cで接続し、DBIまたはSphaira MTPを開いてください",
|
||||||
|
"empty.folder": "空のフォルダ",
|
||||||
|
"loading": "読み込み中…",
|
||||||
|
"col.name": "名前",
|
||||||
|
"col.size": "サイズ",
|
||||||
|
"col.modified": "更新日時",
|
||||||
|
"transfers": "転送",
|
||||||
|
"transfers.count": "{n} 件の転送",
|
||||||
|
"transfers.count_plural": "{n} 件の転送",
|
||||||
|
"transfer.queued": "待機中",
|
||||||
|
"transfer.done": "完了",
|
||||||
|
"transfer.failed": "失敗",
|
||||||
|
"transfer.cancelled": "キャンセル",
|
||||||
|
"transfer.dl": "DL",
|
||||||
|
"transfer.up": "UP",
|
||||||
|
"ctx.delete": "削除…",
|
||||||
|
"confirm.delete": "{label} を削除しますか?",
|
||||||
|
"confirm.delete_items": "{n} 項目を削除しますか?",
|
||||||
|
"confirm.delete_btn": "削除",
|
||||||
|
"confirm.cancel_btn": "キャンセル",
|
||||||
|
"confirm.ok_btn": "OK",
|
||||||
|
"items.count": "{n} 項目",
|
||||||
|
"items.count_plural": "{n} 項目",
|
||||||
|
"status.active": "{n} 実行中 · {speed}",
|
||||||
|
"status.connected": "接続済み {name}",
|
||||||
|
"status.disconnected": "Switch切断",
|
||||||
|
"status.listing_error": "一覧エラー: {error}",
|
||||||
|
"status.downloading": "ダウンロード中 {name}",
|
||||||
|
"status.uploading": "アップロード中 {name}",
|
||||||
|
"menu.options": "オプション",
|
||||||
|
"menu.language": "言語",
|
||||||
|
"menu.language_system": "システムデフォルト",
|
||||||
|
"menu.quit": "OmniMTPを終了",
|
||||||
|
"menu.about": "OmniMTPについて",
|
||||||
|
"about.tagline": "macOS向け Nintendo Switch MTPクライアント",
|
||||||
|
"about.version": "バージョン {version}",
|
||||||
|
"about.copyright": "Copyright © 2026 NiklasCFW"
|
||||||
|
}
|
||||||
50
src/ui/webroot/lang/nl.json
Normal file
50
src/ui/webroot/lang/nl.json
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
{
|
||||||
|
"app.title": "OmniMTP",
|
||||||
|
"badge.no_device": "Geen apparaat",
|
||||||
|
"device.waiting": "Wachten op Nintendo Switch…",
|
||||||
|
"device.connected": "{name} verbonden",
|
||||||
|
"device.nintendo_switch": "Nintendo Switch",
|
||||||
|
"btn.add_files": "Bestanden toevoegen",
|
||||||
|
"btn.scan": "Zoeken",
|
||||||
|
"btn.cancel_all": "Alles annuleren",
|
||||||
|
"btn.go_up": "Omhoog",
|
||||||
|
"btn.refresh": "Vernieuwen",
|
||||||
|
"empty.no_switch_title": "Geen Switch verbonden",
|
||||||
|
"empty.no_switch_sub": "Verbind via USB-C en open DBI of Sphaira MTP",
|
||||||
|
"empty.folder": "Lege map",
|
||||||
|
"loading": "Laden…",
|
||||||
|
"col.name": "Naam",
|
||||||
|
"col.size": "Grootte",
|
||||||
|
"col.modified": "Gewijzigd",
|
||||||
|
"transfers": "Overdrachten",
|
||||||
|
"transfers.count": "{n} overdracht",
|
||||||
|
"transfers.count_plural": "{n} overdrachten",
|
||||||
|
"transfer.queued": "Wachtrij",
|
||||||
|
"transfer.done": "Klaar",
|
||||||
|
"transfer.failed": "Mislukt",
|
||||||
|
"transfer.cancelled": "Geannuleerd",
|
||||||
|
"transfer.dl": "DL",
|
||||||
|
"transfer.up": "UP",
|
||||||
|
"ctx.delete": "Verwijderen…",
|
||||||
|
"confirm.delete": "{label} verwijderen?",
|
||||||
|
"confirm.delete_items": "{n} items verwijderen?",
|
||||||
|
"confirm.delete_btn": "Verwijderen",
|
||||||
|
"confirm.cancel_btn": "Annuleren",
|
||||||
|
"confirm.ok_btn": "OK",
|
||||||
|
"items.count": "{n} item",
|
||||||
|
"items.count_plural": "{n} items",
|
||||||
|
"status.active": "{n} actief · {speed}",
|
||||||
|
"status.connected": "Verbonden {name}",
|
||||||
|
"status.disconnected": "Switch verbroken",
|
||||||
|
"status.listing_error": "Fout bij weergeven: {error}",
|
||||||
|
"status.downloading": "Downloaden {name}",
|
||||||
|
"status.uploading": "Uploaden {name}",
|
||||||
|
"menu.options": "Opties",
|
||||||
|
"menu.language": "Taal",
|
||||||
|
"menu.language_system": "Systeemstandaard",
|
||||||
|
"menu.quit": "OmniMTP afsluiten",
|
||||||
|
"menu.about": "Over OmniMTP",
|
||||||
|
"about.tagline": "Nintendo Switch MTP-client voor macOS",
|
||||||
|
"about.version": "Versie {version}",
|
||||||
|
"about.copyright": "Copyright © 2026 NiklasCFW"
|
||||||
|
}
|
||||||
50
src/ui/webroot/lang/pt.json
Normal file
50
src/ui/webroot/lang/pt.json
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
{
|
||||||
|
"app.title": "OmniMTP",
|
||||||
|
"badge.no_device": "Sem dispositivo",
|
||||||
|
"device.waiting": "Aguardando Nintendo Switch…",
|
||||||
|
"device.connected": "{name} conectada",
|
||||||
|
"device.nintendo_switch": "Nintendo Switch",
|
||||||
|
"btn.add_files": "Adicionar arquivos",
|
||||||
|
"btn.scan": "Procurar",
|
||||||
|
"btn.cancel_all": "Cancelar tudo",
|
||||||
|
"btn.go_up": "Subir",
|
||||||
|
"btn.refresh": "Atualizar",
|
||||||
|
"empty.no_switch_title": "Nenhuma Switch conectada",
|
||||||
|
"empty.no_switch_sub": "Conecte via USB-C e abra o DBI ou Sphaira MTP",
|
||||||
|
"empty.folder": "Pasta vazia",
|
||||||
|
"loading": "Carregando…",
|
||||||
|
"col.name": "Nome",
|
||||||
|
"col.size": "Tamanho",
|
||||||
|
"col.modified": "Modificado",
|
||||||
|
"transfers": "Transferências",
|
||||||
|
"transfers.count": "{n} transferência",
|
||||||
|
"transfers.count_plural": "{n} transferências",
|
||||||
|
"transfer.queued": "Na fila",
|
||||||
|
"transfer.done": "Concluído",
|
||||||
|
"transfer.failed": "Falhou",
|
||||||
|
"transfer.cancelled": "Cancelado",
|
||||||
|
"transfer.dl": "DL",
|
||||||
|
"transfer.up": "UP",
|
||||||
|
"ctx.delete": "Excluir…",
|
||||||
|
"confirm.delete": "Excluir {label}?",
|
||||||
|
"confirm.delete_items": "Excluir {n} itens?",
|
||||||
|
"confirm.delete_btn": "Excluir",
|
||||||
|
"confirm.cancel_btn": "Cancelar",
|
||||||
|
"confirm.ok_btn": "OK",
|
||||||
|
"items.count": "{n} item",
|
||||||
|
"items.count_plural": "{n} itens",
|
||||||
|
"status.active": "{n} ativo · {speed}",
|
||||||
|
"status.connected": "Conectado {name}",
|
||||||
|
"status.disconnected": "Switch desconectada",
|
||||||
|
"status.listing_error": "Erro ao listar: {error}",
|
||||||
|
"status.downloading": "Baixando {name}",
|
||||||
|
"status.uploading": "Enviando {name}",
|
||||||
|
"menu.options": "Opções",
|
||||||
|
"menu.language": "Idioma",
|
||||||
|
"menu.language_system": "Padrão do sistema",
|
||||||
|
"menu.quit": "Sair do OmniMTP",
|
||||||
|
"menu.about": "Sobre o OmniMTP",
|
||||||
|
"about.tagline": "Cliente MTP Nintendo Switch para macOS",
|
||||||
|
"about.version": "Versão {version}",
|
||||||
|
"about.copyright": "Copyright © 2026 NiklasCFW"
|
||||||
|
}
|
||||||
50
src/ui/webroot/lang/ru.json
Normal file
50
src/ui/webroot/lang/ru.json
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
{
|
||||||
|
"app.title": "OmniMTP",
|
||||||
|
"badge.no_device": "Нет устройства",
|
||||||
|
"device.waiting": "Ожидание Nintendo Switch…",
|
||||||
|
"device.connected": "{name} подключена",
|
||||||
|
"device.nintendo_switch": "Nintendo Switch",
|
||||||
|
"btn.add_files": "Добавить файлы",
|
||||||
|
"btn.scan": "Поиск",
|
||||||
|
"btn.cancel_all": "Отменить всё",
|
||||||
|
"btn.go_up": "Вверх",
|
||||||
|
"btn.refresh": "Обновить",
|
||||||
|
"empty.no_switch_title": "Switch не подключена",
|
||||||
|
"empty.no_switch_sub": "Подключите по USB-C и откройте DBI или Sphaira MTP",
|
||||||
|
"empty.folder": "Пустая папка",
|
||||||
|
"loading": "Загрузка…",
|
||||||
|
"col.name": "Имя",
|
||||||
|
"col.size": "Размер",
|
||||||
|
"col.modified": "Изменён",
|
||||||
|
"transfers": "Передачи",
|
||||||
|
"transfers.count": "{n} передача",
|
||||||
|
"transfers.count_plural": "{n} передач",
|
||||||
|
"transfer.queued": "В очереди",
|
||||||
|
"transfer.done": "Готово",
|
||||||
|
"transfer.failed": "Ошибка",
|
||||||
|
"transfer.cancelled": "Отменено",
|
||||||
|
"transfer.dl": "DL",
|
||||||
|
"transfer.up": "UP",
|
||||||
|
"ctx.delete": "Удалить…",
|
||||||
|
"confirm.delete": "Удалить {label}?",
|
||||||
|
"confirm.delete_items": "Удалить {n} элементов?",
|
||||||
|
"confirm.delete_btn": "Удалить",
|
||||||
|
"confirm.cancel_btn": "Отмена",
|
||||||
|
"confirm.ok_btn": "OK",
|
||||||
|
"items.count": "{n} элемент",
|
||||||
|
"items.count_plural": "{n} элементов",
|
||||||
|
"status.active": "{n} активно · {speed}",
|
||||||
|
"status.connected": "Подключено {name}",
|
||||||
|
"status.disconnected": "Switch отключена",
|
||||||
|
"status.listing_error": "Ошибка списка: {error}",
|
||||||
|
"status.downloading": "Скачивание {name}",
|
||||||
|
"status.uploading": "Загрузка {name}",
|
||||||
|
"menu.options": "Параметры",
|
||||||
|
"menu.language": "Язык",
|
||||||
|
"menu.language_system": "Системный",
|
||||||
|
"menu.quit": "Завершить OmniMTP",
|
||||||
|
"menu.about": "О программе OmniMTP",
|
||||||
|
"about.tagline": "MTP-клиент Nintendo Switch для macOS",
|
||||||
|
"about.version": "Версия {version}",
|
||||||
|
"about.copyright": "Copyright © 2026 NiklasCFW"
|
||||||
|
}
|
||||||
50
src/ui/webroot/lang/zh-Hans.json
Normal file
50
src/ui/webroot/lang/zh-Hans.json
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
{
|
||||||
|
"app.title": "OmniMTP",
|
||||||
|
"badge.no_device": "无设备",
|
||||||
|
"device.waiting": "正在等待 Nintendo Switch…",
|
||||||
|
"device.connected": "{name} 已连接",
|
||||||
|
"device.nintendo_switch": "Nintendo Switch",
|
||||||
|
"btn.add_files": "添加文件",
|
||||||
|
"btn.scan": "扫描",
|
||||||
|
"btn.cancel_all": "全部取消",
|
||||||
|
"btn.go_up": "上级",
|
||||||
|
"btn.refresh": "刷新",
|
||||||
|
"empty.no_switch_title": "未连接 Switch",
|
||||||
|
"empty.no_switch_sub": "通过 USB-C 连接并打开 DBI 或 Sphaira MTP",
|
||||||
|
"empty.folder": "空文件夹",
|
||||||
|
"loading": "加载中…",
|
||||||
|
"col.name": "名称",
|
||||||
|
"col.size": "大小",
|
||||||
|
"col.modified": "修改时间",
|
||||||
|
"transfers": "传输",
|
||||||
|
"transfers.count": "{n} 个传输",
|
||||||
|
"transfers.count_plural": "{n} 个传输",
|
||||||
|
"transfer.queued": "排队中",
|
||||||
|
"transfer.done": "完成",
|
||||||
|
"transfer.failed": "失败",
|
||||||
|
"transfer.cancelled": "已取消",
|
||||||
|
"transfer.dl": "下载",
|
||||||
|
"transfer.up": "上传",
|
||||||
|
"ctx.delete": "删除…",
|
||||||
|
"confirm.delete": "删除 {label}?",
|
||||||
|
"confirm.delete_items": "删除 {n} 项?",
|
||||||
|
"confirm.delete_btn": "删除",
|
||||||
|
"confirm.cancel_btn": "取消",
|
||||||
|
"confirm.ok_btn": "确定",
|
||||||
|
"items.count": "{n} 项",
|
||||||
|
"items.count_plural": "{n} 项",
|
||||||
|
"status.active": "{n} 进行中 · {speed}",
|
||||||
|
"status.connected": "已连接 {name}",
|
||||||
|
"status.disconnected": "Switch 已断开",
|
||||||
|
"status.listing_error": "列出错误:{error}",
|
||||||
|
"status.downloading": "正在下载 {name}",
|
||||||
|
"status.uploading": "正在上传 {name}",
|
||||||
|
"menu.options": "选项",
|
||||||
|
"menu.language": "语言",
|
||||||
|
"menu.language_system": "跟随系统",
|
||||||
|
"menu.quit": "退出 OmniMTP",
|
||||||
|
"menu.about": "关于 OmniMTP",
|
||||||
|
"about.tagline": "适用于 macOS 的 Nintendo Switch MTP 客户端",
|
||||||
|
"about.version": "版本 {version}",
|
||||||
|
"about.copyright": "Copyright © 2026 NiklasCFW"
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user