/* * Copyright (c) 2026-present, the Ladybird developers. * * SPDX-License-Identifier: BSD-2-Clause */ #include #include #include #include #include #include #include #include namespace WebView { static constexpr auto DEFAULT_AUTOCOMPLETE_SUGGESTION_LIMIT = 8uz; static constexpr size_t MINIMUM_TITLE_AUTOCOMPLETE_QUERY_LENGTH = 3; static Optional url_without_scheme(StringView url) { auto scheme_separator = url.find("://"sv); if (!scheme_separator.has_value()) return {}; return url.substring_view(*scheme_separator + 3); } static StringView autocomplete_searchable_url(StringView url) { auto stripped_url = url_without_scheme(url).value_or(url); if (stripped_url.starts_with("www."sv, CaseSensitivity::CaseInsensitive)) stripped_url = stripped_url.substring_view(4); return stripped_url; } static StringView autocomplete_url_query(StringView query) { auto stripped_query = url_without_scheme(query).value_or(query); if (stripped_query.starts_with("www."sv, CaseSensitivity::CaseInsensitive)) stripped_query = stripped_query.substring_view(4); return stripped_query; } static StringView autocomplete_title_query(StringView query) { if (Utf8View { query }.length() < MINIMUM_TITLE_AUTOCOMPLETE_QUERY_LENGTH) return {}; return query; } static StringView autocomplete_url_contains_query(StringView query) { // Non-prefix URL matches get noisy very quickly, so only enable them // once the user has typed enough to disambiguate path fragments. if (Utf8View { query }.length() < MINIMUM_TITLE_AUTOCOMPLETE_QUERY_LENGTH) return {}; return query; } static bool matches_query(HistoryEntry const& entry, StringView title_query, StringView url_query) { auto searchable_url = autocomplete_searchable_url(entry.url.bytes_as_string_view()); if (!url_query.is_empty() && searchable_url.starts_with(url_query, CaseSensitivity::CaseInsensitive)) return true; auto url_contains_query = autocomplete_url_contains_query(url_query); if (!url_contains_query.is_empty() && searchable_url.contains(url_contains_query, CaseSensitivity::CaseInsensitive)) return true; return !title_query.is_empty() && entry.title.has_value() && entry.title->contains(title_query, CaseSensitivity::CaseInsensitive); } static u8 match_rank(HistoryEntry const& entry, StringView title_query, StringView url_query) { auto searchable_url = autocomplete_searchable_url(entry.url.bytes_as_string_view()); if (!url_query.is_empty()) { if (searchable_url.equals_ignoring_ascii_case(url_query)) return 0; if (searchable_url.starts_with(url_query, CaseSensitivity::CaseInsensitive)) return 1; } if (!title_query.is_empty() && entry.title.has_value() && entry.title->starts_with_bytes(title_query, CaseSensitivity::CaseInsensitive)) return 2; return 3; } static void sort_matching_entries(Vector& matches, StringView title_query, StringView url_query) { quick_sort(matches, [&](auto const* left, auto const* right) { auto left_rank = match_rank(*left, title_query, url_query); auto right_rank = match_rank(*right, title_query, url_query); if (left_rank != right_rank) return left_rank < right_rank; if (left->visit_count != right->visit_count) return left->visit_count > right->visit_count; if (left->last_visited_time != right->last_visited_time) return left->last_visited_time > right->last_visited_time; return left->url < right->url; }); } [[maybe_unused]] static ByteString log_history_entries(Vector const& entries) { Vector suggestions; suggestions.ensure_capacity(entries.size()); for (auto const& entry : entries) suggestions.unchecked_append(entry.url); return history_log_suggestions(suggestions); } ErrorOr> HistoryStore::create(Database::Database& database) { if (auto database_path = database.database_path(); database_path.has_value()) dbgln_if(WEBVIEW_HISTORY_DEBUG, "[History] Opening persisted history store at {}", database_path->string()); else dbgln_if(WEBVIEW_HISTORY_DEBUG, "[History] Opening memory-backed persisted history store"); Statements statements {}; auto create_history_table = TRY(database.prepare_statement(R"#( CREATE TABLE IF NOT EXISTS History ( url TEXT PRIMARY KEY, title TEXT NOT NULL, favicon TEXT, visit_count INTEGER NOT NULL, last_visited_time INTEGER NOT NULL ); )#"sv)); database.execute_statement(create_history_table, {}); auto create_last_visited_index = TRY(database.prepare_statement(R"#( CREATE INDEX IF NOT EXISTS HistoryLastVisitedTimeIndex ON History(last_visited_time DESC); )#"sv)); database.execute_statement(create_last_visited_index, {}); statements.upsert_entry = TRY(database.prepare_statement(R"#( INSERT INTO History (url, title, visit_count, last_visited_time) VALUES (?, ?, 1, ?) ON CONFLICT(url) DO UPDATE SET title = CASE WHEN excluded.title != '' THEN excluded.title ELSE History.title END, visit_count = History.visit_count + 1, last_visited_time = excluded.last_visited_time; )#"sv)); statements.update_title = TRY(database.prepare_statement(R"#( UPDATE History SET title = ? WHERE url = ?; )#"sv)); statements.update_favicon = TRY(database.prepare_statement(R"#( UPDATE History SET favicon = ? WHERE url = ?; )#"sv)); statements.get_entry = TRY(database.prepare_statement(R"#( SELECT title, visit_count, last_visited_time, COALESCE(favicon, '') FROM History WHERE url = ?; )#"sv)); statements.search_entries = TRY(database.prepare_statement(R"#( SELECT url, title, visit_count, last_visited_time, COALESCE(favicon, '') FROM ( SELECT url, title, visit_count, last_visited_time, COALESCE(favicon, '') AS favicon, CASE WHEN LOWER(CASE WHEN INSTR(url, '://') > 0 THEN SUBSTR(url, INSTR(url, '://') + 3) ELSE url END) LIKE 'www.%' THEN SUBSTR(CASE WHEN INSTR(url, '://') > 0 THEN SUBSTR(url, INSTR(url, '://') + 3) ELSE url END, 5) ELSE CASE WHEN INSTR(url, '://') > 0 THEN SUBSTR(url, INSTR(url, '://') + 3) ELSE url END END AS searchable_url FROM History ) WHERE ((?1 != '' AND LOWER(searchable_url) LIKE LOWER(?1) || '%') OR (?2 != '' AND INSTR(LOWER(searchable_url), LOWER(?2)) > 0) OR (?3 != '' AND INSTR(LOWER(title), LOWER(?3)) > 0)) ORDER BY CASE WHEN ?1 != '' AND LOWER(searchable_url) = LOWER(?1) THEN 0 WHEN ?1 != '' AND LOWER(searchable_url) LIKE LOWER(?1) || '%' THEN 1 WHEN ?3 != '' AND LOWER(title) LIKE LOWER(?3) || '%' THEN 2 ELSE 3 END, visit_count DESC, last_visited_time DESC, url ASC LIMIT ?4; )#"sv)); statements.list_entries = TRY(database.prepare_statement(R"#( SELECT url, title, visit_count, last_visited_time, favicon FROM ( SELECT url, title, visit_count, last_visited_time, COALESCE(favicon, '') AS favicon, CASE WHEN LOWER(CASE WHEN INSTR(url, '://') > 0 THEN SUBSTR(url, INSTR(url, '://') + 3) ELSE url END) LIKE 'www.%' THEN SUBSTR(CASE WHEN INSTR(url, '://') > 0 THEN SUBSTR(url, INSTR(url, '://') + 3) ELSE url END, 5) ELSE CASE WHEN INSTR(url, '://') > 0 THEN SUBSTR(url, INSTR(url, '://') + 3) ELSE url END END AS searchable_url FROM History ) WHERE ((?1 = '' AND ?2 = '') OR (?1 != '' AND INSTR(LOWER(title), LOWER(?1)) > 0) OR (?2 != '' AND INSTR(LOWER(searchable_url), LOWER(?2)) > 0)) ORDER BY last_visited_time DESC, url ASC LIMIT ?3 OFFSET ?4; )#"sv)); statements.delete_entry = TRY(database.prepare_statement("DELETE FROM History WHERE url = ?;"sv)); statements.delete_entries_accessed_since = TRY(database.prepare_statement("DELETE FROM History WHERE last_visited_time >= ?;"sv)); statements.all_urls = TRY(database.prepare_statement("SELECT url FROM History;"sv)); return adopt_own(*new HistoryStore { adopt_own(*new PersistedStorage { database, move(statements) }) }); } NonnullOwnPtr HistoryStore::create() { dbgln_if(WEBVIEW_HISTORY_DEBUG, "[History] Opening transient history store"); return adopt_own(*new HistoryStore { adopt_own(*new TransientStorage {}) }); } NonnullOwnPtr HistoryStore::create_disabled() { dbgln_if(WEBVIEW_HISTORY_DEBUG, "[History] Opening disabled history store"); return adopt_own(*new HistoryStore { adopt_own(*new TransientStorage {}), true }); } HistoryStore::HistoryStore(NonnullOwnPtr&& storage, bool is_disabled) : m_storage(move(storage)) , m_is_disabled(is_disabled) { } HistoryStore::~HistoryStore() = default; Optional HistoryStore::normalize_url(URL::URL const& url) { if (url.scheme().is_empty()) { dbgln_if(WEBVIEW_HISTORY_DEBUG, "[History] Skipping history entry without a scheme: {}", url); return {}; } if (url.scheme().is_one_of("about"sv, "data"sv)) { dbgln_if(WEBVIEW_HISTORY_DEBUG, "[History] Skipping non-browsable history URL: {}", url); return {}; } auto normalized_url = url.serialize(URL::ExcludeFragment::Yes); if (normalized_url.is_empty()) { dbgln_if(WEBVIEW_HISTORY_DEBUG, "[History] Skipping history entry with an empty normalized URL: {}", url); return {}; } return normalized_url; } void HistoryStore::record_visit(URL::URL const& url, Optional title, UnixDateTime visited_at) { if (m_is_disabled) return; auto normalized_url = normalize_url(url); if (!normalized_url.has_value()) return; dbgln_if(WEBVIEW_HISTORY_DEBUG, "[History] Recording visit in {} store: url='{}' title='{}' visited_at={}", m_storage->name(), *normalized_url, title.has_value() ? title->bytes_as_string_view() : ""sv, visited_at.seconds_since_epoch()); m_storage->record_visit(*normalized_url, title, visited_at); } void HistoryStore::update_title(URL::URL const& url, String const& title) { if (m_is_disabled) return; if (title.is_empty()) { dbgln_if(WEBVIEW_HISTORY_DEBUG, "[History] Ignoring empty history title update for {}", url); return; } auto normalized_url = normalize_url(url); if (!normalized_url.has_value()) return; dbgln_if(WEBVIEW_HISTORY_DEBUG, "[History] Updating history title in {} store: url='{}' title='{}'", m_storage->name(), *normalized_url, title); m_storage->update_title(*normalized_url, title); } void HistoryStore::update_favicon(URL::URL const& url, String const& favicon_base64_png) { if (favicon_base64_png.is_empty()) { dbgln_if(WEBVIEW_HISTORY_DEBUG, "[History] Ignoring empty history favicon update for {}", url); return; } auto normalized_url = normalize_url(url); if (!normalized_url.has_value()) return; dbgln_if(WEBVIEW_HISTORY_DEBUG, "[History] Updating history favicon in {} store: url='{}' bytes={}", m_storage->name(), *normalized_url, favicon_base64_png.bytes().size()); m_storage->update_favicon(*normalized_url, favicon_base64_png); } void HistoryStore::record_closed_tab(URL::URL const& url, UnixDateTime closed_at) { m_recently_closed_entries.empend(RecentlyClosedEntry { .urls = { url }, .was_window = false, .active_tab_index = 0, .closed_time = closed_at, }); } void HistoryStore::record_closed_window(Vector urls, size_t active_tab_index, UnixDateTime closed_at) { if (urls.is_empty()) return; m_recently_closed_entries.empend(RecentlyClosedEntry { .urls = move(urls), .was_window = true, .active_tab_index = 0, .closed_time = closed_at, }); auto& entry = m_recently_closed_entries.last(); entry.active_tab_index = active_tab_index < entry.urls.size() ? active_tab_index : entry.urls.size() - 1; } bool HistoryStore::has_recently_closed_entries() const { return !m_recently_closed_entries.is_empty(); } Optional HistoryStore::most_recently_closed_entry() const { if (m_recently_closed_entries.is_empty()) return {}; return m_recently_closed_entries.last(); } Optional HistoryStore::pop_most_recently_closed_entry() { if (m_recently_closed_entries.is_empty()) return {}; return m_recently_closed_entries.take_last(); } Optional HistoryStore::entry_for_url(URL::URL const& url) { if (m_is_disabled) return {}; auto normalized_url = normalize_url(url); if (!normalized_url.has_value()) return {}; auto entry = m_storage->entry_for_url(*normalized_url); if (entry.has_value()) { dbgln_if(WEBVIEW_HISTORY_DEBUG, "[History] Found history entry for '{}': title='{}' visits={} last_visited={} has_favicon={}", entry->url, entry->title.has_value() ? entry->title->bytes_as_string_view() : ""sv, entry->visit_count, entry->last_visited_time.seconds_since_epoch(), entry->favicon_base64_png.has_value()); } else { dbgln_if(WEBVIEW_HISTORY_DEBUG, "[History] No history entry found for '{}'", *normalized_url); } return entry; } Vector HistoryStore::autocomplete_entries(StringView query, size_t limit) { if (m_is_disabled) return {}; auto trimmed_query = query.trim_whitespace(); if (trimmed_query.is_empty()) { dbgln_if(WEBVIEW_HISTORY_DEBUG, "[History] History autocomplete query is empty after trimming"); return {}; } auto title_query = autocomplete_title_query(trimmed_query); auto url_query = autocomplete_url_query(trimmed_query); auto entries = m_storage->autocomplete_entries(title_query, url_query, limit); dbgln_if(WEBVIEW_HISTORY_DEBUG, "[History] {} history autocomplete suggestions for '{}' (title_query='{}', url_query='{}', limit={}): {}", m_storage->name(), trimmed_query, title_query, url_query, limit, log_history_entries(entries)); return entries; } Vector HistoryStore::list_entries(StringView query, size_t offset, size_t limit) { if (m_is_disabled || limit == 0) return {}; auto title_query = query.trim_whitespace(); auto url_query = autocomplete_url_query(title_query); auto entries = m_storage->list_entries(title_query, url_query, offset, limit); dbgln_if(WEBVIEW_HISTORY_DEBUG, "[History] {} history page entries for '{}' (title_query='{}', url_query='{}', offset={}, limit={}): {}", m_storage->name(), title_query, title_query, url_query, offset, limit, log_history_entries(entries)); return entries; } void HistoryStore::remove_entry_for_url(URL::URL const& url) { if (m_is_disabled) return; auto normalized_url = normalize_url(url); if (!normalized_url.has_value()) return; dbgln_if(WEBVIEW_HISTORY_DEBUG, "[History] Removing history entry for '{}'", *normalized_url); m_storage->remove_entry_for_url(*normalized_url); } static Optional site_key_for_history_entry(URL::URL const& url) { if (!url.host().has_value() || url.host()->is_empty_host()) return {}; if (auto registrable_domain = url.host()->registrable_domain(); registrable_domain.has_value()) return registrable_domain.release_value(); return url.serialized_host(); } static bool history_entry_matches_site_key(StringView entry_url, StringView site_key) { auto parsed_url = URL::Parser::basic_parse(entry_url); if (!parsed_url.has_value()) return false; auto const& host = parsed_url->host(); if (!host.has_value() || host->is_empty_host()) return false; auto serialized_host = parsed_url->serialized_host(); auto serialized_host_view = serialized_host.bytes_as_string_view(); if (serialized_host_view.equals_ignoring_ascii_case(site_key)) return true; return serialized_host_view.length() > site_key.length() && serialized_host_view.ends_with(site_key, CaseSensitivity::CaseInsensitive) && serialized_host_view[serialized_host_view.length() - site_key.length() - 1] == '.'; } void HistoryStore::remove_entries_for_same_site(URL::URL const& url) { if (m_is_disabled) return; auto site_key = site_key_for_history_entry(url); if (!site_key.has_value()) { remove_entry_for_url(url); return; } dbgln_if(WEBVIEW_HISTORY_DEBUG, "[History] Removing history entries for site '{}'", *site_key); m_storage->remove_entries_for_same_site(*site_key); } void HistoryStore::remove_entries_accessed_since(UnixDateTime since) { if (m_is_disabled) return; dbgln_if(WEBVIEW_HISTORY_DEBUG, "[History] Removing {} history entries accessed since {}", m_storage->name(), since.seconds_since_epoch()); m_storage->remove_entries_accessed_since(since); m_recently_closed_entries.remove_all_matching([&](auto const& entry) { return entry.closed_time >= since; }); } void HistoryStore::TransientStorage::record_visit(String const& url, Optional const& title, UnixDateTime visited_at) { auto entry = m_entries.find(url); if (entry == m_entries.end()) { auto new_entry = HistoryEntry { .url = url, .title = move(title), .favicon_base64_png = {}, .visit_count = 1, .last_visited_time = visited_at, }; m_entries.set( move(url), move(new_entry)); return; } entry->value.visit_count++; entry->value.last_visited_time = visited_at; if (title.has_value() && !title->is_empty()) entry->value.title = move(title); } void HistoryStore::TransientStorage::update_title(String const& url, String const& title) { auto entry = m_entries.find(url); if (entry == m_entries.end()) return; entry->value.title = move(title); } void HistoryStore::TransientStorage::update_favicon(String const& url, String const& favicon_base64_png) { auto entry = m_entries.find(url); if (entry == m_entries.end()) return; entry->value.favicon_base64_png = move(favicon_base64_png); } Optional HistoryStore::TransientStorage::entry_for_url(String const& url) { auto entry = m_entries.get(url); if (!entry.has_value()) return {}; return *entry; } Vector HistoryStore::TransientStorage::autocomplete_entries(StringView title_query, StringView url_query, size_t limit) { Vector matches; for (auto const& entry : m_entries) { if (matches_query(entry.value, title_query, url_query)) matches.append(&entry.value); } sort_matching_entries(matches, title_query, url_query); Vector entries; entries.ensure_capacity(min(limit, matches.size())); for (size_t i = 0; i < matches.size() && i < limit; ++i) entries.unchecked_append(*matches[i]); return entries; } static bool matches_history_page_query(HistoryEntry const& entry, StringView title_query, StringView url_query) { if (title_query.is_empty() && url_query.is_empty()) return true; auto searchable_url = autocomplete_searchable_url(entry.url.bytes_as_string_view()); if (!url_query.is_empty() && searchable_url.contains(url_query, CaseSensitivity::CaseInsensitive)) return true; return !title_query.is_empty() && entry.title.has_value() && entry.title->contains(title_query, CaseSensitivity::CaseInsensitive); } static void sort_entries_for_history_page(Vector& matches) { quick_sort(matches, [](auto const* left, auto const* right) { if (left->last_visited_time != right->last_visited_time) return left->last_visited_time > right->last_visited_time; return left->url < right->url; }); } Vector HistoryStore::TransientStorage::list_entries(StringView title_query, StringView url_query, size_t offset, size_t limit) { Vector matches; for (auto const& entry : m_entries) { if (matches_history_page_query(entry.value, title_query, url_query)) matches.append(&entry.value); } sort_entries_for_history_page(matches); Vector entries; if (offset >= matches.size()) return entries; auto end = min(matches.size(), offset + limit); entries.ensure_capacity(end - offset); for (size_t i = offset; i < end; ++i) entries.unchecked_append(*matches[i]); return entries; } void HistoryStore::TransientStorage::remove_entry_for_url(String const& url) { m_entries.remove(url); } void HistoryStore::TransientStorage::remove_entries_for_same_site(StringView site_key) { m_entries.remove_all_matching([&](auto const&, auto const& entry) { return history_entry_matches_site_key(entry.url, site_key); }); } void HistoryStore::TransientStorage::remove_entries_accessed_since(UnixDateTime since) { m_entries.remove_all_matching([&](auto const&, auto const& entry) { return entry.last_visited_time >= since; }); } HistoryStore::PersistedStorage::PersistedStorage(Database::Database& database, Statements&& statements) : m_database(database) , m_statements(move(statements)) { } HistoryStore::PersistedStorage::~PersistedStorage() = default; void HistoryStore::PersistedStorage::record_visit(String const& url, Optional const& title, UnixDateTime visited_at) { m_database.execute_statement( m_statements.upsert_entry, {}, url, title.value_or(String {}), visited_at); } void HistoryStore::PersistedStorage::update_title(String const& url, String const& title) { m_database.execute_statement( m_statements.update_title, {}, title, url); } void HistoryStore::PersistedStorage::update_favicon(String const& url, String const& favicon_base64_png) { m_database.execute_statement( m_statements.update_favicon, {}, favicon_base64_png, url); } Optional HistoryStore::PersistedStorage::entry_for_url(String const& url) { Optional entry; m_database.execute_statement( m_statements.get_entry, [&](auto statement_id) { auto title = m_database.result_column(statement_id, 0); auto favicon = m_database.result_column(statement_id, 3); entry = HistoryEntry { .url = url, .title = title.is_empty() ? Optional {} : Optional { move(title) }, .favicon_base64_png = favicon.is_empty() ? Optional {} : Optional { move(favicon) }, .visit_count = m_database.result_column(statement_id, 1), .last_visited_time = m_database.result_column(statement_id, 2), }; }, url); return entry; } Vector HistoryStore::PersistedStorage::autocomplete_entries(StringView title_query, StringView url_query, size_t limit) { Vector entries; entries.ensure_capacity(min(limit, DEFAULT_AUTOCOMPLETE_SUGGESTION_LIMIT)); auto url_query_string = MUST(String::from_utf8(url_query)); auto title_query_string = MUST(String::from_utf8(title_query)); auto url_contains_query_string = MUST(String::from_utf8(autocomplete_url_contains_query(url_query))); m_database.execute_statement( m_statements.search_entries, [&](auto statement_id) { auto title = m_database.result_column(statement_id, 1); auto favicon = m_database.result_column(statement_id, 4); entries.append(HistoryEntry { .url = m_database.result_column(statement_id, 0), .title = title.is_empty() ? Optional {} : Optional { move(title) }, .favicon_base64_png = favicon.is_empty() ? Optional {} : Optional { move(favicon) }, .visit_count = m_database.result_column(statement_id, 2), .last_visited_time = m_database.result_column(statement_id, 3), }); }, url_query_string, url_contains_query_string, title_query_string, static_cast(limit)); return entries; } Vector HistoryStore::PersistedStorage::list_entries(StringView title_query, StringView url_query, size_t offset, size_t limit) { Vector entries; entries.ensure_capacity(limit); auto title_query_string = MUST(String::from_utf8(title_query)); auto url_query_string = MUST(String::from_utf8(url_query)); m_database.execute_statement( m_statements.list_entries, [&](auto statement_id) { auto title = m_database.result_column(statement_id, 1); auto favicon = m_database.result_column(statement_id, 4); entries.append(HistoryEntry { .url = m_database.result_column(statement_id, 0), .title = title.is_empty() ? Optional {} : Optional { move(title) }, .favicon_base64_png = favicon.is_empty() ? Optional {} : Optional { move(favicon) }, .visit_count = m_database.result_column(statement_id, 2), .last_visited_time = m_database.result_column(statement_id, 3), }); }, title_query_string, url_query_string, static_cast(limit), static_cast(offset)); return entries; } void HistoryStore::PersistedStorage::remove_entry_for_url(String const& url) { m_database.execute_statement(m_statements.delete_entry, {}, url); } void HistoryStore::PersistedStorage::remove_entries_for_same_site(StringView site_key) { Vector urls_to_remove; m_database.execute_statement( m_statements.all_urls, [&](auto statement_id) { auto url = m_database.result_column(statement_id, 0); if (history_entry_matches_site_key(url.bytes_as_string_view(), site_key)) urls_to_remove.append(move(url)); }); for (auto const& url : urls_to_remove) remove_entry_for_url(url); } void HistoryStore::PersistedStorage::remove_entries_accessed_since(UnixDateTime since) { m_database.execute_statement(m_statements.delete_entries_accessed_since, {}, since); } }