From f0d1bc05efc2f8eb75c352b9884f00381ba9b591 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 03:09:11 +0000 Subject: [PATCH 1/4] =?UTF-8?q?perf:=20=E4=BF=AE=E5=A4=8D=E8=B7=A8?= =?UTF-8?q?=E7=AB=99=E8=AF=BB=E5=8F=96=E4=B9=A6=E7=AD=BE=E5=B9=B6=E5=85=A8?= =?UTF-8?q?=E9=9D=A2=E4=BC=98=E5=8C=96=E5=AF=BC=E5=85=A5=E3=80=81=E5=90=AF?= =?UTF-8?q?=E5=8A=A8=E4=B8=8E=E6=B8=B2=E6=9F=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 安全 - 所有响应加 Cross-Origin-Resource-Policy: same-origin,并拒绝其他网站 (Sec-Fetch-Site 为 cross-site/same-site 的非导航请求)读取文件和接口, 其他网页不能再用 ' + '', encoding="utf-8") + + def test_page_names_each_asset_by_its_current_version(self): + status, headers, body = self.request("GET", "/") + page = body.decode("utf-8") + self.assertEqual(status, 200) + self.assertEqual(headers["Cache-Control"], "no-cache") + self.assertEqual(int(headers["Content-Length"]), len(body)) + stamp = manage.file_stamp(self.web / "js/app.js") + self.assertIn(f'src="js/app.js?v={stamp}"', page) + self.assertIn('href="css/base.css?v=', page) + self.assertIn('src="js/missing.js"', page, "不存在的文件保持原地址") + self.assertIn('src="https://example.com/a.png"', page) + status, _, body = self.request("GET", "/", {"If-None-Match": headers["ETag"]}) + self.assertEqual((status, body), (304, b"")) + + def test_stamped_assets_are_cached_for_good_and_changes_get_new_addresses(self): + _, _, body = self.request("GET", "/index.html") + stamp = manage.file_stamp(self.web / "js/app.js") + status, headers, _ = self.request("GET", f"/js/app.js?v={stamp}") + self.assertEqual(status, 200) + self.assertEqual(headers["Cache-Control"], manage.IMMUTABLE_CACHE) + for query in ("", "?v=old", "?v=" + stamp + "x"): + with self.subTest(query=query): + _, headers, _ = self.request("GET", "/js/app.js" + query) + self.assertEqual(headers["Cache-Control"], "no-cache") + _, before, _ = self.request("GET", "/index.html") + (self.web / "js/app.js").write_text("changed();", encoding="utf-8") + status, after, body = self.request("GET", "/index.html", {"If-None-Match": before["ETag"]}) + self.assertEqual(status, 200, "资源变化后页面也必须重新下载") + self.assertIn(f'js/app.js?v={manage.file_stamp(self.web / "js/app.js")}', body.decode("utf-8")) + + def test_one_connection_serves_a_whole_page_load(self): + connection = http.client.HTTPConnection("127.0.0.1", self.port, timeout=3) + self.addCleanup(connection.close) + for path in ("/index.html", "/css/base.css", "/js/app.js", "/data.js", "/__service"): + with self.subTest(path=path): + connection.request("GET", path, headers={"Host": f"127.0.0.1:{self.port}"}) + response = connection.getresponse() + response.read() + self.assertEqual(response.status, 200) + self.assertFalse(response.will_close, "静态文件和接口应保持连接") + + def test_posts_close_their_connection(self): + status, headers, _ = self.request("POST", "/__window_state", {"Origin": "https://evil.example"}, "x" * 50) + self.assertEqual(status, 403) + self.assertEqual(headers.get("Connection"), "close") + + +class FaviconTests(ServiceTestCase): + def test_favicon_is_revalidated_instead_of_resent(self): + icon = self.web / "icon.ico" + icon.write_bytes(b"\0" * 2048) + icons = SimpleNamespace(icon_path=lambda skin: icon) + with patch.dict("sys.modules", {"shortcut": icons}): + status, headers, body = self.request("GET", "/__favicon?skin=aurora") + self.assertEqual((status, len(body)), (200, 2048)) + self.assertEqual(headers["Cache-Control"], "no-cache") + status, _, body = self.request("GET", "/__favicon?skin=aurora", {"If-None-Match": headers["ETag"]}) + self.assertEqual((status, body), (304, b"")) + + +class WeatherCacheTests(ServiceTestCase): + def setUp(self): + super().setUp() + manage.WEATHER_CACHE.clear() + self.addCleanup(manage.WEATHER_CACHE.clear) + + def test_tabs_opened_together_share_one_upstream_lookup(self): + with patch.object(manage, "weather_from_uapis", return_value=(21.5, 0, "晴")) as upstream: + for _ in range(3): + status, headers, body = self.request("GET", "/__weather?city=%E6%9D%AD%E5%B7%9E") + self.assertEqual(status, 200) + self.assertEqual(json.loads(body)["current"], {"temperature_2m": 21.5, "weather_code": 0}) + self.assertEqual(headers["X-Weather-Source"], "uapis") + self.assertEqual(upstream.call_count, 1) + self.request("GET", "/__weather?city=%E5%AE%81%E6%B3%A2") + self.assertEqual(upstream.call_count, 2, "不同城市各自查询") + with patch.object(manage, "WEATHER_CACHE_SECONDS", 0): + self.request("GET", "/__weather?city=%E6%9D%AD%E5%B7%9E") + self.assertEqual(upstream.call_count, 3, "过期后重新查询") + + def test_failures_are_not_cached(self): + with patch.object(manage, "weather_from_uapis", side_effect=OSError), \ + patch.object(manage, "weather_from_open_meteo", side_effect=OSError) as fallback: + for _ in range(2): + status, _, _ = self.request("GET", "/__weather?city=x") + self.assertEqual(status, 502) + self.assertEqual(fallback.call_count, 2) + + +class ShortcutIconTests(ServiceTestCase): + def test_repeating_the_same_skin_does_not_rewrite_the_shortcut(self): + changed = [] + icons = SimpleNamespace(set_icon=changed.append) + page = {"Origin": self.page} + with patch.object(manage.sys, "platform", "win32"), patch.dict("sys.modules", {"shortcut": icons}): + for skin in ("cyber", "cyber", "snow", "snow", "cyber"): + status, _, _ = self.request("POST", f"/__icon?skin={skin}", page) + self.assertEqual(status, 204) + self.assertEqual(changed, ["cyber", "snow", "cyber"]) diff --git a/tests/test_macos_distribution.py b/tests/test_macos_distribution.py index d0ef713..ec802fe 100644 --- a/tests/test_macos_distribution.py +++ b/tests/test_macos_distribution.py @@ -37,6 +37,25 @@ def test_runtime_preparation_refreshes_public_files_without_private_bookmarks(se self.assertEqual((runtime / "web/data.js").read_text(encoding="utf-8"), "private data") self.assertEqual((runtime / "data/bookmarks.html").read_text(encoding="utf-8"), "private bookmarks") + def test_unchanged_app_launches_without_copying_its_files_again(self): + with tempfile.TemporaryDirectory() as folder: + root = Path(folder) + bundle, runtime = root / "bundle", root / "runtime" + (bundle / "web").mkdir(parents=True) + (bundle / "web/index.html").write_text("page", encoding="utf-8") + with patch.object(macos_app, "bundle_root", return_value=bundle), patch.object(macos_app, "user_root", return_value=runtime): + macos_app.prepare_runtime() + with patch.object(macos_app.shutil, "copy2", side_effect=AssertionError("copied again")): + macos_app.prepare_runtime() + # A public file that went missing is restored even when the app did not change. + (runtime / "web/index.html").unlink() + macos_app.prepare_runtime() + self.assertEqual((runtime / "web/index.html").read_text(encoding="utf-8"), "page") + (bundle / "web/index.html").write_text("new page!", encoding="utf-8") + macos_app.prepare_runtime() + self.assertEqual((runtime / "web/index.html").read_text(encoding="utf-8"), "new page!") + self.assertEqual(list(runtime.rglob("*.tmp")), []) + def packaged_manage(self): spec = importlib.util.spec_from_file_location("bookmark_packaged_manage", ROOT / "scripts" / "manage.py") manage = importlib.util.module_from_spec(spec) @@ -182,7 +201,7 @@ def test_packaged_restart_relaunches_the_app_not_a_python_script(self): def test_open_page_uses_the_default_browser(self): steps = [] manage = SimpleNamespace( - build=lambda: steps.append("build"), + build_if_stale=lambda: steps.append("build"), local_url=lambda: steps.append("url") or "http://127.0.0.1:8765/index.html?v=1-2", ) with patch.object(macos_app.webbrowser, "open") as browser: diff --git a/tests/test_manage.py b/tests/test_manage.py index 2571391..d05599f 100644 --- a/tests/test_manage.py +++ b/tests/test_manage.py @@ -119,3 +119,127 @@ def test_page_data_only_carries_fields_the_page_reads(self): written = (Path(folder) / "data.js").read_text(encoding="utf-8") self.assertEqual(written, 'window.BOOKMARKS = [{"title": "A", "href": "https://a.example/", ' '"path": "工作", "group": "工作", "host": "a.example"}];\n') + + +class BookmarkParserTests(TestCase): + def paths(self, text): + return [(item["title"], item["path"]) for item in manage.parse_html(text)] + + def test_exports_on_one_line_or_with_other_attribute_orders_are_read(self): + one_line = ('

