9 Commits

Author SHA1 Message Date
57da2d4816 Version bump to 1.0.3 and add CHANGELOG.
Document New Folder / inline rename plus prior 1.0.x release notes.
2026-08-07 00:24:47 +02:00
413cc8fbde Add Finder-style New Folder with inline rename on the Switch.
Create unique folders without a crashing prompt, then focus the name for editing; also fix folder association type and UTF-8 MTP string encoding.
2026-08-07 00:24:47 +02:00
474f4f7714 Version bump to 1.0.2 2026-08-07 00:14:53 +02:00
846f743a5b Support recursive folder uploads via drag-and-drop and the file picker.
Create matching remote directories and enqueue contained files instead of ignoring directories.
2026-08-07 00:14:53 +02:00
bf31dbc031 Version bump to 1.0.1 2026-07-30 16:14:13 +02:00
68353cbc56 Add startup update check against Gitea releases.
Show a native alert when a newer release is available, with menu access to check manually.
2026-07-30 16:13:02 +02:00
86fa763eda Revert "Add Sparkle appcast for in-app update checks."
This reverts commit 3d66bfa666.
2026-07-30 16:13:02 +02:00
3d66bfa666 Add Sparkle appcast for in-app update checks.
Hosts the signed 1.0.0 enclosure feed Sparkle reads from main.
2026-07-30 16:09:00 +02:00
5e3df0085c Recover MTP/USB after Mac sleep without requiring a reboot.
Tear down stale libusb handles on sleep/wake, skip CloseSession on dead pipes, and aggressively rescan until the Switch reconnects.
2026-07-30 15:48:04 +02:00
22 changed files with 952 additions and 75 deletions

37
CHANGELOG.md Normal file
View File

@@ -0,0 +1,37 @@
# Changelog
All notable changes to OmniMTP are documented here.
## [1.0.3] — 2026-08-07
### Added
- **New Folder** on the Switch: toolbar button and context menu (row or empty area)
- Finder-style flow: creates a unique name (`New Folder`, `New Folder 2`, …), then opens inline rename immediately
- MTP rename support (`SetObjectPropValue` / object filename) for committing the inline name
### Fixed
- Crash when creating a folder (invalid JSON bridge response from the old name prompt)
- Folder objects now set MTP association type `Generic Folder` so hosts treat them as directories
- UTF-8 filenames encoded correctly as MTP UTF-16LE (localized names like “Neuer Ordner” no longer corrupt)
## [1.0.2] — 2026-08-07
### Added
- Recursive **folder uploads** via drag-and-drop and **Add Files**
- Creates matching folders on the Switch and queues contained files
- Skips `.DS_Store` / `._*` clutter during folder uploads
## [1.0.1] — 2026-07-30
### Added
- Startup update check against Gitea releases (also **OmniMTP → Check for Updates…**)
### Fixed
- MTP/USB recovery after Mac sleep: reconnects without needing a Mac reboot
## [1.0.0] — 2026-07-26
### Added
- Initial OmniMTP release: native macOS MTP client for Nintendo Switch (DBI / Sphaira)
- Dual-pane browser, drag-and-drop transfers, multi-select, large-file support
- Automatic device detection, Dark Mode UI, static libusb (universal binary)

View File

