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.
This commit is contained in:
2026-07-30 15:48:04 +02:00
parent a69a72cf81
commit 5e3df0085c
14 changed files with 143 additions and 36 deletions

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

@@ -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_;

View File

@@ -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); // ~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 +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

View File

@@ -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…"
}

View File

@@ -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…"
}

View File

@@ -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…"
}

View File

@@ -46,5 +46,6 @@
"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…"
}

View File

@@ -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…"
}

View File

@@ -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を再接続しています…"
}

View File

@@ -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…"
}

View File

@@ -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…"
}

View File

@@ -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 после сна…"
}

View File

@@ -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…"
}