1 #include "RemovableDriveManager.hpp"
2 #include "libslic3r/Platform.hpp"
3 #include <libslic3r/libslic3r.h>
4 
5 #include <boost/nowide/convert.hpp>
6 #include <boost/log/trivial.hpp>
7 
8 #if _WIN32
9 #include <windows.h>
10 #include <tchar.h>
11 #include <winioctl.h>
12 #include <shlwapi.h>
13 
14 #include <Dbt.h>
15 
16 #else
17 // unix, linux & OSX includes
18 #include <errno.h>
19 #include <sys/mount.h>
20 #include <sys/stat.h>
21 #include <glob.h>
22 #include <pwd.h>
23 #include <boost/filesystem.hpp>
24 #include <boost/system/error_code.hpp>
25 #include <boost/filesystem/convenience.hpp>
26 #include <boost/process.hpp>
27 #endif
28 
29 namespace Slic3r {
30 namespace GUI {
31 
32 wxDEFINE_EVENT(EVT_REMOVABLE_DRIVE_EJECTED, RemovableDriveEjectEvent);
33 wxDEFINE_EVENT(EVT_REMOVABLE_DRIVES_CHANGED, RemovableDrivesChangedEvent);
34 
35 #if _WIN32
search_for_removable_drives() const36 std::vector<DriveData> RemovableDriveManager::search_for_removable_drives() const
37 {
38 	// Get logical drives flags by letter in alphabetical order.
39 	DWORD drives_mask = ::GetLogicalDrives();
40 
41 	// Allocate the buffers before the loop.
42 	std::wstring volume_name;
43 	std::wstring file_system_name;
44 	// Iterate the Windows drives from 'C' to 'Z'
45 	std::vector<DriveData> current_drives;
46 	// Skip A and B drives.
47 	drives_mask >>= 2;
48 	for (char drive = 'C'; drive <= 'Z'; ++ drive, drives_mask >>= 1)
49 		if (drives_mask & 1) {
50 			std::string path { drive, ':' };
51 			UINT drive_type = ::GetDriveTypeA(path.c_str());
52 			// DRIVE_REMOVABLE on W are sd cards and usb thumbnails (not usb harddrives)
53 			if (drive_type ==  DRIVE_REMOVABLE) {
54 				// get name of drive
55 				std::wstring wpath = boost::nowide::widen(path);
56 				volume_name.resize(MAX_PATH + 1);
57 				file_system_name.resize(MAX_PATH + 1);
58 				BOOL error = ::GetVolumeInformationW(wpath.c_str(), volume_name.data(), sizeof(volume_name), nullptr, nullptr, nullptr, file_system_name.data(), sizeof(file_system_name));
59 				if (error != 0) {
60 					volume_name.erase(volume_name.begin() + wcslen(volume_name.c_str()), volume_name.end());
61 					if (! file_system_name.empty()) {
62 						ULARGE_INTEGER free_space;
63 						::GetDiskFreeSpaceExW(wpath.c_str(), &free_space, nullptr, nullptr);
64 						if (free_space.QuadPart > 0) {
65 							path += "\\";
66 							current_drives.emplace_back(DriveData{ boost::nowide::narrow(volume_name), path });
67 						}
68 					}
69 				}
70 			}
71 		}
72 	return current_drives;
73 }
74 
75 // Called from UI therefore it blocks the UI thread.
76 // It also blocks updates at the worker thread.
77 // Win32 implementation.
eject_drive()78 void RemovableDriveManager::eject_drive()
79 {
80 	if (m_last_save_path.empty())
81 		return;
82 
83 #ifndef REMOVABLE_DRIVE_MANAGER_OS_CALLBACKS
84 	this->update();
85 #endif // REMOVABLE_DRIVE_MANAGER_OS_CALLBACKS
86 	BOOST_LOG_TRIVIAL(info) << "Ejecting started";
87 	std::scoped_lock<std::mutex> lock(m_drives_mutex);
88 	auto it_drive_data = this->find_last_save_path_drive_data();
89 	if (it_drive_data != m_current_drives.end()) {
90 		// get handle to device
91 		std::string mpath = "\\\\.\\" + m_last_save_path;
92 		mpath = mpath.substr(0, mpath.size() - 1);
93 		HANDLE handle = CreateFileW(boost::nowide::widen(mpath).c_str(), GENERIC_READ | GENERIC_WRITE, FILE_SHARE_READ | FILE_SHARE_WRITE, nullptr, OPEN_EXISTING, 0, nullptr);
94 		if (handle == INVALID_HANDLE_VALUE) {
95 			BOOST_LOG_TRIVIAL(error) << "Ejecting " << mpath << " failed (handle == INVALID_HANDLE_VALUE): " << GetLastError();
96 			assert(m_callback_evt_handler);
97 			if (m_callback_evt_handler)
98 				wxPostEvent(m_callback_evt_handler, RemovableDriveEjectEvent(EVT_REMOVABLE_DRIVE_EJECTED, std::pair<DriveData, bool>(*it_drive_data, false)));
99 			return;
100 		}
101 		DWORD deviceControlRetVal(0);
102 		//these 3 commands should eject device safely but they dont, the device does disappear from file explorer but the "device was safely remove" notification doesnt trigger.
103 		//sd cards does  trigger WM_DEVICECHANGE messege, usb drives dont
104 		BOOL e1 = DeviceIoControl(handle, FSCTL_LOCK_VOLUME, nullptr, 0, nullptr, 0, &deviceControlRetVal, nullptr);
105 		BOOST_LOG_TRIVIAL(debug) << "FSCTL_LOCK_VOLUME " << e1 << " ; " << deviceControlRetVal << " ; " << GetLastError();
106 		BOOL e2 = DeviceIoControl(handle, FSCTL_DISMOUNT_VOLUME, nullptr, 0, nullptr, 0, &deviceControlRetVal, nullptr);
107 		BOOST_LOG_TRIVIAL(debug) << "FSCTL_DISMOUNT_VOLUME " << e2 << " ; " << deviceControlRetVal << " ; " << GetLastError();
108 		// some implemenatations also calls IOCTL_STORAGE_MEDIA_REMOVAL here but it returns error to me
109 		BOOL error = DeviceIoControl(handle, IOCTL_STORAGE_EJECT_MEDIA, nullptr, 0, nullptr, 0, &deviceControlRetVal, nullptr);
110 		if (error == 0) {
111 			CloseHandle(handle);
112 			BOOST_LOG_TRIVIAL(error) << "Ejecting " << mpath << " failed (IOCTL_STORAGE_EJECT_MEDIA)" << deviceControlRetVal << " " << GetLastError();
113 			assert(m_callback_evt_handler);
114 			if (m_callback_evt_handler)
115 				wxPostEvent(m_callback_evt_handler, RemovableDriveEjectEvent(EVT_REMOVABLE_DRIVE_EJECTED, std::pair<DriveData, bool>(*it_drive_data, false)));
116 			return;
117 		}
118 		CloseHandle(handle);
119 		BOOST_LOG_TRIVIAL(info) << "Ejecting finished";
120 		assert(m_callback_evt_handler);
121 		if (m_callback_evt_handler)
122 			wxPostEvent(m_callback_evt_handler, RemovableDriveEjectEvent(EVT_REMOVABLE_DRIVE_EJECTED, std::pair< DriveData, bool >(std::move(*it_drive_data), true)));
123 		m_current_drives.erase(it_drive_data);
124 	}
125 }
126 
get_removable_drive_path(const std::string & path)127 std::string RemovableDriveManager::get_removable_drive_path(const std::string &path)
128 {
129 #ifndef REMOVABLE_DRIVE_MANAGER_OS_CALLBACKS
130 	this->update();
131 #endif // REMOVABLE_DRIVE_MANAGER_OS_CALLBACKS
132 
133 	std::scoped_lock<std::mutex> lock(m_drives_mutex);
134 	if (m_current_drives.empty())
135 		return std::string();
136 	std::size_t found = path.find_last_of("\\");
137 	std::string new_path = path.substr(0, found);
138 	int letter = PathGetDriveNumberW(boost::nowide::widen(new_path).c_str());
139 	for (const DriveData &drive_data : m_current_drives) {
140 		char drive = drive_data.path[0];
141 		if (drive == 'A' + letter)
142 			return path;
143 	}
144 	return m_current_drives.front().path;
145 }
146 
get_removable_drive_from_path(const std::string & path)147 std::string RemovableDriveManager::get_removable_drive_from_path(const std::string& path)
148 {
149 	std::scoped_lock<std::mutex> lock(m_drives_mutex);
150 	std::size_t found = path.find_last_of("\\");
151 	std::string new_path = path.substr(0, found);
152 	int letter = PathGetDriveNumberW(boost::nowide::widen(new_path).c_str());
153 	for (const DriveData &drive_data : m_current_drives) {
154 		assert(! drive_data.path.empty());
155 		if (drive_data.path.front() == 'A' + letter)
156 			return drive_data.path;
157 	}
158 	return std::string();
159 }
160 
161 // Called by Win32 Volume arrived / detached callback.
volumes_changed()162 void RemovableDriveManager::volumes_changed()
163 {
164 	if (m_initialized) {
165 		// Signal the worker thread to wake up and enumerate removable drives.
166 	    m_wakeup = true;
167 		m_thread_stop_condition.notify_all();
168 	}
169 }
170 
171 #else
172 
173 namespace search_for_drives_internal
174 {
compare_filesystem_id(const std::string & path_a,const std::string & path_b)175 	static bool compare_filesystem_id(const std::string &path_a, const std::string &path_b)
176 	{
177 		struct stat buf;
178 		stat(path_a.c_str() ,&buf);
179 		dev_t id_a = buf.st_dev;
180 		stat(path_b.c_str() ,&buf);
181 		dev_t id_b = buf.st_dev;
182 		return id_a == id_b;
183 	}
184 
inspect_file(const std::string & path,const std::string & parent_path,std::vector<DriveData> & out)185 	void inspect_file(const std::string &path, const std::string &parent_path, std::vector<DriveData> &out)
186 	{
187 		//confirms if the file is removable drive and adds it to vector
188 
189 		if (
190 #ifdef __linux__
191 			// Chromium mounts removable drives in a way that produces the same device ID.
192 			platform_flavor() == PlatformFlavor::LinuxOnChromium ||
193 #endif
194 			// If not same file system - could be removable drive.
195 			! compare_filesystem_id(path, parent_path)) {
196 			//free space
197 			boost::system::error_code ec;
198 			boost::filesystem::space_info si = boost::filesystem::space(path, ec);
199 			if (!ec && si.available != 0) {
200 				//user id
201 				struct stat buf;
202 				stat(path.c_str(), &buf);
203 				uid_t uid = buf.st_uid;
204 				std::string username(std::getenv("USER"));
205 				struct passwd *pw = getpwuid(uid);
206 				if (pw != 0 && pw->pw_name == username)
207 					out.emplace_back(DriveData{ boost::filesystem::basename(boost::filesystem::path(path)), path });
208 			}
209 		}
210 	}
211 
search_path(const std::string & path,const std::string & parent_path,std::vector<DriveData> & out)212 	static void search_path(const std::string &path, const std::string &parent_path, std::vector<DriveData> &out)
213 	{
214 	    glob_t globbuf;
215 		globbuf.gl_offs = 2;
216 		int error = glob(path.c_str(), GLOB_TILDE, NULL, &globbuf);
217 		if (error == 0) {
218 			for (size_t i = 0; i < globbuf.gl_pathc; ++ i)
219 				inspect_file(globbuf.gl_pathv[i], parent_path, out);
220 		} else {
221 			//if error - path probably doesnt exists so function just exits
222 			//std::cout<<"glob error "<< error<< "\n";
223 		}
224 		globfree(&globbuf);
225 	}
226 }
227 
search_for_removable_drives() const228 std::vector<DriveData> RemovableDriveManager::search_for_removable_drives() const
229 {
230 	std::vector<DriveData> current_drives;
231 
232 #if __APPLE__
233 
234 	this->list_devices(current_drives);
235 
236 #else
237 
238 	if (platform_flavor() == PlatformFlavor::LinuxOnChromium) {
239 	    // ChromeOS specific: search /mnt/chromeos/removable/* folder
240 		search_for_drives_internal::search_path("/mnt/chromeos/removable/*", "/mnt/chromeos/removable", current_drives);
241    	} else {
242 	    //search /media/* folder
243 		search_for_drives_internal::search_path("/media/*", "/media", current_drives);
244 
245 		//search_path("/Volumes/*", "/Volumes");
246 	    std::string path(std::getenv("USER"));
247 		std::string pp(path);
248 
249 		//search /media/USERNAME/* folder
250 		pp = "/media/"+pp;
251 		path = "/media/" + path + "/*";
252 		search_for_drives_internal::search_path(path, pp, current_drives);
253 
254 		//search /run/media/USERNAME/* folder
255 		path = "/run" + path;
256 		pp = "/run"+pp;
257 		search_for_drives_internal::search_path(path, pp, current_drives);
258 	}
259 
260 #endif
261 
262 	return current_drives;
263 }
264 
265 // Called from UI therefore it blocks the UI thread.
266 // It also blocks updates at the worker thread.
267 // Unix & OSX implementation.
eject_drive()268 void RemovableDriveManager::eject_drive()
269 {
270 	if (m_last_save_path.empty())
271 		return;
272 
273 #ifndef REMOVABLE_DRIVE_MANAGER_OS_CALLBACKS
274 	this->update();
275 #endif // REMOVABLE_DRIVE_MANAGER_OS_CALLBACKS
276 #if __APPLE__
277 	// If eject is still pending on the eject thread, wait until it finishes.
278 	//FIXME while waiting for the eject thread to finish, the main thread is not pumping Cocoa messages, which may lead
279 	// to blocking by the diskutil tool for a couple (up to 10) seconds. This is likely not critical, as the eject normally
280 	// finishes quickly.
281 	this->eject_thread_finish();
282 #endif
283 
284 	BOOST_LOG_TRIVIAL(info) << "Ejecting started";
285 
286 	DriveData drive_data;
287 	{
288 		std::scoped_lock<std::mutex> lock(m_drives_mutex);
289 		auto it_drive_data = this->find_last_save_path_drive_data();
290 		if (it_drive_data == m_current_drives.end())
291 			return;
292 		drive_data = *it_drive_data;
293 	}
294 
295 	std::string correct_path(m_last_save_path);
296 #if __APPLE__
297 	// On Apple, run the eject asynchronously on a worker thread, see the discussion at GH issue #4844.
298 	m_eject_thread = new boost::thread([this, correct_path, drive_data]()
299 #endif
300 	{
301 		//std::cout<<"Ejecting "<<(*it).name<<" from "<< correct_path<<"\n";
302 		// there is no usable command in c++ so terminal command is used instead
303 		// but neither triggers "succesful safe removal messege"
304 
305 		BOOST_LOG_TRIVIAL(info) << "Ejecting started";
306 		boost::process::ipstream istd_err;
307     	boost::process::child child(
308 #if __APPLE__
309 			boost::process::search_path("diskutil"), "eject", correct_path.c_str(), (boost::process::std_out & boost::process::std_err) > istd_err);
310 		//Another option how to eject at mac. Currently not working.
311 		//used insted of system() command;
312 		//this->eject_device(correct_path);
313 #else
314     		boost::process::search_path("umount"), correct_path.c_str(), (boost::process::std_out & boost::process::std_err) > istd_err);
315 #endif
316 		std::string line;
317 		while (child.running() && std::getline(istd_err, line)) {
318 			BOOST_LOG_TRIVIAL(trace) << line;
319 		}
320 		// wait for command to finnish (blocks ui thread)
321 		std::error_code ec;
322 		child.wait(ec);
323 		bool success = false;
324 		if (ec) {
325             // The wait call can fail, as it did in https://github.com/prusa3d/PrusaSlicer/issues/5507
326             // It can happen even in cases where the eject is sucessful, but better report it as failed.
327             // We did not find a way to reliably retrieve the exit code of the process.
328 			BOOST_LOG_TRIVIAL(error) << "boost::process::child::wait() failed during Ejection. State of Ejection is unknown. Error code: " << ec.value();
329 		} else {
330 			int err = child.exit_code();
331 	    	if (err) {
332 	    		BOOST_LOG_TRIVIAL(error) << "Ejecting failed. Exit code: " << err;
333 	    	} else {
334 				BOOST_LOG_TRIVIAL(info) << "Ejecting finished";
335 				success = true;
336 			}
337 		}
338 		assert(m_callback_evt_handler);
339 		if (m_callback_evt_handler)
340 			wxPostEvent(m_callback_evt_handler, RemovableDriveEjectEvent(EVT_REMOVABLE_DRIVE_EJECTED, std::pair<DriveData, bool>(drive_data, success)));
341 		if (success) {
342 			// Remove the drive_data from m_current drives, searching by value, not by pointer, as m_current_drives may get modified during
343 			// asynchronous execution on m_eject_thread.
344 			std::scoped_lock<std::mutex> lock(m_drives_mutex);
345 			auto it = std::find(m_current_drives.begin(), m_current_drives.end(), drive_data);
346 			if (it != m_current_drives.end())
347 				m_current_drives.erase(it);
348 		}
349 	}
350 #if __APPLE__
351 	);
352 #endif // __APPLE__
353 }
354 
get_removable_drive_path(const std::string & path)355 std::string RemovableDriveManager::get_removable_drive_path(const std::string &path)
356 {
357 #ifndef REMOVABLE_DRIVE_MANAGER_OS_CALLBACKS
358 	this->update();
359 #endif // REMOVABLE_DRIVE_MANAGER_OS_CALLBACKS
360 
361 	std::size_t found = path.find_last_of("/");
362 	std::string new_path = found == path.size() - 1 ? path.substr(0, found) : path;
363 
364 	std::scoped_lock<std::mutex> lock(m_drives_mutex);
365 	for (const DriveData &data : m_current_drives)
366 		if (search_for_drives_internal::compare_filesystem_id(new_path, data.path))
367 			return path;
368 	return m_current_drives.empty() ? std::string() : m_current_drives.front().path;
369 }
370 
get_removable_drive_from_path(const std::string & path)371 std::string RemovableDriveManager::get_removable_drive_from_path(const std::string& path)
372 {
373 	std::size_t found = path.find_last_of("/");
374 	std::string new_path = found == path.size() - 1 ? path.substr(0, found) : path;
375     // trim the filename
376     found = new_path.find_last_of("/");
377     new_path = new_path.substr(0, found);
378 
379 	// check if same filesystem
380 	std::scoped_lock<std::mutex> lock(m_drives_mutex);
381 	for (const DriveData &drive_data : m_current_drives)
382 		if (search_for_drives_internal::compare_filesystem_id(new_path, drive_data.path))
383 			return drive_data.path;
384 	return std::string();
385 }
386 #endif
387 
init(wxEvtHandler * callback_evt_handler)388 void RemovableDriveManager::init(wxEvtHandler *callback_evt_handler)
389 {
390 	assert(! m_initialized);
391 	assert(m_callback_evt_handler == nullptr);
392 
393 	if (m_initialized)
394 		return;
395 
396 	m_initialized = true;
397 	m_callback_evt_handler = callback_evt_handler;
398 
399 #if __APPLE__
400     this->register_window_osx();
401 #endif
402 
403 #ifdef REMOVABLE_DRIVE_MANAGER_OS_CALLBACKS
404 	this->update();
405 #else // REMOVABLE_DRIVE_MANAGER_OS_CALLBACKS
406 	// Don't call update() manually, as the UI triggered APIs call this->update() anyways.
407 	m_thread = boost::thread((boost::bind(&RemovableDriveManager::thread_proc, this)));
408 #endif // REMOVABLE_DRIVE_MANAGER_OS_CALLBACKS
409 }
410 
shutdown()411 void RemovableDriveManager::shutdown()
412 {
413 #if __APPLE__
414 	// If eject is still pending on the eject thread, wait until it finishes.
415 	//FIXME while waiting for the eject thread to finish, the main thread is not pumping Cocoa messages, which may lead
416 	// to blocking by the diskutil tool for a couple (up to 10) seconds. This is likely not critical, as the eject normally
417 	// finishes quickly.
418 	this->eject_thread_finish();
419 #endif
420 
421 #ifndef REMOVABLE_DRIVE_MANAGER_OS_CALLBACKS
422     if (m_thread.joinable()) {
423     	// Stop the worker thread, if running.
424 		{
425 			// Notify the worker thread to cancel wait on detection polling.
426 			std::lock_guard<std::mutex> lck(m_thread_stop_mutex);
427 			m_stop = true;
428 		}
429 		m_thread_stop_condition.notify_all();
430 		// Wait for the worker thread to stop.
431 		m_thread.join();
432 		m_stop = false;
433 	}
434 #endif // REMOVABLE_DRIVE_MANAGER_OS_CALLBACKS
435 
436 	m_initialized = false;
437 	m_callback_evt_handler = nullptr;
438 }
439 
set_and_verify_last_save_path(const std::string & path)440 bool RemovableDriveManager::set_and_verify_last_save_path(const std::string &path)
441 {
442 #ifndef REMOVABLE_DRIVE_MANAGER_OS_CALLBACKS
443 	this->update();
444 #endif // REMOVABLE_DRIVE_MANAGER_OS_CALLBACKS
445 	m_last_save_path = this->get_removable_drive_from_path(path);
446 	m_exporting_finished = false;
447 	return ! m_last_save_path.empty();
448 }
449 
status()450 RemovableDriveManager::RemovableDrivesStatus RemovableDriveManager::status()
451 {
452 
453 	RemovableDriveManager::RemovableDrivesStatus out;
454 	{
455 		std::scoped_lock<std::mutex> lock(m_drives_mutex);
456 		out.has_eject =
457 			// Cannot control eject on Chromium.
458 			platform_flavor() != PlatformFlavor::LinuxOnChromium &&
459 			this->find_last_save_path_drive_data() != m_current_drives.end();
460 		out.has_removable_drives = ! m_current_drives.empty();
461 	}
462 	if (! out.has_eject)
463 		m_last_save_path.clear();
464 	out.has_eject = out.has_eject && m_exporting_finished;
465 	return out;
466 }
467 
468 // Update is called from thread_proc() and from most of the public methods on demand.
update()469 void RemovableDriveManager::update()
470 {
471 	std::unique_lock<std::mutex> inside_update_lock(m_inside_update_mutex, std::defer_lock);
472 #ifdef _WIN32
473 	// All wake up calls up to now are now consumed when the drive enumeration starts.
474 	m_wakeup = false;
475 #endif // _WIN32
476 	if (inside_update_lock.try_lock()) {
477 		// Got the lock without waiting. That means, the update was not running.
478 		// Run the update.
479 		std::vector<DriveData> current_drives = this->search_for_removable_drives();
480 		// Post update events.
481 		std::scoped_lock<std::mutex> lock(m_drives_mutex);
482 		std::sort(current_drives.begin(), current_drives.end());
483 		if (current_drives != m_current_drives) {
484 			assert(m_callback_evt_handler);
485 			if (m_callback_evt_handler)
486 				wxPostEvent(m_callback_evt_handler, RemovableDrivesChangedEvent(EVT_REMOVABLE_DRIVES_CHANGED));
487 		}
488 		m_current_drives = std::move(current_drives);
489 	} else {
490 		// Acquiring the m_iniside_update lock failed, therefore another update is running.
491 		// Just block until the other instance of update() finishes.
492 		inside_update_lock.lock();
493 	}
494 }
495 
496 #ifndef REMOVABLE_DRIVE_MANAGER_OS_CALLBACKS
thread_proc()497 void RemovableDriveManager::thread_proc()
498 {
499 	// Signal the worker thread to update initially.
500 #ifdef _WIN32
501     m_wakeup = true;
502 #endif // _WIN32
503 
504 	for (;;) {
505 		// Wait for 2 seconds before running the disk enumeration.
506 		// Cancellable.
507 		{
508 			std::unique_lock<std::mutex> lck(m_thread_stop_mutex);
509 #ifdef _WIN32
510 			// Reacting to updates by WM_DEVICECHANGE and WM_USER_MEDIACHANGED
511 			m_thread_stop_condition.wait(lck, [this]{ return m_stop || m_wakeup; });
512 #else
513 			m_thread_stop_condition.wait_for(lck, std::chrono::seconds(2), [this]{ return m_stop; });
514 #endif
515 		}
516 		if (m_stop)
517 			// Stop the worker thread.
518 			break;
519 		// Update m_current drives and send out update events.
520 		this->update();
521 	}
522 }
523 #endif // REMOVABLE_DRIVE_MANAGER_OS_CALLBACKS
524 
find_last_save_path_drive_data() const525 std::vector<DriveData>::const_iterator RemovableDriveManager::find_last_save_path_drive_data() const
526 {
527 	return Slic3r::binary_find_by_predicate(m_current_drives.begin(), m_current_drives.end(),
528 		[this](const DriveData &data){ return data.path < m_last_save_path; },
529 		[this](const DriveData &data){ return data.path == m_last_save_path; });
530 }
531 
532 #if __APPLE__
eject_thread_finish()533 void RemovableDriveManager::eject_thread_finish()
534 {
535 	if (m_eject_thread) {
536 		m_eject_thread->join();
537 		delete m_eject_thread;
538 		m_eject_thread = nullptr;
539 	}
540 }
541 #endif // __APPLE__
542 
543 }} // namespace Slic3r::GUI
544