@@ -1,5 +1,5 @@
cmake_minimum_required(VERSION 3.25)
project(OmniMTP VERSION 1.0.0
project(OmniMTP VERSION 1.0.3
DESCRIPTION "Nintendo Switch MTP Client for macOS"
LANGUAGES CXX OBJCXX OBJC C
)

View File

@@ -22,17 +22,46 @@ void MTPOperations::put_le64(std::vector<uint8_t>& b, uint64_t v) {
// UTF-8 → MTP UTF-16LE string: [1-byte len][len × 2-byte UTF-16LE chars]
void MTPOperations::encode_mtp_string(std::vector<uint8_t>& buf, const std::string& utf8) {
if (utf8.empty()) { buf.push_back(0); return; }
// Simple ASCII-only encoding (sufficient for filenames in practice)
size_t len = std::min(utf8.size(), size_t(255)) + 1; // +1 for null terminator
buf.push_back(static_cast<uint8_t>(len));
for (size_t i = 0; i < len - 1; ++i) {
uint8_t c = static_cast<uint8_t>(utf8[i]);
buf.push_back(c);
buf.push_back(0);
std::vector<uint16_t> units;
units.reserve(utf8.size() + 1);
size_t i = 0;
while (i < utf8.size() && units.size() < 254) {
const unsigned char c = static_cast<unsigned char>(utf8[i]);
uint32_t cp = 0;
if (c < 0x80) {
cp = c; i += 1;
} else if ((c & 0xE0) == 0xC0 && i + 1 < utf8.size()) {
cp = (uint32_t(c & 0x1F) << 6) | (utf8[i + 1] & 0x3F);
i += 2;
} else if ((c & 0xF0) == 0xE0 && i + 2 < utf8.size()) {
cp = (uint32_t(c & 0x0F) << 12)
| (uint32_t(utf8[i + 1] & 0x3F) << 6)
| (utf8[i + 2] & 0x3F);
i += 3;
} else if ((c & 0xF8) == 0xF0 && i + 3 < utf8.size()) {
cp = (uint32_t(c & 0x07) << 18)
| (uint32_t(utf8[i + 1] & 0x3F) << 12)
| (uint32_t(utf8[i + 2] & 0x3F) << 6)
| (utf8[i + 3] & 0x3F);
i += 4;
} else {
cp = '?'; i += 1;
}
if (cp >= 0x10000) {
cp -= 0x10000;
units.push_back(static_cast<uint16_t>(0xD800 + (cp >> 10)));
if (units.size() < 254)
units.push_back(static_cast<uint16_t>(0xDC00 + (cp & 0x3FF)));
} else {
units.push_back(static_cast<uint16_t>(cp));
}
}
// Null terminator
buf.push_back(0);
buf.push_back(0);
const size_t len = units.size() + 1; // includes null terminator
buf.push_back(static_cast<uint8_t>(len));
for (uint16_t u : units) put_le16(buf, u);
put_le16(buf, 0);
}
// ─── Parsing helpers ──────────────────────────────────────────────────────────
@@ -203,7 +232,8 @@ std::vector<uint8_t> MTPOperations::build_object_info(uint32_t storage_id,
put_le32(b, 0); // image_pix_h
put_le32(b, 0); // image_bit_depth
put_le32(b, parent_handle);
put_le16(b, 0); // association_type
// Generic Folder association (0x0001) required for directory objects.
put_le16(b, format == static_cast<uint16_t>(ObjectFormat::Association) ? 0x0001 : 0);
put_le32(b, 0); // association_desc
put_le32(b, 0); // sequence_number
encode_mtp_string(b, filename);
@@ -330,4 +360,18 @@ uint32_t MTPOperations::create_directory(uint32_t storage_id,
return resp.params.size() >= 3 ? resp.params[2] : 0;
}
void MTPOperations::rename_object(uint32_t handle, const std::string& new_name) {
std::lock_guard lock(session_.mutex());
std::array<uint32_t,2> params{ handle, PROP_OBJECT_FILENAME };
session_.send_command(OpCode::SetObjectPropValue, params);
std::vector<uint8_t> payload;
encode_mtp_string(payload, new_name);
session_.send_data(payload);
auto resp = session_.receive_response();
if (resp.code != ResponseCode::OK)
throw MTPException(resp.code, MTPException::code_name(resp.code));
}
} // namespace mtp

View File

@@ -48,6 +48,9 @@ public:
uint32_t parent_handle,
const std::string& name);
// Rename an existing object via SetObjectPropValue(ObjectFileName).
void rename_object(uint32_t handle, const std::string& new_name);
private:
MTPSession& session_;

View File

@@ -90,8 +90,11 @@ MTPSession::MTPSession() {
}
MTPSession::~MTPSession() {
if (connected_) disconnect();
if (ctx_) libusb_exit(ctx_);
try { disconnect(true); } catch (...) {}
if (ctx_) {
libusb_exit(ctx_);
ctx_ = nullptr;
}
}
// ─── Device detection ─────────────────────────────────────────────────────────
@@ -293,20 +296,26 @@ DeviceInfo MTPSession::connect() {
}
// ─── Disconnect ───────────────────────────────────────────────────────────────
void MTPSession::disconnect() {
if (!connected_.load()) return;
connected_.store(false);
void MTPSession::disconnect(bool force) {
const bool was_connected = connected_.exchange(false);
if (!was_connected && !handle_) return;
try {
send_command(OpCode::CloseSession);
receive_response(); // best-effort; ignore result
} catch (...) {}
// After host sleep the bulk pipe is often dead — CloseSession would hang
// on a 5s timeout and leave Darwin USB in a worse state.
if (!force && handle_) {
try {
send_command(OpCode::CloseSession);
receive_response(); // best-effort; ignore result
} catch (...) {}
}
if (handle_) {
// Best-effort; after suspend these calls frequently return errors.
libusb_release_interface(handle_, ep_.interface_num);
libusb_close(handle_);
handle_ = nullptr;
}
transaction_id_ = 0;
}
// ─── Raw USB I/O ──────────────────────────────────────────────────────────────

View File

@@ -40,7 +40,8 @@ public:
DeviceInfo connect();
// Close MTP session and release USB resources.
void disconnect();
// force=true skips CloseSession (use after sleep/wake when the pipe is dead).
void disconnect(bool force = false);
bool is_connected() const { return connected_.load(); }

View File

@@ -58,9 +58,18 @@ public:
void do_cancel(const std::string& id);
void do_cancel_all();
void do_delete(uint32_t handle);
void do_create_folder(const std::string& name);
// Create a uniquely named folder (default "New Folder") in the current remote dir.
// On success fills out_* and returns true.
bool do_create_folder(const std::string& preferred_name,
uint32_t& out_handle, uint32_t& out_storage_id,
std::string& out_name);
bool do_rename(uint32_t handle, const std::string& new_name);
void reconnect();
// macOS sleep/wake — tear down stale libusb state and reconnect.
void notify_system_sleep();
void notify_system_wake();
bool connected() const { return connected_.load(); }
// Drop-zone rect (CSS pixels, origin top-left) updated by the web UI.
@@ -79,6 +88,8 @@ private:
void start_download(uint32_t handle, uint32_t storage_id,
const std::string& filename, uint64_t size);
void start_upload(const fs::path& src_path, uint64_t file_size);
// Recursively create a remote folder and enqueue files under parent_handle.
void upload_local_tree(const fs::path& dir, uint32_t parent_handle, uint32_t storage_id);
// 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);
@@ -86,6 +97,8 @@ private:
void device_monitor_loop(std::stop_token st);
void on_device_connected(mtp::DeviceInfo di);
void on_device_disconnected();
// Drop session + libusb context without MTP CloseSession (post-sleep recovery).
void force_usb_teardown(const std::string& status_key);
// ── Status bar ────────────────────────────────────────────────────────────
// key is an i18n id (e.g. "status.connected"); args fill {name}/{error}/… placeholders.
@@ -101,7 +114,11 @@ private:
std::unique_ptr<transfer::TransferEngine> engine_;
std::atomic<bool> connected_{false};
std::atomic<bool> usb_recovery_pending_{false};
// After wake/sleep, poll USB more often for a while (monitor iterations).
std::atomic<int> fast_reconnect_remaining_{0};
std::jthread monitor_thread_;
void* wake_observer_ = nullptr; // OmniMTPWakeObserver*
mutable std::mutex device_mtx_;
mtp::DeviceInfo device_info_;

View File

@@ -1,5 +1,6 @@
#include "App.hpp"
#import <Foundation/Foundation.h>
#import <AppKit/AppKit.h>
#include <stdexcept>
#include <cstdio>
#include <filesystem>
@@ -9,6 +10,7 @@
#include <thread>
#include <cstdlib>
#include <cstring>
#include <unordered_set>
namespace fs = std::filesystem;
namespace omniMTP {
@@ -51,25 +53,82 @@ static std::string fmt_eta(double s) {
char b[16]; snprintf(b,sizeof(b),"%dh%02dm",si/3600,(si%3600)/60); return b;
}
} // namespace omniMTP
// ─── Sleep/wake observer (must live outside the C++ namespace for ObjC) ────────
@interface OmniMTPWakeObserver : NSObject
@property (nonatomic, assign) omniMTP::App* app;
@end
@implementation OmniMTPWakeObserver
- (void)onWillSleep:(NSNotification*)notification {
(void)notification;
if (self.app) self.app->notify_system_sleep();
}
- (void)onDidWake:(NSNotification*)notification {
(void)notification;
if (self.app) self.app->notify_system_wake();
}
@end
namespace omniMTP {
// ─── App lifecycle ────────────────────────────────────────────────────────────
App::App() : download_path_(fs::path(std::getenv("HOME") ? std::getenv("HOME") : "/")) {}
App::~App() { shutdown(); }
void App::init() {
download_path_ = fs::path(std::getenv("HOME") ? std::getenv("HOME") : "/");
OmniMTPWakeObserver* observer = [OmniMTPWakeObserver new];
observer.app = this;
wake_observer_ = (__bridge_retained void*)observer;
NSNotificationCenter* nc = [[NSWorkspace sharedWorkspace] notificationCenter];
[nc addObserver:observer selector:@selector(onWillSleep:)
name:NSWorkspaceWillSleepNotification object:nil];
[nc addObserver:observer selector:@selector(onDidWake:)
name:NSWorkspaceDidWakeNotification object:nil];
monitor_thread_ = std::jthread([this](std::stop_token st) { device_monitor_loop(st); });
}
void App::shutdown() {
if (wake_observer_) {
OmniMTPWakeObserver* observer = (__bridge_transfer OmniMTPWakeObserver*)wake_observer_;
wake_observer_ = nullptr;
observer.app = nullptr;
[[[NSWorkspace sharedWorkspace] notificationCenter] removeObserver:observer];
}
monitor_thread_.request_stop();
engine_.reset();
if (connected_.load())
try { if (session_) session_->disconnect(); } catch (...) {}
if (session_)
try { session_->disconnect(true); } catch (...) {}
}
void App::notify_system_sleep() {
// Drop the session before USB powers down so we don't keep a zombie handle.
usb_recovery_pending_.store(true);
fast_reconnect_remaining_.store(0);
}
void App::notify_system_wake() {
// Fresh libusb context + aggressive rescan until the Switch reappears.
usb_recovery_pending_.store(true);
fast_reconnect_remaining_.store(120); // ~3060s of faster retries
set_status("status.usb_recovering", {}, 8.f);
}
// ─── Device monitor ───────────────────────────────────────────────────────────
void App::device_monitor_loop(std::stop_token st) {
while (!st.stop_requested()) {
if (usb_recovery_pending_.exchange(false)) {
force_usb_teardown("status.usb_recovering");
// Let Darwin finish re-enumerating USB after wake.
for (int i = 0; i < 6 && !st.stop_requested(); ++i)
std::this_thread::sleep_for(std::chrono::milliseconds(250));
continue;
}
if (!connected_.load()) {
try {
auto sess = std::make_unique<mtp::MTPSession>();
@@ -86,6 +145,7 @@ void App::device_monitor_loop(std::stop_token st) {
});
}
on_device_connected(std::move(di));
fast_reconnect_remaining_.store(0);
} catch (...) {}
} else {
bool transfer_active = false;
@@ -105,11 +165,36 @@ void App::device_monitor_loop(std::stop_token st) {
}
}
}
for (int i = 0; i < 8 && !st.stop_requested(); ++i)
int fast = fast_reconnect_remaining_.load();
int slices = (fast > 0) ? 2 : 8; // 500ms vs 2s
if (fast > 0)
fast_reconnect_remaining_.fetch_sub(1);
for (int i = 0; i < slices && !st.stop_requested(); ++i)
std::this_thread::sleep_for(std::chrono::milliseconds(250));
}
}
void App::force_usb_teardown(const std::string& status_key) {
{
std::lock_guard lock(device_mtx_);
if (engine_) { engine_->cancel_all(); engine_.reset(); }
ops_.reset();
if (session_) {
// force=true: skip MTP CloseSession; destroy libusb context entirely.
try { session_->disconnect(true); } catch (...) {}
session_.reset();
}
storages_.clear();
remote_entries_.clear();
remote_nav_stack_.clear();
}
connected_.store(false);
remote_needs_refresh_ = false;
if (!status_key.empty())
set_status(status_key, {}, 8.f);
}
void App::on_device_connected(mtp::DeviceInfo di) {
{
std::lock_guard lock(device_mtx_);
@@ -140,18 +225,7 @@ void App::on_device_connected(mtp::DeviceInfo di) {
}
void App::on_device_disconnected() {
{
std::lock_guard lock(device_mtx_);
if (engine_) { engine_->cancel_all(); engine_.reset(); }
ops_.reset();
if (session_) { try { session_->disconnect(); } catch (...) {} session_.reset(); }
storages_.clear();
remote_entries_.clear();
remote_nav_stack_.clear();
}
connected_.store(false);
remote_needs_refresh_ = false;
set_status("status.disconnected");
force_usb_teardown("status.disconnected");
}
// ─── Remote filesystem ────────────────────────────────────────────────────────
@@ -202,6 +276,49 @@ void App::start_upload(const fs::path& src, uint64_t sz) {
src.filename().string(), sz);
set_status("status.uploading", {src.filename().string()});
}
void App::upload_local_tree(const fs::path& dir, uint32_t parent_handle, uint32_t storage_id) {
if (!engine_ || !ops_) return;
const std::string name = dir.filename().string();
if (name.empty() || name == "." || name == "..") return;
uint32_t dir_handle = 0;
try {
std::lock_guard lock(device_mtx_);
if (!ops_) return;
dir_handle = ops_->create_directory(storage_id, parent_handle, name);
} catch (const std::exception& e) {
set_status("status.listing_error", {e.what()});
return;
} catch (...) {
set_status("status.listing_error", {"Failed to create folder: " + name});
return;
}
if (dir_handle == 0) {
set_status("status.listing_error", {"Failed to create folder: " + name});
return;
}
std::error_code ec;
for (fs::directory_iterator it(dir, ec), end; !ec && it != end; it.increment(ec)) {
const auto& entry = *it;
const std::string child_name = entry.path().filename().string();
if (child_name.empty() || child_name == "." || child_name == "..") continue;
// Skip macOS clutter that Switch MTP installers don't need.
if (child_name == ".DS_Store" || child_name.rfind("._", 0) == 0) continue;
std::error_code st_ec;
if (entry.is_directory(st_ec)) {
upload_local_tree(entry.path(), dir_handle, storage_id);
} else if (entry.is_regular_file(st_ec)) {
auto sz = entry.file_size(st_ec);
if (st_ec) continue;
engine_->enqueue_upload(entry.path().string(), dir_handle, storage_id,
child_name, sz);
}
}
}
void App::set_status(const std::string& key, std::vector<std::string> args, float dur) {
status_key_ = key;
status_args_ = std::move(args);
@@ -308,13 +425,8 @@ void App::process_finder_drops() {
drops.swap(finder_drops_);
}
for (auto& dropped : drops) {
fs::path p(dropped);
std::error_code ec;
if (fs::is_directory(p, ec)) continue;
if (!ec && connected_.load()) {
auto sz = fs::file_size(p, ec);
if (!ec) start_upload(p, sz);
}
if (connected_.load())
do_upload(dropped);
}
}
@@ -346,8 +458,17 @@ void App::set_active_storage(uint32_t id) {
}
void App::request_refresh_remote() { refresh_remote(); }
void App::do_upload(const std::string& src) {
if (!connected_.load() || !engine_ || remote_nav_stack_.empty()) return;
fs::path p(src);
std::error_code ec;
if (fs::is_directory(p, ec)) {
const auto& top = remote_nav_stack_.back();
set_status("status.uploading", {p.filename().string()});
upload_local_tree(p, top.handle, top.storage_id);
remote_needs_refresh_ = true;
return;
}
if (ec) return;
auto sz = fs::file_size(p, ec);
if (!ec) start_upload(p, sz);
}
@@ -395,13 +516,99 @@ void App::do_delete(uint32_t h) {
catch (...) {}
refresh_remote();
}
void App::do_create_folder(const std::string& name) {
if (remote_nav_stack_.empty()) return;
bool App::do_create_folder(const std::string& preferred_name,
uint32_t& out_handle, uint32_t& out_storage_id,
std::string& out_name) {
out_handle = 0;
out_storage_id = 0;
out_name.clear();
if (!connected_.load() || remote_nav_stack_.empty()) return false;
auto sanitize = [](std::string s) {
for (char& c : s) {
if (c == '/' || c == '\\' || c == ':' || c == 0) c = '_';
}
while (!s.empty() && (s.front() == ' ' || s.front() == '.'))
s.erase(s.begin());
while (!s.empty() && s.back() == ' ')
s.pop_back();
return s;
};
std::string base = sanitize(preferred_name);
if (base.empty()) base = "New Folder";
// Snapshot names currently visible so we can pick "New Folder 2", …
std::unordered_set<std::string> used;
{
std::lock_guard lock(device_mtx_);
for (const auto& e : remote_entries_)
used.insert(e.name);
}
std::string candidate = base;
if (used.count(candidate)) {
for (int i = 2; i < 10000; ++i) {
candidate = base + " " + std::to_string(i);
if (!used.count(candidate)) break;
}
}
const auto& top = remote_nav_stack_.back();
try { std::lock_guard lock(device_mtx_); if (ops_) ops_->create_directory(top.storage_id, top.handle, name); }
catch (...) {}
uint32_t handle = 0;
try {
std::lock_guard lock(device_mtx_);
if (!ops_) return false;
handle = ops_->create_directory(top.storage_id, top.handle, candidate);
} catch (const std::exception& e) {
set_status("status.listing_error", {e.what()});
return false;
} catch (...) {
set_status("status.listing_error", {"Failed to create folder"});
return false;
}
if (handle == 0) {
set_status("status.listing_error", {"Failed to create folder"});
return false;
}
out_handle = handle;
out_storage_id = top.storage_id;
out_name = candidate;
refresh_remote();
return true;
}
bool App::do_rename(uint32_t handle, const std::string& new_name) {
if (!connected_.load()) return false;
std::string clean = new_name;
for (char& c : clean) {
if (c == '/' || c == '\\' || c == ':' || c == 0) c = '_';
}
while (!clean.empty() && (clean.front() == ' ' || clean.front() == '.'))
clean.erase(clean.begin());
while (!clean.empty() && clean.back() == ' ')
clean.pop_back();
if (clean.empty()) return false;
try {
std::lock_guard lock(device_mtx_);
if (!ops_) return false;
ops_->rename_object(handle, clean);
} catch (const std::exception& e) {
set_status("status.listing_error", {e.what()});
return false;
} catch (...) {
set_status("status.listing_error", {"Rename failed"});
return false;
}
refresh_remote();
return true;
}
void App::reconnect() {
usb_recovery_pending_.store(true);
fast_reconnect_remaining_.store(60);
set_status("status.usb_recovering", {}, 8.f);
}
void App::reconnect() { on_device_disconnected(); }
} // namespace omniMTP

View File

@@ -211,6 +211,73 @@ static NSString* OmniMenuFormat(NSString* key, NSDictionary<NSString*, NSString*
return s;
}
static NSString* const kOmniReleasesLatestURL =
@"https://git.niklascfw.de/api/v1/repos/OmniNX/OmniMTP/releases/latest";
static NSString* const kOmniReleasesPageURL =
@"https://git.niklascfw.de/OmniNX/OmniMTP/releases";
static NSString* const kOmniSkippedUpdateKey = @"omni.skippedUpdateTag";
static NSArray<NSNumber*>* OmniParseVersionParts(NSString* version) {
if (![version isKindOfClass:[NSString class]] || version.length == 0)
return @[];
NSString* v = [version stringByTrimmingCharactersInSet:NSCharacterSet.whitespaceAndNewlineCharacterSet];
if ([v.lowercaseString hasPrefix:@"v"])
v = [v substringFromIndex:1];
NSString* core = [[v componentsSeparatedByString:@"-"] firstObject] ?: v;
NSMutableArray<NSNumber*>* parts = [NSMutableArray array];
for (NSString* p in [core componentsSeparatedByString:@"."]) {
NSInteger n = 0;
NSScanner* scanner = [NSScanner scannerWithString:p];
[scanner scanInteger:&n];
[parts addObject:@(n)];
}
return parts;
}
static NSComparisonResult OmniCompareVersions(NSString* a, NSString* b) {
NSArray<NSNumber*>* pa = OmniParseVersionParts(a);
NSArray<NSNumber*>* pb = OmniParseVersionParts(b);
NSUInteger n = MAX(pa.count, pb.count);
for (NSUInteger i = 0; i < n; ++i) {
NSInteger va = i < pa.count ? pa[i].integerValue : 0;
NSInteger vb = i < pb.count ? pb[i].integerValue : 0;
if (va < vb) return NSOrderedAscending;
if (va > vb) return NSOrderedDescending;
}
return NSOrderedSame;
}
static NSString* OmniCurrentAppVersion(void) {
NSString* v = [NSBundle.mainBundle objectForInfoDictionaryKey:@"CFBundleShortVersionString"];
return (v.length > 0) ? v : @"0.0.0";
}
static NSString* OmniPreferredDownloadURL(NSDictionary* release) {
id assets = release[@"assets"];
if ([assets isKindOfClass:[NSArray class]]) {
for (id asset in (NSArray*)assets) {
if (![asset isKindOfClass:[NSDictionary class]]) continue;
NSString* name = asset[@"name"];
NSString* url = asset[@"browser_download_url"];
if (![name isKindOfClass:[NSString class]] || ![url isKindOfClass:[NSString class]])
continue;
NSString* lower = name.lowercaseString;
if ([lower hasSuffix:@".zip"] || [lower hasSuffix:@".dmg"])
return url;
}
for (id asset in (NSArray*)assets) {
if (![asset isKindOfClass:[NSDictionary class]]) continue;
NSString* url = asset[@"browser_download_url"];
if ([url isKindOfClass:[NSString class]] && url.length)
return url;
}
}
NSString* html = release[@"html_url"];
if ([html isKindOfClass:[NSString class]] && html.length)
return html;
return kOmniReleasesPageURL;
}
// ─── Forward declarations ─────────────────────────────────────────────────────
@class OmniBridge;
@class OmniAppDelegate;
@@ -332,8 +399,34 @@ static NSString* OmniMenuFormat(NSString* key, NSDictionary<NSString*, NSString*
if (handle) _app->do_delete(handle.unsignedIntValue);
} else if ([action isEqualToString:@"create_folder"]) {
NSString* preferred = body[@"defaultName"];
std::string preferredName = (preferred && preferred.length > 0)
? preferred.UTF8String : "New Folder";
uint32_t handle = 0, storageId = 0;
std::string createdName;
bool ok = _app->do_create_folder(preferredName, handle, storageId, createdName);
if (!ok) {
[self sendNullResult:rid];
return;
}
NSDictionary* payload = @{
@"handle": @(handle),
@"storageId": @(storageId),
@"name": [NSString stringWithUTF8String:createdName.c_str()],
};
NSData* data = [NSJSONSerialization dataWithJSONObject:payload options:0 error:nil];
NSString* json = data ? [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding] : @"null";
[self sendRawResult:json responseId:rid];
return;
} else if ([action isEqualToString:@"rename_remote"]) {
NSNumber* handle = body[@"handle"];
NSString* name = body[@"name"];
if (name && name.length > 0) _app->do_create_folder(name.UTF8String);
bool ok = NO;
if (handle && name)
ok = _app->do_rename(handle.unsignedIntValue, name.UTF8String);
[self sendRawResult:(ok ? @"true" : @"false") responseId:rid];
return;
} else if ([action isEqualToString:@"reconnect"]) {
_app->reconnect();
@@ -343,7 +436,7 @@ static NSString* OmniMenuFormat(NSString* key, NSDictionary<NSString*, NSString*
dispatch_async(dispatch_get_main_queue(), ^{
NSOpenPanel* panel = [NSOpenPanel openPanel];
panel.canChooseFiles = YES;
panel.canChooseDirectories = NO;
panel.canChooseDirectories = YES;
panel.allowsMultipleSelection = YES;
[panel beginWithCompletionHandler:^(NSModalResponse r) {
if (r == NSModalResponseOK) {
@@ -805,9 +898,10 @@ static const CGFloat kTrafficLightInset = 72.0;
NSString* path = url.path;
if (!path) continue;
BOOL isDir = NO;
if ([fm fileExistsAtPath:path isDirectory:&isDir] && !isDir)
_app->do_upload(path.UTF8String);
handled = YES;
if ([fm fileExistsAtPath:path isDirectory:&isDir]) {
self->_app->do_upload(path.UTF8String);
handled = YES;
}
}
return handled;
}
@@ -821,6 +915,7 @@ static const CGFloat kTrafficLightInset = 72.0;
- (void)selectLanguage:(id)sender;
- (void)showAbout:(id)sender;
- (void)closeAboutPanel:(id)sender;
- (void)checkForUpdates:(id)sender;
- (void)applyLanguageToWebView;
@end
@@ -831,9 +926,11 @@ static const CGFloat kTrafficLightInset = 72.0;
OmniBridge* _bridge;
NSPanel* _aboutPanel;
NSMenuItem* _aboutItem;
NSMenuItem* _checkUpdatesItem;
NSMenuItem* _optionsItem;
NSMenuItem* _languageItem;
NSMenuItem* _quitItem;
BOOL _updateCheckInFlight;
}
- (instancetype)initWithApp:(omniMTP::App*)app {
@@ -855,6 +952,12 @@ static const CGFloat kTrafficLightInset = 72.0;
keyEquivalent:@""];
_aboutItem.target = self;
[appMenu addItem:_aboutItem];
_checkUpdatesItem = [[NSMenuItem alloc] initWithTitle:OmniMenuString(@"menu.check_updates")
action:@selector(checkForUpdates:)
keyEquivalent:@""];
_checkUpdatesItem.target = self;
[appMenu addItem:_checkUpdatesItem];
[appMenu addItem:[NSMenuItem separatorItem]];
// Options → Language
@@ -1020,6 +1123,163 @@ static const CGFloat kTrafficLightInset = 72.0;
_aboutPanel = nil;
}
- (void)checkForUpdates:(id)sender {
[self startUpdateCheckManual:(sender != nil)];
}
- (void)startUpdateCheckManual:(BOOL)manual {
if (_updateCheckInFlight) return;
_updateCheckInFlight = YES;
if (_checkUpdatesItem)
_checkUpdatesItem.enabled = NO;
NSURL* url = [NSURL URLWithString:kOmniReleasesLatestURL];
NSMutableURLRequest* req = [NSMutableURLRequest requestWithURL:url
cachePolicy:NSURLRequestReloadIgnoringLocalCacheData
timeoutInterval:12.0];
[req setValue:@"OmniMTP" forHTTPHeaderField:@"User-Agent"];
[req setValue:@"application/json" forHTTPHeaderField:@"Accept"];
__weak OmniAppDelegate* weakSelf = self;
NSURLSessionDataTask* task =
[[NSURLSession sharedSession] dataTaskWithRequest:req
completionHandler:^(NSData* data, NSURLResponse* response, NSError* error) {
dispatch_async(dispatch_get_main_queue(), ^{
OmniAppDelegate* selfStrong = weakSelf;
if (!selfStrong) return;
selfStrong->_updateCheckInFlight = NO;
if (selfStrong->_checkUpdatesItem)
selfStrong->_checkUpdatesItem.enabled = YES;
[selfStrong handleUpdateResponse:data
response:response
error:error
manual:manual];
});
}];
[task resume];
}
- (void)handleUpdateResponse:(NSData*)data
response:(NSURLResponse*)response
error:(NSError*)error
manual:(BOOL)manual {
if (error || !data) {
if (manual) {
NSAlert* alert = [[NSAlert alloc] init];
alert.messageText = OmniMenuString(@"update.check_failed_title");
alert.informativeText = error.localizedDescription.length
? error.localizedDescription
: OmniMenuString(@"update.check_failed_body");
[alert addButtonWithTitle:OmniMenuString(@"confirm.ok_btn")];
[alert beginSheetModalForWindow:_window completionHandler:nil];
}
return;
}
NSInteger status = 0;
if ([response isKindOfClass:[NSHTTPURLResponse class]])
status = ((NSHTTPURLResponse*)response).statusCode;
if (status == 404) {
// No releases published yet.
if (manual) [self showUpToDateAlert];
return;
}
if (status < 200 || status >= 300) {
if (manual) {
NSAlert* alert = [[NSAlert alloc] init];
alert.messageText = OmniMenuString(@"update.check_failed_title");
alert.informativeText = OmniMenuFormat(@"update.http_error", @{
@"code": [NSString stringWithFormat:@"%ld", (long)status]
});
[alert addButtonWithTitle:OmniMenuString(@"confirm.ok_btn")];
[alert beginSheetModalForWindow:_window completionHandler:nil];
}
return;
}
id json = [NSJSONSerialization JSONObjectWithData:data options:0 error:nil];
if (![json isKindOfClass:[NSDictionary class]]) {
if (manual) {
NSAlert* alert = [[NSAlert alloc] init];
alert.messageText = OmniMenuString(@"update.check_failed_title");
alert.informativeText = OmniMenuString(@"update.check_failed_body");
[alert addButtonWithTitle:OmniMenuString(@"confirm.ok_btn")];
[alert beginSheetModalForWindow:_window completionHandler:nil];
}
return;
}
NSDictionary* release = (NSDictionary*)json;
if ([release[@"draft"] boolValue] || [release[@"prerelease"] boolValue]) {
if (manual) [self showUpToDateAlert];
return;
}
NSString* tag = release[@"tag_name"];
if (![tag isKindOfClass:[NSString class]] || tag.length == 0)
tag = release[@"name"];
if (![tag isKindOfClass:[NSString class]] || tag.length == 0) {
if (manual) [self showUpToDateAlert];
return;
}
NSString* current = OmniCurrentAppVersion();
if (OmniCompareVersions(current, tag) != NSOrderedAscending) {
if (manual) [self showUpToDateAlert];
return;
}
if (!manual) {
NSString* skipped = [NSUserDefaults.standardUserDefaults stringForKey:kOmniSkippedUpdateKey];
if (skipped.length && OmniCompareVersions(skipped, tag) != NSOrderedAscending)
return;
}
NSString* downloadURL = OmniPreferredDownloadURL(release);
[self showUpdateAvailableForTag:tag current:current downloadURL:downloadURL];
}
- (void)showUpToDateAlert {
NSAlert* alert = [[NSAlert alloc] init];
alert.messageText = OmniMenuString(@"update.up_to_date_title");
alert.informativeText = OmniMenuFormat(@"update.up_to_date_body", @{
@"version": OmniCurrentAppVersion()
});
[alert addButtonWithTitle:OmniMenuString(@"confirm.ok_btn")];
if (_window)
[alert beginSheetModalForWindow:_window completionHandler:nil];
else
[alert runModal];
}
- (void)showUpdateAvailableForTag:(NSString*)tag
current:(NSString*)current
downloadURL:(NSString*)downloadURL {
NSAlert* alert = [[NSAlert alloc] init];
alert.messageText = OmniMenuString(@"update.available_title");
alert.informativeText = OmniMenuFormat(@"update.available_body", @{
@"latest": tag,
@"current": current
});
[alert addButtonWithTitle:OmniMenuString(@"update.download")];
[alert addButtonWithTitle:OmniMenuString(@"update.later")];
[alert addButtonWithTitle:OmniMenuString(@"update.skip")];
void (^handle)(NSModalResponse) = ^(NSModalResponse response) {
if (response == NSAlertFirstButtonReturn) {
NSURL* url = [NSURL URLWithString:downloadURL];
if (url) [NSWorkspace.sharedWorkspace openURL:url];
} else if (response == NSAlertThirdButtonReturn) {
[NSUserDefaults.standardUserDefaults setObject:tag forKey:kOmniSkippedUpdateKey];
}
};
if (_window)
[alert beginSheetModalForWindow:_window completionHandler:handle];
else
handle([alert runModal]);
}
- (BOOL)windowShouldClose:(NSWindow*)sender {
if (sender == _aboutPanel) {
[NSApp stopModal];
@@ -1194,6 +1454,12 @@ static const CGFloat kTrafficLightInset = 72.0;
[_webView loadHTMLString:fallback baseURL:nil];
NSLog(@"[OmniMTP] ERROR: webroot/index.html not found in bundle or source tree");
}
// Defer so the main window is visible before any update sheet appears.
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1.5 * NSEC_PER_SEC)),
dispatch_get_main_queue(), ^{
[self startUpdateCheckManual:NO];
});
}
- (BOOL)applicationShouldTerminateAfterLastWindowClosed:(NSApplication*)app {

View File

@@ -361,6 +361,28 @@ body {
align-items: center;
gap: 7px;
overflow: hidden;
min-width: 0;
flex: 1;
}
.fname-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.fname-edit {
flex: 1;
min-width: 80px;
margin: 0;
padding: 1px 6px;
border: 1px solid var(--accent);
border-radius: 4px;
background: #1e1e22;
color: var(--label);
font: inherit;
outline: none;
box-shadow: 0 0 0 2px rgba(10,132,255,0.25);
user-select: text !important;
-webkit-user-select: text !important;
}
.dot {
width: 7px;

View File

@@ -127,6 +127,70 @@ async function confirmDialog(message) {
return result === true || result === 'true';
}
let _pendingRenameHandle = null;
let _inlineRenameActive = false;
async function createFolderInline() {
if (!S.connected || _inlineRenameActive) return;
const result = await call('create_folder', {
defaultName: t('prompt.new_folder_default'),
});
if (!result || result.handle == null) return;
_pendingRenameHandle = String(result.handle);
_remoteFingerprint = '';
renderRemoteFiles();
}
function startInlineRename(handleKey) {
const key = String(handleKey);
const row = document.querySelector(`#remote-files .frow[data-key="${CSS.escape(key)}"]`);
if (!row) {
_pendingRenameHandle = key;
return;
}
if (row.querySelector('.fname-edit')) return;
_pendingRenameHandle = null;
const textEl = row.querySelector('.fname-text');
if (!textEl) return;
const oldName = textEl.textContent;
const className = textEl.className;
const input = document.createElement('input');
input.type = 'text';
input.className = 'fname-edit';
input.value = oldName;
input.setAttribute('spellcheck', 'false');
textEl.replaceWith(input);
_inlineRenameActive = true;
input.focus();
input.select();
let finished = false;
const finish = async (commit) => {
if (finished) return;
finished = true;
_inlineRenameActive = false;
const newName = input.value.trim();
if (commit && newName && newName !== oldName) {
await call('rename_remote', { handle: Number(key), name: newName });
}
_remoteFingerprint = '';
renderRemoteFiles();
};
input.addEventListener('keydown', e => {
e.stopPropagation();
if (e.key === 'Enter') { e.preventDefault(); finish(true); }
if (e.key === 'Escape') { e.preventDefault(); finish(false); }
});
input.addEventListener('blur', () => finish(true));
input.addEventListener('mousedown', e => e.stopPropagation());
input.addEventListener('click', e => e.stopPropagation());
input.addEventListener('dblclick', e => e.stopPropagation());
}
// ── State ─────────────────────────────────────────────────────────────────────
const S = {
connected: false, deviceName: '',
@@ -251,7 +315,7 @@ function buildFileRows(files, selSet, idKey) {
return `<tr class="frow${sel}" data-idx="${i}" data-key="${esc(key)}" data-is-dir="${f.isDir ? '1' : '0'}">
<td class="col-name"><div class="fname-cell">
<span class="dot ${cc}"></span>
<span class="${cc}">${esc(f.name)}</span>
<span class="fname-text ${cc}">${esc(f.name)}</span>
</div></td>
<td class="col-size">${f.isDir ? '' : esc(f.sizeStr)}</td>
<td class="col-date">${esc(f.date)}</td>
@@ -327,6 +391,9 @@ function renderRemoteFiles() {
const el = document.getElementById('remote-files');
const empty = document.getElementById('remote-empty-state');
// Don't clobber an in-progress inline rename.
if (_inlineRenameActive) return;
if (!S.connected) {
if (empty) empty.style.display = '';
el.querySelectorAll('table,.loading,.empty-state:not(#remote-empty-state)')
@@ -360,6 +427,11 @@ function renderRemoteFiles() {
} else {
applySelectionToDOM(el, S.selRemote);
}
if (_pendingRenameHandle) {
const handle = _pendingRenameHandle;
requestAnimationFrame(() => startInlineRename(handle));
}
}
function attachRemoteEvents(el) {
@@ -443,6 +515,7 @@ function renderRemoteToolbar() {
crumb.querySelectorAll('.crumb-link').forEach(el =>
el.addEventListener('click', () => call('navigate_remote_to', {index: +el.dataset.idx})));
document.getElementById('btn-remote-up').disabled = stack.length <= 1;
document.getElementById('btn-new-folder').disabled = !S.connected;
}
function renderDeviceBar() {
@@ -539,25 +612,53 @@ function renderStatusBar() {
}
let _ctxFile = null;
let _ctxMode = 'file'; // 'file' | 'blank'
function showRemoteCtxMenu(file, e) {
_ctxFile = file;
if (!S.selRemote.has(String(file.handle))) {
S.selRemote.clear();
S.selRemote.add(String(file.handle));
applySelectionToDOM(document.getElementById('remote-files'), S.selRemote);
}
function positionCtxMenu(e) {
const m = document.getElementById('ctx-menu');
m.style.left = e.clientX + 'px';
m.style.top = e.clientY + 'px';
m.classList.remove('hidden');
}
function showRemoteCtxMenu(file, e) {
_ctxFile = file;
_ctxMode = 'file';
if (!S.selRemote.has(String(file.handle))) {
S.selRemote.clear();
S.selRemote.add(String(file.handle));
applySelectionToDOM(document.getElementById('remote-files'), S.selRemote);
}
document.getElementById('ctx-new-folder').style.display = '';
document.getElementById('ctx-sep-delete').style.display = '';
document.getElementById('ctx-delete').style.display = '';
positionCtxMenu(e);
}
function showBlankCtxMenu(e) {
if (!S.connected) return;
_ctxFile = null;
_ctxMode = 'blank';
document.getElementById('ctx-new-folder').style.display = '';
document.getElementById('ctx-sep-delete').style.display = 'none';
document.getElementById('ctx-delete').style.display = 'none';
positionCtxMenu(e);
}
document.addEventListener('click', e => {
if (e.target.closest('#ctx-menu')) return;
document.getElementById('ctx-menu').classList.add('hidden');
});
document.addEventListener('contextmenu', e => { if (!e.target.closest('.frow')) { e.preventDefault(); document.getElementById('ctx-menu').classList.add('hidden'); } });
document.addEventListener('contextmenu', e => {
if (e.target.closest('.frow')) return;
if (e.target.closest('#remote-files') && S.connected) {
e.preventDefault();
showBlankCtxMenu(e);
return;
}
e.preventDefault();
document.getElementById('ctx-menu').classList.add('hidden');
});
window.omniClearSelection = function() {
const sel = window.getSelection();
@@ -588,6 +689,7 @@ document.addEventListener('selectstart', e => {
document.addEventListener('mousedown', e => {
if (e.button !== 0) return;
if (e.target.closest('.fname-edit')) return;
const row = e.target.closest('#remote-files .frow');
if (!row) return;
e.preventDefault();
@@ -605,6 +707,7 @@ document.addEventListener('DOMContentLoaded', async () => {
document.getElementById('btn-remote-up') .addEventListener('click', () => call('navigate_remote_up'));
document.getElementById('btn-remote-refresh').addEventListener('click', () => call('refresh_remote'));
document.getElementById('btn-new-folder') .addEventListener('click', () => createFolderInline());
document.getElementById('btn-scan') .addEventListener('click', () => call('reconnect'));
document.getElementById('btn-open-picker') .addEventListener('click', () => call('open_file_picker'));
document.getElementById('btn-cancel-all') .addEventListener('click', () => call('cancel_all'));
@@ -613,12 +716,15 @@ document.addEventListener('DOMContentLoaded', async () => {
const bindCtx = (id, fn) => {
document.getElementById(id).addEventListener('click', e => {
e.stopPropagation();
document.getElementById('ctx-menu').classList.add('hidden');
fn();
});
};
bindCtx('ctx-new-folder', () => createFolderInline());
bindCtx('ctx-delete', async () => {
if (!_ctxFile) return;
if (!_ctxFile || _ctxMode !== 'file') return;
const multi = S.selRemote.size > 1 && S.selRemote.has(String(_ctxFile.handle));
const message = multi
? t('confirm.delete_items', { n: S.selRemote.size })

View File

@@ -33,6 +33,9 @@
</div>
<select id="storage-sel" class="storage-select"></select>
<div id="remote-breadcrumb" class="breadcrumb"></div>
<button class="nav-btn icon-only" id="btn-new-folder" data-i18n-title="btn.new_folder" title="New Folder" disabled>
<svg viewBox="0 0 16 16"><path d="M2 3.5A1.5 1.5 0 0 1 3.5 2H7l1.5 1.5H12.5A1.5 1.5 0 0 1 14 5v7.5a1.5 1.5 0 0 1-1.5 1.5h-9A1.5 1.5 0 0 1 2 12.5v-9z" fill="none" stroke="currentColor" stroke-width="1.25"/><path d="M8 6.5v5M5.5 9h5" stroke="currentColor" stroke-width="1.25" stroke-linecap="round"/></svg>
</button>
<button class="nav-btn icon-only" id="btn-remote-refresh" data-i18n-title="btn.refresh" title="Refresh">
<svg viewBox="0 0 16 16"><path d="M13.5 8A5.5 5.5 0 1 1 8 2.5V1l3 2.5L8 6V4.5A3.5 3.5 0 1 0 11.5 8h2z" fill="currentColor"/></svg>
</button>
@@ -69,6 +72,8 @@
<!-- Context menu -->
<div id="ctx-menu" class="ctx-menu hidden">
<div class="ctx-item" id="ctx-new-folder" data-i18n="ctx.new_folder">New Folder</div>
<div class="ctx-item ctx-sep" id="ctx-sep-delete"></div>
<div class="ctx-item danger" id="ctx-delete" data-i18n="ctx.delete">Delete…</div>
</div>

View File

@@ -46,5 +46,21 @@
"menu.about": "Über OmniMTP",
"about.tagline": "Nintendo Switch MTP-Client für macOS",
"about.version": "Version {version}",
"about.copyright": "Copyright © 2026 NiklasCFW"
"about.copyright": "Copyright © 2026 NiklasCFW",
"status.usb_recovering": "USB wird nach dem Ruhezustand neu verbunden…",
"menu.check_updates": "Nach Updates suchen…",
"update.available_title": "Update verfügbar",
"update.available_body": "OmniMTP {latest} ist verfügbar.\nDu hast {current}.",
"update.download": "Herunterladen",
"update.later": "Später",
"update.skip": "Diese Version überspringen",
"update.up_to_date_title": "Du bist auf dem neuesten Stand",
"update.up_to_date_body": "OmniMTP {version} ist die aktuelle Version.",
"update.check_failed_title": "Update-Prüfung fehlgeschlagen",
"update.check_failed_body": "Der Update-Server konnte nicht erreicht werden.",
"update.http_error": "Server antwortete mit HTTP {code}.",
"btn.new_folder": "Neuer Ordner",
"ctx.new_folder": "Neuer Ordner",
"prompt.new_folder": "Ordnername:",
"prompt.new_folder_default": "Neuer Ordner"
}

View File

@@ -46,5 +46,21 @@
"menu.about": "About OmniMTP",
"about.tagline": "Nintendo Switch MTP Client for macOS",
"about.version": "Version {version}",
"about.copyright": "Copyright © 2026 NiklasCFW"
"about.copyright": "Copyright © 2026 NiklasCFW",
"status.usb_recovering": "Reconnecting USB after sleep…",
"menu.check_updates": "Check for Updates…",
"update.available_title": "Update Available",
"update.available_body": "OmniMTP {latest} is available.\nYou have {current}.",
"update.download": "Download",
"update.later": "Later",
"update.skip": "Skip This Version",
"update.up_to_date_title": "Youre Up to Date",
"update.up_to_date_body": "OmniMTP {version} is the latest release.",
"update.check_failed_title": "Update Check Failed",
"update.check_failed_body": "Could not reach the update server.",
"update.http_error": "Server returned HTTP {code}.",
"btn.new_folder": "New Folder",
"ctx.new_folder": "New Folder",
"prompt.new_folder": "Folder name:",
"prompt.new_folder_default": "New Folder"
}

View File

@@ -46,5 +46,21 @@
"menu.about": "Acerca de OmniMTP",
"about.tagline": "Cliente MTP de Nintendo Switch para macOS",
"about.version": "Versión {version}",
"about.copyright": "Copyright © 2026 NiklasCFW"
"about.copyright": "Copyright © 2026 NiklasCFW",
"status.usb_recovering": "Reconectando USB tras la suspensión…",
"menu.check_updates": "Buscar actualizaciones…",
"update.available_title": "Actualización disponible",
"update.available_body": "OmniMTP {latest} está disponible.\nTienes {current}.",
"update.download": "Descargar",
"update.later": "Más tarde",
"update.skip": "Omitir esta versión",
"update.up_to_date_title": "Estás al día",
"update.up_to_date_body": "OmniMTP {version} es la última versión.",
"update.check_failed_title": "Error al buscar actualizaciones",
"update.check_failed_body": "No se pudo contactar con el servidor de actualizaciones.",
"update.http_error": "El servidor respondió HTTP {code}.",
"btn.new_folder": "Nueva carpeta",
"ctx.new_folder": "Nueva carpeta",
"prompt.new_folder": "Nombre de la carpeta:",
"prompt.new_folder_default": "Nueva carpeta"
}

View File

@@ -46,5 +46,21 @@
"menu.about": "À propos dOmniMTP",
"about.tagline": "Client MTP Nintendo Switch pour macOS",
"about.version": "Version {version}",
"about.copyright": "Copyright © 2026 NiklasCFW"
"about.copyright": "Copyright © 2026 NiklasCFW",
"status.usb_recovering": "Reconnexion USB après veille…",
"menu.check_updates": "Rechercher des mises à jour…",
"update.available_title": "Mise à jour disponible",
"update.available_body": "OmniMTP {latest} est disponible.\nVous avez {current}.",
"update.download": "Télécharger",
"update.later": "Plus tard",
"update.skip": "Ignorer cette version",
"update.up_to_date_title": "À jour",
"update.up_to_date_body": "OmniMTP {version} est la dernière version.",
"update.check_failed_title": "Échec de la vérification",
"update.check_failed_body": "Impossible de joindre le serveur de mises à jour.",
"update.http_error": "Le serveur a renvoyé HTTP {code}.",
"btn.new_folder": "Nouveau dossier",
"ctx.new_folder": "Nouveau dossier",
"prompt.new_folder": "Nom du dossier :",
"prompt.new_folder_default": "Nouveau dossier"
}

View File

@@ -46,5 +46,21 @@
"menu.about": "Informazioni su OmniMTP",
"about.tagline": "Client MTP Nintendo Switch per macOS",
"about.version": "Versione {version}",
"about.copyright": "Copyright © 2026 NiklasCFW"
"about.copyright": "Copyright © 2026 NiklasCFW",
"status.usb_recovering": "Riconnessione USB dopo lo standby…",
"menu.check_updates": "Verifica aggiornamenti…",
"update.available_title": "Aggiornamento disponibile",
"update.available_body": "OmniMTP {latest} è disponibile.\nHai {current}.",
"update.download": "Scarica",
"update.later": "Dopo",
"update.skip": "Salta questa versione",
"update.up_to_date_title": "Sei aggiornato",
"update.up_to_date_body": "OmniMTP {version} è lultima versione.",
"update.check_failed_title": "Verifica aggiornamenti non riuscita",
"update.check_failed_body": "Impossibile raggiungere il server degli aggiornamenti.",
"update.http_error": "Il server ha risposto HTTP {code}.",
"btn.new_folder": "Nuova cartella",
"ctx.new_folder": "Nuova cartella",
"prompt.new_folder": "Nome cartella:",
"prompt.new_folder_default": "Nuova cartella"
}

View File

@@ -46,5 +46,21 @@
"menu.about": "OmniMTPについて",
"about.tagline": "macOS向け Nintendo Switch MTPクライアント",
"about.version": "バージョン {version}",
"about.copyright": "Copyright © 2026 NiklasCFW"
"about.copyright": "Copyright © 2026 NiklasCFW",
"status.usb_recovering": "スリープ後、USBを再接続しています…",
"menu.check_updates": "アップデートを確認…",
"update.available_title": "アップデートがあります",
"update.available_body": "OmniMTP {latest} が利用可能です。\n現在のバージョンは {current} です。",
"update.download": "ダウンロード",
"update.later": "後で",
"update.skip": "このバージョンをスキップ",
"update.up_to_date_title": "最新です",
"update.up_to_date_body": "OmniMTP {version} が最新リリースです。",
"update.check_failed_title": "アップデート確認に失敗",
"update.check_failed_body": "アップデートサーバーに接続できませんでした。",
"update.http_error": "サーバーが HTTP {code} を返しました。",
"btn.new_folder": "新しいフォルダ",
"ctx.new_folder": "新しいフォルダ",
"prompt.new_folder": "フォルダ名:",
"prompt.new_folder_default": "新しいフォルダ"
}

View File

@@ -46,5 +46,21 @@
"menu.about": "Over OmniMTP",
"about.tagline": "Nintendo Switch MTP-client voor macOS",
"about.version": "Versie {version}",
"about.copyright": "Copyright © 2026 NiklasCFW"
"about.copyright": "Copyright © 2026 NiklasCFW",
"status.usb_recovering": "USB opnieuw verbinden na slaapstand…",
"menu.check_updates": "Controleren op updates…",
"update.available_title": "Update beschikbaar",
"update.available_body": "OmniMTP {latest} is beschikbaar.\nJe hebt {current}.",
"update.download": "Downloaden",
"update.later": "Later",
"update.skip": "Deze versie overslaan",
"update.up_to_date_title": "Je bent up-to-date",
"update.up_to_date_body": "OmniMTP {version} is de nieuwste release.",
"update.check_failed_title": "Updatecontrole mislukt",
"update.check_failed_body": "Kon de updateserver niet bereiken.",
"update.http_error": "Server gaf HTTP {code} terug.",
"btn.new_folder": "Nieuwe map",
"ctx.new_folder": "Nieuwe map",
"prompt.new_folder": "Mapnaam:",
"prompt.new_folder_default": "Nieuwe map"
}

View File

@@ -46,5 +46,21 @@
"menu.about": "Sobre o OmniMTP",
"about.tagline": "Cliente MTP Nintendo Switch para macOS",
"about.version": "Versão {version}",
"about.copyright": "Copyright © 2026 NiklasCFW"
"about.copyright": "Copyright © 2026 NiklasCFW",
"status.usb_recovering": "Reconectando USB após a suspensão…",
"menu.check_updates": "Procurar atualizações…",
"update.available_title": "Atualização disponível",
"update.available_body": "OmniMTP {latest} está disponível.\nVocê tem {current}.",
"update.download": "Baixar",
"update.later": "Mais tarde",
"update.skip": "Ignorar esta versão",
"update.up_to_date_title": "Você está atualizado",
"update.up_to_date_body": "OmniMTP {version} é a versão mais recente.",
"update.check_failed_title": "Falha ao verificar atualizações",
"update.check_failed_body": "Não foi possível contactar o servidor de atualizações.",
"update.http_error": "O servidor retornou HTTP {code}.",
"btn.new_folder": "Nova pasta",
"ctx.new_folder": "Nova pasta",
"prompt.new_folder": "Nome da pasta:",
"prompt.new_folder_default": "Nova pasta"
}

View File

@@ -46,5 +46,21 @@
"menu.about": "О программе OmniMTP",
"about.tagline": "MTP-клиент Nintendo Switch для macOS",
"about.version": "Версия {version}",
"about.copyright": "Copyright © 2026 NiklasCFW"
"about.copyright": "Copyright © 2026 NiklasCFW",
"status.usb_recovering": "Повторное подключение USB после сна…",
"menu.check_updates": "Проверить обновления…",
"update.available_title": "Доступно обновление",
"update.available_body": "Доступна OmniMTP {latest}.\nУ вас {current}.",
"update.download": "Скачать",
"update.later": "Позже",
"update.skip": "Пропустить эту версию",
"update.up_to_date_title": "У вас актуальная версия",
"update.up_to_date_body": "OmniMTP {version} — последний выпуск.",
"update.check_failed_title": "Не удалось проверить обновления",
"update.check_failed_body": "Не удалось связаться с сервером обновлений.",
"update.http_error": "Сервер вернул HTTP {code}.",
"btn.new_folder": "Новая папка",
"ctx.new_folder": "Новая папка",
"prompt.new_folder": "Имя папки:",
"prompt.new_folder_default": "Новая папка"
}

View File

@@ -46,5 +46,21 @@
"menu.about": "关于 OmniMTP",
"about.tagline": "适用于 macOS 的 Nintendo Switch MTP 客户端",
"about.version": "版本 {version}",
"about.copyright": "Copyright © 2026 NiklasCFW"
"about.copyright": "Copyright © 2026 NiklasCFW",
"status.usb_recovering": "休眠后正在重新连接 USB…",
"menu.check_updates": "检查更新…",
"update.available_title": "有可用更新",
"update.available_body": "OmniMTP {latest} 可用。\n当前版本为 {current}。",
"update.download": "下载",
"update.later": "稍后",
"update.skip": "跳过此版本",
"update.up_to_date_title": "已是最新",
"update.up_to_date_body": "OmniMTP {version} 已是最新版本。",
"update.check_failed_title": "检查更新失败",
"update.check_failed_body": "无法连接更新服务器。",
"update.http_error": "服务器返回 HTTP {code}。",
"btn.new_folder": "新建文件夹",
"ctx.new_folder": "新建文件夹",
"prompt.new_folder": "文件夹名称:",
"prompt.new_folder_default": "新建文件夹"
}