diff --git a/Userland/Libraries/LibCore/StandardPaths.cpp b/Userland/Libraries/LibCore/StandardPaths.cpp index 995c992e8f75eb..b0ac2bf606815a 100644 --- a/Userland/Libraries/LibCore/StandardPaths.cpp +++ b/Userland/Libraries/LibCore/StandardPaths.cpp @@ -143,6 +143,23 @@ ByteString StandardPaths::data_directory() return LexicalPath::canonicalized_path(builder.to_byte_string()); } +ByteString StandardPaths::cache_directory() +{ + if (auto* cache_directory = getenv("XDG_CACHE_HOME")) + return LexicalPath::canonicalized_path(cache_directory); + + StringBuilder builder; + builder.append(home_directory()); +#if defined(AK_OS_MACOS) + builder.append("/Library/Caches"sv); +#elif defined(AK_OS_HAIKU) + builder.append("/config/cache"sv); +#else + builder.append("/.cache"sv); +#endif + return LexicalPath::canonicalized_path(builder.to_byte_string()); +} + ErrorOr StandardPaths::runtime_directory() { if (auto* data_directory = getenv("XDG_RUNTIME_DIR")) diff --git a/Userland/Libraries/LibCore/StandardPaths.h b/Userland/Libraries/LibCore/StandardPaths.h index e82115d19b88fe..056fca9b7a8062 100644 --- a/Userland/Libraries/LibCore/StandardPaths.h +++ b/Userland/Libraries/LibCore/StandardPaths.h @@ -24,6 +24,7 @@ class StandardPaths { static ByteString tempfile_directory(); static ByteString config_directory(); static ByteString data_directory(); + static ByteString cache_directory(); static ErrorOr runtime_directory(); static ErrorOr> font_directories(); }; diff --git a/Userland/Libraries/LibGUI/CMakeLists.txt b/Userland/Libraries/LibGUI/CMakeLists.txt index d20bbc80d45f7b..0201fec7e4e396 100644 --- a/Userland/Libraries/LibGUI/CMakeLists.txt +++ b/Userland/Libraries/LibGUI/CMakeLists.txt @@ -53,6 +53,7 @@ set(SOURCES FilePicker.cpp FilePickerDialogGML.cpp FileSystemModel.cpp + FileSystemModelThumbnailCache.cpp FilteringProxyModel.cpp FontPicker.cpp FontPickerDialogGML.cpp @@ -156,5 +157,5 @@ set(GENERATED_SOURCES ) serenity_lib(LibGUI gui) -target_link_libraries(LibGUI PRIVATE LibCore LibELF LibFileSystem LibGfx LibImageDecoderClient LibIPC LibThreading LibRegex LibConfig LibUnicode LibURL) +target_link_libraries(LibGUI PRIVATE LibCore LibCrypto LibELF LibFileSystem LibGfx LibImageDecoderClient LibIPC LibThreading LibRegex LibConfig LibUnicode LibURL) target_link_libraries(LibGUI PUBLIC LibSyntax) diff --git a/Userland/Libraries/LibGUI/FileSystemModel.cpp b/Userland/Libraries/LibGUI/FileSystemModel.cpp index 190702fe00c5bc..c5665652a9f5c2 100644 --- a/Userland/Libraries/LibGUI/FileSystemModel.cpp +++ b/Userland/Libraries/LibGUI/FileSystemModel.cpp @@ -17,11 +17,8 @@ #include #include #include -#include +#include #include -#include -#include -#include #include #include #include @@ -688,141 +685,44 @@ Icon FileSystemModel::icon_for(Node const& node) const return FileIconProvider::icon_for_path(node.full_path(), node.mode); } -using BitmapBackgroundAction = Threading::BackgroundAction>; - -// Mutex protected thumbnail cache data shared between threads. -struct ThumbnailCache { - // Null pointers indicate an image that couldn't be loaded due to errors. - HashMap> thumbnail_cache {}; - HashMap> loading_thumbnails {}; -}; - -static Threading::MutexProtected s_thumbnail_cache {}; -static Threading::MutexProtected> s_image_decoder_client {}; - -static ErrorOr> render_thumbnail(StringView path) -{ - Core::EventLoop event_loop; - Gfx::IntSize const thumbnail_size { 32, 32 }; - - auto file = TRY(Core::MappedFile::map(path)); - auto decoded_image = TRY(s_image_decoder_client.with_locked([=, &file](auto& maybe_client) -> ErrorOr> { - if (!maybe_client) { - maybe_client = TRY(ImageDecoderClient::Client::try_create()); - maybe_client->on_death = []() { - s_image_decoder_client.with_locked([](auto& client) { - client = nullptr; - }); - }; - } - - auto mime_type = Core::guess_mime_type_based_on_filename(path); - - // FIXME: Refactor thumbnail rendering to be more async-aware. Possibly return this promise to the caller. - auto decoded_image = TRY(maybe_client->decode_image(file->bytes(), {}, {}, thumbnail_size, mime_type)->await()); - - return decoded_image; - })); - - auto bitmap = decoded_image.value().frames[0].bitmap; - - auto thumbnail = TRY(Gfx::Bitmap::create(Gfx::BitmapFormat::BGRA8888, thumbnail_size)); - - double scale = min(thumbnail_size.width() / (double)bitmap->width(), thumbnail_size.height() / (double)bitmap->height()); - auto destination = Gfx::IntRect(0, 0, (int)(bitmap->width() * scale), (int)(bitmap->height() * scale)).centered_within(thumbnail->rect()); - - Painter painter(thumbnail); - painter.draw_scaled_bitmap(destination, *bitmap, bitmap->rect(), 1.f, Gfx::ScalingMode::BoxSampling); - return thumbnail; -} - bool FileSystemModel::fetch_thumbnail_for(Node const& node) { auto path = node.full_path(); - - // See if we already have the thumbnail we're looking for in the cache. - auto was_in_cache = s_thumbnail_cache.with_locked([&](auto& cache) { - auto it = cache.thumbnail_cache.find(path); - if (it != cache.thumbnail_cache.end()) { - // Loading was unsuccessful. - if (!(*it).value) - return TriState::False; - // Loading was successful. - node.thumbnail = (*it).value; - return TriState::True; - } - // Loading is in progress. - if (cache.loading_thumbnails.contains(path)) - return TriState::False; - return TriState::Unknown; - }); - if (was_in_cache != TriState::Unknown) - return was_in_cache == TriState::True; - - // Otherwise, arrange to render the thumbnail in background and make it available later. - - m_thumbnail_progress_total++; - auto weak_this = make_weak_ptr(); - auto const action = [path](auto&) { - return render_thumbnail(path); - }; - auto const update_progress = [weak_this](bool with_success) { - using namespace AK::TimeLiterals; - if (auto strong_this = weak_this.strong_ref(); !strong_this.is_null()) { + RefPtr thumbnail; + auto result = ThumbnailCache::the().fetch(path, thumbnail, + [weak_this](RefPtr bitmap) { + using namespace AK::TimeLiterals; + auto strong_this = weak_this.strong_ref(); + if (strong_this.is_null()) + return; strong_this->m_thumbnail_progress++; if (strong_this->on_thumbnail_progress) strong_this->on_thumbnail_progress(strong_this->m_thumbnail_progress, strong_this->m_thumbnail_progress_total); if (strong_this->m_thumbnail_progress == strong_this->m_thumbnail_progress_total) { strong_this->m_thumbnail_progress = 0; strong_this->m_thumbnail_progress_total = 0; + strong_this->m_ui_update_timer.reset(); } - - if (with_success && (!strong_this->m_ui_update_timer.is_valid() || strong_this->m_ui_update_timer.elapsed_time() > 100_ms)) { + if (bitmap && (!strong_this->m_ui_update_timer.is_valid() || strong_this->m_ui_update_timer.elapsed_time() > 100_ms)) { strong_this->did_update(UpdateFlag::DontInvalidateIndices); strong_this->m_ui_update_timer.start(); } - } - }; - - auto const on_complete = [weak_this, path, update_progress](auto thumbnail) -> ErrorOr { - auto finished_generating_thumbnails = false; - s_thumbnail_cache.with_locked([path, thumbnail, &finished_generating_thumbnails](auto& cache) { - cache.thumbnail_cache.set(path, thumbnail); - cache.loading_thumbnails.remove(path); - finished_generating_thumbnails = cache.loading_thumbnails.is_empty(); }); - if (auto strong_this = weak_this.strong_ref(); finished_generating_thumbnails && !strong_this.is_null()) - strong_this->m_ui_update_timer.reset(); - - update_progress(true); - - return {}; - }; - - auto const on_error = [path, update_progress](Error error) -> void { - // Note: We need to defer that to avoid the function removing its last reference - // i.e. trying to destroy itself, which is prohibited. - Core::EventLoop::current().deferred_invoke([path, error = Error::copy(error)]() mutable { - s_thumbnail_cache.with_locked([path, error = move(error)](auto& cache) { - if (error != Error::from_errno(ECANCELED)) { - cache.thumbnail_cache.set(path, nullptr); - dbgln("Failed to load thumbnail for {}: {}", path, error); - } - cache.loading_thumbnails.remove(path); - }); - }); - - update_progress(false); - }; - - s_thumbnail_cache.with_locked([path, action, on_complete, on_error](auto& cache) { - cache.loading_thumbnails.set(path, BitmapBackgroundAction::construct(move(action), move(on_complete), move(on_error))); - }); - - return false; + switch (result) { + case ThumbnailCache::FetchResult::Cached: + node.thumbnail = thumbnail; + return true; + case ThumbnailCache::FetchResult::StartedLoading: + m_thumbnail_progress_total++; + return false; + case ThumbnailCache::FetchResult::Error: + case ThumbnailCache::FetchResult::Loading: + return false; + } + VERIFY_NOT_REACHED(); } int FileSystemModel::column_count(ModelIndex const&) const diff --git a/Userland/Libraries/LibGUI/FileSystemModelThumbnailCache.cpp b/Userland/Libraries/LibGUI/FileSystemModelThumbnailCache.cpp new file mode 100644 index 00000000000000..3ad3d88ff9b891 --- /dev/null +++ b/Userland/Libraries/LibGUI/FileSystemModelThumbnailCache.cpp @@ -0,0 +1,242 @@ +/* + * Copyright (c) 2018-2020, Andreas Kling + * Copyright (c) 2022, the SerenityOS developers. + * Copyright (c) 2026, Bastiaan van der Plaat + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace GUI { + +ThumbnailCache& ThumbnailCache::the() +{ + static ThumbnailCache instance; + return instance; +} + +ByteString ThumbnailCache::disk_cache_dir() +{ + auto cache_base = Core::StandardPaths::cache_directory(); + auto parent = ByteString::formatted("{}/FileManager", cache_base); + auto dir = ByteString::formatted("{}/FileManager/thumbnails", cache_base); + (void)mkdir(cache_base.characters(), 0755); + (void)mkdir(parent.characters(), 0755); + (void)mkdir(dir.characters(), 0755); + + // Scan to populate m_disk_cache_total_size + bool expected = false; + if (m_disk_cache_initialized.compare_exchange_strong(expected, true, AK::MemoryOrder::memory_order_acq_rel)) { + size_t total = 0; + Core::DirIterator it(dir, Core::DirIterator::SkipParentAndBaseDir); + while (it.has_next()) { + auto full = it.next_full_path(); + struct stat st; + if (stat(full.characters(), &st) == 0) + total += st.st_size; + } + m_disk_cache_total_size.store(total, AK::MemoryOrder::memory_order_relaxed); + } + return dir; +} + +ByteString ThumbnailCache::cache_filename(StringView path, struct stat const& st) +{ + // Hash: path + mtime + size + auto key = ByteString::formatted("{}:{}:{}", path, st.st_mtime, st.st_size); + auto digest = Crypto::Hash::MD5::hash(reinterpret_cast(key.characters()), key.length()); + StringBuilder hex; + for (size_t i = 0; i < digest.data_length(); ++i) + hex.appendff("{:02x}", digest.data[i]); + return ByteString::formatted("{}.bmp", hex.to_byte_string()); +} + +RefPtr ThumbnailCache::load_from_disk(ByteString const& cache_path) +{ + auto bitmap_or_error = Gfx::Bitmap::load_from_file(cache_path); + if (bitmap_or_error.is_error()) + return nullptr; + return bitmap_or_error.release_value(); +} + +void ThumbnailCache::save_to_disk(ByteString const& cache_path, Gfx::Bitmap const& bitmap) +{ + auto encoded = Gfx::BMPWriter::encode(bitmap); + if (encoded.is_error()) + return; + + auto file = Core::File::open(cache_path, Core::File::OpenMode::Write); + if (file.is_error()) + return; + (void)file.value()->write_until_depleted(encoded.value()); + + size_t written = encoded.value().size(); + auto new_total = m_disk_cache_total_size.fetch_add(written, AK::MemoryOrder::memory_order_relaxed) + written; + + if (new_total <= DISK_THUMBNAIL_CACHE_MAX_SIZE) + return; + + // Remove oldest thumbnails until we're under the limit again + struct Entry { + ByteString path; + time_t mtime; + off_t size; + }; + Vector entries; + Core::DirIterator it(disk_cache_dir(), Core::DirIterator::SkipParentAndBaseDir); + while (it.has_next()) { + auto full = it.next_full_path(); + struct stat st; + if (stat(full.characters(), &st) == 0) + entries.append({ full, st.st_mtime, st.st_size }); + } + quick_sort(entries, [](auto const& a, auto const& b) { return a.mtime < b.mtime; }); + + size_t current = m_disk_cache_total_size.load(AK::MemoryOrder::memory_order_relaxed); + for (auto const& entry : entries) { + if (current <= DISK_THUMBNAIL_CACHE_MAX_SIZE) + break; + if (unlink(entry.path.characters()) == 0) { + current -= entry.size; + m_disk_cache_total_size.fetch_sub(entry.size, AK::MemoryOrder::memory_order_relaxed); + } + } +} + +ErrorOr> ThumbnailCache::render(StringView path) +{ + Core::EventLoop event_loop; + Gfx::IntSize const thumbnail_size { 32, 32 }; + + auto file = TRY(Core::MappedFile::map(path)); + auto decoded_image = TRY(m_image_decoder_client.with_locked([=, this, &file](auto& maybe_client) -> ErrorOr> { + if (!maybe_client) { + maybe_client = TRY(ImageDecoderClient::Client::try_create()); + maybe_client->on_death = [this]() { + m_image_decoder_client.with_locked([](auto& client) { + client = nullptr; + }); + }; + } + + auto mime_type = Core::guess_mime_type_based_on_filename(path); + + // FIXME: Refactor thumbnail rendering to be more async-aware. Possibly return this promise to the caller. + auto decoded_image = TRY(maybe_client->decode_image(file->bytes(), {}, {}, thumbnail_size, mime_type)->await()); + + return decoded_image; + })); + + auto bitmap = decoded_image.value().frames[0].bitmap; + + auto thumbnail = TRY(Gfx::Bitmap::create(Gfx::BitmapFormat::BGRA8888, thumbnail_size)); + + double scale = min(thumbnail_size.width() / (double)bitmap->width(), thumbnail_size.height() / (double)bitmap->height()); + auto destination = Gfx::IntRect(0, 0, (int)(bitmap->width() * scale), (int)(bitmap->height() * scale)).centered_within(thumbnail->rect()); + + Painter painter(thumbnail); + painter.draw_scaled_bitmap(destination, *bitmap, bitmap->rect(), 1.f, Gfx::ScalingMode::BoxSampling); + return thumbnail; +} + +// Shares on_loaded between the async completion and error handlers. +struct CallbackHolder : RefCounted { + explicit CallbackHolder(Function)> callback) + : fn(move(callback)) + { + } + Function)> fn; +}; + +ThumbnailCache::FetchResult ThumbnailCache::fetch( + ByteString const& path, + RefPtr& out_bitmap, + Function)> on_loaded) +{ + // Avoid caching files inside the cache directory itself, because that would cause infinite recursion + auto cache_dir = disk_cache_dir(); + if (path.starts_with(cache_dir)) + return FetchResult::Error; + + // Compute the disk cache path + struct stat img_st {}; + ByteString disk_cache_path; + if (stat(path.characters(), &img_st) == 0) + disk_cache_path = ByteString::formatted("{}/{}", cache_dir, cache_filename(path, img_st)); + + // Check the in-memory cache first + auto result = m_cache.with_locked([&](auto& cache) { + auto it = cache.thumbnails.find(path); + if (it != cache.thumbnails.end()) { + out_bitmap = (*it).value; + return out_bitmap ? FetchResult::Cached : FetchResult::Error; + } + if (cache.loading.contains(path)) + return FetchResult::Loading; + return FetchResult::StartedLoading; + }); + + if (result != FetchResult::StartedLoading) + return result; + + // Start an async background load, sharing on_loaded across the success and error paths + auto holder = adopt_ref(*new CallbackHolder(move(on_loaded))); + auto holder_for_complete = holder; + + auto action = [this, path, disk_cache_path](auto&) -> ErrorOr> { + if (auto cached = load_from_disk(disk_cache_path)) + return cached.release_nonnull(); + auto thumbnail = TRY(render(path)); + save_to_disk(disk_cache_path, thumbnail); + return thumbnail; + }; + + auto complete = [this, path, holder = move(holder_for_complete)](NonnullRefPtr thumbnail) -> ErrorOr { + m_cache.with_locked([&](auto& cache) { + cache.thumbnails.set(path, thumbnail); + cache.loading.remove(path); + }); + holder->fn(move(thumbnail)); + return {}; + }; + + auto error = [this, path, holder = move(holder)](Error err) { + // Defer the cache update: removing the BackgroundAction from its own completion + // handler would destroy its last reference inside itself, which is prohibited. + Core::EventLoop::current().deferred_invoke([this, path, err = Error::copy(err), holder = move(holder)]() mutable { + m_cache.with_locked([&](auto& cache) { + if (err != Error::from_errno(ECANCELED)) { + cache.thumbnails.set(path, nullptr); + dbgln("Failed to load thumbnail for {}: {}", path, err); + } + cache.loading.remove(path); + }); + holder->fn(nullptr); + }); + }; + + m_cache.with_locked([&](auto& cache) { + cache.loading.set(path, BitmapBackgroundAction::construct(move(action), move(complete), move(error))); + }); + + return FetchResult::StartedLoading; +} + +} diff --git a/Userland/Libraries/LibGUI/FileSystemModelThumbnailCache.h b/Userland/Libraries/LibGUI/FileSystemModelThumbnailCache.h new file mode 100644 index 00000000000000..0f13aa457bd9d2 --- /dev/null +++ b/Userland/Libraries/LibGUI/FileSystemModelThumbnailCache.h @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2018-2020, Andreas Kling + * Copyright (c) 2022, the SerenityOS developers. + * Copyright (c) 2026, Bastiaan van der Plaat + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace GUI { + +static constexpr size_t DISK_THUMBNAIL_CACHE_MAX_SIZE = 10 * 1024 * 1024; // 10 MiB + +class ThumbnailCache { +public: + static ThumbnailCache& the(); + + enum class FetchResult { + Cached, // Thumbnail ready; out_bitmap has been set. + Error, // A previous load attempt failed. + Loading, // Already loading asynchronously. + StartedLoading, // Async load started; @on_loaded called on the main thread when done. + }; + + // Looks up or starts loading the thumbnail for @path. + // On Cached, @out_bitmap is set and returned immediately. + // On StartedLoading, an async load is started and @on_loaded is called on the main + // thread when it finishes. The bitmap argument is null on failure. + FetchResult fetch(ByteString const& path, + RefPtr& out_bitmap, + Function)> on_loaded); + +private: + ThumbnailCache() = default; + + ByteString disk_cache_dir(); + static ByteString cache_filename(StringView path, struct stat const& st); + RefPtr load_from_disk(ByteString const& cache_path); + void save_to_disk(ByteString const& cache_path, Gfx::Bitmap const&); + ErrorOr> render(StringView path); + + Atomic m_disk_cache_total_size { 0 }; + Atomic m_disk_cache_initialized { false }; + + using BitmapBackgroundAction = Threading::BackgroundAction>; + + struct CacheData { + HashMap> thumbnails; + HashMap> loading; + }; + + Threading::MutexProtected m_cache; + Threading::MutexProtected> m_image_decoder_client; +}; + +}