diff --git a/Meta/Lagom/CMakeLists.txt b/Meta/Lagom/CMakeLists.txt index 0567b9882c1..9969e301e96 100644 --- a/Meta/Lagom/CMakeLists.txt +++ b/Meta/Lagom/CMakeLists.txt @@ -573,7 +573,7 @@ if (BUILD_LAGOM) # LibCore lagom_test(../../Tests/LibCore/TestLibCoreIODevice.cpp WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/../../Tests/LibCore) - if (LINUX AND NOT EMSCRIPTEN) + if ((LINUX OR APPLE) AND NOT EMSCRIPTEN) lagom_test(../../Tests/LibCore/TestLibCoreFileWatcher.cpp) endif() diff --git a/Userland/Libraries/LibCore/CMakeLists.txt b/Userland/Libraries/LibCore/CMakeLists.txt index 4deb6fed093..7485a6a5ae0 100644 --- a/Userland/Libraries/LibCore/CMakeLists.txt +++ b/Userland/Libraries/LibCore/CMakeLists.txt @@ -49,9 +49,17 @@ if (SERENITYOS) list(APPEND SOURCES FileWatcherSerenity.cpp) elseif (LINUX AND NOT EMSCRIPTEN) list(APPEND SOURCES FileWatcherLinux.cpp) +elseif (APPLE) + list(APPEND SOURCES FileWatcherMacOS.mm) else() list(APPEND SOURCES FileWatcherUnimplemented.cpp) endif() serenity_lib(LibCore core) target_link_libraries(LibCore PRIVATE LibCrypt LibSystem) + +if (APPLE) + target_link_libraries(LibCore PUBLIC "-framework CoreFoundation") + target_link_libraries(LibCore PUBLIC "-framework CoreServices") + target_link_libraries(LibCore PUBLIC "-framework Foundation") +endif() diff --git a/Userland/Libraries/LibCore/FileWatcherMacOS.mm b/Userland/Libraries/LibCore/FileWatcherMacOS.mm new file mode 100644 index 00000000000..760fe6dc510 --- /dev/null +++ b/Userland/Libraries/LibCore/FileWatcherMacOS.mm @@ -0,0 +1,277 @@ +/* + * Copyright (c) 2023, Tim Flynn + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#include "FileWatcher.h" +#include +#include +#include +#include +#include +#include +#include +#include + +#if !defined(AK_OS_MACOS) +static_assert(false, "This file must only be used for macOS"); +#endif + +#define FixedPoint FixedPointMacOS // AK::FixedPoint conflicts with FixedPoint from MacTypes.h. +#include +#include +#undef FixedPoint + +namespace Core { + +struct MonitoredPath { + DeprecatedString path; + FileWatcherEvent::Type event_mask { FileWatcherEvent::Type::Invalid }; +}; + +static void on_file_system_event(ConstFSEventStreamRef, void*, size_t, void*, FSEventStreamEventFlags const[], FSEventStreamEventId const[]); + +static ErrorOr inode_id_from_path(StringView path) +{ + auto stat = TRY(System::stat(path)); + return stat.st_ino; +} + +class FileWatcherMacOS final : public FileWatcher { + AK_MAKE_NONCOPYABLE(FileWatcherMacOS); + +public: + virtual ~FileWatcherMacOS() override + { + close_event_stream(); + dispatch_release(m_dispatch_queue); + } + + static ErrorOr> create(FileWatcherFlags) + { + auto context = TRY(try_make()); + + auto queue_name = DeprecatedString::formatted("Serenity.FileWatcher.{:p}", context.ptr()); + auto dispatch_queue = dispatch_queue_create(queue_name.characters(), DISPATCH_QUEUE_SERIAL); + if (dispatch_queue == nullptr) + return Error::from_errno(errno); + + // NOTE: This isn't actually used on macOS, but is needed for FileWatcherBase. + // Creating it with an FD of -1 will effectively disable the notifier. + auto notifier = TRY(Notifier::try_create(-1, Notifier::Event::None)); + + return adopt_nonnull_ref_or_enomem(new (nothrow) FileWatcherMacOS(move(context), dispatch_queue, move(notifier))); + } + + ErrorOr add_watch(DeprecatedString path, FileWatcherEvent::Type event_mask) + { + if (m_path_to_inode_id.contains(path)) { + dbgln_if(FILE_WATCHER_DEBUG, "add_watch: path '{}' is already being watched", path); + return false; + } + + auto inode_id = TRY(inode_id_from_path(path)); + TRY(m_path_to_inode_id.try_set(path, inode_id)); + TRY(m_inode_id_to_path.try_set(inode_id, { path, event_mask })); + + TRY(refresh_monitored_paths()); + + dbgln_if(FILE_WATCHER_DEBUG, "add_watch: watching path '{}' inode {}", path, inode_id); + return true; + } + + ErrorOr remove_watch(DeprecatedString path) + { + auto it = m_path_to_inode_id.find(path); + if (it == m_path_to_inode_id.end()) { + dbgln_if(FILE_WATCHER_DEBUG, "remove_watch: path '{}' is not being watched", path); + return false; + } + + m_inode_id_to_path.remove(it->value); + m_path_to_inode_id.remove(it); + + TRY(refresh_monitored_paths()); + + dbgln_if(FILE_WATCHER_DEBUG, "remove_watch: stopped watching path '{}'", path); + return true; + } + + ErrorOr canonicalize_path(DeprecatedString path) + { + LexicalPath lexical_path { move(path) }; + auto parent_path = lexical_path.parent(); + + auto inode_id = TRY(inode_id_from_path(parent_path.string())); + + auto it = m_inode_id_to_path.find(inode_id); + if (it == m_inode_id_to_path.end()) + return Error::from_string_literal("Got an event for a non-existent inode ID"); + + return MonitoredPath { + LexicalPath::join(it->value.path, lexical_path.basename()).string(), + it->value.event_mask + }; + } + + void handle_event(FileWatcherEvent event) + { + NonnullRefPtr strong_this { *this }; + + m_main_event_loop.deferred_invoke( + [strong_this = move(strong_this), event = move(event)]() { + strong_this->on_change(event); + }); + } + +private: + FileWatcherMacOS(NonnullOwnPtr context, dispatch_queue_t dispatch_queue, NonnullRefPtr notifier) + : FileWatcher(-1, move(notifier)) + , m_main_event_loop(EventLoop::current()) + , m_context(move(context)) + , m_dispatch_queue(dispatch_queue) + { + m_context->info = this; + } + + void close_event_stream() + { + if (!m_stream) + return; + + dispatch_sync(m_dispatch_queue, ^{ + FSEventStreamStop(m_stream); + FSEventStreamInvalidate(m_stream); + FSEventStreamRelease(m_stream); + m_stream = nullptr; + }); + } + + ErrorOr refresh_monitored_paths() + { + static constexpr FSEventStreamCreateFlags stream_flags = kFSEventStreamCreateFlagFileEvents | kFSEventStreamCreateFlagUseCFTypes | kFSEventStreamCreateFlagUseExtendedData; + static constexpr CFAbsoluteTime stream_latency = 0.25; + + close_event_stream(); + + if (m_path_to_inode_id.is_empty()) + return {}; + + auto monitored_paths = CFArrayCreateMutable(kCFAllocatorDefault, m_path_to_inode_id.size(), &kCFTypeArrayCallBacks); + if (monitored_paths == nullptr) + return Error::from_errno(ENOMEM); + + for (auto it : m_path_to_inode_id) { + auto path = CFStringCreateWithCString(kCFAllocatorDefault, it.key.characters(), kCFStringEncodingUTF8); + if (path == nullptr) + return Error::from_errno(ENOMEM); + + CFArrayAppendValue(monitored_paths, static_cast(path)); + } + + dispatch_sync(m_dispatch_queue, ^{ + m_stream = FSEventStreamCreate( + kCFAllocatorDefault, + &on_file_system_event, + m_context.ptr(), + monitored_paths, + kFSEventStreamEventIdSinceNow, + stream_latency, + stream_flags); + + if (m_stream) { + FSEventStreamSetDispatchQueue(m_stream, m_dispatch_queue); + FSEventStreamStart(m_stream); + } + }); + + if (!m_stream) + return Error::from_string_literal("Could not create an FSEventStream"); + return {}; + } + + EventLoop& m_main_event_loop; + + NonnullOwnPtr m_context; + dispatch_queue_t m_dispatch_queue { nullptr }; + FSEventStreamRef m_stream { nullptr }; + + HashMap m_path_to_inode_id; + HashMap m_inode_id_to_path; +}; + +void on_file_system_event(ConstFSEventStreamRef, void* user_data, size_t event_size, void* event_paths, FSEventStreamEventFlags const event_flags[], FSEventStreamEventId const[]) +{ + auto& file_watcher = *reinterpret_cast(user_data); + auto paths = reinterpret_cast(event_paths); + + for (size_t i = 0; i < event_size; ++i) { + auto path_dictionary = static_cast(CFArrayGetValueAtIndex(paths, static_cast(i))); + auto path = static_cast(CFDictionaryGetValue(path_dictionary, kFSEventStreamEventExtendedDataPathKey)); + + char file_path_buffer[PATH_MAX] {}; + if (!CFStringGetFileSystemRepresentation(path, file_path_buffer, sizeof(file_path_buffer))) { + dbgln_if(FILE_WATCHER_DEBUG, "Could not convert event to a file path"); + continue; + } + + auto maybe_monitored_path = file_watcher.canonicalize_path(DeprecatedString { file_path_buffer }); + if (maybe_monitored_path.is_error()) { + dbgln_if(FILE_WATCHER_DEBUG, "Could not canonicalize path {}: {}", file_path_buffer, maybe_monitored_path.error()); + continue; + } + auto monitored_path = maybe_monitored_path.release_value(); + + FileWatcherEvent event; + event.event_path = move(monitored_path.path); + + auto flags = event_flags[i]; + if ((flags & kFSEventStreamEventFlagItemCreated) != 0) + event.type |= FileWatcherEvent::Type::ChildCreated; + if ((flags & kFSEventStreamEventFlagItemRemoved) != 0) + event.type |= FileWatcherEvent::Type::ChildDeleted; + if ((flags & kFSEventStreamEventFlagItemModified) != 0) + event.type |= FileWatcherEvent::Type::ContentModified; + if ((flags & kFSEventStreamEventFlagItemInodeMetaMod) != 0) + event.type |= FileWatcherEvent::Type::MetadataModified; + + if (event.type == FileWatcherEvent::Type::Invalid) { + dbgln_if(FILE_WATCHER_DEBUG, "Unknown event type {:x} returned by the FS event for {}", flags, path); + continue; + } + if ((event.type & monitored_path.event_mask) == FileWatcherEvent::Type::Invalid) { + dbgln_if(FILE_WATCHER_DEBUG, "Dropping unwanted FS event {} for {}", flags, path); + continue; + } + + file_watcher.handle_event(move(event)); + } +} + +ErrorOr> FileWatcher::create(FileWatcherFlags flags) +{ + return TRY(FileWatcherMacOS::create(flags)); +} + +FileWatcher::FileWatcher(int watcher_fd, NonnullRefPtr notifier) + : FileWatcherBase(watcher_fd) + , m_notifier(move(notifier)) +{ +} + +FileWatcher::~FileWatcher() = default; + +ErrorOr FileWatcherBase::add_watch(DeprecatedString path, FileWatcherEvent::Type event_mask) +{ + auto& file_watcher = verify_cast(*this); + return file_watcher.add_watch(move(path), event_mask); +} + +ErrorOr FileWatcherBase::remove_watch(DeprecatedString path) +{ + auto& file_watcher = verify_cast(*this); + return file_watcher.remove_watch(move(path)); +} + +}