Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bf31dbc031 | |||
| 68353cbc56 | |||
| 86fa763eda | |||
| 3d66bfa666 | |||
| 5e3df0085c |
@@ -1,5 +1,5 @@
|
||||
cmake_minimum_required(VERSION 3.25)
|
||||
project(OmniMTP VERSION 1.0.0
|
||||
project(OmniMTP VERSION 1.0.1
|
||||
DESCRIPTION "Nintendo Switch MTP Client for macOS"
|
||||
LANGUAGES CXX OBJCXX OBJC C
|
||||
)
|
||||
|
||||
@@ -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 ──────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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(); }
|
||||
|
||||
|
||||
@@ -61,6 +61,10 @@ public:
|
||||
void do_create_folder(const std::string& 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.
|
||||
@@ -86,6 +90,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 +107,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_;
|
||||
|
||||
|
||||
109
src/ui/App.mm
109
src/ui/App.mm
@@ -1,5 +1,6 @@
|
||||
#include "App.hpp"
|
||||
#import <Foundation/Foundation.h>
|
||||
#import <AppKit/AppKit.h>
|
||||
#include <stdexcept>
|
||||
#include <cstdio>
|
||||
#include <filesystem>
|
||||
@@ -51,25 +52,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); // ~30–60s 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 +144,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 +164,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 +224,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 ────────────────────────────────────────────────────────
|
||||
@@ -402,6 +475,10 @@ void App::do_create_folder(const std::string& name) {
|
||||
catch (...) {}
|
||||
refresh_remote();
|
||||
}
|
||||
void App::reconnect() { on_device_disconnected(); }
|
||||
void App::reconnect() {
|
||||
usb_recovery_pending_.store(true);
|
||||
fast_reconnect_remaining_.store(60);
|
||||
set_status("status.usb_recovering", {}, 8.f);
|
||||
}
|
||||
|
||||
} // namespace omniMTP
|
||||
|
||||
239
src/ui/WebUI.mm
239
src/ui/WebUI.mm
@@ -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;
|
||||
@@ -821,6 +888,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 +899,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 +925,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 +1096,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 +1427,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 {
|
||||
|
||||
@@ -46,5 +46,17 @@
|
||||
"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}."
|
||||
}
|
||||
|
||||
@@ -46,5 +46,17 @@
|
||||
"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": "You’re 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}."
|
||||
}
|
||||
|
||||
@@ -46,5 +46,17 @@
|
||||
"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}."
|
||||
}
|
||||
|
||||
@@ -46,5 +46,17 @@
|
||||
"menu.about": "À propos d’OmniMTP",
|
||||
"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}."
|
||||
}
|
||||
|
||||
@@ -46,5 +46,17 @@
|
||||
"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} è l’ultima 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}."
|
||||
}
|
||||
|
||||
@@ -46,5 +46,17 @@
|
||||
"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} を返しました。"
|
||||
}
|
||||
|
||||
@@ -46,5 +46,17 @@
|
||||
"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."
|
||||
}
|
||||
|
||||
@@ -46,5 +46,17 @@
|
||||
"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}."
|
||||
}
|
||||
|
||||
@@ -46,5 +46,17 @@
|
||||
"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}."
|
||||
}
|
||||
|
||||
@@ -46,5 +46,17 @@
|
||||
"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}。"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user