From 5e3df0085c4b6e0ca4def983c5403e8024d722fd Mon Sep 17 00:00:00 2001 From: niklascfw Date: Thu, 30 Jul 2026 15:48:04 +0200 Subject: [PATCH] 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. --- src/mtp/MTPSession.cpp | 27 +++++--- src/mtp/MTPSession.hpp | 3 +- src/ui/App.hpp | 10 +++ src/ui/App.mm | 109 ++++++++++++++++++++++++++----- src/ui/webroot/lang/de.json | 3 +- src/ui/webroot/lang/en.json | 3 +- src/ui/webroot/lang/es.json | 3 +- src/ui/webroot/lang/fr.json | 3 +- src/ui/webroot/lang/it.json | 3 +- src/ui/webroot/lang/ja.json | 3 +- src/ui/webroot/lang/nl.json | 3 +- src/ui/webroot/lang/pt.json | 3 +- src/ui/webroot/lang/ru.json | 3 +- src/ui/webroot/lang/zh-Hans.json | 3 +- 14 files changed, 143 insertions(+), 36 deletions(-) diff --git a/src/mtp/MTPSession.cpp b/src/mtp/MTPSession.cpp index c130549..a69e7f8 100644 --- a/src/mtp/MTPSession.cpp +++ b/src/mtp/MTPSession.cpp @@ -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 ────────────────────────────────────────────────────────────── diff --git a/src/mtp/MTPSession.hpp b/src/mtp/MTPSession.hpp index c1ab9f5..f6ea6cb 100644 --- a/src/mtp/MTPSession.hpp +++ b/src/mtp/MTPSession.hpp @@ -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(); } diff --git a/src/ui/App.hpp b/src/ui/App.hpp index 4ba5750..de86d0a 100644 --- a/src/ui/App.hpp +++ b/src/ui/App.hpp @@ -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 engine_; std::atomic connected_{false}; + std::atomic usb_recovery_pending_{false}; + // After wake/sleep, poll USB more often for a while (monitor iterations). + std::atomic fast_reconnect_remaining_{0}; std::jthread monitor_thread_; + void* wake_observer_ = nullptr; // OmniMTPWakeObserver* mutable std::mutex device_mtx_; mtp::DeviceInfo device_info_; diff --git a/src/ui/App.mm b/src/ui/App.mm index 35a95b6..0882985 100644 --- a/src/ui/App.mm +++ b/src/ui/App.mm @@ -1,5 +1,6 @@ #include "App.hpp" #import +#import #include #include #include @@ -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(); @@ -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 diff --git a/src/ui/webroot/lang/de.json b/src/ui/webroot/lang/de.json index e3fd1a4..d328a99 100644 --- a/src/ui/webroot/lang/de.json +++ b/src/ui/webroot/lang/de.json @@ -46,5 +46,6 @@ "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…" } diff --git a/src/ui/webroot/lang/en.json b/src/ui/webroot/lang/en.json index d2308c8..651ec19 100644 --- a/src/ui/webroot/lang/en.json +++ b/src/ui/webroot/lang/en.json @@ -46,5 +46,6 @@ "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…" } diff --git a/src/ui/webroot/lang/es.json b/src/ui/webroot/lang/es.json index 8b4db96..22d2a9a 100644 --- a/src/ui/webroot/lang/es.json +++ b/src/ui/webroot/lang/es.json @@ -46,5 +46,6 @@ "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…" } diff --git a/src/ui/webroot/lang/fr.json b/src/ui/webroot/lang/fr.json index b57a98e..27596fe 100644 --- a/src/ui/webroot/lang/fr.json +++ b/src/ui/webroot/lang/fr.json @@ -46,5 +46,6 @@ "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…" } diff --git a/src/ui/webroot/lang/it.json b/src/ui/webroot/lang/it.json index d82e677..8d7f920 100644 --- a/src/ui/webroot/lang/it.json +++ b/src/ui/webroot/lang/it.json @@ -46,5 +46,6 @@ "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…" } diff --git a/src/ui/webroot/lang/ja.json b/src/ui/webroot/lang/ja.json index b8177ce..51c80aa 100644 --- a/src/ui/webroot/lang/ja.json +++ b/src/ui/webroot/lang/ja.json @@ -46,5 +46,6 @@ "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を再接続しています…" } diff --git a/src/ui/webroot/lang/nl.json b/src/ui/webroot/lang/nl.json index b34eee5..839efda 100644 --- a/src/ui/webroot/lang/nl.json +++ b/src/ui/webroot/lang/nl.json @@ -46,5 +46,6 @@ "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…" } diff --git a/src/ui/webroot/lang/pt.json b/src/ui/webroot/lang/pt.json index 0970aee..34c5331 100644 --- a/src/ui/webroot/lang/pt.json +++ b/src/ui/webroot/lang/pt.json @@ -46,5 +46,6 @@ "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…" } diff --git a/src/ui/webroot/lang/ru.json b/src/ui/webroot/lang/ru.json index 5184344..54f7459 100644 --- a/src/ui/webroot/lang/ru.json +++ b/src/ui/webroot/lang/ru.json @@ -46,5 +46,6 @@ "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 после сна…" } diff --git a/src/ui/webroot/lang/zh-Hans.json b/src/ui/webroot/lang/zh-Hans.json index 43e6182..f32b639 100644 --- a/src/ui/webroot/lang/zh-Hans.json +++ b/src/ui/webroot/lang/zh-Hans.json @@ -46,5 +46,6 @@ "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…" }