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:
@@ -90,8 +90,11 @@ MTPSession::MTPSession() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
MTPSession::~MTPSession() {
|
MTPSession::~MTPSession() {
|
||||||
if (connected_) disconnect();
|
try { disconnect(true); } catch (...) {}
|
||||||
if (ctx_) libusb_exit(ctx_);
|
if (ctx_) {
|
||||||
|
libusb_exit(ctx_);
|
||||||
|
ctx_ = nullptr;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Device detection ─────────────────────────────────────────────────────────
|
// ─── Device detection ─────────────────────────────────────────────────────────
|
||||||
@@ -293,20 +296,26 @@ DeviceInfo MTPSession::connect() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ─── Disconnect ───────────────────────────────────────────────────────────────
|
// ─── Disconnect ───────────────────────────────────────────────────────────────
|
||||||
void MTPSession::disconnect() {
|
void MTPSession::disconnect(bool force) {
|
||||||
if (!connected_.load()) return;
|
const bool was_connected = connected_.exchange(false);
|
||||||
connected_.store(false);
|
if (!was_connected && !handle_) return;
|
||||||
|
|
||||||
try {
|
// After host sleep the bulk pipe is often dead — CloseSession would hang
|
||||||
send_command(OpCode::CloseSession);
|
// on a 5s timeout and leave Darwin USB in a worse state.
|
||||||
receive_response(); // best-effort; ignore result
|
if (!force && handle_) {
|
||||||
} catch (...) {}
|
try {
|
||||||
|
send_command(OpCode::CloseSession);
|
||||||
|
receive_response(); // best-effort; ignore result
|
||||||
|
} catch (...) {}
|
||||||
|
}
|
||||||
|
|
||||||
if (handle_) {
|
if (handle_) {
|
||||||
|
// Best-effort; after suspend these calls frequently return errors.
|
||||||
libusb_release_interface(handle_, ep_.interface_num);
|
libusb_release_interface(handle_, ep_.interface_num);
|
||||||
libusb_close(handle_);
|
libusb_close(handle_);
|
||||||
handle_ = nullptr;
|
handle_ = nullptr;
|
||||||
}
|
}
|
||||||
|
transaction_id_ = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Raw USB I/O ──────────────────────────────────────────────────────────────
|
// ─── Raw USB I/O ──────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -40,7 +40,8 @@ public:
|
|||||||
DeviceInfo connect();
|
DeviceInfo connect();
|
||||||
|
|
||||||
// Close MTP session and release USB resources.
|
// 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(); }
|
bool is_connected() const { return connected_.load(); }
|
||||||
|
|
||||||
|
|||||||
@@ -61,6 +61,10 @@ public:
|
|||||||
void do_create_folder(const std::string& name);
|
void do_create_folder(const std::string& name);
|
||||||
void reconnect();
|
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(); }
|
bool connected() const { return connected_.load(); }
|
||||||
|
|
||||||
// Drop-zone rect (CSS pixels, origin top-left) updated by the web UI.
|
// 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 device_monitor_loop(std::stop_token st);
|
||||||
void on_device_connected(mtp::DeviceInfo di);
|
void on_device_connected(mtp::DeviceInfo di);
|
||||||
void on_device_disconnected();
|
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 ────────────────────────────────────────────────────────────
|
// ── Status bar ────────────────────────────────────────────────────────────
|
||||||
// key is an i18n id (e.g. "status.connected"); args fill {name}/{error}/… placeholders.
|
// 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::unique_ptr<transfer::TransferEngine> engine_;
|
||||||
|
|
||||||
std::atomic<bool> connected_{false};
|
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_;
|
std::jthread monitor_thread_;
|
||||||
|
void* wake_observer_ = nullptr; // OmniMTPWakeObserver*
|
||||||
mutable std::mutex device_mtx_;
|
mutable std::mutex device_mtx_;
|
||||||
mtp::DeviceInfo device_info_;
|
mtp::DeviceInfo device_info_;
|
||||||
|
|
||||||
|
|||||||
109
src/ui/App.mm
109
src/ui/App.mm
@@ -1,5 +1,6 @@
|
|||||||
#include "App.hpp"
|
#include "App.hpp"
|
||||||
#import <Foundation/Foundation.h>
|
#import <Foundation/Foundation.h>
|
||||||
|
#import <AppKit/AppKit.h>
|
||||||
#include <stdexcept>
|
#include <stdexcept>
|
||||||
#include <cstdio>
|
#include <cstdio>
|
||||||
#include <filesystem>
|
#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;
|
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 lifecycle ────────────────────────────────────────────────────────────
|
||||||
App::App() : download_path_(fs::path(std::getenv("HOME") ? std::getenv("HOME") : "/")) {}
|
App::App() : download_path_(fs::path(std::getenv("HOME") ? std::getenv("HOME") : "/")) {}
|
||||||
App::~App() { shutdown(); }
|
App::~App() { shutdown(); }
|
||||||
|
|
||||||
void App::init() {
|
void App::init() {
|
||||||
download_path_ = fs::path(std::getenv("HOME") ? std::getenv("HOME") : "/");
|
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); });
|
monitor_thread_ = std::jthread([this](std::stop_token st) { device_monitor_loop(st); });
|
||||||
}
|
}
|
||||||
|
|
||||||
void App::shutdown() {
|
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();
|
monitor_thread_.request_stop();
|
||||||
engine_.reset();
|
engine_.reset();
|
||||||
if (connected_.load())
|
if (session_)
|
||||||
try { if (session_) session_->disconnect(); } catch (...) {}
|
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 ───────────────────────────────────────────────────────────
|
// ─── Device monitor ───────────────────────────────────────────────────────────
|
||||||
void App::device_monitor_loop(std::stop_token st) {
|
void App::device_monitor_loop(std::stop_token st) {
|
||||||
while (!st.stop_requested()) {
|
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()) {
|
if (!connected_.load()) {
|
||||||
try {
|
try {
|
||||||
auto sess = std::make_unique<mtp::MTPSession>();
|
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));
|
on_device_connected(std::move(di));
|
||||||
|
fast_reconnect_remaining_.store(0);
|
||||||
} catch (...) {}
|
} catch (...) {}
|
||||||
} else {
|
} else {
|
||||||
bool transfer_active = false;
|
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));
|
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) {
|
void App::on_device_connected(mtp::DeviceInfo di) {
|
||||||
{
|
{
|
||||||
std::lock_guard lock(device_mtx_);
|
std::lock_guard lock(device_mtx_);
|
||||||
@@ -140,18 +224,7 @@ void App::on_device_connected(mtp::DeviceInfo di) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void App::on_device_disconnected() {
|
void App::on_device_disconnected() {
|
||||||
{
|
force_usb_teardown("status.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");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Remote filesystem ────────────────────────────────────────────────────────
|
// ─── Remote filesystem ────────────────────────────────────────────────────────
|
||||||
@@ -402,6 +475,10 @@ void App::do_create_folder(const std::string& name) {
|
|||||||
catch (...) {}
|
catch (...) {}
|
||||||
refresh_remote();
|
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
|
} // namespace omniMTP
|
||||||
|
|||||||
@@ -46,5 +46,6 @@
|
|||||||
"menu.about": "Über OmniMTP",
|
"menu.about": "Über OmniMTP",
|
||||||
"about.tagline": "Nintendo Switch MTP-Client für macOS",
|
"about.tagline": "Nintendo Switch MTP-Client für macOS",
|
||||||
"about.version": "Version {version}",
|
"about.version": "Version {version}",
|
||||||
"about.copyright": "Copyright © 2026 NiklasCFW"
|
"about.copyright": "Copyright © 2026 NiklasCFW",
|
||||||
|
"status.usb_recovering": "USB wird nach dem Ruhezustand neu verbunden…"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,5 +46,6 @@
|
|||||||
"menu.about": "About OmniMTP",
|
"menu.about": "About OmniMTP",
|
||||||
"about.tagline": "Nintendo Switch MTP Client for macOS",
|
"about.tagline": "Nintendo Switch MTP Client for macOS",
|
||||||
"about.version": "Version {version}",
|
"about.version": "Version {version}",
|
||||||
"about.copyright": "Copyright © 2026 NiklasCFW"
|
"about.copyright": "Copyright © 2026 NiklasCFW",
|
||||||
|
"status.usb_recovering": "Reconnecting USB after sleep…"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,5 +46,6 @@
|
|||||||
"menu.about": "Acerca de OmniMTP",
|
"menu.about": "Acerca de OmniMTP",
|
||||||
"about.tagline": "Cliente MTP de Nintendo Switch para macOS",
|
"about.tagline": "Cliente MTP de Nintendo Switch para macOS",
|
||||||
"about.version": "Versión {version}",
|
"about.version": "Versión {version}",
|
||||||
"about.copyright": "Copyright © 2026 NiklasCFW"
|
"about.copyright": "Copyright © 2026 NiklasCFW",
|
||||||
|
"status.usb_recovering": "Reconectando USB tras la suspensión…"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,5 +46,6 @@
|
|||||||
"menu.about": "À propos d’OmniMTP",
|
"menu.about": "À propos d’OmniMTP",
|
||||||
"about.tagline": "Client MTP Nintendo Switch pour macOS",
|
"about.tagline": "Client MTP Nintendo Switch pour macOS",
|
||||||
"about.version": "Version {version}",
|
"about.version": "Version {version}",
|
||||||
"about.copyright": "Copyright © 2026 NiklasCFW"
|
"about.copyright": "Copyright © 2026 NiklasCFW",
|
||||||
|
"status.usb_recovering": "Reconnexion USB après veille…"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,5 +46,6 @@
|
|||||||
"menu.about": "Informazioni su OmniMTP",
|
"menu.about": "Informazioni su OmniMTP",
|
||||||
"about.tagline": "Client MTP Nintendo Switch per macOS",
|
"about.tagline": "Client MTP Nintendo Switch per macOS",
|
||||||
"about.version": "Versione {version}",
|
"about.version": "Versione {version}",
|
||||||
"about.copyright": "Copyright © 2026 NiklasCFW"
|
"about.copyright": "Copyright © 2026 NiklasCFW",
|
||||||
|
"status.usb_recovering": "Riconnessione USB dopo lo standby…"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,5 +46,6 @@
|
|||||||
"menu.about": "OmniMTPについて",
|
"menu.about": "OmniMTPについて",
|
||||||
"about.tagline": "macOS向け Nintendo Switch MTPクライアント",
|
"about.tagline": "macOS向け Nintendo Switch MTPクライアント",
|
||||||
"about.version": "バージョン {version}",
|
"about.version": "バージョン {version}",
|
||||||
"about.copyright": "Copyright © 2026 NiklasCFW"
|
"about.copyright": "Copyright © 2026 NiklasCFW",
|
||||||
|
"status.usb_recovering": "スリープ後、USBを再接続しています…"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,5 +46,6 @@
|
|||||||
"menu.about": "Over OmniMTP",
|
"menu.about": "Over OmniMTP",
|
||||||
"about.tagline": "Nintendo Switch MTP-client voor macOS",
|
"about.tagline": "Nintendo Switch MTP-client voor macOS",
|
||||||
"about.version": "Versie {version}",
|
"about.version": "Versie {version}",
|
||||||
"about.copyright": "Copyright © 2026 NiklasCFW"
|
"about.copyright": "Copyright © 2026 NiklasCFW",
|
||||||
|
"status.usb_recovering": "USB opnieuw verbinden na slaapstand…"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,5 +46,6 @@
|
|||||||
"menu.about": "Sobre o OmniMTP",
|
"menu.about": "Sobre o OmniMTP",
|
||||||
"about.tagline": "Cliente MTP Nintendo Switch para macOS",
|
"about.tagline": "Cliente MTP Nintendo Switch para macOS",
|
||||||
"about.version": "Versão {version}",
|
"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…"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,5 +46,6 @@
|
|||||||
"menu.about": "О программе OmniMTP",
|
"menu.about": "О программе OmniMTP",
|
||||||
"about.tagline": "MTP-клиент Nintendo Switch для macOS",
|
"about.tagline": "MTP-клиент Nintendo Switch для macOS",
|
||||||
"about.version": "Версия {version}",
|
"about.version": "Версия {version}",
|
||||||
"about.copyright": "Copyright © 2026 NiklasCFW"
|
"about.copyright": "Copyright © 2026 NiklasCFW",
|
||||||
|
"status.usb_recovering": "Повторное подключение USB после сна…"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,5 +46,6 @@
|
|||||||
"menu.about": "关于 OmniMTP",
|
"menu.about": "关于 OmniMTP",
|
||||||
"about.tagline": "适用于 macOS 的 Nintendo Switch MTP 客户端",
|
"about.tagline": "适用于 macOS 的 Nintendo Switch MTP 客户端",
|
||||||
"about.version": "版本 {version}",
|
"about.version": "版本 {version}",
|
||||||
"about.copyright": "Copyright © 2026 NiklasCFW"
|
"about.copyright": "Copyright © 2026 NiklasCFW",
|
||||||
|
"status.usb_recovering": "休眠后正在重新连接 USB…"
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user