diff --git a/CMakeLists.txt b/CMakeLists.txt index ff669ab..6bbcae1 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,7 +1,7 @@ cmake_minimum_required(VERSION 3.25) project(UltraLightBrowser - VERSION 2.4.0 + VERSION 2.4.1 DESCRIPTION "Minimal, Ultra-Fast Windows 11 Native Browser Shell (macOS Safari UI Edition)" LANGUAGES CXX C ) diff --git a/README.md b/README.md index c959c3b..c754616 100644 --- a/README.md +++ b/README.md @@ -294,6 +294,7 @@ tests/ 单元测试、页面资源检查、Windows 界面 ## 最近更新 +- **v2.4.1**:修复书签 HTML 导入:正确解析属性空格、制表符和完整属性名,避免把 DATA-HREF 或标题中的文字误当网址;按标准标记识别不同语言的收藏栏;超长网址不再虚报导入成功。新增覆盖以上情况的回归测试。 - **v2.4.0**:新增「显卡视频增强」(设置 → 高级,默认开):让 NVIDIA RTX 显卡做视频超分辨率和 SDR 转 HDR、Intel 显卡做超分;需在 NVIDIA App 里开启,接电源、画面被放大时生效。「播放诊断」显示视频放大倍数。 - **v2.3.0**:音频增强的最后一级换成真峰值限幅器:采样点和采样点之间的峰值都压在 -1 dBTP 以下,音量放大到 300% 也不会削波破音;约增加 4ms 延迟。少数安全策略严格、不允许加载的网站自动改用原来的压缩保护,「播放诊断」会显示用的是哪一种。 - **v2.2.3**:我的线路未连接时显示原因(本机没有 IPv6 / 连不到入口服务器 / 链接失效),便于排查。 diff --git a/resources/resource.rc b/resources/resource.rc index 738879d..de7c2fc 100644 --- a/resources/resource.rc +++ b/resources/resource.rc @@ -34,8 +34,8 @@ IDI_APP_ICON ICON "app.ico" // Version information VS_VERSION_INFO VERSIONINFO -FILEVERSION 2,4,0,0 -PRODUCTVERSION 2,4,0,0 +FILEVERSION 2,4,1,0 +PRODUCTVERSION 2,4,1,0 FILEFLAGSMASK VS_FFI_FILEFLAGSMASK FILEFLAGS 0 FILEOS VOS_NT_WINDOWS32 @@ -48,12 +48,12 @@ BEGIN BEGIN VALUE "CompanyName", "UltraLightBrowser" VALUE "FileDescription", "UltraLight Browser - Minimal, Ultra-Fast Windows 11 Native Browser (Safari-style UI)" - VALUE "FileVersion", "2.4.0.0" + VALUE "FileVersion", "2.4.1.0" VALUE "InternalName", "UltraLightBrowser" VALUE "LegalCopyright", "Copyright (C) 2026 Freecode100Year" VALUE "OriginalFilename", "UltraLightBrowser.exe" VALUE "ProductName", "UltraLight Browser" - VALUE "ProductVersion", "2.4.0.0" + VALUE "ProductVersion", "2.4.1.0" END END BLOCK "VarFileInfo" diff --git a/src/Library.cpp b/src/Library.cpp index bd41f8d..d4189ad 100644 --- a/src/Library.cpp +++ b/src/Library.cpp @@ -133,18 +133,31 @@ std::size_t FindCI(const std::string& hay, const std::string& needle, std::size_ } std::string AttrValue(const std::string& tag, const std::string& name) { - auto pos = FindCI(tag, name + "=", 0); - if (pos == std::string::npos) return {}; - pos += name.size() + 1; - if (pos >= tag.size()) return {}; - const char quote = tag[pos]; - if (quote == '"' || quote == '\'') { - const auto end = tag.find(quote, pos + 1); - if (end == std::string::npos) return {}; - return tag.substr(pos + 1, end - pos - 1); + constexpr const char* whitespace = " \t\r\n\f"; + // Walk complete attributes, skipping quoted values so DATA-HREF and text + // such as TITLE="href=..." cannot be mistaken for the actual link. + auto pos = tag.find_first_of(whitespace); + while (pos != std::string::npos && pos < tag.size()) { + pos = tag.find_first_not_of(whitespace, pos); + if (pos == std::string::npos) break; + const auto start = pos; + pos = tag.find_first_of("= \t\r\n\f", pos); + if (pos == std::string::npos) break; + const auto attribute = Lower(tag.substr(start, pos - start)); + pos = tag.find_first_not_of(whitespace, pos); + if (pos == std::string::npos) break; + if (tag[pos] != '=') continue; // Boolean attribute; parse the next one. + pos = tag.find_first_not_of(whitespace, pos + 1); + if (pos == std::string::npos) break; + const char quote = tag[pos]; + const bool quoted = quote == '"' || quote == '\''; + const auto valueStart = pos + (quoted ? 1 : 0); + const auto end = quoted ? tag.find(quote, valueStart) : tag.find_first_of(whitespace, valueStart); + if (quoted && end == std::string::npos) break; + if (attribute == name) return tag.substr(valueStart, end == std::string::npos ? end : end - valueStart); + pos = end == std::string::npos ? end : end + (quoted ? 1 : 0); } - const auto end = tag.find_first_of(" >", pos); - return tag.substr(pos, end == std::string::npos ? std::string::npos : end - pos); + return {}; } } // namespace @@ -394,8 +407,25 @@ int Library::ImportNetscapeHtml(const std::string& html, std::int64_t now) { while (pos < html.size()) { const auto lt = html.find('<', pos); if (lt == std::string::npos) break; - const auto gt = html.find('>', lt); - if (gt == std::string::npos) break; + if (html.compare(lt, 4, "", lt + 4); + if (end == std::string::npos) break; + pos = end + 3; + continue; + } + auto gt = lt + 1; + char quote = 0; + for (; gt < html.size(); ++gt) { + const char c = html[gt]; + if (quote) { + if (c == quote) quote = 0; + } else if (c == '"' || c == '\'') { + quote = c; + } else if (c == '>') { + break; + } + } + if (gt == html.size()) break; std::string tag = html.substr(lt + 1, gt - lt - 1); std::string name = Lower(tag.substr(0, tag.find_first_of(" \t\r\n"))); pos = gt + 1; @@ -403,6 +433,7 @@ int Library::ImportNetscapeHtml(const std::string& html, std::int64_t now) { const auto close = FindCI(html, "", pos); if (close == std::string::npos) break; pendingFolder = Clip(Trim(HtmlUnescape(html.substr(pos, close - pos))), 128); + if (Lower(AttrValue(tag, "personal_toolbar_folder")) == "true") pendingFolder = kFavoritesFolder; pos = close + 5; } else if (name == "dl") { folderStack.push_back(pendingFolder); @@ -415,7 +446,7 @@ int Library::ImportNetscapeHtml(const std::string& html, std::int64_t now) { const std::string url = HtmlUnescape(AttrValue(tag, "href")); const std::string title = Trim(HtmlUnescape(html.substr(pos, close - pos))); pos = close + 4; - if (!IsWebUrl(url)) continue; + if (!IsWebUrl(url) || url.size() > kMaxUrl) continue; std::string folder = kBookmarksFolder; for (auto it = folderStack.rbegin(); it != folderStack.rend(); ++it) { if (!it->empty()) { folder = *it; break; } @@ -427,8 +458,7 @@ int Library::ImportNetscapeHtml(const std::string& html, std::int64_t now) { } AddFolder(folder); if (!existing.insert(folder + "\n" + url).second) continue; - AddBookmark(title, url, folder, now); - ++imported; + if (!AddBookmark(title, url, folder, now).empty()) ++imported; } } return imported; diff --git a/tests/library_test.cpp b/tests/library_test.cpp index 430fd6e..996fe5d 100644 --- a/tests/library_test.cpp +++ b/tests/library_test.cpp @@ -7,10 +7,41 @@ using namespace UltraLight; +static void TestBookmarkImport(const std::filesystem::path& dir) { + struct Case { const char* name; std::string html; int count; std::string url; std::string folder; }; + const Case cases[] = { + {"attribute whitespace", R"(Space)", 1, "https://space.example/", kBookmarksFolder}, + {"attribute boundary", R"(Right)", 1, "https://right.example/", kBookmarksFolder}, + {"quoted attribute content", R"(Right)", 1, "https://right.example/", kBookmarksFolder}, + {"quoted tag delimiter", R"(Right)", 1, "https://right.example/", kBookmarksFolder}, + {"comment text is not markup", R"(Right)", 1, "https://right.example/", kBookmarksFolder}, + {"tab-delimited unquoted URL", "Tab", 1, "https://tab.example/", kBookmarksFolder}, + {"boolean and mixed-case attributes", R"(Case)", 1, "https://case.example/", kBookmarksFolder}, + {"data attribute alone is not a link", R"(Not a link)", 0, "", kBookmarksFolder}, + {"unterminated attribute rejected", R"(Meine Favoriten
Bar
)", 1, "https://bar.example/", kFavoritesFolder}, + {"non-toolbar folder preserved", R"(

Meine Favoriten

Folder
)", 1, "https://folder.example/", "Meine Favoriten"}, + {"URL size boundary accepted", "Large", 1, "https://large.example/" + std::string(8170, 'a'), kBookmarksFolder}, + {"oversized URL rejected", "Large", 0, "", kBookmarksFolder}, + }; + int failures = 0; + for (const auto& c : cases) { + Library lib(dir / "import-cases"); + const int count = lib.ImportNetscapeHtml(c.html, 1); + if (count != c.count || lib.Bookmarks().size() != static_cast(c.count) || + (c.count && (lib.Bookmarks().front().url != c.url || lib.Bookmarks().front().folder != c.folder))) { + std::cerr << "FAIL: bookmark import " << c.name << '\n'; + ++failures; + } + } + assert(failures == 0); +} + int main() { const auto dir = std::filesystem::temp_directory_path() / "ulb-library-test"; std::filesystem::remove_all(dir); std::filesystem::create_directories(dir); + TestBookmarkImport(dir); std::ofstream(dir / "history.json") << R"([{"url":"https://old.example/","title":"x","last":1,"visits":1}])"; std::ofstream(dir / "session.json") << "[]"; {