开发

' + '

A
B & C' + '

D

') + self.assertEqual(self.paths(one_line), [("A", "开发"), ("B & C", "开发"), ("D", "其他")]) + self.assertEqual(self.paths('

\n

X\n
'), + [("X", "其他")]) + split = '

\n

工作\n

\n

\n

W\n

\n

' + self.assertEqual(self.paths(split), [("W", "工作")]) + + def test_unclosed_links_empty_folders_and_links_without_address(self): + text = ('

空文件夹

E' + '

F

F1
无地址
空白
') + self.assertEqual(self.paths(text), [("E", "其他"), ("F1", "F")]) + + def test_duplicates_are_skipped_in_page_data_without_rewriting_the_source(self): + items = manage.parse_html('
A

X

' + '
dup
B
') + kept, dropped = manage.dedupe_items(items) + self.assertEqual(([item["title"] for item in kept], dropped), (["A", "B"], 1)) + + +class BuildTests(TestCase): + def setUp(self): + import tempfile + + folder = tempfile.TemporaryDirectory() + self.addCleanup(folder.cleanup) + self.root = Path(folder.name) + self.source = self.root / "bookmarks.html" + self.data = self.root / "data.js" + for name, value in (("SRC", self.source), ("EXAMPLE_SRC", self.root / "example.html"), ("DATA_JS", self.data)): + patcher = patch.object(manage, name, value) + patcher.start() + self.addCleanup(patcher.stop) + self.source.write_text(manage.render_bookmarks_html([ + {"title": "A", "href": "https://a.example/", "path": "工作", "group": "工作", "host": "a.example"}, + {"title": "A again", "href": "https://a.example", "path": "其他", "group": "其他", "host": "a.example"}, + ]), encoding="utf-8") + self.before = self.source.read_bytes() + + def test_build_skips_duplicates_but_keeps_the_source_file_exact(self): + self.assertEqual([item["title"] for item in manage.build()], ["A"]) + self.assertEqual(self.source.read_bytes(), self.before) + + def test_launch_rebuilds_only_when_source_or_code_changed(self): + import os + + manage.build() + with patch.object(manage, "build", wraps=manage.build) as build: + manage.build_if_stale() + build.assert_not_called() + stat = self.source.stat() + os.utime(self.source, ns=(stat.st_atime_ns, stat.st_mtime_ns + 1_000_000)) + manage.build_if_stale() + self.assertEqual(build.call_count, 1, "书签源变化后重新生成") + manage.build_if_stale() + self.assertEqual(build.call_count, 1) + with patch.object(manage, "APP_VERSION", "v99.0.0"): + manage.build_if_stale() + self.assertEqual(build.call_count, 2, "程序版本变化后重新生成") + self.data.unlink() + manage.build_if_stale() + self.assertEqual(build.call_count, 3, "缺少 data.js 时重新生成") + + def test_unchanged_page_data_keeps_its_timestamp(self): + import os + + manage.build() + os.utime(self.data, ns=(1_000_000_000, 1_000_000_000)) + manage.build() + self.assertEqual(self.data.stat().st_mtime_ns, 1_000_000_000, "内容不变时不改写,浏览器缓存仍有效") + self.source.write_text(manage.render_bookmarks_html([ + {"title": "B", "href": "https://b.example/", "path": "工作", "group": "工作", "host": "b.example"}, + ]), encoding="utf-8") + manage.build() + self.assertIn('"title": "B"', self.data.read_text(encoding="utf-8")) + self.assertEqual(list(self.root.glob(".data.js.*.tmp")), []) + + def test_html_without_bookmarks_never_replaces_the_current_page(self): + manage.build() + page = self.data.read_bytes() + empty = self.root / "export.html" + for text in ("not a bookmark export", ""): + with self.subTest(text=text): + empty.write_text(text, encoding="utf-8") + with self.assertRaises(SystemExit): + manage.replace_src(empty) + self.assertEqual(self.source.read_bytes(), self.before) + self.assertEqual(self.data.read_bytes(), page) + self.assertFalse((self.root / ".bookmark-backups").exists()) + + def test_backup_counts_are_read_cheaply_and_match_the_parser(self): + manage.build() + self.source.write_text(manage.render_bookmarks_html([ + {"title": "C", "href": "https://c.example/", "path": "其他", "group": "其他", "host": "c.example"}, + ]), encoding="utf-8") + manage.replace_bookmark_source(self.source.read_text(encoding="utf-8") + "\n") + backup = manage.backup_files()[0] + self.assertEqual(manage.bookmark_backups()[0]["count"], len(manage.parse_html(backup.read_text(encoding="utf-8")))) + with patch.object(Path, "read_bytes", side_effect=AssertionError("cached")): + self.assertEqual(manage.bookmark_backups()[0]["count"], 1) + + +class PickerTests(TestCase): + def test_macos_uses_the_native_picker_from_any_thread(self): + result = manage.subprocess.CompletedProcess([], 0, "/Users/me/Downloads/bookmarks.html\n", "") + with patch.object(manage.sys, "platform", "darwin"), patch.object(manage.subprocess, "run", return_value=result) as run: + self.assertEqual(manage.pick_html(), Path("/Users/me/Downloads/bookmarks.html")) + self.assertEqual(run.call_args.args[0][0], "osascript") + cancelled = manage.subprocess.CompletedProcess([], 1, "", "") + with patch.object(manage.sys, "platform", "darwin"), patch.object(manage.subprocess, "run", return_value=cancelled): + self.assertIsNone(manage.pick_html()) + + def test_missing_tkinter_is_a_reported_sync_failure(self): + with patch.object(manage.sys, "platform", "win32"), patch.dict("sys.modules", {"tkinter": None}): + with self.assertRaises(SystemExit): + manage.pick_html() diff --git a/tests/weather.test.cjs b/tests/weather.test.cjs index c2c5d4e..6aac1ae 100644 --- a/tests/weather.test.cjs +++ b/tests/weather.test.cjs @@ -153,10 +153,30 @@ test('打开和手动刷新仍绕过新鲜缓存', async () => { await app.settled(); await app.run('loadWeather(false, true)'); assert.equal(app.requests.filter(r => r.url.startsWith('https://api.open-meteo.com/')).length, 2); - assert.equal(app.requests.filter(r => r.url.startsWith('/__weather?')).length, 2); + // 主源及时返回时不再请求本地备用源。 + assert.equal(app.requests.filter(r => r.url.startsWith('/__weather?')).length, 0); assert.match(app.elements.get('weatherTxt').textContent, /22° · 杭州/); }); +test('主源迟迟没有结果时才请求备用源,任一成功后取消其余请求', async () => { + let primarySignal = null; + const app = fixture({ route: (url, options) => { + if (url.startsWith('https://api.open-meteo.com/')) { + primarySignal = options.signal; + return new Promise((resolve, reject) => options.signal.addEventListener('abort', () => reject(new Error('aborted')))); + } + if (url.startsWith('/__weather?')) { + return response({ current: { weather_code: 2, temperature_2m: 27 }, description: '多云' }); + } + } }); + const started = Date.now(); + await app.run('loadWeather(false, true)'); + assert.ok(Date.now() - started >= 1400, '备用源应在主源超过等待时间后才启动'); + assert.equal(app.requests.filter(r => r.url.startsWith('/__weather?')).length, 1); + assert.match(app.elements.get('weatherTxt').textContent, /多云 27° · 杭州/); + assert.equal(primarySignal.aborted, true, '备用源成功后应取消仍在进行的主源请求'); +}); + const weatherCache = (text, age) => JSON.stringify({ text, code: 0, at: Date.now() - age }); test('每分钟检查一次,复用其他标签页刚写入的缓存,满 10 分钟才重新请求', async () => { @@ -237,7 +257,8 @@ test('天气异常时每秒重试三次,成功后立即停止', async () => { } }); await app.run('loadWeather(false, true)'); assert.equal(primaryAttempts, 4); - assert.equal(fallbackAttempts, 4); + // 前三次主源失败才启用备用源;第四次主源成功,不再请求备用源。 + assert.equal(fallbackAttempts, 3); assert.deepEqual(app.retryDelays, [1000, 1000, 1000]); assert.match(app.elements.get('weatherTxt').textContent, /晴 25° · 杭州/); }); diff --git a/web/README.md b/web/README.md index 885814b..0051427 100644 --- a/web/README.md +++ b/web/README.md @@ -47,11 +47,13 @@ CSS 图片路径相对于 `css/`,例如 `../themes/shuimo.svg`。 | `js/effects.js` | 背景天气、粒子、画布与动画调度 | | `js/app.js` | 按顺序绑定事件、启动时钟天气并渲染书签 | +本地服务输出 `index.html` 时会给 `css/`、`js/` 下的每个资源地址加上 `?v=<修改时间-大小>`,带当前版本号的资源可长期缓存,文件一变地址就变;页面本身和书签数据每次打开仍会重新验证。直接打开 HTML 文件或演示页时地址保持不变。 + `bootstrap.js` 位于页头;`data-loader.js` 在页面底部同步加载数据,不能加 `defer`(内部使用 `document.write`)。其余脚本使用 `defer`,由最后的 `app.js` 统一启动。 ## 天气行为 -打开或刷新网页时,先显示有效缓存,同时请求最新天气;之后每分钟检查一次 `bm-weather` 缓存,满 10 分钟才请求。缓存由各标签页共用,其他页面刚刷新过时直接显示其结果;页面在后台时不请求,切回页面时补上过期天气;定时检查不会中断正在进行的手动刷新。天气旁的刷新按钮可立即重新获取,天气文字用于切换城市。刷新失败不会写入新的缓存时间,已有天气会保留,按钮提示重试。后台休眠可能使定时执行延后。 +打开或刷新网页时,先显示有效缓存,同时请求最新天气(主源失败或 `WEATHER_HEDGE_MS` 即 1.5 秒内没有结果时才请求本地备用源,任一成功后取消其余请求);之后每分钟检查一次 `bm-weather` 缓存,满 10 分钟才请求。缓存由各标签页共用,其他页面刚刷新过时直接显示其结果;页面在后台时不请求,切回页面时补上过期天气;定时检查不会中断正在进行的手动刷新。天气旁的刷新按钮可立即重新获取,天气文字用于切换城市。刷新失败不会写入新的缓存时间,已有天气会保留,按钮提示重试。后台休眠可能使定时执行延后。 刷新周期集中在 `js/weather.js` 的 `WEATHER_REFRESH_MS`(10 分钟),检查间隔为 `WEATHER_TICK_MS`(1 分钟)。天气更新会同步“跟随天气”的背景特效。 @@ -63,16 +65,15 @@ CSS 图片路径相对于 `css/`,例如 `../themes/shuimo.svg`。 ## 回归检查 -安装 Node.js 的开发环境可在项目根目录运行以下命令,无需安装 npm 依赖: +安装 Node.js 22 的开发环境可在项目根目录运行全部前端测试,无需安装 npm 依赖;Python 测试使用标准库: ```powershell -node --test tests/weather.test.cjs -node --test tests/layouts.test.cjs -node --test tests/clock.test.cjs -node --test tests/bookmark-sync.test.cjs -node --test tests/category-arrangement.test.cjs tests/appearance-presets.test.cjs tests/bookmark-menu.test.cjs +node --test "tests/*.test.cjs" +python -B -m unittest discover -s tests -p "test_*.py" ``` +推送到 `main` 或提交 PR 时,`.github/workflows/tests.yml` 会在 Ubuntu、Windows 和 macOS 上运行同样的测试。 + 测试使用模拟网络、存储和页面元素,验证城市切换竞争、取消输入、零坐标、10 分钟刷新、跨标签页共用缓存、后台暂停及失败状态,不访问私人书签或快捷方式接口。 布局测试覆盖分类总览、看板独立分页与搜索重置、目录树展开/收起和原有布局的分类及分页行为,以及不存在的分类回到“全部”、书签地址转义和网站图标备用源。看板默认每组显示 8 个书签(`BOARD_PAGE`);展开状态仅在本次页面内保留,不写入私人书签数据。 diff --git a/web/css/bookmarks.css b/web/css/bookmarks.css index fb0d14d..b65824d 100644 --- a/web/css/bookmarks.css +++ b/web/css/bookmarks.css @@ -218,6 +218,8 @@ html[data-fx="off"] .card-glow { display: none; } from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: none; } } +/* Only a newly opened view rises in; searching, pinning or showing more keeps cards still. */ +:is(#main, #pinnedGrid)[data-enter="off"] .card { animation: none; } .grid .card:nth-child(2), .grid .bookmark-item:nth-child(2) > .card { animation-delay: 0.03s; } .grid .card:nth-child(3), .grid .bookmark-item:nth-child(3) > .card { animation-delay: 0.06s; } .grid .card:nth-child(4), .grid .bookmark-item:nth-child(4) > .card { animation-delay: 0.09s; } diff --git a/web/css/motion.css b/web/css/motion.css index e310f4e..2a371eb 100644 --- a/web/css/motion.css +++ b/web/css/motion.css @@ -223,6 +223,7 @@ html[data-fx="off"] .card { } html[data-fx="off"] .card { transition: none; + animation: none; } html[data-fx="off"] .ico, html[data-fx="off"] .motion-swatch .motion-anim, diff --git a/web/js/appearance-presets.js b/web/js/appearance-presets.js index 205f0c4..23296a1 100644 --- a/web/js/appearance-presets.js +++ b/web/js/appearance-presets.js @@ -18,19 +18,25 @@ function validateAppearanceSnapshot(value) { return { ...result, fxFollowsSystem: value.fxFollowsSystem, focus: value.focus, density: value.density }; } +// An entry this version cannot apply (e.g. saved by a version with another theme list) is hidden +// but written back unchanged, so one such entry no longer makes every other preset unreadable. function readAppearancePresets() { const raw = localStorage.getItem(APPEARANCE_PRESETS_KEY); - if (raw === null) return []; + if (raw === null) return { presets: [], incompatible: [] }; const value = JSON.parse(raw); if (!Array.isArray(value)) throw new Error("外观方案数据无效,无法读取。"); const names = new Set(); - return value.map(item => { + const presets = []; + const incompatible = []; + for (const item of value) { const settings = validateAppearanceSnapshot(item?.settings); const name = typeof item?.name === "string" ? item.name.trim() : ""; - if (!settings || !name || name.length > 40 || names.has(name)) throw new Error("外观方案数据无效,无法读取。"); - names.add(name); - return { name, settings }; - }); + if (settings && name && name.length <= 40 && !names.has(name)) { + names.add(name); + presets.push({ name, settings }); + } else incompatible.push(item); + } + return { presets, incompatible }; } function captureAppearanceSnapshot() { @@ -95,6 +101,7 @@ function initAppearancePresets() { const list = section.querySelector("ul"); const status = section.querySelector(".sync-status"); let presets = []; + let incompatible = []; let readable = true; const showStatus = (message, error = false) => { status.textContent = message; @@ -105,7 +112,7 @@ function initAppearancePresets() { (button || input).focus(); }; const save = next => { - try { localStorage.setItem(APPEARANCE_PRESETS_KEY, JSON.stringify(next)); } + try { localStorage.setItem(APPEARANCE_PRESETS_KEY, JSON.stringify([...next, ...incompatible])); } catch (e) { showStatus("无法保存到此浏览器,方案未更改。请检查浏览器存储权限或空间。", true); return false; } presets = next; draw(); @@ -164,9 +171,13 @@ function initAppearancePresets() { showStatus(""); readable = true; form.querySelector("button").disabled = false; - try { presets = readAppearancePresets(); } + try { + ({ presets, incompatible } = readAppearancePresets()); + if (incompatible.length) showStatus(`有 ${incompatible.length} 个方案无法在此版本使用,已隐藏并原样保留。`); + } catch (e) { presets = []; + incompatible = []; readable = false; form.querySelector("button").disabled = true; showStatus("无法读取已存方案,请检查浏览器存储;原数据未更改。", true); diff --git a/web/js/appearance.js b/web/js/appearance.js index e20e805..81602c4 100644 --- a/web/js/appearance.js +++ b/web/js/appearance.js @@ -92,6 +92,14 @@ function choicePreview(key, id) { } const appearanceChoices = {}; let appearanceDensity = null; +let shortcutIconTimer = 0; +// Previewing skins clicks through several in a row; Windows rewrites the shortcut once they settle. +function syncShortcutIcon(skin) { + clearTimeout(shortcutIconTimer); + shortcutIconTimer = setTimeout(() => { + fetch("/__icon?skin=" + encodeURIComponent(skin), { method: "POST" }).catch(() => {}); + }, 800); +} function setupChoice(list, key, containerId, resolve) { const root = document.documentElement; let stored = null; @@ -122,7 +130,7 @@ function setupChoice(list, key, containerId, resolve) { const fav = document.getElementById("fav"); if (fav) fav.href = "/__favicon?skin=" + encodeURIComponent(root.dataset.skin); document.getElementById("appearanceLabel").textContent = "外观 · " + item.name; - if (syncShortcut) fetch("/__icon?skin=" + encodeURIComponent(root.dataset.skin), { method: "POST" }).catch(() => {}); + if (syncShortcut) syncShortcutIcon(root.dataset.skin); } if (key === "fx" || key === "sky") window.dispatchEvent(new Event("bm-fx")); }; @@ -206,7 +214,7 @@ function initAppearance() { document.documentElement.dataset.skin = resolved; const fav = document.getElementById("fav"); if (fav) fav.href = "/__favicon?skin=" + encodeURIComponent(resolved); - fetch("/__icon?skin=" + encodeURIComponent(resolved), { method: "POST" }).catch(() => {}); + syncShortcutIcon(resolved); } }); diff --git a/web/js/bookmarks.js b/web/js/bookmarks.js index 004aedf..98a4b43 100644 --- a/web/js/bookmarks.js +++ b/web/js/bookmarks.js @@ -28,10 +28,20 @@ function inFolder(item) { if (!state.folder) return true; return item.path === state.folder || item.path.startsWith(state.folder + "/") || item.group === state.folder; } +let searchTermsFor = null; +let searchTerms = []; +// Split the query once per change, not once per bookmark. +function currentSearchTerms() { + if (searchTermsFor !== state.q) { + searchTermsFor = state.q; + const q = state.q.trim().toLowerCase(); + searchTerms = q ? q.split(/\s+/) : []; + } + return searchTerms; +} function hitSearch(item) { - const q = state.q.trim().toLowerCase(); - if (!q) return true; - return q.split(/\s+/).every(word => item.search.includes(word)); + const terms = currentSearchTerms(); + return terms.every(word => item.search.includes(word)); } function matches(item) { return (!activeFolder() || inFolder(item)) && hitSearch(item); } function sectionKey(item) { @@ -149,11 +159,15 @@ function folderNameWidth(items) { return Math.min(4, Math.max(1, ...items.map((item) => folderNameLength(item.name)))); } function updateFolderNameScroll() { + // Measure every label before changing any, so the browser lays the menu out once. + const measured = []; for (const label of document.querySelectorAll("#nav .folder > b")) { const name = label.querySelector(".folder-name"); if (!name) continue; const overflow = Math.ceil(name.scrollWidth - label.clientWidth); - const shouldScroll = folderNameLength(name.textContent.trim()) > 4 && overflow > 1; + measured.push([label, overflow, folderNameLength(name.textContent.trim()) > 4 && overflow > 1]); + } + for (const [label, overflow, shouldScroll] of measured) { label.classList.toggle("is-overflow", shouldScroll); if (shouldScroll) label.style.setProperty("--folder-overflow", overflow + "px"); } @@ -190,10 +204,17 @@ function applyFlips() { ); } } +// Cards rise in when a new view opens; searching, pinning or showing more keeps them still. +let renderedView = null; function render() { snapshotFlips(); + const view = document.documentElement.dataset.layout + "\n" + state.folder; + const entering = view !== renderedView; + renderedView = view; + for (const id of ["main", "pinnedGrid"]) document.getElementById(id).setAttribute("data-enter", entering ? "on" : "off"); const searched = ITEMS.filter(hitSearch); - const visible = searched.filter(matches); + const filtered = !!activeFolder(); + const visible = filtered ? searched.filter(inFolder) : searched; const groupCounts = new Map(); const pathCounts = new Map(); for (const item of searched) { @@ -314,10 +335,9 @@ function render() { updateCategoryArrangementDirection(); applyFlips(); } +const HTML_ESCAPES = { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }; function escapeHtml(s) { - return String(s).replace(/[&<>"']/g, (c) => ({ - "&": "&", "<": "<", ">": ">", '"': """, "'": "'" - }[c])); + return String(s).replace(/[&<>"']/g, (c) => HTML_ESCAPES[c]); } function pickFolder(e) { @@ -377,6 +397,7 @@ function pickFolder(e) { } } +let itemsByUrl = null; function renderLibraryChrome(count) { const title = document.getElementById("libraryTitle"); if (!title) return; @@ -390,11 +411,12 @@ function renderLibraryChrome(count) { local.textContent = state.folder ? `仅 ${state.folder.split("/").pop()}` : "当前分类"; local.setAttribute("aria-pressed", String(state.searchLocal)); const shelf = document.getElementById("pinnedShelf"); - const byUrl = new Map(ITEMS.map(item => [item.href, item])); - const pinned = pinnedUrls.map(url => byUrl.get(url)).filter(Boolean); + if (!itemsByUrl) itemsByUrl = new Map(ITEMS.map(item => [item.href, item])); + const pinned = pinnedUrls.map(url => itemsByUrl.get(url)).filter(Boolean); shelf.hidden = searching || pinned.length === 0; document.getElementById("pinnedCount").textContent = String(pinned.length); - document.getElementById("pinnedGrid").innerHTML = pinned.map(item => cardHtml(item, { shelf: true })).join(""); + // The shelf is hidden while searching, so typing does not rebuild its cards. + document.getElementById("pinnedGrid").innerHTML = shelf.hidden ? "" : pinned.map(item => cardHtml(item, { shelf: true })).join(""); } let libraryStatusTimer = 0; diff --git a/web/js/category-arrangement.js b/web/js/category-arrangement.js index f46df07..4a4d478 100644 --- a/web/js/category-arrangement.js +++ b/web/js/category-arrangement.js @@ -165,8 +165,10 @@ function handleCategoryArrangementClick(event) { return true; } -function clearCategoryDrop() { - for (const block of document.querySelectorAll("[data-category-block]")) block.classList.remove("category-drop-before", "category-drop-after", "category-is-dragging"); +// While dragging only the drop marker moves; the dragged block stays dimmed until the drag ends. +function clearCategoryDrop(dragEnded = true) { + const classes = dragEnded ? ["category-drop-before", "category-drop-after", "category-is-dragging"] : ["category-drop-before", "category-drop-after"]; + for (const block of document.querySelectorAll("[data-category-block]")) block.classList.remove(...classes); } function categoryDropTarget(event) { @@ -224,7 +226,7 @@ function initCategoryArrangement() { if (!target) return; event.preventDefault(); event.dataTransfer.dropEffect = "move"; - clearCategoryDrop(); + clearCategoryDrop(false); target.block.classList.add(target.after ? "category-drop-after" : "category-drop-before"); }); main.addEventListener("drop", event => { diff --git a/web/js/clock.js b/web/js/clock.js index c4a345b..b33b4dd 100644 --- a/web/js/clock.js +++ b/web/js/clock.js @@ -5,10 +5,13 @@ const LUNAR_DAYS = [ "十一", "十二", "十三", "十四", "十五", "十六", "十七", "十八", "十九", "二十", "廿一", "廿二", "廿三", "廿四", "廿五", "廿六", "廿七", "廿八", "廿九", "三十" ]; +// Built once: creating a Chinese-calendar formatter every second costs more than formatting. +const LUNAR_FORMAT = new Intl.DateTimeFormat("zh-CN-u-ca-chinese", { year: "numeric", month: "long", day: "numeric" }); +const SOLAR_FORMAT = new Intl.DateTimeFormat("zh-CN", { year: "numeric", weekday: "long", month: "long", day: "numeric" }); +let clockDateShown = ""; +let clockMinuteShown = ""; function lunarDateText(date) { - const parts = new Intl.DateTimeFormat("zh-CN-u-ca-chinese", { - year: "numeric", month: "long", day: "numeric" - }).formatToParts(date); + const parts = LUNAR_FORMAT.formatToParts(date); const fields = Object.fromEntries(parts.map(({ type, value }) => [type, value])); return fields.yearName + "年" + fields.month + LUNAR_DAYS[Number(fields.day) - 1]; } @@ -25,12 +28,20 @@ function tick() { const mm = String(now.getMinutes()).padStart(2, "0"); const ss = String(now.getSeconds()).padStart(2, "0"); setClock(hh + ":" + mm + ":" + ss); - const dateStr = now.toLocaleDateString("zh-CN", { year: "numeric", weekday: "long", month: "long", day: "numeric" }); - document.getElementById("bgTime").textContent = hh + ":" + mm; - const h = now.getHours(); - const greet = h < 5 ? "夜深了" : h < 11 ? "早上好" : h < 14 ? "中午好" : h < 18 ? "下午好" : h < 22 ? "晚上好" : "夜深了"; - document.getElementById("greet").textContent = dateStr.replace(/星期/, " 星期"); - document.getElementById("lunarDate").textContent = lunarDateText(now) + " · " + greet; + // The seconds change every tick; the dates and greeting only when the minute does. + if (clockMinuteShown !== hh + ":" + mm) { + clockMinuteShown = hh + ":" + mm; + document.getElementById("bgTime").textContent = clockMinuteShown; + const h = now.getHours(); + const greet = h < 5 ? "夜深了" : h < 11 ? "早上好" : h < 14 ? "中午好" : h < 18 ? "下午好" : h < 22 ? "晚上好" : "夜深了"; + const dates = SOLAR_FORMAT.format(now).replace(/星期/, " 星期") + "\n" + lunarDateText(now) + " · " + greet; + if (dates !== clockDateShown) { + clockDateShown = dates; + const [solar, lunar] = dates.split("\n"); + document.getElementById("greet").textContent = solar; + document.getElementById("lunarDate").textContent = lunar; + } + } if (weatherScene.dataset.period !== weatherPeriod()) syncParticles(); } diff --git a/web/js/effects.js b/web/js/effects.js index dc246cc..ecb7968 100644 --- a/web/js/effects.js +++ b/web/js/effects.js @@ -154,9 +154,27 @@ function fxInk() { if (sk === "celadon") return "70, 140, 115"; return LIGHT_SKINS.has(sk) ? "96, 76, 44" : "185, 216, 255"; } +// The page often stays open all day: draw at most 60 frames a second even on 120/144 Hz screens, +// and 30 for slow ambient skies or while the window is in the background. Motion uses real time. +const SKY_FRAME_MS = 1000 / 60; +const SKY_AMBIENT_FRAME_MS = 1000 / 30; +const AMBIENT_SKIES = new Set(["stars", "fireflies", "aurora", "nebula", "beams"]); +function skyFrameInterval() { + if (starTrail.length && document.hasFocus?.() !== false) return SKY_FRAME_MS; + return AMBIENT_SKIES.has(skyType) || document.hasFocus?.() === false ? SKY_AMBIENT_FRAME_MS : SKY_FRAME_MS; +} +let skyNextFrame = 0; function skyFrame(ts) { skyRaf = 0; if (!skyRunning) return; + // Frames are due on a fixed schedule; a little tolerance keeps a 60 Hz screen from skipping + // frames because of timer jitter. + if (skyLast && ts < skyNextFrame - 2) { + skyRaf = requestAnimationFrame(skyFrame); + return; + } + const interval = skyFrameInterval(); + skyNextFrame = skyLast && ts - skyNextFrame < interval ? skyNextFrame + interval : ts + interval; const dt = Math.min(ts - skyLast || 16.7, 50) / 16.7; skyLast = ts; fxCtx.clearRect(0, 0, innerWidth, innerHeight); @@ -372,7 +390,12 @@ function skyFrame(ts) { for (let i = starTrail.length - 1; i >= 0; i--) { const p = starTrail[i]; p.life -= p.decay * dt; - if (p.life <= 0) { starTrail.splice(i, 1); continue; } + if (p.life <= 0) { + // Order does not matter when drawing; moving the last particle here avoids shifting the array. + starTrail[i] = starTrail[starTrail.length - 1]; + starTrail.pop(); + continue; + } if (p.kind === "comet") { fxCtx.fillStyle = "hsla(" + p.hue.toFixed(0) + ", 90%, 76%, " + (p.life * 0.85).toFixed(3) + ")"; fxCtx.beginPath(); @@ -413,9 +436,12 @@ function skyFrame(ts) { fxCtx.save(); fxCtx.translate(p.x, p.y); fxCtx.rotate(p.rot); + // A translucent halo instead of shadowBlur, which re-blurs every particle on every frame. + fxCtx.fillStyle = "rgba(251, 191, 36, " + (p.life * 0.18).toFixed(3) + ")"; + fxCtx.beginPath(); + fxCtx.arc(0, 0, radius * 0.9, 0, 6.29); + fxCtx.fill(); fxCtx.fillStyle = "rgba(" + gold + ", " + (p.life * 0.9).toFixed(3) + ")"; - fxCtx.shadowColor = "rgba(251, 191, 36, 0.55)"; - fxCtx.shadowBlur = radius; fxCtx.beginPath(); for (let point = 0; point < 8; point++) { const angle = point * Math.PI / 4 - Math.PI / 2; @@ -500,6 +526,7 @@ function startSky() { if (skyRunning) return; skyRunning = true; skyLast = 0; + skyNextFrame = 0; skyRaf = requestAnimationFrame(skyFrame); } function syncParticles() { @@ -560,8 +587,13 @@ function setSkyCount(v, save) { } } +let fxResizeRaf = 0; function initEffects() { - window.addEventListener("resize", fxResize); + // Dragging a window edge fires resize continuously; reallocate the canvas once per frame. + window.addEventListener("resize", () => { + if (fxResizeRaf) return; + fxResizeRaf = requestAnimationFrame(() => { fxResizeRaf = 0; fxResize(); }); + }); fxResize(); syncParticles(); window.addEventListener("bm-fx", syncParticles); diff --git a/web/js/library.js b/web/js/library.js index 7e3c74a..1e8728b 100644 --- a/web/js/library.js +++ b/web/js/library.js @@ -20,6 +20,8 @@ function initLibrary() { document.addEventListener("keydown", (event) => { // Enter and Esc also pick or cancel IME candidates; they must not open or clear results. if (event.isComposing || event.keyCode === 229) return; + // Every key press arrives here; only these keys need the dialog check and the card list. + if (!["Escape", "Enter", "ArrowDown", "ArrowUp"].includes(event.key)) return; if (document.querySelector("dialog[open]") || !appearanceMenu.hidden) return; const input = event.target === search; const links = [...document.querySelectorAll('#main a.card[href]')]; diff --git a/web/js/utils.js b/web/js/utils.js index 83880c7..ebe4aeb 100644 --- a/web/js/utils.js +++ b/web/js/utils.js @@ -7,5 +7,9 @@ function writeJson(key, value) { function fetchJson(url, ms) { const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(), ms); - return fetch(url, { signal: ctrl.signal }).then((r) => r.json()).finally(() => clearTimeout(timer)); + return fetch(url, { signal: ctrl.signal }).then((r) => { + // An error page is not a result, even when its body happens to be JSON. + if (r.ok === false) throw new Error("HTTP " + r.status); + return r.json(); + }).finally(() => clearTimeout(timer)); } diff --git a/web/js/weather.js b/web/js/weather.js index b07bb9b..ccc7f58 100644 --- a/web/js/weather.js +++ b/web/js/weather.js @@ -87,6 +87,8 @@ const WEATHER_REFRESH_MS = 10 * 60 * 1000; const WEATHER_TICK_MS = 60 * 1000; const WEATHER_PRIMARY_TIMEOUT_MS = 4000; const WEATHER_FALLBACK_TIMEOUT_MS = 7000; +// 主源超过这个时间仍无结果时,再并行请求本地备用源。 +const WEATHER_HEDGE_MS = 1500; const WEATHER_RETRY_COUNT = 3; const WEATHER_RETRY_DELAY_MS = 1000; let weatherRequest = null; @@ -119,16 +121,54 @@ async function fetchWeatherResponse(url, timeout, signal) { } } -async function fetchWeather(city, signal) { +function fetchWeather(city, signal) { const fallback = localWeatherUrl(city); - const sources = [fetchWeatherResponse(fallback, WEATHER_FALLBACK_TIMEOUT_MS, signal)]; - if (Number.isFinite(city.lat) && Number.isFinite(city.lon)) { - const primary = "https://api.open-meteo.com/v1/forecast?latitude=" + city.lat + - "&longitude=" + city.lon + - "¤t=temperature_2m,weather_code&timezone=auto"; - sources.unshift(fetchWeatherResponse(primary, WEATHER_PRIMARY_TIMEOUT_MS, signal)); + if (!Number.isFinite(city.lat) || !Number.isFinite(city.lon)) { + return fetchWeatherResponse(fallback, WEATHER_FALLBACK_TIMEOUT_MS, signal); } - return Promise.any(sources); + const primary = "https://api.open-meteo.com/v1/forecast?latitude=" + city.lat + + "&longitude=" + city.lon + + "¤t=temperature_2m,weather_code&timezone=auto"; + // 主源通常更快:它失败或迟迟没有结果时才请求本地备用源;任一成功后立即取消其余请求。 + const race = new AbortController(); + const abortRace = () => race.abort(); + signal.addEventListener("abort", abortRace, { once: true }); + let timer = 0; + return new Promise((resolve, reject) => { + const errors = []; + let pending = 0; + let fallbackStarted = false; + let done = false; + const startFallback = () => { + if (fallbackStarted || done || race.signal.aborted) return; + fallbackStarted = true; + clearTimeout(timer); + run(fallback, WEATHER_FALLBACK_TIMEOUT_MS); + }; + const run = (url, timeout) => { + pending++; + fetchWeatherResponse(url, timeout, race.signal).then((data) => { + if (done) return; + done = true; + resolve(data); + race.abort(); + }, (error) => { + pending--; + if (done) return; + errors.push(error); + if (!fallbackStarted && !race.signal.aborted) startFallback(); + else if (!pending) { + done = true; + reject(new AggregateError(errors, "weather sources failed")); + } + }); + }; + run(primary, WEATHER_PRIMARY_TIMEOUT_MS); + if (!done) timer = setTimeout(startFallback, WEATHER_HEDGE_MS); + }).finally(() => { + clearTimeout(timer); + signal.removeEventListener("abort", abortRace); + }); } function waitForWeatherRetry(signal) { @@ -240,7 +280,7 @@ function initWeatherControls() { return; } writeJson("bm-city", city); - localStorage.removeItem("bm-weather"); + try { localStorage.removeItem("bm-weather"); } catch (e) {} setWeatherUI("天气加载中…", null); loadWeather(false, true); }); From 1165b034b00aef0c51952cf16302a8a9f33c3873 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 03:28:14 +0000 Subject: [PATCH 2/4] =?UTF-8?q?test:=20ZIP=20=E5=90=AF=E5=8A=A8=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=E5=A4=B1=E8=B4=A5=E6=97=B6=E8=BE=93=E5=87=BA=E6=9C=8D?= =?UTF-8?q?=E5=8A=A1=E7=AB=AF=E6=97=A5=E5=BF=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 服务进程未能在时限内响应时,原测试会丢弃它的输出,CI 上无法判断原因。 现在失败信息附带最后一次请求的错误和服务端的 stdout/stderr。 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GeH3G1X7FrWZhYQEPmR9Gr --- tests/test_archive_update.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/tests/test_archive_update.py b/tests/test_archive_update.py index cc2edd2..0f04386 100644 --- a/tests/test_archive_update.py +++ b/tests/test_archive_update.py @@ -368,21 +368,23 @@ def test_zip_server_starts_outside_project_without_git_on_path(self): stdout=subprocess.PIPE, stderr=subprocess.PIPE, ) service = None + last_error = None # A cold interpreter start can take several seconds on a busy machine. deadline = time.monotonic() + 15 try: - while time.monotonic() < deadline: - if process.poll() is not None: - self.fail(process.communicate()[1].decode("utf-8", errors="replace")) + while time.monotonic() < deadline and process.poll() is None: try: with urlopen(f"http://127.0.0.1:{port}/__service", timeout=0.2) as response: service = json.load(response) break - except OSError: + except OSError as error: + last_error = error time.sleep(0.1) - self.assertIsNotNone(service, "ZIP 服务应能加载本地升级模块并启动") - self.assertTrue(service["instance"]) finally: if process.poll() is None: process.terminate() - process.communicate(timeout=5) + stdout, stderr = process.communicate(timeout=5) + # Whether the service exited or never answered, its own output says why. + log = (stderr + stdout).decode("utf-8", errors="replace")[-4000:] + self.assertIsNotNone(service, f"ZIP 服务应能加载本地升级模块并启动;最后一次请求:{last_error!r}\n{log}") + self.assertTrue(service["instance"]) From 966d7bb49d20acef505b38e729e7cbbaa4c23c46 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 03:33:32 +0000 Subject: [PATCH 3/4] =?UTF-8?q?fix:=20=E6=9C=AC=E5=9C=B0=E6=9C=8D=E5=8A=A1?= =?UTF-8?q?=E5=90=AF=E5=8A=A8=E6=97=B6=E8=B7=B3=E8=BF=87=E5=8F=8D=E5=90=91?= =?UTF-8?q?=20DNS=20=E8=A7=A3=E6=9E=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit http.server 在监听端口之前会调用 socket.getfqdn 解析 127.0.0.1 的主机名。 macOS 上首次解析可能卡住十几秒,超过启动器等待页面的时间,CI 的 macOS 任务中 ZIP 启动测试也因此在 15 秒内始终连不上服务。这个名字项目里没有用到, 直接使用绑定地址。 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GeH3G1X7FrWZhYQEPmR9Gr --- scripts/manage.py | 8 ++++++++ tests/test_local_service.py | 18 ++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/scripts/manage.py b/scripts/manage.py index a8eda28..d553fa6 100644 --- a/scripts/manage.py +++ b/scripts/manage.py @@ -10,6 +10,7 @@ import os import plistlib import re +import socketserver import subprocess import sys import threading @@ -313,6 +314,13 @@ class BookmarkServer(ThreadingHTTPServer): # A page load opens several connections at once; the default backlog of 5 resets some of them. request_queue_size = 64 + def server_bind(self): + # HTTPServer looks up the host's full name here, before listening. That reverse DNS lookup + # can stall startup for many seconds (seen on macOS), longer than launchers wait for the + # page, and nothing here reads the name. + socketserver.TCPServer.server_bind(self) + self.server_name, self.server_port = self.server_address[:2] + def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.instance = uuid.uuid4().hex diff --git a/tests/test_local_service.py b/tests/test_local_service.py index 612e7dc..b237eda 100644 --- a/tests/test_local_service.py +++ b/tests/test_local_service.py @@ -295,3 +295,21 @@ def test_repeating_the_same_skin_does_not_rewrite_the_shortcut(self): status, _, _ = self.request("POST", f"/__icon?skin={skin}", page) self.assertEqual(status, 204) self.assertEqual(changed, ["cyber", "snow", "cyber"]) + + +class StartupTests(TestCase): + def test_starting_the_service_does_not_wait_for_reverse_dns(self): + # Reverse DNS for 127.0.0.1 can take many seconds; the service must listen without it. + with patch("socket.getfqdn", side_effect=AssertionError("reverse DNS lookup")): + server = manage.BookmarkServer(("127.0.0.1", 0), manage.Handler) + self.addCleanup(server.server_close) + self.assertEqual(server.server_name, "127.0.0.1") + self.assertEqual(server.server_port, server.server_address[1]) + worker = threading.Thread(target=server.serve_forever, daemon=True) + worker.start() + self.addCleanup(worker.join, 2) + self.addCleanup(server.shutdown) + connection = http.client.HTTPConnection("127.0.0.1", server.server_port, timeout=3) + self.addCleanup(connection.close) + connection.request("GET", "/__service", headers={"Host": f"127.0.0.1:{server.server_port}"}) + self.assertEqual(connection.getresponse().status, 200) From cb53f2108990a9432f1d8a398ca017578aca45f7 Mon Sep 17 00:00:00 2001 From: heropml <87559537+heropml@users.noreply.github.com> Date: Sat, 3 Oct 2026 12:00:01 +0800 Subject: [PATCH 4/4] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E5=AE=A1=E6=9F=A5=E4=B8=AD=E5=8F=91=E7=8E=B0=E7=9A=84=E5=8D=87?= =?UTF-8?q?=E7=BA=A7=E3=80=81=E5=AF=BC=E5=85=A5=E4=B8=8E=E5=A4=96=E8=A7=82?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ZIP 升级:清理旧备份失败不再把已应用的升级报告为未完成 - 书签导入:未闭合的 遇到
描述时结束,描述不再拼进标题 - 备份列表:数量按解析去重后计算,与恢复结果一致;缓存随备份删除而清理 - 快捷方式图标:按外观和快捷方式文件状态判断是否重写,外部重建后会重新设置 - 换外观后立即关闭或刷新页面时,用 keepalive 立即发送图标更新 - 同名重复的外观方案只保留第一份,删除后不再重新出现 Co-Authored-By: Claude Opus 5.5 --- scripts/archive_update.py | 5 ++- scripts/manage.py | 53 +++++++++++++++++++------------ scripts/shortcut.py | 22 ++++++++++--- tests/appearance-presets.test.cjs | 16 +++++++++- tests/effects-options.test.cjs | 2 +- tests/test_archive_update.py | 6 ++++ tests/test_local_service.py | 20 ++++++++++-- tests/test_manage.py | 22 +++++++++---- web/js/appearance-presets.js | 5 ++- web/js/appearance.js | 17 +++++++--- 10 files changed, 126 insertions(+), 42 deletions(-) diff --git a/scripts/archive_update.py b/scripts/archive_update.py index 5b6285b..0b347d4 100644 --- a/scripts/archive_update.py +++ b/scripts/archive_update.py @@ -332,8 +332,11 @@ def download(candidate): raise ArchiveUpdateError("下载的版本与已确认版本不一致") _report(progress, "applying", "备份现有程序,准备应用新版文件", source) backup = _apply(root, stage, changes, originals, progress, source) - prune_backups(root / "data/.update-backups") except OSError as error: raise ArchiveUpdateError("程序文件无法读写或磁盘空间不足,升级未完成") from error + try: + prune_backups(root / "data/.update-backups") + except OSError: + pass # the new files are already in place; old backups only cost disk space return {"ok": True, "updated": True, "mode": "archive", "previous": version, "current": status["remote"], "source": source, "backup": backup} diff --git a/scripts/manage.py b/scripts/manage.py index d553fa6..5cc6164 100644 --- a/scripts/manage.py +++ b/scripts/manage.py @@ -40,8 +40,6 @@ PORT = 8765 APP_VERSION = "v1.1.5" HEALTH_RESPONSE = b"bookmark-weather-v3\n" -# Counts links in a backup without parsing the whole tree; snapshots never change once written. -BACKUP_LINK_RE = re.compile(rb"]*\bhref\s*=", re.I) UPDATE_SOURCES = ( ("Gitee", "https://gitee.com/heropml/Bookmark.git"), ("GitHub", "https://github.com/heropml/Bookmark.git"), @@ -327,7 +325,9 @@ def __init__(self, *args, **kwargs): self.restarting = False self.restart_lock = threading.Lock() self.update_check = None # (checked_at, status or UpdateError) - self.icon_skin = None # shortcut icon applied by this service, so repeats cost nothing + # (skin, shortcut files' state) after this service last set the icon; a repeat costs nothing, + # but a shortcut recreated by an installer or launcher is rewritten again. + self.icon_applied = None def update_status(self, refresh: bool = False) -> dict[str, object]: """Reuse this service's recent update check; retry a failed one sooner.""" @@ -509,17 +509,18 @@ def __init__(self): self.link: dict | None = None # being read def handle_starttag(self, tag, attrs): - if tag in ("a", "h3", "dt", "dl"): + # Exports may leave unclosed; the next entry or a
description ends its title. + if tag in ("a", "h3", "dt", "dd", "dl"): self.finish_link() if tag == "h3": self.pending = None # Browsers mark their toolbar folder in exports; its localized name is not a category. self.folder = {"toolbar": any( - (name or "").lower() == "personal_toolbar_folder" and (value or "").lower() == "true" + name == "personal_toolbar_folder" and (value or "").lower() == "true" for name, value in attrs), "text": []} elif tag == "a": self.pending = None - href = next((value for name, value in attrs if (name or "").lower() == "href"), None) + href = next((value for name, value in attrs if name == "href"), None) self.link = {"href": href, "text": []} if href and href.strip() else None elif tag == "dl": self.stack.append(self.pending or "") @@ -823,21 +824,31 @@ def backup_bookmarks() -> None: old.unlink(missing_ok=True) -_BACKUP_COUNTS: dict[tuple[str, int], int] = {} - - -def backup_link_count(path: Path) -> int: - """Snapshots are written once, so a count stays valid for the file's size and mtime.""" - stat = path.stat() - key = (path.name, stat.st_mtime_ns) - if key not in _BACKUP_COUNTS: - _BACKUP_COUNTS[key] = len(BACKUP_LINK_RE.findall(path.read_bytes())) - return _BACKUP_COUNTS[key] +# Snapshots are written once, so each is parsed once; entries leave with the snapshots they count. +_BACKUP_COUNTS: dict[tuple[str, int, int], int] = {} +_BACKUP_COUNTS_LOCK = threading.Lock() def bookmark_backups() -> list[dict]: - return [{"id": path.stem, "created": path.stat().st_mtime, "count": backup_link_count(path)} - for path in backup_files()] + """List snapshots with the number of bookmarks restoring each one would show.""" + backups = [] + keys = set() + for path in backup_files(): + stat = path.stat() + key = (path.name, stat.st_mtime_ns, stat.st_size) + keys.add(key) + with _BACKUP_COUNTS_LOCK: + count = _BACKUP_COUNTS.get(key) + if count is None: + count = len(dedupe_items(parse_html(path.read_text(encoding="utf-8")))[0]) + with _BACKUP_COUNTS_LOCK: + _BACKUP_COUNTS[key] = count + backups.append({"id": path.stem, "created": stat.st_mtime, "count": count}) + with _BACKUP_COUNTS_LOCK: + for key in list(_BACKUP_COUNTS): + if key not in keys: + del _BACKUP_COUNTS[key] + return backups def write_atomic(path: Path, data: bytes) -> None: @@ -1373,11 +1384,11 @@ def do_POST(self): skin = (parse_qs(parsed.query).get("skin") or [""])[0] try: # Rewriting the shortcut starts cscript; the same icon again changes nothing. - if skin != self.server.icon_skin: - from shortcut import set_icon + from shortcut import set_icon, shortcut_state + if self.server.icon_applied != (skin, shortcut_state()): set_icon(skin) - self.server.icon_skin = skin + self.server.icon_applied = (skin, shortcut_state()) self.send_response(204) self.end_headers() except (Exception, SystemExit): diff --git a/scripts/shortcut.py b/scripts/shortcut.py index c8984ef..1c92bc4 100644 --- a/scripts/shortcut.py +++ b/scripts/shortcut.py @@ -28,12 +28,26 @@ def icon_path(skin: str = "aurora") -> Path: return path if path.is_file() else ICON_DIR / "bookmark.ico" +def shortcut_files() -> list[Path]: + desk = Path.home() / "Desktop" / LNK.name + return [LNK, desk] if desk.parent.is_dir() else [LNK] + + +def shortcut_state() -> tuple: + """Changes whenever a shortcut is written, recreated or removed, by this app or anything else.""" + state = [] + for path in shortcut_files(): + try: + stat = path.stat() + state.append((str(path), stat.st_mtime_ns, stat.st_size)) + except OSError: + state.append((str(path), None, None)) + return tuple(state) + + def write_lnk(icon: Path) -> None: target, arguments = launch_command() - desk = Path.home() / "Desktop" / LNK.name - targets = [LNK] - if desk.parent.is_dir(): - targets.append(desk) + targets = shortcut_files() lines = ['Set w = CreateObject("WScript.Shell")'] for dest in targets: lines += [ diff --git a/tests/appearance-presets.test.cjs b/tests/appearance-presets.test.cjs index cb35e37..582c6b1 100644 --- a/tests/appearance-presets.test.cjs +++ b/tests/appearance-presets.test.cjs @@ -73,7 +73,7 @@ function fixture(stored = {}) { localStorage, fetch: () => Promise.resolve(), setTimeout: callback => { callback(); return 1; }, clearTimeout() {}, - window: { dispatchEvent: event => events.push(event.type) }, + window: { addEventListener() {}, dispatchEvent: event => events.push(event.type) }, document: { documentElement: root, body, getElementById: element, createElement: tag => new Element(tag), addEventListener() {}, querySelector: selector => { @@ -285,3 +285,17 @@ test('与当前版本不兼容的方案被隐藏但原样保留,其余方案 assert.deepEqual(stored.map(item => item.name), ['日常', '新方案', '旧版方案']); assert.deepEqual(stored[2], foreign); }); + +test('同名的重复方案只保留第一份,删除后不会再出现', () => { + const valid = fixture().snapshot(); + const app = fixture({ 'bm-appearance-presets': JSON.stringify([ + { name: '日常', settings: valid }, { name: '日常', settings: { ...valid, density: 80 } } + ]) }); + app.open(); + assert.equal(app.list.children.length, 1); + assert.equal(app.status.textContent, ''); + app.action(0, 2); + assert.deepEqual(JSON.parse(app.storage.get('bm-appearance-presets')), []); + app.open(); + assert.equal(app.list.children[0].className, 'appearance-presets-empty'); +}); diff --git a/tests/effects-options.test.cjs b/tests/effects-options.test.cjs index 92117c5..77c7232 100644 --- a/tests/effects-options.test.cjs +++ b/tests/effects-options.test.cjs @@ -126,7 +126,7 @@ function fxChoice({ stored, fx = 'on' } = {}) { const root = { dataset: { fx } }; const context = vm.createContext({ Event: class { constructor(type) { this.type = type; } }, - window: { dispatchEvent() {} }, + window: { addEventListener() {}, dispatchEvent() {} }, document: { documentElement: root, querySelector: () => null, getElementById: id => ({ innerHTML: '', querySelectorAll: () => [], addEventListener: (type, callback) => handlers.set(id + ':' + type, callback) }) diff --git a/tests/test_archive_update.py b/tests/test_archive_update.py index 0f04386..c318223 100644 --- a/tests/test_archive_update.py +++ b/tests/test_archive_update.py @@ -228,6 +228,12 @@ def test_successful_upgrade_keeps_only_recent_program_backups(self): self.assertNotIn("backup-old0", kept) self.assertTrue((backups / "notes.txt").is_file()) + def test_failing_to_prune_old_backups_does_not_fail_an_applied_upgrade(self): + with patch.object(updater, "read_url", side_effect=self.remote), \ + patch.object(updater, "prune_backups", side_effect=PermissionError("locked")): + result = self.install() + self.assertTrue(result["updated"]) + def test_edit_during_download_is_preserved_and_upgrade_is_cancelled(self): def editing(source, url, *args): if url.endswith("web/js/new.js"): diff --git a/tests/test_local_service.py b/tests/test_local_service.py index b237eda..751f135 100644 --- a/tests/test_local_service.py +++ b/tests/test_local_service.py @@ -286,16 +286,32 @@ def test_failures_are_not_cached(self): class ShortcutIconTests(ServiceTestCase): + def icons(self, changed, state): + def set_icon(skin): + changed.append(skin) + state[0] += 1 + return SimpleNamespace(set_icon=set_icon, shortcut_state=lambda: state[0]) + def test_repeating_the_same_skin_does_not_rewrite_the_shortcut(self): changed = [] - icons = SimpleNamespace(set_icon=changed.append) page = {"Origin": self.page} - with patch.object(manage.sys, "platform", "win32"), patch.dict("sys.modules", {"shortcut": icons}): + with patch.object(manage.sys, "platform", "win32"), \ + patch.dict("sys.modules", {"shortcut": self.icons(changed, [0])}): for skin in ("cyber", "cyber", "snow", "snow", "cyber"): status, _, _ = self.request("POST", f"/__icon?skin={skin}", page) self.assertEqual(status, 204) self.assertEqual(changed, ["cyber", "snow", "cyber"]) + def test_shortcut_recreated_elsewhere_gets_the_icon_again(self): + changed, state = [], [0] + page = {"Origin": self.page} + with patch.object(manage.sys, "platform", "win32"), \ + patch.dict("sys.modules", {"shortcut": self.icons(changed, state)}): + self.request("POST", "/__icon?skin=cyber", page) + state[0] += 1 # e.g. the installer wrote a fresh shortcut with the default icon + self.request("POST", "/__icon?skin=cyber", page) + self.assertEqual(changed, ["cyber", "cyber"]) + class StartupTests(TestCase): def test_starting_the_service_does_not_wait_for_reverse_dns(self): diff --git a/tests/test_manage.py b/tests/test_manage.py index d05599f..b2ba558 100644 --- a/tests/test_manage.py +++ b/tests/test_manage.py @@ -140,6 +140,10 @@ def test_unclosed_links_empty_folders_and_links_without_address(self): '

F

F1
无地址
空白
') self.assertEqual(self.paths(text), [("E", "其他"), ("F1", "F")]) + def test_description_after_an_unclosed_link_is_not_part_of_its_title(self): + text = '

\n

A\n
说明\n
B\n
' + self.assertEqual(self.paths(text), [("A", "其他"), ("B", "其他")]) + def test_duplicates_are_skipped_in_page_data_without_rewriting_the_source(self): items = manage.parse_html('
A

X

' '
dup
B
') @@ -217,16 +221,20 @@ def test_html_without_bookmarks_never_replaces_the_current_page(self): self.assertEqual(self.data.read_bytes(), page) self.assertFalse((self.root / ".bookmark-backups").exists()) - def test_backup_counts_are_read_cheaply_and_match_the_parser(self): + def test_backup_counts_match_restore_and_are_read_once(self): manage.build() - self.source.write_text(manage.render_bookmarks_html([ - {"title": "C", "href": "https://c.example/", "path": "其他", "group": "其他", "host": "c.example"}, - ]), encoding="utf-8") + self.source.write_text( + '
C
dup' + '
空
\n', encoding="utf-8") manage.replace_bookmark_source(self.source.read_text(encoding="utf-8") + "\n") backup = manage.backup_files()[0] - self.assertEqual(manage.bookmark_backups()[0]["count"], len(manage.parse_html(backup.read_text(encoding="utf-8")))) - with patch.object(Path, "read_bytes", side_effect=AssertionError("cached")): - self.assertEqual(manage.bookmark_backups()[0]["count"], 1) + self.assertEqual(manage.bookmark_backups()[0]["count"], len(manage.restore_bookmarks(backup.stem))) + counted = manage.bookmark_backups() # restoring took a snapshot of its own + with patch.object(Path, "read_text", side_effect=AssertionError("cached")): + self.assertEqual(manage.bookmark_backups(), counted) + backup.unlink() + manage.bookmark_backups() + self.assertFalse(any(key[0] == backup.name for key in manage._BACKUP_COUNTS)) class PickerTests(TestCase): diff --git a/web/js/appearance-presets.js b/web/js/appearance-presets.js index 23296a1..33e2bde 100644 --- a/web/js/appearance-presets.js +++ b/web/js/appearance-presets.js @@ -31,7 +31,10 @@ function readAppearancePresets() { for (const item of value) { const settings = validateAppearanceSnapshot(item?.settings); const name = typeof item?.name === "string" ? item.name.trim() : ""; - if (settings && name && name.length <= 40 && !names.has(name)) { + // A later copy of a name is redundant, not from another version; keeping it hidden would make + // it reappear once the visible one is deleted. + if (names.has(name)) continue; + if (settings && name && name.length <= 40) { names.add(name); presets.push({ name, settings }); } else incompatible.push(item); diff --git a/web/js/appearance.js b/web/js/appearance.js index 81602c4..70844e9 100644 --- a/web/js/appearance.js +++ b/web/js/appearance.js @@ -93,12 +93,20 @@ function choicePreview(key, id) { const appearanceChoices = {}; let appearanceDensity = null; let shortcutIconTimer = 0; -// Previewing skins clicks through several in a row; Windows rewrites the shortcut once they settle. +let shortcutIconPending = null; +function sendShortcutIcon() { + clearTimeout(shortcutIconTimer); + const skin = shortcutIconPending; + shortcutIconPending = null; + // keepalive lets the request finish when it is sent while the page closes. + if (skin !== null) fetch("/__icon?skin=" + encodeURIComponent(skin), { method: "POST", keepalive: true }).catch(() => {}); +} +// Previewing skins clicks through several in a row; Windows rewrites the shortcut once they settle, +// or right away when the page is closed or reloaded first. function syncShortcutIcon(skin) { clearTimeout(shortcutIconTimer); - shortcutIconTimer = setTimeout(() => { - fetch("/__icon?skin=" + encodeURIComponent(skin), { method: "POST" }).catch(() => {}); - }, 800); + shortcutIconPending = skin; + shortcutIconTimer = setTimeout(sendShortcutIcon, 800); } function setupChoice(list, key, containerId, resolve) { const root = document.documentElement; @@ -207,6 +215,7 @@ function initAppearance() { setupChoice(TRAILS, "trail", "trailChoices"); setupChoice(SKYS, "sky", "skyChoices"); setupChoice(FX, "fx", "fxChoices"); + window.addEventListener("pagehide", sendShortcutIcon); const colorSchemeMq = matchMedia("(prefers-color-scheme: light)"); colorSchemeMq.addEventListener("change", () => { if (appearanceChoices.skin.value() === "auto") {